Compare commits

..

2 Commits

Author SHA1 Message Date
fangshuyu
bd03fbaa00 Refine wiki node-get flag wording 2026-07-02 17:41:35 +08:00
fangshuyu
b48290c5db Improve wiki node-get flag guidance 2026-07-02 17:32:44 +08:00
618 changed files with 81030 additions and 35647 deletions

View File

@@ -86,10 +86,8 @@ 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 source-contract lint guards (lintcheck)
- name: Run errs/ 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
@@ -265,19 +263,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
- name: Run dry-run E2E tests
env:
@@ -285,28 +277,7 @@ jobs:
LARKSUITE_CLI_APP_ID: dry-run
LARKSUITE_CLI_APP_SECRET: dry-run
LARKSUITE_CLI_BRAND: feishu
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
run: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No dry-run CLI E2E needed: $E2E_REASON"
exit 0
fi
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
exit 1
fi
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
fi
if [ -n "$E2E_DRY_PACKAGES" ]; then
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
fi
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live:
needs: [unit-test, lint, script-test, deterministic-gate]
@@ -321,22 +292,15 @@ jobs:
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
- name: Configure bot credentials
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
@@ -346,24 +310,16 @@ jobs:
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No live CLI E2E needed: $E2E_REASON"
exit 0
fi
packages="$E2E_LIVE_PACKAGES"
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
if [ -z "$packages" ]; then
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
echo "No CLI E2E packages to test after exclusions."
exit 1
fi
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
echo "Live CLI E2E packages: $packages"
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
if: ${{ !cancelled() }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests

3
.gitignore vendored
View File

@@ -27,9 +27,6 @@ Thumbs.db
# Go
docs/ref
docs/
!tests/cli_e2e/docs/
!tests/cli_e2e/docs/*.go
!tests/cli_e2e/docs/*.md
vendor/

View File

@@ -105,20 +105,6 @@ 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.
@@ -130,7 +116,6 @@ 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.

View File

@@ -2,142 +2,6 @@
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
- support semantic recurring calendar operations (#1723)
- minute wait (#1768)
### Bug Fixes
- guide drive import concurrency conflicts (#1751)
- **calendar**: guide approval room booking fallback (#1637)
- support pnpm global installs in self-update (#1705)
- resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
### Documentation
- tighten doc creation validation workflow (#1759)
- clarify success envelope contract — judge success by ok, not code (#1730)
### Refactoring
- **envvars**: consolidate agent env value access (#1757)
### Misc
- Improve agent-facing error guidance for drive, markdown, and wiki (#1779)
## [v1.0.65] - 2026-07-03
### Features
- **doc**: Add `+history-list`, `+history-revert`, and `+history-revert-status` shortcuts for document version history (#1612)
### Bug Fixes
- **minutes**: `+speaker-replace` no longer refetches the speaker list — `--from-speaker-id` is passed through as-is (#1731)
### Documentation
- **drive**: Document 30-char query limit for `+search` (#1560)
- **doc**: Add mindnote guidance to lark-doc skill (#1581)
- **doc**: Sync lark-doc skill content from online-doc (#1701)
## [v1.0.64] - 2026-07-02
### Features
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
### Bug Fixes
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
- **cli**: Improve secure label error handling (#1707)
- **cli**: Reduce public content token false positives
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
- **doc**: Align word statistics compound tokens (#1706)
### Documentation
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
- **doc**: Support `reference_map` in docs (#1690)
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
## [v1.0.62] - 2026-07-01
### Features
@@ -1469,12 +1333,6 @@ 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
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60

View File

@@ -51,7 +51,7 @@ script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta

View File

@@ -233,24 +233,6 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
--format csv # Comma-separated values
```
### JSON Output Contract
With `--format json` (the default), success and error envelopes are distinct.
Success goes to **stdout**, exit code `0`:
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
Errors go to **stderr**, non-zero exit code:
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
### Pagination
```bash

View File

@@ -234,24 +234,6 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
--format csv # 逗号分隔值
```
### JSON 输出契约
`--format json`(默认)下,成功与错误的信封结构不同。
成功信封写入 **stdout**,退出码 0
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
错误信封写入 **stderr**,退出码非 0
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
判断命令是否成功,请检查 `ok == true`(或进程退出码),**不要用 `code == 0`**。与原始 OpenAPI 响应(`{"code": 0, "msg": "ok", ...}`)不同,成功信封没有 `code``msg` 字段;`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。完整错误分类见 [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md)。
### 分页
```bash

View File

@@ -10,33 +10,18 @@ step. Maintain these files alongside `skills/` and `shortcuts/`.
A small, fixed markdown subset; each file describes one domain:
# <domain> optional `> skill: <name>` applies to every command below
## <command> the command as typed, minus `lark-cli <domain>`; a
+-prefixed heading (## +create) targets that shortcut
## <command> the command as typed, minus `lark-cli <domain>`
<lead paragraph> when to use this command
### Avoid when when not to use it / which command to use instead
### Prerequisites what you must have first (e.g. an id, and where it comes from)
### Tips gotchas and constraints
### Examples **description** lines, each followed by a fenced command
### Skills bullet skill names, or name/relpath references
(lark-contact/references/x.md), to read for usage;
merged with the domain `> skill:` default (deduped,
domain first)
### <other heading> a custom section; flows through verbatim
Reference another command with `[[command]]` — it renders as `command` in help.
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
("… from [[command]]") it means "get the input there first".
Both service-API commands (`## messages get`) and `+`-prefixed shortcuts
(`## +create`) take entries. A `### Skills` entry is a skill name (validated
against `<name>/SKILL.md`) or a `name/relpath` reference into that skill
(validated against the path); help drops any that don't resolve, so a typo shows
nothing. Point a command at its own reference (e.g. `+search-user`
`lark-contact/references/lark-contact-search-user.md`) rather than re-listing the
domain skill, which the `> skill:` default already covers. When a shortcut also
sets a hand-authored `Tips` list in Go, the overlay's `### Tips` win — they
replace the Go tips (not merged), so keep tips in one place.
## Example
## messages get
@@ -62,5 +47,3 @@ replace the Go tips (not merged), so keep tips in one place.
anything the schema and flags already show; the agent infers the rest.
- Command-form headings resolve to method ids via the registry, so plural resource
names (`messages`) map to the singular method id (`message`) automatically.
`+`-prefixed shortcut headings are matched verbatim (no plural/space folding),
so the heading must equal the shortcut command exactly (`## +history-revert`).

View File

@@ -1,42 +1,6 @@
# contact
> skill: lark-contact
## +search-user
The primary user lookup for user identity: search by keyword or email, resolve known ids with --user-ids, or get yourself with --user-ids me — it does by-id reads too, so as a user you rarely need `+get-user`. Each match returns an open_id and p2p_chat_id to chain into follow-ups.
### Skills
- lark-contact/references/lark-contact-search-user.md
### Avoid when
- Running as a bot — this shortcut is user-only; use [[+get-user]] instead (it supports bot identity)
- You only need users' personal status for ids you already hold → use [[user_profiles batch_query]]
### Examples
**Find a user by name**
```bash
lark-cli contact +search-user --query "alice" --as user
```
**Fetch known users by open_id (me = yourself)**
```bash
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
```
## +get-user
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
### Skills
- lark-contact/references/lark-contact-get-user.md
### Avoid when
- You don't have the user's id yet, or want to match by name/keyword → use [[+search-user]]
- Running as a user — [[+search-user]] --user-ids covers by-id reads and more in one tool
### Tips
- Self lookup (omit --user-id) needs user identity; a bot must pass --user-id
- --user-id-type must match the id you pass (default open_id)
## user_profiles batch_query
Bulk-fetch personal status and signature for user ids you already have.

View File

@@ -40,7 +40,6 @@ type APIOptions struct {
PageLimit int
PageDelay int
Format string
JSON bool
JqExpr string
DryRun bool
File string
@@ -89,11 +88,6 @@ Examples:
opts.Cmd = cmd
opts.Ctx = cmd.Context()
opts.As = core.Identity(asStr)
format, err := output.StandardFormats.Resolve(opts.Format, cmd.Flags().Changed("format"), opts.JSON)
if err != nil {
return err
}
opts.Format = format
if runF != nil {
return runF(opts)
}
@@ -109,8 +103,8 @@ Examples:
cmd.Flags().IntVar(&opts.PageSize, "page-size", 0, "page size (0 = use API default)")
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", output.StandardFormats.Usage())
cmd.Flags().BoolVar(&opts.JSON, "json", false, "shorthand for --format json")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
@@ -122,7 +116,7 @@ Examples:
return nil, cobra.ShellCompDirectiveNoFileComp
}
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return output.StandardFormats.Names(), cobra.ShellCompDirectiveNoFileComp
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetRisk(cmd, "write")
@@ -136,13 +130,6 @@ 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
@@ -256,9 +243,9 @@ func apiRun(opts *APIOptions) error {
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts), *fileMeta)
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
}
return apiDryRun(f, request, config, opts)
return apiDryRun(f, request, config, opts.Format)
}
// Identity info is now included in the JSON envelope; skip stderr printing.
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
@@ -269,7 +256,10 @@ func apiRun(opts *APIOptions) error {
}
out := f.IOStreams.Out
format, _ := output.ParseFormat(opts.Format)
format, formatOK := output.ParseFormat(opts.Format)
if !formatOK {
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
if opts.PageAll {
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
@@ -307,19 +297,8 @@ func apiRun(opts *APIOptions) error {
return nil
}
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 apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
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 {
@@ -346,24 +325,6 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
}
switch format {
case output.FormatPretty:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatPretty)
return errs.MarkRaw(apiErr)
}
scanResult := output.ScanForSafety(commandPath, result, errOut)
if scanResult.Blocked {
return errs.MarkRaw(scanResult.BlockErr)
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
output.FormatValue(out, result, output.FormatPretty)
return nil
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {

View File

@@ -4,14 +4,10 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"mime"
"mime/multipart"
"os"
"path/filepath"
"sort"
"strings"
"testing"
@@ -24,28 +20,13 @@ import (
"github.com/spf13/cobra"
)
func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
cmd := NewCmdApi(f, runF)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
return cmd
}
func newTestRootCmd() *cobra.Command {
return &cobra.Command{
Use: "lark-cli",
SilenceErrors: true,
SilenceUsage: true,
}
}
func TestApiCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -68,89 +49,23 @@ func TestApiCmd_FlagParsing(t *testing.T) {
}
}
func TestApiCmd_OutputFormatResolution(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "json shorthand", args: []string{"--json"}, want: "json"},
{name: "explicit format wins", args: []string{"--format", "table", "--json"}, want: "table"},
{name: "pretty format", args: []string{"--format", "PRETTY"}, want: "pretty"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
args := []string{"GET", "/open-apis/test", "--as", "bot"}
cmd.SetArgs(append(args, tt.args...))
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected options to be captured")
}
if gotOpts.Format != tt.want {
t.Fatalf("format = %q, want %q", gotOpts.Format, tt.want)
}
})
}
}
func TestApiCmd_DryRun(t *testing.T) {
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
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())
output := stdout.String()
if !strings.Contains(output, "Dry Run") {
t.Error("expected 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)
if !strings.Contains(output, "/open-apis/test") {
t.Error("expected path in dry run output")
}
}
@@ -162,7 +77,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--params null with --page-size should not error, got: %v", err)
@@ -183,7 +98,7 @@ func TestApiCmd_BotMode(t *testing.T) {
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
err := cmd.Execute()
if err != nil {
@@ -205,49 +120,12 @@ func TestApiCmd_BotMode(t *testing.T) {
}
}
func TestApiCmd_PrettyFormatsRealResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pretty", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"name": "Alice"}},
},
},
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("pretty output should be valid JSON: %v\n%s", err, out)
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("pretty output data = %#v", got["data"])
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("pretty output data.items = %#v", data["items"])
}
if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") {
t.Fatalf("pretty output should be indented JSON rather than a table, got:\n%s", out)
}
}
func TestApiCmd_MissingArgs(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET"}) // missing path
err := cmd.Execute()
if err == nil {
@@ -255,28 +133,12 @@ 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,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
err := cmd.Execute()
if err == nil {
@@ -289,7 +151,7 @@ func TestApiValidArgsFunction(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
fn := cmd.ValidArgsFunction
tests := []struct {
@@ -355,7 +217,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
@@ -374,7 +236,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -393,7 +255,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
err := cmd.Execute()
if err == nil {
@@ -410,7 +272,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return apiRun(opts)
})
@@ -435,7 +297,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
ContentType: "application/octet-stream",
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
err := cmd.Execute()
if err != nil {
@@ -466,7 +328,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err != nil {
@@ -506,7 +368,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
err := cmd.Execute()
// Should return an error
@@ -547,7 +409,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err != nil {
@@ -586,7 +448,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err == nil {
@@ -621,7 +483,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -644,46 +506,6 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
}
}
func TestApiCmd_PageAll_PrettyAggregatesIndentedJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-pretty", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("page-all pretty output should be valid JSON: %v\n%s", err, out)
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("page-all pretty output data = %#v", got["data"])
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("page-all pretty output data.items = %#v", data["items"])
}
if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") {
t.Fatalf("page-all pretty output should be aggregated indented JSON, got:\n%s", out)
}
}
type apiContentSafetyProvider struct {
called bool
path string
@@ -727,8 +549,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
},
})
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -778,8 +600,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
},
})
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -834,8 +656,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
},
})
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := root.Execute()
if err == nil {
@@ -899,7 +721,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -919,7 +741,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -938,7 +760,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
@@ -969,7 +791,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
err := cmd.Execute()
if err != nil {
@@ -990,7 +812,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
@@ -1008,7 +830,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
@@ -1037,7 +859,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
err := cmd.Execute()
if err != nil {
@@ -1058,7 +880,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -1077,7 +899,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -1095,7 +917,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
@@ -1112,7 +934,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
@@ -1129,7 +951,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
@@ -1152,30 +974,18 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
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, "image") {
t.Errorf("expected dry-run output to mention file field, 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)
if !strings.Contains(out, "Dry Run") {
t.Errorf("expected dry-run header, got: %s", out)
}
}
@@ -1205,7 +1015,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
err := cmd.Execute()
if err == nil {
@@ -1231,7 +1041,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -1244,157 +1054,3 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
t.Errorf("expected method GET, got %s", gotOpts.Method)
}
}
// parseMultipartFilenames drives one api --file upload through the mock
// transport and returns a map of field name -> part filename parsed from the
// captured multipart body, plus the map of text form fields. It fails the test
// if the captured request is not multipart/form-data.
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) (map[string]string, map[string]string) {
t.Helper()
ct := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil {
t.Fatalf("parse Content-Type %q: %v", ct, err)
}
if !strings.HasPrefix(mediaType, "multipart/") {
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
}
filenames := map[string]string{}
fields := map[string]string{}
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
for {
part, err := mr.NextPart()
if err != nil {
break
}
if fn := part.FileName(); fn != "" {
filenames[part.FormName()] = fn
} else {
buf := &bytes.Buffer{}
_, _ = buf.ReadFrom(part)
fields[part.FormName()] = buf.String()
}
}
return filenames, fields
}
func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "invoice.pdf"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "invoice.pdf" {
t.Fatalf("part filename for field %q = %q, want %q", "file", got, "invoice.pdf")
}
}
func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "sub", "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "upload=sub/invoice.pdf"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if _, ok := filenames["upload"]; !ok {
t.Fatalf("expected field name %q from field=path form, got fields %v", "upload", filenames)
}
if got := filenames["upload"]; got != "invoice.pdf" {
t.Fatalf("part filename for field %q = %q, want %q (basename only)", "upload", got, "invoice.pdf")
}
}
func TestApiCmd_FileUpload_WithDataFields(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot",
"--file", "invoice.pdf", "--data", `{"type":"attachment"}`})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, fields := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "invoice.pdf" {
t.Fatalf("part filename = %q, want %q", got, "invoice.pdf")
}
if got := fields["type"]; got != "attachment" {
t.Fatalf("text field type = %q, want %q", got, "attachment")
}
}
func TestApiCmd_FileUpload_StdinFallsBackToUnknown(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.IOStreams.In = bytes.NewReader([]byte("stdin-bytes"))
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "-"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "unknown-file" {
t.Fatalf("stdin part filename = %q, want %q (no stable local name, fallback)", got, "unknown-file")
}
}

View File

@@ -355,31 +355,7 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthScopesCmd_JSONShorthand(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *ScopesOptions
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected options to be captured")
}
if !gotOpts.JSON || gotOpts.Format != "json" {
t.Fatalf("JSON = %v, format = %q; want true, json", gotOpts.JSON, gotOpts.Format)
}
}
func TestAuthScopesCmd_ExplicitFormatWinsOverJSONShorthand(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
@@ -400,8 +376,8 @@ func TestAuthScopesCmd_ExplicitFormatWinsOverJSONShorthand(t *testing.T) {
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
if gotOpts.Format != "pretty" {
t.Errorf("expected explicit format pretty, got %s", gotOpts.Format)
if gotOpts.Format != "json" {
t.Errorf("expected format json, got %s", gotOpts.Format)
}
}

View File

@@ -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{"application", "base", "contact", "docs", "markdown", "apps", "note"}
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
}

View File

@@ -31,11 +31,9 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
Short: "Query scopes enabled for the app",
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
format, err := output.JSONPrettyFormats.Resolve(opts.Format, cmd.Flags().Changed("format"), opts.JSON)
if err != nil {
return err
if opts.JSON {
opts.Format = "json"
}
opts.Format = format
if runF != nil {
return runF(opts)
}
@@ -43,11 +41,8 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
},
}
cmd.Flags().StringVar(&opts.Format, "format", "json", output.JSONPrettyFormats.Usage())
cmd.Flags().BoolVar(&opts.JSON, "json", false, "shorthand for --format json")
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return output.JSONPrettyFormats.Names(), cobra.ShellCompDirectiveNoFileComp
})
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd
@@ -80,10 +75,10 @@ func authScopesRun(opts *ScopesOptions) error {
"failed to get app scope info: %v", err).WithCause(err)
}
if opts.Format == "pretty" {
fmt.Fprintf(f.IOStreams.Out, "App ID: %s\n", config.AppID)
fmt.Fprintf(f.IOStreams.Out, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes))
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)
fmt.Fprintf(f.IOStreams.ErrOut, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes))
for _, s := range appInfo.UserScopes {
fmt.Fprintf(f.IOStreams.Out, " • %s\n", s)
fmt.Fprintf(f.IOStreams.ErrOut, " • %s\n", s)
}
} else {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{

View File

@@ -4,11 +4,9 @@
package auth
import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
@@ -28,35 +26,6 @@ func stubGetAppInfoErr(t *testing.T, errToReturn error) {
t.Cleanup(func() { getAppInfoFn = prev })
}
func TestAuthScopesRun_PrettyWritesBulletedScopesToStdout(t *testing.T) {
prev := getAppInfoFn
getAppInfoFn = func(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
return &appInfo{UserScopes: []string{"im:message"}}, nil
}
t.Cleanup(func() { getAppInfoFn = prev })
opts := scopesTestFactory(t)
opts.Format = "pretty"
out, ok := opts.Factory.IOStreams.Out.(*bytes.Buffer)
if !ok {
t.Fatalf("stdout type = %T, want *bytes.Buffer", opts.Factory.IOStreams.Out)
}
errOut, ok := opts.Factory.IOStreams.ErrOut.(*bytes.Buffer)
if !ok {
t.Fatalf("stderr type = %T, want *bytes.Buffer", opts.Factory.IOStreams.ErrOut)
}
if err := authScopesRun(opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(out.String(), " • im:message\n") {
t.Fatalf("stdout missing bulleted scope: %q", out.String())
}
if strings.Contains(errOut.String(), "im:message") {
t.Fatalf("scope should remain on stdout, stderr = %q", errOut.String())
}
}
// scopesTestFactory builds a Factory + ScopesOptions pair sufficient to drive
// authScopesRun. Config has a non-empty AppID so we get past the config gate
// and reach the getAppInfoFn call.

View File

@@ -25,10 +25,8 @@ 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"
)
@@ -44,18 +42,6 @@ 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.
@@ -168,12 +154,6 @@ 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
@@ -234,7 +214,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
groupRootCommands(rootCmd)
installUnknownSubcommandGuard(rootCmd)
installCobraValidationGuards(rootCmd)
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
// before printing help; non-bare invocations and non-TTY are unaffected.
installRootUpgradePrompt(f, rootCmd)

View File

@@ -4,15 +4,11 @@
package cmd
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -30,85 +26,6 @@ func TestBuildWithoutPluginsStillBuildsBuiltinCommands(t *testing.T) {
}
}
func buildValidationTestRoot(t *testing.T) *cobra.Command {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
return Build(context.Background(), cmdutil.InvocationContext{},
WithIO(strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}),
WithoutPlugins(),
WithoutServiceCommands(),
WithoutStrictMode(),
)
}
func TestBuiltRoot_TopLevelTypoReturnsStructuredSuggestion(t *testing.T) {
root := buildValidationTestRoot(t)
root.SetArgs([]string{"imm"})
err := root.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if len(validationErr.Params) != 1 || validationErr.Params[0].Name != "imm" {
t.Fatalf("params = %v, want one entry named imm", validationErr.Params)
}
found := false
for _, candidate := range validationErr.Params[0].Suggestions {
if candidate == "im" {
found = true
}
}
if !found {
t.Fatalf("suggestions = %v, want im", validationErr.Params[0].Suggestions)
}
}
func TestBuiltRoot_SheetsOneRequiredGroupReturnsValidationExit(t *testing.T) {
root := buildValidationTestRoot(t)
root.SetArgs([]string{
"sheets", "+csv-put",
"--url", "https://example.com/sheets/token",
"--sheet-name", "Sheet1",
"--csv", "a,b",
})
err := root.Execute()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
}
}
func TestBuiltRoot_MailUnknownFlagUsesSharedSuggestions(t *testing.T) {
root := buildValidationTestRoot(t)
root.SetArgs([]string{"mail", "+send", "--tos", "alice@example.com"})
err := root.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if len(validationErr.Params) != 1 || validationErr.Params[0].Name != "--tos" {
t.Fatalf("params = %v, want one entry named --tos", validationErr.Params)
}
found := false
for _, candidate := range validationErr.Params[0].Suggestions {
if candidate == "--to" {
found = true
}
}
if !found {
t.Fatalf("suggestions = %v, want --to", validationErr.Params[0].Suggestions)
}
}
func findCommand(root *cobra.Command, path string) *cobra.Command {
parts := strings.Fields(path)
cmd := root

View File

@@ -916,6 +916,25 @@ 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")

View File

@@ -205,7 +205,7 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
return &core.AppConfig{
AppId: selected.AppID,
AppSecret: stored,
Brand: core.ParseBrand(selected.Brand),
Brand: core.LarkBrand(normalizeBrand(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.ParseBrand(b.envMap["FEISHU_DOMAIN"]),
Brand: core.LarkBrand(normalizeBrand(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.ParseBrand(b.cfg.Accounts.App.Tenant),
Brand: core.LarkBrand(normalizeBrand(b.cfg.Accounts.App.Tenant)),
}, nil
}
@@ -350,6 +350,16 @@ 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.
//

View File

@@ -5,9 +5,7 @@ package config
import (
"context"
"errors"
"fmt"
"net"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/internal/build"
@@ -182,9 +180,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(ctx, httpClient, larkBrand, f.IOStreams.ErrOut)
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
if err != nil {
return nil, classifyRegistrationBeginError(err)
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
}
// Step 2: Build and display verification URL + QR code
@@ -210,17 +208,33 @@ 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)
}
// 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)
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, classifyRegistrationError(err)
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)
}
}
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))
@@ -231,40 +245,3 @@ 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)
}
}

View File

@@ -1,70 +0,0 @@
// 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)
}
})
}
}

View File

@@ -96,40 +96,6 @@ 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"})

View File

@@ -4,23 +4,35 @@
package cmd
import (
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
func parseFlagError(t *testing.T, c *cobra.Command, args ...string) error {
t.Helper()
err := c.Flags().Parse(args)
if err == nil {
t.Fatalf("Parse(%v) returned nil", args)
func TestUnknownFlagName(t *testing.T) {
cases := []struct {
in string
name string
ok bool
}{
{"unknown flag: --query", "query", true},
{"unknown flag: --with-styles", "with-styles", true},
{"unknown shorthand flag: 'z' in -z", "", false},
{"flag needs an argument: --find", "", false},
{`invalid argument "x" for "--count"`, "", false},
}
for _, c := range cases {
name, ok := unknownFlagName(errors.New(c.in))
if name != c.name || ok != c.ok {
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
}
}
return err
}
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
@@ -29,7 +41,7 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
c.Flags().String("find", "", "")
c.Flags().Bool("dry-run", false, "")
err := flagDidYouMean(c, parseFlagError(t, c, "--rang")) // typo of --range
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
@@ -70,88 +82,59 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
}
}
func TestFlagDidYouMean_WikiNodeGetSuggestsNodeToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
root := Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
root.SetArgs([]string{
"wiki", "+node-get",
"--node", "https://feishu.cn/wiki/wikcnABC",
"--as", "user",
})
err := root.Execute()
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--node" {
t.Fatalf("Params = %v, want one entry named --node", verr.Params)
}
found := false
for _, s := range verr.Params[0].Suggestions {
if s == "--node-token" {
found = true
break
}
}
if !found {
t.Fatalf("Params[0].Suggestions = %v, want --node-token", verr.Params[0].Suggestions)
}
if !strings.Contains(verr.Hint, "--node-token") {
t.Fatalf("hint = %q, want --node-token", verr.Hint)
}
}
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
c := &cobra.Command{Use: "demo"}
c.Flags().String("find", "", "")
err := flagDidYouMean(c, parseFlagError(t, c, "--find"))
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
// Non-unknown-flag errors retain the same validation shape and identify the
// flag from pflag's typed ValueRequiredError.
// Non-unknown-flag errors stay generic: invalid_argument subtype, no
// structured param, generic --help hint (no "did you mean" suggestion).
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument (non-unknown-flag errors stay generic)", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--find" {
t.Errorf("Params=%v, want one entry named --find", verr.Params)
if verr.Param != "" || len(verr.Params) != 0 {
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
}
if strings.Contains(verr.Hint, "did you mean") {
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
}
}
func TestFlagDidYouMean_SheetsListsVisibleFlags(t *testing.T) {
root := &cobra.Command{Use: "root"}
sheets := &cobra.Command{Use: "sheets"}
cmdmeta.SetDomain(sheets, "sheets")
root.AddCommand(sheets)
sheets.Flags().String("range", "", "")
sheets.Flags().Int("width", 0, "")
err := flagDidYouMean(sheets, parseFlagError(t, sheets, "--cols"))
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
for _, want := range []string{"--range", "--width"} {
if !strings.Contains(validationErr.Hint, want) {
t.Errorf("hint should include %q, got %q", want, validationErr.Hint)
}
}
}
func TestFlagDidYouMean_InvalidValueTypedError(t *testing.T) {
c := &cobra.Command{Use: "demo"}
c.Flags().Int("width", 0, "")
// A non-numeric value for a typed flag surfaces pflag's InvalidValueError.
err := flagDidYouMean(c, parseFlagError(t, c, "--width=abc"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--width" || verr.Params[0].Reason != "invalid flag value" {
t.Errorf("Params = %v, want one --width entry with reason 'invalid flag value'", verr.Params)
}
}
func TestFlagDidYouMean_InvalidSyntaxTypedError(t *testing.T) {
c := &cobra.Command{Use: "demo"}
c.Flags().String("range", "", "")
// An empty flag name is bad flag syntax and surfaces pflag's InvalidSyntaxError.
err := flagDidYouMean(c, parseFlagError(t, c, "--=oops"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if len(verr.Params) != 1 || verr.Params[0].Reason != "invalid flag syntax" {
t.Errorf("Params = %v, want one entry with reason 'invalid flag syntax'", verr.Params)
}
}

View File

@@ -107,7 +107,6 @@ func Execute() int {
ctx, inv,
WithIO(os.Stdin, os.Stdout, os.Stderr),
HideProfile(isSingleAppMode()),
WithStartupBrand(ResolveStartupBrand(inv.Profile)),
)
// --- Notices (non-blocking) ---
@@ -241,10 +240,10 @@ func configureFlagCompletions(args []string) {
// 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. Any untyped error that reaches this boundary is an internal fault.
// Cobra argument, required-flag and flag-group errors are typed at their
// execution stages by installCobraValidationGuards; flag parse errors are
// typed by the root FlagErrorFunc.
// 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.
func handleRootError(f *cmdutil.Factory, err error) int {
errOut := f.IOStreams.ErrOut
@@ -283,14 +282,57 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return bareErr.Code
}
// Every user-input stage is typed before execution. A bare error here has
// crossed that boundary unexpectedly and must remain visible as an internal
// fault instead of being guessed from English message fragments.
fallback := errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
// 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
}
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
// group commands (no Run/RunE) with an unknown_subcommand error.
//
@@ -302,10 +344,6 @@ func handleRootError(f *cmdutil.Factory, err error) int {
// with reason_code=risk_not_annotated.
func installUnknownSubcommandGuard(cmd *cobra.Command) {
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
// Cobra's legacy Args fallback rejects an unknown top-level token before
// RunE can produce ranked suggestions. Explicitly accepting positional
// tokens lets every pure group, including root, reach the shared guard.
cmd.Args = cobra.ArbitraryArgs
cmd.RunE = unknownSubcommandRunE
// Route an unknown subcommand to unknownSubcommandRunE even when flags
// are also present (e.g. `sheets +cells-find --url ...`). A pure group
@@ -323,132 +361,6 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
}
}
// installCobraValidationGuards types errors at the stage where Cobra knows
// they are user input: positional argument validation, required flags and flag
// groups. This removes the need for final-boundary message matching.
func installCobraValidationGuards(cmd *cobra.Command) {
if cmd == nil {
return
}
if validateArgs := cmd.Args; validateArgs != nil {
cmd.Args = func(c *cobra.Command, args []string) error {
err := validateArgs(c, args)
if err == nil || errs.IsTyped(err) {
return err
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
}
}
previousPreRunE := cmd.PreRunE
previousPreRun := cmd.PreRun
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if previousPreRunE != nil {
if err := previousPreRunE(c, args); err != nil {
return err
}
} else if previousPreRun != nil {
previousPreRun(c, args)
}
if err := c.ValidateRequiredFlags(); err != nil {
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
c.Flags().VisitAll(func(flag *pflag.Flag) {
if flag.Changed || len(flag.Annotations[cobra.BashCompOneRequiredFlag]) == 0 {
return
}
validationErr.WithParams(errs.InvalidParam{Name: "--" + flag.Name, Reason: "required flag is missing"})
})
return validationErr.WithHint("run `%s --help` to see required flags", c.CommandPath())
}
if err := c.ValidateFlagGroups(); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).
WithParams(invalidFlagGroupParams(err.Error())...).
WithHint("run `%s --help` to see valid flag combinations", c.CommandPath()).
WithCause(err)
}
return nil
}
cmd.PreRun = nil
for _, child := range cmd.Commands() {
installCobraValidationGuards(child)
}
}
type flagGroupConstraint int
const (
flagGroupRequiredTogether flagGroupConstraint = iota
flagGroupOneRequired
flagGroupMutuallyExclusive
)
// invalidFlagGroupParams extracts the offending flag group from Cobra's
// flag-group validation error. The message always names the group as
// "[flag-a flag-b ...]" and its wording identifies the constraint, so both are
// read straight from the message instead of re-deriving Cobra's private group
// state. Pinned to cobra v1.10.2's message format (see go.mod).
func invalidFlagGroupParams(message string) []errs.InvalidParam {
names := flagGroupNamesFromMessage(message)
if len(names) == 0 {
return nil
}
return buildFlagGroupParams(names, flagGroupConstraintFromMessage(message))
}
func buildFlagGroupParams(names []string, constraint flagGroupConstraint) []errs.InvalidParam {
flagNames := make([]string, 0, len(names))
for _, name := range names {
name = strings.TrimLeft(name, "-")
if name != "" {
flagNames = append(flagNames, "--"+name)
}
}
if len(flagNames) == 0 {
return nil
}
group := "[" + strings.Join(flagNames, " ") + "]"
reason := "invalid flag combination in " + group
switch constraint {
case flagGroupRequiredTogether:
reason = "all of " + group + " required together"
case flagGroupOneRequired:
reason = "one of " + group + " required"
case flagGroupMutuallyExclusive:
reason = "only one of " + group + " allowed"
}
params := make([]errs.InvalidParam, 0, len(flagNames))
for _, name := range flagNames {
params = append(params, errs.InvalidParam{Name: name, Reason: reason})
}
return params
}
func flagGroupNamesFromMessage(message string) []string {
start := strings.IndexByte(message, '[')
if start < 0 {
return nil
}
end := strings.IndexByte(message[start+1:], ']')
if end < 0 {
return nil
}
return strings.Fields(message[start+1 : start+1+end])
}
func flagGroupConstraintFromMessage(message string) flagGroupConstraint {
switch {
case strings.Contains(message, "at least one of the flags"):
return flagGroupOneRequired
case strings.Contains(message, "must all be set"):
return flagGroupRequiredTogether
case strings.Contains(message, "none of the others can be"):
return flagGroupMutuallyExclusive
default:
return flagGroupConstraint(-1)
}
}
// unknownSubcommandRunE replaces cobra's silent help fallback on group commands
// with a typed *errs.ValidationError: a flag that belongs to a missing
// subcommand, a misplaced subcommand-only flag, or an unknown subcommand name
@@ -679,21 +591,17 @@ func isLarkDomain(c *cobra.Command) bool {
return cmdmeta.Domain(c) != ""
}
// flagDidYouMean is the single FlagErrorFunc inherited by all commands. It
// classifies pflag's typed parse errors and emits one stable validation shape.
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into a typed validation envelope: an
// unknown flag gets a focused "did you mean" hint (so agents recover even when
// the typo is semantic, e.g. --query vs --find, where edit distance alone finds
// nothing) and the offending flag in `params`. Other flag errors stay typed
// but generic.
func flagDidYouMean(c *cobra.Command, ferr error) error {
if ferr == nil {
return nil
}
var notExist *pflag.NotExistError
if !errors.As(ferr, &notExist) {
return typedFlagParseError(c, ferr)
}
name := notExist.GetSpecifiedName()
rawName := "--" + name
if notExist.GetSpecifiedShortnames() != "" {
rawName = "-" + name
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
@@ -705,69 +613,36 @@ func flagDidYouMean(c *cobra.Command, ferr error) error {
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
strings.Join(suggestions, ", "), c.CommandPath())
}
if cmdmeta.Domain(c) == "sheets" {
if list := inlineVisibleFlags(valid); list != "" {
if len(suggestions) > 0 {
hint = fmt.Sprintf("did you mean %s? valid flags: %s", strings.Join(suggestions, ", "), list)
} else {
hint = "valid flags: " + list
}
}
}
// The ranked candidates ride on the param as machine-readable Suggestions so
// an agent can retry without parsing the hint; the hint carries the same
// candidates as prose. The full valid-flag list stays recoverable via --help.
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", rawName, c.CommandPath()).
WithParams(errs.InvalidParam{Name: rawName, Reason: "unknown flag", Suggestions: suggestions}).
WithHint("%s", hint).
WithCause(ferr)
"unknown flag %q for %q", "--"+name, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
WithHint("%s", hint)
}
func typedFlagParseError(c *cobra.Command, ferr error) error {
param := ""
reason := "flag parse error"
var valueRequired *pflag.ValueRequiredError
var invalidValue *pflag.InvalidValueError
var invalidSyntax *pflag.InvalidSyntaxError
switch {
case errors.As(ferr, &valueRequired):
param = "--" + valueRequired.GetSpecifiedName()
if valueRequired.GetSpecifiedShortnames() != "" {
param = "-" + valueRequired.GetSpecifiedName()
}
reason = "flag value is required"
case errors.As(ferr, &invalidValue):
if invalidValue.GetFlag() != nil {
param = "--" + invalidValue.GetFlag().Name
}
reason = "invalid flag value"
case errors.As(ferr, &invalidSyntax):
param = invalidSyntax.GetSpecifiedFlag()
reason = "invalid flag syntax"
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
// those structured but generic — hallucinated flags are essentially always long.
//
// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod
// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match
// silently fails and unknown flags degrade to a generic flag_error — re-verify
// this prefix when bumping cobra.
func unknownFlagName(err error) (string, bool) {
const p = "unknown flag: --"
msg := err.Error()
i := strings.Index(msg, p)
if i < 0 {
return "", false
}
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath()).
WithCause(ferr)
if param != "" {
validationErr.WithParams(errs.InvalidParam{Name: param, Reason: reason})
rest := msg[i+len(p):]
if j := strings.IndexAny(rest, " \t"); j >= 0 {
rest = rest[:j]
}
return validationErr
}
func inlineVisibleFlags(names []string) string {
const limit = 25
if len(names) == 0 {
return ""
}
shown := names
suffix := ""
if len(shown) > limit {
shown = shown[:limit]
suffix = fmt.Sprintf(", ... (%d more; see --help)", len(names)-limit)
}
flags := make([]string, len(shown))
for i, name := range shown {
flags[i] = "--" + name
}
return strings.Join(flags, ", ") + suffix
return rest, true
}
// visibleFlagNames lists the non-hidden flag names of c (for suggestions and
@@ -804,11 +679,7 @@ func installTipsHelpFunc(root *cobra.Command) {
defaultHelp(cmd, args)
return
}
if service.PrepareMethodHelp(cmd, embeddedSkillContent) {
defaultHelp(cmd, args)
return
}
if service.PrepareShortcutHelp(cmd, embeddedSkillContent) {
if service.PrepareMethodHelp(cmd) {
defaultHelp(cmd, args)
return
}

View File

@@ -14,13 +14,11 @@ import (
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/update"
@@ -105,11 +103,6 @@ func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
}
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
t.Helper()
return buildStrictModeIntegrationRootCmdWithCatalog(t, f, nil)
}
func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Factory, catalog *apicatalog.Catalog) *cobra.Command {
t.Helper()
rootCmd := &cobra.Command{Use: "lark-cli"}
rootCmd.SilenceErrors = true
@@ -120,11 +113,7 @@ func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Facto
}
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(api.NewCmdApi(f, nil))
if catalog != nil {
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
} else {
service.RegisterServiceCommands(rootCmd, f)
}
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
@@ -132,29 +121,6 @@ func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Facto
return rootCmd
}
func strictModeFixtureCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{
{
Name: "fixture",
ServicePath: "/open-apis/fixture/v1",
Resources: map[string]meta.Resource{
"things": {
Methods: map[string]meta.Method{
"create": {
Path: "things",
HTTPMethod: "POST",
AccessTokens: []meta.Token{meta.TokenTenant},
RequestBody: map[string]meta.Field{
"name": {Type: "string"},
},
},
},
},
},
},
})
}
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
t.Setenv(envvars.CliAppID, "")
@@ -389,11 +355,10 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
catalog := strictModeFixtureCatalog()
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
})
if code != output.ExitValidation {

View File

@@ -6,7 +6,6 @@ package cmd
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
@@ -285,9 +284,9 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
deprecation.SetPending(&deprecation.Notice{
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
})
err := errs.NewValidationError(errs.SubtypeInvalidArgument, `required flag(s) %q not set`, "values").
WithParam("--values")
exit := handleRootError(f, err)
// The bare error shape cobra's ValidateRequiredFlags produces: not a typed
// errs.* error, so it reaches the deprecation fallback.
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
@@ -382,9 +381,10 @@ func decodeErrorEnvelope(t *testing.T, raw []byte) map[string]any {
return errObj
}
// TestCobraValidationGuardTypesRequiredFlag pins that required-flag errors are
// typed at the Cobra validation stage, before the final dispatcher.
func TestCobraValidationGuardTypesRequiredFlag(t *testing.T) {
// TestHandleRootError_NoDeprecationTypesUsageError pins that a residual cobra
// usage error (missing required flag) is typed as invalid_argument with exit 2
// even with no deprecation pending — never cobra's plain "Error:" line.
func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
@@ -393,15 +393,7 @@ func TestCobraValidationGuardTypesRequiredFlag(t *testing.T) {
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
cmd := &cobra.Command{Use: "demo", RunE: func(*cobra.Command, []string) error { return nil }}
cmd.Flags().String("values", "", "")
cmd.MarkFlagRequired("values")
installCobraValidationGuards(cmd)
err := cmd.Execute()
if err == nil {
t.Fatal("expected missing required flag error")
}
exit := handleRootError(f, err)
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
@@ -419,93 +411,6 @@ func TestCobraValidationGuardTypesRequiredFlag(t *testing.T) {
}
}
func TestCobraValidationGuardTypesFlagGroupErrorsWithParams(t *testing.T) {
tests := []struct {
name string
mark func(*cobra.Command)
args []string
wantNames []string
wantReason string
}{
{
name: "one required",
mark: func(cmd *cobra.Command) {
cmd.MarkFlagsOneRequired("start-cell", "range")
},
wantNames: []string{"--start-cell", "--range"},
wantReason: "one of [--start-cell --range] required",
},
{
name: "required together",
mark: func(cmd *cobra.Command) {
cmd.MarkFlagsRequiredTogether("start-cell", "range")
},
args: []string{"--start-cell", "A1"},
wantNames: []string{"--start-cell", "--range"},
wantReason: "all of [--start-cell --range] required together",
},
{
name: "mutually exclusive",
mark: func(cmd *cobra.Command) {
cmd.MarkFlagsMutuallyExclusive("start-cell", "range")
},
args: []string{"--start-cell", "A1", "--range", "B2"},
wantNames: []string{"--start-cell", "--range"},
wantReason: "only one of [--start-cell --range] allowed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "demo", RunE: func(*cobra.Command, []string) error { return nil }}
cmd.Flags().String("start-cell", "", "")
cmd.Flags().String("range", "", "")
tt.mark(cmd)
installCobraValidationGuards(cmd)
cmd.SetArgs(tt.args)
err := cmd.Execute()
if err == nil {
t.Fatal("expected flag group error")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if len(validationErr.Params) != len(tt.wantNames) {
t.Fatalf("params = %v, want %d entries", validationErr.Params, len(tt.wantNames))
}
for i, wantName := range tt.wantNames {
if validationErr.Params[i].Name != wantName || validationErr.Params[i].Reason != tt.wantReason {
t.Errorf("params[%d] = %+v, want name=%q reason=%q", i, validationErr.Params[i], wantName, tt.wantReason)
}
}
})
}
}
func TestInvalidFlagGroupParamsFromMessage(t *testing.T) {
got := invalidFlagGroupParams("at least one of the flags in the group [start-cell range] is required")
if len(got) != 2 {
t.Fatalf("params = %v, want two entries", got)
}
for i, want := range []string{"--start-cell", "--range"} {
if got[i].Name != want || got[i].Reason != "one of [--start-cell --range] required" {
t.Errorf("params[%d] = %+v", i, got[i])
}
}
}
// TestHandleRootError_LeakedUntypedErrorBecomesInternal pins that an untyped
// error that does NOT match a cobra usage shape (i.e. one that leaked past the
// typed boundary from a helper) is classified as an internal fault (exit 5),

View File

@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
return cmd
}
// completeSchemaPath is a thin adapter over the schema catalog's Complete.
// It uses the same source as schema execution so completion candidates match
// what `schema` can resolve.
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
// It uses the embedded source so completion candidates match what `schema`
// execution can resolve (both overlay-free).
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
mode := f.ResolveStrictMode(cmd.Context())
completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
directive := cobra.ShellCompDirectiveNoFileComp
if noSpace {
directive |= cobra.ShellCompDirectiveNoSpace
@@ -86,19 +86,13 @@ func schemaRun(opts *SchemaOptions) error {
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
}
// runSchema resolves the path through the schema catalog and renders the
// runSchema resolves the path through the embedded catalog and renders the
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
// output shape — a single resolved method renders as one envelope object,
// anything broader as an array — and maps resolve failures to hints.
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
catalog := registry.SchemaCatalog()
if len(catalog.Services()) == 0 {
// No embedded metadata and the runtime fallback is empty too: offline
// with a cold cache, remote meta off, or an unwritable cache dir.
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
}
catalog := registry.EmbeddedCatalog()
target, err := catalog.Resolve(parts)
if err != nil {
return resolveError(err)

View File

@@ -71,18 +71,11 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
}
// domainHelpBase returns the description to seed domain help with — the
// hand-authored Long when present, else the Short.
// hand-authored Long when present, else the Short — captured once into an
// annotation so re-rendering reuses the pristine text instead of the
// already-augmented Long.
func domainHelpBase(cmd *cobra.Command) string {
return captureHelpBase(cmd, domainBaseAnnotation)
}
// captureHelpBase records a command's pristine lead text once — its
// hand-authored Long, or Short when Long is empty — into the given annotation,
// so lazy re-renders compose onto the original text instead of onto an
// already-augmented Long. This is what lets a shortcut's PostMount-authored
// Long survive: it becomes the base the affordance block is appended below.
func captureHelpBase(cmd *cobra.Command, key string) string {
if base, ok := cmd.Annotations[key]; ok {
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
return base
}
base := cmd.Long
@@ -92,7 +85,7 @@ func captureHelpBase(cmd *cobra.Command, key string) string {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[key] = base
cmd.Annotations[domainBaseAnnotation] = base
return base
}
@@ -108,12 +101,12 @@ func methodLong(description, schemaPath, paramsOnly string) string {
}
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
// The affordance overlay coordinates live in cmdmeta (shared with shortcuts).
const (
schemaPathAnnotation = "method-schema-path"
paramsOnlyAnnotation = "method-params-only"
domainBaseAnnotation = "affordance-domain-base"
shortcutBaseAnnotation = "affordance-shortcut-base"
affordanceServiceAnnotation = "affordance-service"
affordanceMethodAnnotation = "affordance-method"
schemaPathAnnotation = "method-schema-path"
paramsOnlyAnnotation = "method-params-only"
domainBaseAnnotation = "affordance-domain-base"
)
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
@@ -122,7 +115,10 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmdmeta.SetAffordanceRef(cmd, service, methodID)
if service != "" && methodID != "" {
cmd.Annotations[affordanceServiceAnnotation] = service
cmd.Annotations[affordanceMethodAnnotation] = methodID
}
cmd.Annotations[schemaPathAnnotation] = schemaPath
if paramsOnly != "" {
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
@@ -132,11 +128,8 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
// guidance at the TOP (Risk, then the affordance block, then the schema
// pointer), returning false for non-method commands. The overlay is parsed
// here — only when help is rendered. skillFS (nil-safe) gates the related-skill
// pointers: each is emitted only when it resolves in the skill tree (see
// affordance.SkillStatPath), so a typo or a build without embedded skills never
// prints a `skills read` that cannot be opened.
func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
// here — only when help is rendered.
func PrepareMethodHelp(cmd *cobra.Command) bool {
ann := cmd.Annotations
if ann == nil {
return false
@@ -148,15 +141,22 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
var b strings.Builder
b.WriteString(cmd.Short)
writeRisk(&b, cmd)
if level, ok := cmdutil.GetRisk(cmd); ok {
// --yes asserts the USER confirmed; the agent must not self-approve.
if level == cmdutil.RiskHighRiskWrite {
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
} else {
fmt.Fprintf(&b, "\n\nRisk: %s", level)
}
}
var skills []string
if raw, ok := affordanceRaw(cmd); ok {
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
if block := renderAffordanceValue(a); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
skills = a.Skills
}
}
@@ -164,93 +164,15 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
b.WriteString(ann[paramsOnlyAnnotation])
writeRelatedSkills(&b, skills, skillFS)
cmd.Long = b.String()
return true
}
// PrepareShortcutHelp composes a +-prefixed shortcut's Long from its affordance
// overlay — the same top layout as method help (description, Risk, guidance
// block, related skills) minus the schema pointer, which shortcuts have none
// of. Returns false when the command is not a shortcut or carries no overlay
// entry, so shortcuts without guidance keep the default help plus the bottom
// risk/tips append.
//
// The lead is the command's pristine base (captureHelpBase): a shortcut that
// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST
// read the skill" directive) keeps it — the affordance block is appended below,
// never clobbering it.
//
// Tips precedence (intentional, not a bug): the overlay's ### Tips win. The
// shortcut's declarative Tips (the Go Tips field) are only a fallback used when
// the overlay declares none; when the overlay has tips, the Go tips are dropped
// (replaced, not merged) so tips never render twice. Authoring a ### Tips block
// therefore silently retires that shortcut's Go Tips — consolidate into one.
func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
return false
}
raw, ok := affordanceRaw(cmd)
if !ok {
return false
}
a, ok := (meta.Method{Affordance: raw}).ParsedAffordance()
if !ok {
return false
}
if len(a.Tips) == 0 {
a.Tips = cmdutil.GetTips(cmd)
}
var b strings.Builder
b.WriteString(captureHelpBase(cmd, shortcutBaseAnnotation))
writeRisk(&b, cmd)
if block := renderAffordanceValue(a); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
writeRelatedSkills(&b, a.Skills, skillFS)
cmd.Long = b.String()
return true
}
// writeRisk appends the "Risk: <level>" line, warning agents not to self-approve
// high-risk-write commands. A no-op when the command has no risk annotation.
func writeRisk(b *strings.Builder, cmd *cobra.Command) {
level, ok := cmdutil.GetRisk(cmd)
if !ok {
return
}
// --yes asserts the USER confirmed; the agent must not self-approve.
if level == cmdutil.RiskHighRiskWrite {
fmt.Fprintf(b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
} else {
fmt.Fprintf(b, "\n\nRisk: %s", level)
}
}
// writeRelatedSkills appends the "Related skills" block for the entries that
// exist in skillFS. Nothing is written when skillFS is nil or no entry resolves,
// so help never prints a `skills read` pointer that cannot be opened.
func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS) {
if skillFS == nil || len(skills) == 0 {
return
}
var avail []string
for _, s := range skills {
if _, err := fs.Stat(skillFS, affordance.SkillStatPath(s)); err == nil {
avail = append(avail, s)
if len(skills) > 0 {
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
for _, s := range skills {
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
}
}
if len(avail) == 0 {
return
}
b.WriteString("\n\nRelated skills (read for end-to-end usage):")
for _, s := range avail {
fmt.Fprintf(b, "\n lark-cli skills read %s", s)
}
cmd.Long = b.String()
return true
}
// affordanceLookup is the overlay source; a package var so tests can inject.
@@ -267,8 +189,12 @@ func RenderAffordanceForCmd(cmd *cobra.Command) string {
}
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
service, methodID, ok := cmdmeta.AffordanceRef(cmd)
if !ok {
if cmd.Annotations == nil {
return nil, false
}
service := cmd.Annotations[affordanceServiceAnnotation]
methodID := cmd.Annotations[affordanceMethodAnnotation]
if service == "" || methodID == "" {
return nil, false
}
return affordanceLookup(service, methodID)
@@ -281,13 +207,7 @@ func renderAffordance(m meta.Method) string {
if !ok {
return ""
}
return renderAffordanceValue(a)
}
// renderAffordanceValue renders an already-parsed affordance. Split from
// renderAffordance so callers can render a value they have adjusted first (e.g.
// a shortcut folding its declarative tips into an overlay that has none).
func renderAffordanceValue(a meta.Affordance) string {
var sections []string
bullets := func(title string, items []string) {
var nonEmpty []string

View File

@@ -7,7 +7,6 @@ import (
"encoding/json"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
@@ -71,8 +70,8 @@ func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long)
}
// The lookup ref is recorded so the help path can resolve it later.
if svc, method, ok := cmdmeta.AffordanceRef(cmd); !ok || svc != "im" || method != "messages.create" {
t.Errorf("affordance ref = %q/%q (ok=%v), want im/messages.create", svc, method, ok)
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
}
}
@@ -120,7 +119,7 @@ func TestPrepareMethodHelp(t *testing.T) {
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
if !PrepareMethodHelp(cmd, nil) {
if !PrepareMethodHelp(cmd) {
t.Fatal("PrepareMethodHelp returned false for a service-method command")
}
long := cmd.Long
@@ -137,133 +136,11 @@ func TestPrepareMethodHelp(t *testing.T) {
}
// A non-service command (no schema-path annotation) is left untouched.
if PrepareMethodHelp(&cobra.Command{Use: "plain"}, nil) {
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
t.Error("PrepareMethodHelp should return false for a non-service command")
}
}
// PrepareShortcutHelp composes a shortcut's Long from its overlay with the same
// top layout as method help (no schema pointer), folding declarative tips when
// the overlay declares none, and leaves shortcuts without an overlay entry (and
// non-shortcut commands) for the default help path.
func TestPrepareShortcutHelp(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
if service == "calendar" && methodID == "+create" {
return json.RawMessage(`{"use_when":["高层创建日程"],"skills":["lark-calendar"]}`), true
}
return nil, false
}
sc := &cobra.Command{Use: "+create", Short: "Create an event"}
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(sc, "calendar", "+create")
cmdutil.SetRisk(sc, "write")
cmdutil.SetTips(sc, []string{"start/end 收 ISO 8601"})
if !PrepareShortcutHelp(sc, nil) {
t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay")
}
for _, want := range []string{"Create an event", "Risk: write", "When to use:", "高层创建日程", "Tips:", "start/end 收 ISO 8601"} {
if !strings.Contains(sc.Long, want) {
t.Errorf("shortcut Long missing %q:\n%s", want, sc.Long)
}
}
if strings.Contains(sc.Long, "Full parameter schema:") {
t.Errorf("shortcut Long must not carry a schema pointer:\n%s", sc.Long)
}
// No overlay entry -> leave it for the default help path.
bare := &cobra.Command{Use: "+bare", Short: "x"}
cmdmeta.SetSource(bare, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(bare, "calendar", "+bare")
if PrepareShortcutHelp(bare, nil) {
t.Error("PrepareShortcutHelp should return false when the shortcut has no overlay")
}
// Non-shortcut source is ignored even with a ref.
notSc := &cobra.Command{Use: "create", Short: "x"}
cmdmeta.SetAffordanceRef(notSc, "calendar", "+create")
if PrepareShortcutHelp(notSc, nil) {
t.Error("PrepareShortcutHelp should return false for a non-shortcut command")
}
}
// Related-skill pointers are gated on existence: a skill that resolves in the
// skill FS renders, a typo is dropped (never print an unopenable `skills read`),
// and a nil skill FS suppresses the whole block.
func TestRelatedSkillsStatGating(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
return json.RawMessage(`{"use_when":["x"],"skills":["lark-real","lark-typo","lark-real/references/deep.md","lark-real/references/missing.md"]}`), true
}
skillFS := fstest.MapFS{
"lark-real/SKILL.md": {Data: []byte("# real")},
"lark-real/references/deep.md": {Data: []byte("# deep")},
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "d"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
if !PrepareMethodHelp(cmd, skillFS) {
t.Fatal("PrepareMethodHelp returned false")
}
if !strings.Contains(cmd.Long, "skills read lark-real\n") {
t.Errorf("existing bare-name skill should render on its own line; got:\n%s", cmd.Long)
}
if strings.Contains(cmd.Long, "lark-typo") {
t.Errorf("nonexistent skill must be dropped, not printed as an unopenable pointer; got:\n%s", cmd.Long)
}
// A name/relpath reference to an existing file renders; a missing one drops.
if !strings.Contains(cmd.Long, "skills read lark-real/references/deep.md") {
t.Errorf("existing reference entry should render; got:\n%s", cmd.Long)
}
if strings.Contains(cmd.Long, "references/missing.md") {
t.Errorf("nonexistent reference must be dropped; got:\n%s", cmd.Long)
}
// nil skill FS: the whole Related-skills block is suppressed.
bare := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
PrepareMethodHelp(bare, nil)
if strings.Contains(bare.Long, "Related skills") {
t.Errorf("nil skillFS should suppress the skills block; got:\n%s", bare.Long)
}
}
// A shortcut that set a hand-authored Long (as the docs shortcuts do in
// PostMount) keeps it as the lead: the affordance block is appended below, not
// clobbered, and re-rendering does not double-append.
func TestPrepareShortcutHelp_PreservesPostMountLong(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
return json.RawMessage(`{"use_when":["高层创建日程"]}`), true
}
const authored = "Custom docs help. AI agents MUST read the skill first."
sc := &cobra.Command{Use: "+create", Short: "Create", Long: authored}
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(sc, "calendar", "+create")
if !PrepareShortcutHelp(sc, nil) {
t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay")
}
if !strings.HasPrefix(sc.Long, authored) {
t.Errorf("hand-authored Long must lead, not be clobbered; got:\n%s", sc.Long)
}
if !strings.Contains(sc.Long, "When to use:") {
t.Errorf("affordance block should be appended below the base; got:\n%s", sc.Long)
}
// Re-render must reuse the captured base, not append the block twice.
PrepareShortcutHelp(sc, nil)
if n := strings.Count(sc.Long, "When to use:"); n != 1 {
t.Errorf("affordance appended %d times across re-renders, want 1:\n%s", n, sc.Long)
}
}
// domainCmd wires a domain-tagged command with a subcommand under a root, the
// shape PrepareDomainHelp expects.
func domainCmd(short, long string) *cobra.Command {

View File

@@ -140,7 +140,6 @@ type ServiceMethodOptions struct {
PageLimit int
PageDelay int
Format string
JSON bool
JqExpr string
DryRun bool
File string // --file flag value
@@ -269,11 +268,6 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
opts.Cmd = cmd
opts.Ctx = cmd.Context()
opts.As = core.Identity(asStr)
format, err := output.StandardFormats.Resolve(opts.Format, cmd.Flags().Changed("format"), opts.JSON)
if err != nil {
return err
}
opts.Format = format
if runF != nil {
return runF(opts)
}
@@ -305,8 +299,8 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
_ = cmd.Flags().MarkHidden(name)
}
}
cmd.Flags().StringVar(&opts.Format, "format", "json", output.StandardFormats.Usage())
cmd.Flags().BoolVar(&opts.JSON, "json", false, "shorthand for --format json")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
if spec.risk == cmdutil.RiskHighRiskWrite {
@@ -317,7 +311,7 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
cmd.Flags().StringVar(&opts.File, "file", "", "File upload [field=]path. Supports - and stdin.")
}
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return output.StandardFormats.Names(), cobra.ShellCompDirectiveNoFileComp
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
// Registered last so the collision guard sees the standard flags above.
@@ -409,9 +403,9 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(request, config, serviceDryRunOutputOptions(f, opts), *fileMeta)
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
}
return serviceDryRun(f, request, config, opts)
return serviceDryRun(f, request, config, opts.Format)
}
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
@@ -426,7 +420,10 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
}
out := f.IOStreams.Out
format, _ := output.ParseFormat(opts.Format)
format, formatOK := output.ParseFormat(opts.Format)
if !formatOK {
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
// Scope-insufficient (99991679) and all other Lark API codes route through
// errclass.BuildAPIError via ac.CheckResponse, producing *errs.PermissionError
@@ -670,19 +667,8 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
return request, nil, nil
}
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 serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
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 {
@@ -709,24 +695,6 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
}
switch format {
case output.FormatPretty:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatPretty)
return apiErr
}
scanResult := output.ScanForSafety(commandPath, result, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
output.FormatValue(out, result, output.FormatPretty)
return nil
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {

View File

@@ -4,14 +4,10 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"mime"
"mime/multipart"
"os"
"path/filepath"
"strings"
"testing"
@@ -201,42 +197,6 @@ func TestNewCmdServiceMethod_RunFCallback(t *testing.T) {
}
}
func TestServiceMethod_OutputFormatResolution(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "json shorthand", args: []string{"--json"}, want: "json"},
{name: "explicit format wins", args: []string{"--format", "table", "--json"}, want: "table"},
{name: "pretty format", args: []string{"--format", "PRETTY"}, want: "pretty"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
})
args := []string{"--as", "bot"}
cmd.SetArgs(append(args, tt.args...))
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if captured == nil {
t.Fatal("expected options to be captured")
}
if captured.Format != tt.want {
t.Fatalf("format = %q, want %q", captured.Format, tt.want)
}
})
}
}
// ── dry-run / buildServiceRequest ──
func TestServiceMethod_DryRun_PathParam(t *testing.T) {
@@ -260,39 +220,13 @@ func TestServiceMethod_DryRun_PathParam(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
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())
if !strings.Contains(stdout.String(), tt.wantInURL) {
t.Errorf("expected URL containing %q, got:\n%s", 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
@@ -380,12 +314,8 @@ func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
if err != nil {
t.Fatalf("expected no error with --page-all skipping page_size, got: %v", err)
}
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"])
if !strings.Contains(stdout.String(), "Dry Run") {
t.Error("expected dry-run output")
}
}
@@ -498,43 +428,6 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
}
}
func TestServiceMethod_PrettyFormatsRealResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, testConfig)
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"name": "Alice"}},
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("pretty output should be valid JSON: %v\n%s", err, out)
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("pretty output data = %#v", got["data"])
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("pretty output data.items = %#v", data["items"])
}
if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") {
t.Fatalf("pretty output should be indented JSON rather than a table, got:\n%s", out)
}
}
func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu,
@@ -576,48 +469,6 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
}
}
func TestServiceMethod_PageAll_PrettyAggregatesIndentedJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-pageall-pretty", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--format", "pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("page-all pretty output should be valid JSON: %v\n%s", err, out)
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("page-all pretty output data = %#v", got["data"])
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("page-all pretty output data.items = %#v", data["items"])
}
if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") {
t.Fatalf("page-all pretty output should be aggregated indented JSON, got:\n%s", out)
}
}
type serviceContentSafetyProvider struct {
called bool
path string
@@ -910,23 +761,26 @@ func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T)
}
}
func TestServiceMethod_UnknownFormat_ReturnsValidationError(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--format", "unknown"})
err := cmd.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if validationErr.Param != "--format" {
t.Errorf("param = %q, want --format", validationErr.Param)
if !strings.Contains(stderr.String(), "warning: unknown format") {
t.Errorf("expected format warning in stderr, got:\n%s", stderr.String())
}
}
@@ -1223,23 +1077,11 @@ func TestServiceMethod_FileUpload_DryRun(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
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, "image") {
t.Errorf("expected dry-run output to mention file field, 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)
if !strings.Contains(out, "Dry Run") {
t.Errorf("expected dry-run header, got: %s", out)
}
}
@@ -1290,63 +1132,6 @@ func TestDetectFileFields(t *testing.T) {
}
}
// parseMultipartFilenames drives one service-method --file upload through the
// mock transport and returns a map of field name -> part filename parsed from
// the captured multipart body. Mirrors cmd/api's helper of the same name
// (inlined here rather than shared, since the two live in different packages)
// to give BuildFormdata's shared local-file fix a second real entry-point
// covering it.
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) map[string]string {
t.Helper()
ct := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil {
t.Fatalf("parse Content-Type %q: %v", ct, err)
}
if !strings.HasPrefix(mediaType, "multipart/") {
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
}
filenames := map[string]string{}
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
for {
part, err := mr.NextPart()
if err != nil {
break
}
if fn := part.FileName(); fn != "" {
filenames[part.FormName()] = fn
}
}
return filenames
}
func TestServiceMethod_FileUpload_PreservesFilename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, testConfig)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("fake-image"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/im/v1/images",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"image_key": "img_xxx"}},
}
reg.Register(stub)
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
cmd.SetArgs([]string{"--file", "photo.jpg", "--data", `{"image_type":"message"}`, "--as", "bot"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames := parseMultipartFilenames(t, stub)
if got := filenames["image"]; got != "photo.jpg" {
t.Fatalf("part filename for field %q = %q, want %q", "image", got, "photo.jpg")
}
}
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)

View File

@@ -1,28 +0,0 @@
// 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
}

View File

@@ -1,87 +0,0 @@
// 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)
}
}

View File

@@ -45,38 +45,11 @@ func TestInstallUnknownSubcommandGuard_InstallsOnGroupsOnly(t *testing.T) {
if files.RunE == nil {
t.Error("files should have RunE installed")
}
if root.Args == nil {
t.Error("root should explicitly accept positional tokens so unknown commands reach RunE")
}
if err := leaf.RunE(leaf, []string{"unexpected-arg"}); err != nil {
t.Errorf("leaf +search RunE should be untouched, got error %v", err)
}
}
func TestTopLevelUnknownCommandReturnsStructuredSuggestion(t *testing.T) {
root, _, _ := newGroupTree()
installUnknownSubcommandGuard(root)
root.SetArgs([]string{"driv"})
err := root.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if len(validationErr.Params) != 1 || validationErr.Params[0].Name != "driv" {
t.Fatalf("params = %v, want one entry named driv", validationErr.Params)
}
found := false
for _, candidate := range validationErr.Params[0].Suggestions {
if candidate == "drive" {
found = true
}
}
if !found {
t.Fatalf("suggestions = %v, want drive", validationErr.Params[0].Suggestions)
}
}
func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
called := false

View File

@@ -5,7 +5,6 @@ package cmdupdate
import (
"fmt"
stdio "io"
"runtime"
"strings"
@@ -14,7 +13,6 @@ 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"
@@ -104,8 +102,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
Long: `Update lark-cli to the latest version.
Detects the installation method automatically:
- npm install: runs npm install -g @larksuite/cli@<version>
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
- npm install: runs npm install -g @larksuite/cli@<version>
- manual/other: shows GitHub Releases download URL
Use --json for structured output (for AI agents and scripts).
@@ -127,15 +124,13 @@ 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",
@@ -157,7 +152,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
@@ -169,23 +164,7 @@ func updateRun(opts *UpdateOptions) error {
if !detect.CanAutoUpdate() {
return doManualUpdate(opts, io, cur, latest, detect, updater)
}
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
return doNpmUpdate(opts, io, cur, latest, updater)
}
// --- Output helpers ---
@@ -247,23 +226,12 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
if detect.Method == selfupdate.InstallPnpm {
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
} else {
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
}
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
emitSkillsTextHints(io, skillsResult)
return nil
}
func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
pm := "npm"
install := updater.RunNpmInstall
if detect.Method == selfupdate.InstallPnpm {
pm = "pnpm"
install = updater.RunPnpmInstall
}
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
restore, err := updater.PrepareSelfReplace()
if err != nil {
return reportError(opts, io, "update_error",
@@ -271,19 +239,19 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
}
if !opts.JSON {
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
}
npmResult := install(latest)
npmResult := updater.RunNpmInstall(latest)
if npmResult.Err != nil {
restore()
combined := npmResult.CombinedOutput()
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{
"type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
"detail": selfupdate.Truncate(combined, maxNpmOutput),
"hint": permissionHint(combined, pm),
"hint": permissionHint(combined),
},
})
return output.ErrBare(output.ExitAPI)
@@ -295,7 +263,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
}
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
if hint := permissionHint(combined, pm); hint != "" {
if hint := permissionHint(combined); hint != "" {
fmt.Fprintf(io.ErrOut, " %s\n", hint)
}
return output.ErrBare(output.ExitAPI)
@@ -306,7 +274,7 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
if err := updater.VerifyBinary(latest); err != nil {
restore()
msg := fmt.Sprintf("new binary verification failed: %s", err)
hint := verificationFailureHint(updater, latest, pm)
hint := verificationFailureHint(updater, latest)
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false,
@@ -336,33 +304,23 @@ func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
if skillsResult != nil {
skillsPM := "npx"
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
skillsPM = "pnpm dlx"
}
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
}
emitSkillsTextHints(io, skillsResult)
return nil
}
func permissionHint(pmOutput, pm string) string {
if !strings.Contains(pmOutput, "EACCES") || isWindows() {
return ""
func permissionHint(npmOutput string) string {
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
}
if pm == "pnpm" {
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
}
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
return ""
}
func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
if updater.CanRestorePreviousVersion() {
return "the previous version has been restored"
}
if pm == "pnpm" {
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}

View File

@@ -9,9 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
@@ -59,27 +57,6 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
t.Cleanup(func() { newUpdater = origNew })
}
// mockDetectAndPnpm mirrors mockDetectAndNpm but wires the pnpm install path
// and fails the test if the npm install path is invoked.
func mockDetectAndPnpm(t *testing.T, result selfupdate.DetectResult, pnpmFn func(string) *selfupdate.NpmResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.PnpmInstallOverride = pnpmFn
u.NpmInstallOverride = func(string) *selfupdate.NpmResult {
t.Errorf("npm install must not be called for a pnpm install")
return &selfupdate.NpmResult{}
}
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
return func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
@@ -104,110 +81,6 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
}
}
func TestUpdatePnpm_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out := stdout.String(); !strings.Contains(out, `"action": "updated"`) {
t.Errorf("expected updated in output, got: %s", out)
}
}
func TestUpdatePnpm_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "via pnpm") {
t.Errorf("expected 'via pnpm' in stderr, got: %s", out)
}
if !strings.Contains(out, "Updating skills via pnpm dlx ...") {
t.Errorf("expected skills sync to report pnpm dlx launcher, got: %s", out)
}
if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected success message, got: %s", out)
}
}
func TestUpdatePnpm_InstallError_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{Err: errors.New("pnpm boom")} },
)
err := cmd.Execute()
if err == nil {
t.Fatal("expected error exit")
}
if out := stdout.String(); !strings.Contains(out, `"ok": false`) || !strings.Contains(out, "update_error") {
t.Errorf("expected failure envelope, got: %s", out)
}
if out := stdout.String(); !strings.Contains(out, "pnpm install failed") {
t.Errorf("expected message to report pnpm as the package manager, got: %s", out)
}
}
func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: false})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "installed via pnpm, but pnpm is not available in PATH") {
t.Errorf("expected pnpm manual reason, got: %s", out)
}
if !strings.Contains(out, "pnpm add -g") {
t.Errorf("expected pnpm add -g hint, got: %s", out)
}
}
func TestNormalizeVersion(t *testing.T) {
tests := []struct {
input string
@@ -393,9 +266,6 @@ func TestUpdateNpm_Human(t *testing.T) {
if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected success message in stderr, got: %s", out)
}
if !strings.Contains(out, "Updating skills via npx ...") {
t.Errorf("expected skills sync to report npx launcher for npm install, got: %s", out)
}
}
func TestUpdateForce_JSON(t *testing.T) {
@@ -869,9 +739,9 @@ func TestPermissionHint(t *testing.T) {
origOS := currentOS
defer func() { currentOS = origOS }()
// Linux + npm: EACCES should produce a hint with npm prefix guidance.
// Linux: EACCES should produce a hint with npm prefix guidance.
currentOS = "linux"
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
if !strings.Contains(hint, "npm global prefix") {
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
}
@@ -879,25 +749,16 @@ func TestPermissionHint(t *testing.T) {
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
}
// Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo.
pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm")
if !strings.Contains(pnpmHint, "pnpm setup") {
t.Errorf("expected pnpm setup hint, got: %s", pnpmHint)
}
if strings.Contains(pnpmHint, "npm global prefix") || strings.Contains(pnpmHint, "sudo") {
t.Errorf("pnpm hint must not reference npm prefix or sudo, got: %s", pnpmHint)
}
// Windows: EACCES hint is suppressed (no EACCES on Windows).
currentOS = "windows"
hint = permissionHint("EACCES: permission denied", "npm")
hint = permissionHint("EACCES: permission denied")
if hint != "" {
t.Errorf("expected empty hint on Windows, got: %s", hint)
}
// Non-EACCES error: always empty.
currentOS = "linux"
if got := permissionHint("some other error", "npm"); got != "" {
if got := permissionHint("some other error"); got != "" {
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
}
}
@@ -1733,64 +1594,3 @@ 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())
}
}

View File

@@ -72,28 +72,6 @@ other category. `error.type` is `"policy"`, `error.subtype` is one of
`challenge_required` / `access_denied`, and process exit is `6` via
`CategoryPolicy`.
### Success envelope (stdout)
For contrast: success responses render to **stdout** as an
`output.Envelope` (`internal/output/envelope.go`), exit code `0`:
```json
{
"ok": true,
"identity": "user",
"data": { "guid": "e297d3d0-..." },
"meta": { "count": 1 }
}
```
Consumers must branch on `ok` (or the process exit code). The success
envelope has **no top-level `code` or `msg` field**`code` exists only
inside `error`, where it is the upstream numeric code (invariant 4).
Wrappers that follow the raw OpenAPI convention and test `code == 0`
will misclassify every successful call as a failure, which is
especially dangerous around write commands (e.g. retrying a create that
already succeeded).
## Categories
| Category | When | Exit | Typed struct |

View File

@@ -319,7 +319,7 @@ func TestPermissionError_FullChain(t *testing.T) {
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
WithMissingScopes("mail:user_mailbox.message:send").
WithIdentity("user").
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=mail:user_mailbox.message:send")
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
if got.Category != errs.CategoryAuthorization {
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
@@ -419,7 +419,7 @@ func TestBuilder_WireFormat(t *testing.T) {
WithHint("run lark-cli auth login --scope calendar:event:create").
WithMissingScopes("calendar:event:create").
WithIdentity("user").
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create")
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
buf, err := json.Marshal(e)
if err != nil {
@@ -439,7 +439,7 @@ func TestBuilder_WireFormat(t *testing.T) {
"hint": "run lark-cli auth login --scope calendar:event:create",
"log_id": "20260520-0a1b2c3d",
"identity": "user",
"console_url": "https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create",
"console_url": "https://open.feishu.cn/app/cli_xxx/auth",
"missing_scopes": []any{"calendar:event:create"},
}
for k, want := range wantFields {

View File

@@ -13,29 +13,17 @@ 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:"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"`
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."`
}
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
@@ -48,20 +36,15 @@ 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 {
SenderType string `json:"sender_type"`
SenderID struct {
SenderID struct {
OpenID string `json:"open_id"`
} `json:"sender_id"`
} `json:"sender"`
@@ -98,54 +81,7 @@ 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 ""
}
}

View File

@@ -84,32 +84,19 @@ 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",
"update_time": "1776409469999",
"content": "{\"text\":\"hello @_user_1\"}",
"mentions": [
{
"key": "@_user_1",
"id": {"open_id": "ou_mentioned"},
"name": "Alice"
}
]
"content": "{\"text\":\"hello there\"}"
}
}
}`
out := runReceive(t, payload)
outMap := runReceiveMap(t, payload)
if out.Type != "im.message.receive_v1" {
t.Errorf("Type = %q", out.Type)
@@ -123,69 +110,12 @@ func TestProcessImMessageReceive_Text(t *testing.T) {
if out.SenderID != "ou_sender" {
t.Errorf("SenderID = %q", out.SenderID)
}
if out.Content != "hello @Alice" {
t.Errorf("Content = %q, want \"hello @Alice\"", out.Content)
if out.Content != "hello there" {
t.Errorf("Content = %q, want \"hello there\"", 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) {
@@ -258,22 +188,3 @@ 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
}

View File

@@ -9,7 +9,6 @@ import (
"os"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
@@ -42,7 +41,10 @@ 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(core.ParseBrand(os.Getenv(envvars.CliBrand)))
brand := credential.Brand(os.Getenv(envvars.CliBrand))
if brand == "" {
brand = credential.BrandFeishu
}
acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand}
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {

View File

@@ -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, " LARK ")
t.Setenv(envvars.CliBrand, "feishu")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
}
if acct.AppID != "cli_test" || acct.AppSecret != "secret_test" || acct.Brand != "lark" {
if acct.AppID != "cli_test" || acct.AppSecret != "secret_test" || acct.Brand != "feishu" {
t.Errorf("unexpected: %+v", acct)
}
}

View File

@@ -16,7 +16,6 @@ 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"
)
@@ -59,7 +58,10 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
}
}
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
brand := credential.Brand(os.Getenv(envvars.CliBrand))
if brand == "" {
brand = credential.BrandFeishu
}
acct := &credential.Account{
AppID: appID,

View File

@@ -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)

4
go.mod
View File

@@ -10,11 +10,11 @@ require (
github.com/gofrs/flock v0.8.1
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.17
github.com/larksuite/oapi-sdk-go/v3 v3.7.2
github.com/larksuite/oapi-sdk-go/v3 v3.5.4
github.com/sergi/go-diff v1.4.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartystreets/goconvey v1.8.1
github.com/spf13/cobra v1.10.2 // typed flag errors are classified in cmd/root.go
github.com/spf13/cobra v1.10.2 // flag-error-text contract: see cmd/root.go unknownFlagName
github.com/spf13/pflag v1.0.9
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0

4
go.sum
View File

@@ -79,8 +79,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/larksuite/oapi-sdk-go/v3 v3.7.2 h1:SCIcXHRmtpQbiaZgDTDi1NYNCzrusi7ePJBR9uKoduE=
github.com/larksuite/oapi-sdk-go/v3 v3.7.2/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=

View File

@@ -77,15 +77,20 @@ func loadService(service string) map[string]json.RawMessage {
// space→dot fallback covers domains where the two already coincide.
func commandFormResolver(service string) func(string) string {
byForm := map[string]string{}
if svc, ok := registry.SchemaCatalog().Service(service); ok {
for _, svc := range registry.EmbeddedServicesTyped() {
if svc.Name != service {
continue
}
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
}
break
}
return func(h string) string {
if id, ok := byForm[strings.TrimSpace(h)]; ok {
h = strings.TrimSpace(h)
if id, ok := byForm[h]; ok {
return id
}
return headingToKey(h) // one home for the shortcut/method key convention
return strings.ReplaceAll(h, " ", ".")
}
}

View File

@@ -7,8 +7,6 @@ import (
"encoding/json"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/meta"
)
// fixtureMD is a minimal affordance source: two methods, each with a lead
@@ -86,38 +84,3 @@ func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
}
}
// The ### Skills section merges with the domain `> skill:` default: domain
// first, then per-command entries, de-duplicated. A command with no ### Skills
// still inherits the domain default.
func TestParseDomainMD_SkillsMerge(t *testing.T) {
md := "# d\n> skill: lark-d\n\n" +
"## foo\ndoes foo.\n\n### Skills\n- lark-workflow\n- lark-d\n\n" + // lark-d duplicates the domain default
"## bar\ndoes bar.\n"
got := parseDomainMD([]byte(md), nil)
if a := got["foo"]; len(a.Skills) != 2 || a.Skills[0] != "lark-d" || a.Skills[1] != "lark-workflow" {
t.Errorf("foo skills = %v, want [lark-d lark-workflow] (domain first, deduped)", a.Skills)
}
if a := got["bar"]; len(a.Skills) != 1 || a.Skills[0] != "lark-d" {
t.Errorf("bar skills = %v, want [lark-d] (domain default inherited)", a.Skills)
}
}
// A +-prefixed shortcut heading keys verbatim (no space->dot folding), so it
// matches the shortcut command as mounted.
func TestParseDomainMD_ShortcutHeadingVerbatim(t *testing.T) {
md := "# d\n\n## +create\ncreate via shortcut.\n"
got := parseDomainMD([]byte(md), nil)
if _, ok := got["+create"]; !ok {
t.Errorf("shortcut heading should key as %q; got keys %v", "+create", keysOf(got))
}
}
func keysOf(m map[string]meta.Affordance) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}

View File

@@ -19,7 +19,6 @@ import (
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
// ### Tips -> tips
// ### Examples -> examples: **description** + a ```fenced``` command
// ### Skills -> skills: bullet skill names, added to the domain default
// ### <other> -> extensions[] (custom section, flows through verbatim)
// [[cmd]] -> a command reference, rendered as `cmd`
//
@@ -35,56 +34,16 @@ var standardSection = map[string]string{
"Prerequisites": "prerequisites",
"Tips": "tips",
"Examples": "examples",
"Skills": "skills",
}
// mergeSkills returns the domain-default skill followed by a command's own skill
// entries, de-duplicated in author order and empties dropped. Backticks (left by
// the shared bullet parse) are stripped so each entry is a bare skill name.
func mergeSkills(domain string, extra []string) []string {
var out []string
seen := map[string]bool{}
add := func(s string) {
s = strings.Trim(strings.TrimSpace(s), "`")
if s == "" || seen[s] {
return
}
seen[s] = true
out = append(out, s)
}
add(domain)
for _, s := range extra {
add(s)
}
return out
}
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
// SkillStatPath maps a `### Skills` entry to the path (relative to the skill
// tree) whose existence gates it: a bare skill name resolves to its SKILL.md,
// while an entry containing a slash is a name/relative-path reference (e.g.
// "lark-contact/references/lark-contact-search-user.md") and resolves to that
// path directly. Both render as `lark-cli skills read <entry>` — the slash form
// skills read already accepts — so a per-command entry can point at that
// command's own reference file, not just re-point the domain skill.
func SkillStatPath(entry string) string {
if strings.Contains(entry, "/") {
return entry
}
return entry + "/SKILL.md"
}
// headingToKey maps a command heading ("instances get") to its affordance key
// ("instances.get"). The space→dot rule holds where the command form matches
// the method id; domains whose resource names differ (e.g. plural "messages"
// vs id segment "message") need the registry's authoritative resource↔id table.
func headingToKey(h string) string {
h = strings.TrimSpace(h)
if strings.HasPrefix(h, "+") { // shortcut command: key is the command verbatim
return h
}
return strings.ReplaceAll(h, " ", ".")
return strings.ReplaceAll(strings.TrimSpace(h), " ", ".")
}
type mdSection struct {
@@ -123,7 +82,6 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
if len(useWhen) > 0 {
a.UseWhen = useWhen
}
var perCmdSkills []string
for _, s := range secs {
switch standardSection[s.label] {
case "avoid_when":
@@ -134,14 +92,12 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
a.Tips = s.items
case "examples":
a.Examples = s.cases
case "skills":
perCmdSkills = s.items
default:
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
}
}
if s := mergeSkills(skill, perCmdSkills); len(s) > 0 {
a.Skills = s
if skill != "" {
a.Skills = []string{skill}
}
out[curKey] = a
}
@@ -201,7 +157,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
inFence, fence = true, nil
} else {
inFence = false
sec.cases = append(sec.cases, meta.AffordanceCase{Description: linkToBacktick(pending), Command: strings.Join(fence, "\n")})
sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")})
pending = ""
}
continue

View File

@@ -6,7 +6,6 @@ package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -17,46 +16,6 @@ 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
@@ -80,24 +39,15 @@ type AppRegUserInfo struct {
TenantBrand string // "feishu" or "lark"
}
// 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) {
// RequestAppRegistration initiates the app registration device flow.
func RequestAppRegistration(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)
endpoint := appRegistrationEndpoint(registrationBootstrapBrand)
regEp := core.ResolveEndpoints(core.BrandFeishu) // registration begin always uses feishu
endpoint := regEp.Accounts + PathAppRegistration
form := url.Values{}
form.Set("action", "begin")
@@ -105,7 +55,7 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand
form.Set("auth_method", "client_secret")
form.Set("request_user_info", "open_id tenant_brand")
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(form.Encode()))
req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
@@ -120,7 +70,7 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("app registration failed: read body: %w", err)
return nil, fmt.Errorf("app registration failed: read body: %v", err)
}
var data map[string]interface{}
@@ -140,26 +90,15 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand
return nil, fmt.Errorf("app registration failed: %s", msg)
}
// 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")
}
expiresIn := getInt(data, "expires_in", 300)
interval := getInt(data, "interval", 5)
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: deviceCode,
DeviceCode: getStr(data, "device_code"),
UserCode: getStr(data, "user_code"),
VerificationUri: verificationUri,
VerificationUriComplete: verificationUriComplete,
@@ -179,97 +118,72 @@ func BuildVerificationURL(baseURL, cliVersion string) string {
"&from=cli"
}
// 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) {
// 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) {
if errOut == nil {
errOut = io.Discard
}
// 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()
const maxPollInterval = 60
const maxPollAttempts = 200
currentBrand := registrationBootstrapBrand
effectiveBrand := currentBrand
switched := false
waitBeforePoll := false
ep := core.ResolveEndpoints(brand)
endpoint := ep.Accounts + PathAppRegistration
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
currentInterval := interval
attempts := 0
for {
if waitBeforePoll {
select {
case <-time.After(time.Duration(interval) * time.Second):
case <-ctx.Done():
return nil, effectiveBrand, registrationContextError(ctx)
}
}
waitBeforePoll = true
for time.Now().Before(deadline) && attempts < maxPollAttempts {
attempts++
if ctx.Err() != nil {
return nil, effectiveBrand, registrationContextError(ctx)
return nil, fmt.Errorf("polling was cancelled")
}
data, err := pollOnce(ctx, httpClient, currentBrand, resp.DeviceCode)
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()))
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: %v\n", err)
interval = minInt(interval+1, maxPollIntervalSeconds)
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)
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
}
}
}
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
}
errStr := getStr(data, "error")
if errStr == "" {
// Success: client_id present
if errStr == "" && getStr(data, "client_id") != "" {
result := &AppRegistrationResult{
ClientID: getStr(data, "client_id"),
ClientSecret: getStr(data, "client_secret"),
@@ -280,37 +194,34 @@ func RegisterAppWithDiscovery(ctx context.Context, httpClient *http.Client, resp
TenantBrand: getStr(userInfoRaw, "tenant_brand"),
}
}
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
return result, nil
}
switch errStr {
case "authorization_pending":
continue
case "slow_down":
interval = minInt(interval+5, maxPollIntervalSeconds)
fmt.Fprintf(errOut, "[lark-cli] app-registration: slow_down, interval increased to %ds\n", interval)
currentInterval = minInt(currentInterval+5, maxPollInterval)
fmt.Fprintf(errOut, "[lark-cli] app-registration: slow_down, interval increased to %ds\n", currentInterval)
continue
case "access_denied":
return nil, effectiveBrand, ErrRegistrationDenied
return nil, fmt.Errorf("app registration denied by user")
case "expired_token", "invalid_grant":
return nil, effectiveBrand, ErrRegistrationExpired
return nil, fmt.Errorf("device code expired, please try again")
}
desc := getStr(data, "error_description")
if desc == "" {
desc = errStr
}
return nil, effectiveBrand, fmt.Errorf("app registration failed: %s", desc)
if desc == "" {
desc = "Unknown error"
}
return nil, 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")
}

View File

@@ -4,28 +4,11 @@
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) {
@@ -48,358 +31,3 @@ 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 }

View File

@@ -2,11 +2,9 @@
// SPDX-License-Identifier: MIT
// Package cmdmeta is the single source of truth for command metadata that the
// policy engine, the hook selector, and help rendering consume. It wraps the
// existing cmdutil annotations (risk_level, supportedIdentities) and adds the
// "domain" axis that the hook selector and Rule path globs need, plus the
// affordance ref (service, method id) that lets service-method and shortcut
// help share one usage-guidance lookup path.
// policy engine and the hook selector both consume. It wraps the existing
// cmdutil annotations (risk_level, supportedIdentities) and adds the
// "domain" axis that the hook selector and Rule path globs need.
//
// Three axes:
//
@@ -53,12 +51,6 @@ const (
sourceAnnotationKey = "cmdmeta.source"
generatedAnnotationKey = "cmdmeta.generated"
// affordance{Service,Method}Key locate the command's usage-guidance overlay
// entry (see internal/affordance). Both service-method commands and
// +-prefixed shortcuts set these so help rendering shares one lookup path.
affordanceServiceKey = "cmdmeta.affordance.service"
affordanceMethodKey = "cmdmeta.affordance.method"
)
// Meta groups the three command-level metadata axes consumed by the policy
@@ -133,35 +125,6 @@ func SetSource(cmd *cobra.Command, source Source, generated bool) {
}
}
// SetAffordanceRef records which affordance overlay entry (service, method id)
// a command maps to, so help rendering can look up its usage guidance. Stored
// on the command itself (no inheritance): each method / shortcut owns its ref.
// A no-op if either coordinate is empty.
func SetAffordanceRef(cmd *cobra.Command, service, method string) {
if service == "" || method == "" {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[affordanceServiceKey] = service
cmd.Annotations[affordanceMethodKey] = method
}
// AffordanceRef returns the command's own affordance overlay coordinates.
// ok is false when the command carries no ref.
func AffordanceRef(cmd *cobra.Command) (service, method string, ok bool) {
if cmd.Annotations == nil {
return "", "", false
}
service = cmd.Annotations[affordanceServiceKey]
method = cmd.Annotations[affordanceMethodKey]
if service == "" || method == "" {
return "", "", false
}
return service, method, true
}
// Domain returns the nearest-ancestor domain for the command. Empty string
// when no ancestor has the annotation -- this is the "unknown" state the
// policy engine must treat as ALLOW.

View File

@@ -8,29 +8,15 @@ 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"`
@@ -40,21 +26,12 @@ 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
context *DryRunContext
extra map[string]interface{}
desc string
calls []DryRunAPICall
extra map[string]interface{}
}
func NewDryRunAPI() *DryRunAPI {
@@ -63,22 +40,30 @@ func NewDryRunAPI() *DryRunAPI {
// --- HTTP method builders (add a call, return self for chaining) ---
// 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})
func (d *DryRunAPI) GET(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "GET", URL: url})
return d
}
func (d *DryRunAPI) GET(url string) *DryRunAPI { return d.call("GET", url) }
func (d *DryRunAPI) POST(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "POST", URL: url})
return d
}
func (d *DryRunAPI) POST(url string) *DryRunAPI { return d.call("POST", url) }
func (d *DryRunAPI) PUT(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "PUT", URL: url})
return d
}
func (d *DryRunAPI) PUT(url string) *DryRunAPI { return d.call("PUT", url) }
func (d *DryRunAPI) DELETE(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "DELETE", 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) }
func (d *DryRunAPI) PATCH(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "PATCH", URL: url})
return d
}
// Body sets the request body on the last added call.
func (d *DryRunAPI) Body(body interface{}) *DryRunAPI {
@@ -113,26 +98,12 @@ 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 {
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))
})
for k, v := range d.extra {
rawURL = strings.ReplaceAll(rawURL, ":"+k, url.PathEscape(fmt.Sprintf("%v", v)))
}
return rawURL
}
// MarshalJSON serializes as {"description": "...", "api": [...calls with resolved URLs], ...extra}.
@@ -147,17 +118,13 @@ func (d *DryRunAPI) MarshalJSON() ([]byte, error) {
Body: c.Body,
}
}
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.
m := make(map[string]interface{}, len(d.extra)+2)
if d.desc != "" {
m["description"] = d.desc
}
m["api"] = resolved
if d.context != nil {
m["context"] = d.context
for k, v := range d.extra {
m[k] = v
}
return json.Marshal(m)
}
@@ -187,7 +154,11 @@ func (d *DryRunAPI) Format() string {
u += "?" + encodeParams(c.Params)
}
b.WriteString(c.Method)
method := c.Method
if method == "" {
method = "GET"
}
b.WriteString(method)
b.WriteByte(' ')
b.WriteString(u)
b.WriteByte('\n')
@@ -244,74 +215,83 @@ func encodeParams(params map[string]interface{}) string {
return vals.Encode()
}
// 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)
// 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)
}
if len(request.Params) > 0 {
dr.Params(request.Params)
}
// 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
filePathDisplay := filePath
if filePathDisplay == "" {
filePathDisplay = "<stdin>"
}
fileInfo := map[string]any{
"file": map[string]string{"field": file.FieldName, "path": filePathDisplay},
"file": map[string]string{"field": fileField, "path": filePathDisplay},
}
if file.FormFields != nil {
fileInfo["form_fields"] = file.FormFields
if formFields != nil {
fileInfo["form_fields"] = formFields
}
fileInfo["options"] = []string{"WithFileUpload"}
dr.Body(fileInfo)
return WriteDryRun(dr, opts)
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
}
// PrintDryRun outputs a standardised dry-run summary using DryRunAPI.
// When format is "pretty", outputs human-readable text; otherwise JSON.
func PrintDryRun(request client.RawApiRequest, config *core.CliConfig, opts DryRunOutputOptions) error {
dr := buildDryRunPreview(request, config)
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)
}
if !util.IsNil(request.Data) {
dr.Body(request.Data)
}
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")
dr.Set("as", string(request.As))
dr.Set("appId", config.AppID)
if config.UserOpenId != "" {
dr.Set("userOpenId", config.UserOpenId)
}
// 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
fmt.Fprintln(w, "=== Dry Run ===")
if format == "pretty" {
fmt.Fprint(w, dr.Format())
} else {
output.PrintJson(w, dr)
}
return output.WriteSuccessEnvelope(dr, output.SuccessEnvelopeOptions{
CommandPath: opts.CommandPath,
Identity: string(opts.Identity),
DryRun: true,
JqExpr: opts.JqExpr,
Out: opts.Out,
ErrOut: opts.ErrOut,
})
return nil
}

View File

@@ -6,12 +6,9 @@ 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"
)
@@ -69,31 +66,11 @@ 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("note", "audit")
Set("as", "user")
data, err := json.Marshal(dr)
if err != nil {
@@ -106,8 +83,8 @@ func TestDryRunAPI_MarshalJSON(t *testing.T) {
if m["description"] != "test api" {
t.Errorf("expected description, got: %v", m["description"])
}
if m["note"] != "audit" {
t.Errorf("expected note=audit, got: %v", m["note"])
if m["as"] != "user" {
t.Errorf("expected as=user, got: %v", m["as"])
}
api, ok := m["api"].([]interface{})
if !ok || len(api) != 1 {
@@ -146,67 +123,31 @@ func TestDryRunAPI_ExtraFieldsOnly(t *testing.T) {
func TestPrintDryRun_JSON(t *testing.T) {
var buf bytes.Buffer
var errBuf bytes.Buffer
err := PrintDryRun(client.RawApiRequest{
err := PrintDryRun(&buf, client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
As: "user",
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
Format: "json",
CommandPath: "lark-cli api",
Identity: core.AsUser,
Out: &buf,
ErrOut: &errBuf,
})
}, &core.CliConfig{AppID: "app123"}, "json")
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
out := buf.String()
if strings.Contains(out, "=== Dry Run ===") {
t.Fatalf("JSON stdout must not contain banner, got: %s", out)
if !strings.Contains(out, "=== Dry Run ===") {
t.Errorf("expected header, 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])
if !strings.Contains(out, "app123") {
t.Errorf("expected appId in output, got: %s", out)
}
}
func TestPrintDryRun_Pretty(t *testing.T) {
var buf bytes.Buffer
var errBuf bytes.Buffer
err := PrintDryRun(client.RawApiRequest{
err := PrintDryRun(&buf, client.RawApiRequest{
Method: "POST",
URL: "/open-apis/test",
Data: map[string]interface{}{"key": "val"},
As: "bot",
}, &core.CliConfig{AppID: "app456"}, DryRunOutputOptions{
Format: "pretty",
Identity: core.AsBot,
Out: &buf,
ErrOut: &errBuf,
})
}, &core.CliConfig{AppID: "app456"}, "pretty")
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
@@ -214,136 +155,6 @@ 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) {

View File

@@ -7,7 +7,6 @@ import (
"bytes"
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
@@ -129,7 +128,7 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
WithParam("--file").
WithCause(err)
}
fd.AddFileWithName(fieldName, filepath.Base(filePath), bytes.NewReader(data))
fd.AddFile(fieldName, bytes.NewReader(data))
}
// Add top-level JSON keys as text form fields.

View File

@@ -6,10 +6,12 @@ package cmdutil
import (
"context"
"net/http"
"os"
"reflect"
"runtime/debug"
"strings"
"sync"
"unicode"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
@@ -38,6 +40,8 @@ const (
BuildKindUnknown = "unknown"
officialModulePath = "github.com/larksuite/cli"
agentTraceMaxLen = 1024
)
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
@@ -45,6 +49,25 @@ func UserAgentValue() string {
return SourceValue + "/" + build.Version
}
// AgentTraceValue returns a header-safe value from the
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
// surrounding whitespace, rejects values containing any Unicode
// control character or exceeding agentTraceMaxLen, and returns ""
// for any invalid or empty value. Callers can use the result
// directly in HTTP headers without further sanitisation.
func AgentTraceValue() string {
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
if v == "" || len(v) > agentTraceMaxLen {
return ""
}
for _, r := range v {
if unicode.IsControl(r) {
return ""
}
}
return v
}
// BaseSecurityHeaders returns headers that every request must carry.
func BaseSecurityHeaders() http.Header {
h := make(http.Header)
@@ -52,7 +75,7 @@ func BaseSecurityHeaders() http.Header {
h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue())
if v := envvars.AgentTrace(); v != "" {
if v := AgentTraceValue(); v != "" {
h.Set(HeaderAgentTrace, v)
}
return h

View File

@@ -6,6 +6,7 @@ package cmdutil
import (
"context"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/extension/credential"
@@ -263,9 +264,88 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
}
// ---------------------------------------------------------------------------
// HeaderAgentTrace injection (via BaseSecurityHeaders)
// AgentTraceValue / HeaderAgentTrace
// ---------------------------------------------------------------------------
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
}
}
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
if got := AgentTraceValue(); got != "trace-abc-123" {
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
}
}
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
if got := AgentTraceValue(); got != "trace-trim" {
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
}
}
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " ")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
}
}
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
}
}
func TestAgentTraceValue_RejectsLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
}
}
func TestAgentTraceValue_RejectsTab(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
}
}
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
}
}
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
}
}
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentTraceMaxLen+1)
t.Setenv(envvars.CliAgentTrace, longVal)
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
}
}
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
val := strings.Repeat("a", agentTraceMaxLen)
t.Setenv(envvars.CliAgentTrace, val)
if got := AgentTraceValue(); got != val {
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
}
}
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
h := BaseSecurityHeaders()

View File

@@ -268,7 +268,7 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
ProfileName: app.ProfileName(),
AppID: app.AppId,
AppSecret: secret,
Brand: ParseBrand(string(app.Brand)),
Brand: app.Brand,
Lang: app.Lang,
DefaultAs: app.DefaultAs,
}

View File

@@ -230,20 +230,3 @@ 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)
}
}

View File

@@ -3,11 +3,9 @@
package core
import "strings"
// LarkBrand represents the Lark platform brand.
// "feishu" targets China-mainland, "lark" targets international.
// ParseBrand and ResolveEndpoints map unrecognized values to BrandFeishu.
// Any other string is treated as a custom base URL.
type LarkBrand string
const (
@@ -15,10 +13,10 @@ const (
BrandLark LarkBrand = "lark"
)
// ParseBrand normalizes a brand string (case-insensitive, whitespace-tolerant);
// anything other than "lark" normalizes to BrandFeishu.
// ParseBrand normalizes a brand string to a LarkBrand constant.
// Unrecognized values default to BrandFeishu.
func ParseBrand(value string) LarkBrand {
if strings.ToLower(strings.TrimSpace(value)) == "lark" {
if value == "lark" {
return BrandLark
}
return BrandFeishu
@@ -38,10 +36,9 @@ type Endpoints struct {
AppLink string // e.g. "https://applink.feishu.cn"
}
// ResolveEndpoints resolves endpoint URLs for the brand, normalizing its
// input so stored values with unusual casing still resolve correctly.
// ResolveEndpoints resolves endpoint URLs based on brand.
func ResolveEndpoints(brand LarkBrand) Endpoints {
switch ParseBrand(string(brand)) {
switch brand {
case BrandLark:
return Endpoints{
Open: "https://open.larksuite.com",

View File

@@ -57,37 +57,3 @@ 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)
}
}

View File

@@ -72,8 +72,7 @@ func AccountFromCliConfig(cfg *core.CliConfig) *Account {
}
}
// ToCliConfig copies the credential-layer account into the downstream config
// shape, normalizing the brand so runtime consumers never see raw casing.
// ToCliConfig copies the credential-layer account into the downstream config shape.
func (a *Account) ToCliConfig() *core.CliConfig {
if a == nil {
return nil
@@ -82,7 +81,7 @@ func (a *Account) ToCliConfig() *core.CliConfig {
ProfileName: a.ProfileName,
AppID: a.AppID,
AppSecret: normalizeAccountAppSecret(a.AppSecret),
Brand: core.ParseBrand(string(a.Brand)),
Brand: a.Brand,
DefaultAs: a.DefaultAs,
UserOpenId: a.UserOpenId,
UserName: a.UserName,

View File

@@ -130,11 +130,3 @@ 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)
}
}

View File

@@ -19,7 +19,6 @@ const (
// Content safety scanning mode
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"

View File

@@ -1,36 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package envvars
import (
"os"
"strings"
"unicode"
)
const (
agentNameMaxLen = 128
agentTraceMaxLen = 1024
)
func AgentName() string {
return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen)
}
func AgentTrace() string {
return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen)
}
func sanitizeSingleLine(raw string, maxLen int) string {
v := strings.TrimSpace(raw)
if v == "" || len(v) > maxLen {
return ""
}
for _, r := range v {
if unicode.IsControl(r) {
return ""
}
}
return v
}

View File

@@ -1,131 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package envvars
import (
"strings"
"testing"
)
func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(CliAgentName, "")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty when env unset", got)
}
}
func TestAgentName_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentName, "claude-code")
if got := AgentName(); got != "claude-code" {
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
}
}
func TestAgentName_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentName, " cursor ")
if got := AgentName(); got != "cursor" {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
}
}
func TestAgentName_RejectsCRLFInjection(t *testing.T) {
t.Setenv(CliAgentName, "agent\r\nX-Evil: attack")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty for CR/LF value", got)
}
}
func TestAgentName_RejectsControlChar(t *testing.T) {
t.Setenv(CliAgentName, "agent\x01injected")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty for control char value", got)
}
}
func TestAgentName_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentNameMaxLen+1)
t.Setenv(CliAgentName, longVal)
if got := AgentName(); got != "" {
t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen)
}
}
func TestAgentTrace_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(CliAgentTrace, "")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty when env unset", got)
}
}
func TestAgentTrace_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentTrace, "trace-abc-123")
if got := AgentTrace(); got != "trace-abc-123" {
t.Fatalf("AgentTrace() = %q, want %q", got, "trace-abc-123")
}
}
func TestAgentTrace_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentTrace, " trace-trim ")
if got := AgentTrace(); got != "trace-trim" {
t.Fatalf("AgentTrace() = %q, want %q (whitespace trimmed)", got, "trace-trim")
}
}
func TestAgentTrace_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
t.Setenv(CliAgentTrace, " ")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for whitespace-only value", got)
}
}
func TestAgentTrace_RejectsCRLF(t *testing.T) {
t.Setenv(CliAgentTrace, "val\r\nX-Evil: attack")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for CR/LF value", got)
}
}
func TestAgentTrace_RejectsLF(t *testing.T) {
t.Setenv(CliAgentTrace, "val\nX-Evil: attack")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for LF value", got)
}
}
func TestAgentTrace_RejectsTab(t *testing.T) {
t.Setenv(CliAgentTrace, "val\tinjected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for tab value", got)
}
}
func TestAgentTrace_RejectsControlChar(t *testing.T) {
t.Setenv(CliAgentTrace, "val\x01injected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for control char value", got)
}
}
func TestAgentTrace_RejectsDEL(t *testing.T) {
t.Setenv(CliAgentTrace, "val\x7finjected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for DEL value", got)
}
}
func TestAgentTrace_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentTraceMaxLen+1)
t.Setenv(CliAgentTrace, longVal)
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
}
}
func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) {
val := strings.Repeat("a", agentTraceMaxLen)
t.Setenv(CliAgentTrace, val)
if got := AgentTrace(); got != val {
t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
}
}

View File

@@ -10,14 +10,12 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// ClassifyContext is the contextual data BuildAPIError uses to populate
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
// Brand and Identity are plain strings at this boundary; ConsoleURL normalizes
// Brand through core.ParseBrand, so callers can pass a raw brand string without
// coupling this contract to core's brand enum.
// Identity is a plain string ("user" / "bot" / "") so this package does not
// depend on internal/core (which would create an import cycle).
type ClassifyContext struct {
Brand string // "feishu" | "lark" — drives console_url host
AppID string // placed in console_url
@@ -446,27 +444,28 @@ func extractMissingScopes(resp map[string]any) []string {
return out
}
// ConsoleURL composes the Feishu/Lark open-platform application-scope apply
// page URL (the official open-pages `/page/scope-apply` entry), suitable for
// PermissionError.ConsoleURL. Empty appID → empty string. Empty scopes list
// returns the page carrying only clientID; otherwise scopes are joined with
// commas in the `scopes` query parameter so the console can pre-select them.
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
// scopes list returns the bare /auth landing page; scopes are joined with
// commas in the `q` query parameter so the console can pre-select them.
//
// brand is "feishu" or "lark"; unknown values default to feishu.
func ConsoleURL(brand, appID string, scopes []string) string {
if appID == "" {
return ""
}
// QueryEscape both values — clientID and scopes both sit in the query
// string, and untrusted content must not be able to inject extra query
// parameters via `&`/`#`. The brand→host mapping is owned by core so the
// open-platform base URL stays a single source of truth.
base := fmt.Sprintf("%s/page/scope-apply?clientID=%s",
core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID))
if len(scopes) == 0 {
return base
host := "open.feishu.cn"
if brand == "lark" {
host = "open.larksuite.com"
}
return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ","))
// PathEscape on appID — it sits in the URL path. QueryEscape on the
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
// content must not be able to inject extra query parameters via `&`/`#`.
pathID := url.PathEscape(appID)
if len(scopes) == 0 {
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
}
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
}
func intFromAny(v any) int {

View File

@@ -113,7 +113,6 @@ 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"},
@@ -423,8 +422,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/page/scope-apply?clientID=cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.feishu.cn scope-apply page", pe.ConsoleURL)
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
}
}
@@ -435,8 +434,8 @@ func TestConsoleURL_LarkBrand(t *testing.T) {
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.larksuite.com scope-apply page", pe.ConsoleURL)
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
}
}
@@ -486,35 +485,35 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
name: "ampersand in scope smuggles extra param",
appID: "cli_good",
scopes: []string{"scope&evil=injected"},
wantInURL: []string{"scopes=scope%26evil%3Dinjected"},
denyInURL: []string{"scopes=scope&evil=injected"},
wantInURL: []string{"q=scope%26evil%3Dinjected"},
denyInURL: []string{"q=scope&evil=injected"},
},
{
name: "hash in scope splits fragment",
appID: "cli_good",
scopes: []string{"scope#fragment"},
wantInURL: []string{"scopes=scope%23fragment"},
denyInURL: []string{"scopes=scope#fragment"},
wantInURL: []string{"q=scope%23fragment"},
denyInURL: []string{"q=scope#fragment"},
},
{
name: "question mark in appID prematurely opens query",
appID: "good?q=injected",
scopes: []string{"docx:document"},
wantInURL: []string{"clientID=good%3Fq%3Dinjected"},
denyInURL: []string{"clientID=good?q=injected"},
wantInURL: []string{"/app/good%3Fq=injected/auth"},
denyInURL: []string{"/app/good?q=injected/auth"},
},
{
name: "hash in appID truncates URL",
appID: "good#fragment",
scopes: []string{"docx:document"},
wantInURL: []string{"clientID=good%23fragment"},
denyInURL: []string{"clientID=good#fragment"},
wantInURL: []string{"/app/good%23fragment/auth"},
denyInURL: []string{"/app/good#fragment/auth"},
},
{
name: "slash in appID does not open a new path segment",
name: "slash in appID escapes path segment",
appID: "good/extra/segment",
scopes: []string{"docx:document"},
wantInURL: []string{"clientID=good%2Fextra%2Fsegment"},
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
},
}
@@ -554,8 +553,8 @@ func TestPermissionError_NoViolations(t *testing.T) {
if pe.MissingScopes != nil {
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
}
if !strings.HasSuffix(pe.ConsoleURL, "/page/scope-apply?clientID=cli_a123") {
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /page/scope-apply?clientID=cli_a123", pe.ConsoleURL)
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
}
}
@@ -759,7 +758,7 @@ func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
// at the app level — re-authenticating cannot fix it. The hint must
// point to the developer console regardless of caller identity, or
// agents will loop on `auth login` forever.
consoleURL := "https://open.feishu.cn/page/scope-apply?clientID=cli_x&scopes=contact%3Acontact"
consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact"
for _, identity := range []string{"user", "bot", ""} {
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
if !strings.Contains(got, "developer console") {

View File

@@ -10,23 +10,20 @@ import "github.com/larksuite/cli/errs"
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var driveCodeMeta = map[int]CodeMeta{
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
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
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
233523001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive/docs transient server error
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
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
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
}
func init() { mergeCodeMeta(driveCodeMeta, "drive") }

View File

@@ -114,35 +114,8 @@ func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
{1061101, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
{233523001, errs.CategoryAPI, errs.SubtypeServerError, true},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
got, ok := LookupCodeMeta(tc.code)
if !ok {
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
}
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
}
})
}
}
func TestLookupCodeMeta_WikiCodes(t *testing.T) {
cases := []struct {
code int
wantCat errs.Category
wantSubtype errs.Subtype
wantRetry bool
}{
{131002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{131005, errs.CategoryAPI, errs.SubtypeNotFound, false},
{131006, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {

View File

@@ -1,17 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// wikiCodeMeta holds wiki-service Lark code -> CodeMeta mappings observed from
// wiki shortcut failure telemetry. Keep these to wiki-wide meanings only; add
// command-specific recovery guidance at the shortcut layer.
var wikiCodeMeta = map[int]CodeMeta{
131002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // param err: space_id is not int / invalid page_token
131005: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // wiki node / space not found
131006: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // wiki space/node read permission denied
}
func init() { mergeCodeMeta(wikiCodeMeta, "wiki") }

View File

@@ -8,11 +8,8 @@ import "encoding/json"
// Affordance is the typed usage guidance overlaid on a method. It is the single
// model the envelope renderer and the command help both parse, so the
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
// Skills entries are either a bare skill name (e.g. "lark-doc") or a
// name/relative-path reference (e.g. "lark-contact/references/x.md"); both
// render as runnable `lark-cli skills read <entry>` pointers. Help validates
// each against the embedded skill tree (a name → its SKILL.md, a reference →
// that path) and drops any that do not resolve.
// Skills entries are skill names (or name/path) rendered as runnable
// `lark-cli skills read <entry>` pointers.
type Affordance struct {
UseWhen []string `json:"use_when,omitempty"`
AvoidWhen []string `json:"avoid_when,omitempty"`

View File

@@ -1,61 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"slices"
"strings"
"github.com/larksuite/cli/errs"
)
// FormatCapabilities is the single description of the output formats a
// command supports. Help text, shell completion, shorthand normalization and
// runtime validation all consume the same value so they cannot drift apart.
type FormatCapabilities struct {
names []string
}
var (
// StandardFormats applies to API, service and ordinary shortcut commands.
StandardFormats = NewFormatCapabilities("json", "pretty", "table", "ndjson", "csv")
// JSONPrettyFormats applies to commands with a dedicated human renderer and
// no streaming/tabular output, such as auth scopes.
JSONPrettyFormats = NewFormatCapabilities("json", "pretty")
)
// NewFormatCapabilities constructs an immutable format capability set.
func NewFormatCapabilities(names ...string) FormatCapabilities {
return FormatCapabilities{names: append([]string(nil), names...)}
}
// Names returns a copy suitable for completion candidates.
func (c FormatCapabilities) Names() []string {
return append([]string(nil), c.names...)
}
// Usage returns the canonical help text for a --format flag.
func (c FormatCapabilities) Usage() string {
return "output format: " + strings.Join(c.names, "|")
}
// Supports reports whether name is part of this command's output contract.
func (c FormatCapabilities) Supports(name string) bool {
return slices.Contains(c.names, strings.ToLower(name))
}
// Resolve applies the --json shorthand and validates the selected format.
// An explicit --format always wins over --json.
func (c FormatCapabilities) Resolve(format string, formatExplicit, jsonShorthand bool) (string, error) {
if jsonShorthand && !formatExplicit {
format = "json"
}
format = strings.ToLower(format)
if c.Supports(format) {
return format, nil
}
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported output format %q; supported formats: %s", format, strings.Join(c.names, ", ")).
WithParam("--format")
}

View File

@@ -1,53 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"errors"
"reflect"
"testing"
"github.com/larksuite/cli/errs"
)
func TestFormatCapabilitiesResolve(t *testing.T) {
tests := []struct {
name string
format string
formatExplicit bool
jsonShorthand bool
want string
wantErr bool
}{
{name: "default", format: "json", want: "json"},
{name: "json shorthand", format: "table", jsonShorthand: true, want: "json"},
{name: "explicit format wins", format: "table", formatExplicit: true, jsonShorthand: true, want: "table"},
{name: "case normalized", format: "PRETTY", formatExplicit: true, want: "pretty"},
{name: "unsupported", format: "xml", formatExplicit: true, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := StandardFormats.Resolve(tt.format, tt.formatExplicit, tt.jsonShorthand)
if tt.wantErr {
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--format" {
t.Fatalf("Resolve() error = %T %v, want invalid_argument --format validation error", err, err)
}
return
}
if err != nil || got != tt.want {
t.Fatalf("Resolve() = %q, %v; want %q, nil", got, err, tt.want)
}
})
}
}
func TestFormatCapabilitiesDriveHelpAndCompletion(t *testing.T) {
if got, want := StandardFormats.Usage(), "output format: json|pretty|table|ndjson|csv"; got != want {
t.Fatalf("Usage() = %q, want %q", got, want)
}
if got, want := StandardFormats.Names(), []string{"json", "pretty", "table", "ndjson", "csv"}; !reflect.DeepEqual(got, want) {
t.Fatalf("Names() = %v, want %v", got, want)
}
}

View File

@@ -7,7 +7,6 @@ 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"`

View File

@@ -9,7 +9,6 @@ import "io"
type SuccessEnvelopeOptions struct {
CommandPath string
Identity string
DryRun bool
JqExpr string
Out io.Writer
ErrOut io.Writer
@@ -42,7 +41,6 @@ func WriteSuccessEnvelope(data interface{}, opts SuccessEnvelopeOptions) error {
env := Envelope{
OK: true,
Identity: opts.Identity,
DryRun: opts.DryRun,
Data: data,
Notice: GetNotice(),
}

View File

@@ -104,47 +104,6 @@ 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{

View File

@@ -103,9 +103,6 @@ func ExtractItems(data interface{}) []interface{} {
func FormatValue(w io.Writer, data interface{}, format Format) {
data = toGeneric(data)
switch format {
case FormatPretty:
PrintJson(w, data)
case FormatNDJSON:
items := ExtractItems(data)
if items != nil {
@@ -152,9 +149,6 @@ func NewPaginatedFormatter(w io.Writer, format Format) *PaginatedFormatter {
// FormatPage formats one page of items.
func (pf *PaginatedFormatter) FormatPage(data interface{}) {
switch pf.Format {
case FormatPretty:
PrintJson(pf.W, data)
case FormatJSON, FormatNDJSON:
if arr, ok := data.([]interface{}); ok {
PrintNdjson(pf.W, arr)

View File

@@ -73,30 +73,6 @@ func TestFormatValue_Table(t *testing.T) {
}
}
func TestFormatValue_Pretty(t *testing.T) {
data := map[string]interface{}{
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"name": "Alice"},
},
},
}
var buf bytes.Buffer
FormatValue(&buf, data, FormatPretty)
out := buf.String()
if !json.Valid([]byte(out)) {
t.Fatalf("pretty output should be valid JSON, got:\n%s", out)
}
if !strings.Contains(out, "\n \"data\": {") || !strings.Contains(out, `"name": "Alice"`) {
t.Fatalf("pretty output should be indented JSON, got:\n%s", out)
}
if strings.Contains(out, "─") {
t.Fatalf("pretty output should not render a table, got:\n%s", out)
}
}
func TestFormatValue_CSV(t *testing.T) {
data := map[string]interface{}{
"data": map[string]interface{}{
@@ -173,20 +149,6 @@ func TestPaginatedFormatter_Table(t *testing.T) {
}
}
func TestPaginatedFormatter_Pretty(t *testing.T) {
var buf bytes.Buffer
pf := NewPaginatedFormatter(&buf, FormatPretty)
pf.FormatPage([]interface{}{map[string]interface{}{"name": "Alice"}})
out := buf.String()
if !json.Valid([]byte(out)) {
t.Fatalf("paginated pretty output should be valid JSON, got:\n%s", out)
}
if !strings.Contains(out, "\n {") || !strings.Contains(out, `"name": "Alice"`) {
t.Fatalf("paginated pretty output should be indented JSON, got:\n%s", out)
}
}
func TestPaginatedFormatter_CSV(t *testing.T) {
var buf bytes.Buffer
pf := NewPaginatedFormatter(&buf, FormatCSV)

View File

@@ -10,7 +10,6 @@ type Format int
const (
FormatJSON Format = iota
FormatPretty
FormatNDJSON
FormatTable
FormatCSV
@@ -23,8 +22,6 @@ func ParseFormat(s string) (Format, bool) {
switch strings.ToLower(s) {
case "json", "":
return FormatJSON, true
case "pretty":
return FormatPretty, true
case "ndjson":
return FormatNDJSON, true
case "table":
@@ -39,8 +36,6 @@ func ParseFormat(s string) (Format, bool) {
// String returns the string representation of a Format.
func (f Format) String() string {
switch f {
case FormatPretty:
return "pretty"
case FormatNDJSON:
return "ndjson"
case FormatTable:

View File

@@ -14,9 +14,6 @@ func TestParseFormat(t *testing.T) {
{"json", FormatJSON, true},
{"JSON", FormatJSON, true},
{"Json", FormatJSON, true},
{"pretty", FormatPretty, true},
{"PRETTY", FormatPretty, true},
{"Pretty", FormatPretty, true},
{"ndjson", FormatNDJSON, true},
{"NDJSON", FormatNDJSON, true},
{"Ndjson", FormatNDJSON, true},
@@ -55,7 +52,6 @@ func TestFormatString(t *testing.T) {
want string
}{
{FormatJSON, "json"},
{FormatPretty, "pretty"},
{FormatNDJSON, "ndjson"},
{FormatTable, "table"},
{FormatCSV, "csv"},

View File

@@ -11,7 +11,6 @@ import (
rootcmd "github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/registry"
@@ -95,7 +94,7 @@ func commandFromCobra(c *cobra.Command, defaultFields map[string][]string) manif
Short: c.Short,
Example: c.Example,
Hidden: c.Hidden,
Runnable: c.Runnable() && !cmdpolicy.IsPureGroup(c),
Runnable: c.Runnable(),
Source: source,
Generated: cmdmeta.Generated(c),
Identities: cmdmeta.Identities(c),

View File

@@ -90,20 +90,6 @@ func TestCollectContainsDocsFetchAndDryRunFlag(t *testing.T) {
}
}
func TestCollectMarksPureNavigationGroupsNonRunnable(t *testing.T) {
got, err := collectCommandIndex(context.Background())
if err != nil {
t.Fatalf("collectCommandIndex() error = %v", err)
}
cmd := findManifestCommand(&got, "approval")
if cmd == nil {
t.Fatalf("approval group not found")
}
if cmd.Runnable {
t.Fatalf("approval is a navigation group and must not be exported as runnable")
}
}
func TestCollectExcludesGeneratedServiceCommands(t *testing.T) {
got, err := collectHandAuthored(context.Background())
if err != nil {

View File

@@ -9,15 +9,12 @@ 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,
@@ -68,8 +65,6 @@ 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,
@@ -375,13 +370,6 @@ 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)

View File

@@ -7,7 +7,6 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"math"
"path/filepath"
"sort"
"strings"
@@ -79,15 +78,12 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
}
for _, match := range credentialURLRE.FindAllString(line, -1) {
if isPlaceholderCredentialURL(file, match) {
if isPlaceholderCredentialURL(match) {
continue
}
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
}
for _, match := range privateIPv4RE.FindAllString(line, -1) {
if !warnForPrivateIPv4(file) {
continue
}
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
}
if source == "branch" && automationBranchRE.MatchString(line) {
@@ -134,9 +130,6 @@ func isCredentialAssignmentMatch(match string) bool {
if isBenignTokenField(name) && !credentialShapedValue(value) {
return false
}
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
return false
}
return isExplicitCredentialKey(name)
}
@@ -291,9 +284,6 @@ func tokenLikePlaceholderValue(key, value string) bool {
if normalized == "" || credentialShapedIdentifier(normalized) {
return false
}
if authCredentialTokenKey(key) {
return false
}
return resourceTokenPlaceholderValue(value) ||
maskedTokenFixturePlaceholderValue(key, normalized) ||
isPlaceholderValue(value) ||
@@ -323,109 +313,11 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
return stars >= 6 && alnum > 0
}
func isWeakTokenCredentialKey(key string) bool {
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
return false
}
return key == "token" ||
strings.HasSuffix(key, "_token") ||
strings.HasSuffix(key, "-token")
}
func isStrongTokenCredentialKey(key string) bool {
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
for _, phrase := range [][2]string{
{"access", "token"},
{"refresh", "token"},
{"auth", "token"},
{"bearer", "token"},
{"session", "token"},
{"service", "token"},
{"bot", "token"},
{"api", "token"},
{"secret", "token"},
} {
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
return true
}
}
return false
}
func weakTokenValueLooksCredentialLike(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
if normalized == "" ||
isNonSecretLiteralValue(value) ||
isPlaceholderValue(value) {
return false
}
candidate := unwrapCredentialValue(normalized)
return credentialShapedIdentifier(candidate) ||
highEntropyCredentialValue(candidate) ||
commandSubstitutionLooksCredentialLike(normalized) ||
(strings.Contains(normalized, "://") &&
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
}
func unwrapCredentialValue(value string) string {
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
}
value = strings.TrimPrefix(value, "$")
value = strings.Trim(value, "%")
return strings.TrimSpace(value)
}
func highEntropyCredentialValue(value string) bool {
if len(value) < 32 {
return false
}
var hasLetter, hasDigit bool
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
hasLetter = true
case r >= '0' && r <= '9':
hasDigit = true
case r == '_' || r == '-' || r == '.' || r == '=':
default:
return false
}
}
return hasLetter && hasDigit && shannonEntropy(value) >= 3.5
}
func shannonEntropy(value string) float64 {
if value == "" {
return 0
}
counts := map[rune]int{}
for _, r := range value {
counts[r]++
}
var entropy float64
length := float64(len([]rune(value)))
for _, count := range counts {
p := float64(count) / length
entropy -= p * log2(p)
}
return entropy
}
func log2(value float64) float64 {
return math.Log(value) / math.Ln2
}
func authCredentialTokenKey(key string) bool {
switch strings.ReplaceAll(strings.ToLower(key), "-", "_") {
case "access_token",
"api_token",
"bot_token",
"refresh_token",
"secret_token",
"session_token",
"service_token",
"bearer_token",
"auth_token",
"authorization_token",
@@ -952,7 +844,7 @@ func looksLikeEqualityComparison(value string) bool {
return strings.HasPrefix(strings.TrimSpace(value), "=")
}
func isPlaceholderCredentialURL(file, raw string) bool {
func isPlaceholderCredentialURL(raw string) bool {
userInfo, ok := credentialURLUserInfo(raw)
if !ok {
return false
@@ -961,8 +853,7 @@ func isPlaceholderCredentialURL(file, raw string) bool {
if !ok {
return false
}
return credentialURLPasswordPlaceholder(password) ||
(sourceOrTestFixtureFile(file) && credentialURLPasswordFixture(password))
return credentialURLPasswordPlaceholder(password)
}
func credentialURLPasswordPlaceholder(password string) bool {
@@ -976,46 +867,6 @@ func credentialURLPasswordPlaceholder(password string) bool {
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
}
func credentialURLPasswordFixture(password string) bool {
normalized := strings.ToLower(strings.Trim(password, `"'`))
switch normalized {
case "p",
"pass",
"password",
"pat_abc",
"pw",
"s3cret",
"secret",
"t":
return true
default:
return false
}
}
func sourceOrTestFixtureFile(file string) bool {
normalized := filepath.ToSlash(file)
return sourceCodeFile(normalized) ||
strings.HasPrefix(normalized, "testdata/") ||
strings.HasPrefix(normalized, "fixtures/") ||
strings.Contains(normalized, "/testdata/") ||
strings.Contains(normalized, "/fixtures/")
}
func warnForPrivateIPv4(file string) bool {
normalized := filepath.ToSlash(file)
if sourceOrTestFixtureFile(normalized) {
return false
}
switch filepath.Ext(normalized) {
case ".md", ".mdx", ".txt", ".json", ".yaml", ".yml", ".toml", ".env":
return true
default:
return strings.HasPrefix(normalized, "docs/") ||
strings.HasPrefix(normalized, "skills/")
}
}
func credentialURLUserInfo(raw string) (string, bool) {
schemeIdx := strings.Index(raw, "://")
if schemeIdx < 0 {

View File

@@ -61,19 +61,6 @@ func TestScanFileWarnsForPrivateIPv4Examples(t *testing.T) {
}
}
func TestScanFileAllowsPrivateIPv4SourceFixtures(t *testing.T) {
got := ScanFile("internal/transport/warn_test.go", []byte(strings.Join([]string{
`proxy := "http://user:pass@10.0.0.1:3128"`,
`target := "socks5://admin:secret@172.16.0.1:1080"`,
`host := "192.168.0.10"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_private_ipv4" {
t.Fatalf("private IPv4 source fixtures should not be public content findings: %#v", got)
}
}
}
func TestSemanticCandidateRequiresSpecificRiskSignals(t *testing.T) {
benign := semanticCandidate("docs/network.md", "file", "For a local lab, use RFC1918 example host 192.168."+"0.10 only.", 1)
if len(benign) != 0 {
@@ -645,45 +632,6 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
}
}
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
`proxy := "http://user:pass@proxy:8080"`,
`repo := "https://u:t@h/r.git"`,
`target := "https://attacker:pw@open.feishu.cn"`,
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
`repo := "http://x-token:PAT_abc@git.host/app_x.git"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_credential_url" {
t.Fatalf("credential URL fixtures should not be credential URL findings: %#v", got)
}
}
}
func TestScanFileAllowsRootCredentialURLFixtures(t *testing.T) {
got := ScanFile("fixtures/network.md", []byte(strings.Join([]string{
`proxy: http://user:pass@proxy:8080`,
`repo: https://u:t@h/r.git`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_credential_url" {
t.Fatalf("root credential URL fixtures should not be credential URL findings: %#v", got)
}
}
}
func TestScanFileAllowsRootPrivateIPv4Fixtures(t *testing.T) {
got := ScanFile("testdata/network.md", []byte(strings.Join([]string{
`endpoint: http://10.0.0.1:8080`,
`redis: 192.168.1.10:6379`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_private_ipv4" {
t.Fatalf("root private IPv4 fixtures should not be private IPv4 findings: %#v", got)
}
}
}
func TestScanFileDetectsCredentialURLsWithRedactedSubstringPasswords(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n"))
for _, item := range got {
@@ -700,7 +648,6 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
"DATABASE_URL=postgres://<user>:real-secret@example.invalid/db",
"DATABASE_URL=postgres://<user>:" + stripeLike + "@example.invalid/db",
"URL=https://<user>:real-secret@example.invalid/path",
"REPO=https://x-token:" + stripeLike + "@git.host/app.git",
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -714,8 +661,8 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
}
}
}
if count != 4 {
t.Fatalf("placeholder-user credential URL findings = %d, want 4: %#v", count, got)
if count != 3 {
t.Fatalf("placeholder-user credential URL findings = %d, want 3: %#v", count, got)
}
}
@@ -777,68 +724,6 @@ func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) {
}
}
func TestScanFileAllowsWeakTokenFieldsWithoutCredentialEvidence(t *testing.T) {
got := ScanFile("docs/resource-tokens.md", []byte(strings.Join([]string{
`{"token":"img_abc123"}`,
`{"token":"img_live_secret"}`,
`{"token":"img_prod_key"}`,
`token=ab********cd`,
`{"image_token":"img_live_secret"}`,
`{"data_mail_token":"mail_abc123"}`,
`{"whiteboard_token":"board_v3_example"}`,
`{"want_token":"token from callback"}`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("weak token fields without credential evidence should not be credential findings: %#v", got)
}
}
}
func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *testing.T) {
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
stripeToken := "sk_" + "live_1234567890abcdef"
randomToken := strings.Join([]string{
"a1b2c3d4",
"e5f6g7h8",
"i9j0k1l2",
"m3n4p5q6",
}, "")
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
`{"token":"` + githubToken + `"}`,
`token=` + stripeToken,
`{"image_token":"` + githubToken + `"}`,
`{"token":"` + randomToken + `"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("high-confidence weak token credential findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
`{"access_token":"img_abc123"}`,
`{"api_token":"img_live_secret"}`,
`{"service_token":"ab********cd"}`,
`{"bot_token":"board_v3_example"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
for _, item := range got {
@@ -1167,12 +1052,10 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
}
}
func TestScanFileAllowsNonFixtureResourceTokenValues(t *testing.T) {
func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) {
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("resource-like bare token value should not be credential finding: %#v", got)
}
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("non-fixture minute token should be credential finding: %#v", got)
}
}

View File

@@ -878,23 +878,16 @@ func extractDryRunJSON(raw []byte) (facts.DryRunRequest, int, error) {
var firstErr error
for start >= 0 {
var preview struct {
API []facts.DryRunRequest `json:"api"`
Data struct {
API []facts.DryRunRequest `json:"api"`
} `json:"data"`
API []facts.DryRunRequest `json:"api"`
}
dec := json.NewDecoder(bytes.NewReader(raw[start:]))
if err := dec.Decode(&preview); err == nil {
api := preview.API
if len(api) == 0 {
api = preview.Data.API
}
if len(api) == 0 {
if len(preview.API) == 0 {
if firstErr == nil {
firstErr = errNoDryRunAPI
}
} else {
return api[0], len(api), nil
return preview.API[0], len(preview.API), nil
}
} else if firstErr == nil {
firstErr = err

View File

@@ -33,17 +33,6 @@ 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))

View File

@@ -6,7 +6,8 @@ package registry
import "github.com/larksuite/cli/internal/apicatalog"
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
// metadata — deterministic across machines, for golden tests and schema lint.
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
// and schema lint.
func EmbeddedCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
}
@@ -17,14 +18,3 @@ func EmbeddedCatalog() apicatalog.Catalog {
func RuntimeCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
}
// SchemaCatalog returns the embedded catalog when metadata is compiled in,
// otherwise the merged runtime catalog. Binaries built from the bare Go module
// embed only the empty meta_data_default.json stub, so the embedded view has
// nothing to resolve; the merged view is the only data such binaries have.
func SchemaCatalog() apicatalog.Catalog {
if len(EmbeddedServicesTyped()) > 0 {
return EmbeddedCatalog()
}
return RuntimeCatalog()
}

View File

@@ -1,67 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/larksuite/cli/internal/apicatalog"
)
// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and
// restores them (with a full state reset) on cleanup.
func swapEmbeddedMeta(t *testing.T, data []byte) {
t.Helper()
resetInit()
orig := embeddedMetaJSON
embeddedMetaJSON = data
t.Cleanup(func() {
waitBackgroundRefresh()
embeddedMetaJSON = orig
resetInit()
})
}
func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) {
swapEmbeddedMeta(t, testCacheJSON("embedded_svc"))
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
c := SchemaCatalog()
if c.Source() != apicatalog.SourceEmbedded {
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded)
}
if _, ok := c.Service("embedded_svc"); !ok {
t.Fatal("expected embedded_svc from embedded metadata")
}
}
// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built
// from the bare Go module (plugin builds): only the empty meta_data_default.json
// stub is compiled in, so SchemaCatalog must serve the merged runtime view that
// Init seeds via sync fetch.
func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) {
swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write(testEnvelopeJSON("remote_svc"))
}))
defer ts.Close()
testMetaURL = ts.URL
c := SchemaCatalog()
if c.Source() != apicatalog.SourceRuntime {
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime)
}
if _, ok := c.Service("remote_svc"); !ok {
t.Fatal("expected remote_svc from runtime fallback")
}
}

View File

@@ -15,7 +15,6 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/update"
)
//go:embed scope_priorities.json scope_overrides.json
@@ -69,12 +68,6 @@ 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).
@@ -92,9 +85,7 @@ func InitWithBrand(brand core.LarkBrand) {
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
if !brandChanged {
// After a CLI upgrade the embedded data can be fresher than an old
// cache; an equal/older cache must not shadow it.
if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) {
if cached, err := loadCachedMerged(); err == nil {
overlayMergedServices(cached)
}
}

View File

@@ -1,102 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
)
// seedCache writes a cache file + cache meta for one service whose Title is
// marker, tagged with the given top-level data version and brand.
func seedCache(t *testing.T, dir, name, marker, version, brand string) {
t.Helper()
cDir := filepath.Join(dir, "cache")
if err := os.MkdirAll(cDir, 0700); err != nil {
t.Fatal(err)
}
reg := MergedRegistry{
Version: version,
Services: []meta.Service{{Name: name, Version: "cache", Title: marker}},
}
data, _ := json.Marshal(reg)
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil {
t.Fatal(err)
}
cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand}
mData, _ := json.Marshal(cm)
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil {
t.Fatal(err)
}
}
// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a
// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a
// pre-seeded cache at cacheVer — the overlay version gate is the only variable.
func initWithCache(t *testing.T, embeddedVer, cacheVer string) {
t.Helper()
embedded, _ := json.Marshal(MergedRegistry{
Version: embeddedVer,
Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}},
})
swapEmbeddedMeta(t, embedded)
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu")
InitWithBrand(core.BrandFeishu)
}
func titleOf(t *testing.T, name string) string {
t.Helper()
svc, ok := ServiceTyped(name)
if !ok {
t.Fatalf("service %q not loaded", name)
}
return svc.Title
}
func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) {
initWithCache(t, "1.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got)
}
}
func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) {
initWithCache(t, "2.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("older cache: got %q, want EMBEDDED", got)
}
}
func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) {
initWithCache(t, "1.0.0", "2.0.0")
if got := titleOf(t, "svc"); got != "CACHE" {
t.Errorf("newer cache: got %q, want CACHE", got)
}
}
func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) {
initWithCache(t, "1.0.0", "not-a-semver")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("unparseable cache version: got %q, want EMBEDDED", got)
}
}
func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) {
// The bare-module stub baseline is "0.0.0"; a real cache version must win so
// plugin builds without compiled meta_data.json still get remote data.
initWithCache(t, "0.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "CACHE" {
t.Errorf("stub-embedded baseline: got %q, want CACHE", got)
}
}

View File

@@ -248,18 +248,10 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
allowSet := LoadOverrideAutoApproveAllow()
// 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))
// 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))
}
}

View File

@@ -75,7 +75,13 @@ func remoteMetaURL(version string) string {
if testMetaURL != "" {
return testMetaURL
}
base := core.ResolveEndpoints(configuredBrand).Open + "/api/tools/open/api_definition"
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"
}
q := "protocol=meta&client_version=" + url.QueryEscape(build.Version)
if version != "" {
q += "&data_version=" + url.QueryEscape(version)

View File

@@ -72,11 +72,9 @@ func hasEmbeddedServices() bool {
}
// testRegistry returns a minimal MergedRegistry with one service.
// The version is a real semver newer than the embedded stub baseline ("0.0.0")
// so cache overlay passes the version gate in InitWithBrand.
func testRegistry(name string) MergedRegistry {
return MergedRegistry{
Version: "1.0.0",
Version: "test-1.0",
Services: []meta.Service{
{
Name: name,
@@ -162,7 +160,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
}
func TestCacheHit_WithinTTL(t *testing.T) {
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
resetInit()
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
@@ -199,7 +197,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
}
func TestNetworkError_SilentDegradation(t *testing.T) {
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
resetInit()
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
@@ -373,8 +371,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
if data == nil {
t.Fatal("expected non-nil data")
}
if reg.Version != "1.0.0" {
t.Errorf("expected version 1.0.0, got %s", reg.Version)
if reg.Version != "test-1.0" {
t.Errorf("expected version test-1.0, got %s", reg.Version)
}
}

View File

@@ -59,9 +59,13 @@ func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string {
if appID == "" || scope == "" {
return ""
}
host := "open.feishu.cn"
if brand == core.BrandLark {
host = "open.larksuite.com"
}
return fmt.Sprintf(
"%s/page/scope-apply?clientID=%s&scopes=%s",
core.ResolveOpenBaseURL(brand),
"https://%s/page/scope-apply?clientID=%s&scopes=%s",
host,
url.QueryEscape(appID),
url.QueryEscape(scope),
)

View File

@@ -12,9 +12,7 @@
"vc:meeting.meetingevent:read": 75
},
"recommend": {
"allow": [
"application:app_slash_command:read"
],
"allow": [],
"deny": [
"im:chat",
"im:message.send_as_user"

View File

@@ -3,10 +3,6 @@
"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 页面和应用" }

View File

@@ -16,7 +16,6 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/vfs"
)
@@ -33,7 +32,6 @@ type InstallMethod int
const (
InstallNpm InstallMethod = iota
InstallPnpm
InstallManual
)
@@ -50,39 +48,27 @@ const (
var (
skillsIndexFetchTimeout = 10 * time.Second
// officialSkillsIndexURL overrides the brand-derived skills index URL in
// tests; empty in production.
officialSkillsIndexURL = ""
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
)
// DetectResult holds installation detection results.
type DetectResult struct {
Method InstallMethod
ResolvedPath string
NpmAvailable bool
PnpmAvailable bool
Method InstallMethod
ResolvedPath string
NpmAvailable bool
}
// CanAutoUpdate returns true if the CLI can update itself automatically.
func (d DetectResult) CanAutoUpdate() bool {
switch d.Method {
case InstallNpm:
return d.NpmAvailable
case InstallPnpm:
return d.PnpmAvailable
}
return false
return d.Method == InstallNpm && d.NpmAvailable
}
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
func (d DetectResult) ManualReason() string {
switch {
case d.Method == InstallNpm && !d.NpmAvailable:
if d.Method == InstallNpm && !d.NpmAvailable {
return "installed via npm, but npm is not available in PATH"
case d.Method == InstallPnpm && !d.PnpmAvailable:
return "installed via pnpm, but pnpm is not available in PATH"
}
return "not installed via npm or pnpm"
return "not installed via npm"
}
// NpmResult holds the result of an npm install or skills update execution.
@@ -104,12 +90,8 @@ 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
SkillsIndexFetchOverride func() *NpmResult
SkillsCommandOverride func(args ...string) *NpmResult
VerifyOverride func(expectedVersion string) error
@@ -119,51 +101,17 @@ type Updater struct {
// running binary is successfully renamed to .old. Used by
// CanRestorePreviousVersion to report whether rollback is possible.
backupCreated bool
// detectCache memoizes the first real DetectInstallMethod result. How this
// binary was installed cannot change during a single process, so caching is
// the correct semantics — and it is required for correctness: the update
// flow mutates the install (pnpm add -g / npm install -g) before syncing
// skills, so a re-detection at skills time could resolve a now-stale
// os.Executable path and misclassify. Seeded pre-update by the first call
// (updateRun), it keeps the post-update skills launcher consistent with the
// launcher reported to the user. Not goroutine-safe; the update flow is
// sequential.
detectCache *DetectResult
}
// 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.
// DetectInstallMethod determines how the CLI was installed and whether
// npm is available for auto-update.
func (u *Updater) DetectInstallMethod() DetectResult {
if u.DetectOverride != nil {
return u.DetectOverride()
}
if u.detectCache != nil {
return *u.detectCache
}
result := u.detectInstallMethod()
u.detectCache = &result
return result
}
// detectInstallMethod performs the real (uncached) detection.
func (u *Updater) detectInstallMethod() DetectResult {
exe, err := vfs.Executable()
if err != nil {
return DetectResult{Method: InstallManual}
@@ -172,54 +120,24 @@ func (u *Updater) detectInstallMethod() DetectResult {
if err != nil {
return DetectResult{Method: InstallManual, ResolvedPath: exe}
}
_, npmErr := exec.LookPath("npm")
_, pnpmErr := exec.LookPath("pnpm")
return detectFromResolved(resolved, npmErr == nil, pnpmErr == nil)
}
// detectFromResolved classifies the resolved binary path into an install
// method and records package-manager availability. Split out from
// DetectInstallMethod so the classification is unit-testable without touching
// the filesystem or PATH.
func detectFromResolved(resolved string, npmOnPath, pnpmOnPath bool) DetectResult {
method := InstallManual
if strings.Contains(resolved, "node_modules") {
if containsPnpmMarker(resolved) {
method = InstallPnpm
} else {
method = InstallNpm
}
method = InstallNpm
}
d := DetectResult{Method: method, ResolvedPath: resolved}
switch method {
case InstallNpm:
d.NpmAvailable = npmOnPath
case InstallPnpm:
d.PnpmAvailable = pnpmOnPath
}
return d
}
// containsPnpmMarker reports whether the resolved binary path belongs to a
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
// (a ".pnpm" directory segment) and the global content-addressable store,
// whose resolved path runs through pnpm's home directory (e.g.
// "~/Library/pnpm/store/v11/links/...") — a "pnpm" segment immediately
// followed by "store". Matching only these two shapes (rather than any bare
// "pnpm" segment) avoids misclassifying an npm install that merely lives under
// a directory named "pnpm". Windows separators are normalized to "/" so the
// classification is OS-independent and unit-testable anywhere.
func containsPnpmMarker(p string) bool {
parts := strings.Split(strings.ReplaceAll(p, `\`, "/"), "/")
for i, part := range parts {
if part == ".pnpm" {
return true
}
if part == "pnpm" && i+1 < len(parts) && parts[i+1] == "store" {
return true
npmAvailable := false
if method == InstallNpm {
if _, err := exec.LookPath("npm"); err == nil {
npmAvailable = true
}
}
return false
return DetectResult{
Method: method,
ResolvedPath: resolved,
NpmAvailable: npmAvailable,
}
}
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
@@ -245,29 +163,6 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
return r
}
// RunPnpmInstall executes pnpm add -g @larksuite/cli@<version>.
func (u *Updater) RunPnpmInstall(version string) *NpmResult {
if u.PnpmInstallOverride != nil {
return u.PnpmInstallOverride(version)
}
r := &NpmResult{}
pnpmPath, err := exec.LookPath("pnpm")
if err != nil {
r.Err = fmt.Errorf("pnpm not found in PATH: %w", err)
return r
}
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, pnpmPath, "add", "-g", NpmPackage+"@"+version)
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
r.Err = fmt.Errorf("pnpm install timed out after %s", npmInstallTimeout)
}
return r
}
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
if u.SkillsIndexFetchOverride != nil {
return u.SkillsIndexFetchOverride()
@@ -277,7 +172,7 @@ func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
ctx, cancel := context.WithTimeout(context.Background(), skillsIndexFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.skillsIndexURL(), nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, officialSkillsIndexURL, nil)
if err != nil {
r.Err = err
return r
@@ -316,7 +211,7 @@ func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
}
func (u *Updater) ListOfficialSkills() *NpmResult {
r := u.runSkillsListOfficial(u.skillsSource())
r := u.runSkillsListOfficial("https://open.feishu.cn")
if r.Err != nil {
r = u.runSkillsListOfficial("larksuite/cli")
}
@@ -332,7 +227,7 @@ func (u *Updater) ListGlobalSkillsJSON() *NpmResult {
}
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
r := u.runSkillsInstall(u.skillsSource(), nameList)
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
if r.Err != nil {
r = u.runSkillsInstall("larksuite/cli", nameList)
}
@@ -340,7 +235,7 @@ func (u *Updater) InstallSkill(nameList []string) *NpmResult {
}
func (u *Updater) InstallAllSkills() *NpmResult {
r := u.runSkillsAdd(u.skillsSource())
r := u.runSkillsAdd("https://open.feishu.cn")
if r.Err != nil {
r = u.runSkillsAdd("larksuite/cli")
}
@@ -366,40 +261,19 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
return u.runSkillsCommand(args...)
}
// skillsInvocation decides how to launch the `skills` CLI. When the lark-cli
// itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so
// pnpm-only environments (pnpm's standalone installer bundles Node without
// putting npm/npx on PATH) can still sync skills after a self-update.
// Otherwise it uses `npx`. The npx auto-confirm flag "-y", when present as the
// leading arg, maps to `pnpm dlx`'s default non-interactive behavior and is
// dropped for the pnpm launcher. Kept pure (no exec/PATH access) so the
// launcher selection is unit-testable on any platform.
func skillsInvocation(method InstallMethod, pnpmAvailable bool, args []string) (launcher string, rest []string) {
if method == InstallPnpm && pnpmAvailable {
r := args
if len(r) > 0 && r[0] == "-y" {
r = r[1:]
}
return "pnpm", append([]string{"dlx"}, r...)
}
return "npx", args
}
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
if u.SkillsCommandOverride != nil {
return u.SkillsCommandOverride(args...)
}
r := &NpmResult{}
det := u.DetectInstallMethod()
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
binPath, err := exec.LookPath(launcher)
npxPath, err := exec.LookPath("npx")
if err != nil {
r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
return r
}
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
cmd := exec.CommandContext(ctx, npxPath, args...)
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()

View File

@@ -17,7 +17,6 @@ import (
"testing"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/vfs"
)
@@ -372,167 +371,3 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
}
}
func TestContainsPnpmMarker(t *testing.T) {
cases := []struct {
path string
want bool
}{
// Classic virtual-store layout (.pnpm segment).
{"/Users/x/Library/pnpm/global/5/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true},
{`C:\Users\x\AppData\Local\pnpm\global\5\node_modules\.pnpm\@larksuite+cli@1.0.44\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
// Global content-addressable store layout (pnpm 11): resolved path runs
// through the pnpm home store, a "pnpm" segment with no ".pnpm".
{"/Users/x/Library/pnpm/store/v11/links/@larksuite/cli/1.0.59/abc123/node_modules/@larksuite/cli/bin/lark-cli", true},
{"/home/x/.local/share/pnpm/store/v10/@larksuite/cli/node_modules/@larksuite/cli/bin/lark-cli", true},
{`C:\Users\x\AppData\Local\pnpm\store\v11\links\@larksuite\cli\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
// npm and non-package installs — no pnpm/.pnpm segment.
{"/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
{"/usr/local/bin/lark-cli", false},
// Substrings that must NOT match: segment must be exactly .pnpm, or
// "pnpm" immediately followed by "store".
{"/opt/homebrew/.pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
{"/opt/pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
// A bare "pnpm" directory NOT followed by "store" (e.g. an npm install
// living under a dir named pnpm) must not be misclassified as pnpm.
{"/opt/pnpm/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
}
for _, c := range cases {
if got := containsPnpmMarker(c.path); got != c.want {
t.Errorf("containsPnpmMarker(%q) = %v, want %v", c.path, got, c.want)
}
}
}
func TestDetectInstallMethod_Pnpm(t *testing.T) {
u := &Updater{DetectOverride: nil}
u.DetectOverride = func() DetectResult {
// Exercise the real classification by feeding a resolved path via a small shim.
return detectFromResolved("/x/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true, true)
}
got := u.DetectInstallMethod()
if got.Method != InstallPnpm {
t.Errorf("Method = %v, want InstallPnpm", got.Method)
}
if !got.PnpmAvailable {
t.Errorf("PnpmAvailable = false, want true")
}
}
func TestDetectInstallMethod_NpmVsManual(t *testing.T) {
if m := detectFromResolved("/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", true, false).Method; m != InstallNpm {
t.Errorf("npm path Method = %v, want InstallNpm", m)
}
if m := detectFromResolved("/usr/local/bin/lark-cli", false, false).Method; m != InstallManual {
t.Errorf("manual path Method = %v, want InstallManual", m)
}
}
func TestCanAutoUpdate_Pnpm(t *testing.T) {
if !(DetectResult{Method: InstallPnpm, PnpmAvailable: true}).CanAutoUpdate() {
t.Error("pnpm available should CanAutoUpdate")
}
if (DetectResult{Method: InstallPnpm, PnpmAvailable: false}).CanAutoUpdate() {
t.Error("pnpm unavailable should not CanAutoUpdate")
}
}
func TestManualReason_Pnpm(t *testing.T) {
if got := (DetectResult{Method: InstallPnpm, NpmAvailable: false, PnpmAvailable: false}).ManualReason(); got != "installed via pnpm, but pnpm is not available in PATH" {
t.Errorf("pnpm reason = %q", got)
}
if got := (DetectResult{Method: InstallManual}).ManualReason(); got != "not installed via npm or pnpm" {
t.Errorf("manual reason = %q", got)
}
}
func TestRunPnpmInstall_Override(t *testing.T) {
u := &Updater{PnpmInstallOverride: func(version string) *NpmResult {
r := &NpmResult{}
r.Stdout.WriteString("added @larksuite/cli@" + version)
return r
}}
got := u.RunPnpmInstall("2.0.0")
if got.Err != nil {
t.Fatalf("unexpected err: %v", got.Err)
}
if !strings.Contains(got.CombinedOutput(), "2.0.0") {
t.Errorf("output = %q, want version echoed", got.CombinedOutput())
}
}
func TestRunPnpmInstall_Error(t *testing.T) {
wantErr := errors.New("boom")
u := &Updater{PnpmInstallOverride: func(string) *NpmResult { return &NpmResult{Err: wantErr} }}
if got := u.RunPnpmInstall("2.0.0"); !errors.Is(got.Err, wantErr) {
t.Errorf("err = %v, want %v", got.Err, wantErr)
}
}
func TestSkillsInvocation(t *testing.T) {
addArgs := []string{"-y", "skills", "add", "https://open.feishu.cn", "-g", "-y"}
cases := []struct {
name string
method InstallMethod
pnpmAvailable bool
args []string
wantLauncher string
wantRest []string
}{
{"pnpm install + pnpm available → pnpm dlx, drop leading -y", InstallPnpm, true, addArgs,
"pnpm", []string{"dlx", "skills", "add", "https://open.feishu.cn", "-g", "-y"}},
{"pnpm install but pnpm unavailable → npx unchanged", InstallPnpm, false, addArgs,
"npx", addArgs},
{"npm install → npx unchanged", InstallNpm, false, addArgs,
"npx", addArgs},
{"manual install → npx unchanged", InstallManual, false, []string{"-y", "skills", "ls", "-g"},
"npx", []string{"-y", "skills", "ls", "-g"}},
{"pnpm without a leading -y → prepend dlx only", InstallPnpm, true, []string{"skills", "ls", "-g"},
"pnpm", []string{"dlx", "skills", "ls", "-g"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotLauncher, gotRest := skillsInvocation(c.method, c.pnpmAvailable, c.args)
if gotLauncher != c.wantLauncher {
t.Errorf("launcher = %q, want %q", gotLauncher, c.wantLauncher)
}
if strings.Join(gotRest, " ") != strings.Join(c.wantRest, " ") {
t.Errorf("rest = %v, want %v", gotRest, c.wantRest)
}
})
}
}
// TestDetectInstallMethod_Caches locks the fix for the post-update re-detection
// hazard: DetectInstallMethod must return the first (pre-update) detection on
// subsequent calls, so the skills launcher chosen after the binary is replaced
// stays consistent with what was detected — and reported — before the update.
func TestDetectInstallMethod_Caches(t *testing.T) {
u := New()
cached := DetectResult{Method: InstallPnpm, PnpmAvailable: true, ResolvedPath: "/x/pnpm/store/v11/links/@larksuite/cli/1.0.0/node_modules/@larksuite/cli/bin/lark-cli"}
u.detectCache = &cached
got := u.DetectInstallMethod()
if got.Method != InstallPnpm || !got.PnpmAvailable {
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)
}
}
}

Some files were not shown because too many files have changed in this diff Show More