Compare commits

...

93 Commits

Author SHA1 Message Date
zhaoyukun.yk
3bb68c317a feat(auth): support managed external credentials
Add Extended-only direct, credential-proxy, and platform-proxy modes while keeping Standard behavior and external-credential product details isolated behind source-neutral runtime capabilities.

Fail closed across helper execution, proxy routing, streams, managed file transfers, and unsupported event flows. Harden system configuration, edition updates, and release publication.

Cover Standard compatibility and Extended integration with contract tests, repository-local three-mode E2E, native trust checks, and CI and release gates.
2026-07-30 18:33:25 +08:00
liangshuo-1
87be09ef5f fix(contact): stop bot match segments carrying tags or empty entries (#2115) 2026-07-30 18:08:38 +08:00
sang-neo03
a575a8ba60 feat(contact): add bot search shortcut (#2083) 2026-07-30 17:03:49 +08:00
calendar-assistant
1f565a290b docs(calendar): warn against container-default timezone in time conversion (#2104)
Agents dropping to the raw `calendar events create/patch` API must convert
wall-clock time to Unix timestamps themselves. In UTC containers this silently
yields an 8-hour offset. Require explicit ISO 8601 offsets on +create/+update
--start/--end, and warn that raw-API timestamp conversion must specify the
target timezone instead of relying on the container default.
2026-07-30 14:06:14 +08:00
yballul-bytedance
68a77eee5c feat: support visible_rule for form questions (#1891)
Form questions can now carry a visible_rule (display condition) so a question shows only when earlier questions match the rule. The rule shares the exact same structure as the view filter, so extract that structure into a single shared reference (lark-base-filter-condition.md) that both view-set-filter and visible_rule point to.

- create/update shortcuts: document visible_rule in --questions help and transcribe the questions body (including visible_rule) into dry-run output
- document that form question updates use full overwrite semantics and must preserve existing fields via read-modify-write
- skill refs: add visible_rule sections to form-questions create/update, note it is only needed when the user asks for a display condition, and clarify that the shared tuple filter protocol does not apply to data-query filters
- tests: pin flag help, verbatim visible_rule passthrough on create/update/list, and add dry-run E2E coverage

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-07-30 12:37:24 +08:00
liangshuo-1
29a97dbde8 chore: release v1.0.80 (#2101) 2026-07-29 21:37:15 +08:00
R0bynZhu
29a6a7b600 docs(slides): +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
* docs(slides): +create 的参数下沉到 create.md,主 skill 只留路由

trace 里 +create 的三类高频错误(--yes、--name、--slides 塞文件路径)
共同点是调用前没读 lark-slides-create.md。原因不是文档缺内容,而是
SKILL.md 里 +create 的信息「够又不够」:给了半截参数描述,模型觉得
够用就直接拼命令,不再打开文档。

- 删掉「创建方式选择」整节(表格 + 两条 WARNING),下沉到 create.md,
  由生成流程 Step 3 和核心规则 2 指向那份文档
- Shortcuts 表 +create 行、核心规则 2 不再复述参数
- Quick Reference 顶部说明参数以文档和 --help 为准,「新建 PPT」行补上
  create.md
- PPTX 一行改写为 drive +import 导入路径;create.md 里写明本命令不读
  本地文件
- create.md 增加「--slides 不接受的形态」对照表,并合并开头零散的
  禁止/推荐/最稳/注意条目
- @ 占位符统一写成 <img src="@./path">,消除「--slides 支持 @ 路径」的歧义

* docs(slides): 去掉 create.md 里的「--slides 不接受的形态」对照表

* docs(slides): 模板一行的触发条件补上「已有 PPTX 要改」

* docs(slides): create.md 澄清「不读取本地文件」的歧义

原句「本命令只从零创建演示文稿,不读取本地文件」与本文档
「本地图片:@<path> 占位符」一节自相矛盾——@ 占位符恰恰会读
本地图片并自动上传。改为只否定「导入本地 PPT 文件的参数」,
不波及图片占位符能力。

* docs(slides): 两步创建的第二步补上 slide create 文档路由

生成流程 Step 3 和「执行前必做」的创建一行原来只指向
lark-slides-create.md,而两步创建的第二步用的是
xml_presentation.slide create,文档没被路由到,模型只能凭
记忆拼参数。
2026-07-29 20:50:24 +08:00
liangshuo-1
c167163d70 feat: propagate invocation metadata (#2097) 2026-07-29 19:39:53 +08:00
zhaojiaxing-coding
7988515e1c feat(drive): add +permission-get-setting shortcut (#1738)
* feat(drive): add +permission-get-setting shortcut

Add a Drive shortcut for reading public permission settings across supported documents, files, folders, and wiki nodes. Resolve URLs into typed resources, preserve permission_public output for machine consumers, and document the shortcut in the permission-governance workflow.

Key features:

- Infer resource type and token from supported Drive URLs while requiring --type for bare tokens

- Query the Drive v2 public permission endpoint with typed validation and user or bot identity

- Support folder permission inspection without recursing into child resources

- Add unit, dry-run E2E, live workflow, output, and skill guidance coverage

* fix(drive): harden permission get setting contract

Harden +permission-get-setting after review findings so callers receive only the documented permission payload and folder support is verified against the live workflow. This prevents malformed responses from being presented as permission settings and keeps the command guidance aligned with the shortcut contract.

Key fixes:
- Reject responses without data.permission_public instead of projecting arbitrary payload fields
- Render complete permission settings in pretty output and mark --token required
- Exercise a created Drive folder in the live workflow and add the command reference
- Correct folder resolution guidance while retaining the shortcut's documented URL forms

* feat/drive-folder-permission-get
2026-07-29 17:57:24 +08:00
zhaojiaxing-coding
c7adff7a3b feat(drive): add +member-list shortcut (#1795)
* feat(drive): add +member-list shortcut

Add a Drive shortcut for listing collaborators on documents, files, folders, and wiki nodes. Resolve supported resource URLs into typed permission requests, preserve raw API data for machine consumers, and keep invalid flag combinations on typed validation paths.

Key features:

- Infer resource type and token from supported Drive URLs while requiring --type for bare tokens

- Validate optional member fields and wiki-only permission type filters

- Provide pretty output, skill guidance, unit coverage, and dry-run/live E2E workflows

- Read dry-run assertions from the standard data.api success envelope

* feat/drive-member-list
2026-07-29 17:04:59 +08:00
ethan-zhx
59237f3104 Feat/detect line text overlap (#2069)
* fix: report ghost text canvas overflow

* fix(slides): detect text-line overlap in xml_text_overlap_lint
2026-07-29 16:20:59 +08:00
R0bynZhu
358cd06838 docs(slides): 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
* docs(slides): consolidate CWD-relative path rule into one global rule

State the "all local file path args must be CWD-relative (absolute
rejected)" rule once in SKILL.md 权威经验, and trim the per-command
repetitions in media-upload / create / screenshot / xml-presentations-get.
Also fix the stale xml-presentations-get param table: --output is optional
(relative), not required.

* feat: try common solution

* chore: 优化措辞

* feat: 优化措辞

* feat: 优化措辞

* docs(slides): 强调调用命令前必读对应命令文档

- 「调用命令前再读」改为「调用相关命令前必须读取相关的文档以了解命令的使用方式」,
  并把原「按需再读」列表合并进来,去掉可选语义
- 移除 lark-shared 的 CRITICAL 前置阅读要求
- Step 4 回读示例补全 `--presentation <xml_presentation_id>` 参数

* docs(slides): Shortcuts 表补充 +screenshot 并写明本地路径参数

- 新增 +screenshot 行:--slide-number 页号(从 1 开始,可重复,一次最多 10 页)、
  --output-dir 保存目录(CWD 内相对路径,默认 .lark-slides/screenshots)
- +xml-get 行补上 --presentation 和 --output(CWD 内相对路径),
  并说明省略 --output 时 XML 返回在 JSON 信封里

* revert(slides): 回退 references 下的文档改动,只保留 SKILL.md

把 lark-slides-create.md、lark-slides-media-upload.md、lark-slides-screenshot.md、
lark-slides-xml-presentations-get.md 还原为 main 的版本,本分支只改 SKILL.md。

* docs(slides): 恢复开始前必读 lark-shared 的 CRITICAL 要求

认证、权限和全局参数以 lark-shared 为准,这条前置阅读不该在本分支被删掉。

* chore: 移除output省略的说明
2026-07-29 10:55:05 +08:00
Yuxuan Zhao
b0b1ca4b5d test(e2e): wait for base role update visibility (#2087) 2026-07-28 21:45:00 +08:00
liangshuo-1
781d188a60 chore: release v1.0.79 (#2082) 2026-07-28 21:02:37 +08:00
calendar-assistant
2e0fb9a880 docs(calendar): refine attendee guidance for bots and user-search identity (#2086)
Consolidate the user-search identity note into SKILL.md, and clarify bot
handling across attendee flows: bots are virtual identities with no
free/busy semantics, no meeting-room seat, and no room preference, so
they must be excluded from +suggestion, +room-find, and the scheduling
free/busy check. Note in create/update that bots remain valid attendees.
2026-07-28 20:34:09 +08:00
ILUO
927b37cd63 docs(task): document create data passthrough (#2080) 2026-07-28 20:26:35 +08:00
zhangjun-bytedance
d2e22c5fca feat: 0728 fix url (#2079) 2026-07-28 19:05:47 +08:00
ethan-zhx
fdae560014 docs(slides): add formula inline element syntax to quick-ref (#2077)
* docs(slides): add formula inline element syntax to quick-ref

* docs(slides): add chart gradient syntax to quick-ref
2026-07-28 17:40:54 +08:00
zhengzhijiej-tech
1b173e1953 fix(sheets): recognize OFL0X local office tokens (#2063) 2026-07-28 15:09:42 +08:00
ethan-zhx
57db1b3a8d feat(slides):update xsd (#2067) 2026-07-28 14:43:15 +08:00
calendar-assistant
4c1c5f5287 docs(calendar): clarify identity selection by event ownership (#2071)
Reframe the identity section around event ownership: use `--as user`
for the logged-in user's own events and `--as bot` for events the bot
creates or participates in, with matching `+agenda` examples.
2026-07-28 14:05:21 +08:00
liangshuo-1
3d2c10cd0b fix(ci): validate static workflow identity (#2015) 2026-07-27 19:39:11 +08:00
liangshuo-1
03de81c5f3 chore: release v1.0.78 (#2061) 2026-07-27 19:17:53 +08:00
yballul-bytedance
7abcaa7f68 feat(drive): add title+body joint search guidance and Top N pagination rules (#2059)
* feat(drive): add title+body joint search guidance and pagination rules for Top N results

- Add new blockquote explaining combined title+body search: use a single
  --query with both keywords instead of splitting into two searches
- Add rule for Top N results: N is an output cap, not --page-size; scan
  up to 3 pages filtering by title and summary_highlighted, read body
  only for title-matched candidates, stop early at N confirmed results
- Add quick-reference table row for folder-scoped title+body search
- Update pagination strategy rule to cover the 3-page cap for joint
  search in addition to the existing 5-page limit for other scenarios

* feat(drive): clarify Top N search output limit

* feat(drive): clarify search filters share one call

---------

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-27 17:19:48 +08:00
zhangjun-bytedance
8fb2476985 0727 fix rich text (#2062) 2026-07-27 16:17:08 +08:00
zhanghuanxu
56c9a2afd8 fix: exempt ghost text from slides lint 2026-07-27 11:59:04 +08:00
zhanghuanxu
2029189809 fix(slides):text may over flow shape 2026-07-27 11:59:04 +08:00
zhanghuanxu
ee427979a8 fix(slides): preserve info lint severity 2026-07-27 11:59:04 +08:00
zhanghuanxu
545abcbbde fix: refine character width estimation for lark-slides text lint
Replace the uniform 0.55em half-width coefficient with per-character-type
coefficients, add font-family awareness (sans/serif), bold multiplier,
letter-spacing support, and fix padding-aware line wrapping.

- Split half-width chars into uppercase (0.57), lowercase (0.51 sans / 0.53
  serif), digits (0.58), and punctuation (0.50)
- Add classify_font_family() to apply slightly wider lowercase widths for
  serif fonts (Georgia, Source Han Serif/思源宋体, Times, etc.)
- Add 5% width multiplier for bold text; detect <strong>/<b>/<i>/<em> tags
  and span-level bold/italic attributes in addition to content attrs
- Fix estimate_text_line_count_for_text to subtract paddingLeft/paddingRight
  from available width before computing wrap lines
- Add resolve_letter_spacing and wire letterSpacing through estimate_text_width
- Extract fontFamily/bold/italic/letterSpacing into element dict during parse
2026-07-27 11:59:04 +08:00
zhanghuanxu
4a73e83f1e fix(slides): allow chartParsedValues roundtrip tag
chartParsedValues is a server-injected roundtrip child tag under
chartField, not an attribute. Move it from ROUNDTRIP_SXSD_ATTRS to a
new ROUNDTRIP_SXSD_TAGS set and skip the tag (and its subtree) in the
SXSD tag whitelist check.
2026-07-27 11:59:04 +08:00
zhanghuanxu
7496420fa8 fix(slides): downgrade background-decoration text overflow to info
Large low-alpha text underneath other text shapes is typically a
background design element; treat text_may_overflow_shape as info in
that case instead of warning/error.
2026-07-27 11:59:04 +08:00
zhanghuanxu
43fabdf524 fix(slides): detect letterSpacing-driven text overflow
Extract letterSpacing from content/paragraph attrs and factor it into
width and line-count estimates, and stop short-circuiting the shape
overflow check for autoFit shapes so that letterSpacing-heavy captions
under normal-auto-fit no longer escape detection.
2026-07-27 11:59:04 +08:00
zhanghuanxu
8c46c74105 fix(slides): upgrade text overflow to error above 10px threshold
Text-shape overflow was always reported as a warning, which let clearly
broken pages pass the lint gate. Overflow > 10px now upgrades to error;
smaller overflows stay as warning to avoid flagging near-fit cases.
2026-07-27 11:59:04 +08:00
zhanghuanxu
70777c86c3 fix(slides): restrict canvas overflow checks 2026-07-27 11:59:04 +08:00
zhangjun-bytedance
38e8806d91 feat: event description support rich text (#1975)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:48:01 +08:00
liangshuo-1
a7865cd0a7 chore: release v1.0.77 (#2051) 2026-07-24 19:20:52 +08:00
BD-ZERO
f77b7eea68 fix(slides): support CSV multi-value for --slide-id in screenshot (#2047)
--slide-id used the cobra StringArray flag type, which only accepts
repeated flags and does not split comma-separated values, unlike
--slide-number (int_array -> cobra IntSlice) which already supported
CSV input. This made the two selector flags inconsistent.

Switch --slide-id to the string_slice flag type (cobra StringSlice),
which natively supports both comma-separated and repeated values, and
update the flag readers from StrArray to StrSlice. normalizeSlideIDs
already trims/dedupes/filters blanks, and
validateSlidesScreenshotSelectorLimit already caps the combined
selector count, so both continue to apply unchanged to CSV input.

Add tests covering --slide-id CSV parsing, whitespace/duplicate
normalization, and the >10 selector limit via CSV, mirroring the
existing --slide-number coverage.

Address review feedback:
- Fix "comma-separate" -> "comma-separated" wording in the --slide-id
  flag description (CodeRabbit).
- Set LARKSUITE_CLI_CONFIG_DIR to t.TempDir() in the new screenshot
  tests, per the AGENTS.md testing convention, so local configuration
  state cannot leak into or be modified by the suite.
- Add a dry-run E2E test (tests/cli_e2e/slides) that pins --slide-id
  CSV parsing through the built CLI binary and asserts the emitted
  slide_ids request body, per the AGENTS.md dry-run E2E requirement
  for shortcut flag/param changes.
- Update the lark-slides skill reference to document that --slide-id
  and --slide-number both accept comma-separated values, not just
  repeated flags, so agents can discover the new syntax.
2026-07-24 18:32:36 +08:00
fangshuyu-768
dd7f741b62 docs(skills): clarify callout child rules (#2048) 2026-07-24 18:18:32 +08:00
kiraWangRuilong
e7d5ecdd01 feat: add risk-control protection (#1910)
1. Add baseline safe protection for Feishu/Lark API endpoints.
2. Add lark-cli config risk-control on|off|default command for workspace-level safety protection control.
2026-07-24 17:12:10 +08:00
zhanghuanxu
4807283368 fix(slides): declare screenshot scope 2026-07-24 15:25:11 +08:00
ILUO
d2bb36591f fix/task search pagination (#2041)
* fix: send task search page token in query

* test: assert task search dry-run pagination contract
2026-07-24 14:28:54 +08:00
yballul-bytedance
5a54bc07db fix(base): classify +form-submit as high-risk-write (#1969)
Form submission writes and submits data through a public share link, an
irreversible action that should require explicit confirmation. Reclassify
the shortcut from write to high-risk-write so the runner's --yes gate fires
before execution, matching +form-delete and other high-risk base commands.

Update the lark-base skill docs (--yes on all examples, param table, tips)
and add tests pinning the confirmation gate (unit) and dry-run structure (e2e).

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-24 11:11:37 +08:00
BD-ZERO
a528b3cb69 feat(slides): add layout density lint for sparse/empty containers (#2022)
feat(slides): add layout density lint for sparse/empty containers

Extend the XML layout lint into a single release gate for Slides XML:

- Add blank_slide, sparse_container_content, and sparse_slide_content
  detection, using visibility- and coverage-aware heuristics (alpha
  filtering, image-overlay/layout-panel exemptions, similar-short-card
  grouping) to avoid flagging intentional whitespace or background
  panels
- Broaden out-of-canvas detection from table/chart/text-only to every
  element kind, with rotation-aware bounding boxes and geometry
  extraction for icon/line/polyline
- Restructure output to schema v2.0: every issue carries rule
  (id/name/comparison/threshold), measurement, related_objects, and
  hint; summary gains status/release_ready/screenshot_review_required
- Change CLI exit-code semantics so only errors block (exit 1);
  warning-only output still exits 0 to let downstream screenshot review
  proceed
- Harden XML attribute parsing (single/double-quoted and spaced
  attributes, self-closing tags no longer bleeding content into the
  next element) and fix edge cases surfaced during review
  (image-overlay coverage ratio, invisible container/panel exemptions,
  bbox_overlap measurement consistency, background-only slide bypass,
  invisible short-card peers)
- Update SKILL.md, validation-checklist.md, and troubleshooting.md to
  match the new gate; add regression tests for the new rules and fixes
2026-07-24 10:47:15 +08:00
huarenmin13
f0176af330 docs(base): clarify complete and partial updates (#1993)
* docs(base): clarify complete and partial updates

Consolidate the update rule introduced in #1879 and make the command-contract boundary explicit. Full-update commands must use trusted current configuration for the first actual request, while delta commands should send the smallest legal payload.

* docs(base): clarify full-update state preservation

Address review feedback by requiring unchanged writable configuration to remain intact, except when the requested update makes a setting inapplicable.

* docs(base): strengthen update contract guidance
2026-07-24 00:01:35 +08:00
R0bynZhu
715aa8d960 feat(slides): fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
From EVAL-07-22-02-53 (42 convos), agents fell back to the full XSD for:
- shape type enum + presetHandlers (rounded corners)
- polyline (bounding-box positioning, required border, connector type)
- table merged cells (colspan / rowspan)

Add compact coverage for each, sized to real usage (shape/polyline type
lists trimmed to what actually appears in generations). Chart gaps deferred.
2026-07-23 22:18:17 +08:00
ILUO
ebc0c53ab5 fix/task id handling (#2023)
* fix: validate task GUID inputs

* fix: make task updates self-confirming

* fix: confirm task completion state

* docs: clarify task ID workflow

* test: cover task ID dry runs

* fix: address task ID review feedback
2026-07-23 20:48:38 +08:00
fangshuyu-768
1e682bd97c fix(slides): normalize presentation flag aliases (#2032) 2026-07-23 18:43:30 +08:00
fangshuyu-768
70424c486c docs(skill): clarify scope handling for query expansion (#2030) 2026-07-23 18:35:44 +08:00
liangshuo-1
b8f56dbc0b feat(apps): support absolute and relative upload paths (#2005) 2026-07-23 17:52:49 +08:00
chenxingyang1019
c74d9b63fb feat(apps): validate +file-list --page-size against server (0, 200] range (#2007)
paas_storage AppFileListForOpenAPI rejects page_size > 200 at the inner
checkMaxKeys guard with ErrInvalidRequest("maxKeys not in range (0, 200]").
Previously the CLI forwarded any --page-size straight to the API, so
--page-size 500 produced an opaque server error round-trip.

Add a client-side Validate check bounding --page-size to [1, 200] (aligned
with the existing validateAppsPageSize precedent in the observability
commands): out-of-range values now fail fast with a typed validation error
and never hit the network. The server tolerates page_size <= 0 by defaulting
to 20, but the CLI default is already 20 and an explicit < 1 is a user error,
so we reject it for a clearer message, consistent with other list commands.

Update the flag description and the lark-apps-file skill reference to
document the 1..200 range, and cover the boundaries in unit tests.
2026-07-23 15:55:08 +08:00
91-enjoy
67015eef8e feat: introducing official card icon (#1973)
Card header icon documentation contained invalid tokens (e.g., mail_colorful, approve_colorful) that do not render, and icon guidance lacked precise token enumeration, causing LLM to guess or fabricate icon tokens. This PR replaces
examples with valid tokens and adds a definitive colorful icon reference table.
2026-07-23 11:01:14 +08:00
liangshuo-1
af8507ea8e chore: release v1.0.76 (#2016) 2026-07-22 23:36:33 +08:00
liangshuo-1
02c2ebcf7c chore: release v1.0.75 (#2014) 2026-07-22 22:29:15 +08:00
liangshuo-1
abf6f99d7e fix(slides): preserve raw XML output verbatim (#2013)
Keep --raw and file output byte-exact by returning the server response without XML reserialization.
2026-07-22 22:06:26 +08:00
tianyouskrrr
8ba910eb9f fix(slides): reindent xml-get output for readability (#1987)
The API always returns presentation/slide XML as a single unindented
line, which is unreadable for decks with many shapes (e.g. PPTX-imported
presentations). slides +xml-get now formats it on the surfaces meant for
a human or a line tool to read:

- --raw and --output reindent the XML with etree so each structural
  element (presentation/slide/shape/style/...) sits on its own line.
  Reformatting never recurses into schema-mixed text-bearing elements
  (p, span, strong, em, u, del, a, shadow, outline, chartTitle,
  chartSubTitle), so rich-text content stays exactly as parsed. CDATA
  sections and the schema's &#32;/&#9;/&#13;/&#10; whitespace character
  references (decimal, hex, and zero-padded) are preserved through the
  parse/write pass instead of being silently normalized away. There is
  no flag to disable this formatting.
- The default JSON envelope returns the server's XML verbatim: it is
  never parsed, so it stays a byte-exact copy of the API response, at
  no reformatting cost and with no failure mode on this path.
- If reformatting --raw/--output content fails (non-strict XML from the
  service), the command falls back to the original content, prints a
  warning to stderr, and reports pretty_printed: false in --output file
  metadata.

Adds github.com/beevik/etree as a direct dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 21:08:29 +08:00
zgz2048
78bf126bb0 docs(base): align record write schema guidance (#2000)
* docs(base): align record write schema guidance

* docs(base): use canonical select field naming

* docs(base): simplify select option guidance
2026-07-22 20:54:54 +08:00
guokexin.02
4eefe32c1a ci: harden npm release publishing (#1918) 2026-07-22 20:53:43 +08:00
Yuxuan Zhao
8f6f8eb0fc test(e2e): declare request identities explicitly (#2004)
* test(e2e): declare request identities explicitly

* test(e2e): skip base workflow without bot credentials
2026-07-22 19:22:08 +08:00
SunPeiYang996
80323bb464 docs: update lark doc HTML size limit (#2001) 2026-07-22 18:22:23 +08:00
YH-1600
0a33bd7c57 docs: add topic move collector workflow (#1473) 2026-07-22 17:45:33 +08:00
Yuxuan Zhao
aafaed06a7 fix(e2e): inject shared credentials by identity (#1995) 2026-07-22 17:43:25 +08:00
syh-cpdsss
54ddcf490b fix: remove legacy shortcut (#1997) 2026-07-22 15:33:40 +08:00
syh-cpdsss
bb246b591f fix: issue#1935 & whiteboard shortcut reformat (#1980) 2026-07-22 14:59:49 +08:00
calendar-assistant
fc2761d16b feat(calendar): auto-add bot self as attendee and note user-only search (#1991)
When creating an event as a bot, resolve the bot's own open_id via
/bot/v3/info and add it to the attendee list, mirroring how a user is
auto-joined to their own events; warn and proceed without it if the
lookup fails. Also note in the +create skill doc that the user-search
API is user-only, so resolving a name to open_id needs --as user.
2026-07-22 14:36:01 +08:00
syh-cpdsss
409a3172da feat: add okr single create shortcut & skill text opti (#1941)
* feat: add okr single create shortcut & skill text opti

* fix: deterministic-gate remove internal paging logic

* fix: CR issue

* opti: okr create/batch-create support note/category, indicator skill update
2026-07-22 14:16:54 +08:00
huarenmin13
483aadee3b fix(base): improve table shortcut behavior & guidance (#1803)
* fix(base): align table shortcut contracts

* fix(base): treat null record projection as omitted

1. Treat select_fields:null as omitted before record-get projection conflict checks.
2. Add dry-run E2E coverage for omitted and flag-projection cases.

```ai-signature
改动范围: shortcuts/base/record_ops.go 与 tests/cli_e2e/base/base_record_list_dryrun_test.go,仅调整 record-get 对 JSON null projection 的处理和回归验证
思考过程: 保持现有 projection normalizer 与互斥规则不变,只在读取 select_fields 后把 null 与缺失键等价,避免扩大到字段上限或 auto_number 行为
改动原因: PR 1803 声明 list search get 使用统一 projection contract,但 record-get 对 select_fields:null 仍返回 invalid_argument,与 record-search 不一致
Break Change: 否;仅将此前失败的 select_fields:null 输入规范化为省略,并保留 flag projection
```

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: b3d37c6c026f0215d994bc7c9bad4c65caee1b3bc2e9584ff20403a4d06969c3

* refactor(base): deduplicate Base dry-run E2E setup

1. Centralize Base dry-run environment setup, timeout handling, command execution,
    and exit-code assertions in runBaseDryRun.
2. Migrate record projection and field update dry-run tests without changing their contract assertio
    ns or covered scenarios.
3. Verify all 11 affected top-level tests and four projection subtests with the current-HEAD binary
    under race mode.

```ai-signature
改动范围: tests/cli_e2e/base/helpers_test.go、base_record_list_dryrun_test.go 与 base_field_update_dryrun_test.go,仅收敛 dry-run 测试执行脚手架
思考过程: 复用现有测试基础设施,把环境隔离、超时、dry-run 参数、命令执行和退出码断言集中到一个 helper,同时保留每个用例的业务断言
改动原因: PR 1803 的新增测试占主要改动量,其中 11 处重复执行模板可安全去重,降低评审体量而不削减 P1 或 P2 场景覆盖
Break Change: 否
```

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: ee39fef8497de65ecea1a0f22d9d87f1622c3f69daa5743ba7fd4c874dbb2ed3

---------

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
2026-07-21 23:22:48 +08:00
SunPeiYang996
e43f497650 docs: clarify fetch metadata and user cites (#1981) 2026-07-21 23:22:20 +08:00
SunPeiYang996
990d633c07 docs(skill): describe html5 block xml usage (#1380) 2026-07-21 22:26:01 +08:00
liangshuo-1
d4168ab84f chore: release v1.0.74 (#1990) 2026-07-21 21:19:43 +08:00
BD-ZERO
12ca42c953 fix(slides): clarify xml-text-overlap-lint error for positional argument (#1986)
* fix: xml_text_overlap_lint.py clarify XML lint input flag error
2026-07-21 20:29:02 +08:00
kongenpei
d382ee9053 feat(base): support per-record batch updates (#1889)
* feat(base): support per-record batch updates

* test(base): cover per-record batch updates

* test(base): make batch update assertions order-independent

* test(base): gate live batch updates on backend rollout

* test(base): keep live batch update coverage enabled

* fix(base): align per-record batch update response

* test(base): verify batch updates through effects

* docs(base): focus batch updates on update_records

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-07-21 20:17:59 +08:00
wangweiming-01
daaacb4977 docs: clarify drive upload overwrite guidance (#1982) 2026-07-21 19:27:03 +08:00
zhanghuanxu
680501c1df fix(slides): detect image text occlusion 2026-07-21 19:25:24 +08:00
zhanghuanxu
6675e3c247 fix(slides): exempt chart roundtrip attributes from lint 2026-07-21 17:16:57 +08:00
zhanghuanxu
7b48709438 fix(slides): warn on text shape overflow 2026-07-21 17:16:57 +08:00
zhumiaoxin
c876841106 fix(im): warn when flag pagination is truncated (#1906) 2026-07-21 15:05:31 +08:00
sang-neo03
4c1a92caa6 refactor: converge success output through a single Emitter that owns the write (#1899)
* refactor: add output emitter contract and differential harness

Introduce a leaf Emitter in internal/output that composes the existing
output primitives (content-safety scan, envelope, jq, format rendering,
notice) behind a single command-scoped port. The emitter is unwired: no
production caller is migrated, so CLI output stays byte-for-byte unchanged.

A differential test harness drives the real legacy entry points
(RuntimeContext.Out/OutRaw/OutFormat/..., WriteSuccessEnvelope and the
pagination formatter) and asserts byte-identical stdout/stderr plus typed
errors, locking behavior before later slices migrate callers.

* refactor: tighten emitter API and cover pagination with real tests

- split Emitter.Success/PartialFailure and drop EmitOptions.OK so a
  missing ok flag can no longer silently emit ok:false
- give StreamPage its own StreamOptions (format + pretty) instead of
  reusing EmitOptions, making "jq needs aggregation" a compile-time fact
- pin the Emitter jq-error contract (returns error, writes no stderr);
  the caller adapter re-emits the legacy stderr line on migration
- add in-package tests driving the real apiPaginate/servicePaginate over
  a mock transport: multi-page aggregation, empty-result fallback,
  MarkRaw handling, and the business-error raw-response red line

* test: use standard TestFactory harness for pagination tests

Replace the hand-rolled RoundTripper + APIClient construction in the
apiPaginate/servicePaginate tests with cmdutil.TestFactory and its
httpmock.Registry, and isolate LARKSUITE_CLI_CONFIG_DIR to t.TempDir(),
matching the repo's standard HTTP-mocked test convention. Assertions and
coverage (multi-page aggregation, empty-result fallback, MarkRaw, and the
business-error raw-response red line) are unchanged.

* refactor: route success output through the single Emitter port

Migrate the success-output surfaces onto internal/output's Emitter,
byte-for-byte identical (proven by frozen golden diffs and the real
paginate/HandleResponse tests):

- RuntimeContext.Out/OutRaw/OutFormat/OutFormatRaw/OutPartialFailure now
  build an Emitter and call Success/PartialFailure; emit and outFormat are
  removed. An adapter maps the returned error back to the legacy
  outputErrOnce / jq-error stderr / exit-code behavior.
- WriteSuccessEnvelope degrades to a thin Emitter.Success delegate; its 8
  callers are unchanged.
- apiPaginate/servicePaginate stream pages via Emitter.StreamPage; the
  aggregate and business-error raw-response branches are untouched.
- HandleResponse routes its non-JSON structured-response branch through
  Emitter.Success.

Frozen golden fixtures replace the runtime legacy oracles so the
differential harness cannot go self-referential after migration.

* fix: keep _notice on struct payloads in Emitter's unknown-format fallback

printLegacyDataJSON now normalizes via toGeneric first (matching FormatValue), so a struct / named-map payload retains its injected _notice on the unknown-format -> JSON fallback rather than dropping it silently. Add a regression test that fails against the pre-fix path.

* refactor: make the Emitter own write failures and stop mutating inputs

Route every Emitter stdout path through a render-to-buffer-then-copy helper so a marshal/render failure leaves stdout empty and surfaces a typed internal error (with cause), and a stdout write failure is propagated instead of silently swallowed. Leaf writers gain error-returning Write* cores; the legacy Print*/FormatValue wrappers keep their exact behavior for unmigrated callers.

- handleEmitterError now captures every error, not only the jq/safety branches; flip OutRaw's write-error test to assert propagation.
- Clone the map before injecting _notice so a caller's payload is never mutated and an existing _notice is never overwritten.
- Preserve jq's own typed error (validation/api) on a bad expression or runtime failure; only wrap genuine stdout write failures.
- Split tests: normative emitter_contract_test.go vs frozen emitter_legacy_compat_test.go (base SHA recorded, self-update env vars removed).

* fix: satisfy license-header and forbidigo lint on the emitter changes

- Move the base-SHA note below the copyright header in the renamed legacy-compat test so the license-header check sees a valid header at the top.
- Route the leaf wrappers' marshal/format stderr messages through a single legacyStderrf helper (one //nolint:forbidigo) instead of bare os.Stderr, preserving exact legacy behavior for unmigrated direct callers while passing forbidigo; drop the now-unused os imports.

* fix: stop legacy CSV wrappers reporting write failures to stderr

Align FormatAsCSV/FormatAsCSVPaginated and FormatValue/FormatPage's CSV branch with the other leaf wrappers: report only marshal failures, swallow write failures. Previously they emitted a 'csv write error' for the (empty) line and the JSON-fallback write failures that the pre-refactor code ignored, and mislabeled a JSON write failure as a CSV one. Failure-path only; success output is unchanged (golden double-diff still byte-for-byte).
2026-07-21 14:32:47 +08:00
HanShaoshuai-k
577ff035c3 fix: allow jq examples in quality gate dry-runs 2026-07-21 14:07:57 +08:00
zhanghuanxu
4b4ca4283a fix: preserve slides schema issues 2026-07-21 13:37:54 +08:00
liuxin-0319
ad4a6d68c7 feat(slides): add history rollback shortcuts (#1714) 2026-07-20 22:27:01 +08:00
luozhixiong01
d8fb368ce4 test: isolate unit tests from user state (#1883) 2026-07-20 22:22:39 +08:00
liangshuo-1
40840915c7 chore: release v1.0.73 (#1971) 2026-07-20 21:38:05 +08:00
hugang-lark
fb57e17905 feat: check room availability for calendar +update (#1965) 2026-07-20 21:08:55 +08:00
cl900811
4cdfa2fcda feat(whiteboard): enhance whiteboard svg parser (#1970) 2026-07-20 20:56:43 +08:00
anngo-nk
3c2cc273f7 feat(apps): design_html support, creative-design skill, unified TOS publish (#1901)
* feat(apps): add design_html app type support and credential author identity

- Add design_html to appTypePolicies (same as modern_html: skip install/env-pull/skills-sync)
- Route +html-publish via policy (useTOSPublish) instead of hardcoded type check
- Parse commit_author_name/commit_author_email from +git-credential-init response
- Use server-provided author identity for repo-local git config, fallback to defaults
- Support meta_token as identifier in +get command
- Use envvars.AgentName() for source_agent in +create (reads LARKSUITE_CLI_AGENT_NAME)
- Add creative HTML guide reference skeleton and SKILL.md routing entry
- Update git-credential skill docs with new output fields

* fix(apps): unify html-publish to TOS path, add html to init skip policy

- Remove useTOSPublish policy field, html-publish always uses TOS upload
- Add html type to appTypePolicies (skip install/env-pull/skills-sync)
- Remove design_html from policies (not yet in use)
- Fix git credential dry-run test for new local_effects entry

* feat(apps): validate --app-id format to reject meta_token with resolution hint

* feat(apps): integrate creative-design skill and update skill docs

- Add creative-design skill under lark-apps/ (same level as references/)
- Update SKILL.md description with creative design trigger keywords
- Add creative design routing in development path selection table
- Add --path relative path guidance in html-publish reference
- Remove old creative-html-guide skeleton (replaced by creative-design)

* feat(apps): skip app sync for html/modern_html in +init

Add skipAppSync policy field; html and modern_html skip npx app sync
on non-empty repo path since static HTML sites don't need it.

* fix(apps): merge creative-design into html routing and add intent entry

- Merge static HTML and creative-design into one path selection row
- Add creative-design intent routing entry before html-publish

* docs(apps): add html local dev flow, unify publish link source

- Add html端到端 flow in local-dev.md (create → init → dev → release-create)
- Unify publish link source: html and full_stack both use +release-get
- Update SKILL.md routing and publish护栏 accordingly

* fix(apps): update html-publish dry-run and skill docs for TOS flow

- DryRun shows actual 3-step TOS flow (pre_release → TOS PUT → release-create)
- Skill docs: output is release_id, use +release-get to poll for online_url
- Remove references to legacy multipart upload and data.url

* TEMP: pin miaoda-cli alpha and add BOE header for testing

- Pin miaoda-cli to 0.1.24-alpha.fb2cf0a (revert to @latest before merge)
- Add x-tt-env=boe_aily_lark_cli header globally (remove before merge)
- html app-type uses --template design-html instead of --app-type (remove before merge)

* docs(apps): add creative mode link format and meta_token recognition

- Add creative mode (html) link format `https://{tenant}/page/{meta_token}` in publish护栏
- Note dev and publish URLs are the same for creative mode, unlike full_stack
- Add meta_token to app_id resolution with full link format in app_id获取

* docs(apps): route html apps through local-dev git pipeline by default

- Select dev path: html apps now default to local-dev pipeline instead of skipping local/cloud axis
- Intent routing: creative-design publishes via local-dev flow instead of +html-publish
- Remove +html-publish fallback from local-dev "when not to use" section

* docs(apps): generalize skill references to cover both html and full_stack

Remove full_stack-only wording from init, create, list, env-pull, and
release-create references since html apps now share the same local dev
and release flow.

* feat(apps): add meta_token to +get pretty output and dry-run description

* docs(apps): unify html as creative mode, fix routing and local-dev flow

- Remove "HTML" as separate dev path; html and full_stack both go through local-dev
- Intent routing: read local-dev before creative-design to establish git pipeline first
- Mark +html-publish as legacy, redirect to local-dev for creative mode
- Split html local-dev into 3 scenarios: first-time, iteration, pre-generated files
- git add . instead of selective add to capture all creative-design output files

* docs(apps): remove dev link from html-publish output, only return release-get online_url

* fix: add license header to deck-stage.js

* docs(apps): clarify dev link only for full_stack, creative mode shares dev/pub URL

* docs(apps): remove +html-publish from intent routing, description, and guardrails

All HTML apps now go through local-dev pipeline. +html-publish is deprecated.

* docs(apps): remove html-publish references from create/release-create/cloud-dev pages

html-publish is no longer the recommended path for HTML apps; all html
and full_stack apps now follow the same local-dev + release-create flow.

* fix(apps): address PR review feedback

- html-publish dry-run: register all 3 API calls (GET pre_release, PUT TOS, POST release-create) instead of hiding steps in metadata
- validateRealAppID: remove cli_ prefix check (not a valid app_id prefix)
- E2E: update git-credential dry-run to expect 4 local_effects
- E2E: update html-publish dry-run to expect GET pre_release

* fix(apps): address PR review — remove legacy multipart dead code, fix docs

- Delete html_publish_client.go and html_publish_client_test.go (legacy multipart)
- Remove runHTMLPublish, enrichHTMLPublishAPIError, buildHTMLPublishFailureHint
- Migrate tests from runHTMLPublish to prepareHTMLPublishTarball (same coverage)
- Remove cli_ prefix from validateRealAppID (not a valid app_id prefix)
- Fix html-publish.md error wording to match actual message
- Register all 3 TOS API calls in html-publish dry-run
- Update E2E tests for new dry-run contract

* fix(apps): correctly merge SKILL.md with main (role mgmt, auth wording, source boundary)

Rebuild SKILL.md from our branch version, then merge in main's additions:
- description: add HTML静态站点发布, 应用角色与成员管理, 应用角色/角色成员
- 身份与授权: use main's updated wording (no proactive re-login)
- intent routing: add +role-* row, +init refs 平台资源与应用源码边界
- 能力边界 → 平台资源与应用源码边界 (7 rules from main)
- 禁止预授权底线: add role ② and html-publish ③ clauses

* docs(apps): route legacy html-publish only for non-git html apps

* docs(apps): strengthen local-dev routing and git recovery guidance

fix:cherry-pick and resolve conflicts

* fix: gofmt apps_errors.go and apps_errors_test.go

* docs(apps): strengthen git credential recovery and add file-upload guidance

- Generalize git error recovery: any git operation failure triggers
  +git-credential-init refresh, with environment analysis on failure
- Add resource file upload rule: use +file-upload instead of local
  paths, base64 inlining, or git commits; files are app-scoped

* test(apps): strengthen html-publish dry-run assertions for TOS 3-step contract

* fix: 文件资源上传

* docs(apps): update creative-design skill content

* fix: re-add license header to deck-stage.js

* refactor(apps): merge system-prompt.md into SKILL.md for creative-design skill

Consolidate the thin SKILL.md wrapper and the full system-prompt.md
methodology into a single file, eliminating an unnecessary indirection.
Update references in claude.md and codex.md accordingly.

* chore: revert TEMP changes — miaoda-cli back to @latest, remove BOE header

* docs(apps): remove 可见范围 from 发布态护栏

创意模式的可见范围权限走 lark-drive 文档权限体系,而非妙搭应用
权限体系,当前的 +access-scope-set/get 无法正确管理创意模式应用
的可见范围。待文档协作支持妙搭能力后,再通过 lark-drive 域能力
引导修改。

TODO: 等文档协作支持妙搭能力后,在 skill 中加入使用文档域权限
能力修改创意模式可见范围的引导。

* docs(lark-apps): 在平台资源与应用源码边界添加路径规则,引导 agent 使用相对路径

`apps` 命令的 `--path`、`--file`、`--output` 只接受 cwd 下的相对路径,传绝对路径会报错。

* docs(lark-apps): 新增创意模式评论路由和裸 meta_token 识别引导

- 意图路由表新增创意模式应用评论,引导走 lark-drive 文档评论体系
- app_id 获取章节补充裸 meta_token 识别:非链接非 app_ 开头时尝试用 +get 解析

* refactor(apps): flatten creative-design built-in-skills into references

- Delete built-in-skills/ directory (9 nested sub-skill folders)
- Move media skill content to references/ as flat .md files
- Add assets/index.html React+Babel starter template
- Integrate publishing flow into creative-design SKILL.md
- Update harness reference docs (aily/claude/codex.md)
- Simplify lark-apps SKILL.md routing to point directly to creative-design
- Remove creative-design standalone .git directory

* refactor(apps): rename creative-design/SKILL.md to creative-design.md

Avoid being mistaken as an independent skill entry point.
Update all internal references (lark-apps routing table + 10 reference files).

* fix(apps): fail closed when queryAppType fails instead of falling back to full_stack

queryAppType now returns an error instead of silently returning "".
+init aborts if the app type cannot be determined, preventing wrong
scaffold type from being committed and pushed to the repository.

---------

Co-authored-by: zhangli <zhangli.268@bytedance.com>
2026-07-20 20:15:43 +08:00
林晓江(XiaoJiang Lin)
b52677269e [codex] support bot menu events (#1765)
* feat(event): support bot menu event

* fix(event): normalize bot menu timestamp
2026-07-20 20:07:21 +08:00
R0bynZhu
78390f8ea1 chore(slides): update lark-slides skill to 0715 snapshot (#1933)
* chore(slides): update lark-slides skill to 0715 snapshot

* fix: 补回lark-share 内容

* fix: 补回一些内容

* fix: 移除豆包特有工具

* fix: 移除多余的xml版本头

* fix: 补回示例xml头

* fix: remove xml-format-guide
2026-07-20 19:23:05 +08:00
木杉
d6cebd6723 docs: clarify local trigger automation (#1958)
* feat: clarify local trigger automation

* docs: refine trigger automation guidance

* docs: correct trigger release contracts

* docs: separate trigger enable and probe authorization

* docs: link the enable-only trigger path

* test: harden trigger authorization contracts

* docs: harden trigger disabled-state handling

* docs: harden trigger release state handling

* docs: verify a finished release before enable

* docs: split trigger start and test flows

* docs: fail closed after trigger probe errors

* docs(apps): fail closed on trigger test and release-create failures

Harden the automation guide's state handling. When testing an existing
online trigger, a formerly-disabled trigger is always restored to
disabled on probe success, failure, uncertain result, or early exit.
When +release-create itself errors or returns no release_id, treat it as
not published and restore the prior trigger state; when the result is
unknown, keep it disabled and verify via +release-list before deciding.

* docs(apps): flag online_url as creator-only before sharing

Point the local-dev and release-get release flows to the access-scope
step so a returned online_url is not presented as a shareable link
without the creator-only visibility caveat, matching the SKILL.md
visibility contract.

* docs(apps): drop out-of-scope SKILL.md edits from the trigger change

The local trigger automation work does not require touching the lark-apps
SKILL.md: its description already routed automation, so compressing it only
dropped routing keywords (access scope, monitoring metrics, trigger
subtypes) to satisfy a non-blocking length convention. Restore SKILL.md to
its prior state and remove the description/optional-output assertions that
only guarded those reverted edits. Release-output-as-optional correctness
remains covered by the release-get contract.
2026-07-20 18:27:32 +08:00
calendar-assistant
79adf89beb docs(vc): default transcript routing to smart notes over minutes (#1961)
Clarify that smart notes (AI summary) and their verbatim docs are
auto-authorized to participants, while minutes carry the raw recording
and require explicit authorization. Rewrite the artifact-selection rule
to cover transcripts: use whichever exists when only one is present,
follow the user's explicit choice, and default to smart notes when both
exist and the user is unspecified.
2026-07-20 16:49:03 +08:00
luozhixiong01
9dd355a52d test: synchronize temporary Git maintenance (#1946) 2026-07-20 16:30:09 +08:00
Neseria
7b989948c4 docs(base): reduce filter and update retry loops (#1879)
* docs(base): disambiguate filter DSL and value shape to cut retry loops

Eval traces show the Base filter/view chain loses time to avoidable
error->lookup->retry loops:
- record/view --filter-json (tuple [[f,op,v]]) gets confused with
  +data-query's object filters ({field_name,operator,value}) -> 800010701
- scalar fields (text/number) get array-wrapped values -> 800010507
- agents guess a field is select from its name, or guess enum values in
  Chinese when stored values are English -> 0 hits then retry

Add a top-of-doc section to the tuple-DSL SSOT (value shape by field type,
check field type first, don't confuse with data-query, use real stored
values), a reciprocal warning in data-query, and two recovery rows in
SKILL.md. Flag-level details (--limit vs --page-size) are left to command
--help per the skill's stated design.

* refactor(base): fold filter guidance into existing sections, drop overfit examples

Address review feedback on the first pass:
- remove the added top-level '## 0 …先读' section — it duplicated §3 (per-type
  value rules) and §7 (易错点), and its examples (状态=="Open", 工时>=3.5)
  overfit the eval case and even clashed with §3's own 状态-as-select example.
- instead sharpen what already exists: §7 names the shared commands and the
  data-query object shape to avoid; §6 gets one process rule (confirm field
  type / real values first); all example-free and principle-based.
- revert the data-query.md note (wrong direction; the confusion is fixed at
  the record/view tuple-DSL SSOT).
- slim the SKILL.md recovery rows to terse, message-keyed, reference-pointing
  entries matching the table's style.

* docs(base): clarify full and partial update guidance

* docs(base): clarify partial update payload guidance

---------

Co-authored-by: wanglei.75 <wanglei.75@bytedance.com>
2026-07-20 14:46:08 +08:00
caojie0621
6ff10229fd fix: standardize CLI shortcut text in English (#1942)
* fix: standardize CLI shortcut text in English

- translate Docs create and update help descriptions
- remove localized permission annotations
- replace Chinese examples and fallback text
- use English labels for Docs IM Markdown resources
- update regression tests for English output

* test: strengthen English output contracts
2026-07-20 14:05:42 +08:00
HanShaoshuai-k
21cff2e2dd fix: reduce public content credential fixture false positives 2026-07-20 13:54:38 +08:00
607 changed files with 53938 additions and 5210 deletions

3
.github/CODEOWNERS vendored
View File

@@ -1,4 +1,7 @@
/go.mod @liangshuo-1
/go.sum @liangshuo-1
/internal/ @liangshuo-1
/shortcuts/common/ @liangshuo-1
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
/skills/ @liangshuo-1

View File

@@ -82,6 +82,56 @@ jobs:
- name: Run sidecar tag build + HMAC round-trip
run: make sidecar-test
extended-integration:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Build both editions and verify identity
run: |
set -euo pipefail
go build -o /tmp/lark-cli-standard .
go build -tags extended -o /tmp/lark-cli-extended .
test "$(/tmp/lark-cli-standard version --json | jq -r .edition)" = "standard"
test "$(/tmp/lark-cli-extended version --json | jq -r .edition)" = "extended"
test "$(/tmp/lark-cli-extended version --json | jq -r '.capabilities[]')" = "external-credential-platform"
- name: Cross-compile Extended platform-specific security code
run: |
set -euo pipefail
GOOS=darwin GOARCH=arm64 go build -tags extended -o /tmp/lark-cli-extended-darwin .
GOOS=windows GOARCH=amd64 go build -tags extended -o /tmp/lark-cli-extended-windows.exe .
- name: Verify edition source isolation
run: go test -count=1 ./internal/externalcredential -run '^TestEditionSourceIsolation$'
- name: Run Extended tests
run: make extended-test
extended-platform-security:
needs: fast-gate
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- name: Run native helper isolation and path trust tests
run: go test -tags extended -count=1 ./internal/externalcredential -run '^(TestNativeAdminControlledPath|TestCredentialProcessEnvironmentUsesExplicitAllowlist|TestCredentialProcessCommandRunsWithIsolatedEnvironment)$'
# ── Layer 2: Quality Gate ──────────────────────────────────────────
unit-test:
needs: fast-gate
@@ -142,6 +192,28 @@ jobs:
node-version: '22'
- name: Run script tests
run: make script-test
- name: Install GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
install-only: true
- name: Validate GoReleaser configuration
run: goreleaser check
- name: Check Extended installer syntax
shell: pwsh
run: |
sh -n scripts/install-extended.sh
$tokens = $null
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile(
(Resolve-Path scripts/install-extended.ps1),
[ref]$tokens,
[ref]$errors
) | Out-Null
if ($errors.Count -ne 0) {
$errors | ForEach-Object { Write-Error $_ }
exit 1
}
deterministic-gate:
needs: fast-gate
@@ -216,16 +288,20 @@ jobs:
# second time here — and, crucially, so an observe-only suite's failure
# can never block merges through coverage's spot in the results loop.
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
go test -race -coverprofile=coverage-standard.txt -covermode=atomic $packages
# Extended implementation files are selected by build tags and would
# otherwise be absent from the uploaded report. Their race-enabled
# suite runs in extended-integration; this pass contributes coverage.
go test -tags extended -coverprofile=coverage-extended.txt -covermode=atomic $packages
- name: Upload coverage to Codecov
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
with:
files: coverage.txt
files: coverage-standard.txt,coverage-extended.txt
token: ${{ secrets.CODECOV_TOKEN }}
- name: Check coverage threshold
run: |
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | tr -d '%')
total=$(go tool cover -func=coverage-standard.txt | grep total | awk '{print $3}' | tr -d '%')
threshold=40
echo "Coverage: ${total}% (threshold: ${threshold}%)"
if (( $(echo "$total < $threshold" | bc -l) )); then
@@ -235,21 +311,31 @@ jobs:
- name: Coverage summary
if: ${{ !cancelled() }}
run: |
if [ ! -f coverage.txt ]; then
echo "No coverage data available" >> $GITHUB_STEP_SUMMARY
exit 0
fi
total=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}')
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
go tool cover -func=coverage.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
report_coverage() {
profile="$1"
label="$2"
echo "### ${label} edition" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ ! -f "$profile" ]; then
echo "No ${label} coverage data available." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
return
fi
total=$(go tool cover -func="$profile" | grep total | awk '{print $3}')
echo "**Total coverage: ${total}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "<details><summary>Details</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
go tool cover -func="$profile" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
}
report_coverage coverage-standard.txt Standard
report_coverage coverage-extended.txt Extended
deadcode:
needs: fast-gate
@@ -520,7 +606,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration, extended-integration, extended-platform-security]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -542,6 +628,8 @@ jobs:
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | extended-integration | ${{ needs.extended-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | extended-platform-security | ${{ needs.extended-platform-security.result }} |" >> $GITHUB_STEP_SUMMARY
# Any failure or cancellation in any job blocks the merge.
# Legitimately skipped jobs (deadcode on push, e2e-live when not
@@ -565,7 +653,9 @@ jobs:
"${{ needs.e2e-dry-run.result }}" \
"${{ needs.e2e-live.result }}" \
"${{ needs.security.result }}" \
"${{ needs.license-header.result }}"; do
"${{ needs.license-header.result }}" \
"${{ needs.extended-integration.result }}" \
"${{ needs.extended-platform-security.result }}"; do
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
FAILED=1
fi

View File

@@ -9,10 +9,45 @@ permissions:
contents: read
jobs:
goreleaser:
preflight:
runs-on: ubuntu-22.04
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
- name: Validate tag and commit
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
node scripts/release-preflight.js --tag "$TAG"
git fetch origin main
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
exit 1
fi
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
exit 1
fi
build-release:
needs: preflight
runs-on: ubuntu-22.04
permissions:
contents: write
id-token: write
attestations: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
@@ -26,35 +61,176 @@ jobs:
with:
python-version: '3.x'
- name: Run GoReleaser
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Build and upload draft release with GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
- name: Include release checksums
run: |
set -euo pipefail
test -s dist/checksums.txt
cp scripts/install-extended.sh scripts/install-extended.ps1 dist/
(cd dist && sha256sum --check checksums.txt)
cp dist/checksums.txt checksums.txt
- name: Verify release edition identities
run: |
set -euo pipefail
mkdir -p /tmp/lark-cli-standard /tmp/lark-cli-extended
tar -xzf "dist/lark-cli-${GITHUB_REF_NAME#v}-linux-amd64.tar.gz" -C /tmp/lark-cli-standard lark-cli
tar -xzf "dist/lark-cli-extended-${GITHUB_REF_NAME#v}-linux-amd64.tar.gz" -C /tmp/lark-cli-extended lark-cli
test "$(/tmp/lark-cli-standard/lark-cli version --json | jq -r .edition)" = "standard"
test "$(/tmp/lark-cli-extended/lark-cli version --json | jq -r .edition)" = "extended"
test "$(/tmp/lark-cli-standard/lark-cli version --json | jq -r .version)" = "${GITHUB_REF_NAME#v}"
test "$(/tmp/lark-cli-extended/lark-cli version --json | jq -r .version)" = "${GITHUB_REF_NAME#v}"
- name: Verify release platform asset matrix
run: bash scripts/verify-release-assets.sh dist "${GITHUB_REF_NAME#v}"
- name: Attest release archives
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
with:
subject-path: |
dist/*.tar.gz
dist/*.zip
dist/checksums.txt
dist/install-extended.sh
dist/install-extended.ps1
- name: Collect release asset
run: |
set -euo pipefail
mkdir npm-publish-asset
cp dist/*.tar.gz dist/*.zip dist/checksums.txt \
dist/install-extended.sh dist/install-extended.ps1 \
npm-publish-asset/
- name: Upload release asset
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset/
if-no-files-found: error
overwrite: true
- name: Publish verified GitHub release
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
RELEASE_TAG: ${{ github.ref_name }}
with:
github-token: ${{ github.token }}
script: |
const crypto = require("node:crypto");
const fs = require("node:fs");
const tag = process.env.RELEASE_TAG;
const { owner, repo } = context.repo;
const releases = await github.paginate(github.rest.repos.listReleases, {
owner,
repo,
per_page: 100,
});
const matches = releases.filter((release) => release.tag_name === tag);
if (matches.length !== 1) {
throw new Error(`expected exactly one draft release for ${tag}, found ${matches.length}`);
}
const release = matches[0];
if (!release.draft) {
throw new Error(`release ${tag} became public before verification completed`);
}
const checksumPath = "dist/checksums.txt";
const checksumBody = fs.readFileSync(checksumPath, "utf8");
const expectedDigests = new Map();
for (const line of checksumBody.split(/\r?\n/)) {
if (!line.trim()) continue;
const match = line.match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
if (!match) throw new Error(`invalid checksums.txt line: ${line}`);
const name = match[2];
if (expectedDigests.has(name)) {
throw new Error(`duplicate checksums.txt entry: ${name}`);
}
expectedDigests.set(name, `sha256:${match[1].toLowerCase()}`);
}
expectedDigests.set(
"checksums.txt",
`sha256:${crypto.createHash("sha256").update(checksumBody).digest("hex")}`,
);
const actualNames = release.assets.map((asset) => asset.name).sort();
const expectedNames = [...expectedDigests.keys()].sort();
if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) {
throw new Error(
`draft release asset set mismatch: expected ${expectedNames.join(", ")}, got ${actualNames.join(", ")}`,
);
}
for (const asset of release.assets) {
const expected = expectedDigests.get(asset.name);
if (!asset.digest) {
throw new Error(`GitHub did not report a digest for draft asset ${asset.name}`);
}
if (asset.digest.toLowerCase() !== expected) {
throw new Error(
`draft asset digest mismatch for ${asset.name}: expected ${expected}, got ${asset.digest}`,
);
}
}
await github.rest.repos.updateRelease({
owner,
repo,
release_id: release.id,
draft: false,
make_latest: "true",
});
publish-npm:
needs: goreleaser
needs: build-release
runs-on: ubuntu-22.04
environment: npm-production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '20'
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Download checksums from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Download release asset
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset
- name: Verify npm publish asset
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
gh release download "${TAG}" --pattern checksums.txt --dir .
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
(cd npm-publish-asset && sha256sum --check checksums.txt)
cp npm-publish-asset/checksums.txt checksums.txt
PACK_JSON="$(npm pack --ignore-scripts --json)"
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
test -s "$PACK_FILE"
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
rm "$PACK_FILE"
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public

View File

@@ -25,19 +25,16 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
@@ -253,19 +250,16 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");

View File

@@ -5,7 +5,8 @@ before:
- python3 scripts/fetch_meta.py
builds:
- binary: lark-cli
- id: standard
binary: lark-cli
env:
- CGO_ENABLED=0
ldflags:
@@ -18,12 +19,54 @@ builds:
- amd64
- arm64
- riscv64
ignore:
- goos: darwin
goarch: riscv64
- goos: windows
goarch: riscv64
- id: extended
binary: lark-cli
tags:
- extended
env:
- CGO_ENABLED=0
ldflags:
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
- riscv64
ignore:
- goos: darwin
goarch: riscv64
- goos: windows
goarch: riscv64
archives:
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
- id: standard
ids:
- standard
name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
format: zip
formats:
- zip
files:
- README.md
- LICENSE
- CHANGELOG.md
- id: extended
ids:
- extended
name_template: "lark-cli-extended-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
formats:
- zip
files:
- README.md
- LICENSE
@@ -31,6 +74,18 @@ archives:
checksum:
name_template: checksums.txt
extra_files:
- glob: ./scripts/install-extended.sh
- glob: ./scripts/install-extended.ps1
release:
# Keep assets undiscoverable by releases/latest until the workflow has
# independently verified checksums, edition identity, and platform coverage.
draft: true
replace_existing_draft: true
extra_files:
- glob: ./scripts/install-extended.sh
- glob: ./scripts/install-extended.ps1
changelog:
sort: asc

View File

@@ -10,9 +10,10 @@
## Build & Test
```bash
make build # Build (runs fetch_meta first)
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
make test # Full: vet + unit + integration
make build # Build (runs fetch_meta first)
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
make live-skills-test # Opt-in real Skills CLI tests; runs with isolated user directories
make test # Full: vet + unit + integration
```
## Notification Opt-Outs

View File

@@ -2,6 +2,176 @@
All notable changes to this project will be documented in this file.
## [v1.0.80] - 2026-07-29
### Features
- **drive**: add +member-list shortcut (#1795)
- **drive**: add +permission-get-setting shortcut (#1738)
- propagate invocation metadata (#2097)
### Documentation
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
- **slides**: +create 的参数下沉到 create.md主 skill 只留路由 (#2096)
### Tests
- **e2e**: wait for base role update visibility (#2087)
### Misc
- Feat/detect line text overlap (#2069)
## [v1.0.79] - 2026-07-28
### Features
- **slides**: update xsd (#2067)
### Bug Fixes
- **ci**: validate static workflow identity (#2015)
- **sheets**: recognize OFL0X local office tokens (#2063)
### Documentation
- **calendar**: clarify identity selection by event ownership (#2071)
- **slides**: add formula inline element syntax to quick-ref (#2077)
## [v1.0.78] - 2026-07-27
### Features
- event description support rich text (#1975)
### Bug Fixes
- **slides**: restrict canvas overflow checks
- **slides**: upgrade text overflow to error above 10px threshold
- **slides**: detect letterSpacing-driven text overflow
- **slides**: downgrade background-decoration text overflow to info
- **slides**: allow chartParsedValues roundtrip tag
- refine character width estimation for lark-slides text lint
- **slides**: preserve info lint severity
- **slides**: text may over flow shape
- exempt ghost text from slides lint
## [v1.0.77] - 2026-07-24
### Features
- introducing official card icon (#1973)
- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
- **apps**: support absolute and relative upload paths (#2005)
- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
- **slides**: add layout density lint for sparse/empty containers (#2022)
- add risk-control protection (#1910)
### Bug Fixes
- **slides**: normalize presentation flag aliases (#2032)
- **base**: classify +form-submit as high-risk-write (#1969)
- **slides**: declare screenshot scope
- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
### Documentation
- **skill**: clarify scope handling for query expansion (#2030)
- **base**: clarify complete and partial updates (#1993)
- **skills**: clarify callout child rules (#2048)
### Misc
- fix/task id handling (#2023)
- fix/task search pagination (#2041)
## [v1.0.75] - 2026-07-22
### Features
- add okr single create shortcut & skill text opti (#1941)
- **calendar**: auto-add bot self as attendee and note user-only search (#1991)
### Bug Fixes
- **base**: improve table shortcut behavior & guidance (#1803)
- issue#1935 & whiteboard shortcut reformat (#1980)
- remove legacy shortcut (#1997)
- **e2e**: inject shared credentials by identity (#1995)
### Documentation
- **skill**: describe html5 block xml usage (#1380)
- clarify fetch metadata and user cites (#1981)
- add topic move collector workflow (#1473)
- update lark doc HTML size limit (#2001)
- **base**: align record write schema guidance (#2000)
### Tests
- **e2e**: declare request identities explicitly (#2004)
### Misc
- harden npm release publishing (#1918)
## [v1.0.74] - 2026-07-21
### Features
- **slides**: add history rollback shortcuts (#1714)
- **base**: support per-record batch updates (#1889)
### Bug Fixes
- preserve slides schema issues
- allow jq examples in quality gate dry-runs
- **im**: warn when flag pagination is truncated (#1906)
- **slides**: warn on text shape overflow
- **slides**: exempt chart roundtrip attributes from lint
- **slides**: detect image text occlusion
- **slides**: clarify xml-text-overlap-lint error for positional argument (#1986)
### Documentation
- clarify drive upload overwrite guidance (#1982)
### Tests
- isolate unit tests from user state (#1883)
### Refactoring
- converge success output through a single Emitter that owns the write (#1899)
## [v1.0.73] - 2026-07-20
### Features
- **apps**: design_html support, creative-design skill, unified TOS publish (#1901)
### Bug Fixes
- **slides**: detect visual elements outside canvas
- reduce public content credential fixture false positives
- standardize CLI shortcut text in English (#1942)
### Documentation
- **base**: reduce filter and update retry loops (#1879)
- **vc**: default transcript routing to smart notes over minutes (#1961)
- clarify local trigger automation (#1958)
### Tests
- synchronize temporary Git maintenance (#1946)
### Misc
- **slides**: update lark-slides skill to 0715 snapshot (#1933)
- [codex] support bot menu events (#1765)
## [v1.0.72] - 2026-07-17
### Features
@@ -1552,6 +1722,13 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70

View File

@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test extended-test
all: test
@@ -50,14 +50,20 @@ fmt-check:
script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/release-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
./cmd/... ./internal/... ./shortcuts/... ./extension/...
live-skills-test: fetch_meta
LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS=1 \
go test -v -count=1 ./cmd/update \
-run '^TestUpdateCommand_(RealSkillsSyncRewritesState|SkillsSyncColdStart)$$'
# examples-build keeps the shipped plugin-SDK examples compilable. If this
# breaks, the plugin author guide's "go build ./..." path is broken.
examples-build:
@@ -116,6 +122,12 @@ sidecar-test:
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
# extended-test compiles and exercises the separately distributed Extended
# edition. The default build remains the Standard npm/npx binary.
extended-test:
go build -tags extended -o /dev/null .
go test $(RACE_FLAG) -count=1 -tags extended ./cmd/... ./internal/... ./shortcuts/... ./extension/... ./tests/externalcredential_e2e
# Run secret-leak checks locally before pushing.
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.

View File

@@ -285,6 +285,29 @@ To reduce these risks, the tool enables default security protections at multiple
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
- Operating system type: macOS, Windows, or Linux
- Device hardware model: for example, Mac17,9
To disable this protection for the current workspace, run:
```bash
lark-cli config risk-control off
```
To enable this protection for the current workspace, run:
```bash
lark-cli config risk-control on
```
To restore the default policy for the current workspace, run:
```bash
lark-cli config risk-control default
```
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
## Star History

View File

@@ -286,6 +286,29 @@ lark-cli schema im.messages.delete
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
为降低访问令牌被盗用后的安全风险CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
- 操作系统类型macOS、Windows 或 Linux
- 设备的硬件产品型号:例如 Mac17,9
如需让当前 workspace 退出该保护,可执行以下命令:
```bash
lark-cli config risk-control off
```
如需开启当前 workspace 的保护,可执行以下命令:
```bash
lark-cli config risk-control on
```
恢复当前 workspace 默认策略可执行:
```bash
lark-cli config risk-control default
```
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
## Star History

View File

@@ -23,6 +23,41 @@ lark-cli contact +search-user --query "alice" --as user
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
```
## +search-bot
Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` to search within specific chats.
### Skills
- lark-contact/references/lark-contact-search-bot.md
### Avoid when
- Looking for a person rather than a bot → use [[+search-user]]
- Running as a bot — this shortcut is user-only
### Tips
- `has_more=true` means the search is incomplete; refine the keyword or search scope instead of paginating
### Examples
**Find bots by keyword**
```bash
lark-cli contact +search-bot --query "会议助手" --as user
```
**Search inside one chat**
```bash
lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user
```
**Find bots you've chatted with**
```bash
lark-cli contact +search-bot --query "助手" --has-chatted --as user
```
**Search several bot keywords in one call**
```bash
lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user
```
## +get-user
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.

View File

@@ -344,20 +344,18 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items)
return nil
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
}, pagOpts)
if err != nil {
return errs.MarkRaw(err)

View File

@@ -0,0 +1,396 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
type apiFailOnWriteWriter struct {
buf bytes.Buffer
writes int
failAt int
err error
}
func (w *apiFailOnWriteWriter) Write(p []byte) (int, error) {
w.writes++
if w.writes == w.failAt {
return 0, w.err
}
return w.buf.Write(p)
}
func newAPIPaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
previousNotice := output.PendingNotice
output.PendingNotice = nil
t.Cleanup(func() { output.PendingNotice = previousNotice })
config := &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
}
f, out, errOut, reg := cmdutil.TestFactory(t, config)
ac, err := f.NewAPIClientWithConfig(config)
if err != nil {
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
}
ac.ErrOut = io.Discard
return ac, out, errOut, reg
}
func apiPaginateRequest() client.RawApiRequest {
return client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test/v1/items",
As: core.AsBot,
}
}
func assertAPIPaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
t.Helper()
wantBytes, err := json.MarshalIndent(want, "", " ")
if err != nil {
t.Fatalf("marshal expected JSON: %v", err)
}
wantBytes = append(wantBytes, '\n')
if !bytes.Equal(got, wantBytes) {
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
}
}
func TestAPIPaginate_DefaultAggregatesAllPages(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
calls := 0
wantTokens := []string{"", "next-1", "next-2"}
for i, wantToken := range wantTokens {
page := i + 1
hasMore := page < len(wantTokens)
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = wantTokens[page]
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(req *http.Request) {
calls++
if got := req.URL.Query().Get("page_token"); got != wantToken {
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
})
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
if calls != 3 {
t.Fatalf("pagination requests = %d, want 3", calls)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1"},
map[string]interface{}{"id": "2"},
map[string]interface{}{"id": "3"},
},
"has_more": false,
},
})
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}
func TestAPIPaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
tests := []struct {
name string
format output.Format
want string
}{
{
name: "ndjson",
format: output.FormatNDJSON,
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
},
{
name: "table",
format: output.FormatTable,
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
},
{
name: "csv",
format: output.FormatCSV,
want: "id,name\n1,Alice\n2,Carol\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "name": "Alice"},
},
"has_more": true,
"page_token": "next-1",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
},
"has_more": false,
},
},
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
})
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
ac, _, errOut, reg := newAPIPaginateTestHarness(t)
sentinel := errors.New("page write failed")
out := &apiFailOnWriteWriter{failAt: 2, err: sentinel}
calls := 0
for page := 1; page <= 2; page++ {
hasMore := true
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": page}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = fmt.Sprintf("next-%d", page)
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(*http.Request) {
calls++
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET",
client.PaginationOptions{PageLimit: 10, PageDelay: -1})
if !errors.Is(err, sentinel) {
t.Fatalf("apiPaginate() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("apiPaginate() problem = %#v, %v; want internal typed error", problem, ok)
}
if calls != 2 {
t.Fatalf("pagination requests = %d, want 2", calls)
}
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
t.Fatalf("stdout bytes = %q, want %q", got, want)
}
}
func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
},
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
})
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
if got := errOut.String(); got != wantWarning {
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
}
}
func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
businessResponse := map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{"detail": "business failed"},
}
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "default_json", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: businessResponse,
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("apiPaginate() error = nil, want business error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), businessResponse)
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "stream_pages", format: output.FormatNDJSON},
{name: "default_paginate_all", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, _ := newAPIPaginateTestHarness(t)
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("apiPaginate() error = nil, want transport error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{},
},
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("apiPaginate() error = nil, want business error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}

View File

@@ -352,6 +352,9 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
}
func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
})
@@ -371,8 +374,33 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
if !strings.Contains(stderr.String(), "binary response detected") {
t.Error("expected binary response hint in stderr")
}
if !strings.Contains(stdout.String(), "saved_path") {
t.Error("expected saved_path in output")
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("stdout is not JSON: %v\nstdout:\n%s", err, stdout.String())
}
savedPath, _ := got["saved_path"].(string)
if savedPath == "" {
t.Fatalf("saved_path missing from output: %#v", got)
}
// The file must land inside the temporary cwd — this pins the isolation
// contract: rolling back TestChdir would leave download.bin in the repo.
wantDir, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatal(err)
}
gotDir, err := filepath.EvalSymlinks(filepath.Dir(savedPath))
if err != nil {
t.Fatalf("saved_path %q dir not resolvable: %v", savedPath, err)
}
if gotDir != wantDir {
t.Errorf("saved_path %q is outside temp cwd %q", savedPath, wantDir)
}
content, err := os.ReadFile(savedPath)
if err != nil {
t.Fatalf("read saved file: %v", err)
}
if string(content) != "fake-binary-content" {
t.Errorf("saved file content = %q, want %q", content, "fake-binary-content")
}
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/runtimeplan"
)
// NewCmdAuth creates the auth command with subcommands.
@@ -30,21 +31,23 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
// PersistentPreRun[E] found walking up the chain, so the root-level
// SilenceUsage=true would be skipped without this line.
cmd.SilenceUsage = true
// cmd.Name() returns the subcommand name (e.g. "login"), not "auth".
// Pass "auth" as a literal so the error message reads
// `"auth" is not supported: ...`
return f.RequireBuiltinCredentialProvider(cmd.Context(), "auth")
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityLocalCredentialManagement)
cmd.AddCommand(NewCmdAuthLogin(f, nil))
cmd.AddCommand(NewCmdAuthLogout(f, nil))
cmd.AddCommand(NewCmdAuthStatus(f, nil))
cmd.AddCommand(NewCmdAuthScopes(f, nil))
cmd.AddCommand(NewCmdAuthList(f, nil))
cmd.AddCommand(NewCmdAuthCheck(f, nil))
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
login := NewCmdAuthLogin(f, nil)
logout := NewCmdAuthLogout(f, nil)
status := NewCmdAuthStatus(f, nil)
scopes := NewCmdAuthScopes(f, nil)
list := NewCmdAuthList(f, nil)
check := NewCmdAuthCheck(f, nil)
qrcode := NewCmdAuthQRCode(f, nil)
for _, diagnostic := range []*cobra.Command{status, scopes, check, qrcode} {
cmdutil.SetRuntimeCapabilities(diagnostic)
}
cmd.AddCommand(login, logout, status, scopes, list, check, qrcode)
return cmd
}

View File

@@ -530,10 +530,7 @@ func TestAuthBlockedByExternalProvider(t *testing.T) {
}{
{"login", []string{"login"}},
{"logout", []string{"logout"}},
{"status", []string{"status"}},
{"check", []string{"check", "--scope", "calendar:read"}}, // --scope is required
{"list", []string{"list"}},
{"scopes", []string{"scopes"}},
}
for _, tt := range tests {
@@ -558,3 +555,19 @@ func TestAuthBlockedByExternalProvider(t *testing.T) {
})
}
}
func TestAuthReadOnlyCommandsAllowedByExternalProvider(t *testing.T) {
f := newFactoryWithExternalProvider(t)
for _, name := range []string{"status", "check", "scopes", "qrcode"} {
t.Run(name, func(t *testing.T) {
cmd := NewCmdAuth(f)
matched, _, err := cmd.Find([]string{name})
if err != nil {
t.Fatal(err)
}
if err := cmd.PersistentPreRunE(matched, nil); err != nil {
t.Fatalf("read-only command blocked: %v", err)
}
})
}
}

View File

@@ -4,6 +4,7 @@
package auth
import (
"context"
"fmt"
"strings"
@@ -12,6 +13,7 @@ import (
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
)
@@ -33,7 +35,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
if runF != nil {
return runF(opts)
}
return authCheckRun(opts)
return authCheckRunContext(cmd.Context(), opts)
},
}
@@ -46,6 +48,10 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
}
func authCheckRun(opts *CheckOptions) error {
return authCheckRunContext(context.Background(), opts)
}
func authCheckRunContext(ctx context.Context, opts *CheckOptions) error {
f := opts.Factory
required := strings.Fields(opts.Scope)
@@ -57,18 +63,74 @@ func authCheckRun(opts *CheckOptions) error {
if err != nil {
return err
}
if config.UserOpenId == "" {
if f.Credential == nil {
return errs.NewInternalError(errs.SubtypeUnknown, "credential inspection is unavailable")
}
inspection, err := f.Credential.InspectToken(ctx, credential.TokenInspectionRequest{
TokenSpec: credential.TokenSpec{
Type: credential.TokenTypeUAT,
AppID: config.AppID,
},
IncludeScopes: true,
})
if err != nil {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown,
"failed to inspect user authorization: %v", err).
WithCause(err)
}
if inspection == nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"credential source returned no authorization inspection")
}
if inspection.Status == credential.TokenInspectionNotLoggedIn && !inspection.Source.Managed {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": false, "error": "not_logged_in", "missing": required})
return output.ErrBare(1)
}
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
if stored == nil {
if !inspection.Present {
if inspection.Source.Managed {
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"credential source %q did not provide a user access token", inspection.Source.Name).
WithHint("authorize the user through the selected credential source")
}
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": false, "error": "no_token", "missing": required})
return output.ErrBare(1)
}
missing := larkauth.MissingScopes(stored.Scope, required)
switch inspection.ScopeState {
case credential.ScopeUnsupported:
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"auth check is unsupported by credential source %q because granted scopes are unavailable", inspection.Source.Name).
WithHint("the credential source must expose trusted scope metadata before `auth check` can evaluate --scope")
case credential.ScopeUnknown:
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"auth check result is unknown because credential source %q returned no scope metadata", inspection.Source.Name).
WithHint("configure the credential source to return trusted scopes for user access tokens")
case credential.ScopeKnown:
// Continue below.
default:
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"credential source %q returned invalid scope inspection state %q", inspection.Source.Name, inspection.ScopeState)
}
suggestion := ""
missing := larkauth.MissingScopes(inspection.Scopes, required)
if inspection.Source.Managed {
if len(missing) > 0 {
suggestion = fmt.Sprintf("grant these scopes through credential source %s: %s", inspection.Source.Name, strings.Join(missing, " "))
}
} else {
suggestion = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " "))
}
return writeAuthCheckResult(f, required, inspection.Scopes, suggestion)
}
func writeAuthCheckResult(f *cmdutil.Factory, required []string, availableScopes, suggestion string) error {
missing := larkauth.MissingScopes(availableScopes, required)
missingSet := make(map[string]bool, len(missing))
for _, s := range missing {
missingSet[s] = true
@@ -82,8 +144,8 @@ func authCheckRun(opts *CheckOptions) error {
ok := len(missing) == 0
result := map[string]interface{}{"ok": ok, "granted": granted, "missing": missing}
if len(missing) > 0 {
result["suggestion"] = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " "))
if len(missing) > 0 && suggestion != "" {
result["suggestion"] = suggestion
}
output.PrintJson(f.IOStreams.Out, result)
if !ok {

View File

@@ -4,14 +4,20 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
larkauth "github.com/larksuite/cli/internal/auth"
"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/zalando/go-keyring"
)
@@ -146,6 +152,128 @@ func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) {
}
}
type authCheckExternalProvider struct {
token *extcred.Token
capabilities credential.ProviderCapabilities
resolveCalls int
}
func (p *authCheckExternalProvider) Name() string { return "external-check-test" }
func (p *authCheckExternalProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return &extcred.Account{AppID: "test-app", Brand: extcred.BrandFeishu}, nil
}
func (p *authCheckExternalProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
p.resolveCalls++
return p.token, nil
}
func (p *authCheckExternalProvider) CredentialCapabilities() credential.ProviderCapabilities {
return p.capabilities
}
func externalAuthCheckFactory(t *testing.T, canInspectScopes bool, token *extcred.Token) (*cmdutil.Factory, *authCheckExternalProvider) {
t.Helper()
cfg := &core.CliConfig{
AppID: "test-app",
Brand: core.BrandFeishu,
}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
provider := &authCheckExternalProvider{
token: token,
capabilities: credential.ProviderCapabilities{
ProvidesOnDemandAuth: true,
CanInspectScopes: canInspectScopes,
},
}
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{provider},
nil,
nil,
nil,
)
return f, provider
}
func TestAuthCheckRun_ExternalDirectUsesProviderScopes(t *testing.T) {
f, provider := externalAuthCheckFactory(t, true, &extcred.Token{
Value: "external-uat",
Scopes: "im:message docx:document",
})
stdout := f.IOStreams.Out.(*bytes.Buffer)
err := authCheckRun(&CheckOptions{
Factory: f,
Scope: "im:message",
})
if err != nil {
t.Fatalf("authCheckRun() error = %v", err)
}
if provider.resolveCalls != 1 {
t.Fatalf("ResolveToken calls = %d, want 1", provider.resolveCalls)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Fatalf("stdout.ok = %v, want true; payload=%v", payload["ok"], payload)
}
granted, ok := payload["granted"].([]any)
if !ok || len(granted) != 1 || granted[0] != "im:message" {
t.Fatalf("stdout.granted = %v, want [im:message]", payload["granted"])
}
}
func TestAuthCheckRun_ExternalProxyReturnsTypedUnknown(t *testing.T) {
f, provider := externalAuthCheckFactory(t, false, &extcred.Token{
Value: "proxy-placeholder",
})
err := authCheckRun(&CheckOptions{
Factory: f,
Scope: "im:message",
})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed error", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("problem = %#v, want validation/failed_precondition", problem)
}
var validation *errs.ValidationError
if !errors.As(err, &validation) || !strings.Contains(problem.Message, "unsupported") || validation.Param != "" || problem.Hint == "" {
t.Fatalf("problem = %#v, want explicit unsupported result with actionable hint and no param", problem)
}
if provider.resolveCalls != 0 {
t.Fatalf("ResolveToken calls = %d, want 0 for proxy scope check", provider.resolveCalls)
}
}
func TestAuthCheckRun_ExternalDirectWithoutScopeMetadataReturnsTypedUnknown(t *testing.T) {
f, _ := externalAuthCheckFactory(t, true, &extcred.Token{
Value: "external-uat",
})
err := authCheckRun(&CheckOptions{
Factory: f,
Scope: "im:message",
})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed error", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("problem = %#v, want validation/failed_precondition", problem)
}
var validation *errs.ValidationError
if !errors.As(err, &validation) || !strings.Contains(problem.Message, "unknown") || validation.Param != "" || problem.Hint == "" {
t.Fatalf("problem = %#v, want explicit unknown result with actionable hint and no param", problem)
}
}
func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) {
// Scope validation is a real input error, not a predicate negative
// answer — it must surface as a typed ValidationError with the normal

View File

@@ -56,6 +56,7 @@ For ASCII output, the result is printed to stdout with fixed size.`,
cmd.Flags().IntVar(&opts.Size, "size", 256, "Size of the QR code image in pixels (default: 256, for PNG mode only)")
cmd.Flags().BoolVar(&opts.ASCII, "ascii", false, "Output ASCII QR code to stdout")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path for PNG image (relative path within current directory, required for non-ASCII mode)")
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -44,6 +44,10 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
func authStatusRun(opts *StatusOptions) error {
f := opts.Factory
editionStatus, err := inspectEditionStatus(f)
if err != nil {
return err
}
config, err := f.Config()
if err != nil {
@@ -64,7 +68,9 @@ func authStatusRun(opts *StatusOptions) error {
result["identities"] = diagnostics
result["identity"] = effectiveIdentity(diagnostics)
addEffectiveVerification(result, diagnostics)
addStatusNote(result, diagnostics)
if !applyEditionStatus(result, diagnostics, editionStatus) {
addStatusNote(result, diagnostics)
}
output.PrintJson(f.IOStreams.Out, result)
return nil

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package auth
import (
"context"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/identitydiag"
)
type editionStatusState struct {
provider string
variant string
}
func inspectEditionStatus(f *cmdutil.Factory) (editionStatusState, error) {
if f == nil || f.Credential == nil {
return editionStatusState{}, nil
}
source, err := f.Credential.InspectSource(context.Background())
if err != nil {
return editionStatusState{}, err
}
if source == nil || !source.Managed {
return editionStatusState{}, nil
}
state := editionStatusState{provider: source.Name}
description := f.RuntimeDescription()
if description.Managed {
state.variant = description.Variant
}
return state, nil
}
func applyEditionStatus(result map[string]interface{}, diagnostics identitydiag.Result, state editionStatusState) bool {
if state.provider == "" {
return false
}
result["source"] = "external"
result["credentialProvider"] = state.provider
if state.variant != "" {
result["externalCredentialMode"] = state.variant
}
switch {
case !diagnostics.User.Available && diagnostics.Bot.Available:
result["note"] = "User identity is " + identitydiag.StatusMessage(diagnostics.User.Status) +
"; bot identity is ready. Update authorization through external credential provider " + state.provider + "."
case diagnostics.User.Status == identitydiag.StatusNeedsRefresh:
result["note"] = "User identity needs refresh. Check external credential provider " + state.provider + "."
case !diagnostics.User.Available && !diagnostics.Bot.Available:
result["note"] = "No usable identity is available. Check external credential provider " + state.provider + "."
}
return true
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package auth
import (
"encoding/json"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/runtimeplan"
)
func TestExtendedAuthStatusReportsManagedSource(t *testing.T) {
cfg := &core.CliConfig{
AppID: "cli_env", Brand: core.BrandFeishu, DefaultAs: core.AsBot,
SupportedIdentities: uint8(extcred.SupportsBot),
}
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&stubExternalProvider{name: "env"}},
nil, nil, f.HttpClient,
)
cmdutil.TestSetRuntimePlan(t, f, runtimeplan.New(runtimeplan.Options{
Description: runtimeplan.Description{
Managed: true,
Variant: "managed-test",
},
}))
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
t.Fatal(err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got["source"] != "external" ||
got["credentialProvider"] != "env" ||
got["externalCredentialMode"] != "managed-test" ||
got["identity"] != "bot" {
t.Fatalf("output = %#v", got)
}
if note, _ := got["note"].(string); strings.Contains(note, "auth login") ||
!strings.Contains(note, "external credential provider env") {
t.Fatalf("note = %q", note)
}
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package auth
import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/identitydiag"
)
type editionStatusState struct{}
func inspectEditionStatus(*cmdutil.Factory) (editionStatusState, error) {
return editionStatusState{}, nil
}
func applyEditionStatus(map[string]interface{}, identitydiag.Result, editionStatusState) bool {
return false
}

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package auth
import (
"encoding/json"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
func TestStandardAuthStatusPreservesExistingProjection(t *testing.T) {
cfg := &core.CliConfig{
AppID: "cli_env", Brand: core.BrandFeishu, DefaultAs: core.AsBot,
SupportedIdentities: uint8(extcred.SupportsBot),
}
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&stubExternalProvider{name: "env"}},
nil, nil, f.HttpClient,
)
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
t.Fatal(err)
}
var got map[string]json.RawMessage
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatal(err)
}
for _, field := range []string{"source", "credentialProvider", "externalCredentialMode"} {
if _, exists := got[field]; exists {
t.Fatalf("Standard auth status contains edition field %q: %s", field, stdout.String())
}
}
var note string
if err := json.Unmarshal(got["note"], &note); err != nil {
t.Fatal(err)
}
if !strings.Contains(note, "lark-cli auth login") {
t.Fatalf("Standard note = %q, want established login guidance", note)
}
}

View File

@@ -35,6 +35,15 @@ func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
if got.Identities.User.Status != "missing" || got.Identities.User.Available {
t.Fatalf("user = %#v, want missing and unavailable", got.Identities.User)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil {
t.Fatalf("json.Unmarshal(raw) error = %v", err)
}
for _, field := range []string{"source", "credentialProvider", "externalCredentialMode"} {
if _, exists := raw[field]; exists {
t.Fatalf("local auth status unexpectedly contains edition field %q: %s", field, stdout.String())
}
}
}
func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {

46
cmd/auth/testmain_test.go Normal file
View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/internal/registry/registrytest"
)
// TestMain isolates auth command tests from the host machine: config, logs
// and the registry cache are redirected to a temp dir, then the registry is
// seeded from the tracked fixture and initialized eagerly. Domain-completion
// tests read the registry, so without seeding a clean checkout would either
// fail or trigger a remote metadata fetch.
//
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
// m.Run before exiting.
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-cmd-auth-test-*")
if err != nil {
println("cmd/auth test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
println("cmd/auth test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil {
println("cmd/auth test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := registrytest.Seed(root); err != nil {
println("cmd/auth test setup: registrytest.Seed failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -29,6 +29,7 @@ import (
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/runtimebootstrap"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
@@ -45,6 +46,7 @@ type buildConfig struct {
skipService bool
serviceCatalog *apicatalog.Catalog
startupBrand core.LarkBrand
runtime *runtimebootstrap.Result
}
// WithStartupBrand initializes the API registry with the given brand before
@@ -58,6 +60,14 @@ func WithStartupBrand(brand core.LarkBrand) BuildOption {
}
}
// withRuntimeBootstrap shares one invocation snapshot across registry,
// credentials, transports, and command capabilities.
func withRuntimeBootstrap(runtime *runtimebootstrap.Result) BuildOption {
return func(c *buildConfig) {
c.runtime = runtime
}
}
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
// Terminal detection is delegated to cmdutil.NewIOStreams.
func WithIO(in io.Reader, out, errOut io.Writer) BuildOption {
@@ -143,9 +153,9 @@ func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOpti
return rootCmd
}
// buildInternal is a pure assembly function: it wires the command tree from
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
// env) belongs in the caller and must be threaded in via BuildOption.
// buildInternal assembles the command tree from one immutable startup
// configuration snapshot. Profile selection happens before any registry
// network decision and the same result is passed to the Factory.
//
// Returns (factory, rootCmd, registry). The registry is nil when plugin
// install failed (FailClosed guard installed) or when no plugin produced
@@ -168,13 +178,29 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
cfg.streams = cmdutil.SystemIO()
}
// Initialize the registry brand before anything touches the runtime
// catalog (its sync.Once would otherwise lock onto the Feishu default).
if cfg.startupBrand != "" {
registry.InitWithBrand(cfg.startupBrand)
startup := cfg.runtime
if startup == nil {
startup = runtimebootstrap.Resolve(inv.Profile)
}
f := cmdutil.NewDefault(cfg.streams, inv)
// Initialize the registry brand before anything touches the runtime
// catalog (its sync.Once would otherwise lock onto the Feishu default).
// Runtime policy can close direct metadata egress before any command is
// registered, without exposing a concrete credential mode here.
registryBrand := cfg.startupBrand
if registryBrand == "" {
registryBrand = resolveStartupBrandFromConfig(inv.Profile, startup.ProfileConfig)
}
if !startup.Plan.AllowsRemoteMetadata() {
if registryBrand == "" {
registryBrand = core.BrandFeishu
}
registry.InitEmbeddedWithBrand(registryBrand)
} else if registryBrand != "" {
registry.InitWithBrand(registryBrand)
}
f := cmdutil.NewDefaultWithRuntimePlan(cfg.streams, inv, startup.ProfileConfig, startup.Plan)
if cfg.keychain != nil {
f.Keychain = cfg.keychain
}
@@ -220,6 +246,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
registerEditionCommands(rootCmd, f)
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(skill.NewCmdSkill(f))
if !cfg.skipService {

176
cmd/build_workspace_test.go Normal file
View File

@@ -0,0 +1,176 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"io"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
func TestStartupProfileSnapshotUsesDetectedWorkspace(t *testing.T) {
previousWorkspace := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(previousWorkspace) })
tests := []struct {
name string
workspace core.Workspace
signalName string
signalValue string
expectedAppID string
expectedBrand core.LarkBrand
}{
{
name: "local",
workspace: core.WorkspaceLocal,
expectedAppID: "cli_local",
expectedBrand: core.BrandFeishu,
},
{
name: "openclaw",
workspace: core.WorkspaceOpenClaw,
signalName: "OPENCLAW_CLI",
signalValue: "1",
expectedAppID: "cli_openclaw",
expectedBrand: core.BrandLark,
},
{
name: "hermes",
workspace: core.WorkspaceHermes,
signalName: "HERMES_HOME",
signalValue: "/managed/hermes",
expectedAppID: "cli_hermes",
expectedBrand: core.BrandLark,
},
{
name: "lark_channel",
workspace: core.WorkspaceLarkChannel,
signalName: "LARK_CHANNEL",
signalValue: "1",
expectedAppID: "cli_lark_channel",
expectedBrand: core.BrandLark,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clearWorkspaceSignals(t)
clearCredentialSignals(t)
configRoot := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configRoot)
t.Setenv(envvars.CliExternalCredentialConfig,
filepath.Join(configRoot, "missing-external-credential.json"))
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
if tt.signalName != "" {
t.Setenv(tt.signalName, tt.signalValue)
}
writeWorkspaceProfile(t, core.WorkspaceLocal, "local", "cli_local", core.BrandFeishu)
if !tt.workspace.IsLocal() {
writeWorkspaceProfile(t, tt.workspace, tt.name, tt.expectedAppID, tt.expectedBrand)
}
// Execute resolves the registry brand before entering
// buildInternal. Pin that ordering independently.
core.SetCurrentWorkspace(core.WorkspaceLocal)
if got := selectInvocationWorkspace(); got != tt.workspace {
t.Fatalf("selected workspace = %q, want %q", got, tt.workspace)
}
if got := ResolveStartupBrand(""); got != tt.expectedBrand {
t.Fatalf("startup brand = %q, want %q", got, tt.expectedBrand)
}
// Build/buildInternal is also a public construction path. Reset the
// process state to local so the test proves it establishes the
// workspace before SelectProfile captures the immutable snapshot.
core.SetCurrentWorkspace(core.WorkspaceLocal)
factory, _, _ := buildInternal(
context.Background(),
cmdutil.InvocationContext{},
WithIO(strings.NewReader(""), io.Discard, io.Discard),
WithoutPlugins(),
WithoutServiceCommands(),
)
if got := core.CurrentWorkspace(); got != tt.workspace {
t.Fatalf("workspace after build = %q, want %q", got, tt.workspace)
}
config, err := factory.Config()
if err != nil {
t.Fatalf("Factory.Config() error = %v", err)
}
if config.AppID != tt.expectedAppID || config.Brand != tt.expectedBrand {
t.Fatalf("resolved config = app %q (%s), want app %q (%s)",
config.AppID, config.Brand, tt.expectedAppID, tt.expectedBrand)
}
})
}
}
func clearWorkspaceSignals(t *testing.T) {
t.Helper()
for _, name := range []string{
"OPENCLAW_CLI",
"OPENCLAW_HOME",
"OPENCLAW_STATE_DIR",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_SERVICE_MARKER",
"OPENCLAW_SERVICE_VERSION",
"OPENCLAW_GATEWAY_PORT",
"OPENCLAW_SHELL",
"HERMES_HOME",
"HERMES_QUIET",
"HERMES_EXEC_ASK",
"HERMES_GATEWAY_TOKEN",
"HERMES_SESSION_KEY",
"LARK_CHANNEL",
} {
t.Setenv(name, "")
}
}
func clearCredentialSignals(t *testing.T) {
t.Helper()
for _, name := range []string{
envvars.CliAppID,
envvars.CliAppSecret,
envvars.CliBrand,
envvars.CliUserAccessToken,
envvars.CliTenantAccessToken,
envvars.CliDefaultAs,
envvars.CliStrictMode,
} {
t.Setenv(name, "")
}
}
func writeWorkspaceProfile(
t *testing.T,
workspace core.Workspace,
name string,
appID string,
brand core.LarkBrand,
) {
t.Helper()
previous := core.CurrentWorkspace()
core.SetCurrentWorkspace(workspace)
defer core.SetCurrentWorkspace(previous)
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: name,
Apps: []core.AppConfig{{
Name: name,
AppId: appID,
AppSecret: core.PlainSecret("test-secret-" + name),
Brand: brand,
Users: []core.AppUser{},
}},
}); err != nil {
t.Fatalf("save %s workspace profile: %v", workspace.Display(), err)
}
}

View File

@@ -6,6 +6,7 @@ package config
import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/runtimeplan"
"github.com/spf13/cobra"
)
@@ -19,21 +20,38 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
// PersistentPreRun[E] found walking up the chain, so the root-level
// SilenceUsage=true would be skipped without this line.
cmd.SilenceUsage = true
// Pass "config" as a literal — cmd.Name() would return the subcommand name.
return f.RequireBuiltinCredentialProvider(cmd.Context(), "config")
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityLocalCredentialManagement)
cmd.AddCommand(NewCmdConfigInit(f, nil))
cmd.AddCommand(NewCmdConfigBind(f, nil))
cmd.AddCommand(NewCmdConfigRemove(f, nil))
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
initCmd := NewCmdConfigInit(f, nil)
bind := NewCmdConfigBind(f, nil)
remove := NewCmdConfigRemove(f, nil)
show := NewCmdConfigShow(f, nil)
defaultAs := NewCmdConfigDefaultAs(f)
strictMode := NewCmdConfigStrictMode(f)
riskControl := NewCmdConfigRiskControl(f)
policy := NewCmdConfigPolicy(f)
plugins := NewCmdConfigPlugins(f)
keychainDowngrade := NewCmdConfigKeychainDowngrade(f)
// Identity preferences live in the Profile, but external providers have
// historically treated these config commands as credential management.
// Check Profile ownership first so a managed runtime gives the actionable
// deployment-managed Profile error, then retain the credential capability
// so Standard external-provider behavior stays unchanged.
for _, identitySetting := range []*cobra.Command{defaultAs, strictMode} {
cmdutil.SetRuntimeCapabilities(
identitySetting,
runtimeplan.CapabilityLocalProfileMutation,
runtimeplan.CapabilityLocalCredentialManagement,
)
}
for _, sourceNeutral := range []*cobra.Command{show, riskControl, policy, plugins} {
cmdutil.SetRuntimeCapabilities(sourceNeutral)
}
cmd.AddCommand(initCmd, bind, remove, show, defaultAs, strictMode, riskControl, policy, plugins, keychainDowngrade)
return cmd
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/runtimeplan"
)
type noopConfigKeychain struct{}
@@ -452,10 +453,16 @@ func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
}
// stubConfigExtProvider simulates env/sidecar credential mode for config guard tests.
type stubConfigExtProvider struct{ name string }
type stubConfigExtProvider struct {
name string
err error
}
func (s *stubConfigExtProvider) Name() string { return s.name }
func (s *stubConfigExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
if s.err != nil {
return nil, s.err
}
return &extcred.Account{AppID: "test-app"}, nil
}
func (s *stubConfigExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
@@ -481,7 +488,6 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
}{
{"init", []string{"init", "--app-id", "x", "--app-secret-stdin"}},
{"remove", []string{"remove"}},
{"show", []string{"show"}},
{"default-as", []string{"default-as", "user"}},
{"strict-mode", []string{"strict-mode", "off"}},
}
@@ -509,6 +515,63 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
}
}
func TestConfigIdentityCommandsCheckProfileOwnershipBeforeCredentialOwnership(t *testing.T) {
profileDenied := errors.New("Profile identity settings are deployment-managed")
credentialChecks := 0
plan := runtimeplan.New(runtimeplan.Options{
Capabilities: func(capability runtimeplan.Capability) error {
switch capability {
case runtimeplan.CapabilityLocalProfileMutation:
return profileDenied
case runtimeplan.CapabilityLocalCredentialManagement:
credentialChecks++
}
return nil
},
})
for _, args := range [][]string{
{"default-as", "bot"},
{"strict-mode", "bot"},
} {
t.Run(args[0], func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, nil, plan)
cmd := NewCmdConfig(f)
cmd.SetArgs(args)
err := cmd.Execute()
if !errors.Is(err, profileDenied) {
t.Fatalf("Execute(%v) error = %v, want Profile ownership denial", args, err)
}
})
}
if credentialChecks != 0 {
t.Fatalf("credential capability checked %d times after Profile denial, want 0", credentialChecks)
}
}
func TestConfigIdentityCommandsRetainCredentialOwnershipCapability(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
root := NewCmdConfig(f)
for _, name := range []string{"default-as", "strict-mode"} {
t.Run(name, func(t *testing.T) {
leaf, _, err := root.Find([]string{name})
if err != nil {
t.Fatal(err)
}
got := cmdutil.GetRuntimeCapabilities(leaf)
want := []runtimeplan.Capability{
runtimeplan.CapabilityLocalProfileMutation,
runtimeplan.CapabilityLocalCredentialManagement,
}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("%s capabilities = %v, want %v", name, got, want)
}
})
}
}
// TestValidateInitLang covers the --lang contract: empty (omitted or explicit)
// is a no-op leaving Lang unset; a short code or Feishu locale canonicalizes to
// the same locale; an unrecognized value errors.

View File

@@ -27,13 +27,6 @@ func NewCmdConfigPlugins(f *cmdutil.Factory) *cobra.Command {
Use: "plugins",
Hidden: true, // diagnostic-only; kept callable, omitted from --help so it stays out of AI-agent context
Short: "Inspect installed plugins and their hook contributions",
// Same leaf-level no-op as config policy: the parent `config`
// group's PersistentPreRunE requires builtin credential, but
// this is a read-only diagnostic that must work everywhere.
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return nil
},
}
cmd.AddCommand(newCmdConfigPluginsShow(f))
return cmd

View File

@@ -16,12 +16,6 @@ func NewCmdConfigPolicy(f *cmdutil.Factory) *cobra.Command {
Use: "policy",
Hidden: true,
Short: "Inspect the user-layer command policy",
// Override parent's RequireBuiltinCredentialProvider check; this
// group is read-only diagnostic and must work under any provider.
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return nil
},
}
cmd.AddCommand(newCmdConfigPolicyShow(f))
return cmd

View File

@@ -132,19 +132,16 @@ func TestConfigPolicyShow_YamlSourceNameIsEmpty(t *testing.T) {
}
}
// Regression: the parent `config` command declares a PersistentPreRunE
// that calls RequireBuiltinCredentialProvider; env credentials cause
// it to return external_provider. `config policy` is a diagnostic
// group that must not be blocked by that check. The group declares
// its own no-op PersistentPreRunE so cobra's "first walking up from
// leaf" picks ours over the config parent's.
func TestConfigPolicy_BypassesConfigParentPersistentPreRunE(t *testing.T) {
// The policy group explicitly overrides the config parent's local credential
// management capability because it is source-neutral diagnostics.
func TestConfigPolicyOverridesCredentialManagementCapability(t *testing.T) {
f, _, _ := newPolicyTestFactory()
group := NewCmdConfigPolicy(f)
if group.PersistentPreRunE == nil {
t.Fatal("config policy group must declare its own PersistentPreRunE to win over config parent")
root := NewCmdConfig(f)
leaf, _, err := root.Find([]string{"policy", "show"})
if err != nil {
t.Fatal(err)
}
if err := group.PersistentPreRunE(group, nil); err != nil {
t.Errorf("config policy PersistentPreRunE should be no-op, got %v", err)
if capabilities := cmdutil.GetRuntimeCapabilities(leaf); len(capabilities) != 0 {
t.Fatalf("policy capabilities = %v, want source-neutral", capabilities)
}
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "risk-control [on|off|default]",
Short: "Manage workspace account-protection policy",
Long: `View or set the account-protection risk-control policy for this workspace.
Account protection is on by default. Use off to opt this workspace out, on to
opt it back in explicitly, or default to remove the explicit preference.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
config, err := core.LoadOrNotConfigured()
if err != nil {
return err
}
if len(args) == 0 {
printRiskControl(f, config)
return nil
}
switch args[0] {
case "on":
enabled := true
config.RiskControl = &enabled
case "off":
enabled := false
config.RiskControl = &enabled
case "default":
config.RiskControl = nil
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid risk-control value %q, valid values: on | off | default", args[0])
}
if err := core.SaveMultiAppConfig(config); err != nil {
return errs.NewInternalError(errs.SubtypeStorage,
"failed to save risk-control policy: %v", err).WithCause(err)
}
fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
return nil
},
}
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
return cmd
}
func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) {
source := "default"
if config.RiskControl != nil {
source = "workspace"
}
fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
}
func riskControlState(enabled bool) string {
if enabled {
return "on"
}
return "off"
}

View File

@@ -0,0 +1,130 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
func TestRiskControlWorkspacePolicy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
if !strings.Contains(stderr.String(), "set to off") {
t.Fatalf("stderr = %q", stderr.String())
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show: %v", err)
}
if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
t.Fatalf("stdout = %q", got)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"on"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set on: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || !*loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"default"})
if err := cmd.Execute(); err != nil {
t.Fatalf("reset default: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl != nil {
t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show default: %v", err)
}
if got := stdout.String(); got != "risk-control: on (source: default)\n" {
t.Fatalf("stdout = %q", got)
}
}
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"invalid"})
err := cmd.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
}
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
cmd := NewCmdConfig(f)
cmd.SetArgs([]string{"risk-control", "off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off with external credentials: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
}

View File

@@ -42,6 +42,16 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
func configShowRun(opts *ConfigShowOptions) error {
f := opts.Factory
// config show describes the effective invocation configuration, not merely
// the bytes in config.json. Preserve the typed bootstrap failure so a
// Standard binary cannot present a local Profile as active when the system
// requires Extended runtime support.
if startupErr := f.RuntimeStartupError(); startupErr != nil {
return startupErr
}
if handled, editionErr := showEditionConfig(f); handled {
return editionErr
}
config, err := core.LoadMultiAppConfig()
if err != nil {

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package config
import (
"context"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
type editionConfigShowResult struct {
Source string `json:"source"`
CredentialProvider string `json:"credentialProvider"`
Manageable bool `json:"manageable"`
Workspace string `json:"workspace"`
AppID string `json:"appId"`
Brand string `json:"brand"`
DefaultAs string `json:"defaultAs"`
Profile *string `json:"profile,omitempty"`
ExternalCredentialMode *string `json:"externalCredentialMode,omitempty"`
RemoteEndpoint *string `json:"remoteEndpoint,omitempty"`
}
func showEditionConfig(f *cmdutil.Factory) (bool, error) {
if f == nil || f.Credential == nil {
return false, nil
}
source, err := f.Credential.InspectSource(context.Background())
if err != nil {
return true, typedEditionProviderError("determine the active credential provider", err)
}
if source == nil || !source.Managed {
return false, nil
}
if source.AppID == "" {
return true, errs.NewInternalError(errs.SubtypeInvalidResponse,
"external credential provider %q returned no account", source.Name)
}
result := editionConfigShowResult{
Source: "external",
CredentialProvider: source.Name,
Manageable: false,
Workspace: core.CurrentWorkspace().Display(),
AppID: source.AppID,
Brand: string(source.Brand),
DefaultAs: string(source.DefaultAs),
}
description := f.RuntimeDescription()
if source.ProfileName != "" {
result.Profile = &source.ProfileName
}
if description.Managed && description.Variant != "" {
result.ExternalCredentialMode = &description.Variant
if description.ProxiesRequests {
result.RemoteEndpoint = &description.DataPlaneEndpoint
}
}
output.PrintJson(f.IOStreams.Out, result)
return true, nil
}
func typedEditionProviderError(action string, err error) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown, "failed to %s: %v", action, err).WithCause(err)
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package config
import (
"bytes"
"encoding/json"
"errors"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/runtimeplan"
)
func TestExtendedConfigShowAllowedWithManagedSource(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
cmd := NewCmdConfig(f)
matched, _, err := cmd.Find([]string{"show"})
if err != nil {
t.Fatal(err)
}
if err := cmd.PersistentPreRunE(matched, nil); err != nil {
t.Fatalf("config show blocked: %v", err)
}
}
func TestExtendedConfigShowProjectsManagedSource(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
var stdout bytes.Buffer
f.IOStreams.Out = &stdout
cmdutil.TestSetRuntimePlan(t, f, runtimeplan.New(runtimeplan.Options{
Description: runtimeplan.Description{
Managed: true,
Variant: "managed-test",
ProxiesRequests: true,
DataPlaneEndpoint: "https://managed.example.test",
},
}))
if err := configShowRun(&ConfigShowOptions{Factory: f}); err != nil {
t.Fatalf("configShowRun() error = %v", err)
}
var got editionConfigShowResult
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Source != "external" ||
got.CredentialProvider != "env" ||
got.Manageable ||
got.AppID != "test-app" ||
got.ExternalCredentialMode == nil ||
*got.ExternalCredentialMode != "managed-test" ||
got.RemoteEndpoint == nil ||
*got.RemoteEndpoint != "https://managed.example.test" {
t.Fatalf("output = %#v", got)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(stdout.Bytes(), &fields); err != nil {
t.Fatal(err)
}
if _, ok := fields["appSecret"]; ok {
t.Fatalf("managed output must not invent appSecret: %s", stdout.String())
}
if _, ok := fields["users"]; ok {
t.Fatalf("managed output must not invent users: %s", stdout.String())
}
}
func TestExtendedConfigShowTypesManagedSourceFailure(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
providerErr := errors.New("provider failed")
cred := credential.NewCredentialProvider(
[]extcred.Provider{&stubConfigExtProvider{name: "broken", err: providerErr}},
nil, nil, nil,
)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.Credential = cred
err := configShowRun(&ConfigShowOptions{Factory: f})
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("error = %#v, want internal/unknown", err)
}
if !errors.Is(err, providerErr) {
t.Fatalf("error does not preserve provider failure: %v", err)
}
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package config
import "github.com/larksuite/cli/internal/cmdutil"
func showEditionConfig(*cmdutil.Factory) (bool, error) {
return false, nil
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package config
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/runtimeplan"
)
func TestStandardConfigShowPreservesLocalConfigPath(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
err := configShowRun(&ConfigShowOptions{Factory: f})
problem, ok := errs.ProblemOf(err)
if !ok ||
problem.Category != errs.CategoryConfig ||
problem.Subtype != errs.SubtypeNotConfigured {
t.Fatalf("error = %#v, want established config/not_configured result", err)
}
}
func TestStandardConfigShowReturnsTypedRuntimeStartupError(t *testing.T) {
startupErr := errs.NewValidationError(
errs.SubtypeFailedPrecondition,
"system external credential configuration requires the lark-cli Extended edition",
).WithHint("install lark-cli Extended or ask the administrator to remove external-credential.json")
f, stdout, _, _ := cmdutil.TestFactoryWithRuntimePlan(
t,
nil,
runtimeplan.Failed(startupErr, runtimeplan.MetadataEmbeddedOnly),
)
err := configShowRun(&ConfigShowOptions{Factory: f})
if !errors.Is(err, startupErr) {
t.Fatalf("config show error = %v, want original startup error", err)
}
problem, ok := errs.ProblemOf(err)
if !ok ||
problem.Category != errs.CategoryValidation ||
problem.Subtype != errs.SubtypeFailedPrecondition ||
problem.Message != "system external credential configuration requires the lark-cli Extended edition" {
t.Fatalf("config show problem = %#v, want typed Extended-required startup failure", problem)
}
if stdout.Len() != 0 {
t.Fatalf("config show wrote local Profile after bootstrap failure: %s", stdout.String())
}
}

View File

@@ -84,6 +84,10 @@ func doctorRun(opts *DoctorOptions) error {
checks = append(checks, checkCLIUpdate()...)
}
if handled, editionErr := runEditionDoctor(opts, checks); handled {
return editionErr
}
// ── 1. Config file ──
_, err := core.LoadMultiAppConfig()
if err != nil {
@@ -130,8 +134,7 @@ func doctorRun(opts *DoctorOptions) error {
checks = append(checks, pass("identity_ready", "at least one identity is available"))
} else {
// No hint: this only summarizes the two checks above, which already carry
// the source-appropriate remediation. A command here would be redundant,
// or wrong (`auth status` is blocked under an external provider).
// the source-appropriate remediation. A command here would be redundant.
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
}
@@ -215,7 +218,7 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error {
// Unlike the root-level async check, this does a synchronous fetch with timeout
// and works regardless of build version (dev builds included).
func checkCLIUpdate() []checkResult {
latest, err := update.FetchLatest()
latest, err := fetchLatestForEdition()
if err != nil {
return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")}
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package doctor
import (
"errors"
"fmt"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
)
func runEditionDoctor(opts *DoctorOptions, checks []checkResult) (bool, error) {
f := opts.Factory
if f == nil || f.Credential == nil {
return false, nil
}
source, err := f.Credential.InspectSource(opts.Ctx)
if err != nil {
checks = append(checks, fail("credential_source", err.Error(), editionDiagnosticErrorHint(err)))
return true, finishDoctor(f, checks)
}
if source == nil || !source.Managed {
return false, nil
}
provider := source.Name
cfg, err := f.Config()
if err != nil {
checks = append(checks,
fail("credential_source", err.Error(), editionDiagnosticErrorHint(err)),
skip("config_file", fmt.Sprintf("local credentials are not used; source is %s", provider)),
)
return true, finishDoctor(f, checks)
}
checks = append(checks, pass("credential_source",
fmt.Sprintf("credentials provided by %s (app %s; token not verified by this check)", provider, cfg.AppID)))
description := f.RuntimeDescription()
if description.Managed {
checks = append(checks, pass("config_file", "config.json found (system external credential mode)"))
} else {
checks = append(checks, skip("config_file",
fmt.Sprintf("local config not used; credentials provided by %s", provider)))
}
checks = append(checks, pass("app_resolved", fmt.Sprintf("app: %s (%s)", cfg.AppID, cfg.Brand)))
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
checks = append(checks,
identityCheck("bot_identity", diagnostics.Bot),
identityCheck("user_identity", diagnostics.User),
)
if diagnostics.Bot.Available || diagnostics.User.Available {
checks = append(checks, pass("identity_ready", "at least one identity is available"))
} else {
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
}
if description.ProxiesRequests {
checks = append(checks, editionProxyNetworkCheck(opts, description.DataPlaneEndpoint, diagnostics))
} else {
checks = append(checks, networkChecks(opts.Ctx, opts, core.ResolveEndpoints(cfg.Brand))...)
}
return true, finishDoctor(f, checks)
}
func editionDiagnosticErrorHint(err error) string {
var blockErr *extcred.BlockError
if errors.As(err, &blockErr) {
return blockErr.Reason
}
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
return cfgErr.Hint
}
return ""
}
func editionProxyNetworkCheck(opts *DoctorOptions, endpoint string, diagnostics identitydiag.Result) checkResult {
if opts.Offline {
return skip("endpoint_external_platform", "skipped (--offline)")
}
verified := func(id identitydiag.Identity) bool { return id.Verified != nil && *id.Verified }
if verified(diagnostics.User) || verified(diagnostics.Bot) {
return pass("endpoint_external_platform", endpoint+" reachable through an authenticated API request")
}
return fail("endpoint_external_platform", endpoint+" could not complete an authenticated API request",
"check the external credential program and platform logs")
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package doctor
import (
"context"
"encoding/json"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/identitydiag"
)
func TestExtendedProxyNetworkCheckUsesAuthenticatedDiagnostics(t *testing.T) {
verified := true
endpoint := "https://credentials.example.com"
got := editionProxyNetworkCheck(&DoctorOptions{}, endpoint, identitydiag.Result{
User: identitydiag.Identity{Verified: &verified},
})
if got.Status != "pass" || got.Name != "endpoint_external_platform" {
t.Fatalf("check = %#v", got)
}
got = editionProxyNetworkCheck(&DoctorOptions{}, endpoint, identitydiag.Result{})
if got.Status != "fail" {
t.Fatalf("unverified check = %#v, want fail", got)
}
}
func TestExtendedDoctorManagedSourceDoesNotRequireLocalConfig(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "cli_env", Brand: core.BrandFeishu,
SupportedIdentities: uint8(extcred.SupportsBot), DefaultAs: core.AsBot,
}
f, out, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{
name: "env",
account: &extcred.Account{
AppID: "cli_env",
SupportedIdentities: extcred.SupportsBot,
},
}},
nil, nil, nil,
)
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err != nil {
t.Fatalf("doctorRun() error = %v", err)
}
var got struct {
OK bool `json:"ok"`
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.OK {
t.Fatalf("checks = %#v", got.Checks)
}
assertCheck(t, got.Checks, "credential_source", "pass")
configCheck := findCheck(t, got.Checks, "config_file")
if configCheck.Status != "skip" ||
!strings.Contains(configCheck.Message, "local config") ||
strings.Contains(configCheck.Message, "config init") {
t.Fatalf("config_file = %#v", configCheck)
}
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package doctor
import "github.com/larksuite/cli/errs"
func runEditionDoctor(opts *DoctorOptions, checks []checkResult) (bool, error) {
if opts == nil || opts.Factory == nil {
return false, nil
}
startupErr := opts.Factory.RuntimeStartupError()
if startupErr == nil {
return false, nil
}
hint := ""
if problem, ok := errs.ProblemOf(startupErr); ok {
hint = problem.Hint
}
checks = append(checks, fail("credential_source", startupErr.Error(), hint))
return true, finishDoctor(opts.Factory, checks)
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package doctor
import (
"context"
"encoding/json"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
func TestStandardDoctorPreservesConfigFirstDiagnostics(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "cli_env", Brand: core.BrandFeishu,
SupportedIdentities: uint8(extcred.SupportsBot), DefaultAs: core.AsBot,
}
f, out, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{
name: "env",
account: &extcred.Account{
AppID: "cli_env",
SupportedIdentities: extcred.SupportsBot,
},
}},
nil, nil, nil,
)
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
t.Fatal("doctorRun() = nil, want established missing-config failure")
}
var got struct {
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatal(err)
}
assertCheck(t, got.Checks, "config_file", "fail")
for _, check := range got.Checks {
if check.Name == "credential_source" {
t.Fatalf("Standard doctor exposed edition diagnostic: %#v", got.Checks)
}
}
}

View File

@@ -4,7 +4,6 @@
package doctor
import (
"bytes"
"context"
"encoding/json"
"net/http"
@@ -175,6 +174,44 @@ func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*ext
return nil, nil
}
type failingDefaultAccountResolver struct {
err error
}
func (r *failingDefaultAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
return nil, r.err
}
func TestDoctor_DefaultResolutionFailurePreservesConfigFileCheck(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, out, _, _ := cmdutil.TestFactory(t, nil)
f.Credential = credential.NewCredentialProvider(
nil,
&failingDefaultAccountResolver{err: core.NotConfiguredError()},
nil,
nil,
)
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
t.Fatal("doctorRun() = nil, want not-configured failure")
}
var got struct {
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String())
}
configCheck := findCheck(t, got.Checks, "config_file")
if configCheck.Status != "fail" {
t.Fatalf("config_file = %#v, want fail", configCheck)
}
for _, check := range got.Checks {
if check.Name == "credential_source" {
t.Fatalf("default source resolution replaced the legacy config check: %#v", got.Checks)
}
}
}
// Under an external credential provider with no usable identity, the
// identity_ready hint must not point at `auth status` (blocked there); the
// per-identity checks already carry the source-appropriate escalation.
@@ -195,12 +232,8 @@ func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T
nil, nil,
func() (*http.Client, error) { return nil, nil },
)
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
f, out, _, _ := cmdutil.TestFactory(t, cfg)
f.Credential = cred
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
t.Fatalf("doctorRun() = nil, want failure when no identity is available")

View File

@@ -0,0 +1,12 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package doctor
import "github.com/larksuite/cli/internal/extendedupdate"
func fetchLatestForEdition() (string, error) {
return extendedupdate.FetchLatest()
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package doctor
import "github.com/larksuite/cli/internal/update"
func fetchLatestForEdition() (string, error) {
return update.FetchLatest()
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmd
import (
"bytes"
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs"
)
func TestStandardDoctorReportsEditionSentinelWithoutLocalProfile(t *testing.T) {
clearWorkspaceSignals(t)
clearCredentialSignals(t)
configDir := t.TempDir()
systemPath := filepath.Join(configDir, "external-credential.json")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
t.Setenv(envvars.CliExternalCredentialConfig, systemPath)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
t.Setenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
if err := vfs.WriteFile(systemPath, []byte("sentinel-only"), 0o600); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
root := Build(
context.Background(),
cmdutil.InvocationContext{},
WithIO(strings.NewReader(""), &stdout, &stderr),
WithoutPlugins(),
WithoutServiceCommands(),
)
root.SetArgs([]string{"doctor", "--offline"})
if err := root.ExecuteContext(context.Background()); err == nil {
t.Fatal("doctor returned nil, want failed diagnostic result")
}
var report struct {
Checks []struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
Hint string `json:"hint"`
} `json:"checks"`
}
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
t.Fatalf("decode doctor output: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String())
}
for _, check := range report.Checks {
if check.Name != "credential_source" {
continue
}
if check.Status != "fail" ||
check.Message != "system external credential configuration requires the lark-cli Extended edition" ||
!strings.Contains(check.Hint, "install lark-cli Extended") {
t.Fatalf("credential_source check = %#v", check)
}
if strings.Contains(stdout.String(), "config init") {
t.Fatalf("doctor suggested local credential bootstrap for an edition sentinel: %s", stdout.String())
}
return
}
t.Fatalf("doctor did not report the edition sentinel: %s", stdout.String())
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmd
import (
cmdversion "github.com/larksuite/cli/cmd/version"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// registerEditionCommands owns the Extended-only command surface.
func registerEditionCommands(root *cobra.Command, f *cmdutil.Factory) {
root.AddCommand(cmdversion.NewCmdVersion(f))
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmd
import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
func TestExtendedRegistersVersionCommand(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
root := &cobra.Command{Use: "lark-cli"}
registerEditionCommands(root, f)
commands := root.Commands()
if len(commands) != 1 || commands[0].Name() != "version" {
t.Fatalf("Extended edition commands = %v, want [version]", commands)
}
if commands[0].Hidden {
t.Fatal("Extended version command must be visible")
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmd
import (
cmdversion "github.com/larksuite/cli/cmd/version"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// registerEditionCommands keeps the release identity probe callable in
// Standard while cmd/version hides it from help. This preserves the ordinary
// command surface and gives installers/CI one edition-neutral verification
// contract.
func registerEditionCommands(root *cobra.Command, f *cmdutil.Factory) {
root.AddCommand(cmdversion.NewCmdVersion(f))
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmd
import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
func TestStandardRegistersHiddenVersionCommand(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
root := &cobra.Command{Use: "lark-cli"}
registerEditionCommands(root, f)
commands := root.Commands()
if len(commands) != 1 || commands[0].Name() != "version" {
t.Fatalf("Standard edition commands = %v, want [version]", commands)
}
if !commands[0].Hidden {
t.Fatal("Standard version command must remain hidden")
}
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/runtimeplan"
)
func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
@@ -16,14 +17,31 @@ func NewCmdEvents(f *cmdutil.Factory) *cobra.Command {
Long: `Unified event consumption system. Use 'event consume <EventKey>' to start consuming events.`,
// Without SilenceUsage, RunE errors print the full flag help banner.
SilenceUsage: true,
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cmd.SilenceUsage = true
// This hook shadows root's PersistentPreRun, so preserve the matched
// command for structured error and declared-scope hints.
f.CurrentCommand = cmd
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
},
}
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityRealtimeEvents)
cmd.AddCommand(NewCmdConsume(f))
cmd.AddCommand(NewCmdList(f))
cmd.AddCommand(NewCmdSchema(f))
cmd.AddCommand(NewCmdStatus(f))
cmd.AddCommand(NewCmdStop(f))
cmd.AddCommand(NewCmdBus(f))
consume := NewCmdConsume(f)
bus := NewCmdBus(f)
list := NewCmdList(f)
schema := NewCmdSchema(f)
status := NewCmdStatus(f)
stop := NewCmdStop(f)
for _, local := range []*cobra.Command{list, schema, status, stop} {
cmdutil.SetRuntimeCapabilities(local)
}
cmd.AddCommand(consume)
cmd.AddCommand(list)
cmd.AddCommand(schema)
cmd.AddCommand(status)
cmd.AddCommand(stop)
cmd.AddCommand(bus)
return cmd
}

View File

@@ -0,0 +1,146 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"errors"
"io/fs"
"path/filepath"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/runtimeplan"
"github.com/larksuite/cli/internal/vfs"
)
func TestEventCommandsRejectDeniedRuntimeCapability(t *testing.T) {
cfg := &core.CliConfig{
AppID: "cli_runtime_event_test",
AppSecret: "must-not-be-used",
Brand: core.BrandFeishu,
}
denied := errs.NewValidationError(errs.SubtypeFailedPrecondition,
"real-time events are unavailable in this runtime").
WithHint("use a runtime that supports real-time events")
plan := runtimeplan.New(runtimeplan.Options{
Capabilities: func(capability runtimeplan.Capability) error {
if capability == runtimeplan.CapabilityRealtimeEvents {
return denied
}
return nil
},
})
t.Run("consume", func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, cfg, plan)
cmd := NewCmdEvents(f)
args := []string{"consume", "guarded-before-event-lookup"}
matched, _, err := cmd.Find(args)
if err != nil {
t.Fatalf("Find() error = %v", err)
}
cmd.SetArgs(args)
requireExternalEventGuard(t, cmd.Execute())
if f.CurrentCommand != matched {
t.Fatalf("CurrentCommand = %v, want matched command %v", f.CurrentCommand, matched)
}
})
t.Run("bus", func(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, cfg, plan)
cmd := NewCmdEvents(f)
args := []string{"_bus"}
matched, _, err := cmd.Find(args)
if err != nil {
t.Fatalf("Find() error = %v", err)
}
cmd.SetArgs(args)
requireExternalEventGuard(t, cmd.Execute())
if f.CurrentCommand != matched {
t.Fatalf("CurrentCommand = %v, want matched command %v", f.CurrentCommand, matched)
}
if _, err := vfs.Stat(filepath.Join(configDir, "events")); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("event bus created runtime files before guard: %v", err)
}
})
}
func TestEventCommandRuntimeCapabilityMatrix(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdEvents(f)
parentCapabilities := cmdutil.GetRuntimeCapabilities(cmd)
if len(parentCapabilities) != 1 || parentCapabilities[0] != runtimeplan.CapabilityRealtimeEvents {
t.Fatalf("event capabilities = %v, want [%s]", parentCapabilities, runtimeplan.CapabilityRealtimeEvents)
}
wantRealtime := map[string]bool{
"_bus": true,
"consume": true,
"list": false,
"schema": false,
"status": false,
"stop": false,
}
children := make(map[string]*cobra.Command, len(wantRealtime))
for _, child := range cmd.Commands() {
name := child.Name()
want, ok := wantRealtime[name]
if !ok {
t.Fatalf("event command %q is missing from the runtime capability matrix", name)
}
children[name] = child
got := cmdutil.GetRuntimeCapabilities(child)
if want {
if len(got) != 1 || got[0] != runtimeplan.CapabilityRealtimeEvents {
t.Errorf("event %s capabilities = %v, want [%s]", name, got, runtimeplan.CapabilityRealtimeEvents)
}
continue
}
if len(got) != 0 {
t.Errorf("event %s capabilities = %v, want source-neutral local command", name, got)
}
}
if len(children) != len(wantRealtime) {
t.Fatalf("event command matrix covered %d commands, want %d", len(children), len(wantRealtime))
}
// Clearing the parent declaration must also clear both consumers. This
// proves they inherit the fail-closed default instead of duplicating a
// leaf annotation that future event commands could forget.
cmdutil.SetRuntimeCapabilities(cmd)
for _, name := range []string{"consume", "_bus"} {
if got := cmdutil.GetRuntimeCapabilities(children[name]); len(got) != 0 {
t.Errorf("event %s capabilities after clearing parent = %v, want inherited empty declaration", name, got)
}
}
}
func requireExternalEventGuard(t *testing.T, err error) {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T %v, want typed problem", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("problem = %s/%s, want %s/%s",
problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeFailedPrecondition)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if validationErr.Param != "" {
t.Fatalf("param = %q, want empty", validationErr.Param)
}
if problem.Hint == "" {
t.Fatal("hint is empty")
}
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/runtimeplan"
)
// NewCmdProfile creates the profile command with subcommands.
@@ -14,13 +15,26 @@ func NewCmdProfile(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "profile",
Short: "Manage configuration profiles",
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
// A child PersistentPreRunE shadows root's PersistentPreRun, so retain
// the invocation state used by structured error hints here.
cmd.SilenceUsage = true
f.CurrentCommand = cmd
return f.RequireCommandRuntimeCapabilities(cmd.Context(), cmd)
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetRuntimeCapabilities(cmd, runtimeplan.CapabilityLocalProfileMutation)
cmdutil.SetTips(cmd, []string{
"AI agents: Do NOT switch or remove profiles unless the user explicitly asks.",
})
cmd.AddCommand(NewCmdProfileList(f))
list := NewCmdProfileList(f)
// Listing profiles is read-only and remains useful for diagnostics under a
// managed credential runtime. Every other profile subcommand mutates local
// profile selection, config, or keychain state and inherits the parent gate.
cmdutil.SetRuntimeCapabilities(list)
cmd.AddCommand(list)
cmd.AddCommand(NewCmdProfileUse(f))
cmd.AddCommand(NewCmdProfileAdd(f))
cmd.AddCommand(NewCmdProfileRemove(f))

View File

@@ -0,0 +1,222 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package profile
import (
"bytes"
"context"
"errors"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/runtimeplan"
"github.com/larksuite/cli/internal/vfs"
)
type recordingProfileKeychain struct {
gets int
sets int
removes int
}
func (k *recordingProfileKeychain) Get(_, _ string) (string, error) {
k.gets++
return "", nil
}
func (k *recordingProfileKeychain) Set(_, _, _ string) error {
k.sets++
return nil
}
func (k *recordingProfileKeychain) Remove(_, _ string) error {
k.removes++
return nil
}
func TestProfileMutationCommandsAreDeniedBeforeLocalStateChanges(t *testing.T) {
denied := errs.NewValidationError(
errs.SubtypeFailedPrecondition,
"local credential management is unavailable in this runtime",
).WithHint("manage credentials through the active provider")
plan := runtimeplan.New(runtimeplan.Options{
Capabilities: func(capability runtimeplan.Capability) error {
if capability == runtimeplan.CapabilityLocalProfileMutation {
return denied
}
return nil
},
})
tests := []struct {
name string
args []string
}{
{
name: "add",
args: []string{"add", "--name", "new", "--app-id", "app-new", "--app-secret-stdin"},
},
{
name: "use",
args: []string{"use", "target"},
},
{
name: "rename",
args: []string{"rename", "target", "renamed"},
},
{
name: "remove",
args: []string{"remove", "target"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
configDir := setupProfileConfigDir(t)
saveManagedGateFixture(t)
configPath := filepath.Join(configDir, "config.json")
before, err := vfs.ReadFile(configPath)
if err != nil {
t.Fatalf("ReadFile(before) error = %v", err)
}
f, _, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, nil, plan)
f.IOStreams.In = strings.NewReader("must-not-be-read\n")
keychain := &recordingProfileKeychain{}
f.Keychain = keychain
cmd := NewCmdProfile(f)
cmd.SetArgs(tt.args)
err = cmd.Execute()
if !errors.Is(err, denied) {
t.Fatalf("Execute() error = %v, want denied runtime error", err)
}
after, readErr := vfs.ReadFile(configPath)
if readErr != nil {
t.Fatalf("ReadFile(after) error = %v", readErr)
}
if !bytes.Equal(after, before) {
t.Fatalf("config changed despite runtime denial:\nbefore: %s\nafter: %s", before, after)
}
if keychain.gets != 0 || keychain.sets != 0 || keychain.removes != 0 {
t.Fatalf("keychain calls = get:%d set:%d remove:%d, want none",
keychain.gets, keychain.sets, keychain.removes)
}
})
}
}
func TestProfileListRemainsAvailableWhenLocalMutationIsDenied(t *testing.T) {
setupProfileConfigDir(t)
saveManagedGateFixture(t)
plan := runtimeplan.New(runtimeplan.Options{
Capabilities: func(capability runtimeplan.Capability) error {
if capability == runtimeplan.CapabilityLocalProfileMutation {
return errs.NewValidationError(
errs.SubtypeFailedPrecondition,
"local credential management is unavailable in this runtime",
)
}
return nil
},
})
f, stdout, _, _ := cmdutil.TestFactoryWithRuntimePlan(t, nil, plan)
cmd := NewCmdProfile(f)
cmd.SetArgs([]string{"list"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile list was blocked by mutation capability: %v", err)
}
if !strings.Contains(stdout.String(), `"name": "default"`) {
t.Fatalf("profile list output = %s, want default profile", stdout.String())
}
}
func TestProfileMutationCommandsRemainAvailableByDefault(t *testing.T) {
setupProfileConfigDir(t)
saveManagedGateFixture(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
// origin/main allows Profile preparation while an environment/extension
// provider is active. The managed runtime blocks this through its explicit
// plan policy; generic provider ownership must not change Standard.
f.Credential = credential.NewCredentialProvider(
[]extcred.Provider{profileEnvironmentProvider{}},
nil,
nil,
nil,
)
cmd := NewCmdProfile(f)
args := []string{"use", "target"}
matched, _, err := cmd.Find(args)
if err != nil {
t.Fatalf("Find() error = %v", err)
}
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use with default runtime plan error = %v", err)
}
if f.CurrentCommand != matched {
t.Fatalf("CurrentCommand = %v, want matched command %v", f.CurrentCommand, matched)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if saved.CurrentApp != "target" || saved.PreviousApp != "default" {
t.Fatalf("selection = current:%q previous:%q, want target/default",
saved.CurrentApp, saved.PreviousApp)
}
}
type profileEnvironmentProvider struct{}
func (profileEnvironmentProvider) Name() string { return "env" }
func (profileEnvironmentProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return &extcred.Account{
AppID: "cli_environment",
Brand: extcred.BrandFeishu,
SupportedIdentities: extcred.SupportsAll,
}, nil
}
func (profileEnvironmentProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return &extcred.Token{Value: "environment-token"}, nil
}
func saveManagedGateFixture(t *testing.T) {
t.Helper()
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.SecretInput{Ref: &core.SecretRef{
Source: "keychain",
ID: "appsecret:app-target",
}},
Brand: core.BrandLark,
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}

View File

@@ -21,6 +21,7 @@ import (
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/runtimebootstrap"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update"
@@ -100,6 +101,12 @@ func Execute() int {
fmt.Fprintln(os.Stderr, "Error:", err)
return 1
}
// Resolve all startup state from the detected workspace. This must happen
// before ResolveStartupBrand, isSingleAppMode, or buildInternal reads
// workspace-scoped configuration.
selectInvocationWorkspace()
startup := runtimebootstrap.Resolve(inv.Profile)
startupBrand := resolveStartupBrandFromConfig(inv.Profile, startup.ProfileConfig)
configureFlagCompletions(os.Args)
ctx := context.Background()
@@ -107,7 +114,8 @@ func Execute() int {
ctx, inv,
WithIO(os.Stdin, os.Stdout, os.Stderr),
HideProfile(isSingleAppMode()),
WithStartupBrand(ResolveStartupBrand(inv.Profile)),
WithStartupBrand(startupBrand),
withRuntimeBootstrap(startup),
)
// --- Notices (non-blocking) ---
@@ -137,7 +145,7 @@ func Execute() int {
// or both may be present in any given envelope.
func setupNotices() {
// Binary update — synchronous cache check + async refresh
if info := update.CheckCached(build.Version); info != nil {
if info := checkCachedEditionUpdate(build.Version); info != nil {
update.SetPending(info)
}
ver := build.Version
@@ -147,9 +155,9 @@ func setupNotices() {
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
}
}()
update.RefreshCache(ver)
refreshEditionUpdateCache(ver)
if update.GetPending() == nil {
if info := update.CheckCached(ver); info != nil {
if info := checkCachedEditionUpdate(ver); info != nil {
update.SetPending(info)
}
}

View File

@@ -371,10 +371,11 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
catalog := strictModeFixtureCatalog()
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
code := executeRootIntegration(t, f, rootCmd, []string{
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--as", "user", "--dry-run",
})
if code != output.ExitValidation {

View File

@@ -18,6 +18,7 @@ import (
cmdconfig "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -435,6 +436,25 @@ func TestHandleRootError_LeakedUntypedErrorBecomesInternal(t *testing.T) {
}
}
func TestHandleRootError_BlockErrorPreservesUntypedFallback(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
blockErr := &extcred.BlockError{Provider: "env", Reason: "LARKSUITE_CLI_APP_ID is missing"}
exit := handleRootError(f, blockErr)
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if errObj["type"] != "internal" || errObj["subtype"] != "unknown" {
t.Fatalf("error = %#v", errObj)
}
if errObj["message"] != "blocked by env: LARKSUITE_CLI_APP_ID is missing" {
t.Fatalf("error.message = %v", errObj["message"])
}
if exit != int(output.ExitInternal) {
t.Fatalf("exit = %d, want %d", exit, output.ExitInternal)
}
}
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
// stderr write fails mid-envelope, handleRootError still returns the typed
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the

View File

@@ -11,7 +11,6 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
)
@@ -58,10 +57,10 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
if !ios.IsTerminal || !ios.OutIsTerminal || !ios.StderrIsTerminal {
return
}
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
// and the IsNewer/semver validation chain; it reads the on-disk cache that
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
info := update.CheckCached(build.Version)
// Gate 4: cached newer version from this binary's release channel.
// Standard reads the npm-backed cache; Extended reads its separate
// GitHub-release cache.
info := checkCachedEditionUpdate(build.Version)
if info == nil {
return
}

View File

@@ -6,7 +6,6 @@ package cmd
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
@@ -15,13 +14,21 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/vfs"
"github.com/spf13/cobra"
)
func writeUpdateState(t *testing.T, dir, latest string) {
func updateStateFileForEdition(edition string) string {
if edition == "extended" {
return "update-state-extended.json"
}
return "update-state.json"
}
func writeUpdateState(t *testing.T, dir, edition, latest string) {
t.Helper()
data := fmt.Sprintf(`{"latest_version":%q,"checked_at":%d}`, latest, time.Now().Unix())
if err := os.WriteFile(filepath.Join(dir, "update-state.json"), []byte(data), 0o644); err != nil {
if err := vfs.WriteFile(filepath.Join(dir, updateStateFileForEdition(edition)), []byte(data), 0o600); err != nil {
t.Fatal(err)
}
}
@@ -105,7 +112,7 @@ func TestOfferRootUpgrade(t *testing.T) {
t.Setenv("RUN_ID", "")
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
if tc.latest != "" {
writeUpdateState(t, dir, tc.latest)
writeUpdateState(t, dir, build.Edition, tc.latest)
}
if tc.optOut {
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
@@ -135,6 +142,53 @@ func TestOfferRootUpgrade(t *testing.T) {
}
}
func TestOfferRootUpgradeIgnoresOtherEditionCache(t *testing.T) {
origV := build.Version
build.Version = "1.0.0"
t.Cleanup(func() { build.Version = origV })
origRun := runRootUpgrade
t.Cleanup(func() { runRootUpgrade = origRun })
origWS := core.CurrentWorkspace()
t.Cleanup(func() { core.SetCurrentWorkspace(origWS) })
core.SetCurrentWorkspace(core.WorkspaceLocal)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
t.Setenv("CI", "")
t.Setenv("BUILD_NUMBER", "")
t.Setenv("RUN_ID", "")
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
otherEdition := "extended"
if build.Edition == "extended" {
otherEdition = "standard"
}
writeUpdateState(t, dir, otherEdition, "9.0.0")
called := false
runRootUpgrade = func(*cobra.Command) { called = true }
var errBuf bytes.Buffer
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader("y\n"),
Out: &bytes.Buffer{},
ErrOut: &errBuf,
IsTerminal: true,
OutIsTerminal: true,
StderrIsTerminal: true,
}}
offerRootUpgrade(f, &cobra.Command{})
if strings.Contains(errBuf.String(), "available") {
t.Fatalf("%s prompt consumed %s cache: %q", build.Edition, otherEdition, errBuf.String())
}
if called {
t.Fatalf("%s upgrade ran from %s cache", build.Edition, otherEdition)
}
}
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
orig := rawInvocationArgs
t.Cleanup(func() { rawInvocationArgs = orig })

View File

@@ -707,20 +707,18 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items)
return nil
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
}, pagOpts)
if err != nil {
return err

View File

@@ -0,0 +1,400 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
type serviceFailOnWriteWriter struct {
buf bytes.Buffer
writes int
failAt int
err error
}
func (w *serviceFailOnWriteWriter) Write(p []byte) (int, error) {
w.writes++
if w.writes == w.failAt {
return 0, w.err
}
return w.buf.Write(p)
}
func newServicePaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
previousNotice := output.PendingNotice
output.PendingNotice = nil
t.Cleanup(func() { output.PendingNotice = previousNotice })
config := &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
}
f, out, errOut, reg := cmdutil.TestFactory(t, config)
ac, err := f.NewAPIClientWithConfig(config)
if err != nil {
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
}
ac.ErrOut = io.Discard
return ac, out, errOut, reg
}
func servicePaginateRequest() client.RawApiRequest {
return client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test/v1/items",
As: core.AsBot,
}
}
func assertServicePaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
t.Helper()
wantBytes, err := json.MarshalIndent(want, "", " ")
if err != nil {
t.Fatalf("marshal expected JSON: %v", err)
}
wantBytes = append(wantBytes, '\n')
if !bytes.Equal(got, wantBytes) {
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
}
}
func TestServicePaginate_DefaultAggregatesAllPages(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
calls := 0
wantTokens := []string{"", "next-1", "next-2"}
for i, wantToken := range wantTokens {
page := i + 1
hasMore := page < len(wantTokens)
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = wantTokens[page]
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(req *http.Request) {
calls++
if got := req.URL.Query().Get("page_token"); got != wantToken {
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatJSON, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
}, ac.CheckResponse)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
}
if calls != 3 {
t.Fatalf("pagination requests = %d, want 3", calls)
}
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1"},
map[string]interface{}{"id": "2"},
map[string]interface{}{"id": "3"},
},
"has_more": false,
},
})
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}
func TestServicePaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
tests := []struct {
name string
format output.Format
want string
}{
{
name: "ndjson",
format: output.FormatNDJSON,
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
},
{
name: "table",
format: output.FormatTable,
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
},
{
name: "csv",
format: output.FormatCSV,
want: "id,name\n1,Alice\n2,Carol\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "name": "Alice"},
},
"has_more": true,
"page_token": "next-1",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
},
"has_more": false,
},
},
})
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
}, ac.CheckResponse)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestServicePaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
ac, _, errOut, reg := newServicePaginateTestHarness(t)
sentinel := errors.New("page write failed")
out := &serviceFailOnWriteWriter{failAt: 2, err: sentinel}
calls := 0
for page := 1; page <= 2; page++ {
hasMore := true
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": page}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = fmt.Sprintf("next-%d", page)
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(*http.Request) {
calls++
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse)
if !errors.Is(err, sentinel) {
t.Fatalf("servicePaginate() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("servicePaginate() problem = %#v, %v; want internal typed error", problem, ok)
}
if calls != 2 {
t.Fatalf("pagination requests = %d, want 2", calls)
}
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
t.Fatalf("stdout bytes = %q, want %q", got, want)
}
}
func TestServicePaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
},
})
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items get",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
}
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
})
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
if got := errOut.String(); got != wantWarning {
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
}
}
func TestServicePaginate_BusinessErrorsWriteRawAndRemainUnmarked(t *testing.T) {
businessResponse := map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{"detail": "business failed"},
}
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "default_json", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: businessResponse,
})
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err == nil {
t.Fatal("servicePaginate() error = nil, want business error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
}
assertServicePaginateJSONBytes(t, out.Bytes(), businessResponse)
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestServicePaginate_TransportErrorsRemainUnmarked(t *testing.T) {
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "stream_pages", format: output.FormatNDJSON},
{name: "default_paginate_all", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, _ := newServicePaginateTestHarness(t)
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err == nil {
t.Fatal("servicePaginate() error = nil, want transport error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestServicePaginate_StreamBusinessErrorRemainsUnmarked(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{},
},
})
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err == nil {
t.Fatal("servicePaginate() error = nil, want business error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"os"
"testing"
"github.com/larksuite/cli/internal/registry/registrytest"
)
// TestMain isolates service command tests from the host machine: config (and
// the registry cache under it) is redirected to a temp dir, then the registry
// is seeded from the tracked fixture and initialized eagerly. Tests pass on a
// clean checkout with no network, no `make fetch_meta`, and no user cache.
//
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
// m.Run before exiting.
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-cmd-service-test-*")
if err != nil {
println("cmd/service test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
println("cmd/service test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := registrytest.Seed(root); err != nil {
println("cmd/service test setup: registrytest.Seed failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
code := m.Run()
os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -10,17 +10,34 @@ import (
"github.com/larksuite/cli/internal/envvars"
)
// selectInvocationWorkspace establishes the workspace before any startup
// consumer reads workspace-scoped configuration. Execute needs this before
// resolving the registry brand, while Build/buildInternal needs it before
// capturing the immutable Profile snapshot passed to the Factory.
func selectInvocationWorkspace() core.Workspace {
workspace := core.DetectWorkspaceFromEnv(os.Getenv)
core.SetCurrentWorkspace(workspace)
return workspace
}
// ResolveStartupBrand resolves the brand before the command tree is built, so
// the registry's remote metadata overlay uses the configured brand from the
// first catalog access. It mirrors the credential chain's brand precedence —
// environment, then the active profile's raw config entry — without touching
// the keychain (no secrets are needed to know the brand).
func ResolveStartupBrand(profile string) core.LarkBrand {
config, _ := core.LoadMultiAppConfig()
return resolveStartupBrandFromConfig(profile, config)
}
// resolveStartupBrandFromConfig keeps registry routing on the same immutable
// Profile snapshot used by credentials and runtime policy.
func resolveStartupBrandFromConfig(profile string, config *core.MultiAppConfig) core.LarkBrand {
if raw := os.Getenv(envvars.CliBrand); raw != "" {
return core.ParseBrand(raw)
}
if cfg, err := core.LoadMultiAppConfig(); err == nil {
if app := cfg.CurrentAppConfig(profile); app != nil {
if config != nil {
if app := config.CurrentAppConfig(profile); app != nil {
return core.ParseBrand(string(app.Brand))
}
}

View File

@@ -5,6 +5,7 @@ package cmd
import (
"context"
"flag"
"fmt"
"os"
"os/exec"
@@ -12,11 +13,34 @@ import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/registry"
)
const startupBrandHelperEnv = "GO_TEST_STARTUP_BRAND_HELPER"
var _ = flag.String("startup-brand-helper", "", "internal startup brand test helper nonce")
func isStartupBrandHelper() bool {
return startupBrandHelperEnabled(os.Getenv(startupBrandHelperEnv), startupBrandHelperNonce(os.Args))
}
func startupBrandHelperEnabled(envNonce, argNonce string) bool {
return envNonce != "" && envNonce == argNonce
}
func startupBrandHelperNonce(args []string) string {
const prefix = "-startup-brand-helper="
for _, arg := range args {
if strings.HasPrefix(arg, prefix) {
return strings.TrimPrefix(arg, prefix)
}
}
return ""
}
func TestResolveStartupBrand_Precedence(t *testing.T) {
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
@@ -54,7 +78,7 @@ func TestResolveStartupBrand_Precedence(t *testing.T) {
// sync.Once, so the brand must be injected before the first catalog access.
// It runs in a subprocess because the registry is process-global.
func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
if os.Getenv("GO_TEST_STARTUP_BRAND_HELPER") == "1" {
if isStartupBrandHelper() {
// Helper: replicate Execute()'s build wiring with a lark config.
buildInternal(
context.Background(), cmdutil.InvocationContext{},
@@ -71,9 +95,11 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
t.Fatal(err)
}
nonce := uuid.NewString()
t.Setenv(startupBrandHelperEnv, nonce)
cmd := exec.Command(os.Args[0], "-test.run", "TestStartupBrandReachesRegistry_RealStartupOrder")
cmd.Args = append(cmd.Args, "-startup-brand-helper="+nonce)
cmd.Env = append(os.Environ(),
"GO_TEST_STARTUP_BRAND_HELPER=1",
"LARKSUITE_CLI_CONFIG_DIR="+tmp,
"LARKSUITE_CLI_REMOTE_META=off", // no network during the subprocess build
)
@@ -85,3 +111,33 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
t.Errorf("registry brand after real startup order = %s, want lark", out)
}
}
func TestStartupBrandHelperRequiresMatchingCommandNonce(t *testing.T) {
for _, tt := range []struct {
name string
envNonce string
argNonce string
want bool
}{
{name: "neither set"},
{name: "ambient environment only", envNonce: "ambient"},
{name: "command argument only", argNonce: "command"},
{name: "mismatch", envNonce: "ambient", argNonce: "command"},
{name: "matching", envNonce: "nonce", argNonce: "nonce", want: true},
} {
t.Run(tt.name, func(t *testing.T) {
if got := startupBrandHelperEnabled(tt.envNonce, tt.argNonce); got != tt.want {
t.Fatalf("startupBrandHelperEnabled() = %v, want %v", got, tt.want)
}
})
}
}
func TestStartupBrandHelperNonce(t *testing.T) {
if got := startupBrandHelperNonce([]string{"test", "-test.run", "brand"}); got != "" {
t.Fatalf("startupBrandHelperNonce() = %q, want empty", got)
}
if got := startupBrandHelperNonce([]string{"test", "-startup-brand-helper=nonce"}); got != "nonce" {
t.Fatalf("startupBrandHelperNonce() = %q, want nonce", got)
}
}

46
cmd/testmain_test.go Normal file
View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"os"
"testing"
"github.com/larksuite/cli/internal/registry/registrytest"
)
// TestMain isolates command-tree tests from the host machine: config (and the
// registry cache under it) is redirected to a temp dir, then the registry is
// seeded from the tracked fixture and initialized eagerly. Tests pass on a
// clean checkout with no network, no `make fetch_meta`, and no user cache.
//
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
// m.Run before exiting.
func TestMain(m *testing.M) {
if isStartupBrandHelper() {
// Re-exec helper subprocess (startup_brand_test.go): the parent test
// already provides an isolated config dir and disables remote metadata,
// and the helper must own the first registry Init to prove the startup
// order — do not seed or eagerly initialize here.
os.Exit(m.Run())
}
root, err := os.MkdirTemp("", "lark-cli-cmd-test-*")
if err != nil {
println("cmd test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
println("cmd test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := registrytest.Seed(root); err != nil {
println("cmd test setup: registrytest.Seed failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
code := m.Run()
os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmdupdate
import (
"errors"
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/extendedupdate"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/update"
)
var (
fetchExtendedLatest = extendedupdate.FetchLatest
installExtended = extendedupdate.Install
)
func updateLongDescription() string {
return `Update lark-cli Extended from the matching GitHub Release.
The command downloads the lark-cli-extended asset for the current platform,
verifies its SHA-256 checksum and compiled edition identity, then replaces the
current binary. It never installs the Standard npm/npx edition.
Use --json for structured output (for AI agents and scripts).
Use --check to only check for updates without installing.`
}
func runEditionUpdate(opts *UpdateOptions) (bool, error) {
io := opts.Factory.IOStreams
cur := currentVersion()
updater := newUpdater()
if !opts.Check {
updater.Brand = resolveSkillsBrand(opts.Factory, io.ErrOut)
updater.CleanupStaleFiles()
}
output.PendingNotice = nil
latest, err := fetchExtendedLatest()
if err != nil {
var typed errs.TypedError
if errors.As(err, &typed) {
return true, reportError(opts, io, "network", typed)
}
return true, reportError(opts, io, "network",
errs.NewNetworkError(errs.SubtypeNetworkTransport,
"failed to check the latest Extended version: %v", err).WithCause(err))
}
if update.ParseVersion(latest) == nil {
return true, reportError(opts, io, "update_error",
errs.NewInternalError(errs.SubtypeInvalidResponse,
"invalid Extended version from GitHub Releases: %s", latest))
}
if !opts.Force && !update.IsNewer(latest, cur) {
var skillsResult *skillscheck.SyncResult
if !opts.Check {
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
}
return true, reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
}
if opts.Check {
return true, reportCheckResult(opts, io, cur, latest, true)
}
if !opts.JSON {
fmt.Fprintf(io.ErrOut, "Updating lark-cli Extended %s %s %s from GitHub Releases ...\n", cur, symArrow(), latest)
}
if err := installExtended(latest); err != nil {
var typed errs.TypedError
if errors.As(err, &typed) {
return true, reportError(opts, io, "update_error", typed)
}
return true, reportError(opts, io, "update_error",
errs.NewInternalError(errs.SubtypeUnknown,
"failed to install lark-cli Extended: %v", err).WithCause(err))
}
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
if opts.JSON {
result := map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": latest,
"latest_version": latest, "edition": build.Edition, "action": "updated",
"message": fmt.Sprintf("lark-cli Extended updated from %s to %s", cur, latest),
"url": releaseURL(latest), "changelog": changelogURL(),
}
applySkillsResult(result, skillsResult)
output.PrintJson(io.Out, result)
return true, nil
}
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli Extended from %s to %s\n", symOK(), cur, latest)
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
emitSkillsTextHints(io, skillsResult)
return true, nil
}

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmdupdate
import (
"bytes"
"context"
"encoding/json"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/selfupdate"
"github.com/larksuite/cli/internal/skillscheck"
)
func TestExtendedUpdateUsesExtendedReleaseInstaller(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
oldFetch, oldInstall := fetchExtendedLatest, installExtended
oldVersion, oldUpdater, oldSync := currentVersion, newUpdater, syncSkills
t.Cleanup(func() {
fetchExtendedLatest, installExtended = oldFetch, oldInstall
currentVersion, newUpdater, syncSkills = oldVersion, oldUpdater, oldSync
})
fetchExtendedLatest = func() (string, error) { return "1.2.4", nil }
currentVersion = func() string { return "1.2.3" }
installed := ""
installExtended = func(version string) error {
installed = version
return nil
}
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallManual}
}}
}
syncSkills = func(skillscheck.SyncOptions) *skillscheck.SyncResult { return &skillscheck.SyncResult{} }
var out, errOut bytes.Buffer
f := cmdutil.NewDefault(cmdutil.NewIOStreams(nil, &out, &errOut), cmdutil.InvocationContext{})
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
if err := cmd.ExecuteContext(context.Background()); err != nil {
t.Fatal(err)
}
if installed != "1.2.4" {
t.Fatalf("installed version = %q, want 1.2.4", installed)
}
var result map[string]interface{}
if err := json.Unmarshal(out.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result["edition"] != "extended" || result["action"] != "updated" {
t.Fatalf("result = %#v", result)
}
}
func TestExtendedUpdateCheckDoesNotInstall(t *testing.T) {
oldFetch, oldInstall := fetchExtendedLatest, installExtended
oldVersion := currentVersion
t.Cleanup(func() {
fetchExtendedLatest, installExtended = oldFetch, oldInstall
currentVersion = oldVersion
})
fetchExtendedLatest = func() (string, error) { return "1.2.4", nil }
currentVersion = func() string { return "1.2.3" }
installExtended = func(string) error {
t.Fatal("installer called during --check")
return nil
}
var out bytes.Buffer
f := cmdutil.NewDefault(cmdutil.NewIOStreams(nil, &out, &bytes.Buffer{}), cmdutil.InvocationContext{})
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json", "--check"})
if err := cmd.ExecuteContext(context.Background()); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmdupdate
func runEditionUpdate(*UpdateOptions) (bool, error) { return false, nil }
func updateLongDescription() string {
return `Update lark-cli to the latest version.
Detects the installation method automatically:
- npm install: runs npm install -g @larksuite/cli@<version>
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
- manual/other: shows GitHub Releases download URL
Use --json for structured output (for AI agents and scripts).
Use --check to only check for updates without installing.`
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdupdate
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-update-test-*")
if err != nil {
panic(err)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
panic(err)
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -101,15 +101,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "update",
Short: "Update lark-cli to the latest version",
Long: `Update lark-cli to the latest version.
Detects the installation method automatically:
- npm install: runs npm install -g @larksuite/cli@<version>
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
- manual/other: shows GitHub Releases download URL
Use --json for structured output (for AI agents and scripts).
Use --check to only check for updates without installing.`,
Long: updateLongDescription(),
RunE: func(cmd *cobra.Command, args []string) error {
return updateRun(opts)
},
@@ -124,6 +116,9 @@ Use --check to only check for updates without installing.`,
}
func updateRun(opts *UpdateOptions) error {
if handled, err := runEditionUpdate(opts); handled {
return err
}
io := opts.Factory.IOStreams
cur := currentVersion()
updater := newUpdater()

View File

@@ -1,6 +1,8 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmdupdate
import (
@@ -24,6 +26,8 @@ import (
"github.com/larksuite/cli/internal/skillscheck"
)
const runLiveSkillsTestsEnv = "LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS"
// newTestFactory creates a test factory with minimal config.
func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
@@ -31,13 +35,17 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
return f, stdout, stderr
}
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
// mockDetect sets up newUpdater to return an Updater with the given DetectResult
// and fully mocked skills operations. Tests that only care about install-method
// detection must never fall through to the real npx skills CLI.
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
@@ -104,6 +112,18 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
}
}
func mockSkillsSync(t *testing.T) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func TestUpdatePnpm_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -228,6 +248,9 @@ func TestNormalizeVersion(t *testing.T) {
}
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
mockSkillsSync(t)
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
@@ -256,6 +279,9 @@ func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
}
func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
mockSkillsSync(t)
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
@@ -281,6 +307,7 @@ func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
}
func TestUpdateManual_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
@@ -312,6 +339,7 @@ func TestUpdateManual_JSON(t *testing.T) {
}
func TestUpdateManual_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
@@ -1161,6 +1189,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
}
called := false
updater := &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
called = true
return successfulSkillsCommand()(args...)
@@ -1177,7 +1206,10 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
updater := &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: successfulSkillsCommand(),
}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
@@ -1197,6 +1229,7 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
t.Fatal(err)
}
updater := &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("npx failed")
@@ -1513,28 +1546,133 @@ func TestEmitSkillsTextHints_Success(t *testing.T) {
}
}
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
// state file. It calls the real npx skills CLI, so the test is skipped when
// npx or the skills registry is unavailable (e.g. no network or fork PRs).
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
if _, err := exec.LookPath("npx"); err != nil {
t.Skipf("npx not found in PATH: %v", err)
// liveSkillsIsolationEnv is the single source of truth for the user-state
// directories a live skills test must redirect under the temporary home. It
// covers the CLI's own config, the agent homes the skills CLI installs into,
// the XDG dirs it derives paths from (XDG_STATE_HOME holds its global
// .skill-lock.json), and the npm/npx overrides that take precedence over
// HOME-derived defaults (both cases: npm reads npm_config_* case-insensitively).
func liveSkillsIsolationEnv(home string) map[string]string {
return map[string]string{
"HOME": home,
"USERPROFILE": home,
"APPDATA": filepath.Join(home, "AppData", "Roaming"),
"LOCALAPPDATA": filepath.Join(home, "AppData", "Local"),
"XDG_CONFIG_HOME": filepath.Join(home, ".config"),
"XDG_DATA_HOME": filepath.Join(home, ".local", "share"),
"XDG_STATE_HOME": filepath.Join(home, ".local", "state"),
"CODEX_HOME": filepath.Join(home, ".codex"),
"CLAUDE_CONFIG_DIR": filepath.Join(home, ".claude"),
"LARKSUITE_CLI_CONFIG_DIR": filepath.Join(home, ".lark-cli"),
"npm_config_cache": filepath.Join(home, ".npm-cache"),
"NPM_CONFIG_CACHE": filepath.Join(home, ".npm-cache"),
"npm_config_prefix": filepath.Join(home, ".npm-global"),
"NPM_CONFIG_PREFIX": filepath.Join(home, ".npm-global"),
"npm_config_userconfig": filepath.Join(home, ".npmrc"),
"NPM_CONFIG_USERCONFIG": filepath.Join(home, ".npmrc"),
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
}
func prepareLiveSkillsIntegration(t *testing.T) string {
t.Helper()
if os.Getenv(runLiveSkillsTestsEnv) != "1" {
t.Skipf("live skills integration test disabled; set %s=1 to run", runLiveSkillsTestsEnv)
}
home := t.TempDir()
for key, value := range liveSkillsIsolationEnv(home) {
t.Setenv(key, value)
}
return home
}
func TestPrepareLiveSkillsIntegration(t *testing.T) {
reachedAfterGate := false
t.Run("requires explicit opt-in", func(t *testing.T) {
t.Setenv(runLiveSkillsTestsEnv, "")
prepareLiveSkillsIntegration(t)
reachedAfterGate = true
})
if reachedAfterGate {
t.Fatal("prepareLiveSkillsIntegration continued without explicit opt-in")
}
t.Run("isolates user directories", func(t *testing.T) {
t.Setenv(runLiveSkillsTestsEnv, "1")
home := prepareLiveSkillsIntegration(t)
// Pin the isolation contract by key: removing a variable from
// liveSkillsIsolationEnv must fail this list, and every redirected
// value must live under the temporary home.
required := []string{
"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME",
"CODEX_HOME", "CLAUDE_CONFIG_DIR", "LARKSUITE_CLI_CONFIG_DIR",
"npm_config_cache", "NPM_CONFIG_CACHE",
"npm_config_prefix", "NPM_CONFIG_PREFIX",
"npm_config_userconfig", "NPM_CONFIG_USERCONFIG",
}
env := liveSkillsIsolationEnv(home)
for _, key := range required {
expected, ok := env[key]
if !ok {
t.Errorf("liveSkillsIsolationEnv dropped required key %s", key)
continue
}
if !strings.HasPrefix(expected, home) {
t.Errorf("%s = %q escapes temporary home %q", key, expected, home)
}
if got := os.Getenv(key); got != expected {
t.Errorf("%s = %q, want %q", key, got, expected)
}
}
})
}
// seedLiveSkillsGlobal verifies the real npx skills CLI is reachable, installs
// lark-calendar into the isolated global skills dir, and returns the parsed
// global skills list. The caller opted in explicitly, so every missing
// precondition is a hard failure — skipping would report "nothing verified"
// as a green run.
func seedLiveSkillsGlobal(t *testing.T) []string {
t.Helper()
if _, err := exec.LookPath("npx"); err != nil {
t.Fatalf("live skills tests opted in but npx not found in PATH: %v", err)
}
// Three sequential npx runs against a cold cache (the isolated home starts
// empty) can be slow; with Fatal-on-timeout semantics the budget errs on
// the generous side.
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
t.Skipf("real skills CLI unavailable: %v", err)
t.Fatalf("live skills tests opted in but real skills CLI unavailable: %v", err)
}
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "-s", "lark-calendar", "-g", "-y").Run(); err != nil {
t.Fatalf("failed to seed isolated global skills: %v", err)
}
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
if err != nil {
t.Skipf("real global skills CLI unavailable: %v", err)
t.Fatalf("real global skills CLI unavailable: %v", err)
}
localSkills := skillscheck.ParseSkillsList(string(globalOut))
if err := ctx.Err(); err != nil {
t.Skipf("real skills CLI availability check timed out: %v", err)
if len(localSkills) == 0 {
t.Fatal("seeded lark-calendar but global skills list is empty")
}
if err := ctx.Err(); err != nil {
t.Fatalf("real skills CLI availability check timed out: %v", err)
}
return localSkills
}
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
// state file. It calls the real npx skills CLI and only runs with explicit
// opt-in. All user directories are redirected to a temporary home.
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
prepareLiveSkillsIntegration(t)
// Phase 1: Verify the real npx skills CLI is available and seed the
// isolated global skills install.
localSkills := seedLiveSkillsGlobal(t)
// Phase 2: Seed a previous sync state simulating an upgrade from v1.0.19.
// lark-doc and lark-mail are recorded as skipped/deleted, meaning the user
@@ -1630,26 +1768,17 @@ func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
// not exist (cold start), the update command installs all official skills and
// writes a fresh state file. No skill should appear in SkippedDeletedSkills
// because there is no previous state to preserve user deletions from.
// This is a live integration test that calls the real npx skills CLI; it is
// skipped when npx or the skills registry is unavailable.
// This is a live integration test that calls the real npx skills CLI and only
// runs with explicit opt-in. All user directories are redirected to a temporary
// home.
func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) {
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
if _, err := exec.LookPath("npx"); err != nil {
t.Skipf("npx not found in PATH: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
t.Skipf("real skills CLI unavailable: %v", err)
}
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
if err != nil {
t.Skipf("real global skills CLI unavailable: %v", err)
}
localSkills := skillscheck.ParseSkillsList(string(globalOut))
if err := ctx.Err(); err != nil {
t.Skipf("real skills CLI availability check timed out: %v", err)
}
prepareLiveSkillsIntegration(t)
// Phase 1: Verify the real npx skills CLI is available and seed one known
// official skill into the isolated global install. Cold start means no
// skills-state.json — locally installed skills may still exist, and seeding
// one keeps the Phase 4 per-skill assertions from running zero times.
localSkills := seedLiveSkillsGlobal(t)
// Phase 2: Use an isolated config dir with no pre-existing skills-state.json.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package cmd
import (
"github.com/larksuite/cli/internal/extendedupdate"
"github.com/larksuite/cli/internal/update"
)
func checkCachedEditionUpdate(currentVersion string) *update.UpdateInfo {
return extendedupdate.CheckCached(currentVersion)
}
func refreshEditionUpdateCache(currentVersion string) {
extendedupdate.RefreshCache(currentVersion)
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package cmd
import "github.com/larksuite/cli/internal/update"
func checkCachedEditionUpdate(currentVersion string) *update.UpdateInfo {
return update.CheckCached(currentVersion)
}
func refreshEditionUpdateCache(currentVersion string) {
update.RefreshCache(currentVersion)
}

56
cmd/version/version.go Normal file
View File

@@ -0,0 +1,56 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package version
import (
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
type options struct {
factory *cmdutil.Factory
json bool
}
type versionReport struct {
Version string `json:"version"`
Edition string `json:"edition"`
Capabilities []string `json:"capabilities"`
}
// NewCmdVersion reports the immutable edition identity compiled into the
// binary. Root --version remains unchanged for compatibility.
func NewCmdVersion(f *cmdutil.Factory) *cobra.Command {
opts := &options{factory: f}
cmd := &cobra.Command{
Use: "version",
Short: "Show version and edition information",
Hidden: hideVersionCommand(),
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if opts.json {
output.PrintJson(opts.factory.IOStreams.Out, versionReport{
Version: build.Version,
Edition: build.Edition,
Capabilities: build.Capabilities(),
})
return nil
}
_, err := fmt.Fprintf(opts.factory.IOStreams.Out, "lark-cli version %s (%s)\n", build.Version, build.Edition)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write version output: %v", err).WithCause(err)
}
return nil
},
}
cmd.Flags().BoolVar(&opts.json, "json", false, "structured JSON output")
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package version
import (
"context"
"encoding/json"
"errors"
"reflect"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
)
func TestVersionJSONReportsCompiledEdition(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, out, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdVersion(f)
cmd.SetArgs([]string{"--json"})
if err := cmd.ExecuteContext(context.Background()); err != nil {
t.Fatal(err)
}
var got struct {
Version string `json:"version"`
Edition string `json:"edition"`
Capabilities []string `json:"capabilities"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Version != build.Version || got.Edition != build.Edition || !reflect.DeepEqual(got.Capabilities, build.Capabilities()) {
t.Fatalf("version output = %#v, want version=%q edition=%q", got, build.Version, build.Edition)
}
}
func TestVersionVisibilityPreservesStandardHelpSurface(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdVersion(f)
wantHidden := build.Edition == "standard"
if cmd.Hidden != wantHidden {
t.Fatalf("version command hidden = %v, want %v for %s", cmd.Hidden, wantHidden, build.Edition)
}
}
type failingWriter struct{ err error }
func (w failingWriter) Write([]byte) (int, error) { return 0, w.err }
func TestVersionTextWriteFailureIsTyped(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
writeErr := errors.New("write failed")
f.IOStreams.Out = failingWriter{err: writeErr}
cmd := NewCmdVersion(f)
err := cmd.ExecuteContext(context.Background())
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeSDKError {
t.Fatalf("error = %#v, want internal/sdk_error", err)
}
if !errors.Is(err, writeErr) {
t.Fatalf("error does not preserve write failure: %v", err)
}
}

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package version
func hideVersionCommand() bool { return false }

View File

@@ -0,0 +1,10 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package version
// Standard keeps its historical help surface unchanged. The command remains
// directly callable for release identity verification.
func hideVersionCommand() bool { return true }

View File

@@ -62,6 +62,8 @@ Typed errors render to **stderr** as one JSON object per process exit:
| `error.message` | informational | not safe to branch on |
| `error.hint` | informational | actionable recovery guidance |
| `error.log_id` | informational | upstream request id (server-side trace) |
| `error.origin` | informational | Extended producer: `cli`, `credential_process`, `proxy`, or `lark`; omitted by Standard to preserve its existing envelope; consumers must tolerate absence and unknown future values |
| `error.proxy_request_id` | informational | external credential platform trace id; never stored in `log_id` |
| `error.retryable` | wire-stable | `true` when present; omitted when `false` |
| `error.param` | per-Subtype-stable | single offending parameter (`ValidationError`); see **Validation parameters** |
| `error.params` | per-Subtype-stable | per-parameter validation detail array (`ValidationError`); see **Validation parameters** |
@@ -104,7 +106,7 @@ already succeeded).
| `config` | local config missing / unbound | 3 | `ConfigError` |
| `network` | DNS, refused, timeout, transport | 4 | `NetworkError` |
| `api` | server-side Lark error w/o specific bucket | 1 | `APIError` |
| `policy` | content safety / security challenge | 6 | `SecurityPolicyError`, `ContentSafetyError` |
| `policy` | security policy denial/challenge, including content safety | 6 | `SecurityPolicyError`, `ContentSafetyError` |
| `internal` | SDK contract violation / decode failure | 5 | `InternalError` |
| `confirmation` | high-risk action needs `--yes` | 10 | `ConfirmationRequiredError` |
@@ -272,7 +274,7 @@ legal for framework dynamic paths (e.g. classifier fanout) but the lint
| Login required | `errs.NewAuthenticationError(errs.SubtypeTokenMissing, msg)` |
| Token lacks scope | `errclass.BuildAPIError(resp, ctx)` |
| Local config missing | `errs.NewConfigError(errs.SubtypeNotConfigured, msg)` |
| Transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTimeout, msg).WithCause(err)` (subtype: `timeout` / `tls` / `dns` / `server_error` / `transport`) |
| Transport or external dependency failure | `errs.NewNetworkError(subtype, msg).WithCause(err)` (subtype: `timeout` / `tls` / `dns` / `server_error` / `transport` / `credential_source_unavailable` / `upstream_unavailable`) |
| Lark API error | `errclass.BuildAPIError(resp, ctx)` |
| SDK / decode bug | `errs.NewInternalError(errs.SubtypeSDKError, msg).WithCause(err)` |
| Policy block | `errs.NewSecurityPolicyError(subtype, msg).WithChallengeURL(url)` or `errs.NewContentSafetyError(subtype, msg).WithRules(...)` |
@@ -513,7 +515,11 @@ Rare; the existing structs cover the 9 Categories with room. If you must:
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
top-level wire fields are forbidden — per-Subtype data goes into the
typed struct as a documented extension field, not into the envelope's
top level.
top level. The external credential platform contract is the single explicit
exception: `origin` and `proxy_request_id` are shared across several error
categories and therefore live in `Problem`. Both fields are optional, and
consumers must ignore them when absent or unknown. Any further shared field
still requires an explicit contract revision and wire-format pin.
## CI guards

134
errs/diagnostic_metadata.go Normal file
View File

@@ -0,0 +1,134 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import (
"encoding/json"
)
// DiagnosticMetadata carries optional producer diagnostics without changing
// the field layout of Problem or any concrete typed error. Keeping this
// metadata in a wrapper preserves source compatibility for callers that use
// positional literals of the existing exported error structs.
type DiagnosticMetadata struct {
Origin string
ProxyRequestID string
}
type diagnosticMetadataWrapper struct {
err error
typed error
metadata DiagnosticMetadata
}
func (e *diagnosticMetadataWrapper) Error() string {
if e == nil || e.err == nil {
return ""
}
return e.err.Error()
}
func (e *diagnosticMetadataWrapper) Unwrap() error {
if e == nil {
return nil
}
return e.err
}
func (e *diagnosticMetadataWrapper) ProblemDetail() *Problem {
if e == nil {
return nil
}
problem, _ := ProblemOf(e.typed)
return problem
}
func (e *diagnosticMetadataWrapper) DiagnosticMetadata() DiagnosticMetadata {
if e == nil {
return DiagnosticMetadata{}
}
return e.metadata
}
// MarshalJSON preserves the concrete typed error's extension fields and adds
// the optional diagnostics as sibling fields in the existing error object.
func (e *diagnosticMetadataWrapper) MarshalJSON() ([]byte, error) {
raw, err := json.Marshal(e.typed)
if err != nil {
return nil, err
}
var object map[string]json.RawMessage
if err := json.Unmarshal(raw, &object); err != nil {
return nil, err
}
if e.metadata.Origin != "" {
origin, err := json.Marshal(e.metadata.Origin)
if err != nil {
return nil, err
}
object["origin"] = origin
}
if e.metadata.ProxyRequestID != "" {
requestID, err := json.Marshal(e.metadata.ProxyRequestID)
if err != nil {
return nil, err
}
object["proxy_request_id"] = requestID
}
return json.Marshal(object)
}
// WithDiagnosticMetadata attaches optional wire diagnostics to a typed error.
// Empty metadata is a no-op. The returned wrapper still participates in
// errors.Is/errors.As and TypedError routing through Unwrap and ProblemDetail.
func WithDiagnosticMetadata(err error, metadata DiagnosticMetadata) error {
if err == nil || (metadata.Origin == "" && metadata.ProxyRequestID == "") {
return err
}
typed, ok := UnwrapTypedError(err)
if !ok {
return err
}
if existing, ok := diagnosticMetadataWrapperForProducer(typed); ok {
merged := existing.metadata
if metadata.Origin != "" {
merged.Origin = metadata.Origin
}
if metadata.ProxyRequestID != "" {
merged.ProxyRequestID = metadata.ProxyRequestID
}
return &diagnosticMetadataWrapper{err: err, typed: existing.typed, metadata: merged}
}
return &diagnosticMetadataWrapper{err: err, typed: typed, metadata: metadata}
}
// DiagnosticMetadataOf returns optional diagnostics attached to the first
// typed producer in err's wrap chain. Metadata on a typed cause belongs to
// that inner producer and must not be projected onto an outer typed error.
func DiagnosticMetadataOf(err error) (DiagnosticMetadata, bool) {
typed, ok := UnwrapTypedError(err)
if !ok {
return DiagnosticMetadata{}, false
}
carrier, ok := diagnosticMetadataWrapperForProducer(typed)
if !ok {
return DiagnosticMetadata{}, false
}
metadata := carrier.DiagnosticMetadata()
if metadata.Origin == "" && metadata.ProxyRequestID == "" {
return DiagnosticMetadata{}, false
}
return metadata, true
}
// diagnosticMetadataWrapperForProducer deliberately checks only the selected
// typed producer. errors.As must not be used here because it would traverse
// into an inner typed cause and associate that cause's metadata with the outer
// producer.
func diagnosticMetadataWrapperForProducer(typed error) (*diagnosticMetadataWrapper, bool) {
wrapper, ok := typed.(*diagnosticMetadataWrapper) //nolint:errorlint // Exact producer identity is the invariant being enforced.
return wrapper, ok
}

View File

@@ -0,0 +1,118 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import (
"encoding/json"
"errors"
"fmt"
"testing"
)
func TestDiagnosticMetadataPreservesTypedErrorContract(t *testing.T) {
permission := NewPermissionError(SubtypeMissingScope, "missing scope").
WithMissingScopes("im:message")
wrapped := WithDiagnosticMetadata(permission, DiagnosticMetadata{
Origin: "proxy",
ProxyRequestID: "proxy_req_1",
})
var gotPermission *PermissionError
if !errors.As(wrapped, &gotPermission) || gotPermission != permission {
t.Fatalf("errors.As() = %p, want original permission error %p", gotPermission, permission)
}
problem, ok := ProblemOf(wrapped)
if !ok || problem != &permission.Problem {
t.Fatalf("ProblemOf() = (%p, %v), want original Problem %p", problem, ok, &permission.Problem)
}
metadata, ok := DiagnosticMetadataOf(wrapped)
if !ok || metadata.Origin != "proxy" || metadata.ProxyRequestID != "proxy_req_1" {
t.Fatalf("DiagnosticMetadataOf() = (%#v, %v)", metadata, ok)
}
raw, err := json.Marshal(wrapped)
if err != nil {
t.Fatal(err)
}
var object map[string]any
if err := json.Unmarshal(raw, &object); err != nil {
t.Fatal(err)
}
if object["origin"] != "proxy" || object["proxy_request_id"] != "proxy_req_1" {
t.Fatalf("metadata missing from JSON: %s", raw)
}
missingScopes, ok := object["missing_scopes"].([]any)
if !ok || len(missingScopes) != 1 || missingScopes[0] != "im:message" {
t.Fatalf("typed extension fields missing from JSON: %s", raw)
}
}
func TestDiagnosticMetadataMergesWithoutMutatingExistingWrapper(t *testing.T) {
typed := NewNetworkError(SubtypeUpstreamUnavailable, "unavailable")
withOrigin := WithDiagnosticMetadata(typed, DiagnosticMetadata{Origin: "proxy"})
withRequestID := WithDiagnosticMetadata(withOrigin, DiagnosticMetadata{ProxyRequestID: "proxy_req_2"})
original, _ := DiagnosticMetadataOf(withOrigin)
if original.ProxyRequestID != "" {
t.Fatalf("existing wrapper was mutated: %#v", original)
}
merged, ok := DiagnosticMetadataOf(withRequestID)
if !ok || merged.Origin != "proxy" || merged.ProxyRequestID != "proxy_req_2" {
t.Fatalf("merged metadata = (%#v, %v)", merged, ok)
}
}
func TestDiagnosticMetadataPreservesOuterErrorContext(t *testing.T) {
cause := errors.New("transport failed")
typed := NewNetworkError(SubtypeNetworkTransport, "request failed").WithCause(cause)
outer := fmt.Errorf("fetch document: %w", typed)
wrapped := WithDiagnosticMetadata(outer, DiagnosticMetadata{Origin: "proxy"})
if got, want := wrapped.Error(), outer.Error(); got != want {
t.Fatalf("Error() = %q, want %q", got, want)
}
if !errors.Is(wrapped, cause) {
t.Fatal("metadata wrapper lost the original cause chain")
}
var gotTyped *NetworkError
if !errors.As(wrapped, &gotTyped) || gotTyped != typed {
t.Fatalf("errors.As() = %p, want original typed error %p", gotTyped, typed)
}
}
func TestDiagnosticMetadataDoesNotCrossTypedProducerBoundary(t *testing.T) {
inner := NewNetworkError(SubtypeUpstreamUnavailable, "proxy unavailable")
annotatedInner := WithDiagnosticMetadata(inner, DiagnosticMetadata{
Origin: "proxy",
ProxyRequestID: "proxy_req_inner",
})
outer := NewInternalError(SubtypeUnknown, "business reclassified failure").
WithCause(annotatedInner)
if metadata, ok := DiagnosticMetadataOf(outer); ok {
t.Fatalf("outer typed producer inherited inner metadata: %#v", metadata)
}
wrapped := WithDiagnosticMetadata(outer, DiagnosticMetadata{Origin: "cli"})
problem, ok := ProblemOf(wrapped)
if !ok || problem != &outer.Problem {
t.Fatalf("ProblemOf() = (%p, %v), want outer Problem %p", problem, ok, &outer.Problem)
}
if problem.Category != CategoryInternal ||
problem.Subtype != SubtypeUnknown ||
problem.Message != "business reclassified failure" {
t.Fatalf("outer typed identity changed: %#v", problem)
}
metadata, ok := DiagnosticMetadataOf(wrapped)
if !ok || metadata.Origin != "cli" || metadata.ProxyRequestID != "" {
t.Fatalf("outer metadata = (%#v, %v), want cli without inner request id", metadata, ok)
}
innerMetadata, ok := DiagnosticMetadataOf(annotatedInner)
if !ok ||
innerMetadata.Origin != "proxy" ||
innerMetadata.ProxyRequestID != "proxy_req_inner" {
t.Fatalf("inner metadata was mutated: (%#v, %v)", innerMetadata, ok)
}
}

View File

@@ -27,7 +27,11 @@ func TestPermissionError_MarshalJSON_HasAllWireFields(t *testing.T) {
Identity: "user",
ConsoleURL: "https://example",
}
b, err := json.Marshal(pe)
withMetadata := WithDiagnosticMetadata(pe, DiagnosticMetadata{
Origin: "proxy",
ProxyRequestID: "proxy_req_123",
})
b, err := json.Marshal(withMetadata)
if err != nil {
t.Fatal(err)
}
@@ -39,6 +43,8 @@ func TestPermissionError_MarshalJSON_HasAllWireFields(t *testing.T) {
`"message":"x"`,
`"hint":"y"`,
`"log_id":"lg"`,
`"origin":"proxy"`,
`"proxy_request_id":"proxy_req_123"`,
`"missing_scopes":["docx:document"]`,
`"identity":"user"`,
`"console_url":"https://example"`,

View File

@@ -48,11 +48,13 @@ const (
// CategoryNetwork subtypes
const (
SubtypeNetworkTransport Subtype = "transport" // fallback when no more-specific network subtype matches
SubtypeNetworkTimeout Subtype = "timeout" // dial / read timeout
SubtypeNetworkTLS Subtype = "tls" // TLS handshake / cert failure
SubtypeNetworkDNS Subtype = "dns" // DNS resolution failure
SubtypeNetworkServer Subtype = "server_error" // upstream HTTP 5xx
SubtypeNetworkTransport Subtype = "transport" // fallback when no more-specific network subtype matches
SubtypeNetworkTimeout Subtype = "timeout" // dial / read timeout
SubtypeNetworkTLS Subtype = "tls" // TLS handshake / cert failure
SubtypeNetworkDNS Subtype = "dns" // DNS resolution failure
SubtypeNetworkServer Subtype = "server_error" // upstream HTTP 5xx
SubtypeCredentialSourceUnavailable Subtype = "credential_source_unavailable" // external credential program or identity service is temporarily unavailable
SubtypeUpstreamUnavailable Subtype = "upstream_unavailable" // external proxy cannot reach the requested upstream service
)
// CategoryAPI subtypes

107
events/application/menu.go Normal file
View File

@@ -0,0 +1,107 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"encoding/json"
"strings"
"github.com/larksuite/cli/internal/event"
)
// BotMenuOutput is the flattened shape for application.bot.menu_v6.
type BotMenuOutput struct {
Type string `json:"type" desc:"Event type; always application.bot.menu_v6"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
AppID string `json:"app_id,omitempty" desc:"Application ID from the event header"`
TenantKey string `json:"tenant_key,omitempty" desc:"Tenant key from the event header"`
EventKey string `json:"event_key,omitempty" desc:"Developer-defined bot menu event key"`
MenuTimestamp string `json:"menu_timestamp,omitempty" desc:"Menu click timestamp from the event body" kind:"timestamp_ms"`
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id; kept as a short alias of operator_open_id" kind:"open_id"`
OperatorOpenID string `json:"operator_open_id,omitempty" desc:"Operator open_id" kind:"open_id"`
OperatorUnionID string `json:"operator_union_id,omitempty" desc:"Operator union_id" kind:"union_id"`
OperatorUserID string `json:"operator_user_id,omitempty" desc:"Operator user_id" kind:"user_id"`
OperatorName string `json:"operator_name,omitempty" desc:"Operator display name"`
}
func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
AppID string `json:"app_id"`
TenantKey string `json:"tenant_key"`
} `json:"header"`
Event struct {
EventKey string `json:"event_key"`
Timestamp json.RawMessage `json:"timestamp"`
Operator struct {
OperatorID struct {
OpenID string `json:"open_id"`
UnionID string `json:"union_id"`
UserID string `json:"user_id"`
} `json:"operator_id"`
OperatorName string `json:"operator_name"`
} `json:"operator"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
menuTimestamp := timestampMillisString(envelope.Event.Timestamp)
timestamp := envelope.Header.CreateTime
if timestamp == "" {
timestamp = menuTimestamp
}
operatorID := envelope.Event.Operator.OperatorID.OpenID
out := &BotMenuOutput{
Type: eventTypeBotMenuV6,
EventID: envelope.Header.EventID,
Timestamp: timestamp,
AppID: envelope.Header.AppID,
TenantKey: envelope.Header.TenantKey,
EventKey: envelope.Event.EventKey,
MenuTimestamp: menuTimestamp,
OperatorID: operatorID,
OperatorOpenID: operatorID,
OperatorUnionID: envelope.Event.Operator.OperatorID.UnionID,
OperatorUserID: envelope.Event.Operator.OperatorID.UserID,
OperatorName: envelope.Event.Operator.OperatorName,
}
return json.Marshal(out)
}
func rawScalarString(raw json.RawMessage) string {
s := strings.TrimSpace(string(raw))
if s == "" || s == "null" {
return ""
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
return text
}
return s
}
func timestampMillisString(raw json.RawMessage) string {
s := rawScalarString(raw)
if len(s) == 10 && allDigits(s) {
return s + "000"
}
return s
}
func allDigits(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return s != ""
}

View File

@@ -0,0 +1,227 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"encoding/json"
"reflect"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestKeysBotMenuMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 1 {
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
}
def := keys[0]
if def.Key != eventTypeBotMenuV6 {
t.Errorf("Key = %q, want %q", def.Key, eventTypeBotMenuV6)
}
if def.EventType != eventTypeBotMenuV6 {
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeBotMenuV6)
}
if def.SubscriptionType != "" {
t.Errorf("SubscriptionType = %q, want default event subscription", def.SubscriptionType)
}
if def.Schema.Custom == nil {
t.Fatal("Schema.Custom is nil")
}
if def.Schema.Custom.Type != reflect.TypeOf(BotMenuOutput{}) {
t.Errorf("custom type = %v, want BotMenuOutput", def.Schema.Custom.Type)
}
if def.Schema.Native != nil {
t.Fatal("Schema.Native must be nil for processed output")
}
if def.Process == nil {
t.Fatal("Process is nil")
}
if !reflect.DeepEqual(def.AuthTypes, []string{"bot"}) {
t.Errorf("AuthTypes = %#v", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeBotMenuV6}) {
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
}
}
func TestBotMenuRegistersCleanly(t *testing.T) {
const key = eventTypeBotMenuV6
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
for _, def := range Keys() {
event.RegisterKey(def)
}
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
}
}
func TestProcessBotMenu(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_001",
"event_type": "application.bot.menu_v6",
"create_time": "1776409469273",
"app_id": "cli_test",
"tenant_key": "tenant_test"
},
"event": {
"event_key": "start_eval",
"timestamp": 1776409469000,
"operator": {
"operator_id": {
"open_id": "ou_operator",
"union_id": "on_operator",
"user_id": "user_operator"
},
"operator_name": "Test User"
}
}
}`
out := runBotMenu(t, payload)
if out.Type != eventTypeBotMenuV6 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
}
if out.EventID != "ev_menu_001" {
t.Errorf("EventID = %q", out.EventID)
}
if out.Timestamp != "1776409469273" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
if out.EventKey != "start_eval" {
t.Errorf("EventKey = %q", out.EventKey)
}
if out.MenuTimestamp != "1776409469000" {
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
}
if out.OperatorID != "ou_operator" || out.OperatorOpenID != "ou_operator" {
t.Errorf("OperatorID/OperatorOpenID = %q/%q", out.OperatorID, out.OperatorOpenID)
}
if out.OperatorUnionID != "on_operator" {
t.Errorf("OperatorUnionID = %q", out.OperatorUnionID)
}
if out.OperatorUserID != "user_operator" {
t.Errorf("OperatorUserID = %q", out.OperatorUserID)
}
if out.OperatorName != "Test User" {
t.Errorf("OperatorName = %q", out.OperatorName)
}
if out.AppID != "cli_test" || out.TenantKey != "tenant_test" {
t.Errorf("AppID/TenantKey = %q/%q", out.AppID, out.TenantKey)
}
}
func TestProcessBotMenuStringTimestampFallback(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_002",
"event_type": "application.bot.menu_v6"
},
"event": {
"event_key": "start_eval",
"timestamp": "1776409469001",
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Timestamp != "1776409469001" {
t.Errorf("Timestamp fallback = %q", out.Timestamp)
}
if out.MenuTimestamp != "1776409469001" {
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
}
}
func TestProcessBotMenuSecondsTimestampFallback(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_seconds",
"event_type": "application.bot.menu_v6"
},
"event": {
"event_key": "start_eval",
"timestamp": 1694592375,
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Timestamp != "1694592375000" {
t.Errorf("Timestamp fallback = %q, want seconds normalized to milliseconds", out.Timestamp)
}
if out.MenuTimestamp != "1694592375000" {
t.Errorf("MenuTimestamp = %q, want seconds normalized to milliseconds", out.MenuTimestamp)
}
}
func TestProcessBotMenuTypeUsesLocalConstant(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_003",
"event_type": "unexpected.event_type",
"create_time": "1776409469275"
},
"event": {
"event_key": "start_eval",
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Type != eventTypeBotMenuV6 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
}
}
func TestProcessBotMenuMalformedPayload(t *testing.T) {
raw := &event.RawEvent{
EventID: "ev_bad",
EventType: eventTypeBotMenuV6,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processBotMenu(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
}
func runBotMenu(t *testing.T, payload string) BotMenuOutput {
t.Helper()
raw := &event.RawEvent{
EventID: "ev_test",
EventType: eventTypeBotMenuV6,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processBotMenu(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("processBotMenu: %v", err)
}
var out BotMenuOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("unmarshal output: %v\n%s", err, got)
}
return out
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package application registers Application-domain EventKeys.
package application
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
const eventTypeBotMenuV6 = "application.bot.menu_v6"
// Keys returns all Application-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeBotMenuV6,
DisplayName: "Bot menu",
Description: "Triggered when a user clicks a custom bot menu item whose action is configured as a push event.",
EventType: eventTypeBotMenuV6,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(BotMenuOutput{})},
},
Process: processBotMenu,
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{eventTypeBotMenuV6},
},
}
}

View File

@@ -5,6 +5,7 @@
package events
import (
"github.com/larksuite/cli/events/application"
"github.com/larksuite/cli/events/approval"
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
@@ -17,6 +18,7 @@ import (
// Mail is intentionally omitted in this phase.
func init() {
all := [][]event.KeyDefinition{
application.Keys(),
approval.Keys(),
im.Keys(),
minutes.Keys(),

View File

@@ -0,0 +1,23 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-internal-auth-test-*")
if err != nil {
panic(err)
}
if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil {
panic(err)
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build extended
package build
const Edition = "extended"
func Capabilities() []string {
return []string{"external-credential-platform"}
}

View File

@@ -0,0 +1,10 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !extended
package build
const Edition = "standard"
func Capabilities() []string { return []string{} }

View File

@@ -23,6 +23,7 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/requestcontext"
"github.com/larksuite/cli/internal/util"
)
@@ -119,6 +120,7 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark
// (a typed *errs.* from resolveAccessToken's missing-credential paths or
// elsewhere) flow through unchanged.
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
ctx = requestcontext.WithIdentity(ctx, as)
var opts []larkcore.RequestOptionFunc
token, err := c.resolveAccessToken(ctx, as)
@@ -155,6 +157,7 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c
// HTTP errors (status >= 400) are handled internally: the body is read (up to 4 KB),
// closed, and returned as a typed *errs.NetworkError — callers only receive successful responses.
func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.Identity, opts ...Option) (*http.Response, error) {
ctx = requestcontext.WithIdentity(ctx, as)
cfg := buildConfig(opts)
// Resolve auth
@@ -212,6 +215,9 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
resp, err := httpClient.Do(httpReq)
if err != nil {
cancel()
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
}
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}

View File

@@ -0,0 +1,169 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"context"
"io"
"net/http"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/requestcontext"
)
// RemoteFile is a service-returned file reference that has passed the active
// runtime's data-plane policy. Its URL is intentionally private so callers
// cannot construct a trusted reference without Validate.
type RemoteFile struct {
rawURL string
}
// URL returns the original service-provided URL verbatim.
func (f RemoteFile) URL() string { return f.rawURL }
// NewRequest constructs a request that remains bound to this validated file
// reference. Business shortcuts do not construct raw URL requests themselves.
func (f RemoteFile) NewRequest(ctx context.Context, method string, body io.Reader) (*http.Request, error) {
if f.rawURL == "" {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"cannot build a request for an empty remote file reference")
}
req, err := http.NewRequestWithContext(ctx, method, f.rawURL, body)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
"build remote file request: %v", err).WithCause(err)
}
return req, nil
}
// DirectRemoteFileValidator applies a caller-specific policy to direct-mode
// URLs. Proxy handles are validated by the managed data-plane policy instead.
type DirectRemoteFileValidator func(context.Context, string) error
// HTTPClientProvider resolves the runtime's configured HTTP client without
// exposing raw client construction to business shortcuts.
type HTTPClientProvider func() (*http.Client, error)
// RemoteFilePolicy is the source-neutral runtime contract for validating and
// routing service-returned file references.
type RemoteFilePolicy interface {
ValidateRemoteFile(rawURL string) error
UsesManagedFilePlane() bool
}
// RemoteFiles is the runtime boundary for service-returned file references.
// It keeps credential mode and edition routing out of business shortcuts.
// Every service-returned file byte transfer must pass through Validate,
// RemoteFile.NewRequest, and Do so the active data-plane policy is enforced.
type RemoteFiles struct {
policy RemoteFilePolicy
managedClient HTTPClientProvider
identity core.Identity
}
// NewRemoteFiles creates a file boundary for one resolved runtime identity.
func NewRemoteFiles(
policy RemoteFilePolicy,
managedClient HTTPClientProvider,
identity core.Identity,
) *RemoteFiles {
return &RemoteFiles{
policy: policy,
managedClient: managedClient,
identity: identity,
}
}
// Validate checks a known service-returned file URL and returns an opaque,
// typed reference. A directValidator, when supplied, is applied only to the
// ordinary/direct data plane; proxy handles use the configured proxy policy.
func (r *RemoteFiles) Validate(
ctx context.Context,
rawURL string,
directValidator ...DirectRemoteFileValidator,
) (RemoteFile, error) {
if r == nil {
return RemoteFile{}, errs.NewInternalError(errs.SubtypeUnknown, "remote file runtime is unavailable")
}
if r.policy != nil {
if err := r.policy.ValidateRemoteFile(rawURL); err != nil {
return RemoteFile{}, err
}
}
if !usesManagedFilePlane(r.policy) && len(directValidator) > 0 && directValidator[0] != nil {
if err := directValidator[0](ctx, rawURL); err != nil {
return RemoteFile{}, err
}
}
return RemoteFile{rawURL: rawURL}, nil
}
// RequirePortableURL rejects commands that would return a managed file handle
// for use outside this CLI. The check is capability-based, so callers do not
// branch on a credential mode.
func (r *RemoteFiles) RequirePortableURL(param string) error {
if r == nil || !usesManagedFilePlane(r.policy) {
return nil
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"%s is unavailable because this credential source requires CLI-managed file routing", param).
WithParam(param).
WithHint("omit %s and let lark-cli transfer the file", param)
}
// Do executes a request for a validated remote file. Ordinary/direct runtimes
// use directClient unchanged; a nil directClient gets the same isolated
// DefaultTransport client historically used by presigned Apps transfers.
// Managed runtimes select the proxy-aware client and copy the caller's
// timeout/redirect policy so existing transfer semantics remain intact.
func (r *RemoteFiles) Do(req *http.Request, file RemoteFile, directClient *http.Client) (*http.Response, error) {
if r == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown, "remote file runtime is unavailable")
}
if req == nil || req.URL == nil || file.rawURL == "" || req.URL.String() != file.rawURL {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"remote file request does not match its validated reference")
}
if directClient == nil {
directClient = &http.Client{Transport: http.DefaultTransport}
}
selected := directClient
managedPlane := usesManagedFilePlane(r.policy)
if managedPlane {
if r.managedClient == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"managed remote file runtime has no HTTP client")
}
managed, err := r.managedClient()
if err != nil {
return nil, err
}
if managed == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"managed remote file runtime returned no HTTP client")
}
if directClient != nil && managed != directClient {
cloned := *managed
cloned.Timeout = directClient.Timeout
if directClient.CheckRedirect != nil {
cloned.CheckRedirect = directClient.CheckRedirect
}
selected = &cloned
} else {
selected = managed
}
}
if !managedPlane {
return selected.Do(req)
}
requestCtx := requestcontext.WithIdentity(req.Context(), r.identity)
return selected.Do(req.Clone(requestCtx))
}
func usesManagedFilePlane(policy RemoteFilePolicy) bool {
return policy != nil && policy.UsesManagedFilePlane()
}

View File

@@ -0,0 +1,317 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"go/ast"
"go/parser"
"go/token"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/internal/vfs"
)
const externalCredentialImplementation = "github.com/larksuite/cli/internal/externalcredential"
// These functions own protocols that are explicitly outside the
// service-returned file plane: user-supplied external downloads and MCP.
// Adding a boundary is an architectural decision; //nolint alone must never
// make a new raw HTTP path possible.
var shortcutRawHTTPBoundaries = map[string]string{
"common/mcp_client.go:DoMCPCall": "MCP protocol client",
"doc/doc_resource_cover.go:downloadDocCoverURL": "validated user-supplied cover URL",
"doc/doc_resource_cover.go:newDocCoverHTTPClient": "guarded external-download client",
"doc/doc_resource_cover.go:cloneDocCoverTransport": "guarded external-download transport",
"im/helpers.go:startURLDownload": "validated user-supplied media URL",
}
func TestCoreDoesNotOwnExternalCredentialProductModel(t *testing.T) {
checkCoreDirectoryIsProductNeutral(t, filepath.Join("..", "core"))
}
func TestRuntimeFrameworkDoesNotImportExternalCredentialImplementation(t *testing.T) {
for _, dir := range []string{
".",
filepath.Join("..", "cmdutil"),
filepath.Join("..", "credential"),
} {
checkDirectoryDoesNotImportExternalCredential(t, dir)
}
}
func checkCoreDirectoryIsProductNeutral(t *testing.T, dir string) {
t.Helper()
entries, err := vfs.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
forbidden := []string{
"externalcredential",
"external-credential",
"credential_proxy",
"platform_proxy",
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if entry.IsDir() {
checkCoreDirectoryIsProductNeutral(t, path)
continue
}
if !strings.HasSuffix(entry.Name(), ".go") {
continue
}
src, err := vfs.ReadFile(path)
if err != nil {
t.Fatal(err)
}
normalizedSource := strings.ToLower(string(src))
for _, productSymbol := range forbidden {
if strings.Contains(normalizedSource, strings.ToLower(productSymbol)) {
t.Errorf("%s contains external credential product symbol %q; keep the core profile model product-neutral", path, productSymbol)
}
}
}
}
func TestBusinessShortcutsUseRuntimeCredentialBoundaries(t *testing.T) {
checkBusinessShortcutDirectory(t, filepath.Join("..", "..", "shortcuts"))
}
func TestShortcutRawHTTPConstructionIsConfinedToExplicitBoundaries(t *testing.T) {
root := filepath.Join("..", "..", "shortcuts")
checkShortcutRawHTTPDirectory(t, root, root)
}
func TestRawHTTPConstructionGuardRecognizesAliasedBypasses(t *testing.T) {
const source = `package fixture
import nethttp "net/http"
func bypass() {
_ = &nethttp.Client{}
_, _ = nethttp.NewRequest(nethttp.MethodGet, "https://files.example", nil)
_ = nethttp.DefaultClient
}`
file, err := parser.ParseFile(token.NewFileSet(), "fixture.go", source, 0)
if err != nil {
t.Fatal(err)
}
uses := rawHTTPConstructions(file, map[string]struct{}{"nethttp": {}})
joined := strings.Join(uses, ",")
for _, want := range []string{"Client literal", "NewRequest", "DefaultClient"} {
if !strings.Contains(joined, want) {
t.Fatalf("raw HTTP uses = %q, want %q", joined, want)
}
}
}
func checkDirectoryDoesNotImportExternalCredential(t *testing.T, dir string) {
t.Helper()
entries, err := vfs.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if entry.IsDir() {
checkDirectoryDoesNotImportExternalCredential(t, path)
continue
}
if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
src, err := vfs.ReadFile(path)
if err != nil {
t.Fatal(err)
}
file, err := parser.ParseFile(token.NewFileSet(), path, src, parser.ImportsOnly)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
for _, spec := range file.Imports {
importPath, err := strconv.Unquote(spec.Path.Value)
if err != nil {
t.Fatalf("parse import in %s: %v", path, err)
}
if importPath == externalCredentialImplementation ||
strings.HasPrefix(importPath, externalCredentialImplementation+"/") {
t.Errorf("%s imports external credential implementation; inject a source-neutral runtime boundary", path)
}
}
}
}
func checkBusinessShortcutDirectory(t *testing.T, dir string) {
t.Helper()
entries, err := vfs.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if entry.IsDir() {
if entry.Name() != "common" {
checkBusinessShortcutDirectory(t, path)
}
continue
}
if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
src, err := vfs.ReadFile(path)
if err != nil {
t.Fatal(err)
}
file, err := parser.ParseFile(token.NewFileSet(), path, src, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
for _, spec := range file.Imports {
importPath, err := strconv.Unquote(spec.Path.Value)
if err != nil {
t.Fatalf("parse import in %s: %v", path, err)
}
if importPath == externalCredentialImplementation ||
strings.HasPrefix(importPath, externalCredentialImplementation+"/") {
t.Errorf("%s imports external credential implementation; use a source-neutral runtime capability boundary", path)
}
}
ast.Inspect(file, func(node ast.Node) bool {
selector, ok := node.(*ast.SelectorExpr)
if ok && selector.Sel.Name == "ExternalCredential" {
t.Errorf("%s selects ExternalCredential directly; use a runtime capability boundary", path)
}
return true
})
}
}
func checkShortcutRawHTTPDirectory(t *testing.T, root, dir string) {
t.Helper()
entries, err := vfs.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if entry.IsDir() {
checkShortcutRawHTTPDirectory(t, root, path)
continue
}
if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
src, err := vfs.ReadFile(path)
if err != nil {
t.Fatal(err)
}
file, err := parser.ParseFile(token.NewFileSet(), path, src, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
httpAliases := make(map[string]struct{})
for _, spec := range file.Imports {
importPath, err := strconv.Unquote(spec.Path.Value)
if err != nil {
t.Fatalf("parse import in %s: %v", path, err)
}
if importPath != "net/http" {
continue
}
alias := "http"
if spec.Name != nil {
alias = spec.Name.Name
}
if alias == "." {
t.Errorf("%s dot-imports net/http; raw HTTP boundaries must remain statically auditable", path)
continue
}
if alias != "_" {
httpAliases[alias] = struct{}{}
}
}
if len(httpAliases) == 0 {
continue
}
relativePath, err := filepath.Rel(root, path)
if err != nil {
t.Fatalf("relative shortcut path for %s: %v", path, err)
}
relativePath = filepath.ToSlash(relativePath)
for _, decl := range file.Decls {
fn, isFunc := decl.(*ast.FuncDecl)
uses := rawHTTPConstructions(decl, httpAliases)
if len(uses) == 0 {
continue
}
if !isFunc {
t.Errorf("%s constructs raw net/http at package scope (%s); route service-returned file bytes through RuntimeContext.RemoteFiles",
path, strings.Join(uses, ", "))
continue
}
boundary := relativePath + ":" + fn.Name.Name
if _, allowed := shortcutRawHTTPBoundaries[boundary]; allowed {
continue
}
t.Errorf("%s function %s constructs raw net/http (%s); route service-returned file bytes through RuntimeContext.RemoteFiles or declare a reviewed non-file-plane boundary",
path, fn.Name.Name, strings.Join(uses, ", "))
}
}
}
func rawHTTPConstructions(node ast.Node, aliases map[string]struct{}) []string {
seen := make(map[string]struct{})
var uses []string
add := func(name string) {
if _, ok := seen[name]; ok {
return
}
seen[name] = struct{}{}
uses = append(uses, name)
}
isHTTPSelector := func(selector *ast.SelectorExpr) bool {
ident, ok := selector.X.(*ast.Ident)
if !ok {
return false
}
_, ok = aliases[ident.Name]
return ok
}
ast.Inspect(node, func(current ast.Node) bool {
switch typed := current.(type) {
case *ast.CallExpr:
selector, ok := typed.Fun.(*ast.SelectorExpr)
if !ok || !isHTTPSelector(selector) {
break
}
switch selector.Sel.Name {
case "NewRequest", "NewRequestWithContext", "Get", "Post", "PostForm", "Head", "Serve", "ListenAndServe":
add(selector.Sel.Name)
}
case *ast.CompositeLit:
selector, ok := typed.Type.(*ast.SelectorExpr)
if !ok || !isHTTPSelector(selector) {
break
}
switch selector.Sel.Name {
case "Client", "Request", "Transport":
add(selector.Sel.Name + " literal")
}
case *ast.SelectorExpr:
if !isHTTPSelector(typed) {
break
}
switch typed.Sel.Name {
case "DefaultClient", "DefaultTransport":
add(typed.Sel.Name)
}
}
return true
})
return uses
}

View File

@@ -0,0 +1,177 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/requestcontext"
)
type testRemoteFilePolicy struct {
allowed string
managed bool
}
func (p testRemoteFilePolicy) ValidateRemoteFile(rawURL string) error {
if p.allowed != "" && rawURL != p.allowed {
return errors.New("remote file rejected")
}
return nil
}
func (p testRemoteFilePolicy) UsesManagedFilePlane() bool { return p.managed }
func TestRemoteFilesDirectPreservesCallerClientAndPolicy(t *testing.T) {
var managedCalls, directCalls, validationCalls int
direct := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
directCalls++
if got := requestcontext.Identity(req.Context()); got != "" {
t.Fatalf("direct request context was changed: identity = %q", got)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("ok")),
Request: req,
}, nil
})}
files := NewRemoteFiles(nil, func() (*http.Client, error) {
managedCalls++
return nil, errors.New("must not be called")
}, core.AsUser)
file, err := files.Validate(context.Background(), "https://files.example/object?signature=x",
func(_ context.Context, rawURL string) error {
validationCalls++
if rawURL != "https://files.example/object?signature=x" {
t.Fatalf("validator URL = %q", rawURL)
}
return nil
})
if err != nil {
t.Fatal(err)
}
req, err := file.NewRequest(context.Background(), http.MethodGet, nil)
if err != nil {
t.Fatal(err)
}
resp, err := files.Do(req, file, direct)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if directCalls != 1 || managedCalls != 0 || validationCalls != 1 {
t.Fatalf("calls direct=%d managed=%d validation=%d", directCalls, managedCalls, validationCalls)
}
}
func TestRemoteFilesManagedValidatesAndRoutesOpaqueHandle(t *testing.T) {
const opaqueURL = "https://proxy.example/lark-cli/v1/files/opaque_1"
policy := testRemoteFilePolicy{allowed: opaqueURL, managed: true}
var managedCalls, directCalls, directValidationCalls int
managed := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
managedCalls++
if got := requestcontext.Identity(req.Context()); got != core.AsBot {
t.Fatalf("request identity = %q, want bot", got)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("ok")),
Request: req,
}, nil
})}
direct := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
directCalls++
return nil, errors.New("must not be called")
})}
files := NewRemoteFiles(policy, func() (*http.Client, error) { return managed, nil }, core.AsBot)
if _, err := files.Validate(context.Background(), "https://objects.example/raw?signature=secret"); err == nil {
t.Fatal("raw object URL accepted in managed mode")
}
file, err := files.Validate(context.Background(), opaqueURL,
func(context.Context, string) error {
directValidationCalls++
return errors.New("must not be called")
})
if err != nil {
t.Fatal(err)
}
req, err := file.NewRequest(context.Background(), http.MethodGet, nil)
if err != nil {
t.Fatal(err)
}
resp, err := files.Do(req, file, direct)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if managedCalls != 1 || directCalls != 0 || directValidationCalls != 0 {
t.Fatalf("calls managed=%d direct=%d validation=%d", managedCalls, directCalls, directValidationCalls)
}
}
func TestRemoteFilesDoRejectsReferenceMismatch(t *testing.T) {
files := NewRemoteFiles(nil, nil, core.AsUser)
file, err := files.Validate(context.Background(), "https://files.example/one")
if err != nil {
t.Fatal(err)
}
req, _ := http.NewRequest(http.MethodGet, "https://files.example/two", nil)
if _, err := files.Do(req, file, http.DefaultClient); err == nil {
t.Fatal("mismatched request and validated file accepted")
}
}
func TestRemoteFilesRequirePortableURL(t *testing.T) {
files := NewRemoteFiles(testRemoteFilePolicy{managed: true}, nil, core.AsBot)
err := files.RequirePortableURL("--url-only")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want ValidationError", err, err)
}
if validationErr.Subtype != errs.SubtypeFailedPrecondition || validationErr.Param != "--url-only" {
t.Fatalf("error = subtype %q param %q", validationErr.Subtype, validationErr.Param)
}
}
func TestCallAPIPreservesURLsInArbitraryJSON(t *testing.T) {
const rawURL = "https://objects.example/raw?signature=opaque"
apiClient, _ := newTestAPIClient(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
resp := jsonResponse(map[string]any{
"code": 0,
"data": map[string]any{
"download_url": rawURL,
"nested": []any{map[string]any{"url": rawURL}},
},
})
resp.Request = req
return resp, nil
}))
result, err := apiClient.CallAPI(context.Background(), RawApiRequest{
Method: http.MethodGet,
URL: "/open-apis/example/v1/raw",
As: core.AsBot,
})
if err != nil {
t.Fatal(err)
}
data := result.(map[string]any)["data"].(map[string]any)
if got := data["download_url"]; got != rawURL {
t.Fatalf("download_url = %v, want exact arbitrary JSON value %q", got, rawURL)
}
nested := data["nested"].([]any)[0].(map[string]any)
if got := nested["url"]; got != rawURL {
t.Fatalf("nested url = %v, want exact arbitrary JSON value %q", got, rawURL)
}
}

View File

@@ -132,16 +132,14 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
})
}
// Content safety scanning for non-JSON presentation formats.
scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
}
output.FormatValue(opts.Out, result, opts.Format)
return nil
emitter := output.NewEmitter(output.EmitterConfig{
Out: opts.Out,
ErrOut: opts.ErrOut,
CommandPath: opts.CommandPath,
Identity: string(identity),
NoticeProvider: output.GetNotice,
})
return emitter.Success(result, output.EmitOptions{Format: opts.Format.String()})
}
// Non-JSON (binary) responses.

View File

@@ -18,6 +18,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
@@ -239,6 +240,87 @@ func TestHandleResponse_JSON(t *testing.T) {
}
}
func TestHandleResponse_NonJSONFormatsEmitExactStructuredResponseBytes(t *testing.T) {
tests := []struct {
name string
format output.Format
want string
}{
{
name: "ndjson",
format: output.FormatNDJSON,
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Bob\"}\n",
},
{
name: "table",
format: output.FormatTable,
want: "id name \n── ─────\n1 Alice\n2 Bob \n",
},
{
name: "csv",
format: output.FormatCSV,
want: "id,name\n1,Alice\n2,Bob\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
reg := &httpmock.Registry{}
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "name": "Alice"},
map[string]interface{}{"id": "2", "name": "Bob"},
},
"has_more": false,
},
},
})
httpResp, err := httpmock.NewClient(reg).Get("https://open.feishu.cn/open-apis/test/v1/items")
if err != nil {
t.Fatalf("fixture request failed: %v", err)
}
body, err := io.ReadAll(httpResp.Body)
_ = httpResp.Body.Close()
if err != nil {
t.Fatalf("read fixture response: %v", err)
}
resp := &larkcore.ApiResp{
StatusCode: httpResp.StatusCode,
Header: httpResp.Header.Clone(),
RawBody: body,
}
var out bytes.Buffer
var errOut bytes.Buffer
err = HandleResponse(resp, ResponseOptions{
Format: tt.format,
Identity: core.AsBot,
Out: &out,
ErrOut: &errOut,
CommandPath: "lark-cli api GET",
})
if err != nil {
t.Fatalf("HandleResponse() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
reg.Verify(t)
})
}
}
func TestHandleResponse_JSONWithJqUsesSuccessEnvelope(t *testing.T) {
body := []byte(`{"code":0,"msg":"ok","data":{"id":"1"}}`)
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})

View File

@@ -20,6 +20,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/runtimeplan"
)
// Factory holds shared dependencies injected into every command.
@@ -43,6 +44,8 @@ type Factory struct {
Credential *credential.CredentialProvider
runtimePlan *runtimeplan.Plan
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
@@ -57,6 +60,34 @@ func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
return f.FileIOProvider.ResolveFileIO(ctx)
}
// NewRemoteFiles returns the invocation's file-transfer boundary without
// exposing credential source, mode, or edition to business shortcuts.
func (f *Factory) NewRemoteFiles(identity core.Identity) *client.RemoteFiles {
if f == nil {
return client.NewRemoteFiles(nil, nil, identity)
}
return client.NewRemoteFiles(runtimeplan.Ensure(f.runtimePlan), f.HttpClient, identity)
}
// RuntimeDescription returns sanitized diagnostics for the active plan.
func (f *Factory) RuntimeDescription() runtimeplan.Description {
if f == nil {
return runtimeplan.Description{}
}
return runtimeplan.Ensure(f.runtimePlan).Describe()
}
// RuntimeStartupError reports whether invocation bootstrap failed before an
// effective credential/runtime configuration could be selected. It exposes
// only the source-neutral plan contract so diagnostic commands do not need to
// know which edition or credential product produced the failure.
func (f *Factory) RuntimeStartupError() error {
if f == nil {
return nil
}
return runtimeplan.Ensure(f.runtimePlan).StartupError()
}
// 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 credential hints.
@@ -210,26 +241,3 @@ func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient
Credential: f.Credential,
}, nil
}
// RequireBuiltinCredentialProvider returns a typed validation error when an
// extension provider is actively managing credentials. Intended for use as
// PersistentPreRunE on the auth and config parent commands.
//
// Returns nil when:
// - f.Credential is nil (test environments without credential setup)
// - No extension provider is active (built-in keychain/config path is used)
func (f *Factory) RequireBuiltinCredentialProvider(ctx context.Context, command string) error {
if f.Credential == nil {
return nil
}
provName, err := f.Credential.ActiveExtensionProviderName(ctx)
if err != nil {
return err
}
if provName == "" {
return nil
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%q is not supported: credentials are provided externally and do not support interactive management", command).
WithHint("If another tool or method for authorization is available in this environment, try that. Otherwise, ask the user to set up credentials through the appropriate channel.")
}

View File

@@ -22,64 +22,105 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/riskcontrol"
"github.com/larksuite/cli/internal/runtimeplan"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/transport"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
)
var (
initRegistryWithBrand = registry.InitWithBrand
initEmbeddedRegistryWithBrand = registry.InitEmbeddedWithBrand
)
// NewDefault creates a production Factory with cached closures.
// 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
// Phase 4: LarkClient derived from Credential and workspace policy
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// Preserve the established standalone Factory behavior. Product-specific
// runtime selection belongs to the CLI composition root, which calls
// NewDefaultWithRuntimePlan with one immutable startup snapshot.
core.SetCurrentWorkspace(core.DetectWorkspaceFromEnv(os.Getenv))
return newDefaultWithRuntimePlan(streams, inv, nil, runtimeplan.Default(), false)
}
// NewDefaultWithRuntimePlan creates a production Factory from the same
// immutable Profile snapshot and source-neutral plan used by startup routing.
func NewDefaultWithRuntimePlan(
streams *IOStreams,
inv InvocationContext,
profileConfig *core.MultiAppConfig,
plan *runtimeplan.Plan,
) *Factory {
return newDefaultWithRuntimePlan(streams, inv, profileConfig, plan, true)
}
func newDefaultWithRuntimePlan(
streams *IOStreams,
inv InvocationContext,
profileConfig *core.MultiAppConfig,
plan *runtimeplan.Plan,
useProfileSnapshot bool,
) *Factory {
streams = normalizeStreams(streams)
f := &Factory{
Keychain: keychain.Default(),
Invocation: inv,
IOStreams: streams,
Keychain: keychain.Default(),
Invocation: inv,
IOStreams: streams,
runtimePlan: runtimeplan.Ensure(plan),
}
// Workspace detection: determines which config subtree to use.
// Must run before any config or credential load, since those paths are
// workspace-scoped. Default is WorkspaceLocal — existing behavior unchanged.
ws := core.DetectWorkspaceFromEnv(os.Getenv)
core.SetCurrentWorkspace(ws)
// Inject workspace-aware dir into keychain's log system.
// This breaks the core↔keychain import cycle by using a function variable.
keychain.RuntimeDirFunc = core.GetRuntimeDir
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
workspaceConfig := core.NewConfigSnapshot()
if profileConfig != nil {
workspaceConfig = core.NewConfigSnapshotFrom(profileConfig)
}
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc(f)
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
f.Credential = buildCredentialProvider(credentialDeps{
Keychain: func() keychain.KeychainAccess { return f.Keychain },
Profile: inv.Profile,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
Keychain: func() keychain.KeychainAccess { return f.Keychain },
Profile: inv.Profile,
HttpClient: f.HttpClient,
ErrOut: f.IOStreams.ErrOut,
RuntimePlan: f.runtimePlan,
ProfileConfigSnapshot: profileConfig,
UseProfileSnapshot: useProfileSnapshot,
})
// Phase 3: Config derived from Credential via an explicit conversion boundary.
// Phase 3: Runtime config contains resolved account data only.
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
return nil, err
}
cfg := acct.ToCliConfig()
registry.InitWithBrand(cfg.Brand)
if f.runtimePlan.AllowsRemoteMetadata() {
initRegistryWithBrand(cfg.Brand)
} else {
// Defense in depth for callers that construct a Factory directly
// instead of going through cmd/build's composition root.
initEmbeddedRegistryWithBrand(cfg.Brand)
}
return cfg, nil
})
// Phase 4: LarkClient from Credential (placeholder AppSecret)
f.LarkClient = cachedLarkClientFunc(f)
// Phase 4: LarkClient composes account data and workspace policy at the SDK
// transport boundary.
f.LarkClient = cachedLarkClientFunc(f, workspaceConfig)
return f
}
@@ -108,13 +149,23 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
// .StderrIsTerminal field, which tests set directly.
var warnIfProxied = transport.WarnIfProxied
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var rt http.RoundTripper = transport.Shared()
var err error
rt, err = applyRuntimePlan(f, rt)
if err != nil {
return nil, err
}
// Risk control remains the final trusted header boundary before either
// the ordinary network transport or the managed proxy data plane.
rt = riskcontrol.NewTransport(rt, hostSignalSource)
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
@@ -128,7 +179,7 @@ func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
})
}
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
return sync.OnceValues(func() (*lark.Client, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -142,8 +193,20 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var sdkBase http.RoundTripper = transport.Shared()
sdkBase, err = applyRuntimePlan(f, sdkBase)
if err != nil {
return nil, err
}
// The innermost SDK boundary always strips reserved host-signal headers;
// a nil source makes it strip-only when workspace policy disables signal
// collection. A managed runtime applies its data-plane policy after this
// boundary so trusted signals remain associated with the original request.
sdkBase = riskcontrol.NewTransport(sdkBase, hostSignalSource)
sdkTransport := wrapSDKTransport(sdkBase)
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
Transport: sdkTransport,
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(acct.Brand)
@@ -152,26 +215,56 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
})
}
func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = transport.Shared()
sdkTransport = &RetryTransport{Base: sdkTransport}
func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
return wrapWithExtension(sdkTransport)
sdkTransport = wrapWithExtension(sdkTransport)
return sdkTransport
}
func applyRuntimePlan(f *Factory, base http.RoundTripper) (http.RoundTripper, error) {
if f == nil {
return base, nil
}
return runtimeplan.Ensure(f.runtimePlan).Wrap(base)
}
type credentialDeps struct {
Keychain func() keychain.KeychainAccess
Profile string
HttpClient func() (*http.Client, error)
ErrOut io.Writer
Keychain func() keychain.KeychainAccess
Profile string
HttpClient func() (*http.Client, error)
ErrOut io.Writer
RuntimePlan *runtimeplan.Plan
ProfileConfigSnapshot *core.MultiAppConfig
UseProfileSnapshot bool
}
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
plan := runtimeplan.Ensure(deps.RuntimePlan)
providers := extcred.Providers()
defaultAcct := credential.NewDefaultAccountProvider(deps.Keychain, deps.Profile)
defaultToken := credential.NewDefaultTokenProvider(defaultAcct, deps.HttpClient, deps.ErrOut)
localAcct := credential.NewDefaultAccountProvider(deps.Keychain, deps.Profile)
if deps.UseProfileSnapshot {
localAcct = credential.NewDefaultAccountProviderFromSnapshot(deps.Keychain, deps.Profile, deps.ProfileConfigSnapshot)
}
localToken := credential.NewDefaultTokenProvider(localAcct, deps.HttpClient, deps.ErrOut)
var defaultAcct credential.DefaultAccountResolver = localAcct
var defaultToken credential.DefaultTokenResolver = localToken
if startupErr := plan.StartupError(); startupErr != nil {
providers = []extcred.Provider{&runtimePlanErrorProvider{err: startupErr}}
defaultAcct = nil
defaultToken = nil
} else if provider, replace := plan.CredentialProvider(); provider != nil {
if replace {
providers = []extcred.Provider{provider}
defaultAcct = nil
defaultToken = nil
} else {
providers = append([]extcred.Provider{provider}, providers...)
}
}
// 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
@@ -180,3 +273,15 @@ func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider
// warning is safe.
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
}
type runtimePlanErrorProvider struct{ err error }
func (p *runtimePlanErrorProvider) Name() string { return "runtime-policy" }
func (p *runtimePlanErrorProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return nil, p.err
}
func (p *runtimePlanErrorProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, p.err
}

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