Compare commits

..

101 Commits

Author SHA1 Message Date
liujinkun
8e19aae336 docs(wiki): address node routing review feedback 2026-07-31 18:32:30 +08:00
liujinkun
6f542aafe2 docs(wiki): route node resolution through shortcut 2026-07-31 15:42:49 +08:00
chenxingyang1019
41692b7041 feat(apps): add cache debug commands (+cache-get/-delete/-clear) (#1896)
* feat(apps): add cache debug commands (+cache-get/-delete/-clear)

Add three apps-domain cache debug shortcuts for inspecting/clearing an app's
runtime cache:
- +cache-get: read a business key's value + metadata (hit/miss)
- +cache-delete: delete a single key (idempotent, write)
- +cache-clear: clear all cache in an environment (high-risk-write, --yes)

value renders raw on --format json, deserialized on --format pretty;
value_size_bytes is computed CLI-side; --environment auto-selects the branch
when omitted. Includes unit tests (hit/miss/dry-run/confirmation) and the
lark-apps cache skill reference.

* fix(apps): normalize cache numeric output fields and tidy comments

Follow-up hardening for the cache debug commands (+cache-get/-delete/-clear):

- Normalize ttl_ms / deleted_key_count via a new cacheInt() helper so
  --format json emits a stable JSON number (or null) regardless of whether
  the server sends the value as a number or a string. Aligns with the
  repo convention that numeric wire fields may arrive as strings; previously
  these were passed through raw, leaving the output type at the server's mercy.
- Add unit tests locking the string-wire -> JSON number contract for both
  cache-get ttl_ms and cache-delete deleted_key_count.
- Tidy two comments: soften cacheBool's speculative "historical wire form"
  claim to a defensive-tolerance note, and drop implementation jargon from
  cache-delete's risk-level rationale.
2026-07-31 14:13:56 +08:00
dc-bytedance
b79827d60a fix: drop stale target version from root upgrade prompt (#2100) 2026-07-31 12:45:43 +08:00
zhaojiaxing-coding
0f35676a28 feat(drive): extend permission shortcuts for Miaoda (#2070)
* feat(drive): support Miaoda apps in permission shortcuts

Extend Drive permission shortcuts to accept Miaoda page URLs and the apps resource type while keeping each endpoint's accepted resource contract explicit.

Key features:

- Infer apps from /page/ URLs and accept explicit --type=apps in +apply-permission, +member-add, +member-list, and +permission-get-setting

- Decouple secure-label target parsing so expanding apply-permission does not widen secure-label support

- Align skill guidance and unit/dry-run coverage with the new resource type

* test(drive): cover apps permission target validation

Add focused coverage for Miaoda apps target handling across apply-permission and secure-label boundaries.

Exercise malformed page URLs, explicit apps bare tokens, typed validation errors, and command-level rejection so future resource-type changes cannot silently widen unsupported secure-label behavior.

* fix(drive): parse permission markers from URL paths

Keep drive +apply-permission resource inference aligned with URL component boundaries. Parse and validate URL inputs before extracting tokens so query strings and fragments cannot redirect permission requests to a different resource.

Key fixes:

- Match document and apps markers only against the parsed URL path

- Reject malformed URLs with a typed --token validation error

- Cover /page/ markers found only in query strings or fragments

* docs(skills): redact Miaoda page token example

Replace the concrete Miaoda page token with a representative pagcn placeholder. This keeps the token shape recognizable while avoiding exposure of a real resource identifier in the skill documentation.

* fix(drive): harden permission target resolution

Make Drive shortcut targets unambiguous before they reach read or write API paths. URL inputs now bind to a recognized root path and a single validated token segment, preventing encoded separators, dot segments, and type conflicts from silently changing the addressed resource.

Key fixes:

- Reject non-root URLs, dot/traversal tokens, and URL/type conflicts for secure-label and permission-apply writes

- Keep permission-setting URL parsing and pretty output reversible for every supported command-local resource kind

- Add unit and dry-run E2E regressions plus aligned permission-apply guidance
2026-07-31 12:16:22 +08:00
wangweiming-01
946964e093 fix(drive): use title for default download filename (#2089) 2026-07-31 12:12:11 +08:00
HanShaoshuai-k
cfe76ad56a ci: add protected public domain allowlists (#2111)
Co-authored-by: HanShaoshuai-k <268785735+HanShaoshuai-k@users.noreply.github.com>
2026-07-31 11:02:04 +08:00
calendar-assistant
fa9c30c690 docs(calendar): confirm scope before editing recurring events (#2119)
Promote the recurring-event rule to a pre-routing gate so it is read
before the specific operation flow, and require confirming the scope
(this event / all / this-and-following) when the user is ambiguous
instead of defaulting to this-event-only. Removes the redundant and
conflicting "edit existing event" row that hard-coded the single-
instance default.
2026-07-30 21:59:08 +08:00
zcc
ba95252019 feat(drive): add comment-operation shortcuts (#1898)
Add comment-domain shortcuts: +batch-query-comments, +resolve-comment,
+restore-comment, +add-reply, +list-replies, +update-reply, +delete-reply
and +react-reply, sharing one target resolver with per-endpoint file_type
sets.

Flatten the comment reference docs by dropping the comments-guide routing
layer and folding its cross-command knowledge into the command refs:
comment-card model, comment/reply/interaction counting and sorting rules
into lark-drive-list-comments.md; the --solved-status prerequisite into
lark-drive-restore-comment.md; the apps exception into
lark-drive-add-comment.md. Comment intents now route straight from the
drive SKILL.md Shortcuts table to each command ref.

Cover the new shortcuts with unit tests, dry-run e2e and live workflow
e2e behind LARK_DRIVE_MD_COMMENT_E2E=1, and register them in
tests/cli_e2e/drive/coverage.md.
2026-07-30 21:53:21 +08:00
zhouyue-bytedance
4a16139348 fix(base): resolve Base URL block types accurately (#2099)
* fix: resolve Base URL block types accurately

* fix: resolve Base block selection from Wiki URLs

* fix(base): guide resolved folder and docx blocks

* fix(base): avoid field fallback for untyped URL blocks

* docs(base): specify URL example fence language

* test(base): cover unmatched URL block resolution
2026-07-30 20:29:47 +08:00
BD-ZERO
6e5308af01 feat: add SXSD schema validation to Slides lint (#2103)
- add XSD-backed SXSD validation for tags, attributes, structure, scalar values, and namespaces
- preserve supported server-filled fields and readback namespace compatibility
- isolate SXSD failures by slide so valid slides continue through layout checks
- improve actionable lint diagnostics and suppress duplicate errors
- add regression coverage for schema validation and Slides readback cases

Validated with unit tests and real Slides create/readback round trips.
2026-07-30 20:04:53 +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
497 changed files with 53470 additions and 5722 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

@@ -9,7 +9,40 @@ 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
@@ -26,35 +59,79 @@ jobs:
with:
python-version: '3.x'
- 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: Run 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
(cd dist && sha256sum --check checksums.txt)
cp dist/checksums.txt checksums.txt
- name: Collect release asset
run: |
set -euo pipefail
mkdir npm-publish-asset
cp dist/*.tar.gz dist/*.zip dist/checksums.txt 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
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

@@ -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
all: test
@@ -51,13 +51,18 @@ script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/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:

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

@@ -49,9 +49,6 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.
Short: "Device Flow authorization login",
Long: `Device Flow authorization login.
With no --scope/--domain/--recommend flag, this requests scopes for all known
business domains (equivalent to --domain all); pass --domain or --scope to narrow it.
For AI agents: this command blocks until the user completes authorization in the
browser. If your harness or agent tool only delivers final turn messages, use --no-wait --json,
send the verification URL (or QR code) to the user as your final message, end the turn, then
@@ -74,7 +71,7 @@ to generate QR codes (supports ASCII and PNG formats).`,
cmdutil.SetRisk(cmd, "write")
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request scopes for all known domains (equivalent to --domain all)")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
var helpBrand core.LarkBrand
if f != nil && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
@@ -147,6 +144,33 @@ func authLoginRun(opts *LoginOptions) error {
}
selectedDomains := opts.Domains
scopeLevel := "" // "common" or "all" (from interactive mode)
// Expand --domain all to all available domains (from_meta projects + shortcut services)
for _, d := range selectedDomains {
if strings.EqualFold(d, "all") {
selectedDomains = sortedKnownDomains(config.Brand)
break
}
}
// Validate domain names and suggest corrections for unknown ones
if len(selectedDomains) > 0 {
knownDomains := allKnownDomains(config.Brand)
for _, d := range selectedDomains {
if !knownDomains[d] {
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, did you mean %q?", d, suggestion).WithParam("--domain")
}
available := make([]string, 0, len(knownDomains))
for k := range knownDomains {
available = append(available, k)
}
sort.Strings(available)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, available domains: %s", d, strings.Join(available, ", ")).WithParam("--domain")
}
}
}
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
@@ -154,51 +178,30 @@ func authLoginRun(opts *LoginOptions) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--exclude requires --scope, --domain, or --recommend to be specified").WithParam("--exclude")
}
// scopeOnly is the one path that must never touch the domain catalog
// (remote or local): --scope given alone, with neither --domain nor
// --recommend. Every other path — including bare `auth login`, now that
// the interactive picker is gone — needs the legal domain set to resolve
// scopes.
scopeOnly := opts.Scope != "" && !opts.Recommend && len(selectedDomains) == 0
var remote map[string][]string
var remoteOK bool
if !scopeOnly {
// Pull the remote scopes.json once for this login (not cached); any
// read failure (network/timeout/non-2xx/malformed) silently falls back
// to the local full computation — no warning, no telemetry.
remote, remoteOK = larkauth.FetchRemoteScopes(config.Brand)
legalDomains, allLegalDomains := legalDomainsFor(remote, remoteOK, config.Brand)
// Expand --domain all against the resolved legal domain set.
for _, d := range selectedDomains {
if strings.EqualFold(d, "all") {
selectedDomains = allLegalDomains
break
if !hasAnyOption {
if !opts.JSON && f.IOStreams.IsTerminal {
result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand)
if err != nil {
return err
}
}
if len(selectedDomains) > 0 {
// Validate explicitly-supplied domain names and suggest corrections.
for _, d := range selectedDomains {
if !legalDomains[d] {
if suggestion := suggestDomain(d, legalDomains); suggestion != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, did you mean %q?", d, suggestion).WithParam("--domain")
}
available := make([]string, 0, len(legalDomains))
for k := range legalDomains {
available = append(available, k)
}
sort.Strings(available)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, available domains: %s", d, strings.Join(available, ", ")).WithParam("--domain")
}
if result == nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no login options selected")
}
selectedDomains = result.Domains
scopeLevel = result.ScopeLevel
} else {
// Bare `auth login` and `--recommend` without `--domain` both span
// the full legal domain set now that the interactive picker and
// the local auto-approve filter are gone (--recommend ≡ --domain all).
selectedDomains = allLegalDomains
log(msg.HintHeader)
log("Common options:")
log(msg.HintCommon1)
log(msg.HintCommon2)
log(msg.HintCommon3)
log(msg.HintCommon4)
log("")
log("View all options:")
log(msg.HintFooter)
log("")
log("Note: this command blocks until authorization is complete. For non-streaming agent harnesses, use --no-wait --json, send the verification URL as the final message of the turn, then run --device-code in a later step after the user confirms authorization.")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "please specify the scopes to authorize").WithParam("--scope")
}
}
@@ -212,8 +215,19 @@ func authLoginRun(opts *LoginOptions) error {
// --scope, --domain, and --recommend combine additively so callers can,
// for example, request all `docs` scopes plus a few specific `drive`
// scopes in a single command.
if len(selectedDomains) > 0 {
candidateScopes := resolveScopesForDomains(selectedDomains, remote, remoteOK, config.Brand)
if len(selectedDomains) > 0 || opts.Recommend {
var candidateScopes []string
if len(selectedDomains) > 0 {
candidateScopes = collectScopesForDomains(selectedDomains, "user", config.Brand)
} else {
// --recommend without --domain: all domains
candidateScopes = collectScopesForDomains(sortedKnownDomains(config.Brand), "user", config.Brand)
}
// Filter to auto-approve scopes if --recommend or interactive "common"
if opts.Recommend || scopeLevel == "common" {
candidateScopes = registry.FilterAutoApproveScopes(candidateScopes)
}
if len(candidateScopes) == 0 && opts.Scope == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no matching scopes found, check domain/scope options")
@@ -344,7 +358,6 @@ func authLoginRun(opts *LoginOptions) error {
}
scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope)
scopeSummary.StatusMessage = result.Token.StatusMessage
// Step 7: Store token
now := time.Now().UnixMilli()
@@ -428,7 +441,6 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
}
scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope)
scopeSummary.StatusMessage = result.Token.StatusMessage
// Store token
now := time.Now().UnixMilli()
@@ -538,30 +550,6 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
return result
}
// resolveScopesForDomains resolves the scope set for the given domains. When
// the remote scopes.json is available it takes the union of each domain's
// user_scopes from the remote result (remote is authoritative, including
// domains this CLI build doesn't know about locally); otherwise it falls back
// to the local synthesis via collectScopesForDomains. Always returns a
// deduplicated, alphabetically sorted slice.
func resolveScopesForDomains(domains []string, remote map[string][]string, remoteOK bool, brand core.LarkBrand) []string {
if remoteOK {
set := make(map[string]bool)
for _, d := range domains {
for _, s := range remote[d] {
set[s] = true
}
}
out := make([]string, 0, len(set))
for s := range set {
out = append(out, s)
}
sort.Strings(out)
return out
}
return collectScopesForDomains(domains, "user", brand)
}
// allKnownDomains returns all valid auth domain names (from_meta projects +
// shortcut services), excluding domains that have auth_domain set (they are
// folded into their parent domain).
@@ -594,25 +582,6 @@ func sortedKnownDomains(brand core.LarkBrand) []string {
return domains
}
// legalDomainsFor returns the authoritative domain set for this login: the
// remote scopes.json keys when available (a remote-listed domain unknown to
// this CLI build is still legal), otherwise the local known-domain set.
// Returns both a membership set (for --domain validation) and a sorted slice
// (for `all` expansion and the bare-login/--recommend-without-domain default).
func legalDomainsFor(remote map[string][]string, remoteOK bool, brand core.LarkBrand) (map[string]bool, []string) {
if remoteOK {
set := make(map[string]bool, len(remote))
sorted := make([]string, 0, len(remote))
for d := range remote {
set[d] = true
sorted = append(sorted, d)
}
sort.Strings(sorted)
return set, sorted
}
return allKnownDomains(brand), sortedKnownDomains(brand)
}
// shortcutSupportsIdentity checks if a shortcut supports the given identity ("user" or "bot").
// Empty AuthTypes defaults to ["user"].
func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {

View File

@@ -0,0 +1,188 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
)
// domainMeta describes a domain for the interactive selector.
type domainMeta struct {
Name string
Title string
Description string
}
// interactiveResult holds the user's selections from the interactive form.
type interactiveResult struct {
Domains []string
ScopeLevel string // "common" or "all"
}
// getDomainMetadata returns metadata for all known domains, sorted by name.
func getDomainMetadata(lang string) []domainMeta {
seen := make(map[string]bool)
var domains []domainMeta
// 1. Domains from from_meta projects (skip domains with auth_domain)
for _, project := range registry.ListFromMetaProjects() {
if registry.HasAuthDomain(project) {
seen[project] = true
continue
}
dm := buildDomainMeta(project, lang)
domains = append(domains, dm)
seen[project] = true
}
// 2. Shortcut-only domains
shortcutOnlyNames := getShortcutOnlyDomainNames()
for _, name := range shortcutOnlyNames {
if !seen[name] {
dm := buildDomainMeta(name, lang)
domains = append(domains, dm)
seen[name] = true
}
}
// 3. Auto-discover remaining shortcut services that are listed as shortcut-only domains
// (skip domains with auth_domain — they are folded into their parent)
shortcutOnlySet := make(map[string]bool)
for _, n := range shortcutOnlyNames {
shortcutOnlySet[n] = true
}
for _, sc := range shortcuts.AllShortcuts() {
if !seen[sc.Service] {
if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) {
dm := buildDomainMeta(sc.Service, lang)
domains = append(domains, dm)
}
seen[sc.Service] = true
}
}
sort.Slice(domains, func(i, j int) bool {
return domains[i].Name < domains[j].Name
})
return domains
}
// buildDomainMeta constructs a domainMeta for a given service name and language.
// It reads from the service_descriptions.json config first, falling back to
// from_meta spec fields if not found.
func buildDomainMeta(name, lang string) domainMeta {
title := registry.GetServiceTitle(name, lang)
desc := registry.GetServiceDetailDescription(name, lang)
if title != "" || desc != "" {
return domainMeta{
Name: name,
Title: title,
Description: desc,
}
}
// Fallback: read from the typed service spec (legacy)
dm := domainMeta{Name: name}
if svc, ok := registry.ServiceTyped(name); ok {
dm.Title = svc.Title
dm.Description = svc.Description
}
return dm
}
// runInteractiveLogin shows an interactive TUI form for domain and permission selection.
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) {
allDomains := getDomainMetadata(lang)
// Build multi-select options
options := make([]huh.Option[string], len(allDomains))
for i, dm := range allDomains {
var label string
switch {
case dm.Title != "" && dm.Description != "":
label = fmt.Sprintf("%-12s %s - %s", dm.Name, dm.Title, dm.Description)
case dm.Title != "":
label = fmt.Sprintf("%-12s %s", dm.Name, dm.Title)
default:
label = fmt.Sprintf("%-12s %s", dm.Name, dm.Description)
}
options[i] = huh.NewOption(label, dm.Name)
}
var selectedDomains []string
var permLevel string
// Phase 1a: domain selection
// Phase 1b: permission level (shown after domain selection completes)
form1 := huh.NewForm(
huh.NewGroup(
huh.NewMultiSelect[string]().
Title(msg.SelectDomains).
Description(msg.DomainHint).
Options(options...).
Value(&selectedDomains).
Validate(func(s []string) error {
if len(s) == 0 {
return fmt.Errorf(msg.ErrNoDomain)
}
return nil
}),
),
huh.NewGroup(
huh.NewSelect[string]().
Title(msg.PermLevel).
Options(
huh.NewOption(msg.PermCommon, "common"),
huh.NewOption(msg.PermAll, "all"),
).
Value(&permLevel),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form1.Run(); err != nil {
if err == huh.ErrUserAborted {
return nil, output.ErrBare(1)
}
return nil, err
}
if len(selectedDomains) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no domains selected").WithParam("--domain")
}
// Compute scope summary
scopes := collectScopesForDomains(selectedDomains, "user", brand)
if permLevel == "common" {
scopes = registry.FilterAutoApproveScopes(scopes)
}
// Print summary
permLabel := msg.PermAllLabel
if permLevel == "common" {
permLabel = msg.PermCommonLabel
}
fmt.Fprintf(ios.ErrOut, msg.Summary)
fmt.Fprintf(ios.ErrOut, msg.SummaryDomains, strings.Join(selectedDomains, ", "))
fmt.Fprintf(ios.ErrOut, msg.SummaryPerm, permLabel)
scopePreview := strings.Join(scopes, ", ")
if len(scopePreview) > 80 {
scopePreview = strings.Join(scopes[:3], ", ") + ", ..."
}
fmt.Fprintf(ios.ErrOut, msg.SummaryScopes, len(scopes), scopePreview)
return &interactiveResult{
Domains: selectedDomains,
ScopeLevel: permLevel,
}, nil
}

View File

@@ -6,49 +6,114 @@ package auth
import "github.com/larksuite/cli/internal/i18n"
type loginMsg struct {
// Interactive UI (login_interactive.go)
SelectDomains string
DomainHint string
PermLevel string
PermCommon string
PermAll string
Summary string
SummaryDomains string
SummaryPerm string
SummaryScopes string
PermAllLabel string
PermCommonLabel string
ErrNoDomain string
ConfirmAuth string
// Non-interactive prompts (login.go)
OpenURL string
WaitingAuth string
AgentTimeoutHint string
AuthSuccess string
LoginSuccess string
ScopeMismatch string
ScopeHint string
GrantedScopes string
NotGrantedScopes string
AuthDetails string
ScopeSeparator string
NoScopes string
OpenURL string
WaitingAuth string
AgentTimeoutHint string
AuthSuccess string
LoginSuccess string
AuthorizedUser string
ScopeMismatch string
ScopeHint string
RequestedScopes string
NewlyGrantedScopes string
NoScopes string
StatusHint string
// Non-interactive hint (no flags)
HintHeader string
HintCommon1 string
HintCommon2 string
HintCommon3 string
HintCommon4 string
HintFooter string
}
var loginMsgZh = &loginMsg{
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 或 agent 工具只会把最终回复发给用户,请改用 \"lark-cli auth login --no-wait --json\" 拿到 device_code 和 verification_url把 verification_url 作为本轮最终消息发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 \"lark-cli auth login --device-code <code>\" 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code导致用户授权链接失效。**必须生成二维码并展示**: 你必须调用 lark-cli auth qrcode 将 verification_url 转为二维码,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output仅当用户明确要求时才使用 ASCII--ascii。**关键:生成后必须在回复中展示图片**,仅生成文件不算完成,须用 image 标签或内联图片等方式将图片包含在回复中。**展示顺序**: 先输出 URL再将二维码图片置于 URL 下方完整展示。**URL 输出规则**: 将 verification_url 视为不可修改的 opaque string不要做任何修改包括 URL 编码/解码、添加空格或标点)。",
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
LoginSuccess: "登录成功! 用户: %s (%s)",
ScopeMismatch: "授权结果异常: 以下请求 scopes 未被授予: %s",
ScopeHint: "以上结果是本次授权请求用户最终确认后的结果请勿持续重试Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
GrantedScopes: "本次已成功授权:",
NotGrantedScopes: "以下是本次未授予的权限:",
AuthDetails: "本次授权结果详情:",
ScopeSeparator: "",
NoScopes: "(空)",
SelectDomains: "选择要授权的业务域",
DomainHint: "空格=选择, 回车=确认",
PermLevel: "权限类型",
PermCommon: "常用权限",
PermAll: "全部权限",
Summary: "\n摘要:\n",
SummaryDomains: " 域: %s\n",
SummaryPerm: " 权限: %s\n",
SummaryScopes: " Scopes (%d): %s\n\n",
PermAllLabel: "全部权限",
PermCommonLabel: "常用权限",
ErrNoDomain: "请至少选择一个业务域",
ConfirmAuth: "确认授权?",
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 或 agent 工具只会把最终回复发给用户,请改用 \"lark-cli auth login --no-wait --json\" 拿到 device_code 和 verification_url把 verification_url 作为本轮最终消息发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 \"lark-cli auth login --device-code <code>\" 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code导致用户授权链接失效。**必须生成二维码并展示**: 你必须调用 lark-cli auth qrcode 将 verification_url 转为二维码,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output仅当用户明确要求时才使用 ASCII--ascii。**关键:生成后必须在回复中展示图片**,仅生成文件不算完成,须用 image 标签或内联图片等方式将图片包含在回复中。**展示顺序**: 先输出 URL再将二维码图片置于 URL 下方完整展示。**URL 输出规则**: 将 verification_url 视为不可修改的 opaque string不要做任何修改包括 URL 编码/解码、添加空格或标点)。",
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
LoginSuccess: "授权成功! 用户: %s (%s)",
AuthorizedUser: "当前授权账号: %s (%s)",
ScopeMismatch: "授权结果异常: 以下请求 scopes 未被授予: %s",
ScopeHint: "以上结果是本次授权请求用户最终确认后的结果请勿持续重试Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
RequestedScopes: " 本次请求 scopes: %s\n",
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
NoScopes: "(空)",
StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
HintHeader: "请指定要授权的权限:\n",
HintCommon1: " --recommend 授权推荐权限",
HintCommon2: " --domain all 授权所有已知域的权限",
HintCommon3: " --domain calendar,task 授权日历和任务域的权限",
HintCommon4: " --domain calendar --recommend 授权日历域的推荐权限",
HintFooter: " lark-cli auth login --help",
}
var loginMsgEn = &loginMsg{
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness or agent tool only delivers final turn messages, use \"lark-cli auth login --no-wait --json\" to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run \"lark-cli auth login --device-code <code>\" in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless.**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation.",
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
LoginSuccess: "Authorization successful! User: %s (%s)",
ScopeMismatch: "authorization result is abnormal: these requested scopes were not granted: %s",
ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
GrantedScopes: "- Successfully authorized in this request:",
NotGrantedScopes: "- Scopes not granted in this request:",
AuthDetails: "- Authorization details:",
ScopeSeparator: ", ",
NoScopes: "(none)",
SelectDomains: "Select domains to authorize",
DomainHint: "Space=toggle, Enter=confirm",
PermLevel: "Permission level",
PermCommon: "Common scopes",
PermAll: "All scopes",
Summary: "\nSummary:\n",
SummaryDomains: " Domains: %s\n",
SummaryPerm: " Level: %s\n",
SummaryScopes: " Scopes (%d): %s\n\n",
PermAllLabel: "All scopes",
PermCommonLabel: "Common scopes",
ErrNoDomain: "please select at least one domain",
ConfirmAuth: "Confirm authorization?",
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness or agent tool only delivers final turn messages, use \"lark-cli auth login --no-wait --json\" to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run \"lark-cli auth login --device-code <code>\" in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless.**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation.",
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
LoginSuccess: "Authorization successful! User: %s (%s)",
AuthorizedUser: "Authorized account: %s (%s)",
ScopeMismatch: "authorization result is abnormal: these requested scopes were not granted: %s",
ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
RequestedScopes: " Requested scopes: %s\n",
NewlyGrantedScopes: " Newly granted scopes: %s\n",
NoScopes: "(none)",
StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
HintHeader: "Please specify the scopes to authorize:\n",
HintCommon1: " --recommend authorize recommended scopes",
HintCommon2: " --domain all authorize all known domain scopes",
HintCommon3: " --domain calendar,task authorize calendar and task scopes",
HintCommon4: " --domain calendar --recommend authorize calendar recommended scopes",
HintFooter: " lark-cli auth login --help",
}
// getLoginMsg returns the login message bundle for the given language.
@@ -58,3 +123,10 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
}
return loginMsgZh
}
// getShortcutOnlyDomainNames returns domain names that exist only as shortcuts
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"application", "base", "contact", "docs", "markdown", "apps", "note"}
}

View File

@@ -17,11 +17,8 @@ func TestGetLoginMsg_Zh(t *testing.T) {
if msg != loginMsgZh {
t.Error("expected zh message set")
}
if msg.OpenURL != "在浏览器中打开以下链接进行认证:\n\n" {
t.Errorf("unexpected OpenURL: %s", msg.OpenURL)
}
if msg.LoginSuccess != "登录成功! 用户: %s (%s)" {
t.Errorf("unexpected LoginSuccess: %s", msg.LoginSuccess)
if msg.SelectDomains != "选择要授权的业务域" {
t.Errorf("unexpected SelectDomains: %s", msg.SelectDomains)
}
}
@@ -30,8 +27,8 @@ func TestGetLoginMsg_En(t *testing.T) {
if msg != loginMsgEn {
t.Error("expected en message set")
}
if msg.OpenURL != "Open this URL in your browser to authenticate:\n\n" {
t.Errorf("unexpected OpenURL: %s", msg.OpenURL)
if msg.SelectDomains != "Select domains to authorize" {
t.Errorf("unexpected SelectDomains: %s", msg.SelectDomains)
}
}
@@ -75,6 +72,29 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
t.Errorf("%s LoginSuccess has no format verb", lang)
}
// AuthorizedUser should contain two %s placeholders (userName, openId)
got = fmt.Sprintf(msg.AuthorizedUser, "testuser", "ou_123")
if got == msg.AuthorizedUser {
t.Errorf("%s AuthorizedUser has no format verb", lang)
}
// SummaryDomains should contain %s
got = fmt.Sprintf(msg.SummaryDomains, "calendar, task")
if got == msg.SummaryDomains {
t.Errorf("%s SummaryDomains has no format verb", lang)
}
// SummaryPerm should contain %s
got = fmt.Sprintf(msg.SummaryPerm, "all")
if got == msg.SummaryPerm {
t.Errorf("%s SummaryPerm has no format verb", lang)
}
// SummaryScopes should contain %d and %s
got = fmt.Sprintf(msg.SummaryScopes, 5, "a, b, c")
if got == msg.SummaryScopes {
t.Errorf("%s SummaryScopes has no format verb", lang)
}
}
}

View File

@@ -20,7 +20,6 @@ type loginScopeSummary struct {
AlreadyGranted []string
Granted []string
Missing []string
StatusMessage string
}
type loginScopeIssue struct {
@@ -115,27 +114,11 @@ func uniqueScopeList(scope string) []string {
// formatScopeList joins scopes for display and falls back to the provided empty
// label when the input slice is empty.
func formatScopeList(scopes []string, empty, separator string) string {
func formatScopeList(scopes []string, empty string) string {
if len(scopes) == 0 {
return empty
}
return strings.Join(scopes, separator)
}
// grantedRequestedScopes returns requested scopes that are not in the missing
// set, preserving the order of the authorization request.
func grantedRequestedScopes(summary *loginScopeSummary) []string {
missing := make(map[string]bool, len(summary.Missing))
for _, scope := range summary.Missing {
missing[scope] = true
}
granted := make([]string, 0, len(summary.Requested))
for _, scope := range summary.Requested {
if !missing[scope] {
granted = append(granted, scope)
}
}
return granted
return strings.Join(scopes, " ")
}
// emptyIfNil normalizes nil slices to empty slices for stable JSON output.
@@ -146,47 +129,14 @@ func emptyIfNil(s []string) []string {
return s
}
// writeLoginScopeBreakdown renders the compact granted/not-granted result.
// writeLoginScopeBreakdown renders the requested/newly granted scope
// breakdown to stderr.
func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary *loginScopeSummary) {
if summary == nil {
summary = &loginScopeSummary{}
}
fmt.Fprintln(errOut.ErrOut)
fmt.Fprintln(errOut.ErrOut, msg.GrantedScopes)
fmt.Fprintf(errOut.ErrOut, " %s\n", formatScopeList(grantedRequestedScopes(summary), msg.NoScopes, msg.ScopeSeparator))
if summary.StatusMessage != "" {
fmt.Fprintln(errOut.ErrOut)
heading := msg.NotGrantedScopes
if len(summary.Missing) == 0 {
heading = msg.AuthDetails
}
fmt.Fprintln(errOut.ErrOut, heading)
writeLoginStatusMessage(errOut, summary.StatusMessage)
return
}
if len(summary.Missing) == 0 {
return
}
fmt.Fprintln(errOut.ErrOut)
fmt.Fprintln(errOut.ErrOut, msg.NotGrantedScopes)
fmt.Fprintf(errOut.ErrOut, " %s\n", formatScopeList(summary.Missing, msg.NoScopes, msg.ScopeSeparator))
}
// writeLoginStatusMessage appends the server-rendered authorization result
// without interpreting or rewriting its contents.
func writeLoginStatusMessage(errOut *cmdutil.IOStreams, statusMessage string) {
if statusMessage == "" {
return
}
for _, line := range strings.SplitAfter(statusMessage, "\n") {
if line == "" {
continue
}
fmt.Fprint(errOut.ErrOut, " ", line)
}
if !strings.HasSuffix(statusMessage, "\n") {
fmt.Fprintln(errOut.ErrOut)
}
fmt.Fprintf(errOut.ErrOut, msg.RequestedScopes, formatScopeList(summary.Requested, msg.NoScopes))
fmt.Fprintf(errOut.ErrOut, msg.NewlyGrantedScopes, formatScopeList(summary.NewlyGranted, msg.NoScopes))
}
// writeLoginSuccess emits the successful login payload in either JSON or text
@@ -204,6 +154,9 @@ func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, op
fmt.Fprintln(f.IOStreams.ErrOut)
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
writeLoginScopeBreakdown(f.IOStreams, msg, summary)
if len(summary.Missing) == 0 && msg.StatusHint != "" {
fmt.Fprintln(f.IOStreams.ErrOut, msg.StatusHint)
}
}
// handleLoginScopeIssue prints or returns a structured missing-scope result
@@ -229,12 +182,15 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
fmt.Fprintln(f.IOStreams.ErrOut)
if loginSucceeded {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
if msg.AuthorizedUser != "" {
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", fmt.Sprintf(msg.AuthorizedUser, userName, openId))
}
} else {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
}
writeLoginScopeBreakdown(f.IOStreams, msg, issue.Summary)
if !loginSucceeded && issue.Hint != "" {
if issue.Hint != "" {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint)
}
return output.ErrBare(output.ExitAuth)
@@ -258,13 +214,10 @@ func authorizationCompletePayload(openId, userName string, summary *loginScopeSu
"granted": emptyIfNil(summary.Granted),
}
if issue != nil {
hint := summary.StatusMessage
if hint == "" {
hint = issue.Message
}
payload["warning"] = map[string]interface{}{
"type": "missing_scope",
"hint": hint,
"type": "missing_scope",
"message": issue.Message,
"hint": issue.Hint,
}
}
return payload

View File

@@ -9,7 +9,7 @@ import (
"errors"
"io"
"net/http"
"reflect"
"slices"
"sort"
"strings"
"testing"
@@ -202,6 +202,25 @@ func TestSortedKnownDomains(t *testing.T) {
}
}
func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
for _, name := range getShortcutOnlyDomainNames() {
zhDesc := registry.GetServiceDescription(name, "zh")
enDesc := registry.GetServiceDescription(name, "en")
if zhDesc == "" {
t.Errorf("missing zh description for shortcut-only domain %q", name)
}
if enDesc == "" {
t.Errorf("missing en description for shortcut-only domain %q", name)
}
}
}
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
}
}
func TestCollectScopesForDomains(t *testing.T) {
projects := registry.ListFromMetaProjects()
if len(projects) == 0 {
@@ -241,81 +260,74 @@ func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
}
}
func TestResolveScopesForDomains_RemoteUsed(t *testing.T) {
remote := map[string][]string{
"im": {"im:message:send", "im:chat:read"},
"docs": {"docs:doc:read"},
func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) {
domains := getDomainMetadata("zh")
nameSet := make(map[string]bool)
for _, dm := range domains {
nameSet[dm.Name] = true
}
got := resolveScopesForDomains([]string{"im"}, remote, true, core.BrandFeishu)
want := []string{"im:chat:read", "im:message:send"} // deduped, ascending by sort.Strings
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestResolveScopesForDomains_UnionAcrossDomains(t *testing.T) {
remote := map[string][]string{
"im": {"im:message:send"},
"docs": {"docs:doc:read"},
}
got := resolveScopesForDomains([]string{"im", "docs"}, remote, true, core.BrandFeishu)
want := []string{"docs:doc:read", "im:message:send"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestResolveScopesForDomains_FallbackToLocal(t *testing.T) {
// remoteOK=false -> falls back to local collectScopesForDomains; im must yield non-empty local scopes
got := resolveScopesForDomains([]string{"im"}, nil, false, core.BrandFeishu)
if len(got) == 0 {
t.Fatal("fallback should return non-empty local scopes for im")
}
}
func TestLegalDomainsFor_RemoteUsed(t *testing.T) {
// includes "newbiz", a domain unknown to this CLI build, verifying a remote-listed domain is still legal
remote := map[string][]string{
"im": {"im:message:send"},
"docs": {"docs:doc:read"},
"newbiz": {"newbiz:thing:read"},
}
set, sorted := legalDomainsFor(remote, true, core.BrandFeishu)
wantSorted := []string{"docs", "im", "newbiz"} // remote keys, ascending by sort.Strings
if !reflect.DeepEqual(sorted, wantSorted) {
t.Fatalf("sorted = %v, want %v", sorted, wantSorted)
}
if len(set) != len(wantSorted) {
t.Fatalf("set size = %d, want %d", len(set), len(wantSorted))
}
for _, d := range wantSorted {
if !set[d] {
t.Errorf("set missing domain %q", d)
// from_meta projects must be present
for _, p := range registry.ListFromMetaProjects() {
if !nameSet[p] {
t.Errorf("from_meta project %q missing from getDomainMetadata", p)
}
}
if !set["newbiz"] {
t.Error("remote-listed domain unknown to this build should still be legal")
}
func TestGetDomainMetadata_IncludesShortcutOnlyDomains(t *testing.T) {
domains := getDomainMetadata("zh")
nameSet := make(map[string]bool)
for _, dm := range domains {
nameSet[dm.Name] = true
}
for _, name := range getShortcutOnlyDomainNames() {
if !nameSet[name] {
t.Errorf("shortcut-only domain %q missing from getDomainMetadata", name)
}
}
}
func TestLegalDomainsFor_FallbackToLocal(t *testing.T) {
// remoteOK=false -> falls back to local allKnownDomains/sortedKnownDomains
set, sorted := legalDomainsFor(nil, false, core.BrandFeishu)
if len(sorted) == 0 {
t.Fatal("fallback should return non-empty local domain slice")
}
// set and sorted are two views of the same local domain set and must correspond
if len(set) != len(sorted) {
t.Fatalf("set size %d != sorted size %d", len(set), len(sorted))
}
for _, d := range sorted {
if !set[d] {
t.Errorf("set missing local domain %q", d)
func TestGetDomainMetadata_Sorted(t *testing.T) {
domains := getDomainMetadata("zh")
for i := 1; i < len(domains); i++ {
if domains[i].Name < domains[i-1].Name {
t.Errorf("not sorted: %q before %q", domains[i-1].Name, domains[i].Name)
}
}
// fallback adopts the local sort order directly, which must equal sortedKnownDomains
if want := sortedKnownDomains(core.BrandFeishu); !reflect.DeepEqual(sorted, want) {
t.Fatalf("sorted = %v, want sortedKnownDomains %v", sorted, want)
}
func TestGetDomainMetadata_HasTitleAndDescription(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {
if dm.Title == "" {
t.Errorf("domain %q has empty Title", dm.Name)
}
}
}
func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu,
})
// TestFactory has IsTerminal=false by default
opts := &LoginOptions{Factory: f, Ctx: context.Background()}
err := authLoginRun(opts)
if err == nil {
t.Fatal("expected error for non-terminal without flags")
}
// Should mention specifying scopes
msg := err.Error()
if !strings.Contains(msg, "scopes") {
t.Errorf("expected error to mention scopes, got: %s", msg)
}
// Stderr should explain the split-flow path for non-streaming agents.
stderrStr := stderr.String()
for _, want := range []string{"--no-wait --json", "final message of the turn", "--device-code"} {
if !strings.Contains(stderrStr, want) {
t.Errorf("expected stderr to mention %q, got: %s", want, stderrStr)
}
}
}
@@ -359,15 +371,12 @@ func TestBuildLoginScopeSummary(t *testing.T) {
func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
summary := &loginScopeSummary{
writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
Requested: []string{"im:message:send", "im:message:reply"},
NewlyGranted: []string{"im:message:send"},
AlreadyGranted: []string{"im:message:reply"},
Granted: []string{"im:message:send", "im:message:reply"},
StatusMessage: "[用户跳过,可重试] 用户未勾选calendar:calendar:update\n" +
"应用身份\n[待审核,通过后自动生效] 以下权限正在等待管理员审核im:message",
}
writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", summary)
})
var data map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
@@ -385,25 +394,19 @@ func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
if len(data["already_granted"].([]interface{})) != 1 {
t.Fatalf("already_granted = %#v", data["already_granted"])
}
if _, ok := data["status_message"]; ok {
t.Fatalf("status_message should not be exposed at the top level: %#v", data)
}
}
func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
issue := &loginScopeIssue{
err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{
Message: "授权结果异常: 以下请求 scopes 未被授予: im:message:send",
Hint: "以上结果是本次授权请求用户最终确认后的结果请勿持续重试Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
Summary: &loginScopeSummary{
Requested: []string{"im:message:send", "im:message:reply"},
Requested: []string{"im:message:send"},
Missing: []string{"im:message:send"},
Granted: []string{"im:message:reply", "base:app:copy"},
StatusMessage: "[用户跳过,可重试] 用户未勾选calendar:calendar:update\n" +
"应用身份\n[待审核,通过后自动生效] 以下权限正在等待管理员审核im:message",
Granted: []string{"base:app:copy"},
},
}
err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, issue, "ou_user", "tester")
}, "ou_user", "tester")
if err == nil {
t.Fatal("expected error, got nil")
}
@@ -412,53 +415,40 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
}
got := stderr.String()
for _, want := range []string{
"OK: 登录成功! 用户: tester (ou_user)",
"OK: 登录成功! 用户: tester (ou_user)\n\n" +
"本次已成功授权:\n" +
" im:message:reply\n\n" +
"以下是本次未授予的权限:\n" +
" [用户跳过,可重试] 用户未勾选calendar:calendar:update\n" +
" 应用身份\n" +
" [待审核,通过后自动生效] 以下权限正在等待管理员审核im:message",
"授权结果异常: 以下请求 scopes 未被授予: im:message:send",
"当前授权账号: tester (ou_user)",
"本次请求 scopes: im:message:send",
"本次新授予 scopes: (空)",
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
"scope 被禁用",
"lark-cli auth status",
} {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
successPos := strings.Index(got, "OK: 登录成功! 用户: tester (ou_user)")
scopePos := strings.Index(got, "本次已成功授权:\n im:message:reply")
missingPos := strings.Index(got, "以下是本次未授予的权限:")
statusPos := strings.Index(got, " [用户跳过,可重试]")
if successPos < 0 || scopePos <= successPos || missingPos <= scopePos || statusPos <= missingPos {
t.Fatalf("login result placement is wrong, got:\n%s", got)
if strings.Contains(got, "最终已授权 scopes:") {
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
}
for _, unwanted := range []string{
issue.Message,
"本次请求 scopes:",
"本次授予 scopes:",
issue.Hint,
"当前授权账号:",
} {
if strings.Contains(got, unwanted) {
t.Fatalf("stderr should not contain %q, got:\n%s", unwanted, got)
}
if strings.Contains(got, "授权成功") {
t.Fatalf("stderr should not contain success wording, got:\n%s", got)
}
if strings.Contains(got, "本次授予 scopes:") {
t.Fatalf("stderr should not duplicate missing scopes, got:\n%s", got)
}
}
func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
issue := &loginScopeIssue{
err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, &loginScopeIssue{
Message: "authorization result is abnormal: these requested scopes were not granted: im:message:send",
Hint: "Granted scopes: base:app:copy. Check app scopes.",
Summary: &loginScopeSummary{
Requested: []string{"im:message:send"},
Missing: []string{"im:message:send"},
Granted: []string{"base:app:copy"},
StatusMessage: "[用户跳过,可重试] 用户未勾选calendar:calendar:update\n" +
"应用身份\n[待审核,通过后自动生效] 以下权限正在等待管理员审核im:message",
},
}
err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, issue, "ou_user", "tester")
}, "ou_user", "tester")
if err == nil {
t.Fatal("expected error, got nil")
}
@@ -483,36 +473,6 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
if warning["type"] != "missing_scope" {
t.Fatalf("warning.type = %v", warning["type"])
}
if _, ok := warning["message"]; ok {
t.Fatalf("warning.message should not be exposed: %#v", warning)
}
if warning["hint"] != issue.Summary.StatusMessage {
t.Fatalf("warning.hint = %v, want %q", warning["hint"], issue.Summary.StatusMessage)
}
if _, ok := data["status_message"]; ok {
t.Fatalf("status_message should not be exposed at the top level: %#v", data)
}
}
func TestAuthorizationCompletePayload_EmptyStatusMessageFallsBackToIssueMessage(t *testing.T) {
issue := &loginScopeIssue{
Message: "authorization result is abnormal: these requested scopes were not granted: im:message:send",
Summary: &loginScopeSummary{
Missing: []string{"im:message:send"},
},
}
payload := authorizationCompletePayload("ou_user", "tester", issue.Summary, issue)
warning, ok := payload["warning"].(map[string]interface{})
if !ok {
t.Fatalf("warning = %#v", payload["warning"])
}
if warning["hint"] != issue.Message {
t.Fatalf("warning.hint = %v, want %q", warning["hint"], issue.Message)
}
if _, ok := warning["message"]; ok {
t.Fatalf("warning.message should not be exposed: %#v", warning)
}
}
func TestWriteLoginSuccess_JSONEmptySlicesNotNull(t *testing.T) {
@@ -535,63 +495,6 @@ func TestWriteLoginSuccess_JSONEmptySlicesNotNull(t *testing.T) {
t.Fatalf("%s = %#v, want JSON array", k, v)
}
}
if _, ok := data["status_message"]; ok {
t.Fatalf("status_message should not be exposed at the top level: %#v", data)
}
}
func TestWriteLoginSuccess_TextStatusMessageWithoutMissingScopesUsesDetailsHeading(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
statusMessage := "[用户跳过,可重试] 用户未勾选calendar:calendar:update\n" +
"应用身份\n[待审核,通过后自动生效] 以下权限正在等待管理员审核im:message"
writeLoginSuccess(&LoginOptions{}, getLoginMsg("zh"), f, "ou_user", "tester", &loginScopeSummary{
Requested: []string{"im:message:send"},
NewlyGranted: []string{"im:message:send"},
Granted: []string{"im:message:send"},
StatusMessage: statusMessage,
})
got := stderr.String()
wantBlock := "本次已成功授权:\n" +
" im:message:send\n\n" +
"本次授权结果详情:\n" +
" [用户跳过,可重试] 用户未勾选calendar:calendar:update\n" +
" 应用身份\n" +
" [待审核,通过后自动生效] 以下权限正在等待管理员审核im:message"
if !strings.Contains(got, wantBlock) {
t.Fatalf("stderr missing formatted authorization block %q, got:\n%s", wantBlock, got)
}
scopePos := strings.Index(got, "本次已成功授权:\n im:message:send")
detailsPos := strings.Index(got, "本次授权结果详情:")
statusPos := strings.Index(got, " [用户跳过,可重试]")
if scopePos < 0 || detailsPos <= scopePos || statusPos <= detailsPos {
t.Fatalf("status_message placement is wrong, got:\n%s", got)
}
if strings.Contains(got, "以下是本次未授予的权限:") {
t.Fatalf("stderr should not label status_message as not granted when no scopes are missing, got:\n%s", got)
}
if strings.Contains(got, "可执行 `lark-cli auth status`") {
t.Fatalf("stderr should not contain the hidden status hint, got:\n%s", got)
}
}
func TestWriteLoginSuccess_TextStatusMessageWithoutMissingScopesUsesEnglishDetailsHeading(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
writeLoginSuccess(&LoginOptions{}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
Requested: []string{"im:message:send"},
Granted: []string{"im:message:send"},
StatusMessage: "Authorization status details",
})
got := stderr.String()
if !strings.Contains(got, "- Authorization details:\n Authorization status details") {
t.Fatalf("stderr missing neutral authorization details heading, got:\n%s", got)
}
if strings.Contains(got, "- Scopes not granted in this request:") {
t.Fatalf("stderr should not label status_message as not granted when no scopes are missing, got:\n%s", got)
}
}
func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
@@ -610,14 +513,15 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
Granted: []string{"im:message:send", "im:message:reply"},
},
expectedPresent: []string{
"登录成功! 用户: tester (ou_user)",
"登录成功! 用户: tester (ou_user)\n\n本次已成功授权\n im:message:sendim:message:reply",
"授权成功! 用户: tester (ou_user)",
"本次请求 scopes: im:message:send im:message:reply",
"本次新授予 scopes: im:message:send",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
},
expectedAbsent: []string{
"以下是本次未授予的权限",
"本次请求 scopes:",
"本次新授予 scopes:",
"lark-cli auth status",
"本次未授予 scopes:",
"最终已授权 scopes:",
"已有 scopes:",
},
},
{
@@ -628,13 +532,14 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
Granted: []string{"im:message:send", "contact:user.base:readonly"},
},
expectedPresent: []string{
"本次已成功授权:\n im:message:send",
"本次请求 scopes: im:message:send",
"本次新授予 scopes: (空)",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
},
expectedAbsent: []string{
"以下是本次未授予的权限",
"本次请求 scopes:",
"本次新授予 scopes:",
"lark-cli auth status",
"本次未授予 scopes:",
"最终已授权 scopes:",
"已有 scopes:",
},
},
{
@@ -645,12 +550,14 @@ func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
Granted: []string{"im:message:reply"},
},
expectedPresent: []string{
"本次已成功授权:\n im:message:reply\n\n以下是本次未授予的权限\n im:message:send",
"本次请求 scopes: im:message:send im:message:reply",
"本次新授予 scopes: (空)",
},
expectedAbsent: []string{
"本次请求 scopes:",
"本次新授予 scopes:",
"lark-cli auth status",
"本次未授予 scopes:",
"已有 scopes:",
"最终已授权 scopes:",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
},
},
}
@@ -692,7 +599,6 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
const statusMessage = "[待审核,通过后用户需重新授权] 以下权限正在等待管理员审核offline_access"
multi := &core.MultiAppConfig{
CurrentApp: "default",
@@ -732,7 +638,6 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
"expires_in": 7200,
"refresh_token_expires_in": 604800,
"scope": "offline_access",
"status_message": statusMessage,
},
})
reg.Register(&httpmock.Stub{
@@ -761,24 +666,25 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
}
got := stderr.String()
for _, want := range []string{
"OK: 登录成功! 用户: tester (ou_user)",
"本次已成功授权:\n (空)\n\n以下是本次未授予的权限\n " + statusMessage,
"授权结果异常: 以下请求 scopes 未被授予: im:message:send",
"当前授权账号: tester (ou_user)",
"本次请求 scopes: im:message:send",
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
"scope 被禁用",
"lark-cli auth status",
} {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
for _, unwanted := range []string{
"授权结果异常:",
"本次请求 scopes:",
"本次新授予 scopes:",
"以上结果是本次授权请求用户最终确认后的结果",
"lark-cli auth status",
"当前授权账号:",
} {
if strings.Contains(got, unwanted) {
t.Fatalf("stderr should not contain %q, got:\n%s", unwanted, got)
}
if strings.Contains(got, "最终已授权 scopes:") {
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
}
if strings.Contains(got, "OK: 授权成功") {
t.Fatalf("stderr should not contain success prefix when scopes are missing, got:\n%s", got)
}
if strings.Contains(got, "本次未授予 scopes:") {
t.Fatalf("stderr should not duplicate missing scopes, got:\n%s", got)
}
if strings.Contains(got, "ERROR:") {
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
@@ -809,7 +715,6 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
const statusMessage = "[待审核,通过后用户需重新授权] 以下权限正在等待管理员审核offline_access"
multi := &core.MultiAppConfig{
CurrentApp: "default",
@@ -849,7 +754,6 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
"expires_in": 7200,
"refresh_token_expires_in": 604800,
"scope": "im:message:send offline_access",
"status_message": statusMessage,
},
})
reg.Register(&httpmock.Stub{
@@ -891,24 +795,24 @@ func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
}
got := stderr.String()
for _, want := range []string{
"OK: 登录成功! 用户: tester (ou_user)",
"本次已成功授权:\n im:message:send\n\n本次授权结果详情\n " + statusMessage,
"OK: 授权成功! 用户: tester (ou_user)",
"本次请求 scopes: im:message:send",
"本次新授予 scopes: im:message:send",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
} {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
for _, unwanted := range []string{"本次请求 scopes:", "本次新授予 scopes:", "lark-cli auth status"} {
if strings.Contains(got, unwanted) {
t.Fatalf("stderr should not contain %q, got:\n%s", unwanted, got)
}
if strings.Contains(got, "最终已授权 scopes:") {
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
}
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "" {
t.Fatalf("loadLoginRequestedScope() after cleanup = (%q, %v), want empty", got, err)
}
}
func TestWriteLoginSuccess_TextOutputEnglishUsesCompactScopeSummary(t *testing.T) {
func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScopes(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
writeLoginSuccess(&LoginOptions{}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
@@ -920,18 +824,16 @@ func TestWriteLoginSuccess_TextOutputEnglishUsesCompactScopeSummary(t *testing.T
got := stderr.String()
for _, want := range []string{
"Authorization successful! User: tester (ou_user)",
"Authorization successful! User: tester (ou_user)\n\n" +
"- Successfully authorized in this request:\n" +
" im:message:send",
"Requested scopes: im:message:send",
"Newly granted scopes: im:message:send",
"Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
} {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
for _, unwanted := range []string{"Scopes not granted in this request", "Requested scopes:", "Newly granted scopes:", "lark-cli auth status"} {
if strings.Contains(got, unwanted) {
t.Fatalf("stderr should not contain %q, got:\n%s", unwanted, got)
}
if strings.Contains(got, "Not granted scopes:") {
t.Fatalf("stderr should not contain not granted scopes, got:\n%s", got)
}
}
@@ -1265,6 +1167,15 @@ func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *
}
}
func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {
if dm.Name == "event" {
t.Error("event should not appear in interactive domain list")
}
}
}
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
domains := allKnownDomains("")
if domains["whiteboard"] {
@@ -1289,3 +1200,12 @@ func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
t.Error("collectScopesForDomains([docs]) should include whiteboard scopes (board:whiteboard:*)")
}
}
func TestGetDomainMetadata_ExcludesAuthDomainChildren(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {
if dm.Name == "whiteboard" {
t.Error("whiteboard should not appear in interactive domain list (has auth_domain=docs)")
}
}
}

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

@@ -31,6 +31,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigRiskControl(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))

View File

@@ -0,0 +1,80 @@
// 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),
// This is persistent workspace policy, not credential management.
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cmd.SilenceUsage = true
return nil
},
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

@@ -28,6 +28,7 @@ type diagMethodEntry struct {
type diagScopeInfo struct {
Scope string `json:"scope"`
Recommend bool `json:"recommend"`
InPriority bool `json:"in_priority"`
}
@@ -68,6 +69,7 @@ type methodKey struct {
// diagBuild builds the full output: flat methods list (merged identities) + scopes.
func diagBuild(domains []string) diagOutput {
recommend := registry.LoadAutoApproveSet()
identities := []string{"user", "bot"}
merged := make(map[methodKey]*diagMethodEntry)
@@ -140,7 +142,7 @@ func diagBuild(domains []string) diagOutput {
scopes := make([]diagScopeInfo, len(scopeList))
for i, s := range scopeList {
_, inPri := priorities[s]
scopes[i] = diagScopeInfo{Scope: s, InPriority: inPri}
scopes[i] = diagScopeInfo{Scope: s, Recommend: recommend[s], InPriority: inPri}
}
return diagOutput{Methods: methods, Scopes: scopes}

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

@@ -65,7 +65,17 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
if info == nil {
return
}
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
// Deliberately no target version here: info.Latest comes from the on-disk
// cache, which has no expiry (the 24h TTL only throttles refreshes, and a
// failed refresh leaves the old value in place), so it can name a version
// that is no longer the one npm would install. The version actually
// installed is resolved live by the update subcommand, which prints
// "Updating lark-cli <cur> -> <latest> via <pm> ..." before installing —
// that is where the user sees the real target. Keep going through the
// update subcommand rather than calling RunNpmInstall directly, otherwise
// that line disappears and the user approves a global install without ever
// being told what gets installed.
fmt.Fprintf(ios.ErrOut, "A newer lark-cli is available (current %s). Upgrade now? [y/N]: ", info.Current)
if !readYes(ios.In) {
return
}

View File

@@ -128,6 +128,17 @@ func TestOfferRootUpgrade(t *testing.T) {
if gotPrompt != tc.wantPrompt {
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
}
// The prompt must not name a target version: info.Latest comes from
// the on-disk cache and can be stale, while the version actually
// installed is resolved live by the update subcommand.
if tc.wantPrompt {
if strings.Contains(errBuf.String(), tc.latest) {
t.Errorf("prompt must not name the cached target version %q (stderr=%q)", tc.latest, errBuf.String())
}
if !strings.Contains(errBuf.String(), build.Version) {
t.Errorf("prompt must name the current version %q (stderr=%q)", build.Version, errBuf.String())
}
}
if called != tc.wantRun {
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
}

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

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

@@ -24,6 +24,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 +33,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 +110,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 +246,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 +277,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 +305,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 +337,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 +1187,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 +1204,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 +1227,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 +1544,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 +1766,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())

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

@@ -34,7 +34,6 @@ type DeviceFlowTokenData struct {
ExpiresIn int
RefreshExpiresIn int
Scope string
StatusMessage string
}
// DeviceFlowResult is the result of polling the token endpoint.
@@ -223,7 +222,6 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSec
ExpiresIn: tokenExpiresIn,
RefreshExpiresIn: refreshExpiresIn,
Scope: getStr(data, "scope"),
StatusMessage: getStr(data, "status_message"),
},
}
}

View File

@@ -7,7 +7,6 @@ import (
"bytes"
"context"
"fmt"
"io"
"log"
"net/http"
"strings"
@@ -217,60 +216,3 @@ func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) {
t.Fatalf("PollDeviceToken() sent %d requests before context cancellation, want 0", got)
}
}
func TestPollDeviceToken_PreservesStatusMessage(t *testing.T) {
t.Parallel()
const statusMessage = "[不可申请,勿重试] 企业管理员禁止申请的权限mail:user_mailbox.message:send\n" +
"[待审核,通过后用户需重新授权] 以下权限正在等待管理员审核offline_access"
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{
"code": 0,
"access_token": "access-token",
"expires_in": 7200,
"scope": "approval:task:read",
"status_message": "[不可申请,勿重试] 企业管理员禁止申请的权限mail:user_mailbox.message:send\n[待审核,通过后用户需重新授权] 以下权限正在等待管理员审核offline_access"
}`)),
}, nil
}),
}
result := PollDeviceToken(context.Background(), client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 1, 3, nil)
if result == nil || !result.OK || result.Token == nil {
t.Fatalf("PollDeviceToken() = %#v, want successful token result", result)
}
if result.Token.StatusMessage != statusMessage {
t.Fatalf("StatusMessage = %q, want %q", result.Token.StatusMessage, statusMessage)
}
}
func TestPollDeviceToken_MissingStatusMessageIsEmpty(t *testing.T) {
t.Parallel()
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{
"code": 0,
"access_token": "access-token",
"expires_in": 7200,
"scope": "approval:task:read"
}`)),
}, nil
}),
}
result := PollDeviceToken(context.Background(), client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 1, 3, nil)
if result == nil || !result.OK || result.Token == nil {
t.Fatalf("PollDeviceToken() = %#v, want successful token result", result)
}
if result.Token.StatusMessage != "" {
t.Fatalf("StatusMessage = %q, want empty string", result.Token.StatusMessage)
}
}

View File

@@ -1,124 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"io"
"net/http"
"strings"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/transport"
)
const (
remoteScopesPath = "/lark-cli/apis/scopes.json"
remoteScopesTimeout = 1 * time.Second
maxRemoteScopesSize = 10 * 1024 * 1024 // 10MB, aligned with internal/registry/remote.go
)
// remoteScopesURLForTest is the injection seam for unit tests to point at an
// httptest server. It is empty at runtime, where the brand-hardcoded production
// URL is used, and must never carry an internal / non-production domain.
var remoteScopesURLForTest string
type remoteScopesFile struct {
Scopes map[string]remoteDomainScopes `json:"scopes"`
}
type remoteDomainScopes struct {
UserScopes []string `json:"user_scopes"`
TenantScopes []string `json:"tenant_scopes"`
}
func remoteScopesURL(brand core.LarkBrand) string {
if remoteScopesURLForTest != "" {
return remoteScopesURLForTest
}
return core.ResolveOpenBaseURL(brand) + remoteScopesPath
}
// FetchRemoteScopes fetches and binary-validates the remote scopes.json.
// It returns (domain -> user_scopes, true) when the whole file is usable, or
// (nil, false) when the caller should fall back to the local set. Any failure
// (network / timeout / non-2xx / empty / bad JSON / structure mismatch /
// malformed scope) returns (nil, false) silently — no warning, no telemetry.
func FetchRemoteScopes(brand core.LarkBrand) (map[string][]string, bool) {
client := transport.NewHTTPClient(remoteScopesTimeout)
req, err := http.NewRequest(http.MethodGet, remoteScopesURL(brand), nil)
if err != nil {
return nil, false
}
resp, err := client.Do(req)
if err != nil {
return nil, false
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, false
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxRemoteScopesSize))
if err != nil || len(body) == 0 {
return nil, false
}
var file remoteScopesFile
if err := json.Unmarshal(body, &file); err != nil {
return nil, false
}
return validateRemoteScopes(file)
}
func validateRemoteScopes(file remoteScopesFile) (map[string][]string, bool) {
if len(file.Scopes) == 0 {
return nil, false
}
result := make(map[string][]string, len(file.Scopes))
for domain, ds := range file.Scopes {
if ds.UserScopes == nil { // missing user_scopes field / null → whole file untrusted
return nil, false
}
for _, s := range ds.UserScopes {
if !isValidScopeFormat(s) {
return nil, false
}
}
result[domain] = ds.UserScopes
}
return result, true
}
// isValidScopeFormat does a light corruption check on a scope string. Lark
// scopes are ":"-separated but the segment count is NOT fixed: real scopes
// range from two segments ("im:message", "mail:event") through three
// ("base:app:copy") to four ("board:whiteboard:node:read",
// "drive:file:view_record:readonly"), and a "." may appear in any segment
// ("im:message.send_as_user", "vc:meeting.meetingevent:read"). We therefore
// only require at least two segments, each non-empty and built from the
// characters Lark uses (lowercase letters, digits, "_", "."). This still
// rejects empty segments and whitespace — a whitespace character would corrupt
// the space-joined OAuth scope string on the wire — as well as other garbage;
// under the binary model any such scope discards the whole remote file. The
// real defence against a tampered file is the trusted HTTPS source plus the
// IAM authorization page, not this shape check, so it is deliberately lenient
// on segment count to avoid falsely rejecting a valid published file.
// i18n / tenant / version are not checked.
func isValidScopeFormat(s string) bool {
parts := strings.Split(s, ":")
if len(parts) < 2 {
return false
}
for _, p := range parts {
if p == "" {
return false
}
for _, c := range p {
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '.') {
return false
}
}
}
return true
}

View File

@@ -1,103 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/core"
)
func withRemoteScopesURL(t *testing.T, url string) {
t.Helper()
prev := remoteScopesURLForTest
remoteScopesURLForTest = url
t.Cleanup(func() { remoteScopesURLForTest = prev })
}
func TestFetchRemoteScopes(t *testing.T) {
cases := []struct {
name string
status int
body string
wantOK bool
wantDom string
wantLen int
}{
{
name: "whole usable returns all user_scopes incl unknown domain",
status: 200,
body: `{"version":"1.5.3","scopes":{"bitable":{"i18n_name":{"zh_cn":"多维表格"},"user_scopes":["base:app:copy","base:app:create"],"tenant_scopes":["base:app:copy"]},"brandnewdomain":{"user_scopes":["newsvc:res:read"]}}}`,
wantOK: true,
wantDom: "brandnewdomain",
wantLen: 1,
},
{name: "missing scopes key falls back", status: 200, body: `{"version":"1"}`, wantOK: false},
{name: "empty scopes falls back", status: 200, body: `{"scopes":{}}`, wantOK: false},
{name: "domain missing user_scopes falls back", status: 200, body: `{"scopes":{"im":{"i18n_name":{"zh_cn":"消息"}}}}`, wantOK: false},
{name: "empty-segment scope falls back", status: 200, body: `{"scopes":{"im":{"user_scopes":["im::message"]}}}`, wantOK: false},
{name: "single-segment scope falls back", status: 200, body: `{"scopes":{"im":{"user_scopes":["nocolon"]}}}`, wantOK: false},
{name: "scope with whitespace falls back", status: 200, body: `{"scopes":{"im":{"user_scopes":["im message:chat:read"]}}}`, wantOK: false},
{name: "variable-segment scopes usable (2/3/4 seg incl dot)", status: 200, body: `{"scopes":{"im":{"user_scopes":["im:message","im:message.send_as_user","base:app:copy","board:whiteboard:node:read"]}}}`, wantOK: true, wantDom: "im", wantLen: 4},
{name: "non-2xx falls back", status: 500, body: `{"scopes":{"im":{"user_scopes":["im:message:send"]}}}`, wantOK: false},
{name: "empty body falls back", status: 200, body: ``, wantOK: false},
{name: "bad json falls back", status: 200, body: `{not json`, wantOK: false},
{name: "i18n/tenant missing still usable", status: 200, body: `{"scopes":{"im":{"user_scopes":["im:message:send_as_bot","vc:meeting.meetingevent:read"]}}}`, wantOK: true, wantDom: "im", wantLen: 2},
{name: "empty user_scopes array is usable", status: 200, body: `{"scopes":{"im":{"user_scopes":[]}}}`, wantOK: true, wantDom: "im", wantLen: 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.body))
}))
t.Cleanup(srv.Close)
withRemoteScopesURL(t, srv.URL)
got, ok := FetchRemoteScopes(core.BrandFeishu)
if ok != tc.wantOK {
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
}
if tc.wantOK {
if _, exists := got[tc.wantDom]; !exists {
t.Fatalf("domain %q missing in result %v", tc.wantDom, got)
}
if len(got[tc.wantDom]) != tc.wantLen {
t.Fatalf("len(%s) = %d, want %d", tc.wantDom, len(got[tc.wantDom]), tc.wantLen)
}
}
})
}
}
func TestFetchRemoteScopesTimeoutFallsBack(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(remoteScopesTimeout + 500*time.Millisecond)
_, _ = w.Write([]byte(`{"scopes":{"im":{"user_scopes":["im:message:send"]}}}`))
}))
t.Cleanup(srv.Close)
withRemoteScopesURL(t, srv.URL)
_, ok := FetchRemoteScopes(core.BrandFeishu)
if ok {
t.Fatal("expected fallback (ok=false) on timeout")
}
}
func TestRemoteScopesURLByBrand(t *testing.T) {
// With an empty seam the production URL is used: core.ResolveOpenBaseURL(brand) + path
remoteScopesURLForTest = ""
feishu := remoteScopesURL(core.BrandFeishu)
lark := remoteScopesURL(core.BrandLark)
if !strings.HasSuffix(feishu, "/lark-cli/apis/scopes.json") || !strings.Contains(feishu, "open.feishu.cn") {
t.Fatalf("feishu url unexpected: %s", feishu)
}
if !strings.Contains(lark, "open.larksuite.com") {
t.Fatalf("lark url unexpected: %s", lark)
}
}

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

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

@@ -22,6 +22,7 @@ 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/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/transport"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
@@ -33,7 +34,7 @@ import (
// 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 {
streams = normalizeStreams(streams)
f := &Factory{
@@ -54,9 +55,10 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
workspaceConfig := core.NewConfigSnapshot()
// 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.
@@ -67,7 +69,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
ErrOut: f.IOStreams.ErrOut,
})
// 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 {
@@ -78,8 +80,9 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
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 +111,16 @@ 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()
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 +134,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 +148,15 @@ 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()
// The innermost SDK boundary always strips reserved host-signal headers;
// a nil source makes it strip-only when workspace policy disables signal
// collection.
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,9 +165,8 @@ 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}

View File

@@ -6,10 +6,15 @@ package cmdutil
import (
"io"
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c1, err := fn()
if err != nil {
@@ -29,7 +34,10 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
}
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.Timeout == 0 {
t.Error("expected non-zero timeout")
@@ -37,7 +45,10 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
}
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.CheckRedirect == nil {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")

View File

@@ -8,6 +8,7 @@ import (
"testing"
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
@@ -36,13 +37,15 @@ var proxyWarnGateCases = []struct {
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
// invokes WarnIfProxied only when stderr is an interactive terminal.
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
isEnabled := false
for _, tc := range proxyWarnGateCases {
t.Run(tc.name, func(t *testing.T) {
calls := installProxyWarnSpy(t)
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
}})
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
f.IOStreams.StderrIsTerminal = tc.terminal
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
if _, err := fn(); err != nil {
t.Fatalf("http client init: %v", err)
}
@@ -73,7 +76,7 @@ func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
// normalizeStreams copies the struct (out := *s), so the
// StderrIsTerminal field survives into f.IOStreams.
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
if _, err := cachedLarkClientFunc(f)(); err != nil {
if _, err := cachedLarkClientFunc(f, nil)(); err != nil {
t.Fatalf("lark client init: %v", err)
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"io/fs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// StatLocalFile returns metadata for a path in the process filesystem namespace.
// It is intended for advisory validation; callers must validate the opened file
// again before using its contents.
func StatLocalFile(path string) (fs.FileInfo, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Stat(localPath)
}
// OpenLocalFile opens a path in the process filesystem namespace.
// Absolute and relative paths are accepted. It is the shared replacement for
// direct os.Open/os.ReadFile use in commands that intentionally read local
// paths outside the workspace sandbox. Callers inspect the returned descriptor
// before reading so validation and use apply to the same opened file.
func OpenLocalFile(path string) (fs.File, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Open(localPath)
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/vfs"
)
func TestOpenLocalFile_AcceptsAbsoluteAndParentRelativePaths(t *testing.T) {
root := t.TempDir()
workDir := filepath.Join(root, "work")
if err := os.Mkdir(workDir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
TestChdir(t, workDir)
for _, input := range []string{path, filepath.Join("..", "input.txt")} {
f, err := OpenLocalFile(input)
if err != nil {
t.Fatalf("OpenLocalFile(%q) error = %v", input, err)
}
got, readErr := io.ReadAll(f)
closeErr := f.Close()
if readErr != nil || closeErr != nil || string(got) != "content" {
t.Fatalf("OpenLocalFile(%q) content=%q read=%v close=%v", input, got, readErr, closeErr)
}
}
}
func TestOpenLocalFile_RejectsInvalidInput(t *testing.T) {
if _, err := OpenLocalFile("input\n.txt"); !errors.Is(err, fileio.ErrPathValidation) {
t.Fatalf("OpenLocalFile() error = %v, want ErrPathValidation", err)
}
}
func TestStatLocalFile_ReturnsMetadata(t *testing.T) {
info, err := StatLocalFile(t.TempDir())
if err != nil {
t.Fatalf("StatLocalFile() error = %v", err)
}
if !info.IsDir() {
t.Fatalf("StatLocalFile() mode = %v, want directory", info.Mode())
}
}
func TestOpenLocalFile_DoesNotStatBeforeOpen(t *testing.T) {
path := filepath.Join(t.TempDir(), "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
previous := vfs.DefaultFS
counting := &countingLocalFileFS{FS: previous}
vfs.DefaultFS = counting
t.Cleanup(func() { vfs.DefaultFS = previous })
f, err := OpenLocalFile(path)
if err != nil {
t.Fatalf("OpenLocalFile() error = %v", err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
if counting.openCalls != 1 || counting.statCalls != 0 {
t.Fatalf("OpenLocalFile() calls: Open=%d Stat=%d, want Open=1 Stat=0", counting.openCalls, counting.statCalls)
}
}
type countingLocalFileFS struct {
vfs.FS
openCalls int
statCalls int
}
func (f *countingLocalFileFS) Open(name string) (*os.File, error) {
f.openCalls++
return f.FS.Open(name)
}
func (f *countingLocalFileFS) Stat(name string) (fs.FileInfo, error) {
f.statCalls++
return f.FS.Stat(name)
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/riskcontrol"
)
type workspaceConfigSource interface {
MultiAppConfig() (*core.MultiAppConfig, error)
}
// resolveSDKHostSignalSource applies workspace policy at the SDK transport
// boundary.
func resolveSDKHostSignalSource(config workspaceConfigSource) riskcontrol.Source {
if config == nil {
return nil
}
workspace, configErr := config.MultiAppConfig()
// Default-on means an existing config with no explicit preference. Absent
// or unreadable config cannot authorize host-signal collection.
if configErr != nil || !workspace.RiskControlEnabled() {
return nil
}
return riskcontrol.NewHostSource()
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"testing"
"github.com/larksuite/cli/internal/core"
)
type staticWorkspaceConfig struct {
config *core.MultiAppConfig
err error
}
func (s staticWorkspaceConfig) MultiAppConfig() (*core.MultiAppConfig, error) {
return s.config, s.err
}
func TestResolveSDKHostSignalSource(t *testing.T) {
disabled := false
tests := []struct {
name string
config workspaceConfigSource
wantSource bool
}{
{name: "workspace default on", config: staticWorkspaceConfig{config: &core.MultiAppConfig{}}, wantSource: true},
{name: "workspace opt-out", config: staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &disabled}}},
{name: "missing config", config: staticWorkspaceConfig{err: errors.New("file does not exist")}},
{name: "unreadable config", config: staticWorkspaceConfig{err: errors.New("permission denied")}},
{name: "nil config value", config: staticWorkspaceConfig{}},
{name: "nil config source"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := resolveSDKHostSignalSource(test.config)
if (got != nil) != test.wantSource {
t.Fatalf("resolveSDKHostSignalSource() = %T, wantSource %t", got, test.wantSource)
}
})
}
}

View File

@@ -26,6 +26,7 @@ const (
HeaderShortcut = "X-Cli-Shortcut"
HeaderExecutionId = "X-Cli-Execution-Id"
HeaderAgentTrace = "X-Agent-Trace"
HeaderAgentName = "X-Agent-Name"
SourceValue = "lark-cli"
@@ -55,6 +56,9 @@ func BaseSecurityHeaders() http.Header {
if v := envvars.AgentTrace(); v != "" {
h.Set(HeaderAgentTrace, v)
}
if v := envvars.AgentName(); v != "" {
h.Set(HeaderAgentName, v)
}
return h
}

View File

@@ -263,9 +263,34 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
}
// ---------------------------------------------------------------------------
// HeaderAgentTrace injection (via BaseSecurityHeaders)
// Agent headers injected via BaseSecurityHeaders
// ---------------------------------------------------------------------------
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentName, "")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != "" {
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentName, v)
}
}
func TestBaseSecurityHeaders_IncludesAgentNameHeaderWhenEnvSet(t *testing.T) {
const agentName = "sample-agent"
t.Setenv(envvars.CliAgentName, agentName)
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != agentName {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentName, v, agentName)
}
}
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvInvalid(t *testing.T) {
t.Setenv(envvars.CliAgentName, "agent\r\nX-Evil: attack")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != "" {
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent for invalid input", HeaderAgentName, v)
}
}
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
h := BaseSecurityHeaders()

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
// Default-factory tests initialize the registry and resolve config. Keep
// them deterministic: never read the developer's real ~/.lark-cli and
// prevent background remote-metadata refreshes from touching user state.
root, err := os.MkdirTemp("", "lark-cli-cmdutil-test-*")
if err != nil {
println("internal/cmdutil test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
panic(err)
}
if err := os.Setenv("LARKSUITE_CLI_REMOTE_META", "off"); err != nil {
panic(err)
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -15,6 +15,7 @@ import (
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/riskcontrol"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -91,13 +92,13 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
}
// ---------------------------------------------------------------------------
// buildSDKTransport chain composition
// wrapSDKTransport chain composition
// ---------------------------------------------------------------------------
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := buildSDKTransport()
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -110,18 +111,23 @@ func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestBuildSDKTransport_WithExtension(t *testing.T) {
func TestWrapSDKTransport_WithExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(nil) })
t.Cleanup(func() { exttransport.Register(previous) })
transport := buildSDKTransport()
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
@@ -138,17 +144,23 @@ func TestBuildSDKTransport_WithExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(nil)
t.Cleanup(func() { exttransport.Register(previous) })
transport := buildSDKTransport()
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -161,9 +173,13 @@ func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
// ---------------------------------------------------------------------------
@@ -261,6 +277,40 @@ func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Resp
return nil
}
type riskHeaderTamperingInterceptor struct{}
func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
req.Header.Set(riskcontrol.HeaderProductModel, "extension-value")
return nil
}
func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(previous) })
var received http.Header
network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer token")
resp, err := wrapSDKTransport(riskcontrol.NewTransport(network, nil)).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(riskcontrol.HeaderOSType) != "" || received.Get(riskcontrol.HeaderProductModel) != "" {
t.Fatalf("extension risk headers reached network: %v", received)
}
}
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
// transport chain, even when an extension tries to delete or spoof it. This
@@ -277,7 +327,7 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(nil) })
// Replicate the SDK chain layering used by buildSDKTransport.
// Replicate the SDK chain layering used by wrapSDKTransport.
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &UserAgentTransport{Base: base}

View File

@@ -60,11 +60,18 @@ func (a *AppConfig) ProfileName() string {
// MultiAppConfig is the multi-app config file format.
type MultiAppConfig struct {
StrictMode StrictMode `json:"strictMode,omitempty"`
RiskControl *bool `json:"riskControl,omitempty"`
CurrentApp string `json:"currentApp,omitempty"`
PreviousApp string `json:"previousApp,omitempty"`
Apps []AppConfig `json:"apps"`
}
// RiskControlEnabled resolves the workspace policy. An omitted preference
// keeps the default-on account-protection behavior.
func (m *MultiAppConfig) RiskControlEnabled() bool {
return m != nil && (m.RiskControl == nil || *m.RiskControl)
}
// CurrentAppConfig returns the currently active app config.
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"io/fs"
"sync"
)
// ConfigSnapshot lazily captures one stable view of config.json for a CLI
// invocation. All runtime consumers share the same load result so account and
// workspace policy resolution cannot observe different file revisions. Callers
// must treat the returned config as read-only.
type ConfigSnapshot struct {
load func() (*MultiAppConfig, error)
}
// NewConfigSnapshot creates a lazily loaded invocation-scoped config snapshot.
func NewConfigSnapshot() *ConfigSnapshot {
return newConfigSnapshot(LoadMultiAppConfig)
}
func newConfigSnapshot(load func() (*MultiAppConfig, error)) *ConfigSnapshot {
if load == nil {
return &ConfigSnapshot{}
}
return &ConfigSnapshot{load: sync.OnceValues(load)}
}
// MultiAppConfig returns the captured persistent config and load error.
func (s *ConfigSnapshot) MultiAppConfig() (*MultiAppConfig, error) {
if s == nil || s.load == nil {
return nil, fs.ErrNotExist
}
return s.load()
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"errors"
"io/fs"
"testing"
)
func TestConfigSnapshotLoadsOnce(t *testing.T) {
calls := 0
want := &MultiAppConfig{}
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return want, nil
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if err != nil {
t.Fatal(err)
}
if config != want {
t.Fatal("snapshot returned a different config instance")
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}
func TestConfigSnapshotZeroValueIsMissing(t *testing.T) {
config, err := (&ConfigSnapshot{}).MultiAppConfig()
if config != nil || !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, fs.ErrNotExist)", config, err)
}
}
func TestConfigSnapshotCachesError(t *testing.T) {
calls := 0
want := errors.New("load failed")
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return nil, want
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if config != nil || !errors.Is(err, want) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, %v)", config, err, want)
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}

View File

@@ -60,7 +60,9 @@ func TestAppConfig_LangOmitEmpty(t *testing.T) {
}
func TestMultiAppConfig_RoundTrip(t *testing.T) {
disabled := false
config := &MultiAppConfig{
RiskControl: &disabled,
Apps: []AppConfig{{
AppId: "cli_test", AppSecret: PlainSecret("s"),
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
@@ -84,6 +86,9 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
if got.Apps[0].Brand != BrandLark {
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
}
if got.RiskControl == nil || *got.RiskControl {
t.Errorf("RiskControl = %v, want explicit false", got.RiskControl)
}
}
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {

View File

@@ -16,16 +16,18 @@ func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
}
func TestAgentName_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentName, "claude-code")
if got := AgentName(); got != "claude-code" {
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
const agentName = "sample-agent"
t.Setenv(CliAgentName, agentName)
if got := AgentName(); got != agentName {
t.Fatalf("AgentName() = %q, want %q", got, agentName)
}
}
func TestAgentName_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentName, " cursor ")
if got := AgentName(); got != "cursor" {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
const agentName = "sample-agent"
t.Setenv(CliAgentName, " "+agentName+" ")
if got := AgentName(); got != agentName {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, agentName)
}
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-event-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

@@ -38,6 +38,10 @@ type Stub struct {
// matches after the first hit. Each match appends to CapturedBodies.
Reusable bool
// Optional (optional): when true, Verify does not require this stub to be
// matched. Useful for negative assertions via OnMatch.
Optional bool
// CapturedHeaders records the request headers of the matched request.
// Populated after RoundTrip matches this stub.
CapturedHeaders http.Header
@@ -137,6 +141,9 @@ func (r *Registry) Verify(t testing.TB) {
if s.matched {
continue
}
if s.Optional {
continue
}
// Reusable stubs never set s.matched; treat any captured hit as a match.
if s.Reusable && len(s.CapturedBodies) > 0 {
continue

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keychain
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-keychain-test-*")
if err != nil {
panic(err)
}
for key, value := range map[string]string{
"LARKSUITE_CLI_DATA_DIR": filepath.Join(root, "data"),
"LARKSUITE_CLI_LOG_DIR": filepath.Join(root, "logs"),
} {
if err := os.Setenv(key, value); err != nil {
panic(err)
}
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -7,70 +7,91 @@ import (
"encoding/csv"
"fmt"
"io"
"os"
)
// FormatAsCSV formats data as CSV (with header) and writes it to w.
func FormatAsCSV(w io.Writer, data interface{}) {
FormatAsCSVPaginated(w, data, true)
// Match the other legacy wrappers: surface only a marshal failure (as the
// JSON fallback historically did); plain write failures stay swallowed.
if err := WriteCSV(w, data); isOutputMarshalError(err) {
legacyStderrf("json marshal error: %v\n", err)
}
}
// WriteCSV formats data as CSV and returns marshal or write errors.
func WriteCSV(w io.Writer, data interface{}) error {
return WriteCSVPaginated(w, data, true)
}
// FormatAsCSVPaginated formats data as CSV with pagination awareness.
// When isFirstPage is true, outputs the header row; otherwise only data rows.
func FormatAsCSVPaginated(w io.Writer, data interface{}, isFirstPage bool) {
if err := WriteCSVPaginated(w, data, isFirstPage); isOutputMarshalError(err) {
legacyStderrf("json marshal error: %v\n", err)
}
}
// WriteCSVPaginated formats data as CSV and returns marshal or write errors.
func WriteCSVPaginated(w io.Writer, data interface{}, isFirstPage bool) error {
rows, cols, isList := prepareRows(data)
if cols == nil {
if isList {
fmt.Fprintln(w, "(empty)")
_, err := fmt.Fprintln(w, "(empty)")
return err
} else {
PrintJson(w, data)
return WriteJSON(w, data)
}
return
}
if len(rows) == 0 {
if isFirstPage {
fmt.Fprintln(w, "(empty)")
_, err := fmt.Fprintln(w, "(empty)")
return err
}
return
return nil
}
if !isList {
// Single object: key,value rows
cw := csv.NewWriter(w)
if isFirstPage {
cw.Write([]string{"key", "value"})
if err := cw.Write([]string{"key", "value"}); err != nil {
return err
}
}
for _, col := range cols {
cw.Write([]string{col, rows[0][col]})
if err := cw.Write([]string{col, rows[0][col]}); err != nil {
return err
}
}
flushCSV(cw)
return
return flushCSV(cw)
}
writeCSVRows(w, rows, cols, isFirstPage)
return writeCSVRows(w, rows, cols, isFirstPage)
}
// writeCSVRows writes CSV data rows (and optionally header) using the given columns.
func writeCSVRows(w io.Writer, rows []map[string]string, cols []string, writeHeader bool) {
func writeCSVRows(w io.Writer, rows []map[string]string, cols []string, writeHeader bool) error {
cw := csv.NewWriter(w)
if writeHeader {
cw.Write(cols)
if err := cw.Write(cols); err != nil {
return err
}
}
for _, row := range rows {
record := make([]string, len(cols))
for i, col := range cols {
record[i] = row[col]
}
cw.Write(record)
if err := cw.Write(record); err != nil {
return err
}
}
flushCSV(cw)
return flushCSV(cw)
}
// flushCSV flushes the csv.Writer and reports any write error to stderr.
func flushCSV(cw *csv.Writer) {
// flushCSV flushes the csv.Writer and returns any write error.
func flushCSV(cw *csv.Writer) error {
cw.Flush()
if err := cw.Error(); err != nil {
fmt.Fprintf(os.Stderr, "csv write error: %v\n", err)
}
return cw.Error()
}

View File

@@ -50,10 +50,11 @@ func wrapBlockError(alert *extcs.Alert) error {
// WriteAlertWarning writes a human-readable content-safety warning to w.
// Used by non-JSON output paths (pretty, table, csv) in warn mode.
func WriteAlertWarning(w io.Writer, alert *extcs.Alert) {
func WriteAlertWarning(w io.Writer, alert *extcs.Alert) error {
if alert == nil {
return
return nil
}
fmt.Fprintf(w, "warning: content safety alert from %s (rules: %s)\n",
_, err := fmt.Fprintf(w, "warning: content safety alert from %s (rules: %s)\n",
alert.Provider, strings.Join(alert.MatchedRules, ", "))
return err
}

336
internal/output/emitter.go Normal file
View File

@@ -0,0 +1,336 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"encoding/json"
"fmt"
"io"
"maps"
"github.com/larksuite/cli/errs"
)
// NoticeProvider supplies the notice attached to a structured envelope.
// The provider is captured by an Emitter so emission never reads the global
// PendingNotice hook implicitly.
type NoticeProvider func() map[string]interface{}
// PrettyRenderer writes the human-readable representation of one result.
// colorEnabled is the terminal capability captured when the Emitter is built.
type PrettyRenderer func(w io.Writer, colorEnabled bool) error
// EmitterConfig contains command-scoped dependencies. A command constructs one
// Emitter and reuses it for its success result or streamed pages.
type EmitterConfig struct {
Out io.Writer
ErrOut io.Writer
CommandPath string
Identity string
ColorEnabled bool
NoticeProvider NoticeProvider
}
// EmitOptions describes one result's wire representation.
//
// The format contract is explicit: JSON (including the empty default) uses an
// Envelope; pretty, table, csv, and ndjson render naked business data. JQ takes
// precedence over Format and filters the JSON Envelope. Raw affects only JSON
// envelope encoding and jq's complex-value encoding.
//
// JQSafetyWarning preserves the legacy difference between RuntimeContext.emit
// (false) and WriteSuccessEnvelope (true) until their callers are migrated.
type EmitOptions struct {
Raw bool
Meta *Meta
Format string
JQ string
DryRun bool
Pretty PrettyRenderer
JQSafetyWarning bool
}
// StreamOptions describes one streamed page's wire representation. Streaming
// carries page items directly, so it deliberately exposes only the fields that
// affect a single page: the format and, for pretty, its renderer. It has no
// OK/Meta/DryRun/JQ — an ok:false envelope, metadata, dry-run, and jq all need
// the aggregated result, which the caller's pagination layer owns before it
// streams pages.
type StreamOptions struct {
Format string
Pretty PrettyRenderer
}
// Emitter owns all command-scoped output dependencies and pagination state.
// It deliberately has no dependency on client or cmdutil.
type Emitter struct {
out io.Writer
errOut io.Writer
commandPath string
identity string
colorEnabled bool
noticeProvider NoticeProvider
streamFormat string
streamFormatter *PaginatedFormatter
}
// NewEmitter constructs a command-scoped output emitter.
func NewEmitter(config EmitterConfig) *Emitter {
errOut := config.ErrOut
if errOut == nil {
errOut = io.Discard
}
return &Emitter{
out: config.Out,
errOut: errOut,
commandPath: config.CommandPath,
identity: config.Identity,
colorEnabled: config.ColorEnabled,
noticeProvider: config.NoticeProvider,
}
}
// Success scans and emits one command result by composing the package's leaf
// primitives. JSON and jq use the standard envelope; pretty, table, csv, and
// ndjson render the business value directly.
func (e *Emitter) Success(data interface{}, opts EmitOptions) error {
if err := e.requireOutput(); err != nil {
return err
}
if opts.JQ != "" {
return e.emitEnvelope(data, true, opts)
}
switch opts.Format {
case "", "json":
return e.emitEnvelope(data, true, opts)
case "pretty":
return e.emitPretty(data, opts)
default:
return e.emitFormatted(data, opts.Format)
}
}
// PartialFailure emits a multi-status result whose envelope honestly reports
// ok:false. It is the typed counterpart to Success for batch operations where
// some items failed but the per-item outcomes are the primary stdout output.
// Like the legacy OutPartialFailure it produces only the JSON/jq envelope; the
// caller owns the non-zero exit signal, keeping the Emitter free of exit
// semantics.
func (e *Emitter) PartialFailure(data interface{}, opts EmitOptions) error {
if err := e.requireOutput(); err != nil {
return err
}
return e.emitEnvelope(data, false, opts)
}
// StreamPage scans and emits one page while retaining table/csv columns from
// the first page. Streamed output carries page items directly, so it takes a
// StreamOptions (format + optional pretty renderer) rather than the full
// EmitOptions: ok/meta/dry-run/jq all need the aggregated result and are the
// caller's pagination-layer responsibility, not a per-page concern. Excluding
// jq from the type makes "jq requires aggregated output" a compile-time fact
// instead of a runtime rejection.
func (e *Emitter) StreamPage(data interface{}, opts StreamOptions) error {
if err := e.requireOutput(); err != nil {
return err
}
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
if opts.Format == "pretty" {
if opts.Pretty == nil {
return errs.NewInternalError(errs.SubtypeUnknown,
"pretty output requires a renderer")
}
return e.emit(func(w io.Writer) error {
return opts.Pretty(w, e.colorEnabled)
})
}
format, known := ParseFormat(opts.Format)
if !known && e.streamFormatter == nil && e.errOut != nil {
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
if e.streamFormatter == nil {
e.streamFormat = opts.Format
e.streamFormatter = NewPaginatedFormatter(nil, format)
} else if opts.Format != e.streamFormat {
return errs.NewInternalError(errs.SubtypeUnknown,
"stream output format changed from %q to %q", e.streamFormat, opts.Format)
}
return e.emit(func(w io.Writer) error {
e.streamFormatter.W = w
return e.streamFormatter.WritePage(data)
})
}
func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) error {
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
env := Envelope{
OK: ok,
Identity: e.identity,
DryRun: opts.DryRun,
Data: data,
Meta: opts.Meta,
Notice: e.notice(),
}
if scanResult.Alert != nil {
env.ContentSafetyAlert = scanResult.Alert
}
if opts.JQ != "" {
if scanResult.Alert != nil && opts.JQSafetyWarning {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
// Buffer the jq output manually so jq's own typed error (a validation
// error for a bad expression, an api error for a runtime failure) is
// returned unchanged; only a genuine stdout write failure is wrapped as
// an internal output error.
var buf bytes.Buffer
var jqErr error
if opts.Raw {
jqErr = JqFilterRaw(&buf, env, opts.JQ)
} else {
jqErr = JqFilter(&buf, env, opts.JQ)
}
if jqErr != nil {
return jqErr
}
if _, err := io.Copy(e.out, &buf); err != nil {
return wrapOutputError("write", err)
}
return nil
}
return e.emit(func(w io.Writer) error {
if opts.Raw {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
return enc.Encode(env)
}
return WriteJSON(w, env)
})
}
func (e *Emitter) emitPretty(data interface{}, opts EmitOptions) error {
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
if opts.Pretty != nil {
return e.emit(func(w io.Writer) error {
return opts.Pretty(w, e.colorEnabled)
})
}
// RuntimeContext.outFormat falls back through Out/OutRaw when no pretty
// renderer is supplied. Keep that second scan visible in the leaf contract
// until production callers are migrated and the legacy behavior is removed.
return e.emitEnvelope(data, true, opts)
}
func (e *Emitter) emitFormatted(data interface{}, rawFormat string) error {
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
format, known := ParseFormat(rawFormat)
if !known && e.errOut != nil {
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", rawFormat)
}
if format == FormatJSON {
return e.printLegacyDataJSON(data)
}
return e.emit(func(w io.Writer) error {
return WriteFormatted(w, data, format)
})
}
type emitterDataMap map[string]interface{}
// printLegacyDataJSON matches FormatValue's JSON branch while sourcing notice
// data from this Emitter instead of PrintJson's global PendingNotice hook.
func (e *Emitter) printLegacyDataJSON(data interface{}) error {
// Normalise structs / named maps to plain generic types first, exactly as
// FormatValue does, so a struct or named-map payload still matches the map
// case below and keeps its injected _notice on the unknown-format fallback.
data = toGeneric(data)
if m, ok := data.(map[string]interface{}); ok {
if _, isEnvelope := m["ok"]; isEnvelope {
if notice := e.notice(); notice != nil {
m = maps.Clone(m)
m["_notice"] = notice
}
}
// The named map retains identical JSON bytes while preventing PrintJson
// from consulting its legacy global notice hook a second time.
return e.emit(func(w io.Writer) error {
return WriteJSON(w, emitterDataMap(m))
})
}
return e.emit(func(w io.Writer) error {
return WriteJSON(w, data)
})
}
func (e *Emitter) emit(render func(io.Writer) error) error {
var buf bytes.Buffer
if err := render(&buf); err != nil {
return wrapOutputError("render", err)
}
if _, err := io.Copy(e.out, &buf); err != nil {
return wrapOutputError("write", err)
}
return nil
}
func wrapOutputError(op string, err error) error {
return errs.NewInternalError(errs.SubtypeUnknown, "failed to %s command output", op).WithCause(err)
}
func (e *Emitter) notice() map[string]interface{} {
if e.noticeProvider == nil {
return nil
}
return e.noticeProvider()
}
func (e *Emitter) requireOutput() error {
if e == nil || e.out == nil {
return errs.NewInternalError(errs.SubtypeUnknown,
"success output writer is not configured")
}
return nil
}

View File

@@ -0,0 +1,350 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/output"
)
type contractFailingWriter struct {
err error
}
func (w contractFailingWriter) Write([]byte) (int, error) {
return 0, w.err
}
type contractSafetyProvider struct {
alert *extcs.Alert
}
func (p *contractSafetyProvider) Name() string {
return "emitter-contract"
}
func (p *contractSafetyProvider) Scan(context.Context, extcs.ScanRequest) (*extcs.Alert, error) {
return p.alert, nil
}
func TestEmitterSuccessWritesAllBytes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
})
data := map[string]interface{}{"id": "1"}
err := emitter.Success(data, output.EmitOptions{Format: "json"})
if err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
want, marshalErr := json.MarshalIndent(output.Envelope{OK: true, Identity: "bot", Data: data}, "", " ")
if marshalErr != nil {
t.Fatalf("marshal expected envelope: %v", marshalErr)
}
want = append(want, '\n')
if !bytes.Equal(stdout.Bytes(), want) {
t.Fatalf("stdout bytes = %q, want %q", stdout.Bytes(), want)
}
}
func TestEmitterMarshalFailureReturnsTypedErrorWithoutOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"unsupported": func() {}}, output.EmitOptions{Format: "json"})
if err == nil {
t.Fatal("Emitter.Success() error = nil, want marshal failure")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
}
var unsupported *json.UnsupportedTypeError
if !errors.As(err, &unsupported) {
t.Fatalf("Emitter.Success() error = %v, want json.UnsupportedTypeError cause", err)
}
if stdout.Len() != 0 {
t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
}
}
func TestEmitterWriterFailurePreservesCause(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
sentinel := errors.New("write failed")
emitter := output.NewEmitter(output.EmitterConfig{
Out: contractFailingWriter{err: sentinel},
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "json"})
if !errors.Is(err, sentinel) {
t.Fatalf("Emitter.Success() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
}
}
func TestEmitterPrettyRendererFailurePreservesCause(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
sentinel := errors.New("pretty render failed")
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
Format: "pretty",
Pretty: func(io.Writer, bool) error {
return sentinel
},
})
if !errors.Is(err, sentinel) {
t.Fatalf("Emitter.Success() error = %v, want preserved renderer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
}
if stdout.Len() != 0 {
t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
}
}
func TestEmitterAlertWarningFailurePreservesCause(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
extcs.Register(&contractSafetyProvider{alert: &extcs.Alert{
Provider: "emitter-contract",
MatchedRules: []string{"fixture-rule"},
}})
t.Cleanup(func() { extcs.Register(nil) })
sentinel := errors.New("warning write failed")
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: contractFailingWriter{err: sentinel},
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success([]interface{}{map[string]interface{}{"id": "1"}}, output.EmitOptions{Format: "table"})
if !errors.Is(err, sentinel) {
t.Fatalf("Emitter.Success() error = %v, want preserved warning writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
}
if stdout.Len() != 0 {
t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
}
}
func TestNewEmitterDefaultsNilErrOutToDiscard(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
extcs.Register(&contractSafetyProvider{alert: &extcs.Alert{
Provider: "emitter-contract",
MatchedRules: []string{"fixture-rule"},
}})
t.Cleanup(func() { extcs.Register(nil) })
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
CommandPath: "lark-cli fixture +emit",
})
if err := emitter.Success([]interface{}{map[string]interface{}{"id": "1"}}, output.EmitOptions{Format: "table"}); err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
if stdout.Len() == 0 {
t.Fatal("Emitter.Success() stdout is empty")
}
}
func TestEmitterDoesNotMutateCallerMap(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
data := map[string]interface{}{"ok": true, "value": "fixture"}
want := map[string]interface{}{"ok": true, "value": "fixture"}
emitter := output.NewEmitter(output.EmitterConfig{
Out: &bytes.Buffer{},
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
NoticeProvider: func() map[string]interface{} {
return map[string]interface{}{"update": "available"}
},
})
if err := emitter.Success(data, output.EmitOptions{Format: "yaml"}); err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
if !reflect.DeepEqual(data, want) {
t.Fatalf("caller map = %#v, want unchanged %#v", data, want)
}
}
func TestEmitterDoesNotOverwriteCallerNotice(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
existing := map[string]interface{}{"source": "caller"}
data := map[string]interface{}{"ok": true, "_notice": existing}
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
NoticeProvider: func() map[string]interface{} {
return map[string]interface{}{"source": "provider"}
},
})
if err := emitter.Success(data, output.EmitOptions{Format: "yaml"}); err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
if got := data["_notice"]; !reflect.DeepEqual(got, existing) {
t.Fatalf("caller _notice = %#v, want unchanged %#v", got, existing)
}
var emitted map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &emitted); err != nil {
t.Fatalf("decode stdout: %v", err)
}
if got := emitted["_notice"]; !reflect.DeepEqual(got, map[string]interface{}{"source": "provider"}) {
t.Fatalf("emitted _notice = %#v, want provider notice", got)
}
}
func TestEmitterReadsNoticeProviderAtMostOncePerEmission(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
calls := 0
emitter := output.NewEmitter(output.EmitterConfig{
Out: &bytes.Buffer{},
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
NoticeProvider: func() map[string]interface{} {
calls++
return map[string]interface{}{"source": "provider"}
},
})
if err := emitter.Success(map[string]interface{}{"ok": true}, output.EmitOptions{Format: "yaml"}); err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
if calls != 1 {
t.Fatalf("notice provider calls = %d, want 1", calls)
}
}
func TestEmitterRawJSONPropagatesWriteError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
sentinel := errors.New("write failed")
emitter := output.NewEmitter(output.EmitterConfig{
Out: contractFailingWriter{err: sentinel},
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
Raw: true, Format: "json",
})
if !errors.Is(err, sentinel) {
t.Fatalf("Emitter.Success() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
}
}
func TestEmitterInvalidJQReturnsErrorWithoutStderr(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stderr := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: &bytes.Buffer{},
ErrOut: stderr,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
Format: "json",
JQ: "this is not valid jq (((",
})
if err == nil {
t.Fatal("Success() with invalid jq = nil, want error")
}
if stderr.Len() != 0 {
t.Fatalf("Success() with invalid jq wrote stderr %q, want empty", stderr.String())
}
}
func TestEmitterJQRuntimeErrorPreservesTypedError(t *testing.T) {
// A valid expression that fails at runtime must surface jq's own typed error
// (an api error), not a wrapped internal output error, and must emit no
// partial stdout.
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
Format: "json",
JQ: `error("boom")`,
})
if err == nil {
t.Fatal("Success() with a runtime jq error = nil, want error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category == errs.CategoryInternal {
t.Fatalf("Success() jq runtime error problem = %#v, %v; want jq's own typed error, not internal", problem, ok)
}
if !strings.Contains(err.Error(), "jq error") {
t.Fatalf("Success() jq runtime error = %v, want jq's own error message preserved", err)
}
if stdout.Len() != 0 {
t.Fatalf("Success() jq runtime error wrote stdout %q, want empty", stdout.String())
}
}
func TestEmitterUnknownFormatStructKeepsNotice(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
type payload struct {
OK bool `json:"ok"`
Value string `json:"value"`
}
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
NoticeProvider: func() map[string]interface{} {
return map[string]interface{}{"update": map[string]interface{}{"latest": "9.9.9"}}
},
})
if err := emitter.Success(payload{OK: true, Value: "fixture"}, output.EmitOptions{Format: "yaml"}); err != nil {
t.Fatalf("Success() error = %v", err)
}
if !strings.Contains(stdout.String(), "_notice") {
t.Fatalf("struct payload on unknown-format fallback dropped _notice:\n%s", stdout.String())
}
}

View File

@@ -0,0 +1,827 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Legacy oracle fixtures are frozen at base SHA 4a56748bfa941ff0ee0bfec92e65acac427732b0.
// Golden regeneration is allowed only from that base, never from the current system under test.
package output_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
type emitterCapture struct {
stdout string
stderr string
err error
}
type emitterSafetyProvider struct {
alert *extcs.Alert
err error
}
func (p *emitterSafetyProvider) Name() string { return "emitter-oracle" }
func (p *emitterSafetyProvider) Scan(context.Context, extcs.ScanRequest) (*extcs.Alert, error) {
return p.alert, p.err
}
const (
runtimeContextLegacyGoldenPath = "testdata/runtime_context_legacy.golden.json"
writeSuccessEnvelopeLegacyGoldenPath = "testdata/write_success_envelope_legacy.golden.json"
)
type runtimeContextOracleCase struct {
name string
data func() interface{}
raw bool
ok bool
meta *output.Meta
jq string
format string
useFormat bool
pretty bool
notice map[string]interface{}
safetyMode string
safetyAlert *extcs.Alert
safetyErr error
}
type runtimeContextLegacyGolden struct {
Cases map[string]emitterCaptureGolden `json:"cases"`
}
type writeSuccessEnvelopeOracleCase struct {
name string
data func() interface{}
dryRun bool
jq string
notice map[string]interface{}
safetyMode string
safetyAlert *extcs.Alert
}
type writeSuccessEnvelopeLegacyGolden struct {
Cases map[string]emitterCaptureGolden `json:"cases"`
}
type emitterCaptureGolden struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Error *emitterErrorGolden `json:"error,omitempty"`
}
type emitterErrorGolden struct {
GoType string `json:"go_type"`
JSON json.RawMessage `json:"json"`
Message string `json:"message"`
ExitCode int `json:"exit_code"`
}
func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
previousNotice := output.PendingNotice
t.Cleanup(func() {
output.PendingNotice = previousNotice
extcs.Register(nil)
})
cases := []runtimeContextOracleCase{
{
name: "json_object",
data: func() interface{} {
return map[string]interface{}{"id": "1", "enabled": true}
},
ok: true,
},
{
name: "raw_json_preserves_html",
data: func() interface{} {
return map[string]interface{}{"html": "<p>a&b</p>"}
},
raw: true,
ok: true,
},
{
name: "format_raw_json_preserves_html",
data: func() interface{} {
return map[string]interface{}{"html": "<p>a&b</p>"}
},
raw: true,
ok: true,
format: "json",
useFormat: true,
},
{
name: "partial_failure_ok_false",
data: func() interface{} {
return map[string]interface{}{"succeeded": 1, "failed": 1}
},
ok: false,
},
{
name: "metadata",
data: func() interface{} {
return []interface{}{map[string]interface{}{"id": "1"}}
},
ok: true,
meta: &output.Meta{Count: 1, Rollback: "lark-cli fixture rollback"},
},
{
name: "jq_scalar",
data: func() interface{} {
return map[string]interface{}{"name": "Alice", "age": 30}
},
ok: true,
jq: ".data.name",
},
{
name: "raw_jq_complex",
data: func() interface{} {
return map[string]interface{}{"document": map[string]interface{}{"html": "<p>a&b</p>"}}
},
raw: true,
ok: true,
jq: ".data.document",
},
{
name: "jq_invalid_expression",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
ok: false,
jq: "invalid[",
},
{
name: "notice",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
ok: true,
notice: map[string]interface{}{"update": map[string]interface{}{"latest": "9.9.9"}},
},
{
name: "pretty",
data: func() interface{} {
return map[string]interface{}{"name": "Alice"}
},
ok: true,
format: "pretty",
useFormat: true,
pretty: true,
},
{
name: "pretty_without_renderer",
data: func() interface{} {
return map[string]interface{}{"name": "Alice"}
},
ok: true,
format: "pretty",
useFormat: true,
},
{
name: "ndjson",
data: func() interface{} {
return map[string]interface{}{"items": []interface{}{
map[string]interface{}{"id": "1"},
map[string]interface{}{"id": "2"},
}}
},
ok: true,
format: "ndjson",
useFormat: true,
},
{
name: "table_with_safety_warning",
data: func() interface{} {
return []interface{}{map[string]interface{}{"id": "1", "name": "Alice"}}
},
ok: true,
format: "table",
useFormat: true,
safetyMode: "warn",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "csv",
data: func() interface{} {
return []interface{}{
map[string]interface{}{"id": "1", "name": "Alice"},
map[string]interface{}{"id": "2", "name": "Bob"},
}
},
ok: true,
format: "csv",
useFormat: true,
},
{
name: "jq_safety_alert_without_stderr_warning",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
ok: true,
jq: ".data.id",
safetyMode: "warn",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "scanner_error_fails_open",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
ok: true,
safetyMode: "warn",
safetyErr: errors.New("scanner unavailable"),
},
{
name: "scanner_block",
data: func() interface{} {
return map[string]interface{}{"id": "blocked"}
},
ok: false,
safetyMode: "block",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "unknown_format_data_envelope_notice",
data: func() interface{} {
return map[string]interface{}{"ok": true, "value": "fixture"}
},
ok: true,
format: "yaml",
useFormat: true,
notice: map[string]interface{}{"skills": map[string]interface{}{"current": "1.0.0"}},
},
}
golden := loadRuntimeContextLegacyGolden(t)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mode := tc.safetyMode
if mode == "" {
mode = "off"
}
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode)
extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert, err: tc.safetyErr})
t.Cleanup(func() { extcs.Register(nil) })
notice := tc.notice
output.PendingNotice = func() map[string]interface{} { return notice }
want, ok := golden.Cases[tc.name]
if !ok {
t.Fatalf("frozen golden case %q is missing", tc.name)
}
opts := runtimeOracleOptions{
raw: tc.raw,
ok: tc.ok,
meta: tc.meta,
jq: tc.jq,
format: tc.format,
useFormat: tc.useFormat,
pretty: tc.pretty,
}
current := runEmitterWithRuntimeContextContract(tc.data(), output.EmitterConfig{
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
NoticeProvider: func() map[string]interface{} { return notice },
}, tc.ok, output.EmitOptions{
Raw: tc.raw,
Meta: tc.meta,
Format: tc.format,
JQ: tc.jq,
Pretty: emitterPrettyRenderer(tc.pretty),
})
assertEmitterGolden(t, want, current)
integrated := runRuntimeContextOracle(t, tc.data(), opts)
assertEmitterGolden(t, want, integrated)
if tc.safetyMode == "block" {
var safetyErr *errs.ContentSafetyError
if !errors.As(current.err, &safetyErr) {
t.Fatalf("Emitter.Success() error = %T, want *errs.ContentSafetyError", current.err)
}
}
})
}
if len(golden.Cases) != len(cases) {
t.Fatalf("golden case count = %d, want %d", len(golden.Cases), len(cases))
}
jqFailure := golden.Cases["jq_invalid_expression"]
if !strings.HasPrefix(jqFailure.Stderr, "error: ") || !strings.HasSuffix(jqFailure.Stderr, "\n") {
t.Fatalf("invalid jq golden stderr = %q, want error line ending in newline", jqFailure.Stderr)
}
if jqFailure.Error == nil || jqFailure.Error.ExitCode != output.ExitValidation {
t.Fatalf("invalid jq golden exit = %#v, want %d", jqFailure.Error, output.ExitValidation)
}
}
func loadRuntimeContextLegacyGolden(t *testing.T) runtimeContextLegacyGolden {
t.Helper()
contents, err := os.ReadFile(runtimeContextLegacyGoldenPath)
if err != nil {
t.Fatalf("read RuntimeContext legacy golden: %v", err)
}
var golden runtimeContextLegacyGolden
if err := json.Unmarshal(contents, &golden); err != nil {
t.Fatalf("decode RuntimeContext legacy golden: %v", err)
}
return golden
}
func captureEmitterGolden(t *testing.T, capture emitterCapture) emitterCaptureGolden {
t.Helper()
golden := emitterCaptureGolden{Stdout: capture.stdout, Stderr: capture.stderr}
if capture.err == nil {
return golden
}
errorJSON, err := json.Marshal(capture.err)
if err != nil {
t.Fatalf("marshal captured error %T: %v", capture.err, err)
}
golden.Error = &emitterErrorGolden{
GoType: fmt.Sprintf("%T", capture.err),
JSON: errorJSON,
Message: capture.err.Error(),
ExitCode: output.ExitCodeOf(capture.err),
}
return golden
}
type runtimeOracleOptions struct {
raw bool
ok bool
meta *output.Meta
jq string
format string
useFormat bool
pretty bool
}
func runRuntimeContextOracle(t *testing.T, data interface{}, opts runtimeOracleOptions) emitterCapture {
t.Helper()
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
parent := &cobra.Command{Use: "lark-cli"}
cmd := &cobra.Command{Use: "fixture"}
leaf := &cobra.Command{Use: "+emit"}
parent.AddCommand(cmd)
cmd.AddCommand(leaf)
factory := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}}
runtime := common.TestNewRuntimeContextForAPI(
context.Background(), leaf, &core.CliConfig{Brand: core.BrandFeishu}, factory, core.AsBot,
)
runtime.Format = opts.format
runtime.JqExpr = opts.jq
pretty := func(w io.Writer) {
fmt.Fprintln(w, "pretty:fixture")
}
if !opts.pretty {
pretty = nil
}
var err error
switch {
case opts.useFormat && opts.raw:
runtime.OutFormatRaw(data, opts.meta, pretty)
case opts.useFormat:
runtime.OutFormat(data, opts.meta, pretty)
case !opts.ok:
err = runtime.OutPartialFailure(data, opts.meta)
case opts.raw:
runtime.OutRaw(data, opts.meta)
default:
runtime.Out(data, opts.meta)
}
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
}
func runEmitterSuccess(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
config.Out = stdout
config.ErrOut = stderr
emitter := output.NewEmitter(config)
var err error
if ok {
err = emitter.Success(data, opts)
} else {
err = emitter.PartialFailure(data, opts)
}
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
}
func runEmitterWithRuntimeContextContract(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture {
capture := runEmitterSuccess(data, config, ok, opts)
if capture.err != nil {
var safetyErr *errs.ContentSafetyError
if errors.As(capture.err, &safetyErr) {
return capture
}
if opts.JQ != "" {
capture.stderr += fmt.Sprintf("error: %v\n", capture.err)
return capture
}
capture.err = nil
}
if !ok {
capture.err = output.PartialFailure(output.ExitAPI)
}
return capture
}
func emitterPrettyRenderer(enabled bool) output.PrettyRenderer {
if !enabled {
return nil
}
return func(w io.Writer, _ bool) error {
_, err := fmt.Fprintln(w, "pretty:fixture")
return err
}
}
func TestEmitterMatchesWriteSuccessEnvelopeLegacyOracle(t *testing.T) {
previousNotice := output.PendingNotice
t.Cleanup(func() {
output.PendingNotice = previousNotice
extcs.Register(nil)
})
cases := []writeSuccessEnvelopeOracleCase{
{
name: "json",
data: func() interface{} { return map[string]interface{}{"id": "1"} },
},
{
name: "dry_run",
data: func() interface{} { return map[string]interface{}{"api": []interface{}{}} },
dryRun: true,
},
{
name: "jq",
data: func() interface{} { return map[string]interface{}{"id": "1"} },
jq: ".data.id",
},
{
name: "notice",
data: func() interface{} { return map[string]interface{}{"id": "1"} },
notice: map[string]interface{}{"update": map[string]interface{}{"latest": "9.9.9"}},
},
{
name: "jq_safety_warning",
data: func() interface{} { return map[string]interface{}{"id": "1"} },
jq: ".data.id",
safetyMode: "warn",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "scanner_block",
data: func() interface{} { return map[string]interface{}{"id": "blocked"} },
safetyMode: "block",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
}
golden := loadWriteSuccessEnvelopeLegacyGolden(t)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mode := tc.safetyMode
if mode == "" {
mode = "off"
}
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode)
extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert})
t.Cleanup(func() { extcs.Register(nil) })
notice := tc.notice
output.PendingNotice = func() map[string]interface{} { return notice }
want, ok := golden.Cases[tc.name]
if !ok {
t.Fatalf("frozen golden case %q is missing", tc.name)
}
current := runEmitterSuccess(tc.data(), output.EmitterConfig{
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
NoticeProvider: func() map[string]interface{} { return notice },
}, true, output.EmitOptions{
Format: "",
Raw: false,
JQ: tc.jq,
DryRun: tc.dryRun,
JQSafetyWarning: true,
})
assertEmitterGolden(t, want, current)
integrated := runWriteSuccessEnvelopeOracle(tc.data(), tc.dryRun, tc.jq)
assertEmitterGolden(t, want, integrated)
})
}
if len(golden.Cases) != len(cases) {
t.Fatalf("golden case count = %d, want %d", len(golden.Cases), len(cases))
}
}
func loadWriteSuccessEnvelopeLegacyGolden(t *testing.T) writeSuccessEnvelopeLegacyGolden {
t.Helper()
contents, err := os.ReadFile(writeSuccessEnvelopeLegacyGoldenPath)
if err != nil {
t.Fatalf("read WriteSuccessEnvelope legacy golden: %v", err)
}
var golden writeSuccessEnvelopeLegacyGolden
if err := json.Unmarshal(contents, &golden); err != nil {
t.Fatalf("decode WriteSuccessEnvelope legacy golden: %v", err)
}
return golden
}
func runWriteSuccessEnvelopeOracle(data interface{}, dryRun bool, jq string) emitterCapture {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
err := output.WriteSuccessEnvelope(data, output.SuccessEnvelopeOptions{
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
DryRun: dryRun,
JqExpr: jq,
Out: stdout,
ErrOut: stderr,
})
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
}
func TestEmitterStreamPageMatchesPaginationLegacyOracle(t *testing.T) {
t.Cleanup(func() { extcs.Register(nil) })
type oracleCase struct {
name string
format output.Format
safetyMode string
safetyAlert *extcs.Alert
}
cases := []oracleCase{
{name: "ndjson", format: output.FormatNDJSON},
{name: "table", format: output.FormatTable},
{name: "csv", format: output.FormatCSV},
{
name: "warn",
format: output.FormatNDJSON,
safetyMode: "warn",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "block",
format: output.FormatTable,
safetyMode: "block",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
}
pages := []interface{}{
[]interface{}{map[string]interface{}{"id": "1", "name": "Alice"}},
[]interface{}{map[string]interface{}{"id": "2", "name": "Bob", "ignored": true}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mode := tc.safetyMode
if mode == "" {
mode = "off"
}
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode)
extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert})
t.Cleanup(func() { extcs.Register(nil) })
legacy := runPaginationOracle(pages, tc.format)
current := runEmitterStreamPages(pages, tc.format.String())
assertEmitterBytes(t, legacy, current)
assertEquivalentError(t, legacy.err, current.err)
})
}
}
func runPaginationOracle(pages []interface{}, format output.Format) emitterCapture {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
formatter := output.NewPaginatedFormatter(stdout, format)
var emitErr error
for _, page := range pages {
scanResult := output.ScanForSafety("lark-cli fixture +emit", page, stderr)
if scanResult.Blocked {
emitErr = scanResult.BlockErr
break
}
if scanResult.Alert != nil {
output.WriteAlertWarning(stderr, scanResult.Alert)
}
formatter.FormatPage(page)
}
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr}
}
func runEmitterStreamPages(pages []interface{}, format string) emitterCapture {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: stderr,
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
})
var emitErr error
for _, page := range pages {
if emitErr = emitter.StreamPage(page, output.StreamOptions{Format: format}); emitErr != nil {
break
}
}
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr}
}
func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
previousNotice := output.PendingNotice
output.PendingNotice = func() map[string]interface{} {
return map[string]interface{}{"source": "global"}
}
t.Cleanup(func() { output.PendingNotice = previousNotice })
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
colorSeen := false
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: stderr,
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
ColorEnabled: true,
NoticeProvider: func() map[string]interface{} {
return map[string]interface{}{"source": "captured"}
},
})
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "json"}); err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
t.Fatalf("notice source was not captured by Emitter:\n%s", stdout.String())
}
stdout.Reset()
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "pretty",
Pretty: func(w io.Writer, colorEnabled bool) error {
colorSeen = colorEnabled
_, err := fmt.Fprintln(w, "pretty")
return err
},
}); err != nil {
t.Fatalf("Emitter.Success(pretty) error = %v", err)
}
if !colorSeen {
t.Fatal("PrettyRenderer did not receive captured ColorEnabled value")
}
stdout.Reset()
if err := emitter.Success(map[string]interface{}{"ok": true, "id": "1"}, output.EmitOptions{Format: "yaml"}); err != nil {
t.Fatalf("Emitter.Success(unknown format) error = %v", err)
}
if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
t.Fatalf("legacy JSON fallback consulted global notice:\n%s", stdout.String())
}
}
type failingEmitterWriter struct {
err error
}
func (w failingEmitterWriter) Write([]byte) (int, error) { return 0, w.err }
func TestEmitterPropagatesOutputError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
sentinel := errors.New("write failed")
emitter := output.NewEmitter(output.EmitterConfig{
Out: failingEmitterWriter{err: sentinel},
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
Raw: true, Format: "json",
JQ: ".data",
})
if !errors.Is(err, sentinel) {
t.Fatalf("Emitter.Success() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
}
}
func assertEmitterBytes(t *testing.T, legacy, current emitterCapture) {
t.Helper()
if legacy.stdout != current.stdout {
t.Fatalf("stdout byte mismatch\nlegacy (%d bytes):\n%q\nEmitter (%d bytes):\n%q",
len(legacy.stdout), legacy.stdout, len(current.stdout), current.stdout)
}
if legacy.stderr != current.stderr {
t.Fatalf("stderr byte mismatch\nlegacy (%d bytes):\n%q\nEmitter (%d bytes):\n%q",
len(legacy.stderr), legacy.stderr, len(current.stderr), current.stderr)
}
}
func assertEmitterGolden(t *testing.T, want emitterCaptureGolden, current emitterCapture) {
t.Helper()
if want.Stdout != current.stdout {
t.Fatalf("stdout byte mismatch\ngolden (%d bytes):\n%q\ncurrent (%d bytes):\n%q",
len(want.Stdout), want.Stdout, len(current.stdout), current.stdout)
}
if want.Stderr != current.stderr {
t.Fatalf("stderr byte mismatch\ngolden (%d bytes):\n%q\ncurrent (%d bytes):\n%q",
len(want.Stderr), want.Stderr, len(current.stderr), current.stderr)
}
got := captureEmitterGolden(t, current)
if (want.Error == nil) != (got.Error == nil) {
t.Fatalf("error presence mismatch: golden=%#v current=%#v", want.Error, got.Error)
}
if want.Error == nil {
return
}
if want.Error.GoType != got.Error.GoType || want.Error.Message != got.Error.Message || want.Error.ExitCode != got.Error.ExitCode {
t.Fatalf("error mismatch:\ngolden: %#v\ncurrent: %#v", want.Error, got.Error)
}
var wantJSON interface{}
if err := json.Unmarshal(want.Error.JSON, &wantJSON); err != nil {
t.Fatalf("decode golden error JSON: %v", err)
}
var gotJSON interface{}
if err := json.Unmarshal(got.Error.JSON, &gotJSON); err != nil {
t.Fatalf("decode current error JSON: %v", err)
}
if !reflect.DeepEqual(wantJSON, gotJSON) {
t.Fatalf("error JSON mismatch:\ngolden: %s\ncurrent: %s", want.Error.JSON, got.Error.JSON)
}
}
func assertEquivalentError(t *testing.T, legacy, current error) {
t.Helper()
if (legacy == nil) != (current == nil) {
t.Fatalf("error presence mismatch: legacy=%v Emitter=%v", legacy, current)
}
if legacy == nil {
return
}
legacyProblem, legacyOK := errs.ProblemOf(legacy)
currentProblem, currentOK := errs.ProblemOf(current)
if legacyOK != currentOK {
t.Fatalf("typed error mismatch: legacy=%T Emitter=%T", legacy, current)
}
if legacyOK && !reflect.DeepEqual(legacyProblem, currentProblem) {
t.Fatalf("problem mismatch:\nlegacy: %#v\nEmitter: %#v", legacyProblem, currentProblem)
}
}

View File

@@ -34,27 +34,17 @@ func SuccessEnvelopeData(result interface{}) interface{} {
// JSON output carries content-safety alerts inside the envelope. When jq is
// applied, the alert may be filtered away, so warn mode also writes stderr.
func WriteSuccessEnvelope(data interface{}, opts SuccessEnvelopeOptions) error {
scanResult := ScanForSafety(opts.CommandPath, data, opts.ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
env := Envelope{
OK: true,
Identity: opts.Identity,
DryRun: opts.DryRun,
Data: data,
Notice: GetNotice(),
}
if scanResult.Alert != nil {
env.ContentSafetyAlert = scanResult.Alert
}
if opts.JqExpr != "" {
if scanResult.Alert != nil && opts.ErrOut != nil {
WriteAlertWarning(opts.ErrOut, scanResult.Alert)
}
return JqFilter(opts.Out, env, opts.JqExpr)
}
PrintJson(opts.Out, env)
return nil
return NewEmitter(EmitterConfig{
Out: opts.Out,
ErrOut: opts.ErrOut,
CommandPath: opts.CommandPath,
Identity: opts.Identity,
NoticeProvider: GetNotice,
}).Success(data, EmitOptions{
Format: "",
Raw: false,
JQ: opts.JqExpr,
DryRun: opts.DryRun,
JQSafetyWarning: true,
})
}

View File

@@ -101,34 +101,44 @@ func ExtractItems(data interface{}) []interface{} {
// FormatValue formats a single response and writes it to w.
func FormatValue(w io.Writer, data interface{}, format Format) {
err := WriteFormatted(w, data, format)
switch {
case err == nil:
return
case isOutputMarshalError(err) && format == FormatNDJSON:
legacyStderrf("ndjson marshal error: %v\n", err)
case isOutputMarshalError(err):
legacyStderrf("json marshal error: %v\n", err)
}
}
// WriteFormatted formats a single response and returns marshal or write errors.
func WriteFormatted(w io.Writer, data interface{}, format Format) error {
data = toGeneric(data)
switch format {
case FormatNDJSON:
items := ExtractItems(data)
if items != nil {
PrintNdjson(w, items)
} else {
PrintNdjson(w, data)
return WriteNDJSON(w, items)
}
return WriteNDJSON(w, data)
case FormatTable:
items := ExtractItems(data)
if items != nil {
FormatAsTable(w, items)
} else {
FormatAsTable(w, data)
return WriteTable(w, items)
}
return WriteTable(w, data)
case FormatCSV:
items := ExtractItems(data)
if items != nil {
FormatAsCSV(w, items)
} else {
FormatAsCSV(w, data)
return WriteCSV(w, items)
}
return WriteCSV(w, data)
default: // FormatJSON
PrintJson(w, data)
return WriteJSON(w, data)
}
}
@@ -148,49 +158,63 @@ func NewPaginatedFormatter(w io.Writer, format Format) *PaginatedFormatter {
// FormatPage formats one page of items.
func (pf *PaginatedFormatter) FormatPage(data interface{}) {
switch pf.Format {
case FormatJSON, FormatNDJSON:
if arr, ok := data.([]interface{}); ok {
PrintNdjson(pf.W, arr)
} else {
PrintNdjson(pf.W, data)
}
case FormatTable:
pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
widths := computeColumnWidths(rows, cols)
if isFirst {
writeHeader(w, cols, widths)
}
for _, row := range rows {
writeRow(w, row, cols, widths)
}
})
case FormatCSV:
pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
writeCSVRows(w, rows, cols, isFirst)
})
err := pf.WritePage(data)
if isOutputMarshalError(err) && (pf.Format == FormatJSON || pf.Format == FormatNDJSON) {
legacyStderrf("ndjson marshal error: %v\n", err)
}
}
// WritePage formats one page of items and returns marshal or write errors.
func (pf *PaginatedFormatter) WritePage(data interface{}) error {
switch pf.Format {
case FormatJSON, FormatNDJSON:
if arr, ok := data.([]interface{}); ok {
return WriteNDJSON(pf.W, arr)
}
return WriteNDJSON(pf.W, data)
case FormatTable:
return pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) error {
widths := computeColumnWidths(rows, cols)
if isFirst {
if err := writeHeader(w, cols, widths); err != nil {
return err
}
}
for _, row := range rows {
if err := writeRow(w, row, cols, widths); err != nil {
return err
}
}
return nil
})
case FormatCSV:
return pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) error {
return writeCSVRows(w, rows, cols, isFirst)
})
}
return nil
}
// formatStructuredPage handles column-locking logic shared by table and csv.
func (pf *PaginatedFormatter) formatStructuredPage(data interface{}, emit func(io.Writer, []map[string]string, []string, bool)) {
func (pf *PaginatedFormatter) formatStructuredPage(data interface{}, emit func(io.Writer, []map[string]string, []string, bool) error) error {
rows, pageCols, isList := prepareRows(data)
if len(rows) == 0 {
if pf.isFirstPage && isList {
fmt.Fprintln(pf.W, "(empty)")
_, err := fmt.Fprintln(pf.W, "(empty)")
return err
}
return
return nil
}
if pf.isFirstPage {
// Lock columns from first page
pf.cols = pageCols
pf.isFirstPage = false
emit(pf.W, rows, pf.cols, true)
return emit(pf.W, rows, pf.cols, true)
} else {
// Reuse first page's columns — missing keys become empty, extra keys ignored
emit(pf.W, rows, pf.cols, false)
return emit(pf.W, rows, pf.cols, false)
}
}

View File

@@ -5,6 +5,7 @@ package output
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
@@ -15,12 +16,44 @@ import (
// PrintJson prints data as formatted JSON to w.
func PrintJson(w io.Writer, data interface{}) {
injectNotice(data)
if err := WriteJSON(w, data); isOutputMarshalError(err) {
legacyStderrf("json marshal error: %v\n", err)
}
}
type outputMarshalError struct {
err error
}
func (e *outputMarshalError) Error() string {
return e.err.Error()
}
func (e *outputMarshalError) Unwrap() error {
return e.err
}
func isOutputMarshalError(err error) bool {
var marshalErr *outputMarshalError
return errors.As(err, &marshalErr)
}
// legacyStderrf reports a leaf-formatter marshal/format failure on os.Stderr,
// preserving the pre-Emitter behavior for direct (unmigrated) callers of the
// Print*/FormatAs* wrappers. The Emitter never uses this — it returns typed
// errors instead. Removed once the remaining direct callers migrate.
func legacyStderrf(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, format, args...) //nolint:forbidigo // legacy leaf-formatter stderr; removed in the output-ownership follow-up
}
// WriteJSON writes data as formatted JSON to w and returns marshal or write errors.
func WriteJSON(w io.Writer, data interface{}) error {
b, err := json.MarshalIndent(data, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "json marshal error: %v\n", err)
return
return &outputMarshalError{err: err}
}
fmt.Fprintln(w, string(b))
_, err = fmt.Fprintln(w, string(b))
return err
}
// injectNotice adds a "_notice" field into CLI envelope maps.
@@ -50,21 +83,38 @@ func injectNotice(data interface{}) {
// PrintNdjson prints data as NDJSON (Newline Delimited JSON) to w.
func PrintNdjson(w io.Writer, data interface{}) {
emit := func(item interface{}) {
if arr, ok := data.([]interface{}); ok {
for _, item := range arr {
if err := WriteNDJSON(w, item); isOutputMarshalError(err) {
legacyStderrf("ndjson marshal error: %v\n", err)
}
}
return
}
if err := WriteNDJSON(w, data); isOutputMarshalError(err) {
legacyStderrf("ndjson marshal error: %v\n", err)
}
}
// WriteNDJSON writes data as NDJSON and returns marshal or write errors.
func WriteNDJSON(w io.Writer, data interface{}) error {
emit := func(item interface{}) error {
b, err := json.Marshal(item)
if err != nil {
fmt.Fprintf(os.Stderr, "ndjson marshal error: %v\n", err)
return
return &outputMarshalError{err: err}
}
fmt.Fprintln(w, string(b))
_, err = fmt.Fprintln(w, string(b))
return err
}
if arr, ok := data.([]interface{}); ok {
for _, item := range arr {
emit(item)
if err := emit(item); err != nil {
return err
}
}
} else {
emit(data)
return nil
}
return emit(data)
}
func cellStr(val interface{}) string {

View File

@@ -16,50 +16,69 @@ const maxColWidth = 100
// - map[string]interface{} (single object) → key-value two-column table
// - empty array → "(empty)"
func FormatAsTable(w io.Writer, data interface{}) {
FormatAsTablePaginated(w, data, true)
if err := WriteTable(w, data); isOutputMarshalError(err) {
legacyStderrf("json marshal error: %v\n", err)
}
}
// WriteTable formats data as a table and returns marshal or write errors.
func WriteTable(w io.Writer, data interface{}) error {
return WriteTablePaginated(w, data, true)
}
// FormatAsTablePaginated formats data as a table with pagination awareness.
// When isFirstPage is true, outputs the header; otherwise only data rows.
func FormatAsTablePaginated(w io.Writer, data interface{}, isFirstPage bool) {
if err := WriteTablePaginated(w, data, isFirstPage); isOutputMarshalError(err) {
legacyStderrf("json marshal error: %v\n", err)
}
}
// WriteTablePaginated formats data as a table and returns marshal or write errors.
func WriteTablePaginated(w io.Writer, data interface{}, isFirstPage bool) error {
rows, cols, isList := prepareRows(data)
if cols == nil {
if isList {
fmt.Fprintln(w, "(empty)")
_, err := fmt.Fprintln(w, "(empty)")
return err
} else {
// Not a list and not an object — print as JSON fallback
PrintJson(w, data)
return WriteJSON(w, data)
}
return
}
if len(rows) == 0 {
if isFirstPage {
fmt.Fprintln(w, "(empty)")
_, err := fmt.Fprintln(w, "(empty)")
return err
}
return
return nil
}
if !isList {
// Single object: key-value two-column format
formatKeyValueTable(w, rows[0], cols)
return
return formatKeyValueTable(w, rows[0], cols)
}
// Calculate column widths (clamped to maxColWidth)
widths := computeColumnWidths(rows, cols)
if isFirstPage {
writeHeader(w, cols, widths)
if err := writeHeader(w, cols, widths); err != nil {
return err
}
}
for _, row := range rows {
writeRow(w, row, cols, widths)
if err := writeRow(w, row, cols, widths); err != nil {
return err
}
}
return nil
}
// formatKeyValueTable renders a single object as a two-column key-value table.
func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) {
func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) error {
maxKeyWidth := 0
for _, col := range cols {
kw := stringWidth(col)
@@ -71,8 +90,11 @@ func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) {
for _, col := range cols {
val := row[col]
val = truncateToWidth(val, maxColWidth)
fmt.Fprintf(w, "%s %s\n", padToWidth(col, maxKeyWidth), val)
if _, err := fmt.Fprintf(w, "%s %s\n", padToWidth(col, maxKeyWidth), val); err != nil {
return err
}
}
return nil
}
// computeColumnWidths returns display widths for each column, clamped to maxColWidth.
@@ -99,25 +121,29 @@ func computeColumnWidths(rows []map[string]string, cols []string) []int {
}
// writeHeader writes the header row and separator line.
func writeHeader(w io.Writer, cols []string, widths []int) {
func writeHeader(w io.Writer, cols []string, widths []int) error {
var header []string
var sep []string
for i, col := range cols {
header = append(header, padToWidth(col, widths[i]))
sep = append(sep, strings.Repeat("─", widths[i]))
}
fmt.Fprintln(w, strings.Join(header, " "))
fmt.Fprintln(w, strings.Join(sep, " "))
if _, err := fmt.Fprintln(w, strings.Join(header, " ")); err != nil {
return err
}
_, err := fmt.Fprintln(w, strings.Join(sep, " "))
return err
}
// writeRow writes a single data row.
func writeRow(w io.Writer, row map[string]string, cols []string, widths []int) {
func writeRow(w io.Writer, row map[string]string, cols []string, widths []int) error {
var cells []string
for i, col := range cols {
val := truncateToWidth(row[col], widths[i])
cells = append(cells, padToWidth(val, widths[i]))
}
fmt.Fprintln(w, strings.Join(cells, " "))
_, err := fmt.Fprintln(w, strings.Join(cells, " "))
return err
}
// padToWidth pads a string with spaces to reach the target display width.

View File

@@ -0,0 +1,107 @@
{
"cases": {
"csv": {
"stdout": "id,name\n1,Alice\n2,Bob\n",
"stderr": ""
},
"format_raw_json_preserves_html": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n }\n}\n",
"stderr": ""
},
"jq_invalid_expression": {
"stdout": "",
"stderr": "error: invalid jq expression: unexpected EOF\n",
"error": {
"go_type": "*errs.ValidationError",
"json": {
"type": "validation",
"subtype": "invalid_argument",
"message": "invalid jq expression: unexpected EOF"
},
"message": "invalid jq expression: unexpected EOF",
"exit_code": 2
}
},
"jq_safety_alert_without_stderr_warning": {
"stdout": "1\n",
"stderr": ""
},
"jq_scalar": {
"stdout": "Alice\n",
"stderr": ""
},
"json_object": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"enabled\": true,\n \"id\": \"1\"\n }\n}\n",
"stderr": ""
},
"metadata": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": [\n {\n \"id\": \"1\"\n }\n ],\n \"meta\": {\n \"count\": 1,\n \"rollback\": \"lark-cli fixture rollback\"\n }\n}\n",
"stderr": ""
},
"ndjson": {
"stdout": "{\"id\":\"1\"}\n{\"id\":\"2\"}\n",
"stderr": ""
},
"notice": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n },\n \"_notice\": {\n \"update\": {\n \"latest\": \"9.9.9\"\n }\n }\n}\n",
"stderr": ""
},
"partial_failure_ok_false": {
"stdout": "{\n \"ok\": false,\n \"identity\": \"bot\",\n \"data\": {\n \"failed\": 1,\n \"succeeded\": 1\n }\n}\n",
"stderr": "",
"error": {
"go_type": "*output.PartialFailureError",
"json": {
"Code": 1
},
"message": "partial failure (exit 1)",
"exit_code": 1
}
},
"pretty": {
"stdout": "pretty:fixture\n",
"stderr": ""
},
"pretty_without_renderer": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"name\": \"Alice\"\n }\n}\n",
"stderr": ""
},
"raw_jq_complex": {
"stdout": "{\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n}\n",
"stderr": ""
},
"raw_json_preserves_html": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n }\n}\n",
"stderr": ""
},
"scanner_block": {
"stdout": "",
"stderr": "",
"error": {
"go_type": "*errs.ContentSafetyError",
"json": {
"type": "policy",
"subtype": "content_safety",
"message": "content safety violation detected (rules: fixture-rule)",
"rules": [
"fixture-rule"
]
},
"message": "content safety violation detected (rules: fixture-rule)",
"exit_code": 6
}
},
"scanner_error_fails_open": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n }\n}\n",
"stderr": "warning: content safety scan error: scanner unavailable\n"
},
"table_with_safety_warning": {
"stdout": "id name \n── ─────\n1 Alice\n",
"stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
},
"unknown_format_data_envelope_notice": {
"stdout": "{\n \"_notice\": {\n \"skills\": {\n \"current\": \"1.0.0\"\n }\n },\n \"ok\": true,\n \"value\": \"fixture\"\n}\n",
"stderr": "warning: unknown format \"yaml\", falling back to json\n"
}
}
}

View File

@@ -0,0 +1,41 @@
{
"cases": {
"dry_run": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"dry_run\": true,\n \"data\": {\n \"api\": []\n }\n}\n",
"stderr": ""
},
"jq": {
"stdout": "1\n",
"stderr": ""
},
"jq_safety_warning": {
"stdout": "1\n",
"stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
},
"json": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n }\n}\n",
"stderr": ""
},
"notice": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n },\n \"_notice\": {\n \"update\": {\n \"latest\": \"9.9.9\"\n }\n }\n}\n",
"stderr": ""
},
"scanner_block": {
"stdout": "",
"stderr": "",
"error": {
"go_type": "*errs.ContentSafetyError",
"json": {
"type": "policy",
"subtype": "content_safety",
"message": "content safety violation detected (rules: fixture-rule)",
"rules": [
"fixture-rule"
]
},
"message": "content safety violation detected (rules: fixture-rule)",
"exit_code": 6
}
}
}
}

View File

@@ -45,6 +45,18 @@ Adding a new row requires approval from the matching CODEOWNERS or quality gate
`legacy-commands.txt` only covers hand-authored legacy commands. Generated OpenAPI service commands are intentionally excluded from `command-manifest.json`; they are included in `command-index.json` only so command references can be checked against the real CLI surface.
## Public Domain Allowlists
`internal/qualitygate/config/allowlists/public-domains.txt` contains supported public hostnames approved for Go source. `fixture-domains.txt` contains test-only hostnames used by `*_test.go`, the repository-root `tests/` directory, or any `testdata/` directory; fixture entries do not apply to production Go files or `skills/`.
Keep one lowercase exact hostname per line, sorted alphabetically. Wildcards, suffix rules, duplicates, schemes, ports, and paths are rejected; approving `larkoffice.com` does not approve its subdomains.
RFC 2606 reserves the `.test`, `.example`, `.invalid`, and `.localhost` namespaces plus the exact names `example.com`, `example.net`, and `example.org`. These names are accepted without an allowlist entry and must not be listed.
Every public entry needs a current non-fixture Go use, evidence that it is a supported public endpoint, and CODEOWNER approval. Other test-only hostnames belong in the fixture list. Tenant-specific, private-control-plane, and internal API hostnames are not eligible.
`lint/domaincontract` validates both lists and scans complete Go files. In CI, unapproved-host findings are limited to values whose expressions intersect added lines; list validation and unused-entry checks remain repository-wide. See `lint/README.md` for scanner semantics.
## Semantic Blocker Policy
The semantic reviewer can propose findings, but the local gatekeeper recomputes whether each finding is reproducible from `facts.json`. A finding blocks only when all of these are true:

View File

@@ -0,0 +1,24 @@
# Exact test-only hostnames. Keep sorted.
abc.feishu.cn
attacker.example.com
bytedance.feishu.cn
cdn.feishu.cn
evil.example.com
example.feishu.cn
example.larkoffice.com
example.larksuite.com
feishu.cn
feishu.doubao.com
gateway.docker.internal
host.containers.internal
host.docker.internal
host.lima.internal
lf3-static.bytednsdoc.com
meetings.feishu.cn
meetings.larksuite.com
p3-lark-file.byteimg.com
passport.feishu.cn
sample.feishu.cn
x.feishu.cn
xxx.feishu.cn
xxx.larksuite.com

View File

@@ -0,0 +1,18 @@
# Exact public hostnames. Keep sorted.
accounts.feishu.cn
accounts.larksuite.com
applink.feishu.cn
applink.larksuite.com
ark.ap-southeast.bytepluses.com
github.com
larkoffice.com
lf-larkemail.bytetos.com
mcp.feishu.cn
mcp.larksuite.com
open.feishu.cn
open.larksuite.com
registry.npmjs.org
registry.npmmirror.com
sf16-sg.tiktokcdn.com
www.feishu.cn
www.larksuite.com

View File

@@ -6,10 +6,11 @@ package diff
import (
"context"
"os"
"os/exec"
"path/filepath"
"reflect"
"testing"
"github.com/larksuite/cli/internal/testutil/gitcmd"
)
func TestScopeIncludesChangedSkillAndRelatedDomain(t *testing.T) {
@@ -122,8 +123,7 @@ func writeFile(t *testing.T, repo, rel, content string) {
func runGit(t *testing.T, repo string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = repo
cmd := gitcmd.Command(repo, args...)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
}
@@ -131,8 +131,7 @@ func runGit(t *testing.T, repo string, args ...string) {
func gitOutput(t *testing.T, repo string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = repo
cmd := gitcmd.Command(repo, args...)
out, err := cmd.Output()
if err != nil {
t.Fatalf("git %v failed: %v", args, err)

View File

@@ -6,10 +6,11 @@ package publiccontent
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/testutil/gitcmd"
)
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
@@ -839,8 +840,7 @@ func runGit(t *testing.T, repo string, args ...string) {
if len(args) > 0 && args[0] == "commit" {
args = append([]string{"commit", "--no-verify"}, args[1:]...)
}
cmd := exec.Command("git", args...)
cmd.Dir = repo
cmd := gitcmd.Command(repo, args...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
@@ -849,8 +849,7 @@ func runGit(t *testing.T, repo string, args ...string) {
func runGitOutput(t *testing.T, repo string, args ...string) []byte {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = repo
cmd := gitcmd.Command(repo, args...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)

View File

@@ -16,6 +16,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/qualitygate/facts"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/report"
@@ -726,7 +727,11 @@ func appendDryRunArg(raw string) ([]string, error) {
return nil, fmt.Errorf("not a lark-cli command")
}
argv = truncateShellTail(argv)
argv = forceDryRunJSONFormat(argv)
var jqValid bool
argv, jqValid = stripDryRunJQFilter(argv)
if jqValid {
argv = forceDryRunJSONFormat(argv)
}
hasDryRunArg := false
dryRunEnabled := false
for _, arg := range argv[1:] {
@@ -775,6 +780,73 @@ func truncateShellTail(argv []string) []string {
return argv
}
// stripDryRunJQFilter removes valid output-only jq filters from the synthetic
// dry-run invocation. Invalid jq syntax and incompatible output flags are left
// untouched so the real CLI execution still rejects the documented command.
// The bool reports whether other output normalization remains safe.
func stripDryRunJQFilter(argv []string) ([]string, bool) {
jqExpr, outputPath, format, hasJQ, jqHasValue := dryRunOutputFlags(argv)
if !hasJQ {
return argv, true
}
if !jqHasValue || output.ValidateJqFlags(jqExpr, outputPath, format) != nil {
return argv, false
}
out := make([]string, 0, len(argv))
for i := 0; i < len(argv); i++ {
arg := argv[i]
switch {
case arg == "--":
return append(out, argv[i:]...), true
case arg == "--jq" || arg == "-q":
i++
case strings.HasPrefix(arg, "--jq=") || strings.HasPrefix(arg, "-q="):
continue
default:
out = append(out, arg)
}
}
return out, true
}
func dryRunOutputFlags(argv []string) (jqExpr, outputPath, format string, hasJQ, jqHasValue bool) {
for i := 1; i < len(argv); i++ {
arg := argv[i]
if arg == "--" {
break
}
switch {
case arg == "--jq" || arg == "-q":
hasJQ = true
jqHasValue = i+1 < len(argv)
if jqHasValue {
jqExpr = argv[i+1]
i++
}
case strings.HasPrefix(arg, "--jq=") || strings.HasPrefix(arg, "-q="):
hasJQ = true
jqHasValue = true
jqExpr = arg[strings.IndexByte(arg, '=')+1:]
case arg == "--output":
if i+1 < len(argv) {
outputPath = argv[i+1]
i++
}
case strings.HasPrefix(arg, "--output="):
outputPath = strings.TrimPrefix(arg, "--output=")
case arg == "--format":
if i+1 < len(argv) {
format = argv[i+1]
i++
}
case strings.HasPrefix(arg, "--format="):
format = strings.TrimPrefix(arg, "--format=")
}
}
return jqExpr, outputPath, format, hasJQ, jqHasValue
}
func dryRunFlagExplicitlyTrue(arg string) bool {
value, ok := strings.CutPrefix(arg, "--dry-run=")
if !ok {

View File

@@ -194,6 +194,38 @@ func TestRunDryRunsIgnoresTrailingShellComment(t *testing.T) {
}
}
func TestRunDryRunsIgnoresJQFilterWhenValidatingRequestPreview(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/im/v1/flags"}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "im +flag-list",
Runnable: true,
Identities: []string{"user"},
Flags: []manifest.Flag{
{Name: "as", TakesValue: true},
{Name: "page-all"},
{Name: "jq", Shorthand: "q", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: `lark-cli im +flag-list --as user --page-all -q '.data.flag_items[-1]'`,
SourceFile: "skills/lark-im/references/lark-im-flag-list.md",
Line: 26,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("jq example should remain executable: %#v", facts)
}
wantArgs := []string{"im", "+flag-list", "--as", "user", "--page-all", "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsMaterializesPlaceholdersInsideJSONFlags(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/im/v1/messages","params":{"chat_id":"oc_test123","page_token":"page_test123"}}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
@@ -795,6 +827,72 @@ func TestAppendDryRunArgForcesInlineJSONFormat(t *testing.T) {
}
}
func TestAppendDryRunArgRemovesJQFilter(t *testing.T) {
tests := []struct {
name string
raw string
want []string
}{
{
name: "short split",
raw: `lark-cli im +flag-list --page-all -q '.data.flag_items[-1]'`,
want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
},
{
name: "long split",
raw: `lark-cli im +flag-list --jq '.data.flag_items[].item_id' --page-all`,
want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
},
{
name: "short inline",
raw: `lark-cli im +flag-list -q='.data.flag_items[-1]' --page-all`,
want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
},
{
name: "long inline",
raw: `lark-cli im +flag-list --jq='.data.flag_items[-1]' --page-all`,
want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
},
{
name: "missing value remains invalid",
raw: `lark-cli im +flag-list --page-all --jq`,
want: []string{"im", "+flag-list", "--page-all", "--jq", "--dry-run"},
},
{
name: "next flag is not accepted as jq expression",
raw: `lark-cli im +flag-list --jq --page-all`,
want: []string{"im", "+flag-list", "--jq", "--page-all", "--dry-run"},
},
{
name: "invalid expression remains invalid",
raw: `lark-cli im +flag-list --jq 'invalid[' --page-all`,
want: []string{"im", "+flag-list", "--jq", "invalid[", "--page-all", "--dry-run"},
},
{
name: "incompatible pretty format remains invalid",
raw: `lark-cli im +flag-list --jq '.data' --format pretty`,
want: []string{"im", "+flag-list", "--jq", ".data", "--format", "pretty", "--dry-run"},
},
{
name: "compatible json format preserves request preview",
raw: `lark-cli im +flag-list --jq '.data' --format json`,
want: []string{"im", "+flag-list", "--format", "json", "--dry-run"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := appendDryRunArg(tt.raw)
if err != nil {
t.Fatalf("appendDryRunArg() error = %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, tt.want)
}
})
}
}
func TestAppendDryRunArgPreservesNonPrettyFormat(t *testing.T) {
for _, raw := range []string{
"lark-cli mail +watch --format data --dry-run",

View File

@@ -7,7 +7,6 @@ import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@@ -15,6 +14,7 @@ import (
qdiff "github.com/larksuite/cli/internal/qualitygate/diff"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/testutil/gitcmd"
"github.com/larksuite/cli/internal/vfs"
)
@@ -600,7 +600,8 @@ func TestNormalizeDiagnosticFileHandlesAbsoluteRepo(t *testing.T) {
func runGit(t *testing.T, repo string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-c", "core.hooksPath=/dev/null", "-C", repo}, args...)...)
commandArgs := append([]string{"-c", "core.hooksPath=/dev/null"}, args...)
cmd := gitcmd.Command(repo, commandArgs...)
cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE=2026-06-17T00:00:00Z", "GIT_COMMITTER_DATE=2026-06-17T00:00:00Z")
out, err := cmd.CombinedOutput()
if err != nil {

View File

@@ -170,11 +170,16 @@ func ListFromMetaProjects() []string {
const DefaultScopeScore = 0
var cachedScopePriorities map[string]int
var cachedAutoApproveSet map[string]bool
var cachedPlatformAutoApprove map[string]bool // from scope_priorities.json only
var cachedOverrideAutoAllow map[string]bool // from scope_overrides.json allow only
var cachedOverrideAutoDeny map[string]bool // from scope_overrides.json deny only
// scopePriorityEntry is used to parse scope_priorities.json entries.
type scopePriorityEntry struct {
ScopeName string `json:"scope_name"`
FinalScore string `json:"final_score"`
Recommend string `json:"recommend"`
}
// LoadScopePriorities loads the scope priorities map from scope_priorities.json.
@@ -221,6 +226,134 @@ func LoadScopePriorities() map[string]int {
return cachedScopePriorities
}
// LoadAutoApproveSet returns the set of auto-approve scope names.
// Sources (merged): recommend=="true" in scope_priorities.json
// + explicit allow/deny in scope_overrides.json.
func LoadAutoApproveSet() map[string]bool {
if cachedAutoApproveSet != nil {
return cachedAutoApproveSet
}
m := make(map[string]bool)
// 1. From scope_priorities.json (Recommend == "true")
if data, err := registryFS.ReadFile("scope_priorities.json"); err == nil {
var entries []scopePriorityEntry
if json.Unmarshal(data, &entries) == nil {
for _, entry := range entries {
if entry.Recommend == "true" {
m[entry.ScopeName] = true
}
}
}
}
// 2. From scope_overrides.json (recommend.allow/deny lists)
if data, err := registryFS.ReadFile("scope_overrides.json"); err == nil {
var wrapper struct {
AutoApprove struct {
Allow []string `json:"allow"`
Deny []string `json:"deny"`
} `json:"recommend"`
}
if json.Unmarshal(data, &wrapper) == nil {
for _, s := range wrapper.AutoApprove.Allow {
m[s] = true
}
for _, s := range wrapper.AutoApprove.Deny {
delete(m, s)
}
}
}
cachedAutoApproveSet = m
return cachedAutoApproveSet
}
// LoadPlatformAutoApproveSet returns scopes with AutoApprove rule on the platform
// (from scope_priorities.json only, before overrides).
func LoadPlatformAutoApproveSet() map[string]bool {
if cachedPlatformAutoApprove != nil {
return cachedPlatformAutoApprove
}
m := make(map[string]bool)
if data, err := registryFS.ReadFile("scope_priorities.json"); err == nil {
var entries []scopePriorityEntry
if json.Unmarshal(data, &entries) == nil {
for _, entry := range entries {
if entry.Recommend == "true" {
m[entry.ScopeName] = true
}
}
}
}
cachedPlatformAutoApprove = m
return cachedPlatformAutoApprove
}
// LoadOverrideAutoApproveAllow returns scopes explicitly listed in
// scope_overrides.json recommend.allow (our desired additions).
func LoadOverrideAutoApproveAllow() map[string]bool {
if cachedOverrideAutoAllow != nil {
return cachedOverrideAutoAllow
}
m := make(map[string]bool)
if data, err := registryFS.ReadFile("scope_overrides.json"); err == nil {
var wrapper struct {
AutoApprove struct {
Allow []string `json:"allow"`
} `json:"recommend"`
}
if json.Unmarshal(data, &wrapper) == nil {
for _, s := range wrapper.AutoApprove.Allow {
m[s] = true
}
}
}
cachedOverrideAutoAllow = m
return cachedOverrideAutoAllow
}
// LoadOverrideAutoApproveDeny returns scopes explicitly listed in
// scope_overrides.json recommend.deny
func LoadOverrideAutoApproveDeny() map[string]bool {
if cachedOverrideAutoDeny != nil {
return cachedOverrideAutoDeny
}
m := make(map[string]bool)
if data, err := registryFS.ReadFile("scope_overrides.json"); err == nil {
var wrapper struct {
AutoApprove struct {
Deny []string `json:"deny"`
} `json:"recommend"`
}
if json.Unmarshal(data, &wrapper) == nil {
for _, s := range wrapper.AutoApprove.Deny {
m[s] = true
}
}
}
cachedOverrideAutoDeny = m
return cachedOverrideAutoDeny
}
// IsAutoApproveScope returns true if the scope has AutoApprove rule.
func IsAutoApproveScope(scope string) bool {
return LoadAutoApproveSet()[scope]
}
// FilterAutoApproveScopes filters a scope list to only include auto-approve scopes.
func FilterAutoApproveScopes(scopes []string) []string {
autoApprove := LoadAutoApproveSet()
var result []string
for _, s := range scopes {
if autoApprove[s] {
result = append(result, s)
}
}
return result
}
// GetScopeScore returns the priority score for a scope, or DefaultScopeScore if not found.
func GetScopeScore(scope string) int {
priorities := LoadScopePriorities()

View File

@@ -101,6 +101,7 @@ func TestSelectRecommendedScope_Empty(t *testing.T) {
}
func TestComputeMinimumScopeSet(t *testing.T) {
ensureFreshRegistry(t)
minSet := ComputeMinimumScopeSet("user")
if len(minSet) == 0 {
if len(ListFromMetaProjects()) == 0 {
@@ -219,6 +220,107 @@ func TestFilterScopes_TooFewParts(t *testing.T) {
}
}
// --- Auto-approve functions ---
func TestLoadAutoApproveSet(t *testing.T) {
aaSet := LoadAutoApproveSet()
if len(aaSet) == 0 {
t.Fatal("expected non-empty auto-approve set")
}
// From scope_priorities.json recommend=="true"
if !aaSet["sheets:spreadsheet:read"] {
t.Error("expected sheets:spreadsheet:read in auto-approve set (recommend=true in priorities)")
}
t.Logf("Auto-approve set has %d scopes", len(aaSet))
}
func TestLoadPlatformAutoApproveSet(t *testing.T) {
paaSet := LoadPlatformAutoApproveSet()
// This should only include scopes from scope_priorities.json with AutoApprove rule.
// It does NOT apply deny overrides.
if len(paaSet) == 0 {
t.Fatal("expected non-empty platform auto-approve set")
}
t.Logf("Platform auto-approve set has %d scopes", len(paaSet))
}
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
allowSet := LoadOverrideAutoApproveAllow()
// recommend.allow special-cases scopes absent from scope_priorities.json
// (application v7 is not in the platform catalog yet) so interactive
// login's "common scopes" tier still offers them. Only the read scope is
// admitted: write stays out of the recommended tier by design.
if !allowSet["application:app_slash_command:read"] {
t.Error("expected application:app_slash_command:read in override allow set")
}
if allowSet["application:app_slash_command:write"] {
t.Error("write scope must NOT be in the recommended tier")
}
if len(allowSet) != 1 {
t.Errorf("expected exactly 1 override allow entry, got %d", len(allowSet))
}
}
func TestLoadOverrideAutoApproveDeny(t *testing.T) {
denySet := LoadOverrideAutoApproveDeny()
// deny list may be empty if all entries are moved to _deny (commented out)
t.Logf("Override deny set has %d scopes", len(denySet))
}
func TestIsAutoApproveScope(t *testing.T) {
// Known auto-approve scope (recommend=true in scope_priorities.json)
if !IsAutoApproveScope("sheets:spreadsheet:read") {
t.Error("expected sheets:spreadsheet:read to be auto-approve")
}
// Completely unknown scope
if IsAutoApproveScope("zzz:unknown:scope") {
t.Error("expected unknown scope to NOT be auto-approve")
}
}
func TestFilterAutoApproveScopes(t *testing.T) {
scopes := []string{
"sheets:spreadsheet:read", // auto-approve (recommend=true in priorities)
"zzz:unknown:scope", // not in auto-approve
}
result := FilterAutoApproveScopes(scopes)
if len(result) < 1 {
t.Fatal("expected at least 1 auto-approve scope in result")
}
// Check that sheets:spreadsheet:read is included
found := false
for _, s := range result {
if s == "sheets:spreadsheet:read" {
found = true
}
// Ensure unknown scopes are not included
if s == "zzz:unknown:scope" {
t.Error("unknown scope should not be in auto-approve result")
}
}
if !found {
t.Error("expected sheets:spreadsheet:read in result")
}
}
func TestFilterAutoApproveScopes_Empty(t *testing.T) {
result := FilterAutoApproveScopes(nil)
if result != nil {
t.Errorf("expected nil, got %v", result)
}
result = FilterAutoApproveScopes([]string{})
if result != nil {
t.Errorf("expected nil for empty input, got %v", result)
}
}
// --- Helper functions ---
func TestGetRegistryDir(t *testing.T) {

View File

@@ -0,0 +1,72 @@
{
"version": "0.0.1",
"services": [
{
"name": "calendar",
"version": "v4",
"title": "Calendar API",
"servicePath": "/open-apis/calendar/v4",
"resources": {
"events": {
"methods": {
"create": {
"path": "calendars/{calendar_id}/events",
"httpMethod": "POST",
"risk": "write",
"scopes": [
"calendar:calendar.event:create"
],
"parameters": {
"calendar_id": {
"type": "string",
"location": "path",
"required": true
}
}
}
}
}
}
},
{
"name": "im",
"version": "v1",
"title": "IM API",
"servicePath": "/open-apis/im/v1",
"resources": {
"chat.members": {
"methods": {
"create": {
"path": "chats/{chat_id}/members",
"httpMethod": "POST",
"risk": "write",
"scopes": [
"im:chat",
"im:chat.members:write_only"
],
"parameters": {
"chat_id": {
"type": "string",
"location": "path",
"required": true
},
"member_id_type": {
"type": "string",
"location": "query",
"required": false
}
}
}
}
}
}
},
{
"name": "task",
"version": "v2",
"title": "Task API",
"servicePath": "/open-apis/task/v2",
"resources": {}
}
]
}

View File

@@ -0,0 +1,146 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package registrytest seeds the registry with a tracked metadata fixture so
// command-tree tests pass on a clean checkout — no `make fetch_meta`, no
// network, no user cache. TestMain funcs of packages that build service
// commands call Seed after redirecting LARKSUITE_CLI_CONFIG_DIR.
package registrytest
import (
_ "embed"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/vfs"
)
// fixtureMetaJSON is a trimmed snapshot of the generated meta_data.json
// holding only the calendar, im and task services that registry-backed tests
// assert against. Its version is pinned to "0.0.1": newer than the empty
// embedded stub ("0.0.0") so it wins on a clean checkout, older than any real
// generated catalog ("1.0.0"+) so a `make fetch_meta` build keeps testing the
// full embedded data.
//
//go:embed fixture_meta.json
var fixtureMetaJSON []byte
// Seed writes fixtureMetaJSON into the registry remote-meta cache under
// LARKSUITE_CLI_CONFIG_DIR and eagerly initializes the registry. testRoot must
// be the temporary root created by the caller's TestMain; Seed rejects a config
// directory outside it before performing any write. The cache
// meta is stamped fresh so Init never sync-fetches or background-refreshes
// over the network. Eager Init pins the catalog for the whole test process before
// any individual test can re-point LARKSUITE_CLI_CONFIG_DIR elsewhere.
//
// The caller's TestMain must set LARKSUITE_CLI_CONFIG_DIR beneath testRoot
// first; Seed refuses unset, mismatched, or escaping paths so it can never
// write into a developer's real ~/.lark-cli.
func Seed(testRoot string) error {
configDir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR")
if err := validateConfigDir(testRoot, configDir); err != nil {
return err
}
var fixture struct {
Version string `json:"version"`
}
if err := json.Unmarshal(fixtureMetaJSON, &fixture); err != nil {
return err
}
cacheDir := filepath.Join(configDir, "cache")
if err := vfs.MkdirAll(cacheDir, 0o700); err != nil {
return err
}
if err := vfs.WriteFile(filepath.Join(cacheDir, "remote_meta.json"), fixtureMetaJSON, 0o644); err != nil {
return err
}
cacheMeta, err := json.Marshal(registry.CacheMeta{
LastCheckAt: time.Now().Unix(),
Version: fixture.Version,
Brand: string(core.BrandFeishu),
})
if err != nil {
return err
}
if err := vfs.WriteFile(filepath.Join(cacheDir, "remote_meta.meta.json"), cacheMeta, 0o644); err != nil {
return err
}
// Neutralize ambient knobs that would defeat the seeding: an inherited
// LARKSUITE_CLI_REMOTE_META=off would stop Init from reading the seeded
// cache at all, and LARKSUITE_CLI_META_TTL=0 would expire the freshness
// stamp and start a background network refresh from inside unit tests.
if err := os.Unsetenv("LARKSUITE_CLI_REMOTE_META"); err != nil {
return err
}
if err := os.Unsetenv("LARKSUITE_CLI_META_TTL"); err != nil {
return err
}
registry.Init()
// Init is a sync.Once, so the seed is pinned for the whole test process.
// Turning remote metadata off afterwards cannot un-seed anything; it is a
// guard for any future post-Init code path that might consult the remote
// cache again after a test re-points LARKSUITE_CLI_CONFIG_DIR elsewhere.
if err := os.Setenv("LARKSUITE_CLI_REMOTE_META", "off"); err != nil {
return err
}
// Self-check: both the fixture and any real generated catalog contain the
// im service. If it is missing, the cache seeding silently stopped working
// (e.g. the registry cache file names or freshness semantics changed) and
// every registry-backed test would fail confusingly — fail loudly here
// instead, pointing at this package.
merged, ok := registry.ServiceTyped("im")
if !ok {
return errors.New("registrytest.Seed: registry has no im service after seeding — " +
"the remote-cache format in internal/registry/remote.go may have changed; update registrytest to match")
}
// Self-check: on a fetch_meta build the real embedded catalog must win over
// the 0.0.1 fixture. If the merged im service diverges from the embedded
// one, the version arbitration flipped (e.g. the generated catalog version
// stopped parsing as semver) and unit tests would silently run against the
// stale trimmed fixture instead of the fresh catalog.
for _, service := range registry.EmbeddedServicesTyped() {
if service.Name != "im" {
continue
}
if service.Version != merged.Version {
return errors.New("registrytest.Seed: the fixture shadowed the real embedded catalog — " +
"check the meta_data.json version against the fixture's \"0.0.1\" arbitration in this package")
}
break
}
return nil
}
// validateConfigDir guards the one real hazard: a TestMain wiring mistake
// pointing LARKSUITE_CLI_CONFIG_DIR at a developer's real directory. Both
// paths come from the caller's own MkdirTemp, so a plain containment check
// is enough.
func validateConfigDir(testRoot, configDir string) error {
if testRoot == "" || configDir == "" {
return errors.New("registrytest.Seed: test root and config dir must be set")
}
if !filepath.IsAbs(testRoot) || !filepath.IsAbs(configDir) {
return errors.New("registrytest.Seed: test root and config dir must be absolute")
}
rel, err := filepath.Rel(filepath.Clean(testRoot), filepath.Clean(configDir))
if err != nil {
return err
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return errors.New("registrytest.Seed: config dir must stay inside the test root")
}
return nil
}

View File

@@ -0,0 +1,229 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registrytest
import (
"net/http"
"os"
"path/filepath"
"slices"
"sort"
"testing"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/registry"
)
func TestValidateConfigDir(t *testing.T) {
root := t.TempDir()
tests := []struct {
name string
testRoot string
configDir string
wantErr bool
}{
{name: "equal", testRoot: root, configDir: root},
{name: "child", testRoot: root, configDir: filepath.Join(root, "config")},
{
name: "sibling",
testRoot: root,
configDir: filepath.Join(filepath.Dir(root), "outside"),
wantErr: true,
},
{name: "empty root", configDir: root, wantErr: true},
{name: "empty config", testRoot: root, wantErr: true},
{name: "relative root", testRoot: "relative", configDir: root, wantErr: true},
{name: "relative config", testRoot: root, configDir: "relative", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateConfigDir(tt.testRoot, tt.configDir)
if (err != nil) != tt.wantErr {
t.Fatalf("validateConfigDir() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestFixtureContract(t *testing.T) {
if len(fixtureMetaJSON) > 20<<10 {
t.Fatalf("fixture size = %d, want <= %d", len(fixtureMetaJSON), 20<<10)
}
reg, err := meta.Parse(fixtureMetaJSON)
if err != nil {
t.Fatalf("meta.Parse() error = %v", err)
}
if reg.Version != "0.0.1" {
t.Fatalf("fixture version = %q, want 0.0.1", reg.Version)
}
gotNames := make([]string, 0, len(reg.Services))
for _, service := range reg.Services {
gotNames = append(gotNames, service.Name)
}
sort.Strings(gotNames)
if !slices.Equal(gotNames, []string{"calendar", "im", "task"}) {
t.Fatalf("fixture services = %v, want [calendar im task]", gotNames)
}
calendarCreate := fixtureMethod(t, reg, "calendar", "events", "create")
assertMethodContract(t, calendarCreate, "calendars/{calendar_id}/events", http.MethodPost)
calendarID, ok := calendarCreate.Parameters["calendar_id"]
if !ok || calendarID.Location != "path" || !calendarID.Required {
t.Fatalf("calendar_id = %+v, want required path parameter", calendarID)
}
if !slices.Contains(calendarCreate.Scopes, "calendar:calendar.event:create") {
t.Fatalf("calendar create scopes = %v, want calendar:calendar.event:create", calendarCreate.Scopes)
}
imCreate := fixtureMethod(t, reg, "im", "chat.members", "create")
assertMethodContract(t, imCreate, "chats/{chat_id}/members", http.MethodPost)
chatID, ok := imCreate.Parameters["chat_id"]
if !ok || chatID.Location != "path" || !chatID.Required {
t.Fatalf("chat_id = %+v, want required path parameter", chatID)
}
memberIDType, ok := imCreate.Parameters["member_id_type"]
if !ok || memberIDType.Location != "query" || memberIDType.Required {
t.Fatalf("member_id_type = %+v, want optional query parameter", memberIDType)
}
if imCreate.Risk != "write" {
t.Fatalf("im create risk = %q, want write", imCreate.Risk)
}
for _, scope := range []string{"im:chat", "im:chat.members:write_only"} {
if !slices.Contains(imCreate.Scopes, scope) {
t.Fatalf("im create scopes = %v, want %s", imCreate.Scopes, scope)
}
}
}
func fixtureMethod(t *testing.T, reg meta.Registry, serviceName, resourceName, methodName string) meta.Method {
t.Helper()
for _, service := range reg.Services {
if service.Name != serviceName {
continue
}
resource, ok := service.Resource(resourceName)
if !ok {
t.Fatalf("fixture service %s has no resource %s", serviceName, resourceName)
}
method, ok := resource.Method(methodName)
if !ok {
t.Fatalf("fixture resource %s.%s has no method %s", serviceName, resourceName, methodName)
}
return method
}
t.Fatalf("fixture has no service %s", serviceName)
return meta.Method{}
}
func assertMethodContract(t *testing.T, method meta.Method, path, httpMethod string) {
t.Helper()
if method.Path != path || method.HTTPMethod != httpMethod {
t.Fatalf("method = %s %s, want %s %s", method.HTTPMethod, method.Path, httpMethod, path)
}
}
// TestSeedRejectsUnsafeConfigDir pins Seed's guard: it must return before
// writing anything when LARKSUITE_CLI_CONFIG_DIR is unset or escapes the
// caller's test root, so a TestMain wiring mistake can never touch a
// developer's real ~/.lark-cli.
func TestSeedRejectsUnsafeConfigDir(t *testing.T) {
root := t.TempDir()
t.Run("unset config dir", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", "")
if err := Seed(root); err == nil {
t.Fatal("Seed() error = nil, want unset config dir rejection")
}
})
t.Run("config dir outside test root", func(t *testing.T) {
outside := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", outside)
if err := Seed(root); err == nil {
t.Fatal("Seed() error = nil, want containment rejection")
}
if _, err := os.Stat(filepath.Join(outside, "cache")); err == nil {
t.Fatal("Seed wrote into the rejected config dir")
}
})
}
// TestSeedWritesFixtureAndInitializesRegistry covers the seeding happy path:
// cache files land under the config dir, the registry initializes from them,
// and both self-checks pass.
func TestSeedWritesFixtureAndInitializesRegistry(t *testing.T) {
root := t.TempDir()
configDir := filepath.Join(root, "config")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
if err := Seed(root); err != nil {
t.Fatalf("Seed() error = %v, want nil", err)
}
for _, name := range []string{"remote_meta.json", "remote_meta.meta.json"} {
if _, err := os.Stat(filepath.Join(configDir, "cache", name)); err != nil {
t.Errorf("cache file %s: %v", name, err)
}
}
if got := os.Getenv("LARKSUITE_CLI_REMOTE_META"); got != "off" {
t.Errorf("LARKSUITE_CLI_REMOTE_META = %q, want off after seeding", got)
}
for _, service := range []string{"calendar", "im", "task"} {
if _, ok := registry.ServiceTyped(service); !ok {
t.Errorf("registry missing service %s after seeding", service)
}
}
}
// TestSeedPropagatesCacheSetupFailures pins that filesystem failures while
// materializing the cache surface as errors instead of leaving the registry
// silently unseeded. Each obstacle is a same-named file/directory in the
// way, which fails on every platform without permission tricks.
func TestSeedPropagatesCacheSetupFailures(t *testing.T) {
seedWith := func(t *testing.T, prepare func(root, configDir string)) error {
t.Helper()
root := t.TempDir()
configDir := filepath.Join(root, "config")
prepare(root, configDir)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
return Seed(root)
}
t.Run("cache dir creation fails", func(t *testing.T) {
err := seedWith(t, func(root, configDir string) {
// config is a regular file, so MkdirAll(config/cache) fails.
if err := os.WriteFile(configDir, nil, 0o600); err != nil {
t.Fatal(err)
}
})
if err == nil {
t.Fatal("Seed() error = nil, want cache dir creation failure")
}
})
t.Run("fixture write fails", func(t *testing.T) {
err := seedWith(t, func(root, configDir string) {
// remote_meta.json is a directory, so WriteFile fails.
if err := os.MkdirAll(filepath.Join(configDir, "cache", "remote_meta.json"), 0o700); err != nil {
t.Fatal(err)
}
})
if err == nil {
t.Fatal("Seed() error = nil, want fixture write failure")
}
})
t.Run("cache meta write fails", func(t *testing.T) {
err := seedWith(t, func(root, configDir string) {
// remote_meta.meta.json is a directory, so WriteFile fails.
if err := os.MkdirAll(filepath.Join(configDir, "cache", "remote_meta.meta.json"), 0o700); err != nil {
t.Fatal(err)
}
})
if err == nil {
t.Fatal("Seed() error = nil, want cache meta write failure")
}
})
}

View File

@@ -39,6 +39,10 @@ func resetInit() {
embeddedVersion = ""
cachedAllScopes = nil
cachedScopePriorities = nil
cachedAutoApproveSet = nil
cachedPlatformAutoApprove = nil
cachedOverrideAutoAllow = nil
cachedOverrideAutoDeny = nil
refreshOnce = sync.Once{}
configuredBrand = ""
enableRemoteMeta = true // tests exercise remote logic

View File

@@ -58,6 +58,26 @@ func GetServiceDescription(name, lang string) string {
return loc.Description
}
// GetServiceTitle returns the localized title for a service domain.
// Returns empty string if not found.
func GetServiceTitle(name, lang string) string {
loc := getServiceLocale(name, lang)
if loc == nil {
return ""
}
return loc.Title
}
// GetServiceDetailDescription returns the localized detail description for a service domain.
// Returns empty string if not found.
func GetServiceDetailDescription(name, lang string) string {
loc := getServiceLocale(name, lang)
if loc == nil {
return ""
}
return loc.Description
}
// GetAuthDomain returns the auth_domain for a service, or "" if not set.
// When auth_domain is set, the service's scopes are collected under the
// parent domain during auth login.

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-registry-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()
// A test that ran Init without a trailing resetInit can leave a background
// refresh goroutine alive; removing the temp root while it writes would
// let it recreate the directory after cleanup. Wait it out first.
waitBackgroundRefresh()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -0,0 +1,142 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package deviceinfo collects the platform hardware product model and the
// platform values used by device-related risk-control headers.
package riskcontrol
import (
"runtime"
"strings"
"sync"
"unicode"
"unicode/utf8"
"golang.org/x/net/http/httpguts"
)
// OSType is the server-side risk-control operating-system enum.
type OSType string
// OS type enum values for X-Agent-Os-Type.
const (
OSTypeUnknown = "0"
OSTypeWindows = "1"
OSTypeLinux = "2"
OSTypeMacOS = "3"
)
const (
// TerminalTypePC is the fixed X-Agent-Terminal-Type value for the CLI.
TerminalTypePC = "1"
// Unknown is used when the hardware product model cannot be collected.
Unknown = "Unknown"
// deviceModelMaxBytes bounds the value added to X-Agent-Device-Type.
// Device models are short identifiers; a larger value is treated as
// malformed rather than truncated so the header never misrepresents it.
deviceModelMaxBytes = 256
)
// Snapshot contains the deliberately small risk-control signal set.
// ProductModel is omitted when the platform cannot provide a safe value.
type Snapshot struct {
OSType OSType
ProductModel string
}
// Source supplies one immutable process-level snapshot.
type Source interface {
Snapshot() Snapshot
}
// HostSource lazily reads host signals once, after outbound policy authorizes
// the first request. Failed probes are cached and are not retried per request.
type HostSource struct {
once sync.Once
value Snapshot
readModel func() string
}
// NewHostSource creates the production host signal source.
func NewHostSource() *HostSource {
return &HostSource{readModel: readDeviceModel}
}
// Snapshot returns the cached host signal snapshot.
func (s *HostSource) Snapshot() Snapshot {
if s == nil {
return Snapshot{}
}
s.once.Do(func() {
readModel := s.readModel
if readModel == nil {
readModel = readDeviceModel
}
s.value = Snapshot{
OSType: GetOSType(OSName()),
ProductModel: normalizeDeviceModel(readModel()),
}
})
return s.value
}
// normalizeModel removes non-printable characters and returns a model only
// when the remaining text is safe to use as an HTTP header value. Input that
// cannot produce a valid model is rejected so Get can fall back to Unknown.
func normalizeDeviceModel(model string) string {
if !utf8.ValidString(model) {
return ""
}
model = strings.Map(func(r rune) rune {
switch {
case r == '\r' || r == '\n' || r == '\x00':
return -1
case unicode.IsSpace(r):
return ' '
case unicode.IsPrint(r):
return r
default:
return -1
}
}, model)
model = strings.Join(strings.Fields(model), " ")
if model == "" || len(model) > deviceModelMaxBytes {
return ""
}
if !httpguts.ValidHeaderFieldValue(model) {
return ""
}
return model
}
// GetOSType maps a platform name to the X-Agent-Os-Type enum.
func GetOSType(osName string) OSType {
switch osName {
case "Windows":
return OSTypeWindows
case "Linux":
return OSTypeLinux
case "MacOS":
return OSTypeMacOS
default:
return OSTypeUnknown
}
}
// OSName returns the platform name used by GetOSType.
func OSName() string {
switch runtime.GOOS {
case "darwin":
return "MacOS"
case "windows":
return "Windows"
case "linux":
return "Linux"
default:
return runtime.GOOS
}
}

View File

@@ -0,0 +1,27 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "golang.org/x/sys/unix"
// readDeviceModel reads the current product key first and falls back to the
// legacy model key. Trying both keys is more robust than branching on a macOS
// version because virtualized or restricted environments may expose only one.
func readDeviceModel() string {
return readDarwinDeviceModel(unix.Sysctl)
}
func readDarwinDeviceModel(readSysctl func(string) (string, error)) string {
for _, key := range [...]string{"hw.product", "hw.model"} {
model, err := readSysctl(key)
if err == nil {
if model = normalizeDeviceModel(model); model != "" {
return model
}
}
}
return ""
}

View File

@@ -0,0 +1,48 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"errors"
"reflect"
"testing"
)
func TestReadDarwinDeviceModelPrefersProductAndFallsBackToModel(t *testing.T) {
t.Run("product available", func(t *testing.T) {
var keys []string
got := readDarwinDeviceModel(func(key string) (string, error) {
keys = append(keys, key)
if key == "hw.product" {
return "Mac16,1", nil
}
return "", errors.New("unexpected fallback")
})
if got != "Mac16,1" {
t.Fatalf("model = %q, want %q", got, "Mac16,1")
}
if want := []string{"hw.product"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("sysctl keys = %v, want %v", keys, want)
}
})
t.Run("product unavailable", func(t *testing.T) {
var keys []string
got := readDarwinDeviceModel(func(key string) (string, error) {
keys = append(keys, key)
if key == "hw.model" {
return "MacBookPro18,3", nil
}
return "", errors.New("not available")
})
if got != "MacBookPro18,3" {
t.Fatalf("model = %q, want %q", got, "MacBookPro18,3")
}
if want := []string{"hw.product", "hw.model"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("sysctl keys = %v, want %v", keys, want)
}
})
}

View File

@@ -0,0 +1,17 @@
//go:build linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
// readDeviceModel returns a stable device model for Linux. DMI and device-tree
// values vary widely and can expose the host or virtualization platform when
// the CLI runs in a container or sandbox.
func readDeviceModel() string {
return readLinuxDeviceModel()
}
func readLinuxDeviceModel() string {
return "linux"
}

View File

@@ -0,0 +1,20 @@
//go:build linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "testing"
func TestReadDeviceModelReturnsLinux(t *testing.T) {
if got := readDeviceModel(); got != "linux" {
t.Fatalf("readDeviceModel() = %q, want %q", got, "linux")
}
}
func TestReadLinuxDeviceModel(t *testing.T) {
if got := readLinuxDeviceModel(); got != "linux" {
t.Fatalf("readLinuxDeviceModel() = %q, want %q", got, "linux")
}
}

View File

@@ -0,0 +1,11 @@
//go:build !darwin && !windows && !linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
// readDeviceModel returns an empty model on unsupported platforms.
func readDeviceModel() string {
return ""
}

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"unicode"
)
func TestHostSourceCachesNonEmptyModel(t *testing.T) {
calls := 0
s := &HostSource{readModel: func() string {
calls++
return " MacBookPro18,3\n"
}}
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
t.Fatalf("first Snapshot().ProductModel = %q, want %q", got.ProductModel, "MacBookPro18,3")
}
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
t.Fatalf("second Snapshot().ProductModel = %q, want cached model", got.ProductModel)
}
if calls != 1 {
t.Fatalf("read called %d times, want 1", calls)
}
}
func TestHostSourceCachesEmptyModel(t *testing.T) {
calls := 0
s := &HostSource{readModel: func() string {
calls++
return ""
}}
if got := s.Snapshot(); got.ProductModel != "" {
t.Fatalf("first Snapshot().ProductModel = %q, want empty", got.ProductModel)
}
if got := s.Snapshot(); got.ProductModel != "" {
t.Fatalf("second Snapshot().ProductModel = %q, want cached empty result", got.ProductModel)
}
if calls != 1 {
t.Fatalf("read called %d times, want 1", calls)
}
}
func TestHostSourceReadsOnceAcrossConcurrentCalls(t *testing.T) {
var calls atomic.Int32
s := &HostSource{readModel: func() string {
calls.Add(1)
return "ThinkPad X1 Carbon"
}}
const goroutines = 32
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
snapshot := s.Snapshot()
if snapshot.ProductModel != "ThinkPad X1 Carbon" {
t.Errorf("Snapshot().ProductModel = %q, want %q", snapshot.ProductModel, "ThinkPad X1 Carbon")
}
}()
}
wg.Wait()
if got := calls.Load(); got != 1 {
t.Fatalf("read called %d times, want 1", got)
}
}
func TestNormalizeDeviceModel(t *testing.T) {
tests := []struct {
name string
model string
want string
}{
{name: "trims surrounding whitespace", model: " MacBookPro18,3\n", want: "MacBookPro18,3"},
{name: "trims device tree terminator", model: "Raspberry Pi 5\x00", want: "Raspberry Pi 5"},
{name: "allows printable Unicode", model: "联想 ThinkPad X1", want: "联想 ThinkPad X1"},
{name: "rejects empty", model: " \t\r\n"},
{name: "rejects invalid UTF-8", model: string([]byte{'M', 0xff, '1'})},
{name: "removes CRLF", model: "model\r\nname", want: "modelname"},
{name: "normalizes tab", model: "model\tname", want: "model name"},
{name: "removes NUL", model: "model\x00name", want: "modelname"},
{name: "removes control character", model: "model\x1fname", want: "modelname"},
{name: "removes DEL", model: "model\x7fname", want: "modelname"},
{name: "normalizes Unicode line separator", model: "model\u2028name", want: "model name"},
{name: "collapses whitespace", model: " model\t \u00a0 name ", want: "model name"},
{name: "accepts maximum byte length", model: strings.Repeat("a", deviceModelMaxBytes), want: strings.Repeat("a", deviceModelMaxBytes)},
{name: "rejects overlong value", model: strings.Repeat("a", deviceModelMaxBytes+1)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeDeviceModel(tt.model); got != tt.want {
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", tt.model, got, tt.want)
}
})
}
}
func TestNormalizeDeviceModelRemovesHTTPControlBytes(t *testing.T) {
for value := 0; value <= 0x7f; value++ {
if value >= 0x20 && value < 0x7f {
continue
}
t.Run(fmt.Sprintf("0x%02x", value), func(t *testing.T) {
model := "model" + string(rune(value)) + "name"
want := "modelname"
if value != '\r' && value != '\n' && value != '\x00' && unicode.IsSpace(rune(value)) {
want = "model name"
}
if got := normalizeDeviceModel(model); got != want {
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", model, got, want)
}
})
}
}
func TestGetOSType(t *testing.T) {
tests := []struct {
name string
want OSType
}{
{name: "Windows", want: OSTypeWindows},
{name: "Linux", want: OSTypeLinux},
{name: "MacOS", want: OSTypeMacOS},
{name: "unknown", want: OSTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetOSType(tt.name); got != tt.want {
t.Errorf("GetOSType(%q) = %q, want %q", tt.name, got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,44 @@
//go:build windows
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "golang.org/x/sys/windows/registry"
// systemInfoRegistryPaths lists registry locations in device-model lookup order.
var systemInfoRegistryPaths = [...]string{
`HARDWARE\DESCRIPTION\System\BIOS`,
`SYSTEM\CurrentControlSet\Control\SystemInformation`,
`SYSTEM\HardwareConfig\Current`,
}
// readDeviceModel returns the first product name found in the Windows registry.
func readDeviceModel() string {
return readWindowsDeviceModel(readWindowsRegistryModel)
}
func readWindowsRegistryModel(path string) (string, error) {
key, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.READ)
if err != nil {
return "", err
}
defer key.Close()
model, _, err := key.GetStringValue("SystemProductName")
return model, err
}
func readWindowsDeviceModel(readRegistryModel func(string) (string, error)) string {
for _, path := range systemInfoRegistryPaths {
model, err := readRegistryModel(path)
if err != nil {
continue
}
if model = normalizeDeviceModel(model); model != "" {
return model
}
}
return ""
}

View File

@@ -0,0 +1,78 @@
//go:build windows
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"errors"
"reflect"
"testing"
)
func TestReadWindowsDeviceModelFallback(t *testing.T) {
readError := errors.New("registry read failed")
tests := []struct {
name string
values map[string]string
errors map[string]error
want string
wantPaths []string
}{
{
name: "first path wins",
values: map[string]string{systemInfoRegistryPaths[0]: "Surface Laptop"},
want: "Surface Laptop",
wantPaths: []string{systemInfoRegistryPaths[0]},
},
{
name: "read failure falls back",
errors: map[string]error{
systemInfoRegistryPaths[0]: readError,
},
values: map[string]string{
systemInfoRegistryPaths[1]: "ThinkPad X1 Carbon",
},
want: "ThinkPad X1 Carbon",
wantPaths: systemInfoRegistryPaths[:2],
},
{
name: "empty normalized value falls back",
values: map[string]string{
systemInfoRegistryPaths[0]: " \r\n\x00",
systemInfoRegistryPaths[1]: "Latitude 7450",
},
want: "Latitude 7450",
wantPaths: systemInfoRegistryPaths[:2],
},
{
name: "all paths fail",
errors: map[string]error{
systemInfoRegistryPaths[0]: readError,
systemInfoRegistryPaths[1]: readError,
systemInfoRegistryPaths[2]: readError,
},
wantPaths: systemInfoRegistryPaths[:],
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var paths []string
got := readWindowsDeviceModel(func(path string) (string, error) {
paths = append(paths, path)
if err := tt.errors[path]; err != nil {
return "", err
}
return tt.values[path], nil
})
if got != tt.want {
t.Fatalf("model = %q, want %q", got, tt.want)
}
if !reflect.DeepEqual(paths, tt.wantPaths) {
t.Fatalf("registry paths = %v, want %v", paths, tt.wantPaths)
}
})
}
}

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/internal/core"
internaltransport "github.com/larksuite/cli/internal/transport"
)
const (
HeaderProductModel = "X-Agent-Device-Type"
HeaderOSType = "X-Agent-Os-Type"
)
var restrictedHeaders = [...]string{HeaderProductModel, HeaderOSType}
// Transport is the feature's final outbound boundary. It removes caller- or
// extension-supplied signal headers first and writes trusted values only after
// authorizing an official SDK origin and authentication state.
type Transport struct {
next http.RoundTripper
source Source
}
// NewTransport creates the final SDK outbound policy boundary. A nil source
// disables collection and injection while preserving restricted-header
// stripping for opt-out and extension-credential requests.
func NewTransport(next http.RoundTripper, source Source) *Transport {
if next == nil {
next = internaltransport.Fallback()
}
return &Transport{
next: next,
source: source,
}
}
// RoundTrip implements http.RoundTripper.
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
if req.Header == nil {
req.Header = make(http.Header)
}
stripRestrictedHeaders(req.Header)
if t.source != nil && t.routeAllowsSignals(req) {
snapshot := t.source.Snapshot()
if isSupportedOSType(snapshot.OSType) {
req.Header.Set(HeaderOSType, string(snapshot.OSType))
}
if model := normalizeDeviceModel(snapshot.ProductModel); model != "" {
req.Header.Set(HeaderProductModel, model)
}
}
return t.next.RoundTrip(req)
}
func isSupportedOSType(value OSType) bool {
switch value {
case OSTypeWindows, OSTypeLinux, OSTypeMacOS:
return true
default:
return false
}
}
func stripRestrictedHeaders(header http.Header) {
for name := range header {
for _, restricted := range restrictedHeaders {
if strings.EqualFold(name, restricted) {
delete(header, name)
break
}
}
}
}
type origin struct {
scheme string
host string
port string
}
var officialFeishuOrigins = [...]origin{
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Open),
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Open),
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Accounts),
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Accounts),
}
func (t *Transport) routeAllowsSignals(req *http.Request) bool {
if req == nil || req.URL == nil {
return false
}
return isOfficialFeishuOrigin(originOf(req.URL))
}
func originOf(value *url.URL) origin {
if value == nil {
return origin{}
}
scheme := strings.ToLower(value.Scheme)
port := value.Port()
if port == "" {
switch scheme {
case "https":
port = "443"
case "http":
port = "80"
}
}
return origin{scheme: scheme, host: strings.ToLower(value.Hostname()), port: port}
}
func apiOrigin(brand core.LarkBrand, endpointURL string) origin {
endpoint, err := url.Parse(endpointURL)
if err != nil {
return origin{}
}
return originOf(endpoint)
}
func isOfficialFeishuOrigin(candidate origin) bool {
if candidate.scheme != "https" || candidate.port != "443" {
return false
}
for _, official := range officialFeishuOrigins {
if candidate == official {
return true
}
}
return false
}

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