Compare commits

...

57 Commits

Author SHA1 Message Date
liangshuo-1
69cf9f206e chore: release v1.0.7 (#375)
Change-Id: I0568fc87795a821802fe793802fc64ac55def6d6
2026-04-09 21:35:34 +08:00
wittam-01
99b8aaa556 feat: improve doc media extension inference (#364)
Change-Id: Ifc7c0e7844908b88e2d527e0933d080b140a50eb
2026-04-09 21:11:47 +08:00
kongenpei
b4a26b2cdc fix(base): unify --json help format with tips and agent hints (#372)
* fix(base): improve --json help examples and group guide

* fix(base): unify --json help tips format

* docs(base): fix view-set-group schema with group_config

* fix(base): remove array wording from view-set-group json help

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-09 21:06:56 +08:00
liangshuo-1
619ec8c2cb fix(api): support stdin and quoted JSON inputs on Windows (#367)
* fix(api): add stdin and single-quote support for --params/--data on Windows (#64)

Windows PowerShell 5.x mangles JSON double-quotes when passing arguments
to native executables, causing --params and --data to fail with
"invalid JSON format". This commit adds two mitigations at the framework
level:

- stdin piping: `echo '{"k":"v"}' | lark-cli --params -` bypasses
  shell argument parsing entirely and works on all platforms/shells.
- single-quote stripping: cmd.exe passes literal single quotes which
  are now transparently removed before JSON parsing.

Implementation:
- New `cmdutil.ResolveInput(raw, stdin)` handles `-` (stdin), strip
  surrounding `'...'`, and plain passthrough.
- `ParseJSONMap` and `ParseOptionalBody` now accept an `io.Reader` and
  delegate to `ResolveInput` before JSON unmarshalling.
- `cmd/api` and `cmd/service` pass `IOStreams.In` and guard against
  simultaneous stdin usage by --params and --data.
- Empty stdin is rejected with a clear error message.

Closes #64

Change-Id: If21e735d0aed5c6a2d6674c1e6c898186fca3aba

* test: add stdin e2e regression coverage

Change-Id: I4e00bf1c6b6f3259f503e3414cae10fa4b34ba75
2026-04-09 19:10:50 +08:00
liujinkun2025
eb3c643f0b route base import guidance to drive +import (#368)
Change-Id: I12f86a343d79b8fb480084305ed34b54aa92fb94
2026-04-09 18:22:05 +08:00
wittam-01
37747177fc feat: auto-grant current user access for bot-created docs, sheets, imports, and uploads (#360)
Change-Id: Idf5b35dbf77d72788895e0a3c34563281d658c88
2026-04-09 18:06:47 +08:00
河伯
9d48ef422b fix(doc): post-process docs +fetch output to improve round-trip fidelity (#214) 2026-04-09 17:57:41 +08:00
tuxedomm
e64d24580a refactor: migrate mail shortcuts to FileIO (#356)
* refactor: migrate mail shortcuts to FileIO

- DraftSnapshot.FIO: inject FileIO into draft snapshot for patch ops
  (addAttachment, loadAndAttachInline, replaceInline)
- emlbuilder.Builder.fio: inject via WithFileIO(), readFile uses FileIO.Open
- mail_draft_edit: loadPatchFile uses runtime.FileIO().Open
- helpers: checkAttachmentSizeLimit takes fio param, uses FileIO.Stat
- validateComposeInlineAndAttachments: pass fio through to size check
- All mail entry points (send/reply/reply_all/forward/draft_create):
  pass runtime.FileIO() to builder and size limit checks
2026-04-09 17:40:30 +08:00
sang-neo03
3db4f42ab8 fix(run): add missing binary check for lark-cli execution (#362) 2026-04-09 17:09:53 +08:00
tuxedomm
0bf4f80ef4 refactor: migrate drive/doc/sheets shortcuts to FileIO (#339)
* refactor: migrate drive/doc/sheets shortcuts to FileIO

- drive_download/upload/import/export: SafeInputPath/SafeOutputPath +
  vfs.Stat/Open/MkdirAll + AtomicWrite → FileIO.Stat/Open/Save
- doc_media_download/insert/upload: same migration pattern
- sheet_export: same migration pattern
- Add Mode() fs.FileMode to fileio.FileInfo for IsRegular() checks
- Add WrapInputStatError helper to preserve error message fidelity
- Add WrapSaveErrorByCategory for standardized save error mapping
2026-04-09 16:34:59 +08:00
feng zhi hao
284e5b6606 feat(mail): add send_as alias support, mailbox/sender discovery APIs, and mail rules API
New capabilities:

  1. Alias (send_as) sending for all compose shortcuts (+send, +reply, +reply-all,
     +forward, +draft-create, +draft-edit):
     - New --mailbox flag separates mailbox routing from sender identity, enabling
       alias sending where --mailbox specifies the owning mailbox and --from
       specifies the alias address in the From header.
     - Example: --mailbox me --from alias@example.com --to bob@example.com
     - --mailbox priority: --mailbox > --from > "me"
     - --from priority: --from > --mailbox > profile("me")

  2. Discovery APIs for available mailboxes and sender addresses:
     - accessible_mailboxes: lists all mailboxes the user can access (primary + shared)
     - send_as: lists available sender addresses for a mailbox (primary, aliases, mailing lists)

  3. Mail rules API:
     - user_mailbox.rules resource: create, delete, list, reorder, update

  4. Reply-all self-exclusion improvement:
     fetchSelfEmailSet now also excludes the --from alias address, preventing the
     sender from appearing in the recipient list when replying via an alias.

  No breaking changes — omitting --mailbox preserves existing behavior.
2026-04-09 14:52:20 +08:00
maochengwei1024-create
af83e5495b fix(config): validate appId and appSecret keychain key consistency (#295)
When config.json is hand-edited, the appId field can become out of sync
with the appSecret keychain reference (e.g. appId changed but
appSecret.id still points to the old app). This causes silent auth
failures at API call time. Add a pre-flight check in
ResolveConfigFromMulti that compares the two before any keychain lookup
or OAPI request, failing fast with actionable guidance.

Change-Id: I74b9ab640642dde3df1ad70890b93b91ee422022
2026-04-09 12:05:24 +08:00
tuxedomm
a3bced3ee5 refactor: migrate base shortcuts to FileIO (#347)
* refactor: migrate base shortcuts to FileIO

- loadJSONInput: SafeInputPath + vfs.ReadFile → fio.Open + io.ReadAll
- parseJSONObject/parseJSONArray/parseJSONValue/parseObjectList/
  parseStringListFlexible: add fio param, pass through to loadJSONInput
- parseStringList: inline comma-split (no longer depends on fio)
- record_upload_attachment: SafeInputPath + vfs.Stat → FileIO.Stat
  with ErrPathValidation check; vfs.Open → FileIO.Open
- All ops files pass runtime.FileIO() to parse helpers
2026-04-09 11:54:58 +08:00
max
35108e1798 feat(vc): extract note doc tokens from calendar event relation API (#333)
* feat(vc): extract meeting_notes and ai_meeting_notes from calendar event relation API

* test(vc): add tests for calendar-to-notes dedup and fallback logic

* fix(vc): address review findings for calendar-to-notes dedup and table output

* refactor(vc): remove ai_meeting_notes concept and simplify dedup logic
2026-04-09 11:20:58 +08:00
tuxedomm
30b97e1bdd chore: add depguard and forbidigo rules to guide FileIO adoption (#342)
- Add depguard linter to block shortcuts/ from importing internal/vfs
  directly (must use runtime.FileIO() instead)
- Add forbidigo rules for os.* filesystem ops, IO streams, os.Exit,
  and filepath.* functions that bypass vfs
- Split os.Remove / os.RemoveAll into separate patterns with accurate
  guidance (RemoveAll not yet in vfs)
- Use compact regex groups for maintainability, no duplicate or
  shadowed patterns

Change-Id: I9e45ab07ca58a61b86bdcea9f1f2cc6181c974bc
2026-04-09 10:56:17 +08:00
liujinkun2025
2715b560b7 add wiki node create shortcut (#320)
Change-Id: I4810fc541c31ae9e3e08539d4b1c91d01f53b7f5
2026-04-09 00:06:57 +08:00
caojie0621
15bd134f5c feat: add sheets +write-image shortcut (#343) 2026-04-08 23:59:39 +08:00
wittam-01
daa21731ad feat: add docs media-preview shortcut (#334)
Change-Id: I5db9e52008e175f975838c8a9c03254afa30f52b
2026-04-08 23:52:24 +08:00
wittam-01
9fab62bf00 feat: add support for additional search filters (#353)
Change-Id: Ib5b06e2df513a835a79a295c45ef1637413afa4e
2026-04-08 23:42:13 +08:00
liangshuo-1
cdd9f9ab49 chore: add missing license headers (#352)
Change-Id: Ic26bedcbb111331eb53d695fccdabd0907a6272f
2026-04-08 23:11:01 +08:00
ygxs
d5d31f0ee4 docs(lark-doc): document advanced boolean and intitle search syntax for AI agents (#210)
Change-Id: I647ffad4579c503711a7ea220c390dca760cd6de
2026-04-08 22:01:10 +08:00
yaozhen00
67cb0a961e ci: add license-header check (#250)
* ci: add license-header check
2026-04-08 21:57:59 +08:00
liangshuo-1
aa4076a7cc docs: add v1.0.6 changelog (#348)
Change-Id: Ic5a1d128c9ec903c0e1a9a673f7da8340e775dc0
2026-04-08 21:57:20 +08:00
JackZhao10086
db9ca5c2a4 feat: improve login scope validation and success output (#317)
* feat(auth): improve scope handling and output in login flow

- Add scope validation to check for missing requested scopes
- Implement detailed scope breakdown in login success output
- Add new message strings for scope-related output
- Refactor login success output to handle both JSON and text formats
- Add tests for scope validation and output scenarios

* feat(auth): add requested scope caching for device code login

Implement caching of requested scopes during device code login flow to ensure proper scope validation after authorization. The cache is stored in JSON files under config directory and automatically cleaned up after successful or failed authorization.

Add tests for scope caching functionality and verify proper integration with existing login flow.

* docs(auth): add function comments for login scope handling

Add detailed doc comments to all functions in login scope cache and result handling files to improve code documentation and maintainability.

* refactor(auth): remove pending scopes and improve json output stability

- Remove PendingScopes field and related logic as it's no longer needed
- Add emptyIfNil helper to ensure nil slices are normalized to empty slices in JSON output
- Update tests to verify JSON output stability and fix expected text outputs

* refactor(auth): extract device token polling function for testability

Move device token polling to a package-level variable to enable mocking in tests
Add test case for scope cleanup when token is nil

* fix(auth): return JSON write errors instead of ignoring them

Previously, JSON write errors were only logged to stderr but not returned, causing tests to pass when they should fail. Now properly propagate these errors to callers and update tests to verify error handling.

* refactor(auth): simplify scope handling and improve user messaging

remove redundant scope display and consolidate hint messages to focus on actionable guidance

* refactor(auth): improve scope handling and messaging in login flow

remove ShortHint field and simplify scope hint messages
always display missing scopes section with consistent formatting
add StatusHint for successful login with no missing scopes
update tests to reflect new message structure and content
2026-04-08 21:06:58 +08:00
ILUO
1f8d4b211d feat(task): support starting pagination from page token (#332) 2026-04-08 21:06:43 +08:00
tuxedomm
63ea52b2e6 refactor: migrate vc/minutes shortcuts to FileIO (#336)
* refactor: migrate vc/minutes shortcuts to FileIO

- vc_notes: replace vfs.Stat + validate.SafeOutputPath + validate.AtomicWrite
  with FileIO.Stat/Save for transcript download
- minutes_download: replace validate.SafeOutputPath + validate.AtomicWriteFromReader
  with FileIO.Save, use FileIO.Stat for overwrite checks
- Use WrapSaveError to preserve original error messages
2026-04-08 19:34:19 +08:00
liangshuo-1
555722ac8e fix: resolve concurrency races in RuntimeContext (#330)
* fix: resolve concurrency races in RuntimeContext

- getAPIClient: replace check-then-act with sync.OnceValues, matching
  the factory_default.go convention; use NewAPIClientWithConfig to avoid
  post-construction config override; fall back to direct construction
  for test contexts that bypass newRuntimeContext.

- outputErr: guard first-error capture with sync.Once to prevent data
  races if Out() is ever called from concurrent goroutines.

Change-Id: I99c94c3dcb7663fa61571c9720163e41a5fc0e36

* fix: use tenant token for auth scopes

Change-Id: I83bb677e9a33e906e207679b2ba8d0364bc20fe3
2026-04-08 19:14:45 +08:00
tuxedomm
f5a8fbf8f1 refactor: migrate common/client/im to FileIO and add localfileio tests (#322)
* refactor: migrate common/client/im to FileIO and add localfileio tests

- runner resolveInputFlags: replace validate.SafeInputPath + vfs.ReadFile
  with FileIO.Open + io.ReadAll
- SaveResponse: delegate to FileIO.Save + ResolvePath
- cmd/api, cmd/service: pass FileIO to ResponseOptions
- im: replace validate.SafeLocalFlagPath with RuntimeContext.ValidatePath,
  migrate download/upload to FileIO.Save/Open/Stat
- Add path_test.go and atomicwrite_test.go for localfileio
- Add validate_media_test.go for im media flag validation
- Adapt test mocks to fileio.FileInfo interface
2026-04-08 17:31:21 +08:00
OwenYWT
adef52ada5 fix(config): save empty config before clearing keychain entries (#291)
* fix(config): save empty config before clearing keychain entries
2026-04-08 16:34:50 +08:00
liujinkun2025
6ac5b4d566 support multipart doc media uploads (#294)
Change-Id: I9d9fb00079dacfc96b5781e12e6ce79945baa2ed
2026-04-08 15:43:15 +08:00
MaxHuang22
7158dc2f3c fix: reject positional arguments in shortcuts (#227)
* fix: reject positional arguments in shortcuts with clear error

Shortcuts silently ignored positional arguments (e.g. `lark-cli docs
+search "hello"`), causing empty results. Add Args validator to all
declarative shortcuts so cobra prints usage and a clear error message
telling users to pass values via flags instead.

Change-Id: I7579f9c871138cf91dd5f5d8c1d51bda3f77a1db

* fix: address PR review comments

- Remove unused *Shortcut parameter from rejectPositionalArgs
- Show all positional args in error message instead of only the first
- Add test case for multiple positional arguments

Change-Id: Ifea92d09ddabcd35fbf2db98d9888d18af59b894
2026-04-08 15:11:36 +08:00
feng zhi hao
c54a1354a0 feat(mail): auto-resolve local image paths in all draft entry points (#205)
All draft-related shortcuts now support <img src="./local.png"> in --body,automatically resolving relative paths into cid: inline MIME parts. Only relative paths are supported; absolute paths are rejected. Previously only +draft-edit supported this; now extended to +draft-create, +send, +reply, +reply-all, and +forward.
2026-04-08 14:36:01 +08:00
Vux
a73c9ae27e fix: improve raw API diagnostics for invalid or empty JSON responses (#257)
- Add internal/client/api_errors.go with WrapDoAPIError and WrapJSONResponseParseError to classify JSON decode issues vs generic network errors
- Route cmd/api DoAPI errors and HandleResponse JSON parse errors through the new helpers
- Add regression tests in cmd/api and internal/client

Related: https://github.com/larksuite/cli/issues/215
2026-04-08 14:28:02 +08:00
tuxedomm
900c12ce8d feat: add FileIO extension for file transfer abstraction (#314)
* feat: add FileIO extension for file transfer abstraction

Introduce extension/fileio package with Provider/FileIO/File interfaces
and a global registry, following the same pattern as extension/credential.

- Add LocalFileIO default implementation with path validation and atomic writes
- Wire FileIOProvider into Factory and resolve at runtime via RuntimeContext.FileIO()
- Factory holds Provider (not resolved instance), deferring resolution to execution time
2026-04-08 14:13:59 +08:00
JackZhao10086
f3c3a4c49f feat: support custom data dir and log directories (#302)
* feat: linux support custom data dir via environment variable

* feat(keychain): support custom log directory via LARKSUITE_CLI_LOG_DIR

* feat(security): validate env dir paths for security

Add validation for environment variable directory paths to ensure they are absolute and safe. This prevents potential security issues from malformed paths. Also add corresponding tests to verify the validation behavior.

* docs(validate): add function and test documentation comments

Add missing documentation comments for SafeEnvDirPath function and related test cases to improve code clarity and maintainability

* refactor(keychain): remove warning logs for invalid env vars
2026-04-08 11:06:58 +08:00
max
2e345a4fdd feat(vc): add +recording shortcut for meeting_id to minute_token conversion (#246)
* feat(vc): add +recording shortcut for meeting_id to minute_token conversion

* fix(vc): address PR review feedback for +recording shortcut

* docs(vc): merge Recording and Minutes in resource diagram as they share minute_token

* docs(vc): simplify resource diagram to use Minutes only

* test(vc): add integration eval for +recording execute paths

* docs(vc): fix +recording description to include both input modes

* fix(vc): address review findings for +recording docs and code consistency
2026-04-08 11:02:24 +08:00
eggyrooch-blip
78bc66ce14 fix(docs): normalize board_tokens in +create response for mermaid/whiteboard content (#10)
+update already calls normalizeDocsUpdateResult to surface board_tokens when
markdown contains mermaid/plantuml/whiteboard blocks. +create was missing the
same call, so callers could not know how many whiteboards were created or
retrieve their tokens. One-line fix: call normalizeDocsUpdateResult after
CallMCPTool in DocsCreate.Execute.
2026-04-08 11:01:45 +08:00
zero-my
fe8da8d924 Fix/task get my tasks complete flag help (#310)
* docs: clarify --complete flag behavior in get-my-tasks reference

* fix: clarify complete flag description in get-my-tasks command
2026-04-08 10:33:22 +08:00
zero-my
12bb01addf docs: clarify --complete flag behavior in get-my-tasks reference (#308) 2026-04-08 09:53:13 +08:00
OwenYWT
d6fada01f5 fix(help): point root help Agent Skills link to README section (#289) 2026-04-07 23:24:24 +08:00
liangshuo-1
6bc6bb67aa docs: add v1.0.5 changelog (#300)
* docs: add v1.0.5 changelog

Change-Id: Ia2c5e8f3d3e5fb95b4509e2f5d62a1ee253cd679
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: bump version to v1.0.5

Change-Id: I8d19ec44311f9bf0e700152beab1fd8d261c3f73
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:32:17 +08:00
calendar-assistant
a1438586ec docs: fix root calendar example (#299)
Change-Id: I25e58345019046bf961636b10e8baad522019279
2026-04-07 21:38:08 +08:00
ILUO
c9b660ae12 docs: clarify task guid for applinks (#287) 2026-04-07 21:35:49 +08:00
Necroneco
567b40778b docs: clarify lark task guid usage (#282) 2026-04-07 21:35:39 +08:00
calendar-assistant
ec23995bce docs: fix README auth scope and api data flag (#298)
Change-Id: Ic62b99367165b5267327829aa672e9f394c784b2
2026-04-07 21:19:01 +08:00
kongenpei
1980b999f7 docs(lark-base): add has_more guidance for record-list pagination (#183)
* docs(lark-base): add has_more paging guidance for record-list

* docs(lark-base): refine record-list key field and paging title

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-07 20:51:35 +08:00
kongenpei
1be9a241b7 fix(base): clarify table-id tbl prefix requirement (#270)
Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-07 20:51:08 +08:00
JackZhao10086
f4afa47de8 feat: add darwin file master key fallback for keychain writes (#285)
* feat: (MacOS) add fallback file-based master key storage

* refactor(keychain): improve master key file handling and corruption checks

- Replace temporary file approach with direct file creation
- Add explicit corruption checks for existing keys
- Ensure atomic operations and proper cleanup on failure

* docs(keychain): add comments to clarify constants and variables

Add descriptive comments to explain the purpose of timeout, crypto parameters, and test variables in the macOS keychain implementation.

* fix(keychain): use atomic write for master key initialization

* fix(keychain): add retry logic for reading master key file

Add retry mechanism when reading existing master key file to handle potential race conditions. Return early if read error occurs instead of waiting for all retries.

* refactor(keychain): simplify master key validation logic

Restructure the key validation flow to reduce redundant checks and improve readability. The corrupted key check is moved after the error handling block for better logical flow.

* refactor(keychain): replace os package with vfs for file operations

Use vfs package instead of os for file operations to improve testability and
abstract filesystem access. This change makes it easier to mock filesystem
operations in tests and provides a consistent interface for file handling.
2026-04-07 19:20:00 +08:00
tuxedomm
bb38ecd41a feat: add transport extension with interceptor pre/post hooks (#292)
* feat: add transport extension with interceptor pre/post hooks

Add extension/transport package following the same Provider pattern as
credential and fileio extensions. The Interceptor interface uses a
PreRoundTrip/post-closure design that guarantees built-in transport
decorators (SecurityHeader, SecurityPolicy, Retry) cannot be skipped,
overridden, or tampered with by extensions. The original request context
is restored after PreRoundTrip to prevent context tampering.

Change-Id: I2e51ff67a0e2d8d32944a0565c2a6781110f281f
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 18:21:50 +08:00
niuchong
9f0758bfef test: isolate registry package state in tests (#280)
Reset registry test globals more completely, tighten the overlay pollution regressions, and ensure tenant scope coverage tests rebuild a fresh isolated registry before asserting.
2026-04-07 18:18:55 +08:00
liujinkun2025
d3d92e37c2 chore: map wiki paths in pr labels (#249)
Change-Id: I6d3bc320255958f280922e595dc67f61a11f4b0b
2026-04-07 16:42:57 +08:00
fengzhangchi-bytedance
b064188f20 fix(issue-labels): reduce mislabeling and handle missing labels (#288)
* fix(issue-labels): reduce mislabeling and handle missing labels

Make type classification more conservative to avoid incorrect labels, and avoid skipping entire issues when some managed labels are missing.

* test(issue-labels): add more real-world issue samples

Add labeled/unlabeled issue examples to cover question/bug/enhancement and domain inference.

* test(issue-labels): avoid duplicate issue samples

Keep one sample per source_url to reduce confusion and maintain stable regression coverage.

* fix(issue-labels): include missing-label-only items in JSON output

Keep stderr and JSON output consistent under --only-missing when desired labels are missing from the repo.
2026-04-07 15:54:03 +08:00
yballul-bytedance
799179fde6 fix: 修正 LarkMessageTrigger 的参数限制 (#213)
Change-Id: Ib291b0c7817cb3e52e80d85dcf26993c7fab487c
2026-04-07 15:28:14 +08:00
liangshuo-1
8db4528269 feat: add strict mode identity filter, profile management and credential extension (#252)
* feat: add strict mode identity filter, profile management and credential extension

Port changes from feat/strict-mode-identity-filter_3 branch:
- Add strict mode for identity filtering and configuration
- Add profile management commands (add/list/remove/rename/use)
- Add credential extension framework (registry, env provider)
- Add VFS abstraction layer
- Refactor factory default and client options
- Update shortcuts to use new credential and validation patterns

Change-Id: I8c104c6b147e1901d94aefcefe35a174932c742b
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: go mod tidy

Change-Id: I0f610ccea6bc874248e84c24770944a3071dcc57
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: fix test failures from credential provider migration

- Remove unused TAT stub registrations in api and service tests
  (CredentialProvider manages tokens, SDK no longer calls TAT endpoint)
- Update strict mode integration test: +chat-create now supports user
  identity, so it should succeed under strict mode user

Change-Id: Iab51c2e12a97995e0b95dcd71df212d2d1f76570
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: migrate remaining os calls to internal/vfs

Replace direct os.Stat/Open/MkdirAll/OpenFile/Remove/ReadDir/UserHomeDir
with vfs equivalents in shortcuts/minutes, shortcuts/drive, and
internal/keychain. Add ReadDir to the vfs interface and OsFs implementation.

Change-Id: I8f97e5fb3e1731b4684d276644fcb10fae823067

* fix: resolve gofmt and goimports formatting issues

Change-Id: If61578631f5698f7ca2d9a946ca59753651463fb

* feat: add Flag.Input support for @file and stdin input sources

Add framework-level support for reading flag values from files (@path)
or stdin (-), solving the fundamental problem of passing complex text
(markdown, multi-line content) via CLI arguments where shell escaping
breaks content. Closes #239, fixes #163.

- Add File/Stdin constants and Input field to Flag struct
- Add resolveInputFlags() in runner pipeline (pre-Validate)
- Support @@ escape for literal @ prefix
- Guard against multiple stdin consumers
- Auto-append "(supports @file, - for stdin)" to help text
- Apply to: docs +create/+update --markdown, im +messages-send/+reply
  --text/--markdown/--content, task +comment --content,
  drive +add-comment --content

Change-Id: I305a326d972417542aeadd70f37b74ea456461ef
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: fix pre-existing test failures in task, minutes, and registry

- task/minutes: remove unused tenant_access_token httpmock stubs
  (TestFactory's testDefaultToken provides tokens directly, so the
  HTTP stub was never consumed and failed verification)
- registry: fix hasEmbeddedData() to check for actual services instead
  of just byte length (meta_data_default.json has empty services array)

Change-Id: Ic7b5fc7f9de09137a7254fe1ddf47d24ade40587
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: suppress nilerr lint for intentional nil returns

Both cases intentionally return nil on error for graceful degradation:
- profile list: show friendly message when config is not initialized
- service: skip scope check when token resolution fails

Change-Id: I7285c37277c9b0361a421ab00359244c2cd150b3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address CodeRabbit review feedback

- runner.go: fail fast when Input is used on non-string flags
- remote_test.go: rename hasEmbeddedData → hasEmbeddedServices
- profile/list.go: add omitempty to optional JSON fields
- service.go: surface context cancellation errors in scope check

Change-Id: I7072d41f8c711b4b37c542e32dfd8150f42b13c0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: tighten credential resolution and profile flows

Change-Id: I83f6d424540eab9b1708944b9b6e26e8477cc60d

* refactor: centralize identity hint resolution

Change-Id: I38d5f98160b92adb62dc929ae73697ae5b3d64f8

* fix: surface unverified extension identities

Change-Id: Ia86d9bd19add9010176339ec4cc89deb033f5b4f

* fix: honor runtime credential sources in config views

Change-Id: I40b2ffedc5c1db5e08e86b9472ea2b84fa02bb29

* fix: prefer runtime values in config show commands

Change-Id: I5663a53e147577f0f1f533f67d12bea504e6b839

* Revert "fix: prefer runtime values in config show commands"

This reverts commit 4f9db3a227.

* Revert "fix: honor runtime credential sources in config views"

This reverts commit b3bfd526c5.

* fix: harden profile flows and credential boundaries

Change-Id: Ica61cd2730a639f71516cb1b237a639cb6511f7a

* fix: optimize profile and config inspection for agents

Change-Id: I19c368102f19654952638180ab947788a6971563

* refactor: unify credential env contracts

Change-Id: I0ff2c0a650ea53589a0626333e8f6e628ef10a54

* docs: expand AGENTS guidance

Change-Id: I289027dfd364c92205012feef6f05037066c035b

* fix: resolve regression bugs found during PR #252 review

- im: fix double SafeInputPath in resolveLocalMedia → uploadImageToIM/
  uploadFileToIM chain that rejected all local image/file uploads
- credential: stop writing plain-text warnings to stderr, preserving
  JSON envelope contract for AI agent consumers
- profile add: reject duplicate app-id to prevent keychain credential
  collisions across profiles
- profile rename: exclude self when checking name uniqueness so renaming
  to own appId works correctly
- config: replace bare fmt.Errorf with output.Errorf in save-failure
  paths (default_as, strict_mode ×2, profile add)
- factory: remove unused resolveDefaultAs method (lint)

Change-Id: I6aa0d064414016f367f1edb08dd0604adf7bf13d
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove flaky TestColdStart_UsesEmbedded (race in registry)

The test triggers a data race: resetInit() writes package globals while
a background goroutine from a previous test may still be reading them.
The embedded-data path is covered by other tests.

Change-Id: I7a0c3bf85a9fb337b9279c9053697f40a0c0a0d4
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: type-strengthen Brand and DefaultAs across credential chain

Replace raw string fields with typed enums for compile-time safety:
- extension/credential: add Brand and Identity named types
- internal/core: AppConfig.DefaultAs and CliConfig.DefaultAs → Identity
- internal/credential: Account.DefaultAs and IdentityHint.DefaultAs → core.Identity

The full data flow is now typed end-to-end:
  extcred.Brand → core.LarkBrand (named-type cast)
  extcred.Identity → core.Identity (named-type cast)

No string intermediaries, no implicit conversions.

Change-Id: I715b3b3f033fcb624010f1af9619e3562740ef08
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: fix gofmt alignment in extension/credential/types.go

Change-Id: Ibfac0703a5a28f3c6ba4a47bf40696028d0f3b90
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove file/stdin input support from task comment content flag

Change-Id: If49704ca4612465a23bd30b755d6e72a35fc2349
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(cmdutil): remove dead code autoDetectIdentity

autoDetectIdentity() is only called from tests, never from production
code. Remove it along with its 3 test cases to reduce surface area
before the upcoming ctx propagation refactor.

Change-Id: I35a188860f17656f3e1fe9874f87f284985ae196

* refactor(cmdutil): add ctx parameter to resolveIdentityHint

Private method resolveIdentityHint now accepts context.Context and
passes it to CredentialProvider.ResolveIdentityHint instead of using
context.Background(). The caller (ResolveAs) still uses
context.Background() temporarily until its own signature is updated.

Change-Id: I14634a4e0dc1d657d56936ba61a7b7a206da8ac4

* refactor(cmdutil): add ctx parameter to ResolveStrictMode

ResolveStrictMode now accepts context.Context and passes it to
CredentialProvider.ResolveAccount instead of using context.Background().

Callers in cobra RunE pass cmd.Context(); callers outside RunE
(cmd/root.go startup, tests) use context.Background() explicitly.

Change-Id: I31be48e548ac5ac5640a65f3bfdde4a53ed1dc7e

* refactor(cmdutil): add ctx parameter to CheckStrictMode

CheckStrictMode now accepts context.Context and forwards it to
ResolveStrictMode. Callers pass cmd.Context() (cobra RunE) or
opts.Ctx (APIOptions/ServiceMethodOptions).

Change-Id: I47888519d4cae8c94054771c32aff075565a8cdc

* refactor(cmdutil): add ctx parameter to ResolveAs

ResolveAs now accepts context.Context as first parameter and forwards
it to ResolveStrictMode and resolveIdentityHint. This completes the
ctx propagation chain: all Factory methods that call
CredentialProvider now receive ctx from cobra cmd.Context().

No more context.Background() calls remain in factory.go for
credential provider operations.

Change-Id: I6d10b6350e3b149470660de3e7855614314e8b29

* test: fix gofmt in cmdutil factory tests

Change-Id: I4a87d5a815b959f14cc4371b73dee4aae106932f

* fix: remove file/stdin input support from im send/reply and drive comment

The Input (file/stdin) feature is not yet ready for these flags:
- im send/reply: --content, --text, --markdown
- drive add-comment: --content

Retained only in doc create/update where markdown from file is essential.

Change-Id: I582b6349528fccb639ad9edc84650cca3b68535c
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: liushiyao <liushiyao.1206@bytedance.com>
2026-04-07 15:21:14 +08:00
feng zhi hao
30dba35c77 fix(mail): restore CID validation and stale PartID lookup lost in revert (#230)
* fix(mail): restore CID validation and stale PartID lookup lost in revert (#199)

The revert of PR #81 (eda2b9c) also removed two independent bugfixes:

1. CID character validation in newInlinePart — reject spaces, tabs,
   angle brackets, and parentheses to prevent malformed MIME output.
2. Stale PartID lookup in validateInlineCIDAfterApply and
   validateOrphanedInlineCIDAfterApply — use findPrimaryBodyPart by
   media type instead of findPart by PrimaryHTMLPartID, which can go
   stale when ops restructure the MIME tree.

* test(mail): add tests for CID character validation and stale PartID lookup

- TestAddInlineRejectsInvalidCharactersInCID: verify spaces, tabs,
  embedded angle brackets, and parentheses in CID are rejected.
- TestValidateInlineCIDAfterSetBody: verify inline CID validation
  works correctly after set_body restructures the MIME tree (covers
  the findPrimaryBodyPart fix for stale PartID).

* fix(mail): add CID character validation to replaceInline and strengthen test assertions

Address CR feedback:
1. Add the same CID character validation (spaces, tabs, angle brackets,
   parentheses) to replaceInline, matching the check in newInlinePart.
   Previously replace_inline could bypass the restriction.
2. Strengthen orphaned CID test assertion to check for specific
   "orphaned cids" error message, not just non-nil error.
3. Add TestReplaceInlineRejectsInvalidCharactersInCID to cover the
   new validation in replace_inline.
2026-04-07 11:13:50 +08:00
williamfzc
2efadece34 feat: add scheduled issue labeler for type/domain triage (#251)
* ci: add issue labeler workflow

Add a manual GitHub Actions workflow and script to poll issues and apply type/domain labels.

* feat(issue-labels): refine heuristics and add docs

Improve domain detection and add safeguards to avoid overriding manual type triage by default. Refresh regression samples from real issues and document usage.

* ci(issue-labels): enable hourly scheduled labeling

Run hourly on schedule with write mode by default while keeping manual dispatch dry-run by default.

* ci(issue-labels): shorten lookback window to 6h

Reduce scheduled scan window while keeping overlap for missed runs.

* ci(issue-labels): opt into Node 24 actions runtime

Set FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 and use Node 24 for the script runtime to avoid upcoming Node 20 deprecation warnings.

* ci(issue-labels): restore lookback input for manual runs

Allow workflow_dispatch to override lookback_hours while keeping hourly schedule fixed.

* ci(issue-labels): upgrade checkout/setup-node to v6

Use actions/checkout@v6 and actions/setup-node@v6 to align with Node 24 runtime and avoid Node 20 deprecation warnings.

* fix(ci): label only unlabeled issues via search api

* fix(ci): refine issue labeling heuristics from live issues

* fix(ci): address remaining issue label review comments

* fix(ci): fix issue label arg parsing regression

* docs(issue-labels): clarify one-shot unlabeled triage scope
2026-04-07 10:35:40 +08:00
Zhiwei Xiao
b7613d64bd feat(drive): support multipart upload for files larger than 20MB (#43)
* feat(drive): support multipart upload for files larger than 20MB

Previously, `drive +upload` rejected files exceeding 20MB with a
validation error. Now files > 20MB automatically use the three-step
chunked upload API (upload_prepare → upload_part × N → upload_finish),
removing the size ceiling for Drive uploads.

Tested with a 189MB file (48 blocks × 4MB) against a live Feishu tenant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(drive): add upload error-path tests to improve coverage

Cover small-file upload (upload_all) success + error paths and
multipart upload error paths (invalid prepare, part API error,
part invalid JSON, finish missing token, custom name flag).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:21:16 +08:00
329 changed files with 24643 additions and 3769 deletions

57
.github/workflows/issue-labels.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Issue Labels
on:
schedule:
- cron: '0 * * * *' # every hour
workflow_dispatch:
inputs:
dry_run:
description: "Do not write labels, only print planned changes"
required: false
default: true
type: boolean
permissions:
contents: read
issues: write
concurrency:
group: issue-labels
cancel-in-progress: true
jobs:
sync-issue-labels:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
# v6+ uses Node 24 runtime for JavaScript actions.
- uses: actions/setup-node@v6
with:
node-version: '24'
- name: Sync managed issue labels
id: sync_issue_labels
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EVENT_NAME: ${{ github.event_name }}
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
run: |
args=(
"--max-issues" "300"
)
# Schedule runs should write labels by default.
# Manual runs default to dry-run unless explicitly disabled.
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_DRY_RUN:-true}" = "true" ]; then
args+=("--dry-run" "--json")
fi
node scripts/issue-labels/index.js "${args[@]}"
- name: Warn when label sync fails
if: ${{ always() && steps.sync_issue_labels.outcome == 'failure' }}
run: |
echo "::warning::Issue label sync failed; labels may be stale."
echo "⚠️ Issue label sync failed; labels may be stale." >> "$GITHUB_STEP_SUMMARY"

26
.github/workflows/license-header.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: License Header
on:
pull_request:
branches: [main]
paths:
- "**/*.go"
- "**/*.js"
- "**/*.py"
- .licenserc.yaml
- .github/workflows/license-header.yml
permissions:
contents: read
pull-requests: write
jobs:
header-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Check license headers
uses: apache/skywalking-eyes/header@8c96ee223558797cdd9eba82c0919258e1cf2dad
with:
config: .licenserc.yaml

1
.gitignore vendored
View File

@@ -31,6 +31,7 @@ tests/mail/reports/
/log/
# Generated / test artifacts
internal/registry/meta_data.json
cmd/api/download.bin

View File

@@ -27,6 +27,8 @@ linters:
- reassign # checks that package variables are not reassigned
- unconvert # removes unnecessary type conversions
- unused # checks for unused constants, variables, functions and types
- depguard # blocks forbidden package imports
- forbidigo # forbids specific function calls
# To enable later after fixing existing issues:
# - errcheck # checks for unchecked errors
@@ -44,8 +46,67 @@ linters:
linters:
- bodyclose
- gocritic
- depguard
- forbidigo
- path-except: (shortcuts/|internal/)
linters:
- forbidigo
- path: internal/vfs/
linters:
- forbidigo
settings:
depguard:
rules:
shortcuts-no-vfs:
files:
- "**/shortcuts/**"
deny:
- pkg: "github.com/larksuite/cli/internal/vfs"
desc: >-
shortcuts must not import internal/vfs directly.
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
- pkg: "github.com/larksuite/cli/internal/vfs/localfileio"
desc: >-
shortcuts must not import internal/vfs/localfileio directly.
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
forbidigo:
forbid:
# ── os: already wrapped in internal/vfs ──
- pattern: os\.(Stat|Lstat|Open|OpenFile|Rename|ReadFile|WriteFile|Getwd|UserHomeDir|ReadDir)\b
msg: "use the corresponding vfs.Xxx() from internal/vfs"
- pattern: os\.(Create|CreateTemp|MkdirTemp)\b
msg: >-
internal/: use vfs.CreateTemp() or vfs.OpenFile().
shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers.
- pattern: os\.Mkdir(All)?\b
msg: "use vfs.MkdirAll() from internal/vfs"
- pattern: os\.Remove\b
msg: >-
internal/: use vfs.Remove() from internal/vfs.
shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers.
- pattern: os\.RemoveAll\b
msg: >-
internal/: add RemoveAll to internal/vfs/fs.go first, then use vfs.RemoveAll().
shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers.
# ── os: not yet in vfs — add to vfs/fs.go first ──
- pattern: os\.(Chdir|Chmod|Chown|Lchown|Chtimes|CopyFS|DirFS|Link|Symlink|Readlink|Truncate|SameFile)\b
msg: "add this function to internal/vfs/fs.go first, then use vfs.Xxx()"
# ── os: IO streams ──
- pattern: os\.Std(in|out|err)\b
msg: "use IOStreams (In/Out/ErrOut) instead of os.Stdin/Stdout/Stderr"
# ── os: process ──
- pattern: os\.Exit\b
msg: >-
Do not use os.Exit in shortcuts/. Return an error instead and let
the caller (cmd layer) decide how to terminate.
# ── filepath: functions that access the filesystem ──
- pattern: filepath\.(EvalSymlinks|Walk|WalkDir|Glob|Abs)\b
msg: >-
These filepath functions access the filesystem directly.
internal/: use vfs helpers or localfileio path validation.
shortcuts/: use runtime.ValidatePath() or runtime.FileIO().
analyze-types: true
gocritic:
disabled-checks:
- appendAssign

16
.licenserc.yaml Normal file
View File

@@ -0,0 +1,16 @@
header:
license:
content: |
Copyright (c) [year] Lark Technologies Pte. Ltd.
SPDX-License-Identifier: MIT
copyright-year: "2026"
paths:
- '**/*.go'
- '**/*.js'
- '**/*.py'
paths-ignore:
- '**/testdata/**'
comment: on-failure

View File

@@ -1,33 +1,78 @@
# AGENTS.md
Concise maintainer/developer guide for building, testing, and opening high-quality PRs in this repo.
## Goal (pick one per PR)
- Make CLI better: improve UX, error messages, help text, flags, and output clarity.
- Improve reliability: fix bugs, edge cases, and regressions with tests.
- Improve developer velocity: simplify code paths, reduce complexity, keep behavior explicit.
- Improve quality gates: strengthen tests/lint/checks without adding heavy process.
## Fast Dev Loop
1. `make build` (runs `python3 scripts/fetch_meta.py` first)
2. `make unit-test` (required before PR)
3. Run changed command(s) manually via `./lark-cli ...`
## Build & Test
```bash
make build # Build (runs fetch_meta first)
make unit-test # Required before PR (runs with -race)
make test # Full: vet + unit + integration
```
## Pre-PR Checks (match CI gates)
1. `make unit-test`
2. `go mod tidy` (must not change `go.mod`/`go.sum`)
2. `go mod tidy` must not change `go.mod`/`go.sum`
3. `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main`
4. If dependencies changed: `go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown`
5. Optional full local suite: `make test` (vet + unit + integration)
## Test/Check Commands
- Unit: `make unit-test`
- Integration: `make integration-test`
- Full: `make test`
- Vet only: `make vet`
- Coverage (local): `go test -race -coverprofile=coverage.txt -covermode=atomic ./...`
## Commit & PR
## Commit/PR Rules
- Use Conventional Commits in English: `feat: ...`, `fix: ...`, `docs: ...`, `ci: ...`, `test: ...`, `chore: ...`, `refactor: ...`
- Keep PR title in the same Conventional Commit format (squash merge keeps it).
- Before opening a real PR, draft/fill description from `.github/pull_request_template.md` and ensure Summary/Changes/Test Plan are complete.
- Never commit secrets/tokens/internal sensitive data.
- Conventional Commits in English: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`, `chore:`, `ci:`
- PR title in the same format. Fill `.github/pull_request_template.md` completely.
- Never commit secrets, tokens, or internal sensitive data.
## Source Layout
| Path | What it does |
|------|-------------|
| `cmd/root.go` | Entry point, command registration, strict mode pruning |
| `cmd/profile/` | Multi-profile management (add/list/use/rename/remove) |
| `cmd/config/` | Config init, show, strict-mode |
| `cmd/service/` | Auto-registered API commands from embedded metadata |
| `shortcuts/common/runner.go` | Shortcut execution pipeline, Flag.Input (@file/stdin) resolution |
| `shortcuts/` | Domain-specific shortcut implementations |
| `internal/cmdutil/factory.go` | Factory pattern — identity resolution, credential, config |
| `internal/cmdutil/factory_default.go` | Production factory wiring |
| `internal/credential/` | Credential provider chain (extension → default) |
| `extension/credential/` | Plugin-facing credential interfaces and env provider |
| `internal/client/client.go` | APIClient: DoSDKRequest, DoStream |
| `internal/core/config.go` | Multi-profile config loading/saving |
| `internal/vfs/` | Filesystem abstraction (use `vfs.*` instead of `os.*`) |
| `internal/validate/path.go` | Path safety validation |
## Who Uses This CLI
This CLI's primary consumers include AI agents (Claude Code, Cursor, Gemini CLI). Your code is read by machines — error messages, output format, and flag design all directly affect agent success rates.
The one rule to internalize: **every error message you write will be parsed by an AI to decide its next action.** Make errors structured, actionable, and specific.
## Code Conventions
### Structured errors in commands
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
### stdout is data, stderr is everything else
Program output (JSON envelopes) goes to stdout. Progress, warnings, hints go to stderr. Mixing them corrupts pipe chains.
### Use `vfs.*` instead of `os.*`
All filesystem access goes through `internal/vfs`. This enables test mocking.
### Validate paths before reading
CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInputPath` before any file I/O.
### Tests
- Every behavior change needs a test alongside the change.
- `cmdutil.TestFactory(t, config)` for test factories.
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.

View File

@@ -2,6 +2,101 @@
All notable changes to this project will be documented in this file.
## [v1.0.7] - 2026-04-09
### Features
- Auto-grant current user access for bot-created docs, sheets, imports, and uploads (#360)
- **mail**: Add `send_as` alias support, mailbox/sender discovery APIs, and mail rules API
- **vc**: Extract note doc tokens from calendar event relation API (#333)
- **wiki**: Add wiki node create shortcut (#320)
- **sheets**: Add `+write-image` shortcut (#343)
- **docs**: Add media-preview shortcut (#334)
- **docs**: Add support for additional search filters (#353)
### Bug Fixes
- **api**: Support stdin and quoted JSON inputs on Windows (#367)
- **doc**: Post-process `docs +fetch` output to improve round-trip fidelity (#214)
- **run**: Add missing binary check for lark-cli execution (#362)
- **config**: Validate appId and appSecret keychain key consistency (#295)
### Refactor
- Route base import guidance to drive `+import` (#368)
- Migrate mail shortcuts to FileIO (#356)
- Migrate drive/doc/sheets shortcuts to FileIO (#339)
- Migrate base shortcuts to FileIO (#347)
### Documentation
- **lark-doc**: Document advanced boolean and intitle search syntax for AI agents (#210)
### Chore
- Add depguard and forbidigo rules to guide FileIO adoption (#342)
## [v1.0.6] - 2026-04-08
### Features
- Improve login scope validation and success output (#317)
- **task**: Support starting pagination from page token (#332)
- Support multipart doc media uploads (#294)
- **mail**: Auto-resolve local image paths in all draft entry points (#205)
- **vc**: Add `+recording` shortcut for `meeting_id` to `minute_token` conversion (#246)
### Bug Fixes
- Resolve concurrency races in RuntimeContext (#330)
- **config**: Save empty config before clearing keychain entries (#291)
- Reject positional arguments in shortcuts (#227)
- Improve raw API diagnostics for invalid or empty JSON responses (#257)
- **docs**: Normalize `board_tokens` in `+create` response for mermaid/whiteboard content (#10)
- **task**: Clarify `--complete` flag help for `get-my-tasks` (#310)
- **help**: Point root help Agent Skills link to README section (#289)
### Documentation
- Clarify `--complete` flag behavior in `get-my-tasks` reference (#308)
### Refactor
- Migrate VC/minutes shortcuts to FileIO (#336)
- Migrate common/client/IM to FileIO and add localfileio tests (#322)
## [v1.0.5] - 2026-04-07
### Features
- **drive**: Support multipart upload for files larger than 20MB (#43)
- Add darwin file master key fallback for keychain writes (#285)
- Add strict mode identity filter, profile management and credential extension (#252)
### Bug Fixes
- **mail**: Restore CID validation and stale PartID lookup lost in revert (#230)
- **base**: Clarify table-id `tbl` prefix requirement (#270)
- Fix parameter constraints for LarkMessageTrigger (#213)
### Documentation
- Fix root calendar example (#299)
- Fix README auth scope and api data flag (#298)
- Clarify task guid for applinks (#287)
- Clarify lark task guid usage (#282)
- **lark-base**: Add `has_more` guidance for record-list pagination (#183)
### Tests
- Isolate registry package state in tests (#280)
### CI
- Add scheduled issue labeler for type/domain triage (#251)
- **issue-labels**: Reduce mislabeling and handle missing labels (#288)
- Map wiki paths in pr labels (#249)
## [v1.0.4] - 2026-04-03
### Features
@@ -161,6 +256,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.7]: https://github.com/larksuite/cli/releases/tag/v1.0.7
[v1.0.6]: https://github.com/larksuite/cli/releases/tag/v1.0.6
[v1.0.5]: https://github.com/larksuite/cli/releases/tag/v1.0.5
[v1.0.4]: https://github.com/larksuite/cli/releases/tag/v1.0.4
[v1.0.3]: https://github.com/larksuite/cli/releases/tag/v1.0.3
[v1.0.2]: https://github.com/larksuite/cli/releases/tag/v1.0.2

View File

@@ -173,7 +173,7 @@ lark-cli auth login --domain calendar,task
lark-cli auth login --recommend
# Exact scope
lark-cli auth login --scope "calendar:calendar:readonly"
lark-cli auth login --scope "calendar:calendar:read"
# Agent mode: return verification URL immediately, non-blocking
lark-cli auth login --domain calendar --no-wait
@@ -216,7 +216,7 @@ Call any Lark Open Platform endpoint directly, covering 2500+ APIs.
```bash
lark-cli api GET /open-apis/calendar/v4/calendars
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --body '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --data '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
```
## Advanced Usage

View File

@@ -174,7 +174,7 @@ lark-cli auth login --domain calendar,task
lark-cli auth login --recommend
# 精确 scope
lark-cli auth login --scope "calendar:calendar:readonly"
lark-cli auth login --scope "calendar:calendar:read"
# Agent 模式:立即返回验证 URL不阻塞
lark-cli auth login --domain calendar --no-wait
@@ -217,7 +217,7 @@ lark-cli calendar events instance_view --params '{"calendar_id":"primary","start
```bash
lark-cli api GET /open-apis/calendar/v4/calendars
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --body '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --data '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
```
## 进阶用法

View File

@@ -5,7 +5,6 @@ package api
import (
"context"
"encoding/json"
"fmt"
"io"
"regexp"
@@ -44,17 +43,6 @@ type APIOptions struct {
DryRun bool
}
func parseJsonOpt(input, label string) (map[string]interface{}, error) {
if input == "" {
return nil, nil
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(input), &result); err != nil {
return nil, output.ErrValidation("%s invalid format, expected JSON object", label)
}
return result, nil
}
var urlPrefixRe = regexp.MustCompile(`https?://[^/]+(/open-apis/.+)`)
func normalisePath(raw string) string {
@@ -88,8 +76,8 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON")
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
@@ -118,19 +106,19 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command
// buildAPIRequest validates flags and builds a RawApiRequest.
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) {
params, err := parseJsonOpt(opts.Params, "--params")
// stdin is an io.Reader consumed at most once. Only one of --params/--data
// may use "-" (stdin); the conflict check below prevents silent data loss.
stdin := opts.Factory.IOStreams.In
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
if err != nil {
return client.RawApiRequest{}, err
}
if params == nil {
params = map[string]interface{}{}
}
var data interface{}
if opts.Data != "" {
data, err = parseJsonOpt(opts.Data, "--data")
if err != nil {
return client.RawApiRequest{}, err
}
data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin)
if err != nil {
return client.RawApiRequest{}, err
}
if opts.PageSize > 0 {
params["page_size"] = opts.PageSize
@@ -152,7 +140,11 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) {
func apiRun(opts *APIOptions) error {
f := opts.Factory
opts.As = f.ResolveAs(opts.Cmd, opts.As)
opts.As = f.ResolveAs(opts.Ctx, opts.Cmd, opts.As)
if err := f.CheckStrictMode(opts.Ctx, opts.As); err != nil {
return err
}
if opts.PageAll && opts.Output != "" {
return output.ErrValidation("--output and --page-all are mutually exclusive")
@@ -166,7 +158,7 @@ func apiRun(opts *APIOptions) error {
return err
}
config, err := f.ResolveConfig(opts.As)
config, err := f.Config()
if err != nil {
return err
}
@@ -195,7 +187,7 @@ func apiRun(opts *APIOptions) error {
resp, err := ac.DoAPI(opts.Ctx, request)
if err != nil {
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
return output.MarkRaw(client.WrapDoAPIError(err))
}
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
@@ -203,6 +195,7 @@ func apiRun(opts *APIOptions) error {
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
FileIO: f.ResolveFileIO(opts.Ctx),
})
// MarkRaw tells root error handler to skip enrichPermissionError,
// preserving the original API error detail (log_id, troubleshooter, etc.).

View File

@@ -70,16 +70,6 @@ func TestApiCmd_BotMode(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
// Register tenant_access_token stub
reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"tenant_access_token": "t-test-token",
"expire": 7200,
},
})
// Register API endpoint stub
reg.Register(&httpmock.Stub{
URL: "/open-apis/test",
@@ -209,6 +199,22 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
}
}
func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error when both --params and --data use stdin")
}
if !strings.Contains(err.Error(), "cannot both read from stdin") {
t.Errorf("expected stdin conflict error, got: %v", err)
}
}
func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -234,13 +240,6 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-bin", "expire": 7200,
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/drive/v1/files/xxx/download",
RawBody: []byte("fake-binary-content"),
@@ -266,14 +265,6 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: core.BrandFeishu,
})
// Register tenant_access_token stub
reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-pa1", "expire": 7200,
},
})
// Register a non-batch API that returns scalar data (no array field)
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users/u123",
@@ -310,13 +301,6 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-err", "expire": 7200,
},
})
// Non-batch API that returns a business error (code != 0)
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/chats/oc_xxx/announcement",
@@ -346,14 +330,6 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
AppID: "test-app-pageall2", AppSecret: "test-secret-pageall2", Brand: core.BrandFeishu,
})
// Register tenant_access_token stub (unique app credentials => new token request)
reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-pa2", "expire": 7200,
},
})
// Register a batch API that returns an array field
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
@@ -409,13 +385,6 @@ func TestApiCmd_APIError_IsRaw(t *testing.T) {
AppID: "test-app-raw", AppSecret: "test-secret-raw", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-raw", "expire": 7200,
},
})
// Return a permission error from the API
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/perm",
@@ -456,13 +425,6 @@ func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
AppID: "test-app-origmsg", AppSecret: "test-secret-origmsg", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-origmsg", "expire": 7200,
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/origmsg",
Body: map[string]interface{}{
@@ -500,18 +462,48 @@ func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
}
}
func TestApiCmd_InvalidJSONResponse_ShowsDiagnostic(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-invalidjson", AppSecret: "test-secret-invalidjson", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/invalidjson",
RawBody: []byte{},
ContentType: "application/json",
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/invalidjson", "--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
}
if exitErr.Detail == nil {
t.Fatal("expected detail on exit error")
}
if !strings.Contains(exitErr.Detail.Message, "invalid JSON response") &&
!strings.Contains(exitErr.Detail.Message, "empty JSON response body") {
t.Fatalf("expected JSON diagnostic, got %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "--output") {
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
}
}
func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-rawpage", AppSecret: "test-secret-rawpage", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-rawpage", "expire": 7200,
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/rawpage",
Body: map[string]interface{}{
@@ -599,13 +591,6 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-jq", "expire": 7200,
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/jq",
Body: map[string]interface{}{
@@ -676,13 +661,6 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-pjq", "expire": 7200,
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{

View File

@@ -16,6 +16,7 @@ import (
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// NewCmdAuth creates the auth command with subcommands.
@@ -100,7 +101,7 @@ type appInfoResponse struct {
// getAppInfo queries app info from the Lark API.
func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
sdk, err := f.LarkClient()
ac, err := f.NewAPIClient()
if err != nil {
return nil, err
}
@@ -108,12 +109,11 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
queryParams := make(larkcore.QueryParams)
queryParams.Set("lang", "zh_cn")
apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: larkauth.ApplicationInfoPath(appId),
QueryParams: queryParams,
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant},
})
apiResp, err := ac.DoSDKRequest(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: larkauth.ApplicationInfoPath(appId),
QueryParams: queryParams,
}, core.AsBot)
if err != nil {
return nil, err
}

View File

@@ -4,12 +4,16 @@
package auth
import (
"context"
"net/http"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/registry"
)
@@ -231,3 +235,71 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
t.Errorf("expected format json, got %s", gotOpts.Format)
}
}
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
})
tokenResolver := &authScopesTokenResolver{}
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
appInfoStub := &httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/application/v6/applications/test-app",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"app": map[string]interface{}{
"creator_id": "ou_creator",
"scopes": []map[string]interface{}{
{
"scope": "im:message",
"token_types": []string{"tenant"},
},
{
"scope": "im:message:send_as_user",
"token_types": []string{"user"},
},
},
},
},
},
}
reg.Register(appInfoStub)
err := authScopesRun(&ScopesOptions{
Factory: f,
Ctx: context.Background(),
Format: "json",
})
if err != nil {
t.Fatalf("authScopesRun() error = %v", err)
}
if len(tokenResolver.requests) != 1 {
t.Fatalf("resolved token requests = %v, want exactly one request", tokenResolver.requests)
}
if got := tokenResolver.requests[0].Type; got != credential.TokenTypeTAT {
t.Fatalf("resolved token type = %q, want %q", got, credential.TokenTypeTAT)
}
if got := appInfoStub.CapturedHeaders.Get("Authorization"); got != "Bearer tenant-token" {
t.Fatalf("Authorization header = %q, want %q", got, "Bearer tenant-token")
}
}
type authScopesTokenResolver struct {
requests []credential.TokenSpec
}
func (r *authScopesTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
r.requests = append(r.requests, req)
switch req.Type {
case credential.TokenTypeTAT:
return &credential.TokenResult{Token: "tenant-token"}, nil
case credential.TokenTypeUAT:
return &credential.TokenResult{Token: "user-token"}, nil
default:
return &credential.TokenResult{Token: "unexpected-token"}, nil
}
}

View File

@@ -46,8 +46,8 @@ func authListRun(opts *ListOptions) error {
return nil
}
app := multi.Apps[0]
if len(app.Users) == 0 {
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 {
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
return nil
}

View File

@@ -34,6 +34,8 @@ type LoginOptions struct {
DeviceCode string
}
var pollDeviceToken = larkauth.PollDeviceToken
// NewCmdAuthLogin creates the auth login subcommand.
func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
opts := &LoginOptions{Factory: f}
@@ -46,6 +48,12 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.
For AI agents: this command blocks until the user completes authorization in the
browser. Run it in the background and retrieve the verification URL from its output.`,
RunE: func(cmd *cobra.Command, args []string) error {
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
return output.Errorf(output.ExitValidation, "strict_mode",
"strict mode is %q, user login is not allowed. "+
"This setting is managed by the administrator and must not be modified by AI agents.",
mode)
}
opts.Ctx = cmd.Context()
if runF != nil {
return runF(opts)
@@ -53,6 +61,7 @@ browser. Run it in the background and retrieve the verification URL from its out
return authLoginRun(opts)
},
}
cmdutil.SetSupportedIdentities(cmd, []string{"user"})
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space-separated)")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
@@ -101,8 +110,10 @@ func authLoginRun(opts *LoginOptions) error {
// Determine UI language from saved config
lang := "zh"
if multi, _ := core.LoadMultiAppConfig(); multi != nil && len(multi.Apps) > 0 {
lang = multi.Apps[0].Lang
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
if app := multi.FindApp(config.ProfileName); app != nil {
lang = app.Lang
}
}
msg := getLoginMsg(lang)
@@ -226,6 +237,9 @@ func authLoginRun(opts *LoginOptions) error {
// --no-wait: return immediately with device code and URL
if opts.NoWait {
if err := saveLoginRequestedScope(authResp.DeviceCode, finalScope); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to cache requested scopes: %v\n", err)
}
data := map[string]interface{}{
"verification_url": authResp.VerificationUriComplete,
"device_code": authResp.DeviceCode,
@@ -235,7 +249,7 @@ func authLoginRun(opts *LoginOptions) error {
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(data); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "error: failed to write JSON output: %v\n", err)
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
}
return nil
}
@@ -252,7 +266,7 @@ func authLoginRun(opts *LoginOptions) error {
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(data); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "error: failed to write JSON output: %v\n", err)
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
}
} else {
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
@@ -261,20 +275,26 @@ func authLoginRun(opts *LoginOptions) error {
// Step 3: Poll for token
log(msg.WaitingAuth)
result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if !result.OK {
if opts.JSON {
b, _ := json.Marshal(map[string]interface{}{
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(map[string]interface{}{
"event": "authorization_failed",
"error": result.Message,
})
fmt.Fprintln(f.IOStreams.Out, string(b))
}); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
}
return output.ErrBare(output.ExitAuth)
}
return output.ErrAuth("authorization failed: %s", result.Message)
}
if result.Token == nil {
return output.ErrAuth("authorization succeeded but no token returned")
}
// Step 6: Get user info
log(msg.AuthSuccess)
@@ -287,6 +307,8 @@ func authLoginRun(opts *LoginOptions) error {
return output.ErrAuth("failed to get user info: %v", err)
}
scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope)
// Step 7: Store token
now := time.Now().UnixMilli()
storedToken := &larkauth.StoredUAToken{
@@ -304,35 +326,16 @@ func authLoginRun(opts *LoginOptions) error {
}
// Step 8: Update config — overwrite Users to single user, clean old tokens
multi, _ := core.LoadMultiAppConfig()
if multi != nil && len(multi.Apps) > 0 {
app := &multi.Apps[0]
for _, oldUser := range app.Users {
if oldUser.UserOpenId != openId {
larkauth.RemoveStoredToken(config.AppID, oldUser.UserOpenId)
}
}
app.Users = []core.AppUser{{UserOpenId: openId, UserName: userName}}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
_ = larkauth.RemoveStoredToken(config.AppID, openId)
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
}
if opts.JSON {
b, _ := json.Marshal(map[string]interface{}{
"event": "authorization_complete",
"user_open_id": openId,
"user_name": userName,
"scope": result.Token.Scope,
})
fmt.Fprintln(f.IOStreams.Out, string(b))
} else {
fmt.Fprintln(f.IOStreams.ErrOut)
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
if result.Token.Scope != "" {
fmt.Fprintf(f.IOStreams.ErrOut, msg.GrantedScopes, result.Token.Scope)
}
if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil {
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName)
}
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary)
return nil
}
@@ -345,13 +348,26 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
if err != nil {
return err
}
requestedScope, err := loadLoginRequestedScope(opts.DeviceCode)
if err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to load cached requested scopes: %v\n", err)
}
cleanupRequestedScope := func() {
if err := removeLoginRequestedScope(opts.DeviceCode); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
}
}
log(msg.WaitingAuth)
result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
opts.DeviceCode, 5, 180, f.IOStreams.ErrOut)
if !result.OK {
if shouldRemoveLoginRequestedScope(result) {
cleanupRequestedScope()
}
return output.ErrAuth("authorization failed: %s", result.Message)
}
defer cleanupRequestedScope()
if result.Token == nil {
return output.ErrAuth("authorization succeeded but no token returned")
}
@@ -367,6 +383,8 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
return output.ErrAuth("failed to get user info: %v", err)
}
scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope)
// Store token
now := time.Now().UnixMilli()
storedToken := &larkauth.StoredUAToken{
@@ -384,21 +402,50 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
}
// Update config — overwrite Users to single user, clean old tokens
multi, _ := core.LoadMultiAppConfig()
if multi != nil && len(multi.Apps) > 0 {
app := &multi.Apps[0]
for _, oldUser := range app.Users {
if oldUser.UserOpenId != openId {
larkauth.RemoveStoredToken(config.AppID, oldUser.UserOpenId)
}
}
app.Users = []core.AppUser{{UserOpenId: openId, UserName: userName}}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
_ = larkauth.RemoveStoredToken(config.AppID, openId)
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil {
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName)
}
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary)
return nil
}
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
multi, err := core.LoadMultiAppConfig()
if err != nil {
return fmt.Errorf("load config: %w", err)
}
app := findProfileByName(multi, profileName)
if app == nil {
return fmt.Errorf("profile %q not found in config", profileName)
}
oldUsers := append([]core.AppUser(nil), app.Users...)
app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}}
if err := core.SaveMultiAppConfig(multi); err != nil {
return fmt.Errorf("save config: %w", err)
}
for _, oldUser := range oldUsers {
if oldUser.UserOpenId != openID {
_ = larkauth.RemoveStoredToken(appID, oldUser.UserOpenId)
}
}
return nil
}
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
for i := range multi.Apps {
if multi.Apps[i].ProfileName() == profileName {
return &multi.Apps[i]
}
}
return nil
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
)
func setupLoginConfigDir(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
}
func TestSyncLoginUserToProfile_UpdatesOnlyTargetProfile(t *testing.T) {
setupLoginConfigDir(t)
multi := &core.MultiAppConfig{
CurrentApp: "target",
Apps: []core.AppConfig{
{
Name: "target",
AppId: "app-target",
Users: []core.AppUser{{UserOpenId: "ou_old", UserName: "old"}},
},
{
Name: "other",
AppId: "app-other",
Users: []core.AppUser{{UserOpenId: "ou_other", UserName: "other"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := syncLoginUserToProfile("target", "app-target", "ou_new", "new-user"); err != nil {
t.Fatalf("syncLoginUserToProfile() error = %v", err)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if got := saved.Apps[0].Users; len(got) != 1 || got[0].UserOpenId != "ou_new" || got[0].UserName != "new-user" {
t.Fatalf("target users = %#v, want replaced login user", got)
}
if got := saved.Apps[1].Users; len(got) != 1 || got[0].UserOpenId != "ou_other" {
t.Fatalf("other users = %#v, want unchanged", got)
}
}
func TestSyncLoginUserToProfile_ProfileNotFoundReturnsError(t *testing.T) {
setupLoginConfigDir(t)
multi := &core.MultiAppConfig{
Apps: []core.AppConfig{{
Name: "default",
AppId: "app-default",
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
err := syncLoginUserToProfile("missing", "app-default", "ou_new", "new-user")
if err == nil {
t.Fatal("expected error for missing profile")
}
if !strings.Contains(err.Error(), `profile "missing" not found`) {
t.Fatalf("error = %v, want missing profile", err)
}
}

View File

@@ -20,11 +20,17 @@ type loginMsg struct {
ConfirmAuth string
// Non-interactive prompts (login.go)
OpenURL string
WaitingAuth string
AuthSuccess string
LoginSuccess string
GrantedScopes string
OpenURL string
WaitingAuth string
AuthSuccess string
LoginSuccess string
ScopeMismatch string
ScopeHint string
RequestedScopes string
NewlyGrantedScopes string
MissingScopes string
NoScopes string
StatusHint string
// Non-interactive hint (no flags)
HintHeader string
@@ -50,11 +56,17 @@ var loginMsgZh = &loginMsg{
ErrNoDomain: "请至少选择一个业务域",
ConfirmAuth: "确认授权?",
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AuthSuccess: "授权成功,正在获取用户信息...",
LoginSuccess: "登录成功! 用户: %s (%s)",
GrantedScopes: " 已授权 scopes: %s\n",
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AuthSuccess: "授权成功,正在获取用户信息...",
LoginSuccess: "登录成功! 用户: %s (%s)",
ScopeMismatch: "授权完成,但以下请求 scopes 未被授予: %s",
ScopeHint: "以上结果是本次授权请求用户最终确认后的结果请勿持续重试Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
RequestedScopes: " 本次请求 scopes: %s\n",
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
MissingScopes: " 本次未授予 scopes: %s\n",
NoScopes: "(空)",
StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
HintHeader: "请指定要授权的权限:\n",
HintCommon1: " --recommend 授权推荐权限",
@@ -79,11 +91,17 @@ var loginMsgEn = &loginMsg{
ErrNoDomain: "please select at least one domain",
ConfirmAuth: "Confirm authorization?",
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AuthSuccess: "Authorization successful, fetching user info...",
LoginSuccess: "Login successful! User: %s (%s)",
GrantedScopes: " Granted scopes: %s\n",
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AuthSuccess: "Authorization successful, fetching user info...",
LoginSuccess: "Login successful! User: %s (%s)",
ScopeMismatch: "authorization completed, but these requested scopes were not granted: %s",
ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
RequestedScopes: " Requested scopes: %s\n",
NewlyGrantedScopes: " Newly granted scopes: %s\n",
MissingScopes: " Not granted scopes: %s\n",
NoScopes: "(none)",
StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
HintHeader: "Please specify the scopes to authorize:\n",
HintCommon1: " --recommend authorize recommended scopes",

View File

@@ -69,12 +69,6 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
t.Errorf("%s LoginSuccess has no format verb", lang)
}
// GrantedScopes should contain %s
got = fmt.Sprintf(msg.GrantedScopes, "scope1 scope2")
if got == msg.GrantedScopes {
t.Errorf("%s GrantedScopes has no format verb", lang)
}
// SummaryDomains should contain %s
got = fmt.Sprintf(msg.SummaryDomains, "calendar, task")
if got == msg.SummaryDomains {

235
cmd/auth/login_result.go Normal file
View File

@@ -0,0 +1,235 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"fmt"
"strings"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
type loginScopeSummary struct {
Requested []string
NewlyGranted []string
AlreadyGranted []string
Granted []string
Missing []string
}
type loginScopeIssue struct {
Message string
Hint string
Summary *loginScopeSummary
}
// ensureRequestedScopesGranted checks whether all requested scopes were granted
// and returns a structured issue when any requested scope is missing.
func ensureRequestedScopesGranted(requestedScope, grantedScope string, msg *loginMsg, summary *loginScopeSummary) *loginScopeIssue {
requested := uniqueScopeList(requestedScope)
if len(requested) == 0 {
return nil
}
missing := larkauth.MissingScopes(grantedScope, requested)
if len(missing) == 0 {
return nil
}
if summary == nil {
summary = &loginScopeSummary{
Requested: requested,
Granted: strings.Fields(grantedScope),
Missing: missing,
}
}
return &loginScopeIssue{
Message: fmt.Sprintf(msg.ScopeMismatch, strings.Join(missing, " ")),
Hint: msg.ScopeHint,
Summary: summary,
}
}
// loadLoginScopeSummary builds a scope summary by comparing the requested scopes,
// previously stored scopes, and the newly granted scopes from the current login.
func loadLoginScopeSummary(appID, openId, requestedScope, grantedScope string) *loginScopeSummary {
previousScope := ""
if previous := larkauth.GetStoredToken(appID, openId); previous != nil {
previousScope = previous.Scope
}
return buildLoginScopeSummary(requestedScope, previousScope, grantedScope)
}
// buildLoginScopeSummary classifies requested scopes into newly granted,
// already granted, and missing buckets while preserving the final granted list.
func buildLoginScopeSummary(requestedScope, previousScope, grantedScope string) *loginScopeSummary {
requested := uniqueScopeList(requestedScope)
previous := uniqueScopeList(previousScope)
granted := uniqueScopeList(grantedScope)
previousSet := make(map[string]bool, len(previous))
for _, scope := range previous {
previousSet[scope] = true
}
grantedSet := make(map[string]bool, len(granted))
for _, scope := range granted {
grantedSet[scope] = true
}
summary := &loginScopeSummary{
Requested: requested,
Granted: granted,
}
for _, scope := range requested {
if !grantedSet[scope] {
summary.Missing = append(summary.Missing, scope)
continue
}
if previousSet[scope] {
summary.AlreadyGranted = append(summary.AlreadyGranted, scope)
continue
}
summary.NewlyGranted = append(summary.NewlyGranted, scope)
}
return summary
}
// uniqueScopeList splits a scope string into a de-duplicated ordered slice.
func uniqueScopeList(scope string) []string {
seen := make(map[string]bool)
var result []string
for _, item := range strings.Fields(scope) {
if seen[item] {
continue
}
seen[item] = true
result = append(result, item)
}
return result
}
// formatScopeList joins scopes for display and falls back to the provided empty
// label when the input slice is empty.
func formatScopeList(scopes []string, empty string) string {
if len(scopes) == 0 {
return empty
}
return strings.Join(scopes, " ")
}
// emptyIfNil normalizes nil slices to empty slices for stable JSON output.
func emptyIfNil(s []string) []string {
if s == nil {
return []string{}
}
return s
}
// writeLoginScopeBreakdown renders the requested/newly granted/missing scope
// breakdown to stderr.
func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary *loginScopeSummary) {
if summary == nil {
summary = &loginScopeSummary{}
}
fmt.Fprintf(errOut.ErrOut, msg.RequestedScopes, formatScopeList(summary.Requested, msg.NoScopes))
fmt.Fprintf(errOut.ErrOut, msg.NewlyGrantedScopes, formatScopeList(summary.NewlyGranted, msg.NoScopes))
fmt.Fprintf(errOut.ErrOut, msg.MissingScopes, formatScopeList(summary.Missing, msg.NoScopes))
}
// writeLoginSuccess emits the successful login payload in either JSON or text
// format together with the computed scope breakdown.
func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, openId, userName string, summary *loginScopeSummary) {
if summary == nil {
summary = &loginScopeSummary{}
}
if opts.JSON {
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, summary, nil))
fmt.Fprintln(f.IOStreams.Out, string(b))
return
}
fmt.Fprintln(f.IOStreams.ErrOut)
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
writeLoginScopeBreakdown(f.IOStreams, msg, summary)
if len(summary.Missing) == 0 && msg.StatusHint != "" {
fmt.Fprintln(f.IOStreams.ErrOut, msg.StatusHint)
}
}
// handleLoginScopeIssue prints or returns a structured missing-scope result
// while preserving a successful login outcome when authorization completed.
func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, issue *loginScopeIssue, openId, userName string) error {
if issue == nil {
return nil
}
loginSucceeded := openId != ""
if opts.JSON {
if loginSucceeded {
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue))
fmt.Fprintln(f.IOStreams.Out, string(b))
return nil
}
detail := map[string]interface{}{
"requested": issue.Summary.Requested,
"granted": issue.Summary.Granted,
"missing": issue.Summary.Missing,
}
return &output.ExitError{
Code: output.ExitAuth,
Detail: &output.ErrDetail{
Type: "missing_scope",
Message: issue.Message,
Hint: issue.Hint,
Detail: detail,
},
}
}
fmt.Fprintln(f.IOStreams.ErrOut)
if loginSucceeded {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
} else {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
}
if loginSucceeded {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
}
writeLoginScopeBreakdown(f.IOStreams, msg, issue.Summary)
if issue.Hint != "" {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint)
}
if loginSucceeded {
return nil
}
return output.ErrBare(output.ExitAuth)
}
// authorizationCompletePayload builds the JSON payload for a completed login,
// optionally attaching a warning when requested scopes are missing.
func authorizationCompletePayload(openId, userName string, summary *loginScopeSummary, issue *loginScopeIssue) map[string]interface{} {
if summary == nil {
summary = &loginScopeSummary{}
}
payload := map[string]interface{}{
"event": "authorization_complete",
"user_open_id": openId,
"user_name": userName,
"scope": strings.Join(summary.Granted, " "),
"requested": emptyIfNil(summary.Requested),
"newly_granted": emptyIfNil(summary.NewlyGranted),
"already_granted": emptyIfNil(summary.AlreadyGranted),
"missing": emptyIfNil(summary.Missing),
"granted": emptyIfNil(summary.Granted),
}
if issue != nil {
payload["warning"] = map[string]interface{}{
"type": "missing_scope",
"message": issue.Message,
"hint": issue.Hint,
}
}
return payload
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"regexp"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
var loginScopeCacheSafeChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
type loginScopeCacheRecord struct {
RequestedScope string `json:"requested_scope"`
}
// loginScopeCacheDir returns the directory used to persist auth login --no-wait
// requested scopes keyed by device_code.
func loginScopeCacheDir() string {
return filepath.Join(core.GetConfigDir(), "cache", "auth_login_scopes")
}
// loginScopeCachePath returns the cache file path for a given device_code.
func loginScopeCachePath(deviceCode string) string {
return filepath.Join(loginScopeCacheDir(), sanitizeLoginScopeCacheKey(deviceCode)+".json")
}
// sanitizeLoginScopeCacheKey converts a device_code into a safe filename token.
func sanitizeLoginScopeCacheKey(deviceCode string) string {
sanitized := loginScopeCacheSafeChars.ReplaceAllString(deviceCode, "_")
if sanitized == "" {
return "default"
}
return sanitized
}
// saveLoginRequestedScope persists the requested scope string for a device_code.
func saveLoginRequestedScope(deviceCode, requestedScope string) error {
if err := vfs.MkdirAll(loginScopeCacheDir(), 0700); err != nil {
return err
}
data, err := json.Marshal(loginScopeCacheRecord{RequestedScope: requestedScope})
if err != nil {
return err
}
return validate.AtomicWrite(loginScopeCachePath(deviceCode), data, 0600)
}
// loadLoginRequestedScope loads the cached requested scope string for a device_code.
// It returns an empty string if no cache entry exists.
func loadLoginRequestedScope(deviceCode string) (string, error) {
data, err := vfs.ReadFile(loginScopeCachePath(deviceCode))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
return "", err
}
var record loginScopeCacheRecord
if err := json.Unmarshal(data, &record); err != nil {
_ = vfs.Remove(loginScopeCachePath(deviceCode))
return "", err
}
return record.RequestedScope, nil
}
// removeLoginRequestedScope deletes the cache entry for a device_code.
func removeLoginRequestedScope(deviceCode string) error {
err := vfs.Remove(loginScopeCachePath(deviceCode))
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
// shouldRemoveLoginRequestedScope indicates whether the requested-scope cache
// should be removed after polling finishes.
func shouldRemoveLoginRequestedScope(result *larkauth.DeviceFlowResult) bool {
if result == nil {
return false
}
if result.OK || result.Error == "access_denied" {
return true
}
return result.Error == "expired_token" && result.Message != "Polling was cancelled"
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"os"
"testing"
"github.com/larksuite/cli/internal/vfs"
)
func TestLoginRequestedScopeCache_RoundTrip(t *testing.T) {
setupLoginConfigDir(t)
deviceCode := "device/code:123"
requestedScope := "im:message:send im:message:reply"
if err := saveLoginRequestedScope(deviceCode, requestedScope); err != nil {
t.Fatalf("saveLoginRequestedScope() error = %v", err)
}
got, err := loadLoginRequestedScope(deviceCode)
if err != nil {
t.Fatalf("loadLoginRequestedScope() error = %v", err)
}
if got != requestedScope {
t.Fatalf("requestedScope = %q, want %q", got, requestedScope)
}
if _, err := vfs.Stat(loginScopeCachePath(deviceCode)); err != nil {
t.Fatalf("Stat(cachePath) error = %v", err)
}
if err := removeLoginRequestedScope(deviceCode); err != nil {
t.Fatalf("removeLoginRequestedScope() error = %v", err)
}
if _, err := vfs.Stat(loginScopeCachePath(deviceCode)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("Stat(cachePath) error = %v, want not exist", err)
}
}
func TestLoadLoginRequestedScope_MissingReturnsEmpty(t *testing.T) {
setupLoginConfigDir(t)
got, err := loadLoginRequestedScope("missing-device-code")
if err != nil {
t.Fatalf("loadLoginRequestedScope() error = %v", err)
}
if got != "" {
t.Fatalf("requestedScope = %q, want empty", got)
}
}

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
func TestAuthLogin_StrictModeBot_Blocked(t *testing.T) {
cfg := &core.CliConfig{
AppID: "a", AppSecret: "s",
SupportedIdentities: uint8(extcred.SupportsBot),
}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
var called bool
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {
called = true
return nil
})
cmd.SetArgs([]string{"--scope", "contact:user.base:readonly"})
err := cmd.Execute()
if called {
t.Error("runF should not be called in bot strict mode")
}
if err == nil {
t.Fatal("expected error in bot strict mode")
}
if !strings.Contains(err.Error(), "strict mode") {
t.Errorf("error should mention strict mode, got: %v", err)
}
}
func TestAuthLogin_StrictModeUser_Allowed(t *testing.T) {
cfg := &core.CliConfig{
AppID: "a", AppSecret: "s",
SupportedIdentities: uint8(extcred.SupportsUser),
}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
var called bool
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {
called = true
return nil
})
cmd.SetArgs([]string{"--scope", "contact:user.base:readonly"})
err := cmd.Execute()
if !called {
t.Error("runF should be called in user strict mode")
}
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestAuthLogin_StrictModeOff_Allowed(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
var called bool
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {
called = true
return nil
})
cmd.SetArgs([]string{"--scope", "contact:user.base:readonly"})
err := cmd.Execute()
if !called {
t.Error("runF should be called when strict mode is off")
}
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}

View File

@@ -5,16 +5,29 @@ package auth
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"sort"
"strings"
"testing"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts/common"
"github.com/zalando/go-keyring"
)
type failWriter struct{}
func (failWriter) Write([]byte) (int, error) {
return 0, errors.New("write failed")
}
func TestSuggestDomain_PrefixMatch(t *testing.T) {
known := map[string]bool{
"calendar": true,
@@ -282,6 +295,606 @@ func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
}
}
func TestEnsureRequestedScopesGranted(t *testing.T) {
issue := ensureRequestedScopesGranted("im:message:send im:message:reply", "im:message:reply", getLoginMsg("en"), nil)
if issue == nil {
t.Fatal("expected missing scope issue")
}
if !strings.Contains(issue.Message, "im:message:send") {
t.Fatalf("message %q missing requested scope", issue.Message)
}
for _, want := range []string{"Do not retry continuously", "scope being disabled", "lark-cli auth status"} {
if !strings.Contains(issue.Hint, want) {
t.Fatalf("hint %q missing %q", issue.Hint, want)
}
}
if got := strings.Join(issue.Summary.Missing, " "); got != "im:message:send" {
t.Fatalf("Missing = %q", got)
}
}
func TestBuildLoginScopeSummary(t *testing.T) {
summary := buildLoginScopeSummary("im:message:send im:message:reply im:message:send", "im:message:reply", "im:message:send im:message:reply im:chat:read")
if got := strings.Join(summary.Requested, " "); got != "im:message:send im:message:reply" {
t.Fatalf("Requested = %q", got)
}
if got := strings.Join(summary.NewlyGranted, " "); got != "im:message:send" {
t.Fatalf("NewlyGranted = %q", got)
}
if got := strings.Join(summary.AlreadyGranted, " "); got != "im:message:reply" {
t.Fatalf("AlreadyGranted = %q", got)
}
if len(summary.Missing) != 0 {
t.Fatalf("Missing = %v, want empty", summary.Missing)
}
if got := strings.Join(summary.Granted, " "); got != "im:message:send im:message:reply im:chat:read" {
t.Fatalf("Granted = %q", got)
}
}
func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
Requested: []string{"im:message:send", "im:message:reply"},
NewlyGranted: []string{"im:message:send"},
AlreadyGranted: []string{"im:message:reply"},
Granted: []string{"im:message:send", "im:message:reply"},
})
var data map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
}
if data["event"] != "authorization_complete" {
t.Fatalf("event = %v", data["event"])
}
if data["scope"] != "im:message:send im:message:reply" {
t.Fatalf("scope = %v", data["scope"])
}
if len(data["newly_granted"].([]interface{})) != 1 {
t.Fatalf("newly_granted = %#v", data["newly_granted"])
}
if len(data["already_granted"].([]interface{})) != 1 {
t.Fatalf("already_granted = %#v", data["already_granted"])
}
}
func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{
Message: "授权完成,但以下请求 scopes 未被授予: im:message:send",
Hint: "以上结果是本次授权请求用户最终确认后的结果请勿持续重试Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
Summary: &loginScopeSummary{
Requested: []string{"im:message:send"},
Missing: []string{"im:message:send"},
Granted: []string{"base:app:copy"},
},
}, "ou_user", "tester")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
got := stderr.String()
for _, want := range []string{
"OK: 登录成功! 用户: tester (ou_user)",
"授权完成,但以下请求 scopes 未被授予: im:message:send",
"本次请求 scopes: im:message:send",
"本次新授予 scopes: (空)",
"本次未授予 scopes: im:message:send",
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
"scope 被禁用",
"lark-cli auth status",
} {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
if strings.Contains(got, "最终已授权 scopes:") {
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
}
if strings.Contains(got, "ERROR:") {
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
}
}
func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, &loginScopeIssue{
Message: "authorization completed, but these requested scopes were not granted: im:message:send",
Hint: "Granted scopes: base:app:copy. Check app scopes.",
Summary: &loginScopeSummary{
Requested: []string{"im:message:send"},
Missing: []string{"im:message:send"},
Granted: []string{"base:app:copy"},
},
}, "ou_user", "tester")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
}
if data["event"] != "authorization_complete" {
t.Fatalf("event = %v", data["event"])
}
if data["user_open_id"] != "ou_user" {
t.Fatalf("user_open_id = %v", data["user_open_id"])
}
warning, ok := data["warning"].(map[string]interface{})
if !ok {
t.Fatalf("warning = %#v", data["warning"])
}
if warning["type"] != "missing_scope" {
t.Fatalf("warning.type = %v", warning["type"])
}
}
func TestWriteLoginSuccess_JSONEmptySlicesNotNull(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
Granted: []string{"offline_access"},
})
var data map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
}
for _, k := range []string{"requested", "newly_granted", "already_granted", "missing", "granted"} {
v, ok := data[k]
if !ok {
t.Fatalf("missing key %q in payload: %v", k, data)
}
if _, ok := v.([]interface{}); !ok {
t.Fatalf("%s = %#v, want JSON array", k, v)
}
}
}
func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
tests := []struct {
name string
summary *loginScopeSummary
expectedPresent []string
expectedAbsent []string
}{
{
name: "mixed newly granted and already granted",
summary: &loginScopeSummary{
Requested: []string{"im:message:send", "im:message:reply"},
NewlyGranted: []string{"im:message:send"},
AlreadyGranted: []string{"im:message:reply"},
Granted: []string{"im:message:send", "im:message:reply"},
},
expectedPresent: []string{
"登录成功! 用户: tester (ou_user)",
"本次请求 scopes: im:message:send im:message:reply",
"本次新授予 scopes: im:message:send",
"本次未授予 scopes: (空)",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
},
expectedAbsent: []string{
"最终已授权 scopes:",
"已有 scopes:",
},
},
{
name: "all already granted",
summary: &loginScopeSummary{
Requested: []string{"im:message:send"},
AlreadyGranted: []string{"im:message:send"},
Granted: []string{"im:message:send", "contact:user.base:readonly"},
},
expectedPresent: []string{
"本次请求 scopes: im:message:send",
"本次新授予 scopes: (空)",
"本次未授予 scopes: (空)",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
},
expectedAbsent: []string{
"最终已授权 scopes:",
"已有 scopes:",
},
},
{
name: "missing scopes are shown",
summary: &loginScopeSummary{
Requested: []string{"im:message:send", "im:message:reply"},
Missing: []string{"im:message:send"},
Granted: []string{"im:message:reply"},
},
expectedPresent: []string{
"本次请求 scopes: im:message:send im:message:reply",
"本次新授予 scopes: (空)",
"本次未授予 scopes: im:message:send",
},
expectedAbsent: []string{
"已有 scopes:",
"最终已授权 scopes:",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
writeLoginSuccess(&LoginOptions{}, getLoginMsg("zh"), f, "ou_user", "tester", tt.summary)
got := stderr.String()
for _, want := range tt.expectedPresent {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
for _, unwanted := range tt.expectedAbsent {
if strings.Contains(got, unwanted) {
t.Fatalf("stderr should not contain %q, got:\n%s", unwanted, got)
}
}
})
}
}
func TestBuildLoginScopeSummary_WithMissingScopes(t *testing.T) {
summary := buildLoginScopeSummary("im:message:send im:message:reply", "im:message:reply", "im:message:reply")
if got := strings.Join(summary.NewlyGranted, " "); got != "" {
t.Fatalf("NewlyGranted = %q, want empty", got)
}
if got := strings.Join(summary.AlreadyGranted, " "); got != "im:message:reply" {
t.Fatalf("AlreadyGranted = %q", got)
}
if got := strings.Join(summary.Missing, " "); got != "im:message:send" {
t.Fatalf("Missing = %q", got)
}
}
func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{Name: "default", AppId: "cli_test"},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 0,
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthTokenV2,
Body: map[string]interface{}{
"access_token": "user-access-token",
"refresh_token": "refresh-token",
"expires_in": 7200,
"refresh_token_expires_in": 604800,
"scope": "offline_access",
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: larkauth.PathUserInfoV1,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"open_id": "ou_user",
"name": "tester",
},
},
})
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
Scope: "im:message:send",
})
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
got := stderr.String()
for _, want := range []string{
"OK: 登录成功! 用户: tester (ou_user)",
"授权完成,但以下请求 scopes 未被授予: im:message:send",
"本次请求 scopes: im:message:send",
"本次未授予 scopes: im:message:send",
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
"scope 被禁用",
"lark-cli auth status",
} {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
if strings.Contains(got, "最终已授权 scopes:") {
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
}
if strings.Contains(got, "ERROR:") {
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
}
stored := larkauth.GetStoredToken("cli_test", "ou_user")
if stored == nil {
t.Fatal("expected token to be stored when authorization succeeds with missing scopes")
}
if stored.Scope != "offline_access" {
t.Fatalf("stored scope = %q", stored.Scope)
}
cfg, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(cfg.Apps) != 1 || len(cfg.Apps[0].Users) != 1 {
t.Fatalf("unexpected users in config: %#v", cfg.Apps)
}
if cfg.Apps[0].Users[0].UserOpenId != "ou_user" {
t.Fatalf("stored user open id = %q", cfg.Apps[0].Users[0].UserOpenId)
}
if cfg.Apps[0].Users[0].UserName != "tester" {
t.Fatalf("stored user name = %q", cfg.Apps[0].Users[0].UserName)
}
}
func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{Name: "default", AppId: "cli_test"},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 0,
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthTokenV2,
Body: map[string]interface{}{
"access_token": "user-access-token",
"refresh_token": "refresh-token",
"expires_in": 7200,
"refresh_token_expires_in": 604800,
"scope": "im:message:send offline_access",
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: larkauth.PathUserInfoV1,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"open_id": "ou_user",
"name": "tester",
},
},
})
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
Scope: "im:message:send",
NoWait: true,
})
if err != nil {
t.Fatalf("no-wait authLoginRun() error = %v", err)
}
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "im:message:send" {
t.Fatalf("loadLoginRequestedScope() = (%q, %v), want requested scope", got, err)
}
stdout.Reset()
stderr.Reset()
err = authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
DeviceCode: "device-code",
})
if err != nil {
t.Fatalf("device-code authLoginRun() error = %v", err)
}
got := stderr.String()
for _, want := range []string{
"OK: 登录成功! 用户: tester (ou_user)",
"本次请求 scopes: im:message:send",
"本次新授予 scopes: im:message:send",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
} {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
if strings.Contains(got, "最终已授权 scopes:") {
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
}
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "" {
t.Fatalf("loadLoginRequestedScope() after cleanup = (%q, %v), want empty", got, err)
}
}
func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScopes(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
writeLoginSuccess(&LoginOptions{}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
Requested: []string{"im:message:send"},
NewlyGranted: []string{"im:message:send"},
Granted: []string{"im:message:send"},
})
got := stderr.String()
for _, want := range []string{
"Login successful! User: tester (ou_user)",
"Requested scopes: im:message:send",
"Newly granted scopes: im:message:send",
"Not granted scopes: (none)",
"Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
} {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
}
func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
if err := saveLoginRequestedScope("device-code", "im:message:send"); err != nil {
t.Fatalf("saveLoginRequestedScope() error = %v", err)
}
original := pollDeviceToken
t.Cleanup(func() { pollDeviceToken = original })
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
return &larkauth.DeviceFlowResult{OK: true, Token: nil}
}
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
DeviceCode: "device-code",
})
if err == nil {
t.Fatal("expected error for nil token")
}
if !strings.Contains(err.Error(), "authorization succeeded but no token returned") {
t.Fatalf("error = %v, want nil token error", err)
}
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "" {
t.Fatalf("loadLoginRequestedScope() after nil token = (%q, %v), want empty", got, err)
}
}
func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
f.IOStreams.Out = failWriter{}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 5,
},
})
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
Scope: "im:message:send",
NoWait: true,
JSON: true,
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "failed to write JSON output") {
t.Fatalf("error = %v, want JSON write failure", err)
}
}
func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
f.IOStreams.Out = failWriter{}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 5,
},
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: ctx,
Scope: "im:message:send",
JSON: true,
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "failed to write JSON output") {
t.Fatalf("error = %v, want JSON write failure", err)
}
}
func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {

View File

@@ -46,8 +46,8 @@ func authLogoutRun(opts *LogoutOptions) error {
return nil
}
app := &multi.Apps[0]
if len(app.Users) == 0 {
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 {
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
return nil
}

30
cmd/bootstrap.go Normal file
View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"errors"
"io"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/pflag"
)
// BootstrapInvocationContext extracts global invocation options before
// the real command tree is built, so provider-backed config resolution sees
// the correct profile from the start.
func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error) {
var globals GlobalOptions
fs := pflag.NewFlagSet("bootstrap", pflag.ContinueOnError)
fs.ParseErrorsAllowlist.UnknownFlags = true
fs.SetInterspersed(true)
fs.SetOutput(io.Discard)
RegisterGlobalFlags(fs, &globals)
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
return cmdutil.InvocationContext{}, err
}
return cmdutil.InvocationContext{Profile: globals.Profile}, nil
}

72
cmd/bootstrap_test.go Normal file
View File

@@ -0,0 +1,72 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import "testing"
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
if err != nil {
t.Fatalf("BootstrapInvocationContext() error = %v", err)
}
if inv.Profile != "target" {
t.Fatalf("BootstrapInvocationContext() profile = %q, want %q", inv.Profile, "target")
}
}
func TestBootstrapInvocationContext_ProfileEquals(t *testing.T) {
inv, err := BootstrapInvocationContext([]string{"auth", "status", "--profile=target"})
if err != nil {
t.Fatalf("BootstrapInvocationContext() error = %v", err)
}
if inv.Profile != "target" {
t.Fatalf("BootstrapInvocationContext() profile = %q, want %q", inv.Profile, "target")
}
}
func TestBootstrapInvocationContext_IgnoresUnknownFlags(t *testing.T) {
inv, err := BootstrapInvocationContext([]string{"auth", "status", "--verify", "--profile", "target"})
if err != nil {
t.Fatalf("BootstrapInvocationContext() error = %v", err)
}
if inv.Profile != "target" {
t.Fatalf("BootstrapInvocationContext() profile = %q, want %q", inv.Profile, "target")
}
}
func TestBootstrapInvocationContext_MissingProfileValue(t *testing.T) {
if _, err := BootstrapInvocationContext([]string{"auth", "status", "--profile"}); err == nil {
t.Fatal("BootstrapInvocationContext() error = nil, want non-nil")
}
}
func TestBootstrapInvocationContext_HelpFlag(t *testing.T) {
inv, err := BootstrapInvocationContext([]string{"--help"})
if err != nil {
t.Fatalf("--help should not error, got: %v", err)
}
if inv.Profile != "" {
t.Fatalf("profile = %q, want empty", inv.Profile)
}
}
func TestBootstrapInvocationContext_ShortHelp(t *testing.T) {
inv, err := BootstrapInvocationContext([]string{"-h"})
if err != nil {
t.Fatalf("-h should not error, got: %v", err)
}
if inv.Profile != "" {
t.Fatalf("profile = %q, want empty", inv.Profile)
}
}
func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "--help"})
if err != nil {
t.Fatalf("--profile + --help should not error, got: %v", err)
}
if inv.Profile != "target" {
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
}
}

View File

@@ -21,12 +21,10 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigRemove(f, nil))
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
return cmd
}
func parseBrand(value string) core.LarkBrand {
if value == "lark" {
return core.BrandLark
}
return core.BrandFeishu
return core.ParseBrand(value)
}

View File

@@ -5,13 +5,35 @@ package config
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
type noopConfigKeychain struct{}
func (n *noopConfigKeychain) Get(service, account string) (string, error) { return "", nil }
func (n *noopConfigKeychain) Set(service, account, value string) error { return nil }
func (n *noopConfigKeychain) Remove(service, account string) error { return nil }
type recordingConfigKeychain struct {
removed []string
}
func (r *recordingConfigKeychain) Get(service, account string) (string, error) { return "", nil }
func (r *recordingConfigKeychain) Set(service, account, value string) error { return nil }
func (r *recordingConfigKeychain) Remove(service, account string) error {
r.removed = append(r.removed, service+":"+account)
return nil
}
func TestConfigInitCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret123\n")
@@ -56,6 +78,60 @@ func TestConfigShowCmd_FlagParsing(t *testing.T) {
}
}
func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configShowRun(&ConfigShowOptions{Factory: f})
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError", err)
}
if exitErr.Code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "not configured" {
t.Fatalf("detail = %#v, want config/not configured", exitErr.Detail)
}
}
func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "missing",
Apps: []core.AppConfig{{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configShowRun(&ConfigShowOptions{Factory: f})
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError", err)
}
if exitErr.Code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "no active profile" {
t.Fatalf("detail = %#v, want config/no active profile", exitErr.Detail)
}
}
func TestConfigInitCmd_LangFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -157,3 +233,110 @@ func TestConfigRemoveCmd_FlagParsing(t *testing.T) {
t.Fatal("expected factory to be preserved in options")
}
}
func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
multi := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: "app-test",
AppSecret: core.SecretInput{
Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-test"},
},
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "Tester"}},
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
kc := &recordingConfigKeychain{}
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.Keychain = kc
// Make subsequent config saves fail while keeping the existing config readable.
if err := os.Chmod(configDir, 0500); err != nil {
t.Fatalf("Chmod(%s) error = %v", configDir, err)
}
defer os.Chmod(configDir, 0700)
err := configRemoveRun(&ConfigRemoveOptions{Factory: f})
if err == nil {
t.Fatal("expected save failure")
}
if !strings.Contains(err.Error(), "failed to save config") {
t.Fatalf("error = %v, want failed to save config", err)
}
if len(kc.removed) != 0 {
t.Fatalf("expected no keychain cleanup before successful save, got removals: %v", kc.removed)
}
// Restore permissions and confirm the original config is still intact.
if err := os.Chmod(configDir, 0700); err != nil {
t.Fatalf("restore Chmod(%s) error = %v", configDir, err)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if saved == nil || len(saved.Apps) != 1 || saved.Apps[0].AppId != "app-test" {
t.Fatalf("saved config = %#v, want original single app preserved", saved)
}
if got := saved.Apps[0].AppSecret.Ref; got == nil || got.ID != "appsecret:app-test" {
t.Fatalf("saved app secret ref = %#v, want preserved keychain ref", got)
}
configPath := filepath.Join(configDir, "config.json")
if _, err := os.Stat(configPath); err != nil {
t.Fatalf("expected existing config file to remain, stat error = %v", err)
}
}
func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
existing := &core.MultiAppConfig{
Apps: []core.AppConfig{
{
Name: "prod",
AppId: "cli_prod",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
},
},
}
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en")
if err == nil {
t.Fatal("expected conflict error")
}
if !strings.Contains(err.Error(), "conflicts with existing appId") {
t.Fatalf("error = %v, want conflict with existing appId", err)
}
}
func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
multi := &core.MultiAppConfig{
CurrentApp: "prod",
Apps: []core.AppConfig{
{
Name: "prod",
AppId: "app-old",
AppSecret: core.SecretInput{Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-old"}},
Brand: core.BrandFeishu,
Lang: "zh",
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "User"}},
},
},
}
err := updateExistingProfileWithoutSecret(multi, "", "app-new", core.BrandLark, "en")
if err == nil {
t.Fatal("expected error when changing app ID without a new secret")
}
if !strings.Contains(err.Error(), "App Secret") {
t.Fatalf("error = %v, want mention of App Secret", err)
}
}

View File

@@ -25,8 +25,13 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
}
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
}
if len(args) == 0 {
current := multi.Apps[0].DefaultAs
current := app.DefaultAs
if current == "" {
current = "auto"
}
@@ -39,9 +44,9 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
return output.ErrValidation("invalid identity type %q, valid values: user | bot | auto", value)
}
multi.Apps[0].DefaultAs = value
app.DefaultAs = core.Identity(value)
if err := core.SaveMultiAppConfig(multi); err != nil {
return fmt.Errorf("failed to save config: %w", err)
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)
return nil

View File

@@ -6,6 +6,7 @@ package config
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"strings"
@@ -16,6 +17,7 @@ import (
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
@@ -29,7 +31,8 @@ type ConfigInitOptions struct {
Brand string
New bool
Lang string
langExplicit bool // true when --lang was explicitly passed
langExplicit bool // true when --lang was explicitly passed
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
}
// NewCmdConfigInit creates the config init subcommand.
@@ -59,6 +62,7 @@ verification URL from its output.`,
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
return cmd
}
@@ -94,6 +98,110 @@ func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand,
return core.SaveMultiAppConfig(config)
}
// saveInitConfig saves a new/updated app config, respecting --profile mode.
// With profileName: appends or updates the named profile (preserves other profiles).
// Without profileName: cleans up old config and saves as the only app.
func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
if profileName != "" {
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
}
cleanupOldConfig(existing, f, appId)
return saveAsOnlyApp(appId, secret, brand, lang)
}
// saveAsProfile appends or updates a named profile in the config.
// If a profile with the same name exists, it updates it; otherwise appends.
// When updating, cleans up old keychain secrets if AppId changed.
func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
multi := existing
if multi == nil {
multi = &core.MultiAppConfig{}
}
if idx := findProfileIndexByName(multi, profileName); idx >= 0 {
// Clean up old keychain secret and user tokens if AppId changed
if multi.Apps[idx].AppId != appId {
core.RemoveSecretStore(multi.Apps[idx].AppSecret, kc)
for _, user := range multi.Apps[idx].Users {
auth.RemoveStoredToken(multi.Apps[idx].AppId, user.UserOpenId)
}
multi.Apps[idx].Users = []core.AppUser{}
}
// Update existing profile
multi.Apps[idx].AppId = appId
multi.Apps[idx].AppSecret = secret
multi.Apps[idx].Brand = brand
multi.Apps[idx].Lang = lang
} else {
if findAppIndexByAppID(multi, profileName) >= 0 {
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
}
// Append new profile
multi.Apps = append(multi.Apps, core.AppConfig{
Name: profileName,
AppId: appId,
AppSecret: secret,
Brand: brand,
Lang: lang,
Users: []core.AppUser{},
})
}
return core.SaveMultiAppConfig(multi)
}
func findProfileIndexByName(multi *core.MultiAppConfig, profileName string) int {
if multi == nil {
return -1
}
for i := range multi.Apps {
if multi.Apps[i].Name == profileName {
return i
}
}
return -1
}
func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
if multi == nil {
return -1
}
for i := range multi.Apps {
if multi.Apps[i].AppId == appID {
return i
}
}
return -1
}
func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error {
if existing == nil {
return output.ErrValidation("App Secret cannot be empty for new configuration")
}
var app *core.AppConfig
if profileName != "" {
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
app = &existing.Apps[idx]
} else {
return output.ErrValidation("App Secret cannot be empty for new profile")
}
} else {
app = existing.CurrentAppConfig("")
if app == nil {
return output.ErrValidation("App Secret cannot be empty for new configuration")
}
}
if app.AppId != appID {
return output.ErrValidation("App Secret cannot be empty when changing App ID")
}
app.AppId = appID
app.Brand = brand
app.Lang = lang
return core.SaveMultiAppConfig(existing)
}
func configInitRun(opts *ConfigInitOptions) error {
f := opts.Factory
@@ -117,6 +225,13 @@ func configInitRun(opts *ConfigInitOptions) error {
existing = nil // treat as empty
}
// Validate --profile name if set
if opts.ProfileName != "" {
if err := core.ValidateProfileName(opts.ProfileName); err != nil {
return output.ErrValidation("%v", err)
}
}
// Mode 1: Non-interactive
if opts.AppID != "" && opts.appSecret != "" {
brand := parseBrand(opts.Brand)
@@ -124,8 +239,7 @@ func configInitRun(opts *ConfigInitOptions) error {
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
cleanupOldConfig(existing, f, opts.AppID)
if err := saveAsOnlyApp(opts.AppID, secret, brand, opts.Lang); err != nil {
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
@@ -136,8 +250,10 @@ func configInitRun(opts *ConfigInitOptions) error {
// For interactive modes, prompt language selection if --lang was not explicitly set
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
savedLang := ""
if existing != nil && len(existing.Apps) > 0 {
savedLang = existing.Apps[0].Lang
if existing != nil {
if app := existing.CurrentAppConfig(""); app != nil {
savedLang = app.Lang
}
}
lang, err := promptLangSelection(savedLang)
if err != nil {
@@ -165,8 +281,7 @@ func configInitRun(opts *ConfigInitOptions) error {
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
cleanupOldConfig(existing, f, result.AppID)
if err := saveAsOnlyApp(result.AppID, secret, result.Brand, opts.Lang); err != nil {
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
@@ -191,21 +306,17 @@ func configInitRun(opts *ConfigInitOptions) error {
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
cleanupOldConfig(existing, f, result.AppID)
if err := saveAsOnlyApp(result.AppID, secret, result.Brand, opts.Lang); err != nil {
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
} else if result.Mode == "existing" && result.AppID != "" {
// Existing app with unchanged secret — update app ID and brand only
if existing != nil && len(existing.Apps) > 0 {
existing.Apps[0].AppId = result.AppID
existing.Apps[0].Brand = result.Brand
existing.Apps[0].Lang = opts.Lang
if err := core.SaveMultiAppConfig(existing); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
if err := updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang); err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
} else {
return output.ErrValidation("App Secret cannot be empty for new configuration")
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
} else {
return output.ErrValidation("App ID and App Secret cannot be empty")
@@ -224,8 +335,8 @@ func configInitRun(opts *ConfigInitOptions) error {
// Mode 5: Legacy interactive (readline fallback)
firstApp := (*core.AppConfig)(nil)
if existing != nil && len(existing.Apps) > 0 {
firstApp = &existing.Apps[0]
if existing != nil {
firstApp = existing.CurrentAppConfig("")
}
reader := bufio.NewReader(f.IOStreams.In)
@@ -296,8 +407,7 @@ func configInitRun(opts *ConfigInitOptions) error {
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
cleanupOldConfig(existing, f, resolvedAppId)
if err := saveAsOnlyApp(resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))

View File

@@ -61,8 +61,8 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
// Load existing config for defaults
existing, _ := core.LoadMultiAppConfig()
var firstApp *core.AppConfig
if existing != nil && len(existing.Apps) > 0 {
firstApp = &existing.Apps[0]
if existing != nil {
firstApp = existing.CurrentAppConfig("")
}
var appID, appSecret, brand string

View File

@@ -44,19 +44,21 @@ func configRemoveRun(opts *ConfigRemoveOptions) error {
return output.ErrValidation("not configured yet")
}
// Clean up keychain entries for all apps
for _, app := range config.Apps {
core.RemoveSecretStore(app.AppSecret, f.Keychain)
for _, user := range app.Users {
auth.RemoveStoredToken(app.AppId, user.UserOpenId)
}
}
// Save empty config
// Save empty config first. If this fails, keep secrets and tokens intact so the
// existing config can still be retried instead of ending up half-removed.
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
if err := core.SaveMultiAppConfig(empty); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
// Clean up keychain entries for all apps after config is cleared.
for _, app := range config.Apps {
core.RemoveSecretStore(app.AppSecret, f.Keychain)
for _, user := range app.Users {
_ = auth.RemoveStoredToken(app.AppId, user.UserOpenId)
}
}
output.PrintSuccess(f.IOStreams.ErrOut, "Configuration removed")
userCount := 0
for _, app := range config.Apps {

View File

@@ -4,7 +4,9 @@
package config
import (
"errors"
"fmt"
"os"
"strings"
"github.com/larksuite/cli/internal/cmdutil"
@@ -40,12 +42,19 @@ func configShowRun(opts *ConfigShowOptions) error {
f := opts.Factory
config, err := core.LoadMultiAppConfig()
if err != nil || config == nil || len(config.Apps) == 0 {
fmt.Fprintf(f.IOStreams.ErrOut, "Not configured yet. Config file path: %s\n", core.GetConfigPath())
fmt.Fprintln(f.IOStreams.ErrOut, "Run `lark-cli config init` to initialize.")
return nil
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
}
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
}
if config == nil || len(config.Apps) == 0 {
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
}
app := config.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli profile list")
}
app := config.Apps[0]
users := "(no logged-in users)"
if len(app.Users) > 0 {
var userStrs []string
@@ -55,6 +64,7 @@ func configShowRun(opts *ConfigShowOptions) error {
users = strings.Join(userStrs, ", ")
}
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"profile": app.ProfileName(),
"appId": app.AppId,
"appSecret": "****",
"brand": app.Brand,

146
cmd/config/strict_mode.go Normal file
View File

@@ -0,0 +1,146 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
// NewCmdConfigStrictMode creates the "config strict-mode" subcommand.
func NewCmdConfigStrictMode(f *cmdutil.Factory) *cobra.Command {
var global bool
var reset bool
cmd := &cobra.Command{
Use: "strict-mode [bot|user|off]",
Short: "View or set strict mode (identity restriction policy)",
Long: `View or set strict mode (identity restriction policy).
Without arguments, shows the current strict mode status and its source.
Pass "bot", "user", or "off" to set strict mode.
Use --global to set at the global level.
Use --reset to clear the profile-level setting (inherit global).
Modes:
bot — only bot identity is allowed, user commands are hidden
user — only user identity is allowed, bot commands are hidden
off — no restriction (default)
WARNING: Strict mode is a security policy set by the administrator.
AI agents are strictly prohibited from modifying this setting.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
multi, err := core.LoadMultiAppConfig()
if err != nil {
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
}
if reset {
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
}
return resetStrictMode(f, multi, app, global, args)
}
if len(args) == 0 {
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
}
return showStrictMode(cmd.Context(), f, multi, app)
}
app := multi.CurrentAppConfig(f.Invocation.Profile)
if !global && app == nil {
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
}
return setStrictMode(f, multi, app, args[0], global)
},
}
cmd.Flags().BoolVar(&global, "global", false, "set at global level (applies to all profiles)")
cmd.Flags().BoolVar(&reset, "reset", false, "reset profile setting to inherit global")
return cmd
}
func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error {
if global {
return output.ErrValidation("--reset cannot be used with --global")
}
if len(args) > 0 {
return output.ErrValidation("--reset cannot be used with a value argument")
}
app.StrictMode = nil
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)")
return nil
}
func showStrictMode(ctx context.Context, f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig) error {
// Runtime effective mode from credential provider chain is the source of truth.
runtime := f.ResolveStrictMode(ctx)
configMode, configSource := resolveStrictModeStatus(multi, app)
if runtime != configMode {
fmt.Fprintf(f.IOStreams.Out, "strict-mode: %s (source: credential provider)\n", runtime)
return nil
}
fmt.Fprintf(f.IOStreams.Out, "strict-mode: %s (source: %s)\n", configMode, configSource)
return nil
}
func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, value string, global bool) error {
mode := core.StrictMode(value)
switch mode {
case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff:
default:
return output.ErrValidation("invalid value %q, valid values: bot | user | off", value)
}
if global {
multi.StrictMode = mode
for _, a := range multi.Apps {
if a.StrictMode != nil && *a.StrictMode != mode {
fmt.Fprintf(f.IOStreams.ErrOut,
"Warning: profile %q has strict-mode explicitly set to %q, "+
"which overrides the global setting. "+
"Use --reset in that profile to inherit global.\n",
a.ProfileName(), *a.StrictMode)
}
}
} else {
if app == nil {
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli config init")
}
app.StrictMode = &mode
}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
scope := "profile"
if global {
scope = "global"
}
fmt.Fprintf(f.IOStreams.ErrOut, "Strict mode set to %s (%s)\n", mode, scope)
return nil
}
func resolveStrictModeStatus(multi *core.MultiAppConfig, app *core.AppConfig) (core.StrictMode, string) {
if app != nil && app.StrictMode != nil {
return *app.StrictMode, fmt.Sprintf("profile %q", app.ProfileName())
}
if multi.StrictMode.IsActive() {
return multi.StrictMode, "global"
}
return core.StrictModeOff, "global (default)"
}

View File

@@ -0,0 +1,164 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
func setupStrictModeTestConfig(t *testing.T) {
t.Helper()
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
multi := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: "test-app",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatal(err)
}
}
func TestStrictMode_Show_Default(t *testing.T) {
setupStrictModeTestConfig(t)
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if !strings.Contains(stdout.String(), "off") {
t.Errorf("expected 'off' in output, got: %s", stdout.String())
}
}
func TestStrictMode_SetBot_Profile(t *testing.T) {
setupStrictModeTestConfig(t)
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"bot"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
multi, _ := core.LoadMultiAppConfig()
app := multi.CurrentAppConfig("")
if app.StrictMode == nil || *app.StrictMode != core.StrictModeBot {
t.Error("expected StrictMode=bot on profile")
}
}
func TestStrictMode_SetUser_Profile(t *testing.T) {
setupStrictModeTestConfig(t)
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"user"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
multi, _ := core.LoadMultiAppConfig()
app := multi.CurrentAppConfig("")
if app.StrictMode == nil || *app.StrictMode != core.StrictModeUser {
t.Error("expected StrictMode=user on profile")
}
}
func TestStrictMode_SetOff_Profile(t *testing.T) {
setupStrictModeTestConfig(t)
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"bot"})
cmd.Execute()
cmd = NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"off"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
multi, _ := core.LoadMultiAppConfig()
app := multi.CurrentAppConfig("")
if app.StrictMode == nil || *app.StrictMode != core.StrictModeOff {
t.Error("expected StrictMode=off on profile")
}
}
func TestStrictMode_SetBot_Global(t *testing.T) {
setupStrictModeTestConfig(t)
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"bot", "--global"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
multi, _ := core.LoadMultiAppConfig()
if multi.StrictMode != core.StrictModeBot {
t.Error("expected global StrictMode=bot")
}
}
func TestStrictMode_SetGlobal_DoesNotRequireActiveProfile(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
multi := &core.MultiAppConfig{
CurrentApp: "missing-profile",
Apps: []core.AppConfig{{
Name: "default",
AppId: "test-app",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"bot", "--global"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if saved.StrictMode != core.StrictModeBot {
t.Fatalf("StrictMode = %q, want %q", saved.StrictMode, core.StrictModeBot)
}
}
func TestStrictMode_Reset(t *testing.T) {
setupStrictModeTestConfig(t)
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"bot"})
cmd.Execute()
cmd = NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"--reset"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
multi, _ := core.LoadMultiAppConfig()
app := multi.CurrentAppConfig("")
if app.StrictMode != nil {
t.Errorf("expected nil StrictMode after reset, got %v", *app.StrictMode)
}
}
func TestStrictMode_InvalidValue(t *testing.T) {
setupStrictModeTestConfig(t)
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test-app", AppSecret: "secret"})
cmd := NewCmdConfigStrictMode(f)
cmd.SetArgs([]string{"on"})
err := cmd.Execute()
if err == nil {
t.Error("expected error for invalid value 'on'")
}
}

17
cmd/global_flags.go Normal file
View File

@@ -0,0 +1,17 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import "github.com/spf13/pflag"
// GlobalOptions are the root-level flags shared by bootstrap parsing and the
// actual Cobra command tree.
type GlobalOptions struct {
Profile string
}
// RegisterGlobalFlags registers the root-level persistent flags.
func RegisterGlobalFlags(fs *pflag.FlagSet, opts *GlobalOptions) {
fs.StringVar(&opts.Profile, "profile", "", "use a specific profile")
}

137
cmd/profile/add.go Normal file
View File

@@ -0,0 +1,137 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package profile
import (
"bufio"
"errors"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// NewCmdProfileAdd creates the profile add subcommand.
func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
var (
name string
appID string
appSecretStdin bool
brand string
lang string
use bool
)
cmd := &cobra.Command{
Use: "add",
Short: "Add a new profile",
RunE: func(cmd *cobra.Command, args []string) error {
return profileAddRun(f, name, appID, appSecretStdin, brand, lang, use)
},
}
cmd.Flags().StringVar(&name, "name", "", "profile name (required)")
cmd.Flags().StringVar(&appID, "app-id", "", "App ID (required)")
cmd.Flags().BoolVar(&appSecretStdin, "app-secret-stdin", false, "read App Secret from stdin")
cmd.Flags().StringVar(&brand, "brand", "feishu", "feishu or lark")
cmd.Flags().StringVar(&lang, "lang", "zh", "language for interactive prompts (zh or en)")
cmd.Flags().BoolVar(&use, "use", false, "switch to this profile after adding")
_ = cmd.MarkFlagRequired("name")
_ = cmd.MarkFlagRequired("app-id")
return cmd
}
func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, brand, lang string, useAfter bool) error {
if err := core.ValidateProfileName(name); err != nil {
return output.ErrValidation("%v", err)
}
// Read secret from stdin
if !appSecretStdin {
return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret")
}
scanner := bufio.NewScanner(f.IOStreams.In)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return output.ErrValidation("failed to read secret from stdin: %v", err)
}
return output.ErrValidation("stdin is empty, expected app secret")
}
appSecret := strings.TrimSpace(scanner.Text())
if appSecret == "" {
return output.ErrValidation("app secret read from stdin is empty")
}
// Load or create config
multi, err := core.LoadMultiAppConfig()
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return output.Errorf(output.ExitInternal, "internal", "failed to load config: %v", err)
}
multi = &core.MultiAppConfig{}
}
// Check name uniqueness
if multi.FindApp(name) != nil {
return output.ErrValidation("profile %q already exists", name)
}
// Check app-id uniqueness — keychain stores secrets by appId, so
// multiple profiles sharing the same appId would collide on credentials.
for _, a := range multi.Apps {
if a.AppId == appID {
return output.ErrValidation("app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName())
}
}
// Store secret securely
secret, err := core.ForStorage(appID, core.PlainSecret(appSecret), f.Keychain)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
parsedBrand := core.ParseBrand(brand)
// Capture current profile before appending (avoid setting PreviousApp to self)
var previousName string
if useAfter {
if currentApp := multi.CurrentAppConfig(""); currentApp != nil {
previousName = currentApp.ProfileName()
}
}
// Append profile
multi.Apps = append(multi.Apps, core.AppConfig{
Name: name,
AppId: appID,
AppSecret: secret,
Brand: parsedBrand,
Lang: lang,
Users: []core.AppUser{},
})
if useAfter {
if previousName != "" {
multi.PreviousApp = previousName
}
multi.CurrentApp = name
}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q added (%s, %s)", name, appID, parsedBrand))
if useAfter {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q", name))
}
return nil
}

85
cmd/profile/list.go Normal file
View File

@@ -0,0 +1,85 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package profile
import (
"errors"
"os"
"github.com/spf13/cobra"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// profileListItem is the JSON output for a single profile entry.
type profileListItem struct {
Name string `json:"name"`
AppID string `json:"appId"`
Brand core.LarkBrand `json:"brand"`
Active bool `json:"active"`
User string `json:"user,omitempty"`
TokenStatus string `json:"tokenStatus,omitempty"`
}
// NewCmdProfileList creates the profile list subcommand.
func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List all profiles",
RunE: func(cmd *cobra.Command, args []string) error {
return profileListRun(f)
},
}
return cmd
}
func profileListRun(f *cmdutil.Factory) error {
multi, err := core.LoadMultiAppConfig()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
output.PrintJson(f.IOStreams.Out, []profileListItem{})
return nil
}
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
}
if multi == nil || len(multi.Apps) == 0 {
output.PrintJson(f.IOStreams.Out, []profileListItem{})
return nil
}
// Intentionally uses "" to show the persistent active profile, not the ephemeral --profile override.
currentApp := multi.CurrentAppConfig("")
currentName := ""
if currentApp != nil {
currentName = currentApp.ProfileName()
}
items := make([]profileListItem, 0, len(multi.Apps))
for i := range multi.Apps {
app := &multi.Apps[i]
name := app.ProfileName()
item := profileListItem{
Name: name,
AppID: app.AppId,
Brand: app.Brand,
Active: name == currentName,
}
if len(app.Users) > 0 {
item.User = app.Users[0].UserName
stored := larkauth.GetStoredToken(app.AppId, app.Users[0].UserOpenId)
if stored != nil {
item.TokenStatus = larkauth.TokenStatus(stored)
}
}
items = append(items, item)
}
output.PrintJson(f.IOStreams.Out, items)
return nil
}

29
cmd/profile/profile.go Normal file
View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package profile
import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
)
// NewCmdProfile creates the profile command with subcommands.
func NewCmdProfile(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "profile",
Short: "Manage configuration profiles",
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetTips(cmd, []string{
"AI agents: Do NOT switch or remove profiles unless the user explicitly asks.",
})
cmd.AddCommand(NewCmdProfileList(f))
cmd.AddCommand(NewCmdProfileUse(f))
cmd.AddCommand(NewCmdProfileAdd(f))
cmd.AddCommand(NewCmdProfileRemove(f))
cmd.AddCommand(NewCmdProfileRename(f))
return cmd
}

371
cmd/profile/profile_test.go Normal file
View File

@@ -0,0 +1,371 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package profile
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs"
)
type failRenameFS struct {
vfs.OsFs
err error
}
func (fs *failRenameFS) Rename(oldpath, newpath string) error {
return fs.err
}
func setupProfileConfigDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
return dir
}
func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) {
dir := setupProfileConfigDir(t)
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "test", "app-test", true, "feishu", "zh", false)
if err == nil {
t.Fatal("expected error for invalid existing config")
}
if !strings.Contains(err.Error(), "failed to load config") {
t.Fatalf("error = %v, want failed to load config", err)
}
}
func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret-new\n")
if err := profileAddRun(f, "target", "app-target", true, "lark", "en", true); err != nil {
t.Fatalf("profileAddRun() error = %v", err)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if saved.CurrentApp != "target" {
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "target")
}
if saved.PreviousApp != "default" {
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "default")
}
if len(saved.Apps) != 2 {
t.Fatalf("len(Apps) = %d, want 2", len(saved.Apps))
}
}
func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
CurrentApp: "target",
PreviousApp: "default",
Apps: []core.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
if err := profileRemoveRun(f, "target"); err != nil {
t.Fatalf("profileRemoveRun() error = %v", err)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if saved.CurrentApp != "default" {
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "default")
}
if saved.PreviousApp != "default" {
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "default")
}
if len(saved.Apps) != 1 || saved.Apps[0].ProfileName() != "default" {
t.Fatalf("remaining apps = %#v, want only default", saved.Apps)
}
}
func TestProfileRenameRun_UpdatesCurrentAndPreviousReferences(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
CurrentApp: "old",
PreviousApp: "old",
Apps: []core.AppConfig{{
Name: "old",
AppId: "app-old",
AppSecret: core.PlainSecret("secret-old"),
Brand: core.BrandFeishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
if err := profileRenameRun(f, "old", "new"); err != nil {
t.Fatalf("profileRenameRun() error = %v", err)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if saved.CurrentApp != "new" {
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "new")
}
if saved.PreviousApp != "new" {
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "new")
}
if saved.Apps[0].ProfileName() != "new" {
t.Fatalf("ProfileName() = %q, want %q", saved.Apps[0].ProfileName(), "new")
}
}
func TestProfileRenameRun_AllowsRenameToOwnAppID(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
CurrentApp: "old",
PreviousApp: "old",
Apps: []core.AppConfig{{
Name: "old",
AppId: "app-old",
AppSecret: core.PlainSecret("secret-old"),
Brand: core.BrandFeishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
if err := profileRenameRun(f, "old", "app-old"); err != nil {
t.Fatalf("profileRenameRun() error = %v", err)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if saved.CurrentApp != "app-old" {
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "app-old")
}
if saved.PreviousApp != "app-old" {
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "app-old")
}
if saved.Apps[0].Name != "app-old" {
t.Fatalf("Name = %q, want %q", saved.Apps[0].Name, "app-old")
}
}
func TestProfileUseRun_ToggleBackUsesPreviousProfile(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
CurrentApp: "default",
PreviousApp: "target",
Apps: []core.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
if err := profileUseRun(f, "-"); err != nil {
t.Fatalf("profileUseRun() error = %v", err)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if saved.CurrentApp != "target" {
t.Fatalf("CurrentApp = %q, want %q", saved.CurrentApp, "target")
}
if saved.PreviousApp != "default" {
t.Fatalf("PreviousApp = %q, want %q", saved.PreviousApp, "default")
}
}
func TestProfileListRun_OutputsProfiles(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
if err := profileListRun(f); err != nil {
t.Fatalf("profileListRun() error = %v", err)
}
var got []profileListItem
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal() error = %v; output=%s", err, stdout.String())
}
if len(got) != 2 {
t.Fatalf("len(got) = %d, want 2", len(got))
}
if got[0].Name != "default" || !got[0].Active {
t.Fatalf("got[0] = %#v, want active default profile", got[0])
}
if got[1].Name != "target" || got[1].Active {
t.Fatalf("got[1] = %#v, want inactive target profile", got[1])
}
}
func TestProfileListRun_NotConfiguredReturnsEmptyList(t *testing.T) {
setupProfileConfigDir(t)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := profileListRun(f); err != nil {
t.Fatalf("profileListRun() error = %v", err)
}
var got []profileListItem
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("Unmarshal() error = %v; output=%s", err, stdout.String())
}
if len(got) != 0 {
t.Fatalf("len(got) = %d, want 0", len(got))
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestProfileRemoveRun_SaveFailureReturnsStructuredError(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
CurrentApp: "target",
Apps: []core.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
restoreFS := vfs.DefaultFS
vfs.DefaultFS = &failRenameFS{err: errors.New("rename boom")}
t.Cleanup(func() { vfs.DefaultFS = restoreFS })
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRemoveRun(f, "target")
if err == nil {
t.Fatal("expected save error")
}
assertInternalExitError(t, err, "failed to save config")
}
func TestProfileRenameRun_SaveFailureReturnsStructuredError(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
CurrentApp: "old",
Apps: []core.AppConfig{{
Name: "old",
AppId: "app-old",
AppSecret: core.PlainSecret("secret-old"),
Brand: core.BrandFeishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
restoreFS := vfs.DefaultFS
vfs.DefaultFS = &failRenameFS{err: errors.New("rename boom")}
t.Cleanup(func() { vfs.DefaultFS = restoreFS })
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRenameRun(f, "old", "new")
if err == nil {
t.Fatal("expected save error")
}
assertInternalExitError(t, err, "failed to save config")
}
func TestProfileUseRun_SaveFailureReturnsStructuredError(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
restoreFS := vfs.DefaultFS
vfs.DefaultFS = &failRenameFS{err: errors.New("rename boom")}
t.Cleanup(func() { vfs.DefaultFS = restoreFS })
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileUseRun(f, "target")
if err == nil {
t.Fatal("expected save error")
}
assertInternalExitError(t, err, "failed to save config")
}
func assertInternalExitError(t *testing.T, err error, wantMsg string) {
t.Helper()
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError; err=%v", err, err)
}
if exitErr.Code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "internal" {
t.Fatalf("detail = %#v, want internal detail", exitErr.Detail)
}
if !strings.Contains(exitErr.Detail.Message, wantMsg) {
t.Fatalf("message = %q, want contains %q", exitErr.Detail.Message, wantMsg)
}
}

78
cmd/profile/remove.go Normal file
View File

@@ -0,0 +1,78 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package profile
import (
"fmt"
"strings"
"github.com/spf13/cobra"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// NewCmdProfileRemove creates the profile remove subcommand.
func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "remove <name>",
Short: "Remove a profile",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return profileRemoveRun(f, args[0])
},
}
cmdutil.SetTips(cmd, []string{
"AI agents: Do NOT remove profiles unless the user explicitly asks. This is destructive and clears all associated credentials.",
})
return cmd
}
func profileRemoveRun(f *cmdutil.Factory, name string) error {
multi, err := core.LoadMultiAppConfig()
if err != nil {
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
}
idx := multi.FindAppIndex(name)
if idx < 0 {
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
}
if len(multi.Apps) == 1 {
return output.ErrValidation("cannot remove the only profile")
}
app := &multi.Apps[idx]
removedName := app.ProfileName()
appId := app.AppId
appSecret := app.AppSecret
users := app.Users
// Remove from slice
multi.Apps = append(multi.Apps[:idx], multi.Apps[idx+1:]...)
// Fix currentApp / previousApp references
if multi.CurrentApp == removedName {
multi.CurrentApp = multi.Apps[0].ProfileName()
}
if multi.PreviousApp == removedName {
multi.PreviousApp = ""
}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
// Best-effort credential cleanup after config commit
core.RemoveSecretStore(appSecret, f.Keychain)
for _, user := range users {
larkauth.RemoveStoredToken(appId, user.UserOpenId)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q removed", removedName))
return nil
}

73
cmd/profile/rename.go Normal file
View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package profile
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// NewCmdProfileRename creates the profile rename subcommand.
func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "rename <old> <new>",
Short: "Rename a profile",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
return profileRenameRun(f, args[0], args[1])
},
}
return cmd
}
func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
if err := core.ValidateProfileName(newName); err != nil {
return output.ErrValidation("%v", err)
}
multi, err := core.LoadMultiAppConfig()
if err != nil {
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
}
idx := multi.FindAppIndex(oldName)
if idx < 0 {
return output.ErrValidation("profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
}
// Check new name uniqueness across other profiles, allowing renames to this
// profile's own appId or current name.
for i := range multi.Apps {
if i == idx {
continue
}
if multi.Apps[i].Name == newName || multi.Apps[i].AppId == newName {
return output.ErrValidation("profile %q already exists", newName)
}
}
oldProfileName := multi.Apps[idx].ProfileName()
multi.Apps[idx].Name = newName
// Update currentApp / previousApp references
if multi.CurrentApp == oldProfileName {
multi.CurrentApp = newName
}
if multi.PreviousApp == oldProfileName {
multi.PreviousApp = newName
}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile renamed: %q -> %q", oldProfileName, newName))
return nil
}

73
cmd/profile/use.go Normal file
View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package profile
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// NewCmdProfileUse creates the profile use subcommand.
func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "use <name>",
Short: "Switch to a profile (use '-' to toggle back)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return profileUseRun(f, args[0])
},
}
cmdutil.SetTips(cmd, []string{
"AI agents: Do NOT switch profiles unless the user explicitly asks.",
})
return cmd
}
func profileUseRun(f *cmdutil.Factory, name string) error {
multi, err := core.LoadMultiAppConfig()
if err != nil {
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
}
// Handle "-" for toggle-back
if name == "-" {
if multi.PreviousApp == "" {
return output.ErrValidation("no previous profile to switch back to")
}
name = multi.PreviousApp
}
app := multi.FindApp(name)
if app == nil {
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
}
targetName := app.ProfileName()
// Short-circuit if already on the target profile
currentApp := multi.CurrentAppConfig("")
if currentApp != nil && currentApp.ProfileName() == targetName {
fmt.Fprintf(f.IOStreams.ErrOut, "Already on profile %q\n", targetName)
return nil
}
// Update previous and current
if currentApp != nil {
multi.PreviousApp = currentApp.ProfileName()
}
multi.CurrentApp = targetName
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q (%s, %s)", targetName, app.AppId, app.Brand))
return nil
}

80
cmd/prune.go Normal file
View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"slices"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
// pruneForStrictMode removes commands incompatible with the active strict mode.
func pruneForStrictMode(root *cobra.Command, mode core.StrictMode) {
pruneIncompatible(root, mode)
pruneEmpty(root)
}
// pruneIncompatible recursively replaces commands whose annotation declares
// identities incompatible with the forced identity. Commands without annotation are kept.
// Hidden stubs preserve direct execution so users get a strict-mode error instead
// of Cobra's generic "unknown flag" fallback from the parent command.
func pruneIncompatible(parent *cobra.Command, mode core.StrictMode) {
forced := string(mode.ForcedIdentity())
var toRemove []*cobra.Command
var toAdd []*cobra.Command
for _, child := range parent.Commands() {
ids := cmdutil.GetSupportedIdentities(child)
if ids != nil && !slices.Contains(ids, forced) {
toRemove = append(toRemove, child)
toAdd = append(toAdd, strictModeStubFrom(child, mode))
continue
}
pruneIncompatible(child, mode)
}
if len(toRemove) > 0 {
parent.RemoveCommand(toRemove...)
parent.AddCommand(toAdd...)
}
}
func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Command {
return &cobra.Command{
Use: child.Use,
Aliases: append([]string(nil), child.Aliases...),
Hidden: true,
DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error {
return output.Errorf(output.ExitValidation, "strict_mode",
"strict mode is %q, only %s identity is allowed. "+
"This setting is managed by the administrator and must not be modified by AI agents.",
mode, mode.ForcedIdentity())
},
}
}
// pruneEmpty recursively removes group commands (no Run/RunE) that have
// no remaining subcommands after pruning. If only hidden stubs remain, keep
// the group hidden so direct execution still resolves to the stub path.
func pruneEmpty(parent *cobra.Command) {
var toRemove []*cobra.Command
for _, child := range parent.Commands() {
pruneEmpty(child)
if child.Run != nil || child.RunE != nil {
continue
}
switch {
case child.HasAvailableSubCommands():
case len(child.Commands()) > 0:
child.Hidden = true
default:
toRemove = append(toRemove, child)
}
}
if len(toRemove) > 0 {
parent.RemoveCommand(toRemove...)
}
}

200
cmd/prune_test.go Normal file
View File

@@ -0,0 +1,200 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
func newTestTree() *cobra.Command {
root := &cobra.Command{Use: "root"}
svc := &cobra.Command{Use: "im"}
root.AddCommand(svc)
noop := func(*cobra.Command, []string) error { return nil }
userOnly := &cobra.Command{Use: "+search", Short: "user only", RunE: noop}
cmdutil.SetSupportedIdentities(userOnly, []string{"user"})
svc.AddCommand(userOnly)
botOnly := &cobra.Command{Use: "+subscribe", Short: "bot only", RunE: noop}
cmdutil.SetSupportedIdentities(botOnly, []string{"bot"})
svc.AddCommand(botOnly)
dual := &cobra.Command{Use: "+send", Short: "dual", RunE: noop}
cmdutil.SetSupportedIdentities(dual, []string{"user", "bot"})
svc.AddCommand(dual)
noAnnotation := &cobra.Command{Use: "+legacy", Short: "no annotation", RunE: noop}
svc.AddCommand(noAnnotation)
res := &cobra.Command{Use: "messages"}
svc.AddCommand(res)
userMethod := &cobra.Command{Use: "search", RunE: func(*cobra.Command, []string) error { return nil }}
cmdutil.SetSupportedIdentities(userMethod, []string{"user"})
res.AddCommand(userMethod)
auth := &cobra.Command{Use: "auth"}
root.AddCommand(auth)
login := &cobra.Command{Use: "login", RunE: noop}
cmdutil.SetSupportedIdentities(login, []string{"user"})
auth.AddCommand(login)
return root
}
func findCmd(root *cobra.Command, names ...string) *cobra.Command {
cmd := root
for _, name := range names {
found := false
for _, c := range cmd.Commands() {
if c.Name() == name {
cmd = c
found = true
break
}
}
if !found {
return nil
}
}
return cmd
}
func TestPruneForStrictMode_Bot(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
if cmd := findCmd(root, "im", "+search"); cmd == nil || !cmd.Hidden {
t.Error("+search (user-only) should be replaced by a hidden stub in bot mode")
}
if findCmd(root, "im", "+subscribe") == nil {
t.Error("+subscribe (bot-only) should be kept in bot mode")
}
if findCmd(root, "im", "+send") == nil {
t.Error("+send (dual) should be kept in bot mode")
}
if findCmd(root, "im", "+legacy") == nil {
t.Error("+legacy (no annotation) should be kept")
}
if cmd := findCmd(root, "im", "messages", "search"); cmd == nil || !cmd.Hidden {
t.Error("search (user-only method) should be replaced by a hidden stub in bot mode")
}
if cmd := findCmd(root, "auth", "login"); cmd == nil || !cmd.Hidden {
t.Error("auth login should be replaced by a hidden stub in bot mode")
}
}
func TestPruneForStrictMode_User(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeUser)
if findCmd(root, "im", "+search") == nil {
t.Error("+search (user-only) should be kept in user mode")
}
if cmd := findCmd(root, "im", "+subscribe"); cmd == nil || !cmd.Hidden {
t.Error("+subscribe (bot-only) should be replaced by a hidden stub in user mode")
}
if findCmd(root, "im", "+send") == nil {
t.Error("+send (dual) should be kept in user mode")
}
if cmd := findCmd(root, "auth", "login"); cmd == nil || cmd.Hidden {
t.Error("auth login should be kept in user mode")
}
}
func TestPruneEmpty(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
if cmd := findCmd(root, "im", "messages"); cmd == nil || !cmd.Hidden {
t.Error("resource 'messages' should be kept hidden when only hidden stubs remain")
}
}
func TestPruneEmpty_PreservesOriginallyHiddenGroup(t *testing.T) {
root := &cobra.Command{Use: "root"}
hidden := &cobra.Command{Use: "hidden", Hidden: true}
root.AddCommand(hidden)
hidden.AddCommand(&cobra.Command{
Use: "visible",
RunE: func(*cobra.Command, []string) error { return nil },
})
pruneEmpty(root)
if !hidden.Hidden {
t.Fatal("expected originally hidden group to remain hidden")
}
}
func TestPruneForStrictMode_Bot_DirectUserShortcutReturnsStrictMode(t *testing.T) {
root := newTestTree()
root.SilenceErrors = true
root.SilenceUsage = true
pruneForStrictMode(root, core.StrictModeBot)
root.SetArgs([]string{"im", "+search", "--query", "hello"})
err := root.Execute()
if err == nil {
t.Fatal("expected strict-mode error")
}
if !strings.Contains(err.Error(), `strict mode is "bot"`) {
t.Fatalf("unexpected error: %v", err)
}
}
func TestPruneForStrictMode_Bot_DirectNestedUserMethodReturnsStrictMode(t *testing.T) {
root := newTestTree()
root.SilenceErrors = true
root.SilenceUsage = true
pruneForStrictMode(root, core.StrictModeBot)
root.SetArgs([]string{"im", "messages", "search", "--query", "hello"})
err := root.Execute()
if err == nil {
t.Fatal("expected strict-mode error")
}
if !strings.Contains(err.Error(), `strict mode is "bot"`) {
t.Fatalf("unexpected error: %v", err)
}
}
func TestPruneForStrictMode_Bot_DirectAuthLoginReturnsStrictMode(t *testing.T) {
root := newTestTree()
root.SilenceErrors = true
root.SilenceUsage = true
pruneForStrictMode(root, core.StrictModeBot)
root.SetArgs([]string{"auth", "login", "--json", "--scope", "im:message.send_as_user"})
err := root.Execute()
if err == nil {
t.Fatal("expected strict-mode error")
}
if !strings.Contains(err.Error(), `strict mode is "bot"`) {
t.Fatalf("unexpected error: %v", err)
}
}
func TestPruneForStrictMode_User_DirectBotShortcutReturnsStrictMode(t *testing.T) {
root := newTestTree()
root.SilenceErrors = true
root.SilenceUsage = true
pruneForStrictMode(root, core.StrictModeUser)
root.SetArgs([]string{"im", "+subscribe", "--topic", "x"})
err := root.Execute()
if err == nil {
t.Fatal("expected strict-mode error")
}
if !strings.Contains(err.Error(), `strict mode is "user"`) {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -5,6 +5,7 @@ package cmd
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -18,6 +19,7 @@ import (
"github.com/larksuite/cli/cmd/completion"
cmdconfig "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/cmd/doctor"
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service"
internalauth "github.com/larksuite/cli/internal/auth"
@@ -43,7 +45,7 @@ EXAMPLES:
lark-cli calendar +agenda
# List calendar events
lark-cli calendar events list --params '{"calendar_id":"primary"}'
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
# Search users
lark-cli contact +search-user --query "John"
@@ -76,7 +78,7 @@ AI AGENT SKILLS:
npx skills add larksuite/cli -s lark-calendar -y
npx skills add larksuite/cli -s lark-im -y
Learn more: https://github.com/larksuite/cli#install-ai-agent-skills
Learn more: https://github.com/larksuite/cli#agent-skills
COMMUNITY:
GitHub: https://github.com/larksuite/cli
@@ -87,8 +89,14 @@ More help: lark-cli <command> --help`
// Execute runs the root command and returns the process exit code.
func Execute() int {
f := cmdutil.NewDefault()
inv, err := BootstrapInvocationContext(os.Args[1:])
if err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
return 1
}
f := cmdutil.NewDefault(inv)
globals := &GlobalOptions{Profile: inv.Profile}
rootCmd := &cobra.Command{
Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
@@ -97,12 +105,15 @@ func Execute() int {
}
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
RegisterGlobalFlags(rootCmd.PersistentFlags(), globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
cmd.SilenceUsage = true
}
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(profile.NewCmdProfile(f))
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
rootCmd.AddCommand(api.NewCmdApi(f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
@@ -110,6 +121,11 @@ func Execute() int {
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)
// Prune commands incompatible with strict mode.
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
}
// --- Update check (non-blocking) ---
if !isCompletionCommand(os.Args) {
setupUpdateNotice()

View File

@@ -1,279 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"encoding/json"
"reflect"
"testing"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
// buildTestRootCmd creates a root command with api, service, and shortcut
// subcommands wired to a test factory, simulating the real CLI command tree.
func buildTestRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
t.Helper()
rootCmd := &cobra.Command{Use: "lark-cli"}
rootCmd.SilenceErrors = true
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
cmd.SilenceUsage = true
}
rootCmd.AddCommand(api.NewCmdApi(f, nil))
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)
return rootCmd
}
// executeE2E runs a command through the full command tree and handleRootError,
// returning exit code — matching real CLI behavior.
func executeE2E(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Command, args []string) int {
t.Helper()
rootCmd.SetArgs(args)
if err := rootCmd.Execute(); err != nil {
return handleRootError(f, err)
}
return 0
}
// registerTokenStub registers a tenant_access_token stub so bot auth succeeds.
func registerTokenStub(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-e2e-token", "expire": 7200,
},
})
}
// parseEnvelope parses stderr bytes into an ErrorEnvelope.
func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
t.Helper()
if stderr.Len() == 0 {
t.Fatal("expected non-empty stderr, got empty")
}
var env output.ErrorEnvelope
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String())
}
return env
}
// assertEnvelope verifies exit code, stdout is empty, and stderr matches the
// expected ErrorEnvelope exactly via reflect.DeepEqual.
func assertEnvelope(t *testing.T, code int, wantCode int, stdout *bytes.Buffer, stderr *bytes.Buffer, want output.ErrorEnvelope) {
t.Helper()
if code != wantCode {
t.Errorf("exit code: got %d, want %d", code, wantCode)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
got := parseEnvelope(t, stderr)
if !reflect.DeepEqual(got, want) {
gotJSON, _ := json.MarshalIndent(got, "", " ")
wantJSON, _ := json.MarshalIndent(want, "", " ")
t.Errorf("stderr envelope mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
}
}
// --- api command ---
func TestE2E_Api_BusinessError_OutputsEnvelope(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "e2e-api-err", AppSecret: "secret", Brand: core.BrandFeishu,
})
registerTokenStub(reg)
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/messages",
Body: map[string]interface{}{
"code": 230002,
"msg": "Bot/User can NOT be out of the chat.",
"error": map[string]interface{}{
"log_id": "test-log-id-001",
},
},
})
rootCmd := buildTestRootCmd(t, f)
code := executeE2E(t, f, rootCmd, []string{
"api", "--as", "bot", "POST", "/open-apis/im/v1/messages",
"--params", `{"receive_id_type":"chat_id"}`,
"--data", `{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"test\"}"}`,
})
// api uses MarkRaw: detail preserved, no enrichment
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api_error",
Code: 230002,
Message: "API error: [230002] Bot/User can NOT be out of the chat.",
Detail: map[string]interface{}{
"log_id": "test-log-id-001",
},
},
})
}
func TestE2E_Api_PermissionError_NotEnriched(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "e2e-api-perm", AppSecret: "secret", Brand: core.BrandFeishu,
})
registerTokenStub(reg)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/perm",
Body: map[string]interface{}{
"code": 99991672,
"msg": "scope not enabled for this app",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "calendar:calendar:readonly"},
},
"log_id": "test-log-id-perm",
},
},
})
rootCmd := buildTestRootCmd(t, f)
code := executeE2E(t, f, rootCmd, []string{
"api", "--as", "bot", "GET", "/open-apis/test/perm",
})
// api uses MarkRaw: enrichment skipped, detail preserved, no console_url
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "permission",
Code: 99991672,
Message: "Permission denied [99991672]",
Hint: "check app permissions or re-authorize: lark-cli auth login",
Detail: map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "calendar:calendar:readonly"},
},
"log_id": "test-log-id-perm",
},
},
})
}
// --- service command ---
func TestE2E_Service_BusinessError_OutputsEnvelope(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "e2e-svc-err", AppSecret: "secret", Brand: core.BrandFeishu,
})
registerTokenStub(reg)
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/chats/oc_fake",
Body: map[string]interface{}{
"code": 99992356,
"msg": "id not exist",
"error": map[string]interface{}{
"log_id": "test-log-id-svc",
},
},
})
rootCmd := buildTestRootCmd(t, f)
code := executeE2E(t, f, rootCmd, []string{
"im", "chats", "get", "--params", `{"chat_id":"oc_fake"}`, "--as", "bot",
})
// service: no MarkRaw, non-permission error — detail preserved
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api_error",
Code: 99992356,
Message: "API error: [99992356] id not exist",
Detail: map[string]interface{}{
"log_id": "test-log-id-svc",
},
},
})
}
func TestE2E_Service_PermissionError_Enriched(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "e2e-svc-perm", AppSecret: "secret", Brand: core.BrandFeishu,
})
registerTokenStub(reg)
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/chats/oc_test",
Body: map[string]interface{}{
"code": 99991672,
"msg": "scope not enabled",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "im:chat:readonly"},
},
},
},
})
rootCmd := buildTestRootCmd(t, f)
code := executeE2E(t, f, rootCmd, []string{
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "bot",
})
// service: no MarkRaw — enrichment applied, detail cleared, console_url set
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "permission",
Code: 99991672,
Message: "App scope not enabled: required scope im:chat:readonly [99991672]",
Hint: "enable the scope in developer console (see console_url)",
ConsoleURL: "https://open.feishu.cn/page/scope-apply?clientID=e2e-svc-perm&scopes=im%3Achat%3Areadonly",
},
})
}
// --- shortcut command ---
func TestE2E_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "e2e-sc-err", AppSecret: "secret", Brand: core.BrandFeishu,
})
registerTokenStub(reg)
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/messages",
Status: 400,
Body: map[string]interface{}{
"code": 230002,
"msg": "Bot/User can NOT be out of the chat.",
},
})
rootCmd := buildTestRootCmd(t, f)
code := executeE2E(t, f, rootCmd, []string{
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
})
// shortcut: no MarkRaw, no HandleResponse — error via DoAPIJSON path
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api_error",
Code: 230002,
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
},
})
}

View File

@@ -0,0 +1,490 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/service"
"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/output"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
// buildIntegrationRootCmd creates a root command with api, service, and shortcut
// subcommands wired to a test factory, simulating the real CLI command tree.
func buildIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
t.Helper()
rootCmd := &cobra.Command{Use: "lark-cli"}
rootCmd.SilenceErrors = true
rootCmd.SetOut(f.IOStreams.Out)
rootCmd.SetErr(f.IOStreams.ErrOut)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
cmd.SilenceUsage = true
}
rootCmd.AddCommand(api.NewCmdApi(f, nil))
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)
return rootCmd
}
// executeRootIntegration runs a command through the full command tree and
// handleRootError, returning the exit code matching real CLI behavior.
func executeRootIntegration(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Command, args []string) int {
t.Helper()
rootCmd.SetArgs(args)
if err := rootCmd.Execute(); err != nil {
return handleRootError(f, err)
}
return 0
}
// parseEnvelope parses stderr bytes into an ErrorEnvelope.
func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
t.Helper()
if stderr.Len() == 0 {
t.Fatal("expected non-empty stderr, got empty")
}
var env output.ErrorEnvelope
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String())
}
return env
}
// assertEnvelope verifies exit code, stdout is empty, and stderr matches the
// expected ErrorEnvelope exactly via reflect.DeepEqual.
func assertEnvelope(t *testing.T, code int, wantCode int, stdout *bytes.Buffer, stderr *bytes.Buffer, want output.ErrorEnvelope) {
t.Helper()
if code != wantCode {
t.Errorf("exit code: got %d, want %d", code, wantCode)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
got := parseEnvelope(t, stderr)
if !reflect.DeepEqual(got, want) {
gotJSON, _ := json.MarshalIndent(got, "", " ")
wantJSON, _ := json.MarshalIndent(want, "", " ")
t.Errorf("stderr envelope mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
}
}
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
t.Helper()
rootCmd := &cobra.Command{Use: "lark-cli"}
rootCmd.SilenceErrors = true
rootCmd.SetOut(f.IOStreams.Out)
rootCmd.SetErr(f.IOStreams.ErrOut)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
cmd.SilenceUsage = true
}
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(api.NewCmdApi(f, nil))
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
}
return rootCmd
}
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv(envvars.CliDefaultAs, "")
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
targetMode := mode
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
},
{
Name: "target",
AppId: "app-target",
AppSecret: core.PlainSecret("secret-target"),
Brand: core.BrandFeishu,
StrictMode: &targetMode,
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f := cmdutil.NewDefault(cmdutil.InvocationContext{Profile: profile})
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
f.IOStreams = &cmdutil.IOStreams{In: nil, Out: stdout, ErrOut: stderr}
return f, stdout, stderr
}
func resetBuffers(stdout *bytes.Buffer, stderr *bytes.Buffer) {
stdout.Reset()
stderr.Reset()
}
func parseDryRunJSON(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
out := stdout.String()
const prefix = "=== Dry Run ===\n"
if !strings.HasPrefix(out, prefix) {
t.Fatalf("expected dry-run prefix, got:\n%s", out)
}
var payload map[string]interface{}
if err := json.Unmarshal([]byte(strings.TrimPrefix(out, prefix)), &payload); err != nil {
t.Fatalf("failed to parse dry-run payload: %v\nstdout: %s", err, out)
}
return payload
}
// --- api command ---
func TestIntegration_Api_BusinessError_OutputsEnvelope(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "e2e-api-err", AppSecret: "secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/messages",
Body: map[string]interface{}{
"code": 230002,
"msg": "Bot/User can NOT be out of the chat.",
"error": map[string]interface{}{
"log_id": "test-log-id-001",
},
},
})
rootCmd := buildIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"api", "--as", "bot", "POST", "/open-apis/im/v1/messages",
"--params", `{"receive_id_type":"chat_id"}`,
"--data", `{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"test\"}"}`,
})
// api uses MarkRaw: detail preserved, no enrichment
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api_error",
Code: 230002,
Message: "API error: [230002] Bot/User can NOT be out of the chat.",
Detail: map[string]interface{}{
"log_id": "test-log-id-001",
},
},
})
}
func TestIntegration_Api_PermissionError_NotEnriched(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "e2e-api-perm", AppSecret: "secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/perm",
Body: map[string]interface{}{
"code": 99991672,
"msg": "scope not enabled for this app",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "calendar:calendar:readonly"},
},
"log_id": "test-log-id-perm",
},
},
})
rootCmd := buildIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"api", "--as", "bot", "GET", "/open-apis/test/perm",
})
// api uses MarkRaw: enrichment skipped, detail preserved, no console_url
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "permission",
Code: 99991672,
Message: "Permission denied [99991672]",
Hint: "check app permissions or re-authorize: lark-cli auth login",
Detail: map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "calendar:calendar:readonly"},
},
"log_id": "test-log-id-perm",
},
},
})
}
// --- service command ---
func TestIntegration_Service_BusinessError_OutputsEnvelope(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "e2e-svc-err", AppSecret: "secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/chats/oc_fake",
Body: map[string]interface{}{
"code": 99992356,
"msg": "id not exist",
"error": map[string]interface{}{
"log_id": "test-log-id-svc",
},
},
})
rootCmd := buildIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"im", "chats", "get", "--params", `{"chat_id":"oc_fake"}`, "--as", "bot",
})
// service: no MarkRaw, non-permission error — detail preserved
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api_error",
Code: 99992356,
Message: "API error: [99992356] id not exist",
Detail: map[string]interface{}{
"log_id": "test-log-id-svc",
},
},
})
}
func TestIntegration_Service_PermissionError_Enriched(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "e2e-svc-perm", AppSecret: "secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/chats/oc_test",
Body: map[string]interface{}{
"code": 99991672,
"msg": "scope not enabled",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "im:chat:readonly"},
},
},
},
})
rootCmd := buildIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "bot",
})
// service: no MarkRaw — enrichment applied, detail cleared, console_url set
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "permission",
Code: 99991672,
Message: "App scope not enabled: required scope im:chat:readonly [99991672]",
Hint: "enable the scope in developer console (see console_url)",
ConsoleURL: "https://open.feishu.cn/page/scope-apply?clientID=e2e-svc-perm&scopes=im%3Achat%3Areadonly",
},
})
}
func TestIntegration_StrictModeBot_ProfileOverride_HidesCommandsInHelp(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{"auth", "--help"})
if code != 0 {
t.Fatalf("auth --help exit code = %d, want 0", code)
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got: %s", stderr.String())
}
if strings.Contains(stdout.String(), "login") {
t.Fatalf("auth --help should hide login in bot mode, got:\n%s", stdout.String())
}
resetBuffers(stdout, stderr)
rootCmd = buildStrictModeIntegrationRootCmd(t, f)
code = executeRootIntegration(t, f, rootCmd, []string{"im", "--help"})
if code != 0 {
t.Fatalf("im --help exit code = %d, want 0", code)
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got: %s", stderr.String())
}
if strings.Contains(stdout.String(), "+messages-search") {
t.Fatalf("im --help should hide +messages-search in bot mode, got:\n%s", stdout.String())
}
if !strings.Contains(stdout.String(), "+chat-create") {
t.Fatalf("im --help should keep +chat-create in bot mode, got:\n%s", stdout.String())
}
}
func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"auth", "login", "--json", "--scope", "im:message.send_as_user",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
},
})
}
func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"im", "+messages-search", "--chat-id", "oc_xxx", "--query", "hello",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "bot", only bot identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
},
})
}
func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *testing.T) {
// +chat-create supports both user and bot identities, so strict mode user
// should allow it and force user identity.
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"im", "+chat-create", "--name", "probe", "--dry-run",
})
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
}
out := stdout.String()
if out == "" {
t.Fatal("expected non-empty stdout for dry-run")
}
}
func TestIntegration_StrictModeBot_ProfileOverride_ServiceDryRunForcesBotIdentity(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
})
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got: %s", stderr.String())
}
payload := parseDryRunJSON(t, stdout)
if got := payload["as"]; got != "bot" {
t.Fatalf("dry-run as = %v, want bot", got)
}
}
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "strict_mode",
Message: `strict mode is "user", only user identity is allowed. This setting is managed by the administrator and must not be modified by AI agents.`,
},
})
}
func TestIntegration_StrictModeBot_ProfileOverride_APIDryRunForcesBotIdentity(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run",
})
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got: %s", stderr.String())
}
payload := parseDryRunJSON(t, stdout)
if got := payload["as"]; got != "bot" {
t.Fatalf("dry-run as = %v, want bot", got)
}
}
// --- shortcut command ---
func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "e2e-sc-err", AppSecret: "secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/messages",
Status: 400,
Body: map[string]interface{}{
"code": 230002,
"msg": "Bot/User can NOT be out of the chat.",
},
})
rootCmd := buildIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
})
// shortcut: no MarkRaw, no HandleResponse — error via DoAPIJSON path
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api_error",
Code: 230002,
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
},
})
}

View File

@@ -187,3 +187,12 @@ func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) {
})
}
}
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
}
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
}
}

View File

@@ -5,7 +5,6 @@ package service
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
@@ -14,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/util"
@@ -147,10 +147,10 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON")
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin)")
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)")
}
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
@@ -169,13 +169,20 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}
})
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
}
return cmd
}
func serviceMethodRun(opts *ServiceMethodOptions) error {
f := opts.Factory
opts.As = f.ResolveAs(opts.Cmd, opts.As)
opts.As = f.ResolveAs(opts.Ctx, opts.Cmd, opts.As)
if err := f.CheckStrictMode(opts.Ctx, opts.As); err != nil {
return err
}
// Check if this API method supports the resolved identity.
if tokens, ok := opts.Method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
@@ -191,7 +198,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
return err
}
config, err := f.ResolveConfig(opts.As)
config, err := f.Config()
if err != nil {
return err
}
@@ -200,7 +207,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
scopes, _ := opts.Method["scopes"].([]interface{})
if !opts.As.IsBot() {
if err := checkServiceScopes(config, opts.Method, scopes); err != nil {
if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method, scopes); err != nil {
return err
}
}
@@ -242,30 +249,36 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
FileIO: f.ResolveFileIO(opts.Ctx),
CheckError: checkErr,
})
}
// checkServiceScopes pre-checks user scopes before making the API call.
func checkServiceScopes(config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
if ctx.Err() != nil {
return ctx.Err()
}
result, err := cred.ResolveToken(ctx, credential.NewTokenSpec(identity, config.AppID))
if err != nil || result == nil || result.Scopes == "" {
return nil //nolint:nilerr // skip scope check when token resolution fails or has no scopes
}
requiredScopes, hasRequired := method["requiredScopes"].([]interface{})
if hasRequired && len(requiredScopes) > 0 {
// Strict: ALL requiredScopes must be present
stored := auth.GetStoredToken(config.AppID, config.UserOpenId)
if stored != nil {
required := make([]string, 0, len(requiredScopes))
for _, s := range requiredScopes {
if str, ok := s.(string); ok {
required = append(required, str)
}
}
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
return output.ErrWithHint(output.ExitAuth, "missing_scope",
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")))
required := make([]string, 0, len(requiredScopes))
for _, s := range requiredScopes {
if str, ok := s.(string); ok {
required = append(required, str)
}
}
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
return output.ErrWithHint(output.ExitAuth, "missing_scope",
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")))
}
return nil
}
@@ -274,16 +287,12 @@ func checkServiceScopes(config *core.CliConfig, method map[string]interface{}, s
}
// Default: ANY one of the declared scopes is sufficient
stored := auth.GetStoredToken(config.AppID, config.UserOpenId)
if stored == nil {
return nil
}
grantedScopes := make(map[string]bool)
for _, s := range strings.Fields(stored.Scope) {
grantedScopes[s] = true
grantedSet := make(map[string]bool)
for _, s := range strings.Fields(result.Scopes) {
grantedSet[s] = true
}
for _, s := range scopes {
if str, ok := s.(string); ok && grantedScopes[str] {
if str, ok := s.(string); ok && grantedSet[str] {
return nil
}
}
@@ -300,13 +309,15 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
schemaPath := opts.SchemaPath
httpMethod := registry.GetStrFromMap(method, "httpMethod")
var params map[string]interface{}
if opts.Params != "" {
if err := json.Unmarshal([]byte(opts.Params), &params); err != nil {
return client.RawApiRequest{}, output.ErrValidation("--params invalid JSON format")
}
} else {
params = map[string]interface{}{}
// stdin is an io.Reader consumed at most once. Only one of --params/--data
// may use "-" (stdin); the conflict check below prevents silent data loss.
stdin := opts.Factory.IOStreams.In
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin)
if err != nil {
return client.RawApiRequest{}, err
}
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
@@ -355,7 +366,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, erro
}
}
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data)
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin)
if err != nil {
return client.RawApiRequest{}, err
}

View File

@@ -44,16 +44,6 @@ func driveMethod(httpMethod string, params map[string]interface{}) map[string]in
return m
}
func tokenStub() *httpmock.Stub {
return &httpmock.Stub{
URL: "tenant_access_token",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test", "expire": 7200,
},
}
}
// ── registerService ──
func TestRegisterService(t *testing.T) {
@@ -318,7 +308,7 @@ func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
if err == nil {
t.Fatal("expected error for invalid JSON")
}
if !strings.Contains(err.Error(), "--params invalid JSON format") {
if !strings.Contains(err.Error(), "--params invalid format") {
t.Errorf("unexpected error: %v", err)
}
}
@@ -341,6 +331,24 @@ func TestServiceMethod_InvalidDataJSON(t *testing.T) {
}
}
func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
cmd.SetArgs([]string{"--params", "-", "--data", "-", "--dry-run"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error when both --params and --data use stdin")
}
if !strings.Contains(err.Error(), "cannot both read from stdin") {
t.Errorf("expected stdin conflict error, got: %v", err)
}
}
func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
@@ -364,7 +372,6 @@ func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) {
func TestServiceMethod_BotMode_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, testConfig)
reg.Register(tokenStub())
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
@@ -391,7 +398,6 @@ func TestServiceMethod_BotMode_APIError(t *testing.T) {
AppID: "test-app-err", AppSecret: "test-secret-err", Brand: core.BrandFeishu,
})
reg.Register(tokenStub())
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{"code": 40003, "msg": "invalid token"},
@@ -425,7 +431,6 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu,
})
reg.Register(tokenStub())
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
@@ -455,7 +460,6 @@ func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
})
reg.Register(tokenStub())
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
@@ -540,7 +544,6 @@ func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) {
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
})
reg.Register(tokenStub())
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
@@ -612,7 +615,6 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
AppID: "test-app-spjq", AppSecret: "test-secret-spjq", Brand: core.BrandFeishu,
})
reg.Register(tokenStub())
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{

116
extension/credential/env/env.go vendored Normal file
View File

@@ -0,0 +1,116 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package env
import (
"context"
"fmt"
"os"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/envvars"
)
// Provider resolves credentials from environment variables.
type Provider struct{}
func (p *Provider) Name() string { return "env" }
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
appID := os.Getenv(envvars.CliAppID)
appSecret := os.Getenv(envvars.CliAppSecret)
hasUAT := os.Getenv(envvars.CliUserAccessToken) != ""
hasTAT := os.Getenv(envvars.CliTenantAccessToken) != ""
if appID == "" && appSecret == "" {
switch {
case hasUAT:
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliUserAccessToken + " is set but " + envvars.CliAppID + " is missing"}
case hasTAT:
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliTenantAccessToken + " is set but " + envvars.CliAppID + " is missing"}
default:
return nil, nil
}
}
if appID == "" {
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliAppSecret + " is set but " + envvars.CliAppID + " is missing"}
}
if appSecret == "" && !hasUAT && !hasTAT {
return nil, &credential.BlockError{
Provider: "env",
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
}
}
brand := credential.Brand(os.Getenv(envvars.CliBrand))
if brand == "" {
brand = credential.BrandFeishu
}
acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand}
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
case "", credential.IdentityAuto:
acct.DefaultAs = id
case credential.IdentityUser, credential.IdentityBot:
acct.DefaultAs = id
default:
return nil, &credential.BlockError{
Provider: "env",
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
}
}
// Explicit strict mode policy takes priority
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
case "bot":
acct.SupportedIdentities = credential.SupportsBot
case "user":
acct.SupportedIdentities = credential.SupportsUser
case "off":
acct.SupportedIdentities = credential.SupportsAll
case "":
// Infer from available tokens
if hasUAT {
acct.SupportedIdentities |= credential.SupportsUser
}
if hasTAT {
acct.SupportedIdentities |= credential.SupportsBot
}
default:
return nil, &credential.BlockError{
Provider: "env",
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
}
}
if acct.DefaultAs == "" {
switch {
case hasUAT:
acct.DefaultAs = credential.IdentityUser
case hasTAT:
acct.DefaultAs = credential.IdentityBot
}
}
return acct, nil
}
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
var envKey string
switch req.Type {
case credential.TokenTypeUAT:
envKey = envvars.CliUserAccessToken
case credential.TokenTypeTAT:
envKey = envvars.CliTenantAccessToken
default:
return nil, nil
}
token := os.Getenv(envKey)
if token == "" {
return nil, nil
}
return &credential.Token{Value: token, Source: "env:" + envKey}, nil
}
func init() {
credential.Register(&Provider{})
}

282
extension/credential/env/env_test.go vendored Normal file
View File

@@ -0,0 +1,282 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package env
import (
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/envvars"
)
func TestProvider_Name(t *testing.T) {
if (&Provider{}).Name() != "env" {
t.Fail()
}
}
func TestResolveAccount_BothSet(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_test")
t.Setenv(envvars.CliAppSecret, "secret_test")
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 != "feishu" {
t.Errorf("unexpected: %+v", acct)
}
}
func TestResolveAccount_NeitherSet(t *testing.T) {
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil || acct != nil {
t.Errorf("expected nil, nil; got %+v, %v", acct, err)
}
}
func TestResolveAccount_OnlyIDSet(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_test")
_, err := (&Provider{}).ResolveAccount(context.Background())
var blockErr *credential.BlockError
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %v", err)
}
}
func TestResolveAccount_AppIDAndUserTokenWithoutSecret(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_test")
t.Setenv(envvars.CliUserAccessToken, "uat_test")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
}
if acct == nil {
t.Fatal("expected account, got nil")
}
if acct.AppSecret != credential.NoAppSecret {
t.Fatalf("AppSecret = %q, want credential.NoAppSecret", acct.AppSecret)
}
if acct.AppID != "cli_test" {
t.Fatalf("AppID = %q, want cli_test", acct.AppID)
}
}
func TestResolveAccount_OnlySecretSet(t *testing.T) {
t.Setenv(envvars.CliAppSecret, "secret_test")
_, err := (&Provider{}).ResolveAccount(context.Background())
var blockErr *credential.BlockError
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %v", err)
}
}
func TestResolveAccount_OnlyTokenSetWithoutAppID(t *testing.T) {
t.Setenv(envvars.CliUserAccessToken, "uat_test")
_, err := (&Provider{}).ResolveAccount(context.Background())
var blockErr *credential.BlockError
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %v", err)
}
if !strings.Contains(err.Error(), envvars.CliAppID) {
t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID)
}
}
func TestResolveAccount_DefaultBrand(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_test")
t.Setenv(envvars.CliAppSecret, "secret_test")
acct, _ := (&Provider{}).ResolveAccount(context.Background())
if acct.Brand != "feishu" {
t.Errorf("expected 'feishu', got %q", acct.Brand)
}
}
func TestResolveAccount_DefaultAsFromEnv(t *testing.T) {
t.Setenv(envvars.CliAppID, "cli_test")
t.Setenv(envvars.CliAppSecret, "secret_test")
t.Setenv(envvars.CliDefaultAs, "user")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
}
if acct.DefaultAs != "user" {
t.Errorf("expected default-as user, got %q", acct.DefaultAs)
}
}
func TestResolveToken_UATSet(t *testing.T) {
t.Setenv(envvars.CliUserAccessToken, "u-env")
tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
if err != nil {
t.Fatal(err)
}
if tok.Value != "u-env" || tok.Source != "env:"+envvars.CliUserAccessToken {
t.Errorf("unexpected: %+v", tok)
}
}
func TestResolveToken_TATSet(t *testing.T) {
t.Setenv(envvars.CliTenantAccessToken, "t-env")
tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
if err != nil {
t.Fatal(err)
}
if tok.Value != "t-env" || tok.Source != "env:"+envvars.CliTenantAccessToken {
t.Errorf("unexpected: %+v", tok)
}
}
func TestResolveToken_NotSet(t *testing.T) {
tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
if err != nil || tok != nil {
t.Errorf("expected nil, nil; got %+v, %v", tok, err)
}
}
func TestResolveAccount_StrictModeBot(t *testing.T) {
t.Setenv(envvars.CliAppID, "app")
t.Setenv(envvars.CliAppSecret, "secret")
t.Setenv(envvars.CliStrictMode, "bot")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
}
if !acct.SupportedIdentities.BotOnly() {
t.Errorf("expected bot-only, got %d", acct.SupportedIdentities)
}
}
func TestResolveAccount_StrictModeUser(t *testing.T) {
t.Setenv(envvars.CliAppID, "app")
t.Setenv(envvars.CliAppSecret, "secret")
t.Setenv(envvars.CliStrictMode, "user")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
}
if !acct.SupportedIdentities.UserOnly() {
t.Errorf("expected user-only, got %d", acct.SupportedIdentities)
}
}
func TestResolveAccount_StrictModeOff(t *testing.T) {
t.Setenv(envvars.CliAppID, "app")
t.Setenv(envvars.CliAppSecret, "secret")
t.Setenv(envvars.CliStrictMode, "off")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
}
if acct.SupportedIdentities != credential.SupportsAll {
t.Errorf("expected SupportsAll, got %d", acct.SupportedIdentities)
}
}
func TestResolveAccount_InferFromUATOnly(t *testing.T) {
t.Setenv(envvars.CliAppID, "app")
t.Setenv(envvars.CliAppSecret, "secret")
t.Setenv(envvars.CliUserAccessToken, "u-tok")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
}
if !acct.SupportedIdentities.UserOnly() {
t.Errorf("expected user-only from UAT inference, got %d", acct.SupportedIdentities)
}
if acct.DefaultAs != "user" {
t.Errorf("expected default-as user from UAT inference, got %q", acct.DefaultAs)
}
}
func TestResolveAccount_InferFromTATOnly(t *testing.T) {
t.Setenv(envvars.CliAppID, "app")
t.Setenv(envvars.CliAppSecret, "secret")
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
}
if !acct.SupportedIdentities.BotOnly() {
t.Errorf("expected bot-only from TAT inference, got %d", acct.SupportedIdentities)
}
if acct.DefaultAs != "bot" {
t.Errorf("expected default-as bot from TAT inference, got %q", acct.DefaultAs)
}
}
func TestResolveAccount_InferBothTokens(t *testing.T) {
t.Setenv(envvars.CliAppID, "app")
t.Setenv(envvars.CliAppSecret, "secret")
t.Setenv(envvars.CliUserAccessToken, "u-tok")
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
}
if acct.SupportedIdentities != credential.SupportsAll {
t.Errorf("expected SupportsAll, got %d", acct.SupportedIdentities)
}
if acct.DefaultAs != "user" {
t.Errorf("expected default-as user when both tokens are present, got %q", acct.DefaultAs)
}
}
func TestResolveAccount_StrictModeOverridesTokenInference(t *testing.T) {
t.Setenv(envvars.CliAppID, "app")
t.Setenv(envvars.CliAppSecret, "secret")
t.Setenv(envvars.CliUserAccessToken, "u-tok")
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
t.Setenv(envvars.CliStrictMode, "bot")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
}
if !acct.SupportedIdentities.BotOnly() {
t.Errorf("strict mode should override token inference, got %d", acct.SupportedIdentities)
}
}
func TestResolveAccount_InvalidStrictModeRejected(t *testing.T) {
t.Setenv(envvars.CliAppID, "app")
t.Setenv(envvars.CliAppSecret, "secret")
t.Setenv(envvars.CliStrictMode, "invalid")
_, err := (&Provider{}).ResolveAccount(context.Background())
if err == nil {
t.Fatal("expected error for invalid strict mode")
}
var blockErr *credential.BlockError
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %T", err)
}
if !strings.Contains(err.Error(), envvars.CliStrictMode) {
t.Fatalf("error = %v, want mention of %s", err, envvars.CliStrictMode)
}
}
func TestResolveAccount_InvalidDefaultAsRejected(t *testing.T) {
t.Setenv(envvars.CliAppID, "app")
t.Setenv(envvars.CliAppSecret, "secret")
t.Setenv(envvars.CliDefaultAs, "invalid")
_, err := (&Provider{}).ResolveAccount(context.Background())
if err == nil {
t.Fatal("expected error for invalid default-as")
}
var blockErr *credential.BlockError
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %T", err)
}
if !strings.Contains(err.Error(), envvars.CliDefaultAs) {
t.Fatalf("error = %v, want mention of %s", err, envvars.CliDefaultAs)
}
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import "sync"
var (
mu sync.Mutex
providers []Provider
)
// Register registers a credential Provider.
// Providers are consulted in registration order.
// Typically called from init() via blank import.
func Register(p Provider) {
mu.Lock()
defer mu.Unlock()
providers = append(providers, p)
}
// Providers returns all registered providers (snapshot).
func Providers() []Provider {
mu.Lock()
defer mu.Unlock()
result := make([]Provider, len(providers))
copy(result, providers)
return result
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"context"
"testing"
)
type stubProvider struct{ name string }
func (s *stubProvider) Name() string { return s.name }
func (s *stubProvider) ResolveAccount(ctx context.Context) (*Account, error) {
return &Account{AppID: s.name}, nil
}
func (s *stubProvider) ResolveToken(ctx context.Context, req TokenSpec) (*Token, error) {
return &Token{Value: "tok-" + s.name, Source: s.name}, nil
}
func TestRegisterAndProviders(t *testing.T) {
mu.Lock()
old := providers
providers = nil
mu.Unlock()
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
Register(&stubProvider{name: "a"})
Register(&stubProvider{name: "b"})
got := Providers()
if len(got) != 2 {
t.Fatalf("expected 2, got %d", len(got))
}
if got[0].Name() != "a" || got[1].Name() != "b" {
t.Errorf("unexpected order: %s, %s", got[0].Name(), got[1].Name())
}
}
func TestProviders_ReturnsSnapshot(t *testing.T) {
mu.Lock()
old := providers
providers = nil
mu.Unlock()
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
Register(&stubProvider{name: "x"})
snap := Providers()
Register(&stubProvider{name: "y"})
if len(snap) != 1 {
t.Fatalf("snapshot should not be affected, got %d", len(snap))
}
}

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import "context"
// Brand represents the Lark platform brand.
type Brand string
const (
BrandLark Brand = "lark"
BrandFeishu Brand = "feishu"
)
// NoAppSecret marks that a credential source does not provide a real app secret.
// Token-only sources should return this value instead of inventing placeholder text.
const NoAppSecret = ""
// Identity represents the caller identity type.
type Identity string
const (
IdentityUser Identity = "user"
IdentityBot Identity = "bot"
IdentityAuto Identity = "auto"
)
// IdentitySupport declares which identities a credential source can provide.
type IdentitySupport uint8
const (
SupportsUser IdentitySupport = 1 << iota
SupportsBot
SupportsAll = SupportsUser | SupportsBot
)
// Has reports whether s includes the given flag.
func (s IdentitySupport) Has(flag IdentitySupport) bool { return s&flag != 0 }
// UserOnly returns true if only user identity is supported.
func (s IdentitySupport) UserOnly() bool { return s == SupportsUser }
// BotOnly returns true if only bot identity is supported.
func (s IdentitySupport) BotOnly() bool { return s == SupportsBot }
// Account holds resolved app credentials and configuration.
type Account struct {
AppID string
AppSecret string // real app secret; empty or NoAppSecret means unavailable
Brand Brand // BrandLark or BrandFeishu
DefaultAs Identity // IdentityUser / IdentityBot / IdentityAuto; empty = not set
ProfileName string
OpenID string // optional; if UAT is available, API result takes precedence
SupportedIdentities IdentitySupport // zero = provider did not declare; treat as no restriction
}
// Token holds a resolved access token and optional metadata.
type Token struct {
Value string
Scopes string // space-separated; empty = skip scope pre-check
Source string // e.g. "env:LARKSUITE_CLI_USER_ACCESS_TOKEN", "vault:addr"
}
// TokenType represents the kind of access token.
type TokenType string
const (
TokenTypeUAT TokenType = "uat"
TokenTypeTAT TokenType = "tat"
)
// TokenSpec describes what token is needed.
type TokenSpec struct {
Type TokenType
AppID string
}
// BlockError is returned by a Provider to actively reject a request
// and prevent subsequent providers in the chain from being consulted.
type BlockError struct {
Provider string
Reason string
}
func (e *BlockError) Error() string {
return "blocked by " + e.Provider + ": " + e.Reason
}
// Provider is the unified interface for credential resolution.
//
// Flow control uses Go's native mechanisms:
// - Handle: return &Account{...}, nil or return &Token{...}, nil
// - Skip: return nil, nil
// - Block: return nil, &BlockError{...}
type Provider interface {
Name() string
ResolveAccount(ctx context.Context) (*Account, error)
ResolveToken(ctx context.Context, req TokenSpec) (*Token, error)
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import "testing"
func TestIdentitySupport_Has(t *testing.T) {
if !SupportsAll.Has(SupportsUser) {
t.Error("SupportsAll should have SupportsUser")
}
if !SupportsAll.Has(SupportsBot) {
t.Error("SupportsAll should have SupportsBot")
}
if SupportsUser.Has(SupportsBot) {
t.Error("SupportsUser should not have SupportsBot")
}
}
func TestIdentitySupport_UserOnly(t *testing.T) {
if !SupportsUser.UserOnly() {
t.Error("SupportsUser.UserOnly() should be true")
}
if SupportsAll.UserOnly() {
t.Error("SupportsAll.UserOnly() should be false")
}
if IdentitySupport(0).UserOnly() {
t.Error("zero value UserOnly() should be false")
}
}
func TestIdentitySupport_BotOnly(t *testing.T) {
if !SupportsBot.BotOnly() {
t.Error("SupportsBot.BotOnly() should be true")
}
if SupportsAll.BotOnly() {
t.Error("SupportsAll.BotOnly() should be false")
}
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package fileio
import "errors"
// ErrPathValidation indicates the path failed security validation
// (traversal, absolute, control chars, symlink escape, etc.).
var ErrPathValidation = errors.New("path validation failed")
// PathValidationError wraps a path validation error.
// errors.Is(err, ErrPathValidation) returns true.
// errors.Is(err, <original OS error>) also works via the chain.
type PathValidationError struct {
Err error // original error
}
func (e *PathValidationError) Error() string { return e.Err.Error() }
func (e *PathValidationError) Unwrap() []error {
return []error{ErrPathValidation, e.Err}
}
// MkdirError indicates parent directory creation failed.
// Use errors.As(err, &fileio.MkdirError{}) to match.
type MkdirError struct {
Err error
}
func (e *MkdirError) Error() string { return e.Err.Error() }
func (e *MkdirError) Unwrap() error { return e.Err }
// WriteError indicates file write failed.
// Use errors.As(err, &fileio.WriteError{}) to match.
type WriteError struct {
Err error
}
func (e *WriteError) Error() string { return e.Err.Error() }
func (e *WriteError) Unwrap() error { return e.Err }

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package fileio
import "sync"
var (
mu sync.Mutex
provider Provider
)
// Register registers a FileIO Provider.
// Later registrations override earlier ones (last-write-wins).
// Unlike credential.Register which appends to a chain (multiple credential
// sources are tried in order), FileIO uses a single active provider because
// only one file I/O backend is active at a time (local vs server mode).
// Typically called from init() via blank import.
func Register(p Provider) {
mu.Lock()
defer mu.Unlock()
provider = p
}
// GetProvider returns the currently registered Provider.
// Returns nil if no provider has been registered.
func GetProvider() Provider {
mu.Lock()
defer mu.Unlock()
return provider
}

73
extension/fileio/types.go Normal file
View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package fileio
import (
"context"
"io"
"io/fs"
)
// Provider creates FileIO instances.
// Follows the same API style as extension/credential.Provider.
type Provider interface {
Name() string
ResolveFileIO(ctx context.Context) FileIO
}
// FileIO abstracts file transfer operations for CLI commands.
// The default implementation operates on the local filesystem with
// path validation, directory creation, and atomic writes.
// Inject a custom implementation via Factory.FileIOProvider to replace
// file transfer behavior (e.g. streaming in server mode).
type FileIO interface {
// Open opens a file for reading (upload, attachment, template scenarios).
// The default implementation validates the path via SafeInputPath.
Open(name string) (File, error)
// Stat returns file metadata (size validation, existence checks).
// The default implementation validates the path via SafeInputPath.
// Use os.IsNotExist(err) to distinguish "file not found" from "invalid path".
Stat(name string) (FileInfo, error)
// ResolvePath returns the validated, absolute path for the given output path.
// The default implementation delegates to SafeOutputPath.
// Use this to obtain the canonical saved path for user-facing output.
ResolvePath(path string) (string, error)
// Save writes content to the target path and returns a SaveResult.
// The default implementation validates via SafeOutputPath, creates
// parent directories, and writes atomically.
Save(path string, opts SaveOptions, body io.Reader) (SaveResult, error)
}
// FileInfo is a minimal subset of os.FileInfo covering actual CLI usage.
// os.FileInfo satisfies this interface.
type FileInfo interface {
Size() int64
IsDir() bool
Mode() fs.FileMode
}
// File is the interface returned by FileIO.Open.
// It covers the subset of *os.File methods actually used by CLI commands.
// *os.File satisfies this interface without adaptation.
type File interface {
io.Reader
io.ReaderAt
io.Closer
}
// SaveResult holds the outcome of a Save operation.
type SaveResult interface {
Size() int64 // actual bytes written
}
// SaveOptions carries metadata for Save.
// The default (local) implementation ignores these fields;
// server-mode implementations use them to construct streaming response frames.
type SaveOptions struct {
ContentType string // MIME type
ContentLength int64 // content length; -1 if unknown
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import "sync"
var (
mu sync.Mutex
provider Provider
)
// Register registers a transport Provider.
// Later registrations override earlier ones.
// Typically called from init() via blank import.
func Register(p Provider) {
mu.Lock()
defer mu.Unlock()
provider = p
}
// GetProvider returns the currently registered Provider.
// Returns nil if no provider has been registered.
func GetProvider() Provider {
mu.Lock()
defer mu.Unlock()
return provider
}

View File

@@ -0,0 +1,77 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"context"
"net/http"
"testing"
)
type stubInterceptor struct{}
func (s *stubInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
return nil
}
type stubProvider struct {
name string
}
func (s *stubProvider) Name() string { return s.name }
func (s *stubProvider) ResolveInterceptor(context.Context) Interceptor { return &stubInterceptor{} }
func TestGetProvider_NilByDefault(t *testing.T) {
mu.Lock()
provider = nil
mu.Unlock()
if got := GetProvider(); got != nil {
t.Fatalf("expected nil, got %v", got)
}
}
func TestRegisterAndGet(t *testing.T) {
mu.Lock()
provider = nil
mu.Unlock()
p := &stubProvider{name: "a"}
Register(p)
got := GetProvider()
if got != p {
t.Fatalf("expected registered provider, got %v", got)
}
}
func TestLastRegistrationWins(t *testing.T) {
mu.Lock()
provider = nil
mu.Unlock()
a := &stubProvider{name: "a"}
b := &stubProvider{name: "b"}
Register(a)
Register(b)
got := GetProvider()
if got != b {
t.Fatalf("expected provider b, got %v", got)
}
}
func TestResolveInterceptor_ReturnsNonNil(t *testing.T) {
mu.Lock()
provider = nil
mu.Unlock()
p := &stubProvider{name: "test"}
Register(p)
ic := GetProvider().ResolveInterceptor(context.Background())
if ic == nil {
t.Fatal("expected non-nil Interceptor")
}
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"context"
"net/http"
)
// Provider creates Interceptor instances.
// Follows the same API style as extension/credential.Provider and extension/fileio.Provider.
type Provider interface {
Name() string
ResolveInterceptor(ctx context.Context) Interceptor
}
// Interceptor defines network-layer customization via a pre/post hook pair.
// The built-in transport chain always executes between PreRoundTrip and the
// returned post function, and cannot be skipped or overridden by the extension.
//
// PreRoundTrip is called before the built-in chain. Use it to add custom
// headers, rewrite the host, or start trace spans. Built-in decorators run
// after this and will override any same-named security headers set here.
// The extension must not replace req.Context() — the middleware restores
// the original context after PreRoundTrip returns.
//
// The returned function (if non-nil) is called after the built-in chain
// completes. Use it for logging, ending trace spans, or recording metrics.
type Interceptor interface {
PreRoundTrip(req *http.Request) func(resp *http.Response, err error)
}

2
go.mod
View File

@@ -12,6 +12,7 @@ require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartystreets/goconvey v1.8.1
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.9
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0
github.com/zalando/go-keyring v0.2.8
@@ -54,7 +55,6 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/smarty/assertions v1.15.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (

View File

@@ -19,6 +19,7 @@ import (
"github.com/gofrs/flock"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/vfs"
)
var safeIDChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
@@ -128,7 +129,7 @@ func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *Store
configDir := core.GetConfigDir()
lockDir := filepath.Join(configDir, "locks")
if err := os.MkdirAll(lockDir, 0700); err != nil {
if err := vfs.MkdirAll(lockDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create lock directory: %w", err)
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package charcheck provides character-level security checks shared across
// path validation (localfileio) and input validation (validate) packages.
// Keeping these checks in one place ensures consistent detection of dangerous
// Unicode and control characters throughout the codebase.
package charcheck
import "fmt"
// RejectControlChars rejects C0 control characters (except \t and \n) and
// dangerous Unicode characters (Bidi overrides, zero-width, line/paragraph
// separators) that enable visual spoofing attacks.
func RejectControlChars(value, flagName string) error {
for _, r := range value {
if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7f) {
return fmt.Errorf("%s contains invalid control characters", flagName)
}
if IsDangerousUnicode(r) {
return fmt.Errorf("%s contains dangerous Unicode characters", flagName)
}
}
return nil
}
// IsDangerousUnicode identifies Unicode code points used for visual spoofing
// attacks. These characters are invisible or alter text direction, allowing
// attackers to make "report.exe" display as "report.txt" (Bidi override) or
// insert hidden content (zero-width characters).
func IsDangerousUnicode(r rune) bool {
switch {
case r >= 0x200B && r <= 0x200D: // zero-width space/non-joiner/joiner
return true
case r == 0xFEFF: // BOM / ZWNBSP
return true
case r >= 0x202A && r <= 0x202E: // Bidi: LRE/RLE/PDF/LRO/RLO
return true
case r >= 0x2028 && r <= 0x2029: // line/paragraph separator
return true
case r >= 0x2066 && r <= 0x2069: // Bidi isolates: LRI/RLI/FSI/PDI
return true
}
return false
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
)
const rawAPIJSONHint = "The endpoint may have returned an empty or non-standard JSON body. If it returns a file, rerun with --output."
// WrapDoAPIError upgrades malformed JSON decode errors from the SDK into
// actionable API errors for raw `lark-cli api` calls. All other failures
// remain network errors.
func WrapDoAPIError(err error) error {
if err == nil {
return nil
}
if isJSONDecodeError(err, false) {
return output.ErrWithHint(output.ExitAPI, "api_error",
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
}
return output.ErrNetwork("API call failed: %v", err)
}
// WrapJSONResponseParseError upgrades empty or malformed JSON response bodies
// into API errors with hints instead of generic parse failures.
func WrapJSONResponseParseError(err error, body []byte) error {
if err == nil {
return nil
}
if len(bytes.TrimSpace(body)) == 0 {
return output.ErrWithHint(output.ExitAPI, "api_error",
"API returned an empty JSON response body", rawAPIJSONHint)
}
if isJSONDecodeError(err, true) {
return output.ErrWithHint(output.ExitAPI, "api_error",
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
}
return output.ErrNetwork("API call failed: %v", err)
}
func isJSONDecodeError(err error, allowEOF bool) bool {
var syntaxErr *json.SyntaxError
var unmarshalTypeErr *json.UnmarshalTypeError
if errors.As(err, &syntaxErr) || errors.As(err, &unmarshalTypeErr) {
return true
}
if allowEOF && (errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
return true
}
msg := err.Error()
if allowEOF && strings.Contains(msg, "unexpected EOF") {
return true
}
return strings.Contains(msg, "unexpected end of JSON input") ||
strings.Contains(msg, "invalid character") ||
strings.Contains(msg, "cannot unmarshal")
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"encoding/json"
"errors"
"io"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
)
func TestWrapDoAPIError_BareEOFIsNetworkError(t *testing.T) {
err := WrapDoAPIError(io.EOF)
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitNetwork {
t.Fatalf("expected ExitNetwork, got %d", exitErr.Code)
}
if strings.Contains(exitErr.Error(), "invalid JSON response") {
t.Fatalf("unexpected JSON diagnostic for bare EOF: %q", exitErr.Error())
}
}
func TestWrapDoAPIError_SyntaxErrorIsAPIDiagnostic(t *testing.T) {
err := WrapDoAPIError(&json.SyntaxError{Offset: 1})
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
}
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") {
t.Fatalf("expected JSON diagnostic message, got %#v", exitErr.Detail)
}
}
func TestWrapJSONResponseParseError_UnexpectedEOFIsAPIDiagnostic(t *testing.T) {
err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, []byte("{"))
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
}
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") {
t.Fatalf("expected invalid JSON diagnostic, got %#v", exitErr.Detail)
}
}

View File

@@ -4,18 +4,22 @@
package client
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
)
@@ -32,10 +36,26 @@ type RawApiRequest struct {
// APIClient wraps lark.Client for all Lark Open API calls.
type APIClient struct {
Config *core.CliConfig
SDK *lark.Client // All Lark API calls go through SDK
HTTP *http.Client // Only for non-Lark API (OAuth, MCP, etc.)
ErrOut io.Writer // debug/progress output
Config *core.CliConfig
SDK *lark.Client // All Lark API calls go through SDK
HTTP *http.Client // Only for non-Lark API (OAuth, MCP, etc.)
ErrOut io.Writer // debug/progress output
Credential *credential.CredentialProvider
}
func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (string, error) {
result, err := c.Credential.ResolveToken(ctx, credential.NewTokenSpec(as, c.Config.AppID))
if err != nil {
var unavailableErr *credential.TokenUnavailableError
if errors.As(err, &unavailableErr) {
return "", output.ErrAuth("no access token available for %s", as)
}
return "", err
}
if result.Token == "" {
return "", output.ErrAuth("no access token available for %s", as)
}
return result.Token, nil
}
// buildApiReq converts a RawApiRequest into SDK types and collects
@@ -74,17 +94,15 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
var opts []larkcore.RequestOptionFunc
token, err := c.resolveAccessToken(ctx, as)
if err != nil {
return nil, err
}
if as.IsBot() {
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}
opts = append(opts, larkcore.WithTenantAccessToken(token))
} else {
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser}
if c.Config.UserOpenId == "" {
return nil, fmt.Errorf("login required: lark-cli auth login (or use --as bot)")
}
token, err := auth.GetValidAccessToken(c.HTTP, auth.NewUATCallOptions(c.Config, c.ErrOut))
if err != nil {
return nil, err
}
opts = append(opts, larkcore.WithUserAccessToken(token))
}
@@ -92,6 +110,146 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c
return c.SDK.Do(ctx, req, opts...)
}
// DoStream executes a streaming HTTP request against the Lark OpenAPI endpoint.
// Unlike DoSDKRequest (which buffers the full body via the SDK), DoStream returns
// a live *http.Response whose Body is an io.Reader for streaming consumption.
// Auth is resolved via Credential (same as DoSDKRequest). Security headers and
// any extra headers from opts are applied automatically.
// HTTP errors (status >= 400) are handled internally: the body is read (up to 4 KB),
// closed, and returned as an output.ErrNetwork — callers only receive successful responses.
func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.Identity, opts ...Option) (*http.Response, error) {
cfg := buildConfig(opts)
// Resolve auth
token, err := c.resolveAccessToken(ctx, as)
if err != nil {
return nil, err
}
// Build URL
requestURL, err := buildStreamURL(c.Config.Brand, req)
if err != nil {
return nil, err
}
// Build body
bodyReader, contentType, err := buildStreamBody(req.Body)
if err != nil {
return nil, err
}
// Timeout — use context deadline only; httpClient.Timeout would cut off
// healthy streaming responses because it includes body read time.
httpClient := *c.HTTP
httpClient.Timeout = 0
cancel := func() {}
requestCtx := ctx
if cfg.timeout > 0 {
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
requestCtx, cancel = context.WithTimeout(ctx, cfg.timeout)
}
}
// Build request
httpReq, err := http.NewRequestWithContext(requestCtx, req.HttpMethod, requestURL, bodyReader)
if err != nil {
cancel()
return nil, output.ErrNetwork("stream request failed: %s", err)
}
// Apply headers from opts
for k, vs := range cfg.headers {
for _, v := range vs {
httpReq.Header.Add(k, v)
}
}
if contentType != "" {
httpReq.Header.Set("Content-Type", contentType)
}
httpReq.Header.Set("Authorization", "Bearer "+token)
resp, err := httpClient.Do(httpReq)
if err != nil {
cancel()
return nil, output.ErrNetwork("stream request failed: %s", err)
}
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}
// Handle HTTP errors internally
if resp.StatusCode >= 400 {
defer resp.Body.Close()
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
msg := strings.TrimSpace(string(errBody))
if msg != "" {
return nil, output.ErrNetwork("HTTP %d: %s", resp.StatusCode, msg)
}
return nil, output.ErrNetwork("HTTP %d", resp.StatusCode)
}
return resp, nil
}
type cancelOnCloseBody struct {
io.ReadCloser
cancel context.CancelFunc
}
func (r *cancelOnCloseBody) Close() error {
err := r.ReadCloser.Close()
if r.cancel != nil {
r.cancel()
}
return err
}
func buildStreamURL(brand core.LarkBrand, req *larkcore.ApiReq) (string, error) {
requestURL := req.ApiPath
if !strings.HasPrefix(requestURL, "http://") && !strings.HasPrefix(requestURL, "https://") {
var pathSegs []string
for _, segment := range strings.Split(req.ApiPath, "/") {
if !strings.HasPrefix(segment, ":") {
pathSegs = append(pathSegs, segment)
continue
}
pathKey := strings.TrimPrefix(segment, ":")
pathValue, ok := req.PathParams[pathKey]
if !ok {
return "", output.ErrValidation("missing path param %q for %s", pathKey, req.ApiPath)
}
if pathValue == "" {
return "", output.ErrValidation("empty path param %q for %s", pathKey, req.ApiPath)
}
pathSegs = append(pathSegs, url.PathEscape(pathValue))
}
endpoints := core.ResolveEndpoints(brand)
requestURL = strings.TrimRight(endpoints.Open, "/") + strings.Join(pathSegs, "/")
}
if query := req.QueryParams.Encode(); query != "" {
requestURL += "?" + query
}
return requestURL, nil
}
func buildStreamBody(body interface{}) (io.Reader, string, error) {
switch typed := body.(type) {
case nil:
return nil, "", nil
case io.Reader:
return typed, "", nil
case []byte:
return bytes.NewReader(typed), "", nil
case string:
return strings.NewReader(typed), "text/plain; charset=utf-8", nil
default:
payload, err := json.Marshal(typed)
if err != nil {
return nil, "", output.Errorf(output.ExitInternal, "api_error", "failed to encode request body: %s", err)
}
return bytes.NewReader(payload), "application/json", nil
}
}
// DoAPI executes a raw Lark SDK request and returns the raw *larkcore.ApiResp.
// Unlike CallAPI which always JSON-decodes, DoAPI returns the raw response — suitable
// for file downloads (pass larkcore.WithFileDownload() via request.ExtraOpts) and

View File

@@ -7,13 +7,20 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
)
// roundTripFunc is an adapter to use a function as http.RoundTripper.
@@ -31,18 +38,36 @@ func jsonResponse(body interface{}) *http.Response {
}
}
// staticTokenResolver always returns a fixed token without any HTTP calls.
type staticTokenResolver struct{}
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{Token: "test-token"}, nil
}
type missingTokenResolver struct{}
func (s *missingTokenResolver) ResolveToken(_ context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
return nil, &credential.TokenUnavailableError{Source: "default", Type: req.Type}
}
// newTestAPIClient creates an APIClient with a mock HTTP transport.
func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Buffer) {
t.Helper()
errBuf := &bytes.Buffer{}
httpClient := &http.Client{Transport: rt}
sdk := lark.NewClient("test-app", "test-secret",
lark.WithEnableTokenCache(false),
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(httpClient),
)
testCred := credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil)
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
return &APIClient{
SDK: sdk,
ErrOut: errBuf,
SDK: sdk,
ErrOut: errBuf,
Credential: testCred,
Config: cfg,
}, errBuf
}
@@ -87,21 +112,13 @@ func TestMimeToExt(t *testing.T) {
func TestStreamPages_NonBatchAPI_NoArrayField(t *testing.T) {
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "tenant_access_token"):
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-token", "expire": 7200,
}), nil
default:
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"user_id": "u123",
"name": "Test User",
},
}), nil
}
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"user_id": "u123",
"name": "Test User",
},
}), nil
})
ac, errBuf := newTestAPIClient(t, rt)
@@ -138,21 +155,13 @@ func TestStreamPages_NonBatchAPI_NoArrayField(t *testing.T) {
func TestStreamPages_BatchAPI_WithArrayField(t *testing.T) {
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "tenant_access_token"):
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-token", "expire": 7200,
}), nil
default:
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}},
"has_more": false,
},
}), nil
}
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}},
"has_more": false,
},
}), nil
})
ac, errBuf := newTestAPIClient(t, rt)
@@ -186,23 +195,15 @@ func TestStreamPages_BatchAPI_WithArrayField(t *testing.T) {
func TestPaginateAll_PageLimitStopsPagination(t *testing.T) {
apiCalls := 0
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "tenant_access_token"):
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-token", "expire": 7200,
}), nil
default:
apiCalls++
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": apiCalls}},
"has_more": true,
"page_token": "next",
},
}), nil
}
apiCalls++
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": apiCalls}},
"has_more": true,
"page_token": "next",
},
}), nil
})
ac, errBuf := newTestAPIClient(t, rt)
@@ -319,21 +320,13 @@ func TestBuildApiReq_QueryParams(t *testing.T) {
func TestPaginateAll_NoStreamSummaryLog(t *testing.T) {
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "tenant_access_token"):
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-token", "expire": 7200,
}), nil
default:
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
}), nil
}
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
}), nil
})
ac, errBuf := newTestAPIClient(t, rt)
@@ -354,3 +347,78 @@ func TestPaginateAll_NoStreamSummaryLog(t *testing.T) {
t.Fatal("expected non-nil result")
}
}
func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
time.Sleep(25 * time.Millisecond)
_, _ = io.WriteString(w, "ok")
}))
defer srv.Close()
ac := &APIClient{
HTTP: &http.Client{Timeout: 5 * time.Millisecond},
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
resp, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: srv.URL,
}, core.AsBot)
if err != nil {
t.Fatalf("DoStream() error = %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("ReadAll() error = %v", err)
}
if string(body) != "ok" {
t.Fatalf("response body = %q, want %q", string(body), "ok")
}
}
func TestDoSDKRequest_MissingTokenReturnsAuthError(t *testing.T) {
ac, _ := newTestAPIClient(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatal("unexpected HTTP request")
return nil, nil
}))
ac.Credential = credential.NewCredentialProvider(nil, nil, &missingTokenResolver{}, nil)
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/test",
}, core.AsBot)
if err == nil {
t.Fatal("DoSDKRequest() error = nil, want auth error")
}
var exitErr *output.ExitError
if !strings.Contains(err.Error(), "no access token available") || !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
t.Fatalf("DoSDKRequest() error = %v, want auth error", err)
}
}
func TestDoStream_MissingTokenReturnsAuthError(t *testing.T) {
ac := &APIClient{
HTTP: &http.Client{},
Credential: credential.NewCredentialProvider(nil, nil, &missingTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
_, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "https://example.com/open-apis/test",
}, core.AsBot)
if err == nil {
t.Fatal("DoStream() error = nil, want auth error")
}
var exitErr *output.ExitError
if !strings.Contains(err.Error(), "no access token available") || !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
t.Fatalf("DoStream() error = %v, want auth error", err)
}
}

46
internal/client/option.go Normal file
View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"net/http"
"time"
)
// Option configures API request behavior for DoStream (and future DoSDKRequest).
type Option func(*requestConfig)
type requestConfig struct {
timeout time.Duration
headers http.Header
}
// WithTimeout sets a request-level timeout that overrides the client default.
func WithTimeout(d time.Duration) Option {
return func(c *requestConfig) {
c.timeout = d
}
}
// WithHeaders adds extra HTTP headers to the request.
func WithHeaders(h http.Header) Option {
return func(c *requestConfig) {
if c.headers == nil {
c.headers = make(http.Header)
}
for k, vs := range h {
for _, v := range vs {
c.headers.Add(k, v)
}
}
}
}
func buildConfig(opts []Option) requestConfig {
var cfg requestConfig
for _, o := range opts {
o(&cfg)
}
return cfg
}

View File

@@ -6,18 +6,17 @@ package client
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"os"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
)
// ── Response routing ──
@@ -29,6 +28,7 @@ type ResponseOptions struct {
JqExpr string // if set, apply jq filter instead of Format
Out io.Writer // stdout
ErrOut io.Writer // stderr
FileIO fileio.FileIO // file transfer abstraction; required when saving files (--output or binary response)
// CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse.
CheckError func(interface{}) error
}
@@ -55,13 +55,13 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
if IsJSONContentType(ct) || ct == "" {
result, err := ParseJSONResponse(resp)
if err != nil {
return output.ErrNetwork("API call failed: %v", err)
return WrapJSONResponseParseError(err, resp.RawBody)
}
if apiErr := check(result); apiErr != nil {
return apiErr
}
if opts.OutputPath != "" {
return saveAndPrint(resp, opts.OutputPath, opts.Out)
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out)
}
if opts.JqExpr != "" {
return output.JqFilter(opts.Out, result, opts.JqExpr)
@@ -75,11 +75,11 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
return output.ErrValidation("--jq requires a JSON response (got Content-Type: %s)", ct)
}
if opts.OutputPath != "" {
return saveAndPrint(resp, opts.OutputPath, opts.Out)
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out)
}
// No --output: auto-save with derived filename.
meta, err := SaveResponse(resp, ResolveFilename(resp))
meta, err := SaveResponse(opts.FileIO, resp, ResolveFilename(resp))
if err != nil {
return output.Errorf(output.ExitInternal, "file_error", "%s", err)
}
@@ -88,8 +88,8 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
return nil
}
func saveAndPrint(resp *larkcore.ApiResp, path string, w io.Writer) error {
meta, err := SaveResponse(resp, path)
func saveAndPrint(fio fileio.FileIO, resp *larkcore.ApiResp, path string, w io.Writer) error {
meta, err := SaveResponse(fio, resp, path)
if err != nil {
return output.Errorf(output.ExitInternal, "file_error", "%s", err)
}
@@ -111,7 +111,7 @@ func ParseJSONResponse(resp *larkcore.ApiResp) (interface{}, error) {
dec := json.NewDecoder(bytes.NewReader(resp.RawBody))
dec.UseNumber()
if err := dec.Decode(&result); err != nil {
return nil, fmt.Errorf("response parse error: %v (body: %s)", err, util.TruncateStr(string(resp.RawBody), 500))
return nil, fmt.Errorf("response parse error: %w (body: %s)", err, util.TruncateStr(string(resp.RawBody), 500))
}
return result, nil
}
@@ -119,23 +119,34 @@ func ParseJSONResponse(resp *larkcore.ApiResp) (interface{}, error) {
// ── File saving ──
// SaveResponse writes an API response body to the given outputPath and returns metadata.
func SaveResponse(resp *larkcore.ApiResp, outputPath string) (map[string]interface{}, error) {
safePath, err := validate.SafeOutputPath(outputPath)
// It delegates to FileIO.Save for path validation and atomic write; fio must not be nil.
func SaveResponse(fio fileio.FileIO, resp *larkcore.ApiResp, outputPath string) (map[string]interface{}, error) {
result, err := fio.Save(outputPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: int64(len(resp.RawBody)),
}, bytes.NewReader(resp.RawBody))
if err != nil {
return nil, fmt.Errorf("unsafe output path: %s", err)
var me *fileio.MkdirError
var we *fileio.WriteError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return nil, fmt.Errorf("unsafe output path: %s", err)
case errors.As(err, &me):
return nil, fmt.Errorf("create directory: %s", err)
case errors.As(err, &we):
return nil, fmt.Errorf("cannot write file: %s", err)
default:
return nil, fmt.Errorf("cannot write file: %s", err)
}
}
if err := os.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
return nil, fmt.Errorf("create directory: %s", err)
resolvedPath, err := fio.ResolvePath(outputPath)
if err != nil || resolvedPath == "" {
resolvedPath = outputPath
}
if err := validate.AtomicWrite(safePath, resp.RawBody, 0644); err != nil {
return nil, fmt.Errorf("cannot write file: %s", err)
}
return map[string]interface{}{
"saved_path": safePath,
"size_bytes": len(resp.RawBody),
"saved_path": resolvedPath,
"size_bytes": result.Size(),
"content_type": resp.Header.Get("Content-Type"),
}, nil
}

View File

@@ -6,6 +6,7 @@ package client
import (
"bytes"
"errors"
"io"
"net/http"
"os"
"path/filepath"
@@ -15,6 +16,7 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
func newApiResp(body []byte, headers map[string]string) *larkcore.ApiResp {
@@ -75,6 +77,17 @@ func TestParseJSONResponse_Invalid(t *testing.T) {
}
}
func TestParseJSONResponse_EmptyBody_WrapsEOF(t *testing.T) {
resp := newApiResp([]byte{}, map[string]string{"Content-Type": "application/json"})
_, err := ParseJSONResponse(resp)
if err == nil {
t.Fatal("expected error for empty body")
}
if !errors.Is(err, io.EOF) {
t.Fatalf("expected wrapped io.EOF, got %v", err)
}
}
func TestResolveFilename(t *testing.T) {
tests := []struct {
name string
@@ -150,11 +163,11 @@ func TestSaveResponse(t *testing.T) {
body := []byte("hello binary data")
resp := newApiResp(body, map[string]string{"Content-Type": "application/octet-stream"})
meta, err := SaveResponse(resp, "test_output.bin")
meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "test_output.bin")
if err != nil {
t.Fatalf("SaveResponse failed: %v", err)
}
if meta["size_bytes"] != len(body) {
if meta["size_bytes"] != int64(len(body)) {
t.Errorf("expected size_bytes=%d, got %v", len(body), meta["size_bytes"])
}
@@ -176,7 +189,7 @@ func TestSaveResponse_CreatesDir(t *testing.T) {
resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
meta, err := SaveResponse(resp, filepath.Join("sub", "deep", "out.bin"))
meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, filepath.Join("sub", "deep", "out.bin"))
if err != nil {
t.Fatalf("SaveResponse with nested dir failed: %v", err)
}
@@ -195,6 +208,7 @@ func TestHandleResponse_JSON(t *testing.T) {
err := HandleResponse(resp, ResponseOptions{
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err != nil {
t.Fatalf("HandleResponse failed: %v", err)
@@ -213,12 +227,44 @@ func TestHandleResponse_JSONWithError(t *testing.T) {
err := HandleResponse(resp, ResponseOptions{
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err == nil {
t.Error("expected error for non-zero code")
}
}
func TestHandleResponse_EmptyJSONBody_ShowsDiagnostic(t *testing.T) {
resp := newApiResp([]byte{}, map[string]string{"Content-Type": "application/json"})
var out bytes.Buffer
var errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{
Out: &out,
ErrOut: &errOut,
})
if err == nil {
t.Fatal("expected error for empty JSON body")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
}
if exitErr.Detail == nil {
t.Fatal("expected detail on exit error")
}
if exitErr.Detail.Message != "API returned an empty JSON response body" {
t.Fatalf("unexpected message: %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "--output") {
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
}
}
func TestHandleResponse_BinaryAutoSave(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
@@ -232,6 +278,7 @@ func TestHandleResponse_BinaryAutoSave(t *testing.T) {
err := HandleResponse(resp, ResponseOptions{
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err != nil {
t.Fatalf("HandleResponse binary failed: %v", err)
@@ -255,6 +302,7 @@ func TestHandleResponse_BinaryWithOutput(t *testing.T) {
OutputPath: "out.png",
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err != nil {
t.Fatalf("HandleResponse with output path failed: %v", err)
@@ -269,7 +317,7 @@ func TestHandleResponse_NonJSONError_404(t *testing.T) {
resp := newApiRespWithStatus(404, []byte("404 page not found"), map[string]string{"Content-Type": "text/plain"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut})
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatal("expected error for 404 text/plain")
}
@@ -287,7 +335,7 @@ func TestHandleResponse_NonJSONError_502(t *testing.T) {
resp := newApiRespWithStatus(502, []byte("<html>Bad Gateway</html>"), map[string]string{"Content-Type": "text/html"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut})
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatal("expected error for 502 text/html")
}
@@ -310,7 +358,7 @@ func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) {
resp := newApiRespWithStatus(200, []byte("plain text file content"), map[string]string{"Content-Type": "text/plain"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut})
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err != nil {
t.Fatalf("expected no error for 200 text/plain, got: %v", err)
}
@@ -336,12 +384,53 @@ func TestHandleResponse_BinaryWithJq_RejectsNonJSON(t *testing.T) {
}
}
func TestSaveResponse_RejectsPathTraversal(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
os.Chdir(dir)
defer os.Chdir(origWd)
resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
_, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "../../evil.txt")
if err == nil {
t.Fatal("expected error for path traversal")
}
if !strings.Contains(err.Error(), "unsafe output path") {
t.Errorf("expected 'unsafe output path' wrapper, got: %v", err)
}
}
func TestSaveResponse_RejectsAbsolutePath(t *testing.T) {
resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
_, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "/tmp/evil.txt")
if err == nil {
t.Fatal("expected error for absolute path")
}
}
func TestSaveResponse_MetadataContainsAbsolutePath(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
os.Chdir(dir)
defer os.Chdir(origWd)
resp := newApiResp([]byte("x"), map[string]string{"Content-Type": "text/plain"})
meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "rel.txt")
if err != nil {
t.Fatalf("SaveResponse failed: %v", err)
}
savedPath, _ := meta["saved_path"].(string)
if !filepath.IsAbs(savedPath) {
t.Errorf("saved_path should be absolute, got %q", savedPath)
}
}
func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) {
body := []byte(`{"code":99991400,"msg":"invalid token"}`)
resp := newApiRespWithStatus(403, body, map[string]string{"Content-Type": "application/json"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut})
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatal("expected error for 403 JSON with non-zero code")
}

View File

@@ -3,9 +3,31 @@
package cmdutil
import "github.com/spf13/cobra"
import (
"strings"
"github.com/spf13/cobra"
)
const skipAuthCheckKey = "skipAuthCheck"
const annotationSupportedIdentities = "lark:supportedIdentities"
// SetSupportedIdentities marks which identities a command supports.
func SetSupportedIdentities(cmd *cobra.Command, identities []string) {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[annotationSupportedIdentities] = strings.Join(identities, ",")
}
// GetSupportedIdentities returns the declared identities, or nil if not declared.
func GetSupportedIdentities(cmd *cobra.Command) []string {
v, ok := cmd.Annotations[annotationSupportedIdentities]
if !ok || v == "" {
return nil
}
return strings.Split(v, ",")
}
// DisableAuthCheck marks a command (and all its children) as not requiring auth.
func DisableAuthCheck(cmd *cobra.Command) {

View File

@@ -49,3 +49,27 @@ func TestIsAuthCheckDisabled_NoInheritanceUpward(t *testing.T) {
t.Error("child should have disabled auth check")
}
}
func TestSetGetSupportedIdentities(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
if got := GetSupportedIdentities(cmd); got != nil {
t.Errorf("expected nil, got %v", got)
}
SetSupportedIdentities(cmd, []string{"user", "bot"})
got := GetSupportedIdentities(cmd)
if len(got) != 2 || got[0] != "user" || got[1] != "bot" {
t.Errorf("expected [user bot], got %v", got)
}
}
func TestSetSupportedIdentities_OverwriteExisting(t *testing.T) {
cmd := &cobra.Command{Use: "test", Annotations: map[string]string{"other": "val"}}
SetSupportedIdentities(cmd, []string{"bot"})
if cmd.Annotations["other"] != "val" {
t.Error("existing annotation should be preserved")
}
got := GetSupportedIdentities(cmd)
if len(got) != 1 || got[0] != "bot" {
t.Errorf("expected [bot], got %v", got)
}
}

View File

@@ -4,92 +4,114 @@
package cmdutil
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strings"
lark "github.com/larksuite/oapi-sdk-go/v3"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/auth"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
// ResolveConfig returns Config() for bot identity, or AuthConfig() for user identity.
func (f *Factory) ResolveConfig(as core.Identity) (*core.CliConfig, error) {
if as.IsBot() {
return f.Config()
}
return f.AuthConfig()
}
// Factory holds shared dependencies injected into every command.
// All function fields are lazily initialized and cached after first call.
// In tests, replace any field to stub out external dependencies.
type InvocationContext struct {
Profile string
}
type Factory struct {
Config func() (*core.CliConfig, error) // lazily loads app config (credentials, brand, defaultAs)
AuthConfig func() (*core.CliConfig, error) // like Config but also requires a logged-in user
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
IOStreams *IOStreams // stdin/stdout/stderr streams
Invocation InvocationContext // Immutable call context; do not mutate after Factory construction.
Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests)
IdentityAutoDetected bool // set by ResolveAs when identity was auto-detected
ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call
Credential *credential.CredentialProvider
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
}
// ResolveFileIO resolves a FileIO instance using the current execution context.
// The provider controls whether the returned instance is fresh or cached.
func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
if f == nil || f.FileIOProvider == nil {
return nil
}
return f.FileIOProvider.ResolveFileIO(ctx)
}
// ResolveAs returns the effective identity type.
// If the user explicitly passed --as, use that value; otherwise use the configured default.
// When the value is "auto" (or unset), auto-detect based on login state.
func (f *Factory) ResolveAs(cmd *cobra.Command, flagAs core.Identity) core.Identity {
// When the value is "auto" (or unset), auto-detect based on credential hints.
func (f *Factory) ResolveAs(ctx context.Context, cmd *cobra.Command, flagAs core.Identity) core.Identity {
f.IdentityAutoDetected = false
// Strict mode: force identity regardless of flags or config.
if forced := f.ResolveStrictMode(ctx).ForcedIdentity(); forced != "" {
f.ResolvedIdentity = forced
return forced
}
if cmd != nil && cmd.Flags().Changed("as") {
if flagAs != "auto" {
f.ResolvedIdentity = flagAs
return flagAs
}
// --as auto: fall through to auto-detect
} else if defaultAs := f.resolveDefaultAs(); defaultAs != "" && defaultAs != "auto" {
f.ResolvedIdentity = core.Identity(defaultAs)
return f.ResolvedIdentity
}
// Auto-detect based on login state
hint := f.resolveIdentityHint(ctx)
if cmd == nil || !cmd.Flags().Changed("as") {
if defaultAs := resolveDefaultAsFromHint(hint); defaultAs != "" && defaultAs != core.AsAuto {
f.ResolvedIdentity = defaultAs
return f.ResolvedIdentity
}
}
// Auto-detect based on credential hint
f.IdentityAutoDetected = true
result := f.autoDetectIdentity()
result := autoDetectIdentityFromHint(hint)
f.ResolvedIdentity = result
return result
}
// resolveDefaultAs returns the configured default identity: env var > config file.
func (f *Factory) resolveDefaultAs() string {
if v := os.Getenv("LARKSUITE_CLI_DEFAULT_AS"); v != "" {
return v
}
if cfg, err := f.Config(); err == nil {
return cfg.DefaultAs
func resolveDefaultAsFromHint(hint *credential.IdentityHint) core.Identity {
if hint != nil {
return hint.DefaultAs
}
return ""
}
// autoDetectIdentity checks the login state and returns user if logged in, bot otherwise.
func (f *Factory) autoDetectIdentity() core.Identity {
cfg, err := f.Config()
if err != nil || cfg.UserOpenId == "" {
return core.AsBot
func autoDetectIdentityFromHint(hint *credential.IdentityHint) core.Identity {
if hint != nil && hint.AutoAs != "" {
return hint.AutoAs
}
stored := auth.GetStoredToken(cfg.AppID, cfg.UserOpenId)
if stored == nil {
return core.AsBot
return core.AsBot
}
func (f *Factory) resolveIdentityHint(ctx context.Context) *credential.IdentityHint {
if f.Credential == nil {
return nil
}
if auth.TokenStatus(stored) == "expired" {
return core.AsBot
hint, err := f.Credential.ResolveIdentityHint(ctx)
if err != nil {
return nil
}
return core.AsUser
return hint
}
// CheckIdentity verifies the resolved identity is in the supported list.
@@ -111,6 +133,39 @@ func (f *Factory) CheckIdentity(as core.Identity, supported []string) error {
return fmt.Errorf("--as %s is not supported, this command only supports: %s", as, list)
}
// ResolveStrictMode returns the effective strict mode by reading
// Account.SupportedIdentities from the credential provider chain.
func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
if f.Credential == nil {
return core.StrictModeOff
}
acct, err := f.Credential.ResolveAccount(ctx)
if err != nil || acct == nil {
return core.StrictModeOff
}
ids := extcred.IdentitySupport(acct.SupportedIdentities)
switch {
case ids.BotOnly():
return core.StrictModeBot
case ids.UserOnly():
return core.StrictModeUser
default:
return core.StrictModeOff
}
}
// CheckStrictMode returns an error if strict mode is active and identity is not allowed.
func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error {
mode := f.ResolveStrictMode(ctx)
if mode.IsActive() && !mode.AllowsIdentity(as) {
return output.Errorf(output.ExitValidation, "strict_mode",
"strict mode is %q, only %s identity is allowed. "+
"This setting is managed by the administrator and must not be modified by AI agents.",
mode, mode.ForcedIdentity())
}
return nil
}
// NewAPIClient creates an APIClient using the Factory's base Config (app credentials only).
// For user-mode calls where the correct user profile matters, use NewAPIClientWithConfig instead.
func (f *Factory) NewAPIClient() (*client.APIClient, error) {
@@ -122,8 +177,7 @@ func (f *Factory) NewAPIClient() (*client.APIClient, error) {
}
// NewAPIClientWithConfig creates an APIClient with an explicit config.
// Use this when the caller has already resolved the correct user profile
// (e.g. via AuthConfig for user-mode commands).
// Use this when the caller has already resolved the correct config.
func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient, error) {
sdk, err := f.LarkClient()
if err != nil {
@@ -137,5 +191,11 @@ func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient
if f.IOStreams != nil {
errOut = f.IOStreams.ErrOut
}
return &client.APIClient{Config: cfg, SDK: sdk, HTTP: httpClient, ErrOut: errOut}, nil
return &client.APIClient{
Config: cfg,
SDK: sdk,
HTTP: httpClient,
ErrOut: errOut,
Credential: f.Credential,
}, nil
}

View File

@@ -4,7 +4,9 @@
package cmdutil
import (
"context"
"fmt"
"io"
"net/http"
"os"
"sync"
@@ -14,17 +16,28 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"golang.org/x/term"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/util"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
)
// NewDefault creates a production Factory with cached closures.
func NewDefault() *Factory {
// Initialization follows a credential-first order:
//
// Phase 1: HttpClient (no credential dependency)
// Phase 2: Credential (sole data source for account info)
// Phase 3: Config derived from Credential
// Phase 4: LarkClient derived from Credential
func NewDefault(inv InvocationContext) *Factory {
f := &Factory{
Keychain: keychain.Default(),
Keychain: keychain.Default(),
Invocation: inv,
}
f.IOStreams = &IOStreams{
In: os.Stdin,
@@ -32,28 +45,36 @@ func NewDefault() *Factory {
ErrOut: os.Stderr,
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())),
}
f.Config = cachedConfigFunc(f)
f.AuthConfig = cachedAuthConfigFunc(f)
f.HttpClient = cachedHttpClientFunc()
f.LarkClient = cachedLarkClientFunc(f)
return f
}
func cachedConfigFunc(f *Factory) func() (*core.CliConfig, error) {
return sync.OnceValues(func() (*core.CliConfig, error) {
cfg, err := core.RequireConfig(f.Keychain)
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc()
// Phase 2: Credential (sole data source)
f.Credential = buildCredentialProvider(credentialDeps{
Keychain: f.Keychain,
Profile: inv.Profile,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
})
// Phase 3: Config derived from Credential via an explicit conversion boundary.
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
return cfg, err
return nil, err
}
cfg := acct.ToCliConfig()
registry.InitWithBrand(cfg.Brand)
return cfg, nil
})
}
func cachedAuthConfigFunc(f *Factory) func() (*core.CliConfig, error) {
return sync.OnceValues(func() (*core.CliConfig, error) {
return core.RequireAuth(f.Keychain)
})
// Phase 4: LarkClient from Credential (placeholder AppSecret)
f.LarkClient = cachedLarkClientFunc(f)
return f
}
// safeRedirectPolicy prevents credential headers from being forwarded
@@ -79,8 +100,8 @@ func cachedHttpClientFunc() func() (*http.Client, error) {
var transport http.RoundTripper = util.NewBaseTransport()
transport = &RetryTransport{Base: transport}
transport = &SecurityHeaderTransport{Base: transport}
transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor
transport = wrapWithExtension(transport)
client := &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
@@ -92,26 +113,50 @@ func cachedHttpClientFunc() func() (*http.Client, error) {
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
return sync.OnceValues(func() (*lark.Client, error) {
cfg, err := f.Config()
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
return nil, err
}
opts := []lark.ClientOptionFunc{
lark.WithEnableTokenCache(false),
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHeaders(BaseSecurityHeaders()),
}
// Build SDK transport chain
util.WarnIfProxied(os.Stderr)
var sdkTransport http.RoundTripper = util.NewBaseTransport()
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: sdkTransport,
Transport: buildSDKTransport(),
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(cfg.Brand)
ep := core.ResolveEndpoints(acct.Brand)
opts = append(opts, lark.WithOpenBaseUrl(ep.Open))
client := lark.NewClient(cfg.AppID, cfg.AppSecret, opts...)
return client, nil
return lark.NewClient(acct.AppID, credential.RuntimeAppSecret(acct.AppSecret), opts...), nil
})
}
func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = util.NewBaseTransport()
sdkTransport = &RetryTransport{Base: sdkTransport}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
return wrapWithExtension(sdkTransport)
}
type credentialDeps struct {
Keychain keychain.KeychainAccess
Profile string
HttpClient func() (*http.Client, error)
ErrOut io.Writer
}
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
providers := extcred.Providers()
defaultAcct := credential.NewDefaultAccountProvider(deps.Keychain, deps.Profile)
defaultToken := credential.NewDefaultTokenProvider(defaultAcct, deps.HttpClient, deps.ErrOut)
// NOTE: Do not pass deps.ErrOut as warnOut. Credential resolution
// happens before the command runs, so any plain-text warning written
// to stderr would break the JSON envelope contract that AI agents
// depend on. enrichUserInfo failures are already non-fatal (the
// provider clears unverified identity fields), so silencing the
// warning is safe.
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
}

View File

@@ -0,0 +1,401 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
_ "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/extension/fileio"
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
type countingFileIOProvider struct {
resolveCalls int
}
func (p *countingFileIOProvider) Name() string { return "counting" }
func (p *countingFileIOProvider) ResolveFileIO(context.Context) fileio.FileIO {
p.resolveCalls++
return &localfileio.LocalFileIO{}
}
func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
bot := core.StrictModeBot
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
},
{
Name: "target",
AppId: "app-target",
AppSecret: core.PlainSecret("secret-target"),
Brand: core.BrandFeishu,
StrictMode: &bot,
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f := NewDefault(InvocationContext{Profile: "target"})
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot {
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeBot)
}
cfg, err := f.Config()
if err != nil {
t.Fatalf("Config() error = %v", err)
}
if cfg.ProfileName != "target" {
t.Fatalf("Config() profile = %q, want %q", cfg.ProfileName, "target")
}
if cfg.AppID != "app-target" {
t.Fatalf("Config() appID = %q, want %q", cfg.AppID, "app-target")
}
}
func TestNewDefault_InvocationProfileMissingSticksAcrossEarlyStrictMode(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f := NewDefault(InvocationContext{Profile: "missing"})
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
t.Fatalf("ResolveStrictMode() = %q, want %q", got, core.StrictModeOff)
}
_, err := f.Config()
if err == nil {
t.Fatal("Config() error = nil, want non-nil")
}
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("Config() error type = %T, want *core.ConfigError", err)
}
if cfgErr.Message != `profile "missing" not found` {
t.Fatalf("Config() error message = %q, want %q", cfgErr.Message, `profile "missing" not found`)
}
}
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := buildSDKTransport()
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
}
ua, ok := sec.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
}
func TestNewDefault_ResolveAs_UsesDefaultAsFromEnvAccount(t *testing.T) {
t.Setenv(envvars.CliAppID, "env-app")
t.Setenv(envvars.CliAppSecret, "env-secret")
t.Setenv(envvars.CliDefaultAs, "user")
t.Setenv(envvars.CliUserAccessToken, "")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f := NewDefault(InvocationContext{})
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsUser {
t.Fatalf("ResolveAs() = %q, want %q", got, core.AsUser)
}
if f.IdentityAutoDetected {
t.Fatal("IdentityAutoDetected = true, want false")
}
}
func TestNewDefault_ConfigReturnsCliConfigCopyOfCredentialAccount(t *testing.T) {
t.Setenv(envvars.CliAppID, "env-app")
t.Setenv(envvars.CliAppSecret, "env-secret")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliUserAccessToken, "uat-token")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f := NewDefault(InvocationContext{})
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
cfg, err := f.Config()
if err != nil {
t.Fatalf("Config() error = %v", err)
}
cfg.AppID = "mutated-cli-config"
if acct.AppID != "env-app" {
t.Fatalf("credential account mutated via Config(): got %q, want %q", acct.AppID, "env-app")
}
}
func TestNewDefault_ConfigUsesRuntimePlaceholderForTokenOnlyEnvAccount(t *testing.T) {
t.Setenv(envvars.CliAppID, "env-app")
t.Setenv(envvars.CliAppSecret, "")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliUserAccessToken, "uat-token")
t.Setenv(envvars.CliTenantAccessToken, "")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f := NewDefault(InvocationContext{})
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct.AppSecret != "" {
t.Fatalf("credential account AppSecret = %q, want empty string", acct.AppSecret)
}
cfg, err := f.Config()
if err != nil {
t.Fatalf("Config() error = %v", err)
}
if cfg.AppSecret != "" {
t.Fatalf("Config().AppSecret = %q, want empty string for token-only account", cfg.AppSecret)
}
if credential.HasRealAppSecret(cfg.AppSecret) {
t.Fatalf("Config().AppSecret = %q, want token-only no-secret marker", cfg.AppSecret)
}
}
func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.T) {
prev := fileio.GetProvider()
provider := &countingFileIOProvider{}
fileio.Register(provider)
t.Cleanup(func() { fileio.Register(prev) })
f := NewDefault(InvocationContext{})
if f.FileIOProvider != provider {
t.Fatalf("NewDefault() provider = %T, want %T", f.FileIOProvider, provider)
}
if provider.resolveCalls != 0 {
t.Fatalf("ResolveFileIO() calls after NewDefault() = %d, want 0", provider.resolveCalls)
}
if got := f.ResolveFileIO(context.Background()); got == nil {
t.Fatal("ResolveFileIO() = nil, want non-nil")
}
if provider.resolveCalls != 1 {
t.Fatalf("ResolveFileIO() calls after explicit resolve = %d, want 1", provider.resolveCalls)
}
}
type stubTransportProvider struct {
interceptor exttransport.Interceptor
}
func (s *stubTransportProvider) Name() string { return "stub" }
func (s *stubTransportProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
if s.interceptor != nil {
return s.interceptor
}
return &stubTransportImpl{}
}
type stubTransportImpl struct{}
func (s *stubTransportImpl) PreRoundTrip(req *http.Request) func(*http.Response, error) {
return nil
}
// headerCapturingInterceptor sets custom headers in PreRoundTrip and records
// whether PostRoundTrip was called, to verify execution order.
type headerCapturingInterceptor struct {
preCalled bool
postCalled bool
}
func (h *headerCapturingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
h.preCalled = true
// Set a custom header that should survive (no built-in override)
req.Header.Set("X-Custom-Trace", "ext-trace-123")
// Try to override a security header — should be overwritten by SecurityHeaderTransport
req.Header.Set(HeaderSource, "ext-tampered")
return func(resp *http.Response, err error) {
h.postCalled = true
}
}
func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
var receivedHeaders http.Header
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedHeaders = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
ic := &headerCapturingInterceptor{}
exttransport.Register(&stubTransportProvider{interceptor: ic})
t.Cleanup(func() { exttransport.Register(nil) })
// Use HTTP transport chain (has SecurityHeaderTransport)
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &SecurityHeaderTransport{Base: base}
transport := wrapWithExtension(base)
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
// PreRoundTrip was called
if !ic.preCalled {
t.Fatal("PreRoundTrip was not called")
}
// PostRoundTrip (closure) was called
if !ic.postCalled {
t.Fatal("PostRoundTrip closure was not called")
}
// Custom header set by extension survives (no built-in override)
if got := receivedHeaders.Get("X-Custom-Trace"); got != "ext-trace-123" {
t.Fatalf("X-Custom-Trace = %q, want %q", got, "ext-trace-123")
}
// Security header overridden by extension is restored by SecurityHeaderTransport
if got := receivedHeaders.Get(HeaderSource); got != SourceValue {
t.Fatalf("%s = %q, want %q (built-in should override extension)", HeaderSource, got, SourceValue)
}
}
func TestExtensionInterceptor_ContextTamperPrevented(t *testing.T) {
type ctxKeyType string
const testKey ctxKeyType = "original"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
var ctxValue any
// Use a custom transport that captures the context value seen by the built-in chain
capturer := roundTripFunc(func(req *http.Request) (*http.Response, error) {
ctxValue = req.Context().Value(testKey)
return http.DefaultTransport.RoundTrip(req)
})
// Interceptor that tries to tamper with context
tamperIC := interceptorFunc(func(req *http.Request) func(*http.Response, error) {
// Try to replace context with a new one
*req = *req.WithContext(context.WithValue(req.Context(), testKey, "tampered"))
return nil
})
mid := &extensionMiddleware{Base: capturer, Ext: tamperIC}
origCtx := context.WithValue(context.Background(), testKey, "original")
req, _ := http.NewRequestWithContext(origCtx, "GET", srv.URL, nil)
resp, err := mid.RoundTrip(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
resp.Body.Close()
// Built-in chain should see original context, not tampered
if ctxValue != "original" {
t.Fatalf("built-in chain saw context value %q, want %q", ctxValue, "original")
}
}
// interceptorFunc adapts a function to exttransport.Interceptor.
type interceptorFunc func(*http.Request) func(*http.Response, error)
func (f interceptorFunc) PreRoundTrip(req *http.Request) func(*http.Response, error) { return f(req) }
func TestBuildSDKTransport_WithExtension(t *testing.T) {
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(nil) })
transport := buildSDKTransport()
// Chain: extensionMiddleware → SecurityPolicy → UserAgent → Retry → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
}
sec, ok := mid.Base.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("transport type = %T, want *auth.SecurityPolicyTransport", mid.Base)
}
ua, ok := sec.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("transport type = %T, want *UserAgentTransport", sec.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
}
}
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
exttransport.Register(nil)
transport := buildSDKTransport()
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
}
ua, ok := sec.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
}

View File

@@ -4,13 +4,14 @@
package cmdutil
import (
"os"
"context"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
// newCmdWithAsFlag creates a cobra.Command with a --as string flag for testing.
@@ -29,7 +30,7 @@ func TestResolveAs_ExplicitAs(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := newCmdWithAsFlag("bot", true)
got := f.ResolveAs(cmd, core.AsBot)
got := f.ResolveAs(context.Background(), cmd, core.AsBot)
if got != core.AsBot {
t.Errorf("want bot, got %s", got)
}
@@ -45,7 +46,7 @@ func TestResolveAs_ExplicitAsUser(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := newCmdWithAsFlag("user", true)
got := f.ResolveAs(cmd, core.AsUser)
got := f.ResolveAs(context.Background(), cmd, core.AsUser)
if got != core.AsUser {
t.Errorf("want user, got %s", got)
}
@@ -60,7 +61,7 @@ func TestResolveAs_ExplicitAuto_FallsToAutoDetect(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := newCmdWithAsFlag("auto", true)
got := f.ResolveAs(cmd, "auto")
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsBot {
t.Errorf("want bot (auto-detect, no login), got %s", got)
}
@@ -76,7 +77,7 @@ func TestResolveAs_DefaultAs_FromConfig(t *testing.T) {
})
cmd := newCmdWithAsFlag("auto", false) // --as not changed
got := f.ResolveAs(cmd, "auto")
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsBot {
t.Errorf("want bot (from default-as config), got %s", got)
}
@@ -85,16 +86,18 @@ func TestResolveAs_DefaultAs_FromConfig(t *testing.T) {
}
}
func TestResolveAs_DefaultAs_FromEnv(t *testing.T) {
os.Setenv("LARKSUITE_CLI_DEFAULT_AS", "user")
defer os.Unsetenv("LARKSUITE_CLI_DEFAULT_AS")
func TestResolveAs_DefaultAs_EnvDoesNotBypassConfigSource(t *testing.T) {
t.Setenv(envvars.CliDefaultAs, "user")
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(cmd, "auto")
if got != core.AsUser {
t.Errorf("want user (from env), got %s", got)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsBot {
t.Errorf("want bot (env default-as should not bypass config source), got %s", got)
}
if !f.IdentityAutoDetected {
t.Error("IdentityAutoDetected should be true when no account default-as is set")
}
}
@@ -106,7 +109,7 @@ func TestResolveAs_DefaultAs_AutoValue_FallsToAutoDetect(t *testing.T) {
})
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(cmd, "auto")
got := f.ResolveAs(context.Background(), cmd, "auto")
// No UserOpenId → auto-detect returns bot
if got != core.AsBot {
t.Errorf("want bot (auto-detect), got %s", got)
@@ -119,7 +122,7 @@ func TestResolveAs_DefaultAs_AutoValue_FallsToAutoDetect(t *testing.T) {
func TestResolveAs_NilCmd_AutoDetect(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
got := f.ResolveAs(nil, "auto")
got := f.ResolveAs(context.Background(), nil, "auto")
if got != core.AsBot {
t.Errorf("want bot, got %s", got)
}
@@ -183,56 +186,6 @@ func TestCheckIdentity_Unsupported_AutoDetected(t *testing.T) {
}
}
// --- ResolveConfig tests ---
func TestResolveConfig_Bot(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s"}
f, _, _, _ := TestFactory(t, cfg)
got, err := f.ResolveConfig(core.AsBot)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.AppID != "a" {
t.Errorf("want AppID a, got %s", got.AppID)
}
}
func TestResolveConfig_User(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s"}
f, _, _, _ := TestFactory(t, cfg)
got, err := f.ResolveConfig(core.AsUser)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.AppID != "a" {
t.Errorf("want AppID a, got %s", got.AppID)
}
}
// --- autoDetectIdentity tests ---
func TestAutoDetectIdentity_NoUserOpenId(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
got := f.autoDetectIdentity()
if got != core.AsBot {
t.Errorf("want bot (no UserOpenId), got %s", got)
}
}
func TestAutoDetectIdentity_ConfigError(t *testing.T) {
f := &Factory{
Config: func() (*core.CliConfig, error) {
return nil, os.ErrNotExist
},
}
got := f.autoDetectIdentity()
if got != core.AsBot {
t.Errorf("want bot (config error), got %s", got)
}
}
// --- NewAPIClient / NewAPIClientWithConfig tests ---
func TestNewAPIClient(t *testing.T) {
@@ -280,3 +233,125 @@ func TestNewAPIClientWithConfig_NilIOStreams(t *testing.T) {
t.Fatal("expected non-nil APIClient")
}
}
// --- ResolveStrictMode tests ---
func TestResolveStrictMode_Off(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
t.Errorf("expected off, got %q", got)
}
}
func TestResolveStrictMode_BotFromAccount(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2} // SupportsBot = 2
f, _, _, _ := TestFactory(t, cfg)
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeBot {
t.Errorf("expected bot, got %q", got)
}
}
func TestResolveStrictMode_UserFromAccount(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1} // SupportsUser = 1
f, _, _, _ := TestFactory(t, cfg)
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeUser {
t.Errorf("expected user, got %q", got)
}
}
func TestResolveStrictMode_BothIdentities(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 3} // SupportsAll = 3
f, _, _, _ := TestFactory(t, cfg)
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
t.Errorf("expected off when both supported, got %q", got)
}
}
func TestResolveStrictMode_NilCredential(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
f.Credential = nil
if got := f.ResolveStrictMode(context.Background()); got != core.StrictModeOff {
t.Errorf("expected off with nil credential, got %q", got)
}
}
// --- CheckStrictMode tests ---
func TestCheckStrictMode_BotMode_BotAllowed(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
if err := f.CheckStrictMode(context.Background(), core.AsBot); err != nil {
t.Errorf("bot should be allowed in bot mode, got: %v", err)
}
}
func TestCheckStrictMode_BotMode_UserBlocked(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
err := f.CheckStrictMode(context.Background(), core.AsUser)
if err == nil {
t.Fatal("expected error for user in bot mode")
}
if !strings.Contains(err.Error(), "strict mode") {
t.Errorf("error should mention strict mode, got: %v", err)
}
}
func TestCheckStrictMode_UserMode_UserAllowed(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
if err := f.CheckStrictMode(context.Background(), core.AsUser); err != nil {
t.Errorf("user should be allowed in user mode, got: %v", err)
}
}
func TestCheckStrictMode_UserMode_BotBlocked(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
err := f.CheckStrictMode(context.Background(), core.AsBot)
if err == nil {
t.Fatal("expected error for bot in user mode")
}
}
func TestCheckStrictMode_Off_BothAllowed(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
if err := f.CheckStrictMode(context.Background(), core.AsUser); err != nil {
t.Errorf("user should be allowed when off: %v", err)
}
if err := f.CheckStrictMode(context.Background(), core.AsBot); err != nil {
t.Errorf("bot should be allowed when off: %v", err)
}
}
// --- ResolveAs strict mode tests ---
func TestResolveAs_StrictModeBot_ForceBot(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsBot {
t.Errorf("bot mode should force bot, got %s", got)
}
}
func TestResolveAs_StrictModeUser_ForceUser(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", SupportedIdentities: 1}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsUser {
t.Errorf("user mode should force user, got %s", got)
}
}
func TestResolveAs_StrictModeBot_IgnoresDefaultAsUser(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", DefaultAs: "user", SupportedIdentities: 2}
f, _, _, _ := TestFactory(t, cfg)
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(context.Background(), cmd, "auto")
if got != core.AsBot {
t.Errorf("bot mode should override default-as user, got %s", got)
}
}

View File

@@ -5,35 +5,46 @@ package cmdutil
import (
"encoding/json"
"io"
"github.com/larksuite/cli/internal/output"
)
// ParseOptionalBody parses --data JSON for methods that accept a request body.
// Supports stdin (-) and single-quote stripping via ResolveInput.
// Returns (nil, nil) if the method has no body or data is empty.
func ParseOptionalBody(httpMethod, data string) (interface{}, error) {
func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, error) {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
default:
return nil, nil
}
if data == "" {
resolved, err := ResolveInput(data, stdin)
if err != nil {
return nil, output.ErrValidation("--data: %s", err)
}
if resolved == "" {
return nil, nil
}
var body interface{}
if err := json.Unmarshal([]byte(data), &body); err != nil {
if err := json.Unmarshal([]byte(resolved), &body); err != nil {
return nil, output.ErrValidation("--data invalid JSON format")
}
return body, nil
}
// ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty.
func ParseJSONMap(input, label string) (map[string]any, error) {
if input == "" {
// Supports stdin (-) and single-quote stripping via ResolveInput.
func ParseJSONMap(input, label string, stdin io.Reader) (map[string]any, error) {
resolved, err := ResolveInput(input, stdin)
if err != nil {
return nil, output.ErrValidation("%s: %s", label, err)
}
if resolved == "" {
return map[string]any{}, nil
}
var result map[string]any
if err := json.Unmarshal([]byte(input), &result); err != nil {
if err := json.Unmarshal([]byte(resolved), &result); err != nil {
return nil, output.ErrValidation("%s invalid format, expected JSON object", label)
}
return result, nil

View File

@@ -23,7 +23,7 @@ func TestParseOptionalBody(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseOptionalBody(tt.method, tt.data)
got, err := ParseOptionalBody(tt.method, tt.data, nil)
if (err != nil) != tt.wantErr {
t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr)
return
@@ -53,7 +53,7 @@ func TestParseJSONMap(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseJSONMap(tt.input, tt.label)
got, err := ParseJSONMap(tt.input, tt.label, nil)
if (err != nil) != tt.wantErr {
t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr)
return

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"fmt"
"io"
"strings"
)
// ResolveInput resolves special input conventions for a raw flag value:
// - "-" → read all bytes from stdin
// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility)
// - other → return as-is
//
// This allows callers to bypass shell quoting issues (especially on Windows
// PowerShell) by piping JSON via stdin instead of command-line arguments.
func ResolveInput(raw string, stdin io.Reader) (string, error) {
if raw == "" {
return "", nil
}
// stdin
if raw == "-" {
if stdin == nil {
return "", fmt.Errorf("stdin is not available")
}
data, err := io.ReadAll(stdin)
if err != nil {
return "", fmt.Errorf("failed to read stdin: %w", err)
}
s := strings.TrimSpace(string(data))
if s == "" {
return "", fmt.Errorf("stdin is empty (did you forget to pipe input?)")
}
return s, nil
}
// strip surrounding single quotes (Windows cmd.exe passes them literally)
if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' {
raw = raw[1 : len(raw)-1]
}
return raw, nil
}

View File

@@ -0,0 +1,189 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"fmt"
"strings"
"testing"
)
func TestResolveInput_Stdin(t *testing.T) {
got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != `{"key":"value"}` {
t.Errorf("got %q, want %q", got, `{"key":"value"}`)
}
}
func TestResolveInput_Stdin_TrimNewline(t *testing.T) {
got, err := ResolveInput("-", strings.NewReader("{\"k\":\"v\"}\n"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != `{"k":"v"}` {
t.Errorf("got %q, want %q", got, `{"k":"v"}`)
}
}
func TestResolveInput_Stdin_Empty(t *testing.T) {
_, err := ResolveInput("-", strings.NewReader(""))
if err == nil {
t.Error("expected error for empty stdin")
}
if !strings.Contains(err.Error(), "stdin is empty") {
t.Errorf("expected 'stdin is empty' error, got: %v", err)
}
}
type errorReader struct{}
func (errorReader) Read([]byte) (int, error) { return 0, fmt.Errorf("disk failure") }
func TestResolveInput_Stdin_ReadError(t *testing.T) {
_, err := ResolveInput("-", errorReader{})
if err == nil || !strings.Contains(err.Error(), "failed to read stdin") {
t.Errorf("expected read error, got: %v", err)
}
}
func TestResolveInput_Stdin_WhitespaceOnly(t *testing.T) {
_, err := ResolveInput("-", strings.NewReader(" \n\t\n "))
if err == nil {
t.Error("expected error for whitespace-only stdin")
}
}
func TestResolveInput_Stdin_Nil(t *testing.T) {
_, err := ResolveInput("-", nil)
if err == nil {
t.Error("expected error for nil stdin")
}
}
func TestResolveInput_StripSingleQuotes(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"cmd.exe JSON", `'{"key":"value"}'`, `{"key":"value"}`},
{"cmd.exe empty", `'{}'`, `{}`},
{"no quotes", `{"key":"value"}`, `{"key":"value"}`},
{"just quotes", `''`, ``},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ResolveInput(tt.in, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestResolveInput_Empty(t *testing.T) {
got, err := ResolveInput("", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "" {
t.Errorf("got %q, want empty", got)
}
}
func TestResolveInput_PlainValue(t *testing.T) {
got, err := ResolveInput(`{"already":"valid"}`, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != `{"already":"valid"}` {
t.Errorf("got %q, want %q", got, `{"already":"valid"}`)
}
}
func TestResolveInput_AtPrefixPassedThrough(t *testing.T) {
// Without @file support, @-prefixed values are passed as-is
got, err := ResolveInput("@something", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "@something" {
t.Errorf("got %q, want %q", got, "@something")
}
}
// Integration: ResolveInput flows through ParseJSONMap correctly.
func TestParseJSONMap_WithStdin(t *testing.T) {
stdin := strings.NewReader(`{"message_id":"om_xxx","user_id_type":"open_id"}`)
got, err := ParseJSONMap("-", "--params", stdin)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 2 {
t.Errorf("got %d keys, want 2", len(got))
}
}
func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) {
got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got["key"] != "value" {
t.Errorf("got %v, want key=value", got)
}
}
func TestParseOptionalBody_WithStdin(t *testing.T) {
stdin := strings.NewReader(`{"text":"hello"}`)
got, err := ParseOptionalBody("POST", "-", stdin)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil {
t.Fatal("expected non-nil body")
}
m, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("expected map, got %T", got)
}
if m["text"] != "hello" {
t.Errorf("got %v, want text=hello", m)
}
}
// Simulates exact strings Go receives on different Windows shells.
func TestParseJSONMap_WindowsShellScenarios(t *testing.T) {
tests := []struct {
name string
input string
wantLen int
wantErr bool
}{
{"bash: normal JSON", `{"a":"1","b":"2"}`, 2, false},
{"cmd.exe: single-quoted", `'{"a":"1","b":"2"}'`, 2, false}, // strip ' fix
{"PS 5.x: mangled", `{a:1,b:2}`, 0, true}, // unrecoverable
{"PS 5.x: empty JSON OK", `{}`, 0, false}, // no inner "
{"PS 7.3+: normal JSON", `{"a":"1"}`, 1, false}, // already fixed
{"PS escaped: correct", `{"a":"1"}`, 1, false}, // after CommandLineToArgvW
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseJSONMap(tt.input, "--params", nil)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && len(got) != tt.wantLen {
t.Errorf("got %d keys, want %d", len(got), tt.wantLen)
}
})
}
}

View File

@@ -68,6 +68,16 @@ func ExecutionIdFromContext(ctx context.Context) (string, bool) {
// RequestOptionFunc that injects the corresponding headers into SDK requests.
// Returns nil if the context has no Shortcut info.
func ShortcutHeaderOpts(ctx context.Context) larkcore.RequestOptionFunc {
h := ShortcutHeaders(ctx)
if h == nil {
return nil
}
return larkcore.WithHeaders(h)
}
// ShortcutHeaders extracts Shortcut info from the context and returns
// the corresponding HTTP headers. Returns nil if the context has no Shortcut info.
func ShortcutHeaders(ctx context.Context) http.Header {
name, ok := ShortcutNameFromContext(ctx)
if !ok {
return nil
@@ -77,5 +87,5 @@ func ShortcutHeaderOpts(ctx context.Context) larkcore.RequestOptionFunc {
if eid, ok := ExecutionIdFromContext(ctx); ok {
h.Set(HeaderExecutionId, eid)
}
return larkcore.WithHeaders(h)
return h
}

View File

@@ -5,14 +5,19 @@ package cmdutil
import (
"bytes"
"context"
"net/http"
"os"
"testing"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/vfs"
)
// noopKeychain is a no-op KeychainAccess for tests that don't need keychain.
@@ -34,16 +39,14 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
stderrBuf := &bytes.Buffer{}
mockClient := httpmock.NewClient(reg)
// SDK mock client wraps the mock transport with UserAgentTransport
// so that User-Agent overrides the SDK default (oapi-sdk-go/v3.x.x).
sdkMockClient := &http.Client{
Transport: &UserAgentTransport{Base: reg},
}
// Build a test LarkClient using the config
var testLarkClient *lark.Client
if config != nil && config.AppID != "" {
opts := []lark.ClientOptionFunc{
lark.WithEnableTokenCache(false),
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(sdkMockClient),
lark.WithHeaders(BaseSecurityHeaders()),
@@ -51,16 +54,58 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
if config.Brand != "" {
opts = append(opts, lark.WithOpenBaseUrl(core.ResolveOpenBaseURL(config.Brand)))
}
testLarkClient = lark.NewClient(config.AppID, config.AppSecret, opts...)
testLarkClient = lark.NewClient(config.AppID, credential.RuntimeAppSecret(config.AppSecret), opts...)
}
testCred := credential.NewCredentialProvider(
nil,
&testDefaultAcct{config: config},
&testDefaultToken{},
func() (*http.Client, error) { return mockClient, nil },
)
f := &Factory{
Config: func() (*core.CliConfig, error) { return config, nil },
AuthConfig: func() (*core.CliConfig, error) { return config, nil },
HttpClient: func() (*http.Client, error) { return mockClient, nil },
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},
Keychain: &noopKeychain{},
Config: func() (*core.CliConfig, error) { return config, nil },
HttpClient: func() (*http.Client, error) { return mockClient, nil },
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},
Keychain: &noopKeychain{},
Credential: testCred,
FileIOProvider: fileio.GetProvider(),
}
return f, stdoutBuf, stderrBuf, reg
}
type testDefaultAcct struct {
config *core.CliConfig
}
func (a *testDefaultAcct) ResolveAccount(ctx context.Context) (*credential.Account, error) {
if a.config == nil {
return &credential.Account{}, nil
}
return credential.AccountFromCliConfig(a.config), nil
}
// TestChdir changes the working directory to dir for the duration of the test.
// The original directory is restored via t.Cleanup.
// This enables tests to use LocalFileIO (which resolves relative paths under cwd)
// with temporary directories, keeping test artifacts out of the source tree.
// Not compatible with t.Parallel() — os.Chdir is process-wide.
func TestChdir(t *testing.T, dir string) {
t.Helper()
orig, err := vfs.Getwd()
if err != nil {
t.Fatalf("Getwd: %v", err)
}
if err := os.Chdir(dir); err != nil { //nolint:forbidigo // no vfs.Chdir yet; test-only, process-wide chdir
t.Fatalf("Chdir(%s): %v", dir, err)
}
t.Cleanup(func() { os.Chdir(orig) }) //nolint:forbidigo // matching restore
}
type testDefaultToken struct{}
func (t *testDefaultToken) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{Token: "test-token"}, nil
}

View File

@@ -4,9 +4,11 @@
package cmdutil
import (
"context"
"net/http"
"time"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/util"
)
@@ -100,3 +102,40 @@ func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response,
}
return t.base().RoundTrip(req)
}
// extensionMiddleware wraps the built-in transport chain with pre/post hooks.
// The built-in chain always executes and cannot be skipped or overridden.
// The original request context is restored after PreRoundTrip to prevent
// extensions from tampering with cancellation, deadlines, or built-in values.
type extensionMiddleware struct {
Base http.RoundTripper
Ext exttransport.Interceptor
}
// RoundTrip calls PreRoundTrip, restores the original context, executes
// the built-in chain, then calls the post hook if non-nil.
func (m *extensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
origCtx := req.Context()
req = req.Clone(origCtx) // isolate caller's request before extension mutations
post := m.Ext.PreRoundTrip(req)
req = req.WithContext(origCtx) // restore original context
resp, err := m.Base.RoundTrip(req)
if post != nil {
post(resp, err)
}
return resp, err
}
// wrapWithExtension wraps transport with the registered extension middleware.
// If no extension is registered, returns transport unchanged.
func wrapWithExtension(transport http.RoundTripper) http.RoundTripper {
p := exttransport.GetProvider()
if p == nil {
return transport
}
tr := p.ResolveInterceptor(context.Background())
if tr == nil {
return transport
}
return &extensionMiddleware{Base: transport, Ext: tr}
}

View File

@@ -9,10 +9,13 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"unicode/utf8"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// Identity represents the caller identity for API requests.
@@ -21,6 +24,7 @@ type Identity string
const (
AsUser Identity = "user"
AsBot Identity = "bot"
AsAuto Identity = "auto"
)
// IsBot returns true if the identity is bot.
@@ -34,27 +38,129 @@ type AppUser struct {
// AppConfig is a per-app configuration entry (stored format — secrets may be unresolved).
type AppConfig struct {
AppId string `json:"appId"`
AppSecret SecretInput `json:"appSecret"`
Brand LarkBrand `json:"brand"`
Lang string `json:"lang,omitempty"`
DefaultAs string `json:"defaultAs,omitempty"` // "user" | "bot" | "auto"
Users []AppUser `json:"users"`
Name string `json:"name,omitempty"`
AppId string `json:"appId"`
AppSecret SecretInput `json:"appSecret"`
Brand LarkBrand `json:"brand"`
Lang string `json:"lang,omitempty"`
DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto
StrictMode *StrictMode `json:"strictMode,omitempty"`
Users []AppUser `json:"users"`
}
// ProfileName returns the display name for this app config.
// If Name is set, returns Name; otherwise falls back to AppId.
func (a *AppConfig) ProfileName() string {
if a.Name != "" {
return a.Name
}
return a.AppId
}
// MultiAppConfig is the multi-app config file format.
type MultiAppConfig struct {
Apps []AppConfig `json:"apps"`
StrictMode StrictMode `json:"strictMode,omitempty"`
CurrentApp string `json:"currentApp,omitempty"`
PreviousApp string `json:"previousApp,omitempty"`
Apps []AppConfig `json:"apps"`
}
// CurrentAppConfig returns the currently active app config.
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {
if profileOverride != "" {
if app := m.FindApp(profileOverride); app != nil {
return app
}
return nil
}
if m.CurrentApp != "" {
if app := m.FindApp(m.CurrentApp); app != nil {
return app
}
return nil // explicit currentApp not found; don't silently fallback
}
if len(m.Apps) > 0 {
return &m.Apps[0]
}
return nil
}
// FindApp looks up an app by name, then by appId. Returns nil if not found.
// Name match takes priority: if profile A has Name "X" and profile B has AppId "X",
// FindApp("X") returns profile A.
func (m *MultiAppConfig) FindApp(name string) *AppConfig {
// First pass: match by Name
for i := range m.Apps {
if m.Apps[i].Name != "" && m.Apps[i].Name == name {
return &m.Apps[i]
}
}
// Second pass: match by AppId
for i := range m.Apps {
if m.Apps[i].AppId == name {
return &m.Apps[i]
}
}
return nil
}
// FindAppIndex looks up an app index by name, then by appId. Returns -1 if not found.
func (m *MultiAppConfig) FindAppIndex(name string) int {
for i := range m.Apps {
if m.Apps[i].Name != "" && m.Apps[i].Name == name {
return i
}
}
for i := range m.Apps {
if m.Apps[i].AppId == name {
return i
}
}
return -1
}
// ProfileNames returns all profile names (Name if set, otherwise AppId).
func (m *MultiAppConfig) ProfileNames() []string {
names := make([]string, len(m.Apps))
for i := range m.Apps {
names[i] = m.Apps[i].ProfileName()
}
return names
}
// ValidateProfileName checks that a profile name is valid.
// Rejects empty names, whitespace, control characters, and shell-problematic characters,
// but allows Unicode letters (e.g. Chinese, Japanese) for localized profile names.
func ValidateProfileName(name string) error {
if name == "" {
return fmt.Errorf("profile name cannot be empty")
}
if utf8.RuneCountInString(name) > 64 {
return fmt.Errorf("profile name %q is too long (max 64 characters)", name)
}
for _, r := range name {
if r <= 0x1F || r == 0x7F { // control characters
return fmt.Errorf("invalid profile name %q: contains control characters", name)
}
switch r {
case ' ', '\t', '/', '\\', '"', '\'', '`', '$', '#', '!', '&', '|', ';', '(', ')', '{', '}', '[', ']', '<', '>', '?', '*', '~':
return fmt.Errorf("invalid profile name %q: contains invalid character %q", name, r)
}
}
return nil
}
// CliConfig is the resolved single-app config used by downstream code.
type CliConfig struct {
AppID string
AppSecret string
Brand LarkBrand
DefaultAs string // "user" | "bot" | "auto" | "" (from config file)
UserOpenId string
UserName string
ProfileName string
AppID string
AppSecret string
Brand LarkBrand
DefaultAs Identity // AsUser | AsBot | AsAuto | "" (from config file)
UserOpenId string
UserName string
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
}
// GetConfigDir returns the config directory path.
@@ -64,7 +170,7 @@ func GetConfigDir() string {
if dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR"); dir != "" {
return dir
}
home, err := os.UserHomeDir()
home, err := vfs.UserHomeDir()
if err != nil || home == "" {
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
}
@@ -78,7 +184,7 @@ func GetConfigPath() string {
// LoadMultiAppConfig loads multi-app config from disk.
func LoadMultiAppConfig() (*MultiAppConfig, error) {
data, err := os.ReadFile(GetConfigPath())
data, err := vfs.ReadFile(GetConfigPath())
if err != nil {
return nil, err
}
@@ -96,7 +202,7 @@ func LoadMultiAppConfig() (*MultiAppConfig, error) {
// SaveMultiAppConfig saves config to disk.
func SaveMultiAppConfig(config *MultiAppConfig) error {
dir := GetConfigDir()
if err := os.MkdirAll(dir, 0700); err != nil {
if err := vfs.MkdirAll(dir, 0700); err != nil {
return err
}
data, err := json.MarshalIndent(config, "", " ")
@@ -106,13 +212,40 @@ func SaveMultiAppConfig(config *MultiAppConfig) error {
return validate.AtomicWrite(GetConfigPath(), append(data, '\n'), 0600)
}
// RequireConfig loads the single-app config. Takes Apps[0] directly.
// RequireConfig loads the single-app config using the default profile resolution.
func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) {
return RequireConfigForProfile(kc, "")
}
// RequireConfigForProfile loads the single-app config for a specific profile.
// Resolution priority: profileOverride > config.CurrentApp > Apps[0].
func RequireConfigForProfile(kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
raw, err := LoadMultiAppConfig()
if err != nil || raw == nil || len(raw.Apps) == 0 {
return nil, &ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
}
app := raw.Apps[0]
return ResolveConfigFromMulti(raw, kc, profileOverride)
}
// ResolveConfigFromMulti resolves a single-app config from an already-loaded MultiAppConfig.
// This avoids re-reading the config file when the caller has already loaded it.
func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
app := raw.CurrentAppConfig(profileOverride)
if app == nil {
return nil, &ConfigError{
Code: 2,
Type: "config",
Message: fmt.Sprintf("profile %q not found", profileOverride),
Hint: fmt.Sprintf("available profiles: %s", formatProfileNames(raw.ProfileNames())),
}
}
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
return nil, &ConfigError{Code: 2, Type: "config",
Message: "appId and appSecret keychain key are out of sync",
Hint: err.Error()}
}
secret, err := ResolveSecretInput(app.AppSecret, kc)
if err != nil {
// If the error comes from the keychain, it will already be wrapped as an ExitError.
@@ -124,10 +257,11 @@ func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) {
return nil, &ConfigError{Code: 2, Type: "config", Message: err.Error()}
}
cfg := &CliConfig{
AppID: app.AppId,
AppSecret: secret,
Brand: app.Brand,
DefaultAs: app.DefaultAs,
ProfileName: app.ProfileName(),
AppID: app.AppId,
AppSecret: secret,
Brand: app.Brand,
DefaultAs: app.DefaultAs,
}
if len(app.Users) > 0 {
cfg.UserOpenId = app.Users[0].UserOpenId
@@ -138,7 +272,12 @@ func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) {
// RequireAuth loads config and ensures a user is logged in.
func RequireAuth(kc keychain.KeychainAccess) (*CliConfig, error) {
cfg, err := RequireConfig(kc)
return RequireAuthForProfile(kc, "")
}
// RequireAuthForProfile loads config for a profile and ensures a user is logged in.
func RequireAuthForProfile(kc keychain.KeychainAccess, profileOverride string) (*CliConfig, error) {
cfg, err := RequireConfigForProfile(kc, profileOverride)
if err != nil {
return nil, err
}
@@ -147,3 +286,11 @@ func RequireAuth(kc keychain.KeychainAccess) (*CliConfig, error) {
}
return cfg, nil
}
// formatProfileNames joins profile names for display.
func formatProfileNames(names []string) string {
if len(names) == 0 {
return "(none)"
}
return strings.Join(names, ", ")
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"encoding/json"
"testing"
)
func TestMultiAppConfig_StrictMode_JSON(t *testing.T) {
// StrictMode="" should be omitted (omitempty)
m := &MultiAppConfig{
Apps: []AppConfig{{AppId: "a", AppSecret: PlainSecret("s"), Brand: BrandFeishu, Users: []AppUser{}}},
}
data, _ := json.Marshal(m)
if string(data) != `{"apps":[{"appId":"a","appSecret":"s","brand":"feishu","users":[]}]}` {
t.Errorf("StrictMode empty should be omitted, got: %s", data)
}
// StrictMode="bot" should be present
m.StrictMode = StrictModeBot
data, _ = json.Marshal(m)
var parsed map[string]interface{}
json.Unmarshal(data, &parsed)
if parsed["strictMode"] != "bot" {
t.Errorf("StrictMode=bot should be present, got: %s", data)
}
}
func TestAppConfig_StrictMode_JSON(t *testing.T) {
// StrictMode nil should be omitted
app := &AppConfig{AppId: "a", AppSecret: PlainSecret("s"), Brand: BrandFeishu, Users: []AppUser{}}
data, _ := json.Marshal(app)
var parsed map[string]interface{}
json.Unmarshal(data, &parsed)
if _, ok := parsed["strictMode"]; ok {
t.Errorf("nil StrictMode should be omitted, got: %s", data)
}
// StrictMode = pointer to "user"
v := StrictModeUser
app.StrictMode = &v
data, _ = json.Marshal(app)
json.Unmarshal(data, &parsed)
if parsed["strictMode"] != "user" {
t.Errorf("StrictMode=user should be present, got: %s", data)
}
// StrictMode = pointer to "off" (explicit off — should be present, not omitted)
voff := StrictModeOff
app.StrictMode = &voff
data, _ = json.Marshal(app)
json.Unmarshal(data, &parsed)
if val, ok := parsed["strictMode"]; !ok || val != "off" {
t.Errorf("StrictMode=off (explicit) should be present, got: %s", data)
}
}

View File

@@ -5,9 +5,21 @@ package core
import (
"encoding/json"
"errors"
"testing"
"github.com/larksuite/cli/internal/keychain"
)
// stubKeychain is a minimal KeychainAccess that always returns ErrNotFound.
type stubKeychain struct{}
func (stubKeychain) Get(service, account string) (string, error) {
return "", keychain.ErrNotFound
}
func (stubKeychain) Set(service, account, value string) error { return nil }
func (stubKeychain) Remove(service, account string) error { return nil }
func TestAppConfig_LangSerialization(t *testing.T) {
app := AppConfig{
AppId: "cli_test", AppSecret: PlainSecret("secret"),
@@ -72,3 +84,106 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
}
}
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_new_app",
AppSecret: SecretInput{Ref: &SecretRef{
Source: "keychain",
ID: "appsecret:cli_old_app",
}},
Brand: BrandFeishu,
},
},
}
_, err := ResolveConfigFromMulti(raw, nil, "")
if err == nil {
t.Fatal("expected error for mismatched appId and appSecret keychain key")
}
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected ConfigError, got %T: %v", err, err)
}
if cfgErr.Hint == "" {
t.Error("expected non-empty hint in ConfigError")
}
}
func TestResolveConfigFromMulti_AcceptsPlainSecret(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_abc",
AppSecret: PlainSecret("my-secret"),
Brand: BrandFeishu,
},
},
}
cfg, err := ResolveConfigFromMulti(raw, nil, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.AppID != "cli_abc" {
t.Errorf("AppID = %q, want %q", cfg.AppID, "cli_abc")
}
}
func TestResolveConfigFromMulti_MatchingKeychainRefPassesValidation(t *testing.T) {
// Keychain ref matches appId, so validation passes.
// The subsequent ResolveSecretInput will fail (no real keychain),
// but that proves the mismatch check itself passed.
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_abc",
AppSecret: SecretInput{Ref: &SecretRef{
Source: "keychain",
ID: "appsecret:cli_abc",
}},
Brand: BrandFeishu,
},
},
}
_, err := ResolveConfigFromMulti(raw, stubKeychain{}, "")
if err == nil {
// stubKeychain returns ErrNotFound, so we expect a keychain error,
// but NOT a mismatch error — that's the point of this test.
t.Fatal("expected error (keychain entry not found), got nil")
}
// The error should come from keychain resolution, NOT from our mismatch check.
var cfgErr *ConfigError
if errors.As(err, &cfgErr) {
if cfgErr.Message == "appId and appSecret keychain key are out of sync" {
t.Fatal("error came from mismatch check, but keys should match")
}
}
}
func TestResolveConfigFromMulti_DoesNotUseEnvProfileFallback(t *testing.T) {
t.Setenv("LARKSUITE_CLI_PROFILE", "missing")
raw := &MultiAppConfig{
CurrentApp: "active",
Apps: []AppConfig{
{
Name: "active",
AppId: "cli_active",
AppSecret: PlainSecret("secret"),
Brand: BrandFeishu,
},
},
}
cfg, err := ResolveConfigFromMulti(raw, nil, "")
if err != nil {
t.Fatalf("ResolveConfigFromMulti() error = %v", err)
}
if cfg.ProfileName != "active" {
t.Fatalf("ResolveConfigFromMulti() profile = %q, want %q", cfg.ProfileName, "active")
}
}

View File

@@ -5,10 +5,10 @@ package core
import (
"fmt"
"os"
"strings"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/vfs"
)
const secretKeyPrefix = "appsecret:"
@@ -25,7 +25,7 @@ func ResolveSecretInput(s SecretInput, kc keychain.KeychainAccess) (string, erro
}
switch s.Ref.Source {
case "file":
data, err := os.ReadFile(s.Ref.ID)
data, err := vfs.ReadFile(s.Ref.ID)
if err != nil {
return "", fmt.Errorf("failed to read secret file %s: %w", s.Ref.ID, err)
}
@@ -52,6 +52,25 @@ func ForStorage(appId string, input SecretInput, kc keychain.KeychainAccess) (Se
return SecretInput{Ref: &SecretRef{Source: "keychain", ID: key}}, nil
}
// ValidateSecretKeyMatch checks that the appSecret keychain key references the
// expected appId. This prevents silent mismatches when config.json is edited by
// hand (e.g. appId changed but appSecret.id still points to the old app).
// Only applicable when appSecret is a keychain SecretRef; other forms are skipped.
func ValidateSecretKeyMatch(appId string, secret SecretInput) error {
if secret.Ref == nil || secret.Ref.Source != "keychain" {
return nil
}
expected := secretAccountKey(appId)
if secret.Ref.ID != expected {
return fmt.Errorf(
"appSecret keychain key %q does not match appId %q (expected %q); "+
"please run `lark-cli config init` to reconfigure",
secret.Ref.ID, appId, expected,
)
}
return nil
}
// RemoveSecretStore cleans up keychain entries when an app is removed.
// Errors are intentionally ignored — cleanup is best-effort.
func RemoveSecretStore(input SecretInput, kc keychain.KeychainAccess) {

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"strings"
"testing"
)
func TestValidateSecretKeyMatch_KeychainMatches(t *testing.T) {
secret := SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_abc123"}}
if err := ValidateSecretKeyMatch("cli_abc123", secret); err != nil {
t.Errorf("expected no error, got: %v", err)
}
}
func TestValidateSecretKeyMatch_KeychainMismatch(t *testing.T) {
secret := SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_old_app"}}
err := ValidateSecretKeyMatch("cli_new_app", secret)
if err == nil {
t.Fatal("expected error for mismatched appId and keychain key")
}
// Verify the error message contains useful context
msg := err.Error()
for _, want := range []string{"cli_old_app", "cli_new_app", "appsecret:cli_new_app", "config init"} {
if !strings.Contains(msg, want) {
t.Errorf("error message missing %q: %s", want, msg)
}
}
}
func TestValidateSecretKeyMatch_PlainSecret_Skipped(t *testing.T) {
secret := PlainSecret("some-secret")
if err := ValidateSecretKeyMatch("cli_abc123", secret); err != nil {
t.Errorf("plain secret should be skipped, got: %v", err)
}
}
func TestValidateSecretKeyMatch_FileRef_Skipped(t *testing.T) {
secret := SecretInput{Ref: &SecretRef{Source: "file", ID: "/tmp/secret.txt"}}
if err := ValidateSecretKeyMatch("cli_abc123", secret); err != nil {
t.Errorf("file ref should be skipped, got: %v", err)
}
}
func TestValidateSecretKeyMatch_ZeroValue_Skipped(t *testing.T) {
if err := ValidateSecretKeyMatch("cli_abc123", SecretInput{}); err != nil {
t.Errorf("zero SecretInput should be skipped, got: %v", err)
}
}
func TestValidateSecretKeyMatch_EmptyAppId_Mismatch(t *testing.T) {
secret := SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_abc123"}}
err := ValidateSecretKeyMatch("", secret)
if err == nil {
t.Fatal("expected error when appId is empty but keychain key references a real app")
}
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
// StrictMode represents the identity restriction policy.
type StrictMode string
const (
StrictModeOff StrictMode = "off"
StrictModeBot StrictMode = "bot"
StrictModeUser StrictMode = "user"
)
// IsActive returns true if strict mode restricts identity.
func (m StrictMode) IsActive() bool {
return m == StrictModeBot || m == StrictModeUser
}
// AllowsIdentity reports whether the given identity is permitted under this mode.
func (m StrictMode) AllowsIdentity(id Identity) bool {
switch m {
case StrictModeBot:
return id.IsBot()
case StrictModeUser:
return id == AsUser
default:
return true
}
}
// ForcedIdentity returns the identity forced by this mode, or "" if not active.
func (m StrictMode) ForcedIdentity() Identity {
switch m {
case StrictModeBot:
return AsBot
case StrictModeUser:
return AsUser
default:
return ""
}
}

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import "testing"
func TestStrictMode_IsActive(t *testing.T) {
tests := []struct {
mode StrictMode
active bool
}{
{StrictModeOff, false},
{"", false},
{StrictModeBot, true},
{StrictModeUser, true},
}
for _, tt := range tests {
if got := tt.mode.IsActive(); got != tt.active {
t.Errorf("StrictMode(%q).IsActive() = %v, want %v", tt.mode, got, tt.active)
}
}
}
func TestStrictMode_AllowsIdentity(t *testing.T) {
tests := []struct {
mode StrictMode
id Identity
ok bool
}{
{StrictModeOff, AsUser, true},
{StrictModeOff, AsBot, true},
{StrictModeBot, AsBot, true},
{StrictModeBot, AsUser, false},
{StrictModeUser, AsUser, true},
{StrictModeUser, AsBot, false},
{"", AsUser, true},
{"", AsBot, true},
}
for _, tt := range tests {
if got := tt.mode.AllowsIdentity(tt.id); got != tt.ok {
t.Errorf("StrictMode(%q).AllowsIdentity(%q) = %v, want %v", tt.mode, tt.id, got, tt.ok)
}
}
}
func TestStrictMode_ForcedIdentity(t *testing.T) {
tests := []struct {
mode StrictMode
want Identity
}{
{StrictModeOff, ""},
{StrictModeBot, AsBot},
{StrictModeUser, AsUser},
{"", ""},
}
for _, tt := range tests {
if got := tt.mode.ForcedIdentity(); got != tt.want {
t.Errorf("StrictMode(%q).ForcedIdentity() = %q, want %q", tt.mode, got, tt.want)
}
}
}

View File

@@ -13,6 +13,15 @@ const (
BrandLark LarkBrand = "lark"
)
// ParseBrand normalizes a brand string to a LarkBrand constant.
// Unrecognized values default to BrandFeishu.
func ParseBrand(value string) LarkBrand {
if value == "lark" {
return BrandLark
}
return BrandFeishu
}
// Endpoints holds resolved endpoint URLs for different Lark services.
type Endpoints struct {
Open string // e.g. "https://open.feishu.cn"

View File

@@ -0,0 +1,344 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"sync"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
)
// DefaultAccountResolver is implemented by the default account provider.
type DefaultAccountResolver interface {
ResolveAccount(ctx context.Context) (*Account, error)
}
// DefaultTokenResolver is implemented by the default token provider.
type DefaultTokenResolver interface {
ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error)
}
var (
getStoredToken = auth.GetStoredToken
getStoredTokenStatus = auth.TokenStatus
)
type credentialSource interface {
Name() string
TryResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, bool, error)
ResolveIdentityHint(ctx context.Context, acct *Account) (*IdentityHint, error)
}
type extensionTokenSource struct {
provider extcred.Provider
}
func (s extensionTokenSource) Name() string { return s.provider.Name() }
func (s extensionTokenSource) TryResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, bool, error) {
tok, err := s.provider.ResolveToken(ctx, extcred.TokenSpec{
Type: extcred.TokenType(req.Type.String()),
AppID: req.AppID,
})
if err != nil {
return nil, false, err
}
if tok == nil {
return nil, false, nil
}
if tok.Value == "" {
return nil, false, &MalformedTokenResultError{Source: s.Name(), Type: req.Type, Reason: "empty token"}
}
return &TokenResult{Token: tok.Value, Scopes: tok.Scopes}, true, nil
}
func (s extensionTokenSource) ResolveIdentityHint(ctx context.Context, acct *Account) (*IdentityHint, error) {
hint := &IdentityHint{}
if acct == nil {
return hint, nil
}
hint.DefaultAs = acct.DefaultAs
// Extension sources verify user identity via enrichUserInfo, so a resolved
// UserOpenId is sufficient here; no keychain-backed token status lookup is needed.
if acct.UserOpenId != "" {
hint.AutoAs = core.AsUser
return hint, nil
}
ids := extcred.IdentitySupport(acct.SupportedIdentities)
switch {
case ids.UserOnly():
hint.AutoAs = core.AsUser
case ids.BotOnly():
hint.AutoAs = core.AsBot
}
return hint, nil
}
type defaultTokenSource struct {
resolver DefaultTokenResolver
}
func (s defaultTokenSource) Name() string { return "default" }
func (s defaultTokenSource) TryResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, bool, error) {
if s.resolver == nil {
return nil, false, nil
}
result, err := s.resolver.ResolveToken(ctx, req)
if err != nil {
return nil, false, err
}
if result == nil {
return nil, false, &MalformedTokenResultError{Source: s.Name(), Type: req.Type, Reason: "nil token result"}
}
if result.Token == "" {
return nil, false, &MalformedTokenResultError{Source: s.Name(), Type: req.Type, Reason: "empty token"}
}
return result, true, nil
}
func (s defaultTokenSource) ResolveIdentityHint(ctx context.Context, acct *Account) (*IdentityHint, error) {
hint := &IdentityHint{}
if acct == nil {
return hint, nil
}
hint.DefaultAs = acct.DefaultAs
if acct.UserOpenId == "" {
hint.AutoAs = core.AsBot
return hint, nil
}
stored := getStoredToken(acct.AppID, acct.UserOpenId)
if stored == nil {
hint.AutoAs = core.AsBot
return hint, nil
}
if getStoredTokenStatus(stored) == "expired" {
hint.AutoAs = core.AsBot
return hint, nil
}
hint.AutoAs = core.AsUser
return hint, nil
}
// CredentialProvider is the unified entry point for all credential resolution.
type CredentialProvider struct {
providers []extcred.Provider
defaultAcct DefaultAccountResolver
defaultToken DefaultTokenResolver
httpClient func() (*http.Client, error)
warnOut io.Writer
accountOnce sync.Once
account *Account
accountErr error
selectedSource credentialSource
hintOnce sync.Once
hint *IdentityHint
hintErr error
}
// NewCredentialProvider creates a CredentialProvider.
func NewCredentialProvider(providers []extcred.Provider, defaultAcct DefaultAccountResolver, defaultToken DefaultTokenResolver, httpClient func() (*http.Client, error)) *CredentialProvider {
return &CredentialProvider{
providers: providers,
defaultAcct: defaultAcct,
defaultToken: defaultToken,
httpClient: httpClient,
}
}
func (p *CredentialProvider) SetWarnOut(warnOut io.Writer) *CredentialProvider {
p.warnOut = warnOut
return p
}
// ResolveAccount resolves app credentials. Result is cached after first call.
// NOTE: Uses sync.Once — only the context from the first call is used for resolution.
// Subsequent calls return the cached result regardless of their context.
// This is acceptable for CLI (single invocation per process) but not for long-running servers.
func (p *CredentialProvider) ResolveAccount(ctx context.Context) (*Account, error) {
p.accountOnce.Do(func() {
p.account, p.accountErr = p.doResolveAccount(ctx)
})
return p.account, p.accountErr
}
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
for _, prov := range p.providers {
acct, err := prov.ResolveAccount(ctx)
if err != nil {
return nil, err
}
if acct != nil {
internal := convertAccount(acct)
source := extensionTokenSource{provider: prov}
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
if p.warnOut != nil {
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
}
// enrichUserInfo failure is non-fatal: SupportedIdentities
// (used for strict mode) is already set by the provider.
// Clear unverified user identity for safety.
internal.UserOpenId = ""
internal.UserName = ""
}
p.selectedSource = source
return internal, nil
}
}
if p.defaultAcct != nil {
acct, err := p.defaultAcct.ResolveAccount(ctx)
if err != nil {
return nil, err
}
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
return acct, nil
}
return nil, fmt.Errorf("no credential provider returned an account; run 'lark-cli config' to set up")
}
// enrichUserInfo resolves user identity when extension provides a UAT.
// If UAT is available, user_info API call is mandatory (security: verify token validity).
// If no UAT from extension, falls back to provider-supplied OpenID.
func (p *CredentialProvider) enrichUserInfo(ctx context.Context, acct *Account, source credentialSource) error {
if p.httpClient == nil || source == nil {
return nil
}
tok, found, err := source.TryResolveToken(ctx, TokenSpec{Type: TokenTypeUAT, AppID: acct.AppID})
if err != nil {
var blockErr *extcred.BlockError
if errors.As(err, &blockErr) {
return nil // provider explicitly blocks UAT; skip enrichment
}
return fmt.Errorf("failed to resolve UAT for user identity verification: %w", err)
}
if !found {
return nil
}
// Have UAT — must verify and resolve identity
hc, err := p.httpClient()
if err != nil {
return fmt.Errorf("failed to get HTTP client for user_info: %w", err)
}
info, err := fetchUserInfo(ctx, hc, acct.Brand, tok.Token)
if err != nil {
return fmt.Errorf("failed to verify user identity: %w", err)
}
acct.UserOpenId = info.OpenID
acct.UserName = info.Name
return nil
}
func (p *CredentialProvider) selectedCredentialSource(ctx context.Context) (credentialSource, error) {
if p.selectedSource != nil {
return p.selectedSource, nil
}
if p.defaultAcct == nil {
return nil, nil
}
if _, err := p.ResolveAccount(ctx); err != nil {
return nil, err
}
if p.selectedSource == nil {
return nil, fmt.Errorf("credential provider resolved an account without selecting a token source")
}
return p.selectedSource, nil
}
func resolveTokenFromSource(ctx context.Context, source credentialSource, req TokenSpec) (*TokenResult, error) {
result, found, err := source.TryResolveToken(ctx, req)
if err != nil {
return nil, err
}
if !found {
return nil, &TokenUnavailableError{Source: source.Name(), Type: req.Type}
}
return result, nil
}
// ResolveIdentityHint resolves default/auto identity guidance from the selected source.
// NOTE: Uses sync.Once — only the context from the first call is used for resolution.
// This matches ResolveAccount and keeps identity decisions stable within one CLI invocation.
func (p *CredentialProvider) ResolveIdentityHint(ctx context.Context) (*IdentityHint, error) {
p.hintOnce.Do(func() {
p.hint, p.hintErr = p.doResolveIdentityHint(ctx)
})
return p.hint, p.hintErr
}
func (p *CredentialProvider) doResolveIdentityHint(ctx context.Context) (*IdentityHint, error) {
acct, err := p.ResolveAccount(ctx)
if err != nil {
return nil, err
}
if acct == nil {
return &IdentityHint{}, nil
}
source, err := p.selectedCredentialSource(ctx)
if err != nil {
return nil, err
}
if source == nil {
return &IdentityHint{}, nil
}
hint, err := source.ResolveIdentityHint(ctx, acct)
if err != nil {
return nil, err
}
if hint == nil {
return &IdentityHint{}, nil
}
return hint, nil
}
// ResolveToken resolves an access token.
func (p *CredentialProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
source, err := p.selectedCredentialSource(ctx)
if err != nil {
return nil, err
}
if source != nil {
return resolveTokenFromSource(ctx, source, req)
}
for _, prov := range p.providers {
source := extensionTokenSource{provider: prov}
result, found, err := source.TryResolveToken(ctx, req)
if err != nil {
return nil, err
}
if found {
return result, nil
}
}
source = defaultTokenSource{resolver: p.defaultToken}
result, found, err := source.TryResolveToken(ctx, req)
if err != nil {
return nil, err
}
if found {
return result, nil
}
return nil, &TokenUnavailableError{Type: req.Type}
}
func convertAccount(ext *extcred.Account) *Account {
return &Account{
AppID: ext.AppID,
AppSecret: ext.AppSecret,
Brand: core.LarkBrand(ext.Brand),
DefaultAs: core.Identity(ext.DefaultAs),
ProfileName: ext.ProfileName,
UserOpenId: ext.OpenID,
SupportedIdentities: uint8(ext.SupportedIdentities),
}
}

View File

@@ -0,0 +1,424 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"bytes"
"context"
"errors"
"net/http"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
)
type mockExtProvider struct {
name string
account *extcred.Account
token *extcred.Token
err error
accountErr error
tokenErr error
}
func (m *mockExtProvider) Name() string { return m.name }
func (m *mockExtProvider) ResolveAccount(ctx context.Context) (*extcred.Account, error) {
if m.accountErr != nil {
return nil, m.accountErr
}
return m.account, m.err
}
func (m *mockExtProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
if m.tokenErr != nil {
return nil, m.tokenErr
}
return m.token, m.err
}
type mockDefaultAcct struct {
account *Account
err error
}
func (m *mockDefaultAcct) ResolveAccount(ctx context.Context) (*Account, error) {
return m.account, m.err
}
type mockDefaultToken struct {
result *TokenResult
err error
}
func (m *mockDefaultToken) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
return m.result, m.err
}
func TestCredentialProvider_AccountFromExtension(t *testing.T) {
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{AppID: "ext_app", Brand: "lark"}}},
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
&mockDefaultToken{}, nil,
)
acct, err := cp.ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
}
if acct.AppID != "ext_app" {
t.Errorf("expected ext_app, got %s", acct.AppID)
}
}
func TestCredentialProvider_AccountFallsToDefault(t *testing.T) {
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{name: "skip"}},
&mockDefaultAcct{account: &Account{AppID: "default_app", Brand: "feishu"}},
&mockDefaultToken{}, nil,
)
acct, err := cp.ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
}
if acct.AppID != "default_app" {
t.Errorf("expected default_app, got %s", acct.AppID)
}
}
func TestCredentialProvider_AccountBlockStopsChain(t *testing.T) {
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{name: "blocker", err: &extcred.BlockError{Provider: "blocker", Reason: "denied"}}},
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
&mockDefaultToken{}, nil,
)
_, err := cp.ResolveAccount(context.Background())
if err == nil {
t.Fatal("expected error")
}
var blockErr *extcred.BlockError
if !errors.As(err, &blockErr) {
t.Fatalf("expected BlockError, got %T", err)
}
}
func TestCredentialProvider_AccountCached(t *testing.T) {
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{AppID: "cached"}}},
nil, nil, nil,
)
a1, _ := cp.ResolveAccount(context.Background())
a2, _ := cp.ResolveAccount(context.Background())
if a1 != a2 {
t.Error("expected same pointer (cached)")
}
}
func TestCredentialProvider_TokenFromExtension(t *testing.T) {
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{
name: "env",
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
token: &extcred.Token{Value: "ext_tok", Source: "env"},
}},
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
)
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err != nil {
t.Fatal(err)
}
if result.Token != "ext_tok" {
t.Errorf("expected ext_tok, got %s", result.Token)
}
}
func TestCredentialProvider_TokenFallsToDefault(t *testing.T) {
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{name: "skip"}},
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
)
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err != nil {
t.Fatal(err)
}
if result.Token != "default_tok" {
t.Errorf("expected default_tok, got %s", result.Token)
}
}
func TestCredentialProvider_TokenDoesNotMixSourcesAfterDefaultAccountSelection(t *testing.T) {
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{name: "env", token: &extcred.Token{Value: "ext_tok", Source: "env"}}},
&mockDefaultAcct{account: &Account{AppID: "default_app", Brand: core.BrandFeishu}},
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
nil,
)
if _, err := cp.ResolveAccount(context.Background()); err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err != nil {
t.Fatalf("ResolveToken() error = %v", err)
}
if result.Token != "default_tok" {
t.Fatalf("ResolveToken() token = %q, want %q", result.Token, "default_tok")
}
}
func TestCredentialProvider_SelectedSourceWithoutTokenReturnsUnavailableError(t *testing.T) {
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{
name: "env",
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
}},
nil, nil, nil,
)
if _, err := cp.ResolveAccount(context.Background()); err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err == nil {
t.Fatal("ResolveToken() error = nil, want unavailable error")
}
var unavailableErr *TokenUnavailableError
if !errors.As(err, &unavailableErr) {
t.Fatalf("ResolveToken() error type = %T, want *TokenUnavailableError", err)
}
if unavailableErr.Source != "env" || unavailableErr.Type != TokenTypeUAT {
t.Fatalf("ResolveToken() unavailable error = %+v, want source env and type uat", unavailableErr)
}
}
func TestCredentialProvider_ResolveTokenPropagatesNonBlockExtensionError(t *testing.T) {
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{name: "env", err: errors.New("provider exploded")}},
nil,
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
nil,
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err == nil || err.Error() != "provider exploded" {
t.Fatalf("ResolveToken() error = %v, want provider exploded", err)
}
}
func TestCredentialProvider_ResolveIdentityHint_FromExtensionAccount(t *testing.T) {
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{
AppID: "ext_app",
Brand: "feishu",
DefaultAs: extcred.IdentityUser,
SupportedIdentities: extcred.SupportsUser,
}}},
nil, nil, nil,
)
hint, err := cp.ResolveIdentityHint(context.Background())
if err != nil {
t.Fatalf("ResolveIdentityHint() error = %v", err)
}
if hint.DefaultAs != core.AsUser {
t.Fatalf("ResolveIdentityHint() defaultAs = %q, want %q", hint.DefaultAs, core.AsUser)
}
if hint.AutoAs != core.AsUser {
t.Fatalf("ResolveIdentityHint() autoAs = %q, want %q", hint.AutoAs, core.AsUser)
}
}
func TestCredentialProvider_ResolveIdentityHint_DefaultSourceUsesStoredTokenState(t *testing.T) {
origGetStoredToken := getStoredToken
origTokenStatus := getStoredTokenStatus
t.Cleanup(func() {
getStoredToken = origGetStoredToken
getStoredTokenStatus = origTokenStatus
})
getStoredToken = func(appID, userOpenID string) *auth.StoredUAToken {
if appID != "default_app" || userOpenID != "ou_default" {
t.Fatalf("GetStoredToken() args = (%q, %q), want (%q, %q)", appID, userOpenID, "default_app", "ou_default")
}
return &auth.StoredUAToken{AppId: appID, UserOpenId: userOpenID}
}
getStoredTokenStatus = func(token *auth.StoredUAToken) string {
return "valid"
}
cp := NewCredentialProvider(
nil,
&mockDefaultAcct{account: &Account{AppID: "default_app", Brand: core.BrandFeishu, UserOpenId: "ou_default"}},
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
nil,
)
hint, err := cp.ResolveIdentityHint(context.Background())
if err != nil {
t.Fatalf("ResolveIdentityHint() error = %v", err)
}
if hint.AutoAs != core.AsUser {
t.Fatalf("ResolveIdentityHint() autoAs = %q, want %q", hint.AutoAs, core.AsUser)
}
}
func TestCredentialProvider_ResolveIdentityHint_CachesResult(t *testing.T) {
origGetStoredToken := getStoredToken
origTokenStatus := getStoredTokenStatus
t.Cleanup(func() {
getStoredToken = origGetStoredToken
getStoredTokenStatus = origTokenStatus
})
storedCalls := 0
statusCalls := 0
getStoredToken = func(appID, userOpenID string) *auth.StoredUAToken {
storedCalls++
return &auth.StoredUAToken{AppId: appID, UserOpenId: userOpenID}
}
getStoredTokenStatus = func(token *auth.StoredUAToken) string {
statusCalls++
return "valid"
}
cp := NewCredentialProvider(
nil,
&mockDefaultAcct{account: &Account{AppID: "default_app", Brand: core.BrandFeishu, UserOpenId: "ou_default"}},
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
nil,
)
for i := 0; i < 2; i++ {
hint, err := cp.ResolveIdentityHint(context.Background())
if err != nil {
t.Fatalf("ResolveIdentityHint() error = %v", err)
}
if hint.AutoAs != core.AsUser {
t.Fatalf("ResolveIdentityHint() autoAs = %q, want %q", hint.AutoAs, core.AsUser)
}
}
if storedCalls != 1 {
t.Fatalf("GetStoredToken() calls = %d, want 1", storedCalls)
}
if statusCalls != 1 {
t.Fatalf("TokenStatus() calls = %d, want 1", statusCalls)
}
}
func TestCredentialProvider_ResolveTokenTreatsEmptyDefaultTokenAsMalformed(t *testing.T) {
cp := NewCredentialProvider(
nil,
nil,
&mockDefaultToken{result: &TokenResult{Token: ""}},
nil,
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err == nil || !strings.Contains(err.Error(), "empty token") {
t.Fatalf("ResolveToken() error = %v, want malformed empty token error", err)
}
}
func TestCredentialProvider_ResolveAccountDoesNotEnrichWithTokenFromDifferentProvider(t *testing.T) {
httpClientCalls := 0
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{name: "env", token: &extcred.Token{Value: "ext_tok", Source: "env"}}},
&mockDefaultAcct{account: &Account{
AppID: "default_app",
Brand: core.BrandFeishu,
UserOpenId: "ou_default",
UserName: "Default User",
}},
&mockDefaultToken{},
func() (*http.Client, error) {
httpClientCalls++
return nil, errors.New("unexpected enrich call")
},
)
acct, err := cp.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if httpClientCalls != 0 {
t.Fatalf("httpClient() called %d times, want 0", httpClientCalls)
}
if acct.UserOpenId != "ou_default" || acct.UserName != "Default User" {
t.Fatalf("resolved user = (%q, %q), want (%q, %q)", acct.UserOpenId, acct.UserName, "ou_default", "Default User")
}
}
func TestCredentialProvider_ResolveAccountClearsUnverifiedExtensionIdentityOnTokenError(t *testing.T) {
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{
AppID: "ext_app",
Brand: "feishu",
OpenID: "ou_ext",
}, tokenErr: errors.New("token lookup failed")}},
nil,
nil,
func() (*http.Client, error) {
t.Fatal("httpClient() should not be called when token lookup fails")
return nil, nil
},
)
acct, err := cp.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct.UserOpenId != "" || acct.UserName != "" {
t.Fatalf("resolved user = (%q, %q), want cleared unverified identity", acct.UserOpenId, acct.UserName)
}
}
func TestCredentialProvider_ResolveAccountWarnsWhenExtensionIdentityVerificationFails(t *testing.T) {
var warnBuf bytes.Buffer
cp := NewCredentialProvider(
[]extcred.Provider{&mockExtProvider{name: "env", account: &extcred.Account{
AppID: "ext_app",
Brand: "feishu",
OpenID: "ou_ext",
}, tokenErr: errors.New("token lookup failed")}},
nil,
nil,
func() (*http.Client, error) {
t.Fatal("httpClient() should not be called when token lookup fails")
return nil, nil
},
)
cp.SetWarnOut(&warnBuf)
acct, err := cp.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct.UserOpenId != "" || acct.UserName != "" {
t.Fatalf("resolved user = (%q, %q), want cleared unverified identity", acct.UserOpenId, acct.UserName)
}
if !strings.Contains(warnBuf.String(), "unable to verify user identity from credential source \"env\"") {
t.Fatalf("warning output = %q, want source-specific verification warning", warnBuf.String())
}
if !strings.Contains(warnBuf.String(), "token lookup failed") {
t.Fatalf("warning output = %q, want underlying error", warnBuf.String())
}
}
func TestCredentialProvider_ResolveTokenDoesNotBypassFailedDefaultAccountResolution(t *testing.T) {
cp := NewCredentialProvider(
nil,
&mockDefaultAcct{err: errors.New("config unavailable")},
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
nil,
)
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
if err == nil || err.Error() != "config unavailable" {
t.Fatalf("ResolveToken() error = %v, want config unavailable", err)
}
}

View File

@@ -0,0 +1,173 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keychain"
extcred "github.com/larksuite/cli/extension/credential"
)
// DefaultAccountProvider resolves account from config.json via keychain.
type DefaultAccountProvider struct {
keychain keychain.KeychainAccess
profile string
}
func NewDefaultAccountProvider(kc keychain.KeychainAccess, profile string) *DefaultAccountProvider {
return &DefaultAccountProvider{keychain: kc, profile: profile}
}
func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account, error) {
// Load config once — used for both credentials and strict mode.
multi, err := core.LoadMultiAppConfig()
if err != nil {
return nil, &core.ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
}
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain, p.profile)
if err != nil {
return nil, err
}
cfg.SupportedIdentities = strictModeToIdentitySupport(multi, p.profile)
return AccountFromCliConfig(cfg), nil
}
// strictModeToIdentitySupport maps the config-level strict mode to
// the SupportedIdentities bitflag using an already-loaded MultiAppConfig.
func strictModeToIdentitySupport(multi *core.MultiAppConfig, profileOverride string) uint8 {
app := multi.CurrentAppConfig(profileOverride)
var mode core.StrictMode
if app != nil && app.StrictMode != nil {
mode = *app.StrictMode
} else {
mode = multi.StrictMode
}
switch mode {
case core.StrictModeBot:
return uint8(extcred.SupportsBot)
case core.StrictModeUser:
return uint8(extcred.SupportsUser)
default:
return 0
}
}
// DefaultTokenProvider resolves UAT/TAT using keychain + direct HTTP calls.
// No SDK/LarkClient dependency — eliminates circular dependency with Factory.
type DefaultTokenProvider struct {
defaultAcct *DefaultAccountProvider
httpClient func() (*http.Client, error)
errOut io.Writer
tatOnce sync.Once
tatResult *TokenResult
tatErr error
}
func NewDefaultTokenProvider(defaultAcct *DefaultAccountProvider, httpClient func() (*http.Client, error), errOut io.Writer) *DefaultTokenProvider {
return &DefaultTokenProvider{defaultAcct: defaultAcct, httpClient: httpClient, errOut: errOut}
}
func (p *DefaultTokenProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
switch req.Type {
case TokenTypeUAT:
return p.resolveUAT(ctx)
case TokenTypeTAT:
return p.resolveTAT(ctx)
default:
return nil, fmt.Errorf("unsupported token type: %s", req.Type)
}
}
// resolveUAT resolves a user access token. Not cached (unlike TAT) because UAT
// may be refreshed between calls and GetValidAccessToken handles its own caching.
func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, error) {
acct, err := p.defaultAcct.ResolveAccount(ctx)
if err != nil {
return nil, err
}
httpClient, err := p.httpClient()
if err != nil {
return nil, err
}
token, err := auth.GetValidAccessToken(httpClient, auth.NewUATCallOptions(acct.ToCliConfig(), p.errOut))
if err != nil {
return nil, err
}
stored := auth.GetStoredToken(acct.AppID, acct.UserOpenId)
scopes := ""
if stored != nil {
scopes = stored.Scope
}
return &TokenResult{Token: token, Scopes: scopes}, nil
}
// resolveTAT resolves a tenant access token. Result is cached after first call.
// NOTE: Uses sync.Once — only the context from the first call is used.
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
p.tatOnce.Do(func() {
p.tatResult, p.tatErr = p.doResolveTAT(ctx)
})
return p.tatResult, p.tatErr
}
func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult, error) {
acct, err := p.defaultAcct.ResolveAccount(ctx)
if err != nil {
return nil, err
}
httpClient, err := p.httpClient()
if err != nil {
return nil, err
}
ep := core.ResolveEndpoints(acct.Brand)
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
body, err := json.Marshal(map[string]string{
"app_id": acct.AppID,
"app_secret": acct.AppSecret,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal TAT request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to parse TAT response: %w", err)
}
if result.Code != 0 {
return nil, fmt.Errorf("TAT API error: [%d] %s", result.Code, result.Msg)
}
return &TokenResult{Token: result.TenantAccessToken}, nil
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"testing"
)
func TestDefaultTokenProvider_Dispatches(t *testing.T) {
// Just verify the type implements DefaultTokenResolver
var _ DefaultTokenResolver = &DefaultTokenProvider{}
}
func TestDefaultAccountProvider_Implements(t *testing.T) {
var _ DefaultAccountResolver = &DefaultAccountProvider{}
}

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential_test
import (
"context"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
envprovider "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
)
type noopKC struct{}
func (n *noopKC) Get(service, account string) (string, error) { return "", nil }
func (n *noopKC) Set(service, account, value string) error { return nil }
func (n *noopKC) Remove(service, account string) error { return nil }
func TestFullChain_EnvWins(t *testing.T) {
t.Setenv(envvars.CliAppID, "env_app")
t.Setenv(envvars.CliAppSecret, "env_secret")
t.Setenv(envvars.CliUserAccessToken, "env_uat")
ep := &envprovider.Provider{}
cp := credential.NewCredentialProvider(
[]extcred.Provider{ep},
nil, nil, nil,
)
acct, err := cp.ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
}
if acct.AppID != "env_app" {
t.Errorf("expected env_app, got %s", acct.AppID)
}
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
Type: credential.TokenTypeUAT, AppID: "env_app",
})
if err != nil {
t.Fatal(err)
}
if result.Token != "env_uat" {
t.Errorf("expected env_uat, got %s", result.Token)
}
}
func TestFullChain_Fallthrough(t *testing.T) {
// env provider returns nil (no env vars set), falls through to default token
ep := &envprovider.Provider{}
mock := &mockDefaultTokenProvider{token: "mock_tok", scopes: "drive:read"}
cp := credential.NewCredentialProvider(
[]extcred.Provider{ep},
nil, mock, nil,
)
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
Type: credential.TokenTypeUAT, AppID: "app1",
})
if err != nil {
t.Fatal(err)
}
if result.Token != "mock_tok" || result.Scopes != "drive:read" {
t.Errorf("unexpected: %+v", result)
}
}
type mockDefaultTokenProvider struct {
token string
scopes string
}
func (m *mockDefaultTokenProvider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{Token: m.token, Scopes: m.scopes}, nil
}
func TestFullChain_ConfigStrictMode(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
botMode := core.StrictModeBot
multi := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: "cfg_app",
AppSecret: core.PlainSecret("cfg_secret"),
Brand: core.BrandLark,
StrictMode: &botMode,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatal(err)
}
ep := &envprovider.Provider{}
defaultAcct := credential.NewDefaultAccountProvider(&noopKC{}, "")
cp := credential.NewCredentialProvider(
[]extcred.Provider{ep},
defaultAcct, nil, nil,
)
acct, err := cp.ResolveAccount(context.Background())
if err != nil {
t.Fatal(err)
}
if acct.SupportedIdentities != uint8(extcred.SupportsBot) {
t.Errorf("expected SupportsBot (%d), got %d", extcred.SupportsBot, acct.SupportedIdentities)
}
}

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