Compare commits

..

55 Commits

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

* fix: deterministic-gate remove internal paging logic

* fix: CR issue

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

* test(base): verify batch updates through effects

* docs(base): focus batch updates on update_records

---------

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

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

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

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

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

* test: use standard TestFactory harness for pagination tests

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

* refactor: route success output through the single Emitter port

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(apps): address PR review feedback

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

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

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

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

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

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

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

fix:cherry-pick and resolve conflicts

* fix: gofmt apps_errors.go and apps_errors_test.go

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

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

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

* fix: 文件资源上传

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

* fix: 补回lark-share 内容

* fix: 补回一些内容

* fix: 移除豆包特有工具

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

* fix: 补回示例xml头

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

* docs: refine trigger automation guidance

* docs: correct trigger release contracts

* docs: separate trigger enable and probe authorization

* docs: link the enable-only trigger path

* test: harden trigger authorization contracts

* docs: harden trigger disabled-state handling

* docs: harden trigger release state handling

* docs: verify a finished release before enable

* docs: split trigger start and test flows

* docs: fail closed after trigger probe errors

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

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

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

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

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

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

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

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

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

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

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

* docs(base): clarify partial update payload guidance

---------

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

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

* test: strengthen English output contracts
2026-07-20 14:05:42 +08:00
HanShaoshuai-k
21cff2e2dd fix: reduce public content credential fixture false positives 2026-07-20 13:54:38 +08:00
zhanghuanxu
44514ad114 fix(slides): detect visual elements outside canvas 2026-07-19 21:45:42 +08:00
liangshuo-1
4a56748bfa chore: release v1.0.72 (#1943) 2026-07-17 19:43:46 +08:00
luozhixiong01
0b6faa01bf ci: deduplicate PR runs and serialize live E2E (#1888)
* ci: deduplicate PR runs and serialize live E2E

* ci: preserve live E2E cleanup on supersession

* ci: harden live E2E supersession check

* ci: gate live E2E on dry-run planning

Make the dry-run result a hard prerequisite for live E2E so skip-mode changes never acquire the repository-wide slot. This intentionally trades one full dry-run duration of live startup latency for lower contention on the exclusive queue.

* ci: bound dry-run E2E planning

The dry-run job is now a hard prerequisite for live E2E. Bound its
execution so a stalled planning job cannot delay a PR verdict for the
default six-hour job limit.

* test: tighten live E2E supersession contract
2026-07-17 19:37:48 +08:00
LightsDancer
1efe2dfb33 feat(approval): support approval event consumption (#1924)
Register approval.instance.status_changed_v4 and approval.task.status_changed_v4 with custom flattened schemas and user-auth pre-consume subscription setup.

Handle approval subscription_type as optional multi-value pre-registration metadata: omitted values register both involved and managed relations, explicit values can be single, comma-separated, or JSON array, and consumers do not unsubscribe on exit.

Report partial approval subscription registration failures with registered and failed relation context while preserving the underlying typed error classification.

Document approval event output fields and subscription semantics, and refresh approval skill references from API metadata.
2026-07-17 18:38:29 +08:00
luozhixiong01
767386cb57 fix: stabilize drive delete E2E terminal-state checks (#1939)
* fix: converge drive delete workflow test on terminal state

* fix: narrow drive delete tolerance to the verified transient

* test: lock the delete failure guard with a subprocess contract test

* test: lock task-result and retry-exhaustion failure boundaries
2026-07-17 18:00:38 +08:00
luozhixiong01
e71c76155e test: fix drive cover download retries (#1934) 2026-07-17 17:53:01 +08:00
luozhixiong01
c363acf94e test: use tri-state wiki node identity in delete verification (#1931)
A get_node success response may omit data.node/node_token (the field is
optional), so a missing token must not be read as proof of deletion.
Classify the response as same / different / unknown: only a different
non-empty node_token proves the original node is gone (move-to-drive),
while an unknown identity keeps polling in isWikiNodeDeleted and still
attempts deletion in deleteWikiNodeAndVerify instead of leaking nodes.
2026-07-17 17:52:12 +08:00
zhanghuanxu
05285bb696 feat(slides): report resolved table size mismatches 2026-07-17 17:29:39 +08:00
zhanghuanxu
4c0f93bd6a feat(slides):lint table out of canvas 2026-07-17 17:29:39 +08:00
Yuxuan Zhao
76ebd49382 test: stabilize live e2e auth retries (#1904)
* test: stabilize live e2e auth retries

* fix(e2e): scope shared tenant credentials
2026-07-17 17:20:45 +08:00
zhengzhijiej-tech
6c14c425fc docs(sheets): use English placeholder in table-get guidance (#1936) 2026-07-17 17:01:22 +08:00
calendar-assistant
27df16d3b2 fix(vc): don't fail +detail for in-progress meetings (#1930)
An ongoing meeting has no minute/note yet, so the recording lookup in
+detail returned an unclassified error that was surfaced as a hard error,
making the whole command exit 1 / ok:false even though meeting.get had
already succeeded.

Detect the in-progress state up front (same start/end heuristic as
+meeting-events, reading raw timestamps) and skip the recording call,
returning the meeting metadata with an informational hint instead of an
error. Recording failures for ended meetings are likewise degraded to a
hint rather than failing the command.

Also note in the vc-agent skill that sending an in-meeting message only
needs meeting_id and must not pre-fetch +detail / +recording / +notes.
2026-07-17 15:54:26 +08:00
zgz2048
47dc003601 docs: document base field default values (#1500)
* docs: document base field default values

* docs(base): update field default value schema
2026-07-17 15:17:01 +08:00
zhanghuanxu
4e0a6a988c docs(slides): document table dimensions 2026-07-17 11:36:53 +08:00
liangshuo-1
708196040a chore: release v1.0.71 (#1919) 2026-07-16 20:34:43 +08:00
yballul-bytedance
65586577a3 feat(drive): add secure label support and clarify comment location API (#1913)
Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-16 18:09:38 +08:00
wangweiming-01
be1f3621de perf(drive): optimize drive +delete workflow (#1909) 2026-07-16 16:21:37 +08:00
356 changed files with 30490 additions and 4389 deletions

View File

@@ -1,4 +1,5 @@
name: CI
run-name: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
on:
push:
@@ -8,6 +9,12 @@ on:
types: [opened, synchronize, reopened, edited]
workflow_dispatch:
# PR metadata edits can retrigger full CI for the same head. Keep only the
# newest run for a pull request; push and manual runs use a unique run ID.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
actions: read
@@ -295,6 +302,11 @@ jobs:
e2e-dry-run:
needs: [unit-test, lint, script-test, deterministic-gate]
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
mode: ${{ steps.e2e_domains.outputs.mode }}
reason: ${{ steps.e2e_domains.outputs.reason }}
live_packages: ${{ steps.e2e_domains.outputs.live_packages }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -308,6 +320,23 @@ jobs:
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Validate CLI E2E domain outputs
env:
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
case "$E2E_MODE" in
skip)
[ -z "$E2E_LIVE_PACKAGES" ] || { echo "::error::Skip mode must not resolve live packages"; exit 1; }
;;
full|subset)
[ -n "$E2E_LIVE_PACKAGES" ] || { echo "::error::No live packages resolved for mode $E2E_MODE"; exit 1; }
;;
*)
echo "::error::Invalid CLI E2E mode: $E2E_MODE"
exit 1
;;
esac
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
@@ -341,16 +370,22 @@ jobs:
fi
e2e-live:
needs: [unit-test, lint, script-test, deterministic-gate]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != '' }}
runs-on: ubuntu-latest
timeout-minutes: 30
# Live E2E uses one repository-wide execution slot.
concurrency:
group: lark-cli-e2e-live
cancel-in-progress: false
queue: max
permissions:
actions: read
contents: read
checks: write
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
LARKSUITE_CLI_BRAND: feishu
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -361,31 +396,68 @@ jobs:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
id: build_cli
run: make build
- name: Configure bot credentials
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
- name: Prepare shared live E2E tenant token
id: live_e2e_tat
env:
LARKSUITE_CLI_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
run: node scripts/fetch_e2e_tat.js
- name: Run CLI E2E tests
# Keep an active Go test alive so t.Cleanup can finish. A queued stale
# run is rejected below before it can start live E2E.
if: ${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
RUN_ID: ${{ github.run_id }}
RUN_NUMBER: ${{ github.run_number }}
RUN_GENERATION: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ needs.e2e-dry-run.outputs.mode }}
E2E_REASON: ${{ needs.e2e-dry-run.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ needs.e2e-dry-run.outputs.live_packages }}
E2E_TENANT_AUTH_FILE: ${{ steps.live_e2e_tat.outputs.path }}
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
if [ "$EVENT_NAME" = "pull_request" ]; then
workflow_id="$(gh api "repos/$REPOSITORY/actions/runs/$RUN_ID" --jq '.workflow_id')"
newer_runs="$(
gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs" \
-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100 |
jq -r --arg repository "$REPOSITORY" --arg generation "$RUN_GENERATION" --argjson run_number "$RUN_NUMBER" \
'.workflow_runs[] | select(.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number) | .id'
)"
if [ -n "$newer_runs" ]; then
echo "::error::Superseded before live E2E started by newer workflow run(s): $newer_runs"
exit 1
fi
fi
if [ -z "${E2E_TENANT_AUTH_FILE:-}" ] || [ ! -f "$E2E_TENANT_AUTH_FILE" ]; then
echo "::error::Missing shared live E2E tenant token file"
exit 1
fi
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No live CLI E2E needed: $E2E_REASON"
exit 0
export TEST_TENANT_ACCESS_TOKEN="$(cat "$E2E_TENANT_AUTH_FILE")"
rm -f "$E2E_TENANT_AUTH_FILE"
if ! LARKSUITE_CLI_APP_ID="$TEST_BOT1_APP_ID" \
LARKSUITE_CLI_TENANT_ACCESS_TOKEN="$TEST_TENANT_ACCESS_TOKEN" \
./lark-cli whoami --as bot | node -e '
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => { input += chunk; });
process.stdin.on("end", () => {
const result = JSON.parse(input);
if (result.identity !== "bot" || result.available !== true || result.tokenStatus !== "ready") process.exit(1);
});
'; then
echo "::error::Tenant credential preflight failed"
exit 1
fi
echo "Tenant credential preflight succeeded"
packages="$E2E_LIVE_PACKAGES"
if [ -z "$packages" ]; then
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
@@ -395,7 +467,7 @@ jobs:
echo "Live CLI E2E packages: $packages"
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
if: ${{ !cancelled() }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests
@@ -472,8 +544,8 @@ jobs:
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
# Any failure or cancellation in any job blocks the merge.
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
# license-header on push) are OK.
# Legitimately skipped jobs (deadcode on push, e2e-live when not
# needed or on a fork, license-header on push) are OK.
#
# plugin-integration and sidecar-integration are intentionally NOT
# in this loop yet: they run on every PR and their status is shown

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,108 @@
All notable changes to this project will be documented in this file.
## [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
- **slides**: lint table out of canvas
- **slides**: report resolved table size mismatches
- **approval**: support approval event consumption (#1924)
### Bug Fixes
- **vc**: don't fail +detail for in-progress meetings (#1930)
- stabilize drive delete E2E terminal-state checks (#1939)
### Documentation
- **slides**: document table dimensions
- document base field default values (#1500)
- **sheets**: use English placeholder in table-get guidance (#1936)
### Tests
- stabilize live e2e auth retries (#1904)
- use tri-state wiki node identity in delete verification (#1931)
- fix drive cover download retries (#1934)
## [v1.0.71] - 2026-07-16
### Features
- add wiki move-to-drive shortcut (#1869)
- **apps**: add role management shortcuts (#1881)
- **drive**: add secure label support and clarify comment location API (#1913)
### Bug Fixes
- **base**: improve dashboard shortcut guidance (#1787)
### Documentation
- **apps**: add platform SQL authoring guide to the db-execute skill (#1912)
### Misc
- add L4 plugin-integration and sidecar-integration CI jobs (#1840)
- **drive**: optimize drive +delete workflow (#1909)
## [v1.0.70] - 2026-07-15
### Features
@@ -1506,6 +1608,10 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[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
[v1.0.69]: https://github.com/larksuite/cli/releases/tag/v1.0.69
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68

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

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

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

@@ -17,6 +17,8 @@ import (
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
@@ -36,6 +38,8 @@ func TestRunList_TextOutput(t *testing.T) {
out := stdout.String()
for _, want := range []string{
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"im.message.receive_v1",
"im.message.message_read_v1",
"task.task.update_user_access_v2",
@@ -90,6 +94,8 @@ func TestRunList_JSONOutput(t *testing.T) {
t.Fatal("event list JSON missing task.task.update_user_access_v2")
}
for _, want := range []string{
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {

View File

@@ -19,6 +19,29 @@ import (
_ "github.com/larksuite/cli/events"
)
type approvalSchemaJSONPayload struct {
JQRootPath string `json:"jq_root_path"`
AuthTypes []string `json:"auth_types"`
Scopes []string `json:"scopes"`
Params []approvalSchemaJSONParam `json:"params"`
ResolvedOutputSchema approvalSchemaJSONResolvedSchema `json:"resolved_output_schema"`
}
type approvalSchemaJSONParam struct {
Name string `json:"name"`
Type string `json:"type"`
Required bool `json:"required"`
SubscriptionKey bool `json:"subscription_key"`
}
type approvalSchemaJSONResolvedSchema struct {
Properties map[string]approvalSchemaJSONProperty `json:"properties"`
}
type approvalSchemaJSONProperty struct {
Format string `json:"format"`
}
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
@@ -158,6 +181,60 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
}
}
func TestRunSchema_ApprovalStatusChangedJSON(t *testing.T) {
tests := []struct {
key string
scope string
}{
{"approval.instance.status_changed_v4", "approval:instance:read"},
{"approval.task.status_changed_v4", "approval:task:read"},
}
for _, tc := range tests {
t.Run(tc.key, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, tc.key, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload approvalSchemaJSONPayload
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if payload.JQRootPath != "." {
t.Errorf("jq_root_path = %v, want .", payload.JQRootPath)
}
if got := payload.AuthTypes; !reflect.DeepEqual(got, []string{"user"}) {
t.Errorf("auth_types = %#v, want user", got)
}
if got := payload.Scopes; !reflect.DeepEqual(got, []string{tc.scope}) {
t.Errorf("scopes = %#v, want %s", got, tc.scope)
}
if len(payload.Params) != 1 {
t.Fatalf("params = %#v, want one subscription_type param", payload.Params)
}
param := payload.Params[0]
if param.Name != "subscription_type" || param.Type != "multi" || param.Required || param.SubscriptionKey {
t.Fatalf("subscription_type param = %#v, want optional multi non-subscription-key param", param)
}
props := payload.ResolvedOutputSchema.Properties
for _, field := range []string{"type", "event_id", "timestamp", "approval_code", "instance_code", "status", "operate_time"} {
if _, ok := props[field]; !ok {
t.Errorf("approval schema missing flat field %q: %+v", field, props)
}
}
if _, ok := props["event"]; ok {
t.Errorf("approval Custom schema should be flat, got envelope field event: %+v", props)
}
if got := props["operate_time"].Format; got != "timestamp_ms" {
t.Errorf("operate_time format = %v, want timestamp_ms", got)
}
})
}
}
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"vc.meeting.participant_meeting_started_v1",

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

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

@@ -0,0 +1,155 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
type approvalEventType string
type approvalSubscriptionPath string
type approvalSubscriptionConfig struct {
eventType approvalEventType
subscribePath approvalSubscriptionPath
}
func approvalSubscriptionPreConsume(cfg approvalSubscriptionConfig) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
eventType := string(cfg.eventType)
subscribePath := string(cfg.subscribePath)
subscriptionTypes, err := approvalSubscriptionTypes(eventType, params)
if err != nil {
return nil, err
}
registered := make([]string, 0, len(subscriptionTypes))
for _, subscriptionType := range subscriptionTypes {
body := map[string]string{"subscription_type": subscriptionType}
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
return nil, approvalSubscriptionRegistrationError(eventType, registered, subscriptionType, err)
}
registered = append(registered, subscriptionType)
}
// Approval subscriptions are durable user-auth relations. Consuming events
// should not cancel that relation when this local process exits.
return nil, nil
}
}
func approvalSubscriptionTypes(eventType string, params map[string]string) ([]string, error) {
raw := strings.TrimSpace(params["subscription_type"])
if raw == "" {
return append([]string(nil), approvalAllSubscriptionTypes...), nil
}
values, err := parseApprovalSubscriptionTypeValues(raw)
if err != nil {
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
}
selected := make(map[string]bool, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
switch value {
case approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged:
selected[value] = true
default:
return nil, invalidApprovalSubscriptionTypeError(eventType, value)
}
}
result := make([]string, 0, len(selected))
for _, value := range approvalAllSubscriptionTypes {
if selected[value] {
result = append(result, value)
}
}
if len(result) == 0 {
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
}
return result, nil
}
func parseApprovalSubscriptionTypeValues(raw string) ([]string, error) {
if strings.HasPrefix(raw, "[") {
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil, err
}
return values, nil
}
return strings.Split(raw, ","), nil
}
func approvalSubscriptionRegistrationError(eventType string, registered []string, failed string, err error) error {
if err == nil {
return nil
}
msg := fmt.Sprintf(
"approval subscription pre-consume failed for EventKey %s: failed subscription_type %s",
eventType,
failed,
)
hint := fmt.Sprintf(
"no approval subscription relation was registered for EventKey %s; fix the cause and retry",
eventType,
)
if len(registered) > 0 {
msg = fmt.Sprintf(
"approval subscription pre-consume partially completed for EventKey %s: registered subscription_type(s) [%s], failed subscription_type %s",
eventType,
strings.Join(registered, ", "),
failed,
)
hint = fmt.Sprintf(
"server-side approval subscription relation(s) already registered for EventKey %s: %s; after fixing the cause, retry with --param subscription_type=%s to register the failed relation",
eventType,
strings.Join(registered, ", "),
failed,
)
}
if p, ok := errs.ProblemOf(err); ok {
if upstream := strings.TrimSpace(p.Message); upstream != "" {
p.Message = msg + ": " + upstream
} else {
p.Message = msg
}
if upstreamHint := strings.TrimSpace(p.Hint); upstreamHint != "" {
p.Hint = upstreamHint + "\n" + hint
} else {
p.Hint = hint
}
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "%s: %v", msg, err).
WithHint("%s", hint).
WithCause(err)
}
func invalidApprovalSubscriptionTypeError(eventType, value string) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid subscription_type for EventKey %s: %q", eventType, value).
WithParam("--param").
WithHint("omit subscription_type to register both approval subscription relations, or pass --param subscription_type=%s, --param subscription_type=%s, or --param subscription_type=%s,%s; run `lark-cli event schema %s` for details",
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
eventType)
}

179
events/approval/register.go Normal file
View File

@@ -0,0 +1,179 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package approval registers Approval-domain EventKeys.
package approval
import (
"context"
"encoding/json"
"reflect"
"github.com/larksuite/cli/internal/event"
)
const (
eventTypeApprovalInstanceStatusChangedV4 = "approval.instance.status_changed_v4"
eventTypeApprovalTaskStatusChangedV4 = "approval.task.status_changed_v4"
pathApprovalInstancesSubscription = "/open-apis/approval/v4/instances/subscription"
pathApprovalTasksSubscription = "/open-apis/approval/v4/tasks/subscription"
approvalSubscriptionTypeInvolved = "INVOLVED_APPROVAL"
approvalSubscriptionTypeManaged = "MANAGED_APPROVAL"
)
var approvalAllSubscriptionTypes = []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
}
// Keys returns all Approval-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeApprovalInstanceStatusChangedV4,
DisplayName: "Approval instance status changed",
Description: "Triggered after an approval instance status becomes visible to the requester or approval participants",
EventType: eventTypeApprovalInstanceStatusChangedV4,
Params: approvalSubscriptionParams(),
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{})},
},
Process: processApprovalInstanceStatusChanged,
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
}),
Scopes: []string{"approval:instance:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeApprovalInstanceStatusChangedV4},
},
{
Key: eventTypeApprovalTaskStatusChangedV4,
DisplayName: "Approval task status changed",
Description: "Triggered after an approval task status becomes visible to the requester or task approver",
EventType: eventTypeApprovalTaskStatusChangedV4,
Params: approvalSubscriptionParams(),
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{})},
},
Process: processApprovalTaskStatusChanged,
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
}),
Scopes: []string{"approval:task:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeApprovalTaskStatusChangedV4},
},
}
}
func approvalSubscriptionParams() []event.ParamDef {
return []event.ParamDef{
{
Name: "subscription_type",
Type: event.ParamMulti,
Description: "Approval subscription relation type(s) to register for the current authorized user. Omit to register both involved and managed approval relations.",
Values: []event.ParamValue{
{
Value: approvalSubscriptionTypeInvolved,
Desc: "Receive events where the current user is the approval requester or approver.",
},
{
Value: approvalSubscriptionTypeManaged,
Desc: "Receive events under approval definitions managed by the current user.",
},
},
},
}
}
func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
if raw == nil {
return nil, nil
}
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
ApprovalCode string `json:"approval_code"`
InstanceCode string `json:"instance_code"`
ExternalID string `json:"external_id"`
Status string `json:"status"`
OperateTime string `json:"operate_time"`
StartUser *ApprovalUserID `json:"start_user"`
} `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
}
out := &ApprovalInstanceStatusChangedV4Output{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
ApprovalCode: envelope.Event.ApprovalCode,
InstanceCode: envelope.Event.InstanceCode,
ExternalID: envelope.Event.ExternalID,
Status: envelope.Event.Status,
OperateTime: envelope.Event.OperateTime,
StartUser: envelope.Event.StartUser,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}
func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
if raw == nil {
return nil, nil
}
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
ApprovalCode string `json:"approval_code"`
InstanceCode string `json:"instance_code"`
TaskID string `json:"task_id"`
ExternalID string `json:"external_id"`
TaskExternalID string `json:"task_external_id"`
AssignedUser *ApprovalUserID `json:"assigned_user"`
Status string `json:"status"`
OperateTime string `json:"operate_time"`
} `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
}
out := &ApprovalTaskStatusChangedV4Output{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
ApprovalCode: envelope.Event.ApprovalCode,
InstanceCode: envelope.Event.InstanceCode,
TaskID: envelope.Event.TaskID,
ExternalID: envelope.Event.ExternalID,
TaskExternalID: envelope.Event.TaskExternalID,
AssignedUser: envelope.Event.AssignedUser,
Status: envelope.Event.Status,
OperateTime: envelope.Event.OperateTime,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}

View File

@@ -0,0 +1,654 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
type recordedCall struct {
method string
path string
body interface{}
}
type fakeAPIClient struct {
calls []recordedCall
err error
errOnCall int
}
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
if f.err != nil && (f.errOnCall == 0 || f.errOnCall == len(f.calls)) {
return nil, f.err
}
return json.RawMessage(`{}`), nil
}
func TestKeysApprovalMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 2 {
t.Fatalf("len(Keys()) = %d, want 2", len(keys))
}
tests := []struct {
key string
scope string
schemaType reflect.Type
subscribe string
}{
{
key: eventTypeApprovalInstanceStatusChangedV4,
scope: "approval:instance:read",
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
subscribe: pathApprovalInstancesSubscription,
},
{
key: eventTypeApprovalTaskStatusChangedV4,
scope: "approval:task:read",
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
subscribe: pathApprovalTasksSubscription,
},
}
byKey := make(map[string]event.KeyDefinition, len(keys))
for _, def := range keys {
byKey[def.Key] = def
}
for _, tc := range tests {
t.Run(tc.key, func(t *testing.T) {
def, ok := byKey[tc.key]
if !ok {
t.Fatalf("missing key %s", tc.key)
}
if def.EventType != tc.key {
t.Errorf("EventType = %q, want %q", def.EventType, tc.key)
}
if def.Schema.Custom == nil || def.Schema.Custom.Type != tc.schemaType {
t.Fatalf("Custom schema Type = %v, want %v", def.Schema.Custom, tc.schemaType)
}
if def.Schema.Native != nil {
t.Fatal("approval events must use Custom schema while SDK event types are not exported")
}
if def.Process == nil {
t.Fatal("Process must flatten raw V2 envelopes")
}
if def.PreConsume == nil {
t.Fatal("PreConsume must subscribe approval user-auth events")
}
if !reflect.DeepEqual(def.Scopes, []string{tc.scope}) {
t.Errorf("Scopes = %#v, want %q", def.Scopes, tc.scope)
}
if !reflect.DeepEqual(def.AuthTypes, []string{"user"}) {
t.Errorf("AuthTypes = %#v, want user", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{tc.key}) {
t.Errorf("RequiredConsoleEvents = %#v, want %q", def.RequiredConsoleEvents, tc.key)
}
assertSubscriptionParam(t, def.Params)
})
}
}
func assertSubscriptionParam(t *testing.T, params []event.ParamDef) {
t.Helper()
if len(params) != 1 {
t.Fatalf("len(params) = %d, want 1", len(params))
}
p := params[0]
if p.Name != "subscription_type" || p.Type != event.ParamMulti || p.Required || p.SubscriptionKey {
t.Fatalf("subscription_type param = %+v, want optional multi non-subscription-key param", p)
}
got := map[string]string{}
for _, v := range p.Values {
got[v.Value] = v.Desc
}
for _, want := range []string{approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged} {
if got[want] == "" {
t.Errorf("subscription_type value %q missing or empty desc; values=%+v", want, p.Values)
}
}
}
type reflectedApprovalSchema struct {
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
}
type reflectedApprovalSchemaProperty struct {
Format string `json:"format"`
Enum []string `json:"enum"`
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
}
func TestApprovalSchemasAnnotations(t *testing.T) {
tests := []struct {
name string
schemaType reflect.Type
eventType string
statusValues []string
userField string
}{
{
name: "instance",
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
eventType: eventTypeApprovalInstanceStatusChangedV4,
statusValues: []string{"PENDING", "APPROVED", "REJECTED", "CANCELED", "DELETED", "REVERTED", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
userField: "start_user",
},
{
name: "task",
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
eventType: eventTypeApprovalTaskStatusChangedV4,
statusValues: []string{"REVERTED", "PENDING", "APPROVED", "REJECTED", "TRANSFERRED", "ROLLBACK", "DONE", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
userField: "assigned_user",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var schema reflectedApprovalSchema
if err := json.Unmarshal(schemas.FromType(tc.schemaType), &schema); err != nil {
t.Fatalf("unmarshal schema: %v", err)
}
props := schema.Properties
eventTypeEnum := props["type"].Enum
if len(eventTypeEnum) != 1 || eventTypeEnum[0] != tc.eventType {
t.Fatalf("type enum = %v, want %s", eventTypeEnum, tc.eventType)
}
if got := props["timestamp"].Format; got != "timestamp_ms" {
t.Errorf("timestamp format = %v, want timestamp_ms", got)
}
assertEnumContains(t, props["status"].Enum, tc.statusValues)
if got := props["operate_time"].Format; got != "timestamp_ms" {
t.Errorf("event.operate_time format = %v, want timestamp_ms", got)
}
userProps := props[tc.userField].Properties
if got := userProps["open_id"].Format; got != "open_id" {
t.Errorf("%s.open_id format = %v, want open_id", tc.userField, got)
}
if got := userProps["union_id"].Format; got != "union_id" {
t.Errorf("%s.union_id format = %v, want union_id", tc.userField, got)
}
if got := userProps["user_id"].Format; got != "user_id" {
t.Errorf("%s.user_id format = %v, want user_id", tc.userField, got)
}
})
}
}
func assertEnumContains(t *testing.T, raw []string, wants []string) {
t.Helper()
got := make(map[string]bool, len(raw))
for _, v := range raw {
got[v] = true
}
for _, want := range wants {
if !got[want] {
t.Errorf("enum missing %q; enum=%v", want, raw)
}
}
}
func TestApprovalPreConsumeRegistersSubscriptionTypesWithoutCleanup(t *testing.T) {
tests := []struct {
name string
eventType string
subscribePath string
params map[string]string
wantTypes []string
}{
{
name: "instance omitted subscription_type registers both",
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
{
name: "task explicit single managed",
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
params: map[string]string{"subscription_type": approvalSubscriptionTypeManaged},
wantTypes: []string{approvalSubscriptionTypeManaged},
},
{
name: "task comma separated multi canonicalizes and deduplicates",
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
params: map[string]string{
"subscription_type": approvalSubscriptionTypeManaged + "," + approvalSubscriptionTypeInvolved + "," + approvalSubscriptionTypeManaged,
},
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
{
name: "instance json array multi",
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
params: map[string]string{
"subscription_type": `["MANAGED_APPROVAL","INVOLVED_APPROVAL"]`,
},
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: approvalEventType(tc.eventType),
subscribePath: approvalSubscriptionPath(tc.subscribePath),
})
rt := &fakeAPIClient{}
cleanup, err := pc(context.Background(), rt, tc.params)
if err != nil {
t.Fatalf("PreConsume returned error: %v", err)
}
if cleanup != nil {
t.Fatal("cleanup must be nil; approval consume must not unsubscribe on exit")
}
assertSubscriptionCalls(t, rt.calls, tc.subscribePath, tc.wantTypes)
})
}
}
func assertSubscriptionCalls(t *testing.T, got []recordedCall, wantPath string, wantTypes []string) {
t.Helper()
if len(got) != len(wantTypes) {
t.Fatalf("calls after pre-consume = %d, want %d; calls=%+v", len(got), len(wantTypes), got)
}
for i, wantType := range wantTypes {
assertCall(t, got[i], "POST", wantPath, map[string]string{"subscription_type": wantType})
}
}
func assertCall(t *testing.T, got recordedCall, wantMethod, wantPath string, wantBody interface{}) {
t.Helper()
if got.method != wantMethod {
t.Errorf("method = %q, want %q", got.method, wantMethod)
}
if got.path != wantPath {
t.Errorf("path = %q, want %q", got.path, wantPath)
}
if !reflect.DeepEqual(got.body, wantBody) {
t.Errorf("body = %#v, want %#v", got.body, wantBody)
}
}
func TestApprovalPreConsumeValidationErrors(t *testing.T) {
t.Run("nil runtime", func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
})
_, err := pc(context.Background(), nil, map[string]string{"subscription_type": approvalSubscriptionTypeInvolved})
if err == nil {
t.Fatal("expected nil runtime error")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryInternal {
t.Fatalf("err = %T/%v, want typed internal error", err, err)
}
})
for _, raw := range []string{"BAD", "[]", `["INVOLVED_APPROVAL",3]`} {
t.Run("invalid subscription type "+raw, func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
})
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{"subscription_type": raw})
if err == nil {
t.Fatal("expected invalid subscription_type error")
}
if cleanup != nil {
t.Fatal("cleanup must be nil on validation error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T/%v, want *errs.ValidationError", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
t.Errorf("subtype/param = %s/%q, want invalid_argument/--param", ve.Subtype, ve.Param)
}
if ve.Hint == "" {
t.Error("invalid subscription_type should carry a hint")
}
})
}
t.Run("partial registration failure reports registered and failed relation types", func(t *testing.T) {
upstream := errs.NewAPIError(errs.SubtypeServerError, "approval subscription API failed")
rt := &fakeAPIClient{err: upstream, errOnCall: 2}
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
})
cleanup, err := pc(context.Background(), rt, map[string]string{})
if err == nil {
t.Fatal("expected partial registration error")
}
if cleanup != nil {
t.Fatal("cleanup must be nil on registration error")
}
assertSubscriptionCalls(t, rt.calls, pathApprovalTasksSubscription, []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
t.Fatalf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
}
for _, want := range []string{
"registered subscription_type(s) [INVOLVED_APPROVAL]",
"failed subscription_type MANAGED_APPROVAL",
} {
if !strings.Contains(p.Message, want) {
t.Errorf("partial error message missing %q: %q", want, p.Message)
}
}
for _, want := range []string{
"already registered",
"--param subscription_type=MANAGED_APPROVAL",
} {
if !strings.Contains(p.Hint, want) {
t.Errorf("partial error hint missing %q: %q", want, p.Hint)
}
}
})
}
func TestApprovalSubscriptionRegistrationErrorVariants(t *testing.T) {
t.Run("nil error", func(t *testing.T) {
if err := approvalSubscriptionRegistrationError(eventTypeApprovalTaskStatusChangedV4, nil, approvalSubscriptionTypeInvolved, nil); err != nil {
t.Fatalf("nil cause returned error: %v", err)
}
})
t.Run("typed error with existing hint and empty message", func(t *testing.T) {
upstream := errs.NewAPIError(errs.SubtypeServerError, "").WithHint("retry later")
err := approvalSubscriptionRegistrationError(
eventTypeApprovalTaskStatusChangedV4,
nil,
approvalSubscriptionTypeInvolved,
upstream,
)
if err != upstream {
t.Fatalf("typed error should be annotated in place; got %T/%v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if !strings.Contains(p.Message, "failed subscription_type INVOLVED_APPROVAL") {
t.Errorf("message missing failed relation: %q", p.Message)
}
for _, want := range []string{"retry later", "no approval subscription relation was registered"} {
if !strings.Contains(p.Hint, want) {
t.Errorf("hint missing %q: %q", want, p.Hint)
}
}
})
t.Run("untyped error is wrapped with retry context", func(t *testing.T) {
cause := errors.New("transport closed")
err := approvalSubscriptionRegistrationError(
eventTypeApprovalTaskStatusChangedV4,
nil,
approvalSubscriptionTypeInvolved,
cause,
)
if !errors.Is(err, cause) {
t.Fatalf("wrapped error should preserve cause; got %T/%v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeSDKError {
t.Fatalf("category/subtype = %s/%s, want internal/sdk_error", p.Category, p.Subtype)
}
if !strings.Contains(p.Hint, "no approval subscription relation was registered") {
t.Errorf("hint missing no-registration context: %q", p.Hint)
}
})
}
func TestProcessApprovalInstanceStatusChanged(t *testing.T) {
out := runApprovalInstanceStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_instance_001",
"event_type": "approval.instance.status_changed_v4",
"create_time": "1710000000000"
},
"event": {
"approval_code": "approval_code_001",
"instance_code": "instance_code_001",
"external_id": "external_001",
"status": "PENDING",
"operate_time": "1666079207003",
"start_user": {
"open_id": "ou_start",
"union_id": "on_start",
"user_id": "user_start"
}
}
}`)
if out.Type != eventTypeApprovalInstanceStatusChangedV4 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalInstanceStatusChangedV4)
}
if out.EventID != "evt_approval_instance_001" || out.Timestamp != "1710000000000" {
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
}
if out.ApprovalCode != "approval_code_001" || out.InstanceCode != "instance_code_001" {
t.Errorf("approval/instance code = %q/%q", out.ApprovalCode, out.InstanceCode)
}
if out.ExternalID != "external_001" || out.Status != "PENDING" || out.OperateTime != "1666079207003" {
t.Errorf("external/status/operate_time = %q/%q/%q", out.ExternalID, out.Status, out.OperateTime)
}
if out.StartUser == nil || out.StartUser.OpenID != "ou_start" || out.StartUser.UnionID != "on_start" || out.StartUser.UserID != "user_start" {
t.Fatalf("StartUser = %+v, want full user ids", out.StartUser)
}
}
func TestProcessApprovalTaskStatusChanged(t *testing.T) {
out := runApprovalTaskStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_task_001",
"event_type": "approval.task.status_changed_v4",
"create_time": "1710000000001"
},
"event": {
"approval_code": "approval_code_002",
"instance_code": "instance_code_002",
"task_id": "task_001",
"external_id": "external_002",
"task_external_id": "task_external_001",
"status": "APPROVED",
"operate_time": "1666079207004",
"assigned_user": {
"open_id": "ou_assignee",
"union_id": "on_assignee",
"user_id": "user_assignee"
}
}
}`)
if out.Type != eventTypeApprovalTaskStatusChangedV4 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalTaskStatusChangedV4)
}
if out.EventID != "evt_approval_task_001" || out.Timestamp != "1710000000001" {
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
}
if out.ApprovalCode != "approval_code_002" || out.InstanceCode != "instance_code_002" || out.TaskID != "task_001" {
t.Errorf("approval/instance/task = %q/%q/%q", out.ApprovalCode, out.InstanceCode, out.TaskID)
}
if out.ExternalID != "external_002" || out.TaskExternalID != "task_external_001" || out.Status != "APPROVED" || out.OperateTime != "1666079207004" {
t.Errorf("external/task_external/status/operate_time = %q/%q/%q/%q", out.ExternalID, out.TaskExternalID, out.Status, out.OperateTime)
}
if out.AssignedUser == nil || out.AssignedUser.OpenID != "ou_assignee" || out.AssignedUser.UnionID != "on_assignee" || out.AssignedUser.UserID != "user_assignee" {
t.Fatalf("AssignedUser = %+v, want full user ids", out.AssignedUser)
}
}
func TestProcessApprovalStatusChangedUsesRawEventTypeFallback(t *testing.T) {
instance := runApprovalInstanceStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_instance_fallback",
"create_time": "1710000000002"
},
"event": {
"approval_code": "approval_code_fallback",
"instance_code": "instance_code_fallback",
"status": "APPROVED",
"operate_time": "1666079207005"
}
}`)
if instance.Type != eventTypeApprovalInstanceStatusChangedV4 {
t.Errorf("instance Type fallback = %q, want %q", instance.Type, eventTypeApprovalInstanceStatusChangedV4)
}
task := runApprovalTaskStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_task_fallback",
"create_time": "1710000000003"
},
"event": {
"approval_code": "approval_code_fallback",
"instance_code": "instance_code_fallback",
"task_id": "task_fallback",
"status": "DONE",
"operate_time": "1666079207006"
}
}`)
if task.Type != eventTypeApprovalTaskStatusChangedV4 {
t.Errorf("task Type fallback = %q, want %q", task.Type, eventTypeApprovalTaskStatusChangedV4)
}
}
func TestProcessApprovalStatusChangedMalformedPayloadPassthrough(t *testing.T) {
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{"instance", eventTypeApprovalInstanceStatusChangedV4, processApprovalInstanceStatusChanged},
{"task", eventTypeApprovalTaskStatusChangedV4, processApprovalTaskStatusChanged},
} {
t.Run(tc.name, func(t *testing.T) {
raw := &event.RawEvent{
EventType: tc.eventType,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := tc.process(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 TestProcessApprovalStatusChangedNilRaw(t *testing.T) {
for _, tc := range []struct {
name string
process event.ProcessFunc
}{
{"instance", processApprovalInstanceStatusChanged},
{"task", processApprovalTaskStatusChanged},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.process(context.Background(), nil, nil, nil)
if err != nil {
t.Fatalf("Process nil raw returned error: %v", err)
}
if got != nil {
t.Fatalf("Process nil raw output = %s, want nil", string(got))
}
})
}
}
func runApprovalInstanceStatusChanged(t *testing.T, payload string) ApprovalInstanceStatusChangedV4Output {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeApprovalInstanceStatusChangedV4,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processApprovalInstanceStatusChanged(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process returned error: %v", err)
}
var out ApprovalInstanceStatusChangedV4Output
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid instance JSON: %v\nraw=%s", err, string(got))
}
return out
}
func runApprovalTaskStatusChanged(t *testing.T, payload string) ApprovalTaskStatusChangedV4Output {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeApprovalTaskStatusChangedV4,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processApprovalTaskStatusChanged(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process returned error: %v", err)
}
var out ApprovalTaskStatusChangedV4Output
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid task JSON: %v\nraw=%s", err, string(got))
}
return out
}
func TestApprovalKeysRegisterCleanly(t *testing.T) {
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
}
for _, def := range Keys() {
event.RegisterKey(def)
}
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
}
}
}
var _ event.APIClient = (*fakeAPIClient)(nil)

42
events/approval/types.go Normal file
View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
// ApprovalUserID identifies a user in the three Lark ID formats included by
// approval status-change events.
type ApprovalUserID struct {
OpenID string `json:"open_id,omitempty" desc:"User open_id; prefixed with ou_" kind:"open_id"`
UnionID string `json:"union_id,omitempty" desc:"User union_id" kind:"union_id"`
UserID string `json:"user_id,omitempty" desc:"User id within the tenant" kind:"user_id"`
}
// ApprovalInstanceStatusChangedV4Output is the flattened shape for
// approval.instance.status_changed_v4.
type ApprovalInstanceStatusChangedV4Output struct {
Type string `json:"type" desc:"Event type; always approval.instance.status_changed_v4" enum:"approval.instance.status_changed_v4"`
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); taken from header.create_time when present" kind:"timestamp_ms"`
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval instance id; present only for third-party approvals"`
Status string `json:"status,omitempty" desc:"Approval instance status" enum:"PENDING,APPROVED,REJECTED,CANCELED,DELETED,REVERTED,OVERTIME_CLOSE,OVERTIME_RECOVER"`
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
StartUser *ApprovalUserID `json:"start_user,omitempty" desc:"Approval instance starter; omitted when unavailable"`
}
// ApprovalTaskStatusChangedV4Output is the flattened shape for
// approval.task.status_changed_v4.
type ApprovalTaskStatusChangedV4Output struct {
Type string `json:"type" desc:"Event type; always approval.task.status_changed_v4" enum:"approval.task.status_changed_v4"`
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); taken from header.create_time when present" kind:"timestamp_ms"`
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
TaskID string `json:"task_id,omitempty" desc:"Approval task id"`
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval external id; present only for third-party approvals"`
TaskExternalID string `json:"task_external_id,omitempty" desc:"Third-party approval task external id; present only when emitted by the upstream service"`
AssignedUser *ApprovalUserID `json:"assigned_user,omitempty" desc:"Task assignee or operator user ids; omitted for automatic flows without an operator"`
Status string `json:"status,omitempty" desc:"Approval task status" enum:"REVERTED,PENDING,APPROVED,REJECTED,TRANSFERRED,ROLLBACK,DONE,OVERTIME_CLOSE,OVERTIME_RECOVER"`
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
}

View File

@@ -5,6 +5,8 @@
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"
"github.com/larksuite/cli/events/task"
@@ -16,6 +18,8 @@ import (
// Mail is intentionally omitted in this phase.
func init() {
all := [][]event.KeyDefinition{
application.Keys(),
approval.Keys(),
im.Keys(),
minutes.Keys(),
task.Keys(),

View File

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

View File

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

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

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

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

@@ -19,12 +19,18 @@ import (
type eventPayload struct {
Comment *struct {
Body string `json:"body"`
Path string `json:"path"`
} `json:"comment"`
Review *struct {
Body string `json:"body"`
} `json:"review"`
}
type commentContent struct {
Body string
Path string
}
func main() {
eventPath := flag.String("event", os.Getenv("GITHUB_EVENT_PATH"), "GitHub event payload path")
kind := flag.String("kind", os.Getenv("GITHUB_EVENT_NAME"), "GitHub event kind")
@@ -34,12 +40,11 @@ func main() {
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
os.Exit(2)
}
body, err := commentBody(*eventPath)
diags, err := auditEvent(*eventPath, *kind)
if err != nil {
fmt.Fprintf(os.Stderr, "comment-audit: %v\n", err)
os.Exit(2)
}
diags := diagnostics(publiccontent.ScanComment(*kind, body))
if len(diags) > 0 {
fmt.Fprintln(os.Stderr, auditFailureSummary(len(diags)))
}
@@ -47,32 +52,44 @@ func main() {
os.Exit(report.ExitCode(diags))
}
func auditEvent(eventPath, kind string) ([]report.Diagnostic, error) {
content, err := commentBody(eventPath)
if err != nil {
return nil, err
}
return scanCommentContent(kind, content), nil
}
func scanCommentContent(kind string, content commentContent) []report.Diagnostic {
return diagnostics(publiccontent.ScanCommentAtPath(kind, content.Path, content.Body))
}
func auditFailureSummary(count int) string {
return fmt.Sprintf("post-publication audit found public content findings: %d", count)
}
func commentBody(path string) (string, error) {
func commentBody(path string) (commentContent, error) {
safePath, err := validate.SafeInputPath(path)
if err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
return commentContent{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
WithParam("--event").
WithCause(err)
}
data, err := vfs.ReadFile(safePath)
if err != nil {
return "", err
return commentContent{}, err
}
var payload eventPayload
if err := json.Unmarshal(data, &payload); err != nil {
return "", err
return commentContent{}, err
}
switch {
case payload.Comment != nil:
return payload.Comment.Body, nil
return commentContent{Body: payload.Comment.Body, Path: payload.Comment.Path}, nil
case payload.Review != nil:
return payload.Review.Body, nil
return commentContent{Body: payload.Review.Body}, nil
default:
return "", nil
return commentContent{}, nil
}
}

View File

@@ -7,9 +7,11 @@ import (
"errors"
"os"
"path/filepath"
"strconv"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
)
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
@@ -32,11 +34,92 @@ func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
if err != nil {
t.Fatalf("commentBody() error = %v", err)
}
if got != "clean comment" {
t.Fatalf("comment body = %q", got)
if got.Body != "clean comment" || got.Path != "" {
t.Fatalf("comment content = %#v", got)
}
}
func TestCommentBodyReadsReviewCommentPath(t *testing.T) {
dir := t.TempDir()
if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"test suggestion","path":"cmd/agent/list_test.go"}}`); err != nil {
t.Fatal(err)
}
origDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = os.Chdir(origDir)
})
got, err := commentBody("event.json")
if err != nil {
t.Fatalf("commentBody() error = %v", err)
}
if got.Body != "test suggestion" || got.Path != "cmd/agent/list_test.go" {
t.Fatalf("comment content = %#v", got)
}
}
func TestCommentAuditUsesReviewCommentPathForFixtureClassification(t *testing.T) {
dir := t.TempDir()
body := `CLIENT_SECRET=$(security find-generic-password -w)`
event := `{"comment":{"body":` + strconv.Quote(body) + `,"path":"scripts/config_test.sh"}}`
if err := writeTestFile(filepath.Join(dir, "event.json"), event); err != nil {
t.Fatal(err)
}
origDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = os.Chdir(origDir)
})
diags, err := auditEvent("event.json", "pull_request_review_comment")
if err != nil {
t.Fatalf("auditEvent() error = %v", err)
}
for _, diag := range diags {
if diag.Rule == "public_content_generic_credential" {
t.Fatalf("review comment fixture should not be a credential diagnostic: %#v", diags)
}
}
pathless := publiccontent.ScanComment("pull_request_review_comment", body)
for _, finding := range pathless {
if finding.Rule == "public_content_generic_credential" {
return
}
}
t.Fatalf("test precondition failed: pathless comment should be classified as a credential: %#v", pathless)
}
func TestScanCommentContentPreservesReviewCommentPath(t *testing.T) {
providerValue := "gh" + "p_" + "1234567890abcdef" + "1234567890abcdef" + "1234"
content := commentContent{
Body: `cfg := &Config{AccessToken: "` + providerValue + `"}`,
Path: "cmd/agent/list_test.go",
}
diags := scanCommentContent("pull_request_review_comment", content)
for _, diag := range diags {
if diag.Rule != "public_content_generic_credential" {
continue
}
if diag.File != content.Path {
t.Fatalf("credential diagnostic file = %q, want %q", diag.File, content.Path)
}
return
}
t.Fatalf("missing provider credential diagnostic: %#v", diags)
}
func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "event.json")
if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil {

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) {
@@ -23,9 +24,10 @@ func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
runGit(t, repo, "add", "baseline.md")
runGit(t, repo, "commit", "-m", "base")
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change
api_`+`key = "example-public-key"
api_`+`key = "`+providerValue+`"
`)
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
@@ -199,13 +201,14 @@ func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "base")
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
`{"access_` + `token":"real-json-token"}`,
`{"client_` + `secret": "real ` + `secret value"}`,
`{"tenantAccess` + `Token":"real-tenant-camel-token"}`,
`{"github` + `Token":"real-github-token"}`,
`{"vendorApi` + `Key":"real-vendor-key"}`,
`{"slackBot` + `Token":"xoxb-real-token"}`,
`{"access_` + `token":"` + providerValue + `"}`,
`{"client_` + `secret": "` + providerValue + `"}`,
`{"tenantAccess` + `Token":"` + providerValue + `"}`,
`{"github` + `Token":"` + providerValue + `"}`,
`{"vendorApi` + `Key":"` + providerValue + `"}`,
`{"slackBot` + `Token":"xoxb_` + `1234567890abcdef"}`,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "add json config")
@@ -215,14 +218,7 @@ func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
for _, item := range got {
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
count++
for _, forbidden := range []string{
"real-json-token",
"real secret value",
"real-tenant-camel-token",
"real-github-token",
"real-vendor-key",
"xoxb-real-token",
} {
for _, forbidden := range []string{providerValue, "xoxb_" + "1234567890abcdef"} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
@@ -306,8 +302,8 @@ func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
count++
}
}
if count != 3 {
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
if count != 2 {
t.Fatalf("angle-wrapped provider credential findings = %d, want 2: %#v", count, got)
}
}
@@ -338,12 +334,12 @@ func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
count++
}
}
if count != 7 {
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
if count != 4 {
t.Fatalf("provider-shaped benign-key findings = %d, want 4: %#v", count, got)
}
}
func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
func TestCollectAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
@@ -358,15 +354,11 @@ func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
count++
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
}
}
if count != 3 {
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
}
}
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
@@ -374,7 +366,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"AWS_ACCESS_KEY_ID: " + accessKey,
@@ -391,7 +383,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
continue
}
count++
if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
if strings.Contains(item.Excerpt, accessKey) {
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
}
}
@@ -432,7 +424,7 @@ func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
}
}
func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
func TestCollectAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
@@ -448,15 +440,11 @@ func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T)
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
count++
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
}
}
if count != 4 {
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
}
}
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
@@ -489,12 +477,13 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY_OPENAI: real-openai-key",
"TOKEN_GITHUB: real-github-token",
"CLIENT_SECRET_GOOGLE: real-google-secret",
"SECRET_KEY_BASE: real-secret-key-base",
"APP_PASSWORD_PROD: real-prod-password",
"API_KEY_OPENAI: " + providerValue,
"TOKEN_GITHUB: " + providerValue,
"CLIENT_SECRET_GOOGLE: " + providerValue,
"SECRET_KEY_BASE: " + providerValue,
"APP_PASSWORD_PROD: " + providerValue,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
@@ -506,13 +495,7 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
continue
}
count++
for _, forbidden := range []string{
"real-openai-key",
"real-github-token",
"real-google-secret",
"real-secret-key-base",
"real-prod-password",
} {
for _, forbidden := range []string{providerValue} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
@@ -621,7 +604,8 @@ func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan_test.go"), "SECRET_TOKEN=fixture\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan.go"), "const privateKeyFixture = \""+privateKeyBeginPrefix+privateKeyMarker+"\"\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "rules.go"), "markers := []string{\"generated with automation\"}\n")
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN="+providerValue+"\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "add scanner fixtures")
@@ -685,10 +669,11 @@ func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN=space-value\n")
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN=quote-value\n")
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN="+providerValue+"\n")
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN="+providerValue+"\n")
runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN="+providerValue+"\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "add special paths")
@@ -855,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)
@@ -865,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

@@ -4,8 +4,15 @@
package publiccontent
func ScanComment(kind, body string) []Finding {
return ScanCommentAtPath(kind, "", body)
}
func ScanCommentAtPath(kind, path, body string) []Finding {
if kind == "" {
kind = "comment"
}
return scanText(kind, "comment", body, false)
if path == "" {
path = kind
}
return scanText(path, "comment", body, isDetectorRuleFile(path))
}

View File

@@ -3,7 +3,10 @@
package publiccontent
import "testing"
import (
"strings"
"testing"
)
func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
@@ -17,3 +20,60 @@ func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
}
}
}
func TestScanCommentAllowsMermaidCredentialTerminology(t *testing.T) {
body := strings.Join([]string{
"```mermaid",
"sequenceDiagram",
" participant Client",
" participant AccessTokenHashTransport",
" participant SecurityPolicyTransport",
" Client->>AccessTokenHashTransport: Send request with bearer token",
" AccessTokenHashTransport->>AccessTokenHashTransport: Clone request and inject token hash",
" Client -> ClientSecret: Resolve configured credential",
" AccessTokenHashTransport->>SecurityPolicyTransport: Forward enriched request",
"```",
}, "\n")
got := ScanComment("issue_comment", body)
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("mermaid credential terminology should not be a credential finding: %#v", got)
}
}
}
func TestScanCommentDetectsCredentialAssignmentInsideMermaidMessage(t *testing.T) {
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
credentialAssignment := "password=" + providerValue
body := strings.Join([]string{
"```mermaid",
"sequenceDiagram",
" Client->>Server: Send " + credentialAssignment,
"```",
}, "\n")
got := ScanComment("issue_comment", body)
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("credential assignment inside mermaid message should be reported: %#v", got)
}
}
func TestScanCommentAtPathAllowsTestFixtureCredentialPlaceholder(t *testing.T) {
body := `cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret"}`
got := ScanCommentAtPath("pull_request_review_comment", "cmd/agent/list_test.go", body)
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("review comment test fixture should not be a credential finding: %#v", got)
}
}
}
func TestScanCommentAtPathDetectsProviderCredentialInTestFile(t *testing.T) {
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
body := `cfg := &Config{AccessToken: "` + providerValue + `"}`
got := ScanCommentAtPath("pull_request_review_comment", "cmd/agent/list_test.go", body)
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("provider credential in review comment should be reported: %#v", got)
}
}

View File

@@ -0,0 +1,88 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"encoding/base64"
"net/url"
"strings"
)
func credentialValueHasStrongEvidence(key, value string) bool {
normalized := strings.TrimRight(strings.TrimSpace(value), ",;")
normalized = strings.TrimSpace(strings.Trim(normalized, `"'<>`))
candidates := credentialEvidenceCandidates(unwrapCredentialValue(normalized))
for _, candidate := range candidates {
if providerCredentialIdentifier(candidate) {
return true
}
}
if isCredentialMetadataField(key) {
return false
}
for _, candidate := range candidates {
if highEntropyCredentialValue(strings.ToLower(candidate)) || base64PaddedCredentialValue(candidate) {
return true
}
}
return percentEncodedCredentialValue(strings.ToLower(candidates[0])) ||
commandSubstitutionLooksCredentialLike(strings.ToLower(normalized))
}
func credentialEvidenceCandidates(value string) []string {
candidates := []string{value}
for range 3 {
decoded, err := url.PathUnescape(value)
if err != nil || decoded == value {
break
}
candidates = append(candidates, decoded)
value = decoded
}
return candidates
}
func isCredentialMetadataField(key string) bool {
if isBenignTokenField(key) {
return true
}
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
if len(parts) < 2 {
return false
}
switch parts[len(parts)-1] {
case "hash", "id", "kind", "marker", "prefix", "transport":
return true
default:
return false
}
}
func base64PaddedCredentialValue(value string) bool {
if len(value) < 16 || !strings.HasSuffix(value, "=") {
return false
}
if _, err := base64.StdEncoding.DecodeString(value); err != nil {
return false
}
return shannonEntropy(strings.TrimRight(value, "=")) >= 3.5
}
func percentEncodedCredentialValue(value string) bool {
if len(value) < 16 {
return false
}
var escapes int
for i := 0; i+2 < len(value); i++ {
if value[i] == '%' && isHexByte(value[i+1]) && isHexByte(value[i+2]) {
escapes++
i += 2
}
}
return escapes >= 2
}
func isHexByte(value byte) bool {
return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f')
}

View File

@@ -13,7 +13,7 @@ import (
)
var (
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\s,}\]]+))`)
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*(?::=|[:=])\s*(?:!!str\s+)?(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\x60[^\x60]*\x60)|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\x60\s,}\]]+))`)
jwtLikeRE = regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`)
credentialURLRE = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]*:[^@\s/]+@[^)\s]+`)
bearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+|["']Authorization["']\s*:\s*["']Bearer\s+)[A-Za-z0-9._+/=-]{12,}`)
@@ -383,33 +383,63 @@ func anglePlaceholderIdentifier(value string) bool {
}
func credentialShapedValue(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
normalized := strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'<>`))
return credentialShapedIdentifier(normalized)
}
func credentialShapedIdentifier(value string) bool {
return providerCredentialIdentifier(value)
}
func providerCredentialIdentifier(value string) bool {
value = strings.TrimSpace(value)
switch {
case strings.HasPrefix(value, "sk_live_"),
strings.HasPrefix(value, "sk_test_"),
strings.HasPrefix(value, "ghp_"),
strings.HasPrefix(value, "gho_"),
strings.HasPrefix(value, "ghu_"),
strings.HasPrefix(value, "github_pat_"),
strings.HasPrefix(value, "xoxb_"),
strings.HasPrefix(value, "xoxp_"),
strings.HasPrefix(value, "xoxa_"):
return true
case strings.HasPrefix(value, "real-") &&
(strings.Contains(value, "secret") ||
strings.Contains(value, "token") ||
strings.Contains(value, "key") ||
strings.Contains(value, "password")):
case providerTokenWithBody(value, "sk_live_", 16, ""),
providerTokenWithBody(value, "sk_test_", 16, ""),
providerTokenWithBody(value, "ghp_", 16, ""),
providerTokenWithBody(value, "gho_", 16, ""),
providerTokenWithBody(value, "ghu_", 16, ""),
providerTokenWithBody(value, "github_pat_", 16, "_"),
providerTokenWithBody(value, "xoxb_", 16, "-"),
providerTokenWithBody(value, "xoxp_", 16, "-"),
providerTokenWithBody(value, "xoxa_", 16, "-"),
providerTokenWithBody(value, "xoxb-", 16, "-"),
providerTokenWithBody(value, "xoxp-", 16, "-"),
providerTokenWithBody(value, "xoxa-", 16, "-"),
awsAccessKeyIdentifier(value):
return true
default:
return false
}
}
func providerTokenWithBody(value, prefix string, minBodyLength int, separators string) bool {
body, ok := strings.CutPrefix(value, prefix)
if !ok || len(body) < minBodyLength {
return false
}
for _, r := range body {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || strings.ContainsRune(separators, r) {
continue
}
return false
}
return true
}
func awsAccessKeyIdentifier(value string) bool {
if len(value) != 20 || (!strings.HasPrefix(value, "AKIA") && !strings.HasPrefix(value, "ASIA")) {
return false
}
for _, r := range value[4:] {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
continue
}
return false
}
return true
}
func resourceTokenPlaceholderValue(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
switch normalized {

View File

@@ -47,15 +47,30 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
out = append(out, newFinding("public_content_private_key_block", file, privateKeyLine, source, "private key block"))
inPrivateKey = false
}
for _, match := range credentialAssignmentRE.FindAllStringSubmatch(line, -1) {
if !isCredentialAssignmentMatch(match[0]) {
for _, location := range credentialAssignmentRE.FindAllStringIndex(line, -1) {
rawMatch := line[location[0]:location[1]]
if !validCredentialAssignmentStart(line, location[0], rawMatch) {
continue
}
match := credentialAssignmentRE.FindStringSubmatch(rawMatch)
if !isCredentialAssignmentMatch(rawMatch) {
continue
}
value := credentialAssignmentValue(match)
keyName, _ := normalizedCredentialAssignmentKey(match[0])
keyName, _ := normalizedCredentialAssignmentKey(rawMatch)
evidenceValue := value
if sourceCodeFile(file) {
if rhs, ok := sourceCodeTypedCredentialRHS(line, location[0], rawMatch); ok {
evidenceValue = rhs
}
}
if !(isWebhookCredentialKey(keyName) && webhookAssignmentValueLooksCredentialLike(value)) &&
!credentialValueHasStrongEvidence(keyName, evidenceValue) {
continue
}
if value == "" ||
isNonSecretLiteralValue(value) ||
isBenignCodeCredentialExpression(file, line, match[0], value) ||
isBenignCodeCredentialExpression(file, line, location[0], rawMatch, value) ||
isPlaceholderValue(value) ||
isPermissionScopeIdentifierAssignment(keyName, value) ||
isResourceTokenPlaceholderAssignment(keyName, value) {
@@ -64,7 +79,7 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
if looksLikeEqualityComparison(value) {
continue
}
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(rawMatch)))
}
for _, match := range jwtLikeRE.FindAllString(line, -1) {
if !isJWTToken(match) {
@@ -123,21 +138,43 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
return out
}
func validCredentialAssignmentStart(line string, start int, match string) bool {
if start <= 0 || credentialAssignmentOperator(match) != ":" {
return true
}
prefix := strings.TrimSpace(line[:start])
for _, arrow := range []string{"-->>", "->>", "-->", "->"} {
if strings.HasSuffix(prefix, arrow) {
return false
}
}
return true
}
func credentialAssignmentOperator(match string) string {
key, ok := credentialAssignmentKey(match)
if !ok {
return ""
}
rest := strings.TrimSpace(match[len(key):])
if strings.HasPrefix(rest, ":=") {
return ":="
}
if strings.HasPrefix(rest, ":") {
return ":"
}
if strings.HasPrefix(rest, "=") {
return "="
}
return ""
}
func isCredentialAssignmentMatch(match string) bool {
name, value, ok := normalizedCredentialAssignment(match)
name, _, ok := normalizedCredentialAssignment(match)
if !ok {
return false
}
if isWebhookCredentialKey(name) && webhookAssignmentValueLooksCredentialLike(value) {
return true
}
if isBenignTokenField(name) && !credentialShapedValue(value) {
return false
}
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
return false
}
return isExplicitCredentialKey(name)
return isExplicitCredentialKey(name) || isWebhookCredentialKey(name)
}
func normalizedCredentialAssignmentKey(match string) (string, bool) {
@@ -288,7 +325,7 @@ func tokenLikePlaceholderKey(key string) bool {
func tokenLikePlaceholderValue(key, value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
if normalized == "" || credentialShapedIdentifier(normalized) {
if normalized == "" || credentialShapedIdentifier(strings.Trim(value, `"'`)) {
return false
}
if authCredentialTokenKey(key) {
@@ -323,52 +360,8 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
return stars >= 6 && alnum > 0
}
func isWeakTokenCredentialKey(key string) bool {
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
return false
}
return key == "token" ||
strings.HasSuffix(key, "_token") ||
strings.HasSuffix(key, "-token")
}
func isStrongTokenCredentialKey(key string) bool {
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
for _, phrase := range [][2]string{
{"access", "token"},
{"refresh", "token"},
{"auth", "token"},
{"bearer", "token"},
{"session", "token"},
{"service", "token"},
{"bot", "token"},
{"api", "token"},
{"secret", "token"},
} {
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
return true
}
}
return false
}
func weakTokenValueLooksCredentialLike(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
if normalized == "" ||
isNonSecretLiteralValue(value) ||
isPlaceholderValue(value) {
return false
}
candidate := unwrapCredentialValue(normalized)
return credentialShapedIdentifier(candidate) ||
highEntropyCredentialValue(candidate) ||
commandSubstitutionLooksCredentialLike(normalized) ||
(strings.Contains(normalized, "://") &&
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
}
func unwrapCredentialValue(value string) string {
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
value = strings.TrimSpace(strings.Trim(value, "\"'<>`"))
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
}
@@ -488,17 +481,20 @@ func numericStringPlaceholderValue(value string) bool {
return true
}
func isBenignCodeCredentialExpression(file, line, match, value string) bool {
func isBenignCodeCredentialExpression(file, line string, matchStart int, match, value string) bool {
normalized := strings.TrimSpace(value)
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
return true
}
if !sourceCodeFile(file) || credentialShapedValue(value) {
if !sourceCodeFile(file) {
return false
}
if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok {
if rhs, ok := sourceCodeTypedCredentialRHS(line, matchStart, match); ok {
return isBenignTypedCredentialRHS(rhs)
}
if credentialShapedValue(value) {
return false
}
rawValueQuoted := credentialAssignmentRawValueQuoted(match)
if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) {
return true
@@ -518,17 +514,16 @@ func isBenignCodeCredentialExpression(file, line, match, value string) bool {
return codeReferenceExpression(normalized)
}
func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
idx := strings.Index(line, match)
if idx < 0 {
func sourceCodeTypedCredentialRHS(line string, matchStart int, match string) (string, bool) {
if matchStart < 0 || matchStart+len(match) > len(line) || line[matchStart:matchStart+len(match)] != match {
return "", false
}
key, ok := credentialAssignmentKey(match)
if !ok {
return "", false
}
rest := strings.TrimSpace(line[idx+len(key):])
if !strings.HasPrefix(rest, ":") {
rest := strings.TrimSpace(line[matchStart+len(key):])
if !strings.HasPrefix(rest, ":") || strings.HasPrefix(rest, ":=") {
return "", false
}
typeAndRHS := strings.TrimSpace(strings.TrimPrefix(rest, ":"))
@@ -536,7 +531,12 @@ func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
if assignmentIdx < 0 {
return "", false
}
return strings.TrimSpace(typeAndRHS[assignmentIdx+1:]), true
rhs := strings.TrimSpace(typeAndRHS[assignmentIdx+1:])
parsed := credentialAssignmentRE.FindStringSubmatch("client_secret=" + rhs)
if parsed == nil {
return rhs, true
}
return credentialAssignmentValue(parsed), true
}
func isBenignTypedCredentialRHS(value string) bool {
@@ -568,7 +568,7 @@ func credentialAssignmentRawValueQuoted(match string) bool {
func sourceCodeFile(file string) bool {
switch filepath.Ext(file) {
case ".go", ".js", ".jsx", ".py", ".ts", ".tsx":
case ".go", ".js", ".jsx", ".py", ".sh", ".ts", ".tsx":
return true
default:
return false
@@ -593,6 +593,7 @@ func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool {
sourceCodeFakeOrPlaceholderLiteral(literal) ||
sourceCodeCredentialTermLiteral(literal) ||
sourceCodeCredentialPrefixLiteral(literal) ||
sourceCodeStringExpressionLiteral(literal) ||
sourceCodeVocabularyLiteral(literal) ||
sourceCodeSchemaTypeLiteral(literal) ||
benignCredentialStatusLiteral(literal)
@@ -685,6 +686,18 @@ func sourceCodeCredentialPrefixLiteral(value string) bool {
}
}
func sourceCodeStringExpressionLiteral(value string) bool {
normalized := strings.TrimSpace(value)
if normalized == "" ||
credentialShapedIdentifier(normalized) ||
highEntropyCredentialValue(strings.ToLower(normalized)) {
return false
}
return strings.Contains(normalized, "${") ||
strings.Contains(normalized, "$(") ||
(strings.Contains(normalized, `\b`) && strings.ContainsAny(normalized, "|[]{}()+*?"))
}
func sourceCodeVocabularyLiteral(value string) bool {
switch strings.ToLower(value) {
case "bot", "tenant", "user":
@@ -753,7 +766,7 @@ func codeIdentifier(value string) bool {
func isNonSecretLiteralValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
case "true", "false", "null", "nil", "{", "[":
case "true", "false", "null", "nil", "{", "[", `\`:
return true
default:
return false
@@ -980,6 +993,7 @@ func credentialURLPasswordFixture(password string) bool {
normalized := strings.ToLower(strings.Trim(password, `"'`))
switch normalized {
case "p",
"p%40ss",
"pass",
"password",
"pat_abc",

View File

@@ -251,26 +251,22 @@ func TestScanFileDoesNotTreatURLEncodedCredentialAsPlaceholder(t *testing.T) {
}
}
func TestScanFileDoesNotTreatPlaceholderMarkerSubstringsAsPlaceholders(t *testing.T) {
func TestScanFileAllowsReadablePlaceholderMarkerSubstrings(t *testing.T) {
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
"API_KEY=notredactedreal",
"API_KEY=notplaceholdersecret",
"API_KEY=abcxxxxreal",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
t.Fatalf("readable credential words should not be findings: %#v", got)
}
}
if count != 3 {
t.Fatalf("placeholder-marker substring findings = %d, want 3: %#v", count, got)
}
}
func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
paddedSecretPrefix := "dGhpc2lz" + "YXNlY3JldA"
paddedTokenPrefix := "YWJj" + "ZGVmZ2g"
paddedTokenPrefix := "UTdrMm1O" + "OXBSNHZYOA"
paddedSecret := base64PaddedFixture(paddedSecretPrefix)
paddedToken := base64PaddedFixture(paddedTokenPrefix)
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
@@ -294,17 +290,25 @@ func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
}
}
func TestScanFileAllowsReadableBase64Lookalike(t *testing.T) {
got := ScanFile("docs/config.md", []byte("client_secret=placeholder=\n"))
if findingRules(got)["public_content_generic_credential"] {
t.Fatalf("readable base64 lookalike should not be a credential finding: %#v", got)
}
}
func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
jsonToken := "real-json-token"
jsonSecret := "real " + "secret value"
jsonKey := "real-json-key"
jsonTenantToken := "real-tenant-json-token"
jsonAppSecret := "real-app-secret"
jsonPrefixedKey := "real-prefixed-key"
jsonTenantCamelToken := "real-tenant-camel-token"
jsonGithubToken := "real-github-token"
jsonVendorKey := "real-vendor-key"
jsonSlackBotToken := "xoxb-real-token"
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
jsonToken := providerValue
jsonSecret := providerValue
jsonKey := providerValue
jsonTenantToken := providerValue
jsonAppSecret := providerValue
jsonPrefixedKey := providerValue
jsonTenantCamelToken := providerValue
jsonGithubToken := providerValue
jsonVendorKey := providerValue
jsonSlackBotToken := "xoxb_" + "1234567890abcdef"
got := ScanFile("docs/public.json", []byte(strings.Join([]string{
`{"access_` + `token":"` + jsonToken + `"}`,
`{"client_` + `secret": "` + jsonSecret + `"}`,
@@ -334,12 +338,13 @@ func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
}
func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY_OPENAI: real-openai-key",
"TOKEN_GITHUB: real-github-token",
"CLIENT_SECRET_GOOGLE: real-google-secret",
"SECRET_KEY_BASE: real-secret-key-base",
"APP_PASSWORD_PROD: real-prod-password",
"API_KEY_OPENAI: " + providerValue,
"TOKEN_GITHUB: " + providerValue,
"CLIENT_SECRET_GOOGLE: " + providerValue,
"SECRET_KEY_BASE: " + providerValue,
"APP_PASSWORD_PROD: " + providerValue,
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -347,13 +352,7 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
continue
}
count++
for _, forbidden := range []string{
"real-openai-key",
"real-github-token",
"real-google-secret",
"real-secret-key-base",
"real-prod-password",
} {
for _, forbidden := range []string{providerValue} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
@@ -364,85 +363,77 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
}
}
func TestScanFileDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
func TestScanFileAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY_OPENAI: prod_key",
"CLIENT_SECRET_GOOGLE: prod_secret",
"TOKEN_GITHUB: github_token",
"APP_PASSWORD_PROD: prod_password",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
}
}
if count != 4 {
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY: <" + stripeLike + ">",
"SECRET_TOKEN: <" + patLike + ">",
"CLIENT_SECRET: <real-client-secret-value>",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
cases := []struct {
name string
text string
want bool
}{
{name: "stripe", text: "API_KEY: <" + stripeLike + ">", want: true},
{name: "github", text: "SECRET_TOKEN: <" + patLike + ">", want: true},
{name: "readable", text: "CLIENT_SECRET: <real-client-secret-value>", want: false},
}
if count != 3 {
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
})
}
}
func TestScanFileDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
got := ScanFile("docs/public.json", []byte(strings.Join([]string{
`{"access_token_expires_in":"` + patLike + `"}`,
`{"refresh_token_expires_in":"` + stripeLike + `"}`,
`{"client_secret_status":"real-client-secret-value"}`,
`{"client_secret_name":"real-client-secret-value"}`,
`{"app_token":"` + patLike + `"}`,
`{"sync_token":"` + stripeLike + `"}`,
`{"target_token":"real-client-secret-value"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
cases := []struct {
name string
text string
want bool
}{
{name: "expiry provider token", text: `{"access_token_expires_in":"` + patLike + `"}`, want: true},
{name: "expiry provider secret", text: `{"refresh_token_expires_in":"` + stripeLike + `"}`, want: true},
{name: "status readable", text: `{"client_secret_status":"real-client-secret-value"}`, want: false},
{name: "name readable", text: `{"client_secret_name":"real-client-secret-value"}`, want: false},
{name: "app provider token", text: `{"app_token":"` + patLike + `"}`, want: true},
{name: "sync provider secret", text: `{"sync_token":"` + stripeLike + `"}`, want: true},
{name: "target readable", text: `{"target_token":"real-client-secret-value"}`, want: false},
}
if count != 7 {
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertGenericCredentialFinding(t, "docs/public.json", tc.text, tc.want)
})
}
}
func TestScanFileDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
func TestScanFileAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY_NAME: prod_key",
"CLIENT_SECRET_NAME: prod_secret",
"SECRET_STATUS: prod_secret",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
}
}
if count != 3 {
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
}
}
func TestScanFileDetectsAccessKeyCredentials(t *testing.T) {
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"AWS_ACCESS_KEY_ID: " + accessKey,
"ACCESS_KEY_ID: " + accessKey,
@@ -593,18 +584,18 @@ func TestScanFileAllowsCredentialReferenceValues(t *testing.T) {
func TestScanFileDetectsMalformedGithubExpressionCredentialValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY=${{" + stripeLike + "}}",
"TOKEN=${{real-secret-token-value}}",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
cases := []struct {
name string
text string
want bool
}{
{name: "provider", text: "API_KEY=${{" + stripeLike + "}}", want: true},
{name: "readable", text: "TOKEN=${{real-secret-token-value}}", want: false},
}
if count != 2 {
t.Fatalf("malformed GitHub expression credential findings = %d, want 2: %#v", count, got)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
})
}
}
@@ -648,6 +639,7 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
`proxy := "http://user:pass@proxy:8080"`,
`proxy := "http://user:p%40ss@proxy:8080/path"`,
`repo := "https://u:t@h/r.git"`,
`target := "https://attacker:pw@open.feishu.cn"`,
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
@@ -821,26 +813,36 @@ func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *tes
}
}
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
func TestScanFileAllowsStrongAuthTokenKeysWithoutStrongValueEvidence(t *testing.T) {
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
`{"access_token":"img_abc123"}`,
`{"api_token":"img_live_secret"}`,
`{"service_token":"ab********cd"}`,
`{"bot_token":"board_v3_example"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
t.Fatalf("token field names alone should not produce findings: %#v", got)
}
}
if count != 4 {
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(strings.Join([]string{
`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`,
`cfg := &core.CliConfig{AppID: "a", AppSecret: "s"}`,
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600)`,
`rt := &stubRoundTripper{respBody: ` + "`" + `{"access_token":"t","token_type":"Bearer"}` + "`" + `}`,
`envContent := "FEISHU_APP_ID=cli_hermes_abc\nFEISHU_APP_SECRET=hermes_secret_123\nFEISHU_DOMAIN=lark\n"`,
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_auto\nFEISHU_APP_SECRET=auto_secret\n"), 0600)`,
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_new_app\nFEISHU_APP_SECRET=new_secret\n"), 0600)`,
`if got := out.String(); got != "username=x-access-token\npassword=valid-pat\n\n" {`,
`if got := out.String(); got != "username=x-access-token\npassword=restored-pat\n\n" {`,
`if got := stdout.String(); got != "username=x-access-token\npassword=pat-token\n\n" {`,
`return &core.CliConfig{AppID: "dummy", AppSecret: "dummy"}`,
`os.WriteFile(path, []byte("API_KEY=replace-me\n"), 0600)`,
`body := "APP_ID=\"cli_xxxxx\"\nAPP_SECRET=\"xxxxx\"\n"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
@@ -848,8 +850,114 @@ func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
}
}
func TestScanFileAllowsCredentialIdentifierFields(t *testing.T) {
got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
`"api_key_id": "k1",`,
`"secret_id": "s1",`,
`"token_id": "t1",`,
`"private_key_id": "pk1",`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("credential identifier fields should not be credential findings: %#v", got)
}
}
}
func TestScanFileDetectsCredentialShapedIdentifierFieldValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
`"api_key_id": "` + stripeLike + `",`,
`"token_id": "` + githubToken + `",`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 2 {
t.Fatalf("credential-shaped identifier field findings = %d, want 2: %#v", count, got)
}
}
func TestCredentialShapedValueTrimsWhitespaceBeforeDelimiters(t *testing.T) {
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
if !credentialShapedValue(` "` + providerValue + `" `) {
t.Fatal("space-padded quoted provider credential should be recognized")
}
}
func TestScanFileDetectsProviderCredentialsAcrossAssignmentSyntaxes(t *testing.T) {
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
tests := []struct {
name string
path string
text string
}{
{name: "Go raw string", path: "pkg/config.go", text: "const clientSecret = `" + providerValue + "`"},
{name: "TypeScript template literal", path: "pkg/config.ts", text: "const clientSecret = `" + providerValue + "`;"},
{name: "shell backtick", path: "scripts/config.sh", text: "client_secret=`" + providerValue + "`"},
{name: "YAML string tag", path: "docs/config.yaml", text: "client_secret: !!str " + providerValue},
{name: "YAML string tag double quoted", path: "docs/config.yaml", text: `client_secret: !!str "` + providerValue + `"`},
{name: "YAML string tag single quoted", path: "docs/config.yaml", text: `client_secret: !!str '` + providerValue + `'`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ScanFile(tt.path, []byte(tt.text+"\n"))
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("provider credential should be reported: %#v", got)
}
})
}
}
func TestScanFileDetectsPercentEncodedProviderCredential(t *testing.T) {
providerBody := strings.Join([]string{"1234567890abcdef", "1234567890abcdef", "1234"}, "")
tests := []string{
"access_token: ghp%" + "5F" + providerBody,
"access_token_hash: ghp%" + "255F" + providerBody,
}
for _, text := range tests {
got := ScanFile("docs/config.yaml", []byte(text+"\n"))
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("percent-encoded provider credential should be reported: %#v", got)
}
}
}
func TestScanFileRequiresCompleteProviderCredentialFormats(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"token_type: asian",
"token_prefix: ASIA",
"token_prefix: ghp_",
"api_key: sk_live_example",
"token_prefix: asianmarketsegment01",
"token_prefix: ghp_placeholder_value",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("incomplete provider prefixes should not be credential findings: %#v", got)
}
}
}
func TestScanFileAllowsEncodedTokenMetadataURL(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte("token_url: https%3A%2F%2Fexample.invalid/oauth/token\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("encoded token metadata URL should not be credential finding: %#v", got)
}
}
}
func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
got := ScanFile("fixtures/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
got := ScanFile("fixtures/minutes_detail.go", []byte(strings.Join([]string{
"var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)",
"REALISTIC_TOKEN_RE=\"\\\"${TOKEN_BODY}\\\"|\\`${TOKEN_BODY}\\`|\\\\b${TOKEN_BODY}\\\\b\"",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("regexp token validator should not be credential finding: %#v", got)
@@ -927,6 +1035,22 @@ func TestScanFileAllowsSourceCodeCredentialNonSecretLiterals(t *testing.T) {
}
}
func TestScanFileAllowsSourceCodeSyntheticCredentialIdentifiers(t *testing.T) {
got := ScanFile("fixtures/sheets_media.go", []byte(strings.Join([]string{
`const fakeOfficeTokenPrefix = "fake_office_"`,
`const localOfficeTokenPrefix = "local_office_"`,
`const imageLiveSecretMarker = "img_live_secret"`,
`const imageProdKeyMarker = "img_prod_key"`,
`if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {`,
`if strings.HasPrefix(spreadsheetToken, localOfficeTokenPrefix) {`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("source code token prefix references should not be credential findings: %#v", got)
}
}
}
func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
`app_secret=***`,
@@ -941,22 +1065,18 @@ func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
}
}
func TestScanFileDetectsPartiallyMaskedCredentialValues(t *testing.T) {
func TestScanFileAllowsPartiallyMaskedCredentialValues(t *testing.T) {
got := ScanFile("fixtures/config.md", []byte(strings.Join([]string{
"client_secret=realprefix***realsuffix",
"client_secret=ab********cd",
"access_token=ab********cd",
"refresh_token=realprefix********realsuffix",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
t.Fatalf("partially masked values should not be credential findings: %#v", got)
}
}
if count != 4 {
t.Fatalf("partially masked credential findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
@@ -972,6 +1092,7 @@ func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
}
func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
cases := []struct {
name string
file string
@@ -980,32 +1101,47 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
{
name: "typescript simple secret",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string = "real-client-secret-value"`,
text: `const clientSecret: string = "` + providerValue + `"`,
},
{
name: "typescript numeric password",
name: "typescript terminated secret",
file: "fixtures/source_secret.ts",
text: `const password: string = "12345678901234567890"`,
text: `const clientSecret: string = "` + providerValue + `";`,
},
{
name: "typescript secret with trailing comment",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string = "` + providerValue + `"; // production`,
},
{
name: "typescript asserted secret",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string = "` + providerValue + `" as const;`,
},
{
name: "typescript provider password",
file: "fixtures/source_secret.ts",
text: `const password: string = "` + providerValue + `"`,
},
{
name: "typescript union secret",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string | undefined = "real-client-secret-value"`,
text: `const clientSecret: string | undefined = "` + providerValue + `"`,
},
{
name: "python simple secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: str = "real-client-secret-value"`,
text: `self.client_secret: str = "` + providerValue + `"`,
},
{
name: "python union secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: str | None = "real-client-secret-value"`,
text: `self.client_secret: str | None = "` + providerValue + `"`,
},
{
name: "python optional secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: Optional[str] = "real-client-secret-value"`,
text: `self.client_secret: Optional[str] = "` + providerValue + `"`,
},
}
for _, tc := range cases {
@@ -1018,24 +1154,154 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
}
}
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
`const ClientSecret = "real-client-secret-value"`,
`const GithubToken = "` + githubToken + `"`,
`const Password = "12345678901234567890"`,
`const ClientSecretNumber = "12345678901234567890"`,
`const ClientSecretFormat = "abc%sdefreal"`,
`fmt.Println("done"); const ClientSecret = "abc%sdefreal"`,
}, "\n")+"\n"))
func TestScanFileDetectsRepeatedTypedCredentialAssignments(t *testing.T) {
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
assertGenericCredentialFinding(t, "fixtures/source_secret.ts", `const clientSecret: string = "placeholder";`, false)
assertGenericCredentialFinding(t, "fixtures/source_secret.ts", `const clientSecret: string = "`+providerValue+`";`, true)
got := ScanFile("fixtures/source_secret.ts", []byte(
`const clientSecret: string = "placeholder"; const clientSecret: string = "`+providerValue+`";`+"\n",
))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 6 {
t.Fatalf("source code credential-shaped literal findings = %d, want 6: %#v", count, got)
if count != 1 {
t.Fatalf("repeated typed credential findings = %d, want 1: %#v", count, got)
}
}
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
cases := []struct {
name string
text string
want bool
}{
{name: "stripe", text: `const ClientSecret = "` + stripeLike + `"`, want: true},
{name: "github", text: `const GithubToken = "` + githubToken + `"`, want: true},
{name: "password number", text: `const Password = "12345678901234567890"`, want: false},
{name: "secret number", text: `const ClientSecretNumber = "12345678901234567890"`, want: false},
{name: "format literal", text: `const ClientSecretFormat = "abc%sdefreal"`, want: false},
{name: "inline format literal", text: `fmt.Println("done"); const ClientSecret = "abc%sdefreal"`, want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertGenericCredentialFinding(t, "fixtures/source_secret.go", tc.text, tc.want)
})
}
}
func TestScanFileDetectsGoShortDeclarationCredentials(t *testing.T) {
providerSecret := "sk_" + "live_1234567890abcdef"
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
`clientSecret := "` + providerSecret + `"`,
`accessToken := "` + providerToken + `"`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 2 {
t.Fatalf("Go short declaration credential findings = %d, want 2: %#v", count, got)
}
}
func TestGenericCredentialDecisionMatrix(t *testing.T) {
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
highEntropyValue := "Q7k2mN9pR4vX8cL3" + "sT6yU1aD5fG0hJ2z"
tokenHash := "6f1ed002ab559585" + "9014ebf0951522d9" +
"a0e3c1f4206254d" + "28a13efbbc8d56a30"
tests := []struct {
name string
path string
text string
comment bool
want bool
}{
{name: "source synthetic token prefix", path: "pkg/sheets.go", text: `const localOfficeTokenPrefix = "local_office_"`, want: false},
{name: "source token kind state", path: "pkg/client.py", text: `self._token_kind: TokenKind | None = None`, want: false},
{name: "documentation token prefix", path: "docs/config.yaml", text: `token_prefix: local_office_`, want: false},
{name: "documentation token kind", path: "docs/config.yaml", text: `token_kind: bearer`, want: false},
{name: "documentation token hash", path: "docs/config.yaml", text: `access_token_hash: ` + tokenHash, want: false},
{name: "comment fixture placeholder", text: `AppSecret: "fake-secret"`, comment: true, want: false},
{name: "test fixture placeholder", path: "pkg/config_test.go", text: `AppSecret: "fake-secret"`, want: false},
{name: "test real-labeled token", path: "pkg/config_test.go", text: `token: "real-tenant-access-token"`, want: false},
{name: "test ambiguous concrete secret word", path: "pkg/config_test.go", text: `AppSecret: "supersecret"`, want: false},
{name: "resource token placeholder", path: "docs/images.md", text: `"token": "img_abc123"`, want: false},
{name: "partially masked token", path: "docs/auth.md", text: `token=ab********cd`, want: false},
{name: "source readable secret words", path: "pkg/config.go", text: `const AppSecret = "customer-prod-secret"`, want: false},
{name: "documentation readable secret words", path: "docs/config.yaml", text: `client_secret: customer-prod-secret`, want: false},
{name: "comment middle fixture marker", text: `API_KEY=prod-fake-key`, comment: true, want: false},
{name: "comment negated fixture marker", text: `AppSecret: "not-fake-secret"`, comment: true, want: false},
{name: "source with credential words", path: "pkg/config.go", text: `secretWithPassword := "hunter2"`, want: false},
{name: "production filename containing sample", path: "pkg/sampler.go", text: `clientSecret := "customer-prod-secret"`, want: false},
{name: "provider token under weak key", path: "docs/config.yaml", text: `token: ` + providerToken, want: true},
{name: "provider token under hash key", path: "docs/config.yaml", text: `access_token_hash: ` + providerToken, want: true},
{name: "high entropy strong secret", path: "docs/config.yaml", text: `client_secret: ` + highEntropyValue, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got []Finding
if tt.comment {
got = ScanComment("issue_comment", tt.text)
} else {
got = ScanFile(tt.path, []byte(tt.text+"\n"))
}
if actual := findingRules(got)["public_content_generic_credential"]; actual != tt.want {
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, tt.want, got)
}
})
}
}
func TestScanFileClassifiesLowEvidenceTestFixtureCredentials(t *testing.T) {
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
highEntropyValue := "Q7k2mN9pR4vX8cL3" + "sT6yU1aD5fG0hJ2z"
tests := []struct {
name string
value string
want bool
}{
{name: "human readable access token", value: "user-access-token", want: false},
{name: "delimited secret value", value: "secret-value", want: false},
{name: "underscored secret fixture", value: "plain_secret", want: false},
{name: "short delimited fixture", value: "t-abc", want: false},
{name: "embedded test marker", value: "perm-grant-test-secret-skip", want: false},
{name: "real labeled fixture", value: "real-token", want: false},
{name: "ambiguous concrete word", value: "supersecret", want: false},
{name: "provider token", value: providerToken, want: true},
{name: "high entropy secret", value: highEntropyValue, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ScanFile("pkg/config_test.go", []byte(`AppSecret: "`+tt.value+`"`+"\n"))
if actual := findingRules(got)["public_content_generic_credential"]; actual != tt.want {
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, tt.want, got)
}
})
}
}
func TestScanFileAllowsLowEvidenceTestFixtureAssignmentSyntaxes(t *testing.T) {
got := ScanFile("pkg/config_test.go", []byte(strings.Join([]string{
`secret := "secret-value"`,
`samplePassword := "sample-password"`,
`bodyWithToken := "plain text body\\nDownload: https://example.com/file?token=tok_aaa\\n"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("low-evidence test fixture assignment should not be reported: %#v", got)
}
}
}
@@ -1116,9 +1382,10 @@ func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
`{"client_token":"` + stripeLike + `"}`,
`{"client_token":"real-client-secret-value"}`,
`{"client_token":"` + githubToken + `"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -1152,9 +1419,10 @@ func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
`{ "resource_token": "` + stripeLike + `" }`,
`{ "block_token": "real-client-secret-value" }`,
`{ "block_token": "` + githubToken + `" }`,
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -1368,39 +1636,43 @@ func TestScanFileAllowsConventionalCredentialPlaceholders(t *testing.T) {
}
}
func TestScanFileDetectsCredentialShapedPlaceholderLookalikes(t *testing.T) {
func TestScanFileAllowsInvalidProviderPlaceholderLookalikes(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
"client_secret: " + stripeLike + "_HERE",
"api_key: YOUR_" + stripeLike,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
t.Fatalf("invalid provider placeholder lookalike should not be blocked: %#v", got)
}
}
if count != 2 {
t.Fatalf("credential-shaped placeholder lookalike findings = %d, want 2: %#v", count, got)
}
}
func TestScanFileDetectsPercentWrappedCredentialValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
"CLIENT_SECRET=%" + stripeLike + "%",
"GITHUB_TOKEN=%" + patLike + "%",
"TOKEN=%real-secret-token-value%",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
cases := []struct {
name string
text string
want bool
}{
{name: "stripe", text: "CLIENT_SECRET=%" + stripeLike + "%", want: true},
{name: "github", text: "GITHUB_TOKEN=%" + patLike + "%", want: true},
{name: "readable", text: "TOKEN=%real-secret-token-value%", want: false},
}
if count != 3 {
t.Fatalf("percent-wrapped credential findings = %d, want 3: %#v", count, got)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertGenericCredentialFinding(t, "docs/config.md", tc.text, tc.want)
})
}
}
func assertGenericCredentialFinding(t *testing.T, file, text string, want bool) {
t.Helper()
got := ScanFile(file, []byte(text+"\n"))
if actual := findingRules(got)["public_content_generic_credential"]; actual != want {
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, want, got)
}
}

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"
)
@@ -203,7 +203,8 @@ func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
if err := vfs.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
t.Fatal(err)
}
publicDoc := "api_" + "key = \"example-public-key\"\n" +
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
publicDoc := "api_" + "key = \"" + providerValue + "\"\n" +
"Public docs describe a pri" + "vate request header and trust classification detail.\n"
if err := vfs.WriteFile(filepath.Join(repo, "docs", "public.md"), []byte(publicDoc), 0o644); err != nil {
t.Fatal(err)
@@ -599,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

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

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

@@ -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,55 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package gitcmd provides Git process helpers for tests that use temporary
// repositories.
package gitcmd
import (
"os"
"os/exec"
"strconv"
"testing"
)
const (
maintenanceAutoDetach = "maintenance.autoDetach"
gcAutoDetach = "gc.autoDetach"
)
// Command creates a Git command whose automatic maintenance stays in the
// command lifecycle, so temporary repository cleanup cannot race a detached
// maintenance process.
func Command(dir string, args ...string) *exec.Cmd {
commandArgs := make([]string, 0, len(args)+4)
commandArgs = append(commandArgs,
"-c", maintenanceAutoDetach+"=false",
"-c", gcAutoDetach+"=false",
)
commandArgs = append(commandArgs, args...)
cmd := exec.Command("git", commandArgs...)
cmd.Dir = dir
return cmd
}
// SetSynchronousMaintenanceEnv applies the same lifecycle contract to every
// Git process started by the current test, including processes created through
// production command runners. Tests using it must not run in parallel.
func SetSynchronousMaintenanceEnv(t *testing.T) {
t.Helper()
count := 0
if value, ok := os.LookupEnv("GIT_CONFIG_COUNT"); ok {
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 0 {
t.Fatalf("invalid GIT_CONFIG_COUNT %q", value)
}
count = parsed
}
for _, key := range []string{maintenanceAutoDetach, gcAutoDetach} {
index := strconv.Itoa(count)
t.Setenv("GIT_CONFIG_KEY_"+index, key)
t.Setenv("GIT_CONFIG_VALUE_"+index, "false")
count++
}
t.Setenv("GIT_CONFIG_COUNT", strconv.Itoa(count))
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package gitcmd
import (
"os/exec"
"strings"
"testing"
)
func TestCommandDisablesDetachedMaintenance(t *testing.T) {
for _, key := range []string{"maintenance.autoDetach", "gc.autoDetach"} {
cmd := Command(t.TempDir(), "config", "--get", "--type=bool", key)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git config %s: %v\n%s", key, err, out)
}
if got := strings.TrimSpace(string(out)); got != "false" {
t.Fatalf("%s = %q, want false", key, got)
}
}
}
func TestSetSynchronousMaintenanceEnv(t *testing.T) {
t.Setenv("GIT_CONFIG_COUNT", "1")
t.Setenv("GIT_CONFIG_KEY_0", "user.name")
t.Setenv("GIT_CONFIG_VALUE_0", "Existing Test User")
SetSynchronousMaintenanceEnv(t)
for key, want := range map[string]string{
"user.name": "Existing Test User",
maintenanceAutoDetach: "false",
gcAutoDetach: "false",
} {
cmd := exec.Command("git", "config", "--get", "--type=bool", key)
if key == "user.name" {
cmd = exec.Command("git", "config", "--get", key)
}
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git config %s: %v\n%s", key, err, out)
}
if got := strings.TrimSpace(string(out)); got != want {
t.Fatalf("%s = %q, want %q", key, got, want)
}
}
}

View File

@@ -34,7 +34,12 @@ func writeFixture(t *testing.T, files fixtureRepo) string {
func runGit(t *testing.T, root string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
commandArgs := []string{
"-c", "maintenance.autoDetach=false",
"-c", "gc.autoDetach=false",
}
commandArgs = append(commandArgs, args...)
cmd := exec.Command("git", commandArgs...)
cmd.Dir = root
out, err := cmd.CombinedOutput()
if err != nil {
@@ -43,6 +48,14 @@ func runGit(t *testing.T, root string, args ...string) string {
return strings.TrimSpace(string(out))
}
func TestRunGitDisablesDetachedMaintenance(t *testing.T) {
for _, key := range []string{"maintenance.autoDetach", "gc.autoDetach"} {
if got := runGit(t, t.TempDir(), "config", "--get", "--type=bool", key); got != "false" {
t.Fatalf("%s = %q, want false", key, got)
}
}
}
func TestLoadSubtypeAllowlist_ExtractsTypedConstValues(t *testing.T) {
root := writeFixture(t, fixtureRepo{
"errs/subtypes.go": `package errs

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.70",
"version": "1.0.74",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -18,6 +18,11 @@ workflow_permissions="$(awk '
in_permissions && /^[^[:space:]]/ { exit }
in_permissions { print }
' "$workflow")"
workflow_concurrency="$(awk '
/^concurrency:/ { in_concurrency = 1; print; next }
in_concurrency && /^[^[:space:]]/ { exit }
in_concurrency { print }
' "$workflow")"
fast_gate_section="$(job_section fast-gate)"
unit_test_section="$(job_section unit-test)"
lint_section="$(awk '
@@ -46,6 +51,27 @@ results_section="$(awk '
in_job { print }
' "$workflow")"
fork_safe_guard="github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork"
live_job_condition="always() && ($fork_safe_guard) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != ''"
if ! grep -Fq "run-name: \${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}" "$workflow"; then
echo "CI should expose a stable PR generation while preserving default push and manual run titles" >&2
exit 1
fi
if ! grep -Fq "RUN_GENERATION: \${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}" <<<"$section"; then
echo "the supersession generation should match the PR-only run name" >&2
exit 1
fi
if ! grep -Fq 'group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}' <<<"$workflow_concurrency"; then
echo "CI should deduplicate runs for the same pull request without grouping push or manual runs" >&2
exit 1
fi
if ! grep -Fq "cancel-in-progress: \${{ github.event_name == 'pull_request' }}" <<<"$workflow_concurrency"; then
echo "CI should cancel superseded pull request runs but preserve push and manual runs" >&2
exit 1
fi
for denied_permission in "checks: write" "pull-requests: write" "issues: write"; do
if grep -Eq "^[[:space:]]*${denied_permission}$" <<<"$workflow_permissions"; then
@@ -210,8 +236,84 @@ if ! grep -Fq "deterministic-gate" <<<"$results_section"; then
exit 1
fi
if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
echo "e2e-live should run on push and same-repository pull_request, but skip fork pull_request"
if ! grep -Fq "if: \${{ $live_job_condition }}" <<<"$section"; then
echo "e2e-live should preserve active cleanup while requiring a successful non-skip dry run and excluding fork pull requests"
exit 1
fi
if ! grep -Fq "needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]" <<<"$section"; then
echo "e2e-live should wait outside the exclusive queue until e2e-dry-run finishes"
exit 1
fi
if ! grep -Fq "timeout-minutes: 20" <<<"$dry_run_section"; then
echo "e2e-dry-run should bound the planning gate before live E2E" >&2
exit 1
fi
if ! grep -Fq "timeout-minutes: 30" <<<"$section"; then
echo "e2e-live should release the repository-wide slot after 30 minutes" >&2
exit 1
fi
if ! grep -Fq "group: lark-cli-e2e-live" <<<"$section"; then
echo "e2e-live should use one repository-wide execution slot" >&2
exit 1
fi
if ! grep -Fq "cancel-in-progress: false" <<<"$section"; then
echo "e2e-live should queue waiting runs instead of cancelling an active live test" >&2
exit 1
fi
if ! grep -Fq "queue: max" <<<"$section"; then
echo "e2e-live should preserve queued runs instead of replacing an existing pending run" >&2
exit 1
fi
if ! grep -Fq "actions: read" <<<"$section"; then
echo "e2e-live should use read-only Actions access for the supersession check" >&2
exit 1
fi
live_test_step="$(awk '
/^ - name: Run CLI E2E tests/ { in_step = 1 }
in_step { print }
in_step && /^ - name: Publish CLI E2E test report/ { exit }
' <<<"$section")"
if ! grep -Fq "if: \${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}" <<<"$live_test_step"; then
echo "the active live test step should survive ordinary workflow supersession only after setup succeeds" >&2
exit 1
fi
for required in \
'gh api "repos/$REPOSITORY/actions/runs/$RUN_ID"' \
'gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs"' \
'-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100' \
'.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number' \
'::error::Superseded before live E2E started' \
'exit 1'; do
if ! grep -Fq -- "$required" <<<"$live_test_step"; then
echo "the live startup check should fail closed before a superseded run starts live E2E: missing $required" >&2
exit 1
fi
done
if ! awk '
/if \[ -n "\$newer_runs" \]; then/ { superseded_state = 1; next }
superseded_state == 1 && /::error::Superseded before live E2E started/ { superseded_state = 2; next }
superseded_state == 2 && /^[[:space:]]+exit 1[[:space:]]*$/ { superseded_state = 3; next }
superseded_state > 0 && /^[[:space:]]+fi[[:space:]]*$/ {
if (superseded_state != 3) exit 2
superseded_closed = 1
superseded_state = 0
next
}
/go run gotest.tools\/gotestsum@/ { test_started = 1; if (!superseded_closed) exit 3 }
END { exit superseded_closed && test_started ? 0 : 1 }
' <<<"$live_test_step"; then
echo "a superseded live run must stop before gotestsum starts" >&2
exit 1
fi
@@ -222,6 +324,39 @@ if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
exit 1
fi
for output in \
'mode: ${{ steps.e2e_domains.outputs.mode }}' \
'reason: ${{ steps.e2e_domains.outputs.reason }}' \
'live_packages: ${{ steps.e2e_domains.outputs.live_packages }}'; do
if ! grep -Fq "$output" <<<"$dry_run_section"; then
echo "e2e-dry-run should publish $output for the live job" >&2
exit 1
fi
done
for validation_contract in \
'case "$E2E_MODE" in' \
'skip)' \
'[ -z "$E2E_LIVE_PACKAGES" ]' \
'full|subset)' \
'[ -n "$E2E_LIVE_PACKAGES" ]' \
'Invalid CLI E2E mode' \
'exit 1'; do
if ! grep -Fq "$validation_contract" <<<"$dry_run_section"; then
echo "e2e-dry-run should fail invalid domain output before live can be skipped: missing $validation_contract" >&2
exit 1
fi
done
if ! awk '
/- name: Validate CLI E2E domain outputs/ { validated = 1 }
/- name: Build lark-cli/ { exit validated ? 0 : 1 }
END { if (!validated) exit 1 }
' <<<"$dry_run_section"; then
echo "e2e-dry-run should validate domain outputs before building" >&2
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
exit 1
@@ -244,21 +379,21 @@ if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
exit 1
fi
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
! grep -Fq "id: e2e_domains" <<<"$section" ||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
if grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
echo "e2e-live should reuse e2e-dry-run outputs instead of resolving domains again"
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
echo "e2e-live should use resolved live_packages instead of always running the full suite"
if ! grep -Fq "E2E_LIVE_PACKAGES: \${{ needs.e2e-dry-run.outputs.live_packages }}" <<<"$section"; then
echo "e2e-live should reuse live_packages resolved by e2e-dry-run"
exit 1
fi
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
if ! grep -Fq "E2E_MODE: \${{ needs.e2e-dry-run.outputs.mode }}" <<<"$section" ||
! grep -Fq "E2E_REASON: \${{ needs.e2e-dry-run.outputs.reason }}" <<<"$section" ||
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
echo "e2e-live should pass dynamic domain output through env before shell use"
echo "e2e-live should consume the exact mode and reason produced by e2e-dry-run"
exit 1
fi
@@ -272,16 +407,23 @@ if ! awk '
exit 1
fi
if ! awk '
/^ - name: Build lark-cli/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should skip building lark-cli when domain mode is skip"
if grep -Fq "steps.e2e_domains.outputs" <<<"$section"; then
echo "e2e-live should not retain step-local domain outputs after adopting the dry-run job gate"
exit 1
fi
for step_name in "Build lark-cli" "Prepare shared live E2E tenant token"; do
live_setup_step="$(awk -v name="$step_name" '
$0 == " - name: " name { in_step = 1 }
in_step { print }
in_step && /^ - name:/ && $0 != " - name: " name { exit }
' <<<"$section")"
if grep -Eq '^ if:' <<<"$live_setup_step"; then
echo "e2e-live $step_name should run unconditionally after the non-skip job gate" >&2
exit 1
fi
done
if ! grep -Fq "permissions:" <<<"$section" ||
! grep -Fq "contents: read" <<<"$section" ||
! grep -Fq "checks: write" <<<"$section"; then
@@ -299,18 +441,88 @@ if grep -Fq "live_e2e_credentials" <<<"$section" || grep -Fq "configured=false"
exit 1
fi
if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET" <<<"$section"; then
echo "e2e-live should make missing bot credentials a visible configuration failure on eligible runs"
if ! grep -Fq "node scripts/fetch_e2e_tat.js" <<<"$section"; then
echo "e2e-live should fetch the tenant token via the dedicated script"
exit 1
fi
if grep -Fq "config init" <<<"$section"; then
echo "e2e-live should use env credentials instead of config init"
exit 1
fi
if ! grep -Fq "TEST_BOT1_APP_ID: \${{ secrets.TEST_BOT1_APP_ID }}" <<<"$section"; then
echo "e2e-live should keep the bot app id under a test-only job env name"
exit 1
fi
if awk '
/^ e2e-live:/ { in_job = 1; next }
in_job && /^ [A-Za-z0-9_-]+:/ { in_job = 0 }
in_job && /^ env:/ { in_env = 1; next }
in_env && /^ steps:/ { in_env = 0 }
in_env && /LARKSUITE_CLI_APP_ID:/ { found_standard_app_id = 1 }
END { exit found_standard_app_id ? 0 : 1 }
' "$workflow"; then
echo "e2e-live should not activate the env credential provider at job scope"
exit 1
fi
if ! grep -Fq "LARKSUITE_CLI_BRAND: feishu" <<<"$section"; then
echo "e2e-live should pin the env credential brand to feishu"
exit 1
fi
if awk '
/^ e2e-live:/ { in_job = 1; next }
in_job && /^ [A-Za-z0-9_-]+:/ { in_job = 0 }
in_job && /^ env:/ { in_env = 1; next }
in_env && /^ steps:/ { in_env = 0 }
in_env && /(SECRET|ACCESS_TOKEN):/ { found_sensitive = 1 }
END { exit found_sensitive ? 0 : 1 }
' "$workflow"; then
echo "e2e-live should not expose live E2E credentials through job-level env"
exit 1
fi
if ! awk '
/^ - name: Configure bot credentials/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
END { exit found ? 0 : 1 }
/^ - name: Prepare shared live E2E tenant token/ { in_step = 1 }
in_step && /id: live_e2e_tat/ { has_id = 1 }
in_step && /^ if:/ { has_if = 1 }
in_step && /LARKSUITE_CLI_APP_ID: \$\{\{ secrets\.TEST_BOT1_APP_ID \}\}/ { has_app_id = 1 }
in_step && /secrets\.TEST_BOT1_APP_SECRET/ { has_bot_credential = 1 }
in_step && /node scripts\/fetch_e2e_tat\.js/ { has_script = 1 }
in_step && /GITHUB_ENV/ { uses_github_env = 1 }
in_step && /^ - name:/ && !/Prepare shared live E2E tenant token/ { in_step = 0 }
END { exit has_id && !has_if && has_app_id && has_bot_credential && has_script && !uses_github_env ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should only configure bot credentials when domain mode is not skip"
echo "e2e-live should pass only a private tenant token file path through step output"
exit 1
fi
if ! awk '
/^ - name: Run CLI E2E tests/ { in_step = 1 }
in_step && /E2E_TENANT_AUTH_FILE: \$\{\{ steps\.live_e2e_tat\.outputs\.path \}\}/ { has_file = 1 }
in_step && /secrets\.TEST_USER_ACCESS_TOKEN/ { has_user_credential = 1 }
in_step && /Missing shared live E2E tenant token file/ { checks_file = 1 }
in_step && /^ *export / && /TEST_TENANT_ACCESS_TOKEN/ && /E2E_TENANT_AUTH_FILE/ { exports_test_tat = 1 }
in_step && /^ *export / && /LARKSUITE_CLI_TENANT_ACCESS_TOKEN/ { exports_standard_tat = 1 }
in_step && /LARKSUITE_CLI_APP_ID="\$TEST_BOT1_APP_ID"/ { scopes_preflight_app_id = 1 }
in_step && /LARKSUITE_CLI_TENANT_ACCESS_TOKEN="\$TEST_TENANT_ACCESS_TOKEN"/ { scopes_preflight_tat = 1 }
in_step && /lark-cli whoami --as bot/ { has_preflight = 1 }
in_step && /Tenant credential preflight failed/ { checks_preflight = 1 }
in_step && /TEST_USER_ACCESS_TOKEN/ && /secrets\.TEST_USER_ACCESS_TOKEN/ { has_user_env = 1 }
in_step && /LARKSUITE_CLI_USER_ACCESS_TOKEN/ && /secrets\.TEST_USER_ACCESS_TOKEN/ { has_global_user_env = 1 }
in_step && /trap / { has_trap = 1 }
in_step && /^ - name:/ && !/Run CLI E2E tests/ { in_step = 0 }
END { exit has_file && has_user_credential && checks_file && exports_test_tat && !exports_standard_tat && scopes_preflight_app_id && scopes_preflight_tat && has_preflight && checks_preflight && has_user_env && !has_global_user_env && !has_trap ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should expose live E2E credentials only inside the test shell step"
exit 1
fi
if grep -Fq 'if [ "$E2E_MODE" = "skip" ]' <<<"$section"; then
echo "e2e-live should not retain an unreachable step-level skip branch"
exit 1
fi
@@ -319,8 +531,8 @@ if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
exit 1
fi
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
exit 1
fi
@@ -342,7 +554,7 @@ if grep -Fq '${{ secrets.CODECOV_TOKEN }}' <<<"$coverage_step" &&
fi
if grep -Fq '${{ secrets.' <<<"$section" &&
! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
! grep -Fq "$fork_safe_guard" <<<"$section"; then
echo "live E2E secrets should be available on push and same-repository pull_request, but not fork pull_request" >&2
exit 1
fi

164
scripts/fetch_e2e_tat.js Normal file
View File

@@ -0,0 +1,164 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Fetches a live E2E tenant access token (TAT) for the shared bot identity.
//
// Invoked from the e2e-live CI job. Exchanges the bot app id/secret for a
// tenant access token, writes the token to a private file under $RUNNER_TEMP,
// and emits the file path as a step output so the test step can read it once
// and then delete it.
//
// The secret arrives via environment variables; the OAuth parameter names are
// literal because this is a source code file (.js), so the quality gate's
// benign-code-credential exemption applies to the process.env references.
const fs = require("node:fs");
const http = require("node:http");
const https = require("node:https");
const path = require("node:path");
const { URL } = require("node:url");
const ENDPOINT = process.env.E2E_TAT_ENDPOINT || "https://accounts.feishu.cn/oauth/v3/token";
const MAX_ATTEMPTS = 4;
const RETRY_BASE_MS = parseInt(process.env.E2E_TAT_RETRY_BASE_MS || "1000", 10);
function requireEnv(name) {
const value = process.env[name];
if (!value) {
console.error(`::error::Missing required environment variable: ${name}`);
process.exit(1);
}
return value;
}
function postForm(url, body) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const transport = parsed.protocol === "http:" ? http : https;
const req = transport.request(
parsed,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(body),
},
timeout: 20000,
},
(resp) => {
const chunks = [];
let settled = false;
const rejectOnce = (error) => {
if (!settled) {
settled = true;
reject(error);
}
};
resp.on("data", (chunk) => chunks.push(chunk));
resp.on("aborted", () => rejectOnce(new Error("response aborted before completion")));
resp.on("error", rejectOnce);
resp.on("close", () => {
if (!resp.complete) {
rejectOnce(new Error("response closed before completion"));
}
});
resp.on("end", () => {
if (!resp.complete) {
rejectOnce(new Error("response ended before completion"));
return;
}
settled = true;
resolve({
status: resp.statusCode,
body: Buffer.concat(chunks).toString("utf8"),
headers: resp.headers,
});
});
},
);
req.on("timeout", () => {
req.destroy();
reject(new Error("request timed out"));
});
req.on("error", reject);
req.write(body);
req.end();
});
}
function encodeForm(params) {
return Object.entries(params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join("&");
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchTenantToken() {
const appId = requireEnv("LARKSUITE_CLI_APP_ID");
const appSecret = requireEnv("TEST_BOT1_APP_SECRET");
const body = encodeForm({
grant_type: "client_credentials",
client_id: appId,
client_secret: appSecret,
});
let lastError = "";
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
const { status, body: respBody, headers } = await postForm(ENDPOINT, body);
let payload;
try {
payload = JSON.parse(respBody);
} catch {
const logID = headers["x-tt-logid"] || headers["x-request-id"] || "unavailable";
lastError = `HTTP ${status}, log_id=${logID}, non-JSON response`;
}
if (payload) {
const token = payload.access_token;
if (status === 200 && payload.code === 0 && token) {
return token;
}
lastError = `HTTP ${status}, code=${payload.code}, error=${payload.error}, msg=${payload.msg || payload.error_description}`;
}
} catch (err) {
lastError = err.message;
}
if (attempt < MAX_ATTEMPTS) {
await sleep(2 ** (attempt - 1) * RETRY_BASE_MS);
}
}
console.error(`::error::Failed to fetch tenant access token: ${lastError}`);
process.exit(1);
}
async function main() {
const token = await fetchTenantToken();
console.log(`::add-mask::${token}`);
const tatPath = path.join(process.env.RUNNER_TEMP, "e2e-live-tat");
fs.writeFileSync(tatPath, token, { encoding: "utf8", mode: 0o600 });
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `path=${tatPath}\n`);
}
console.log("Prepared shared live E2E tenant token");
}
if (require.main === module) {
main();
}
module.exports = {
encodeForm,
fetchTenantToken,
postForm,
requireEnv,
};

View File

@@ -0,0 +1,203 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const assert = require("node:assert/strict");
const fs = require("node:fs");
const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const test = require("node:test");
const scriptPath = path.join(__dirname, "fetch_e2e_tat.js");
function startServer(handler) {
const server = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
handler(req, res, body);
});
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const port = server.address().port;
resolve({ server, port });
});
});
}
function abortResponse(res) {
res.writeHead(200, {
"Content-Type": "application/json",
"Content-Length": "100",
});
res.write('{"code":0');
setImmediate(() => res.destroy());
}
function runScript(envOverrides) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fetch-e2e-tat-"));
const githubOutput = path.join(tmpDir, "github-output");
const env = {
...process.env,
LARKSUITE_CLI_APP_ID: "test_app_id",
TEST_BOT1_APP_SECRET: "test-secret",
RUNNER_TEMP: tmpDir,
GITHUB_OUTPUT: githubOutput,
E2E_TAT_RETRY_BASE_MS: "10",
...envOverrides,
};
return new Promise((resolve) => {
const child = spawn(process.execPath, [scriptPath], {
cwd: path.join(__dirname, ".."),
env,
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (data) => {
stdout += data;
});
child.stderr.on("data", (data) => {
stderr += data;
});
child.on("close", (code) => {
const output = fs.existsSync(githubOutput)
? fs.readFileSync(githubOutput, "utf8")
: "";
resolve({ tmpDir, stdout, stderr, output, exitCode: code });
});
});
}
test("encodeForm encodes form parameters", () => {
const { encodeForm } = require(scriptPath);
const result = encodeForm({
grant_type: "client_credentials",
client_id: "abc&def",
client_secret: "test-secret",
note: "x=y",
});
const params = new URLSearchParams(result);
assert.equal(params.get("grant_type"), "client_credentials");
assert.equal(params.get("client_id"), "abc&def");
assert.equal(params.get("client_secret"), "test-secret");
assert.equal(params.get("note"), "x=y");
});
test("exits with error when app id is missing", async () => {
const result = await runScript({ LARKSUITE_CLI_APP_ID: "" });
assert.notEqual(result.exitCode, 0);
assert.match(result.stderr, /Missing required environment variable: LARKSUITE_CLI_APP_ID/);
});
test("exits with error when app secret is missing", async () => {
const result = await runScript({ TEST_BOT1_APP_SECRET: "" });
assert.notEqual(result.exitCode, 0);
assert.match(result.stderr, /Missing required environment variable: TEST_BOT1_APP_SECRET/);
});
test("fetches token and writes it to a private file", async () => {
const { server, port } = await startServer((req, res, body) => {
assert.equal(req.method, "POST");
const params = new URLSearchParams(body);
assert.equal(params.get("grant_type"), "client_credentials");
assert.equal(params.get("client_id"), "test_app_id");
assert.equal(params.get("client_secret"), "test-secret");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ code: 0, access_token: "test-token" }));
});
try {
const result = await runScript({
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
});
assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
assert.ok(result.stdout.includes("::add-mask::test-token"));
assert.ok(result.stdout.includes("Prepared shared live E2E tenant token"));
const tatPath = path.join(result.tmpDir, "e2e-live-tat");
assert.ok(fs.existsSync(tatPath), "token file should exist");
const stat = fs.statSync(tatPath);
assert.equal(stat.mode & 0o777, 0o600, "token file should be owner-only");
assert.equal(fs.readFileSync(tatPath, "utf8"), "test-token");
assert.ok(
result.output.includes(`path=${tatPath}`),
"should write path to GITHUB_OUTPUT",
);
} finally {
server.close();
}
});
test("retries an interrupted response and then succeeds", async () => {
let requestCount = 0;
const { server, port } = await startServer((req, res) => {
requestCount++;
if (requestCount === 1) {
abortResponse(res);
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ code: 0, access_token: "test-token" }));
});
try {
const result = await runScript({
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
});
assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
assert.equal(requestCount, 2);
} finally {
server.close();
}
});
test("fails after every interrupted response is retried", async () => {
let requestCount = 0;
const { server, port } = await startServer((req, res) => {
requestCount++;
abortResponse(res);
});
try {
const result = await runScript({
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
});
assert.notEqual(result.exitCode, 0);
assert.equal(requestCount, 4);
assert.match(result.stderr, /Failed to fetch tenant access token/);
} finally {
server.close();
}
});
test("exits with error after all retries fail", async () => {
let requestCount = 0;
const { server, port } = await startServer((req, res) => {
requestCount++;
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ code: 500, error: "server error" }));
});
try {
const result = await runScript({
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
});
assert.notEqual(result.exitCode, 0);
assert.equal(requestCount, 4);
assert.match(result.stderr, /Failed to fetch tenant access token/);
} finally {
server.close();
}
});

View File

@@ -0,0 +1,469 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
const automationSkillDoc = "../../skills/lark-apps/references/lark-apps-automation.md"
const localDevSkillDoc = "../../skills/lark-apps/references/lark-apps-local-dev.md"
const larkAppsSkillDoc = "../../skills/lark-apps/SKILL.md"
const releaseGetSkillDoc = "../../skills/lark-apps/references/lark-apps-release-get.md"
func readAutomationSkillDoc(t *testing.T) string {
return readAppsSkillDoc(t, automationSkillDoc)
}
func readLocalDevSkillDoc(t *testing.T) string {
return readAppsSkillDoc(t, localDevSkillDoc)
}
func readReleaseGetSkillDoc(t *testing.T) string {
return readAppsSkillDoc(t, releaseGetSkillDoc)
}
func readAppsSkillDoc(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read skill doc %s: %v", path, err)
}
return string(raw)
}
func skillSection(t *testing.T, doc, heading string) string {
t.Helper()
start := strings.Index(doc, heading)
if start < 0 {
t.Fatalf("missing skill section %q", heading)
}
rest := doc[start+len(heading):]
if next := strings.Index(rest, "\n## "); next >= 0 {
return rest[:next]
}
return rest
}
func skillSubsection(t *testing.T, doc, heading string) string {
t.Helper()
start := strings.Index(doc, heading)
if start < 0 {
t.Fatalf("missing skill subsection %q", heading)
}
rest := doc[start+len(heading):]
end := len(rest)
for _, marker := range []string{"\n### ", "\n## "} {
if next := strings.Index(rest, marker); next >= 0 && next < end {
end = next
}
}
return rest[:end]
}
func requireInOrder(t *testing.T, text string, tokens ...string) {
t.Helper()
offset := 0
for _, token := range tokens {
idx := strings.Index(text[offset:], token)
if idx < 0 {
t.Fatalf("missing %q after %q", token, text[:offset])
}
offset += idx + len(token)
}
}
func requireFirstOccurrencesInOrder(t *testing.T, text string, tokens ...string) {
t.Helper()
previous := -1
for _, token := range tokens {
idx := strings.Index(text, token)
if idx < 0 {
t.Fatalf("missing %q", token)
}
if idx <= previous {
t.Fatalf("first %q at %d must follow the previous contract token at %d", token, idx, previous)
}
previous = idx
}
}
func TestAutomationSkillContract_ChangedHandlerStartWaitsForThisRelease(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 实现或更新 handler 后发布并启动/测试")
requireInOrder(t, section,
"仅当本轮确实需要新增或修改 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` handler",
"+automation-get",
"记录发布前状态",
"--name",
"项目 guide",
"按项目 guide 完成同名业务 handler 并本地验证。",
"在 Git 已确认/预授权时 commit然后执行",
"git push origin sprint/default",
"临时停用授权",
"+automation-disable",
"确认 disabled",
"+release-create --branch sprint/default",
"data.release_id",
"+release-get",
"data.status=finished",
"仅启动",
"+automation-enable",
"+automation-get",
"不制造 runtime probe",
"测试",
"运行时验证的操作级授权",
"完成全部 preflight",
"才执行 `+automation-enable`",
"真实 runtime",
"仅要求测试",
"恢复到发布前状态",
)
requireFirstOccurrencesInOrder(t, section,
"+automation-get",
"git push origin sprint/default",
"临时停用授权",
"+automation-disable",
"+release-create --branch sprint/default",
"data.status=finished",
"仅启动",
)
for _, boundary := range []string{
"仅当本轮确实需要新增或修改 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` handler且用户要求把这次代码发布后启动或测试时才使用此路径。",
"按项目 guide 完成同名业务 handler 并本地验证。",
"在 Git 已确认/预授权时 commit然后执行 `git push origin sprint/default`。",
"若该命令本身返回错误或未返回 `data.release_id`:视为确认未创建本轮 release新代码未上线原本 enabled 的 trigger 恢复 enabled 并回读、原本 disabled 的保持 disabled 后停止;若因超时等导致结果未知,保持 disabled先用 `+release-list --status finished --page-size 1` 核对是否已产生新 release 再决定。",
"只有 `data.status=finished` 才能继续;`publishing` 时每 20 秒继续轮询,整体最多约 5 分钟。",
"确认 `failed` 时报告发布失败,原本 enabled 的 trigger 仅在确认新代码未上线后恢复 enabled原本 disabled 的保持 disabled。",
"发布状态仍不确定时不得进入 enable、probe 或状态恢复分支。",
"**仅启动**:取得持续启动授权后执行 `+automation-enable`,并用 `+automation-get` 确认 enabled到此结束不制造 runtime probe。",
"**测试(含“启动并测试”)**:先按下节“运行时验证的操作级授权”完成全部 preflight",
"若用户仅要求测试而不是持续启动,只在本轮 release 已 `finished` 且 probe 成功后恢复到发布前状态",
"无论用户是仅测试还是启动并测试probe 失败、结果不确定或 enable 后提前结束时,一律 `+automation-disable` 并回读 disabled",
"不得把“发布前 enabled”当作失败后的恢复依据",
"没有通用的 `automation-debug` 或 trigger 日志 shortcut。",
} {
if !strings.Contains(section, boundary) {
t.Errorf("complete-start section must explain %q boundary", boundary)
}
}
}
func TestAutomationSkillContract_BindsTheExactNameAsUser(t *testing.T) {
doc := readAutomationSkillDoc(t)
for _, boundary := range []string{
"全部操作需 `--as user`AuthType: user。",
"当用户希望触发器实际执行业务代码时,先确认当前工作区是已初始化的应用项目,并读取其中与触发器任务匹配的 guide。",
"`--name` 是应用内唯一的 trigger 定位键;代码侧绑定名称必须与它逐字相同。不得用 trigger ID 或方法名代替它。具体 handler 语法和接入方式以项目 guide 为准。",
} {
if !strings.Contains(doc, boundary) {
t.Errorf("automation skill must preserve %q", boundary)
}
}
}
func TestAutomationSkillContract_RoutesAndDiagnosesUnfiredTriggers(t *testing.T) {
doc := readAutomationSkillDoc(t)
routeSection := skillSection(t, doc, "## 何时用本 skill路由锚点")
errorSection := skillSection(t, doc, "## 常见错误与决策场景")
if !strings.Contains(routeSection, "「触发器没反应 / enable 了不触发 / 为什么没执行 / 验证一下触发器」→ 先按「未触发时的诊断顺序」诊断;对 UPSERT 和 feishu-approval 仅验证配置边界,不承诺 handler 或 live 验证。") {
t.Error("routing anchors must direct unfired triggers to the bounded diagnostic flow")
}
if !strings.Contains(errorSection, "已证实的 cron、webhook、record-changeINSERT/UPDATE/DELETE按「未触发时的诊断顺序」排查UPSERT 和 feishu-approval 仅核对配置边界,不承诺 handler 或 live 验证。") {
t.Error("error table must preserve the bounded unfired-trigger diagnostic flow")
}
}
func TestAutomationSkillContract_ConfigurationStopsDisabled(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅创建/配置触发器")
for _, boundary := range []string{
"用 `+automation-create` 创建,并省略 `--status` 或显式传 `disabled`,然后报告 name 和 disabled 状态。",
"不要传 `--status enabled`,也不要写 handler、commit/push、release 或 enable更不能把创建 API 成功称为“可运行”。",
"默认 disabled 是这个意图的终点,不是稍后自动 enable 的待办。",
} {
if !strings.Contains(section, boundary) {
t.Errorf("configuration-only section must preserve %q", boundary)
}
}
}
func TestAutomationSkillContract_EnableExistingTriggerDoesNotPublish(t *testing.T) {
doc := readAutomationSkillDoc(t)
section := skillSubsection(t, doc, "### 仅启用已有 disabled trigger")
routeSection := skillSection(t, doc, "## 何时用本 skill路由锚点")
requireInOrder(t, section,
"用户只要求启用已存在且 disabled 的 trigger",
"+automation-get",
"+release-list --status finished --page-size 1",
"已完成线上 release",
"当前线上应用",
"不能证明该 trigger name 已绑定 handler",
"+automation-enable",
"+automation-get",
"不得修改 handler、commit/push 或 release",
"对 UPSERT 或 feishu-approval 只改变配置状态",
)
if !strings.Contains(section, "未发布时不得自动创建 release也不得声称 trigger 已开始实际运行") {
t.Error("enable-only flow must distinguish configuration enablement from a published runtime")
}
if !strings.Contains(section, "即使存在 finished release也只能把 enable 报告为配置激活") {
t.Error("enable-only flow must not infer handler provenance from app release history")
}
if strings.Contains(section, "apps +get") || strings.Contains(section, "`is_published`") {
t.Error("enable-only flow must use finished release history instead of an optional app detail field")
}
for _, forbidden := range []string{"git push", "+release-create"} {
if strings.Contains(section, forbidden) {
t.Errorf("enable-only flow must not contain %q", forbidden)
}
}
if !strings.Contains(routeSection, "「启用 / 启动已有 trigger」→ 先核对现有状态;只启用时不要修改源码或发布应用。") {
t.Error("routing anchors must keep existing-trigger enablement separate from code release")
}
}
func TestAutomationSkillContract_TestExistingTriggerDoesNotPublish(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 测试已有线上 trigger不改代码")
requireInOrder(t, section,
"用户要求测试已经发布的 trigger",
"+automation-get",
"+release-list --status finished --page-size 1",
"当前线上代码",
"不得为测试自动修改源码、commit/push 或 release",
"在任何临时 enable 之前完成",
"测试请求已明确包含临时 enable或另行取得 enable 授权",
"运行时验证的操作级授权",
"无论 probe 成功、失败、结果不确定,还是临时 enable 后提前结束或中断,最终都必须 `+automation-disable` 并回读 disabled",
)
for _, forbidden := range []string{"git push", "+release-create"} {
if strings.Contains(section, forbidden) {
t.Errorf("existing-trigger test flow must not contain %q", forbidden)
}
}
}
func TestAutomationSkillContract_HandlerOnlyStopsBeforeRelease(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅完成 handler不发布/不启用)")
for _, boundary := range []string{
"创建或定位已明确 name 的 disabled trigger读取项目 guide按其要求实现同名业务 handler完成本地验证。",
"只在既有 Git 确认或预授权下 commit/push停止在 `+release-create` 和 `+automation-enable` 之前。",
"用户没有明确“发布好”时,先问,不能默认把完整应用上线。",
} {
if !strings.Contains(section, boundary) {
t.Errorf("handler-only section must preserve %q", boundary)
}
}
}
func TestAutomationSkillContract_HandlerOnlyExcludesUnverifiedRuntimeTypes(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅完成 handler不发布/不启用)")
if !strings.Contains(section, "仅对 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` 使用此路径。") {
t.Error("handler-only flow must exclude UPSERT and feishu-approval without a verified runtime contract")
}
}
func TestAutomationSkillContract_PublishedHandlerStaysDisabled(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 把 handler 发布好,但先不要启动")
for _, boundary := range []string{
"仅对 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` 使用此路径。",
"先用 `+automation-get` 定位;不存在时用 `+automation-create` 创建同名 disabled trigger再次回读确认。",
"已存在时记录它是否 enabled。",
"若 trigger 已 enabled先说明发布前必须临时停用以及可能造成的运行中断并取得这次临时停用授权未获授权时停止在发布前。",
"取得授权后,在发布前执行 `+automation-disable`,并再次用 `+automation-get` 确认 disabled。",
"按项目 guide 完成同名业务 handler 并本地验证后commit、`git push origin sprint/default`。",
"随后发布完整应用:",
"若 `+release-create` 本身返回错误或未返回 `data.release_id`:视为确认未创建本轮 release新代码未上线原本 enabled 的 trigger 恢复 enabled 并回读、原本 disabled 的保持 disabled然后停止若因超时等导致创建结果未知保持 disabled先用 `+release-list --status finished --page-size 1` 核对是否已产生新 release 再决定。",
"取得 `data.release_id` 后,对**这一轮** ID 调用 `+release-get``publishing` 时每 20 秒继续轮询,整体最多约 5 分钟;超时且状态仍不确定时报告 `release_id` 和当前 status并保持 disabled只有 `data.status=finished` 才算完成。",
"确认 `failed` 且新代码未上线时,原本 enabled 的 trigger 恢复 enabled 并回读,原本 disabled 的保持 disabled。",
"release 是整个应用上线可能影响既有线上功能未获得启动或测试授权时finished 后始终保持 disabled不执行 `+automation-enable`。",
} {
if !strings.Contains(section, boundary) {
t.Errorf("publish-without-start section must preserve %q", boundary)
}
}
requireFirstOccurrencesInOrder(t, section,
"+automation-get",
"git push origin sprint/default",
"临时停用授权",
"+automation-disable",
"+release-create",
)
}
func TestAutomationSkillContract_UPSERTAndApprovalStayConfigurationOnly(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### UPSERT 与飞书审批边界")
for _, boundary := range []string{
"record-change 的 UPSERT 可创建 disabled 配置,但当前没有已证实的运行时代码契约;不得静默按 UPDATE 处理,也不得承诺 handler 或 live 验证。",
"feishu-approval 可创建 disabled 配置,并读取或更新 `event_type`、对应 status 和可选 `approval_code`。",
"当前没有已证实的运行时 handler 契约或实际投递验证;不要把 enable 或审批 API 成功称为业务代码已执行。",
} {
if !strings.Contains(section, boundary) {
t.Errorf("UPSERT/approval boundary section must preserve %q", boundary)
}
}
}
func TestAutomationSkillContract_RuntimeProbeRequiresOperationScope(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 运行时验证的操作级授权")
for _, boundary := range []string{
"启用 trigger 的授权不等于制造 runtime 事件的授权,测试授权也不等于任意数据库写入授权。",
"record-change 在执行任何 DML 前,必须明确并取得覆盖以下作用域的授权",
"环境、表、操作、精确测试记录或筛选条件、payload、预期结果和清理方式",
"优先使用专用测试记录",
"`DELETE`",
"[lark-apps-db-execute.md](lark-apps-db-execute.md)",
"先 `SELECT count(*)`、执行 `--dry-run`",
"取得针对该删除目标的明确授权",
"+automation-list --trigger-type record-change --all",
"同一环境、表和操作可能命中的其他 enabled trigger",
"聚合业务影响",
"恢复 UPDATE 或清理 INSERT 也可能再次触发自动化",
"缺少安全、已授权且可清理的事件入口时,记录 blocked",
} {
if !strings.Contains(section, boundary) {
t.Errorf("runtime probe section must preserve %q", boundary)
}
}
}
func TestAutomationSkillContract_UsesResolvableSharedSkillLink(t *testing.T) {
doc := readAutomationSkillDoc(t)
if strings.Contains(doc, "](../lark-shared/SKILL.md)") {
t.Error("automation reference must not resolve lark-shared inside the lark-apps directory")
}
if !strings.Contains(doc, "](../../lark-shared/SKILL.md)") {
t.Error("automation reference must link to the sibling lark-shared skill")
}
sharedSkillDoc := filepath.Clean(filepath.Join(filepath.Dir(automationSkillDoc), "../../lark-shared/SKILL.md"))
if _, err := os.Stat(sharedSkillDoc); err != nil {
t.Fatalf("automation reference target %s must exist: %v", sharedSkillDoc, err)
}
}
func TestAppsSkillContract_AllSharedSkillLinksResolve(t *testing.T) {
docs := []string{larkAppsSkillDoc}
references, err := filepath.Glob("../../skills/lark-apps/references/*.md")
if err != nil {
t.Fatalf("glob lark-apps references: %v", err)
}
docs = append(docs, references...)
sharedLink := regexp.MustCompile(`\]\(([^)]+lark-shared/SKILL\.md)\)`)
for _, docPath := range docs {
doc := readAppsSkillDoc(t, docPath)
for _, match := range sharedLink.FindAllStringSubmatch(doc, -1) {
target := filepath.Clean(filepath.Join(filepath.Dir(docPath), match[1]))
if _, err := os.Stat(target); err != nil {
t.Errorf("%s shared-skill link %q resolves to missing target %s: %v", docPath, match[1], target, err)
}
}
}
}
func TestLocalDevSkillContract_UsesProjectGuideWithoutSyncInternals(t *testing.T) {
section := skillSection(t, readLocalDevSkillDoc(t), "## Trigger guide 的项目边界")
for _, boundary := range []string{
"先查看工作区 `.agents/skills/`,读取与自动化任务匹配的 `trigger-guide`。",
"文件缺失或不能覆盖当前任务时,报告项目缺少可用的领域 guide不要在本 lark-cli reference 中猜测安装命令、版本或包内目录。",
} {
if !strings.Contains(section, boundary) {
t.Errorf("trigger-guide boundary section must explain %q", boundary)
}
}
for _, implementationShape := range []string{
"npx ", "skills sync", "data.", "skills_", "_CACHE_DIR", "nestjs-",
"@lark-apaas/miaoda-cli", "@lark-apaas/coding-steering", "miaoda-coding", "skills_common/",
} {
if strings.Contains(section, implementationShape) {
t.Errorf("local-dev skill must not expose project-sync implementation shape %q", implementationShape)
}
}
}
func TestAppsSkillContract_DoesNotExposeSteeringImplementation(t *testing.T) {
for name, doc := range map[string]string{
"automation": readAutomationSkillDoc(t),
"local-dev": readLocalDevSkillDoc(t),
} {
for _, implementationShape := range []string{
"npx ", "skills sync", "@lark-apaas/miaoda-cli", "@lark-apaas/coding-steering", "miaoda-coding", "skills_common/",
} {
if strings.Contains(doc, implementationShape) {
t.Errorf("%s skill must not expose project-sync implementation shape %q", name, implementationShape)
}
}
}
}
func TestLocalDevSkillContract_UsesEnvironmentAndDefersEnableToAutomationSOP(t *testing.T) {
doc := readLocalDevSkillDoc(t)
releaseSection := skillSection(t, doc, "## 改完代码后部署上线")
for _, legacy := range []string{"--env dev", "--env online"} {
if strings.Contains(doc, legacy) {
t.Errorf("local-dev skill must not recommend legacy %q", legacy)
}
}
for _, boundary := range []string{
"`publishing` 时每 20 秒继续轮询,整体最多约 5 分钟;超时仍未完成时停止本轮轮询、报告 `release_id` 和当前 status。",
"若本次改动包含自动化 handler在执行本节通用 commit/push/release 序列前就转到 [automation SOP](lark-apps-automation.md) 的匹配路径,由该 SOP 负责完整的状态门禁、commit/push、release 和可选 enable/test不要先按本节发布再补 trigger 状态检查。",
"用户只要求启用已有 trigger 时,转到 [automation SOP 的「仅启用已有 disabled trigger」路径](lark-apps-automation.md#仅启用已有-disabled-trigger);不得因 enable 反向修改 handler、commit/push 或 release。",
"使用 `--environment dev|online`,不要使用旧的 `--env`。只有确认应用已开启多环境时才引导 `--environment dev`;单环境应用省略 `--environment`(服务端选 online或显式传 `--environment online`。",
} {
if !strings.Contains(doc, boundary) {
t.Errorf("local-dev skill must preserve %q", boundary)
}
}
routeIndex := strings.Index(releaseSection, "若本次改动包含自动化 handler")
releaseIndex := strings.Index(releaseSection, "+release-create")
if routeIndex < 0 || releaseIndex < 0 || routeIndex >= releaseIndex {
t.Error("automation routing must appear before the generic release sequence")
}
}
func TestLocalDevSkillContract_DoesNotRequireOnlineURL(t *testing.T) {
section := skillSection(t, readLocalDevSkillDoc(t), "## 改完代码后部署上线")
if strings.Contains(section, "`finished` 成功时该命令输出已含 `online_url`") {
t.Error("release guidance must not claim every finished release includes online_url")
}
if !strings.Contains(section, "若返回 `online_url`,可直接使用;未返回时不要编造链接。") {
t.Error("release guidance must explain that online_url is optional")
}
}
func TestLocalDevSkillContract_TreatsErrorLogsAsOptional(t *testing.T) {
section := skillSection(t, readLocalDevSkillDoc(t), "## 改完代码后部署上线")
if !strings.Contains(section, "`failed` 时若返回非空 `error_logs`,据此给出失败原因;否则只报告 `release_id` 和当前 status不要编造原因") {
t.Error("release guidance must not promise error_logs on every failed release")
}
}
func TestReleaseSkillContract_TreatsOptionalOutputAsOptional(t *testing.T) {
releaseGet := readReleaseGetSkillDoc(t)
for _, boundary := range []string{
"`finished` 后才可能有 `online_url`。",
"若输出含 `online_url`,直接读取它作为本轮发布的线上访问链接;未返回时只报告发布完成,不要编造链接。",
"若输出含 `error_logs``step`/`error_log`),据此向用户转述关键失败步骤和可行动修复;未返回时不要编造失败原因。",
} {
if !strings.Contains(releaseGet, boundary) {
t.Errorf("release-get skill must preserve optional-output boundary %q", boundary)
}
}
}

View File

@@ -8,7 +8,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
)
func appsValidationError(format string, args ...any) *errs.ValidationError {
@@ -74,32 +73,3 @@ func appsInputPathEntryError(path string, err error) error {
func appsFileIOError(err error, format string, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeFileIO, format, args...).WithCause(err)
}
// enrichHTMLPublishAPIError adapts a typed failure from the HTML publish
// endpoint: refines endpoint-scoped business codes, prefixes the message with
// command context, and attaches endpoint-specific recovery hints. A
// still-untyped error is lifted at the SDK boundary instead.
func enrichHTMLPublishAPIError(err error) error {
if err == nil {
return nil
}
p, ok := errs.ProblemOf(err)
if !ok {
return client.WrapDoAPIError(err)
}
// The HTML publish business codes (90001/90002) are scoped to this
// endpoint, not service-global, so their subtype classification lives
// here instead of the global errclass code table. Only an
// otherwise-unclassified API error is refined; a stronger upstream
// classification is never overridden.
if p.Category == errs.CategoryAPI && p.Subtype == errs.SubtypeUnknown && p.Code == errCodeAppNotFound {
p.Subtype = errs.SubtypeNotFound
}
if p.Message != "" {
p.Message = "html-publish failed: " + p.Message
}
if hint := buildHTMLPublishFailureHint(p.Code); hint != "" {
p.Hint = hint
}
return err
}

View File

@@ -57,57 +57,3 @@ func TestAppsFileIOError_ClassifiesInternalFileIO(t *testing.T) {
t.Fatalf("cause chain not preserved: %v", err)
}
}
func TestEnrichHTMLPublishAPIError_LiftsUntypedBoundaryError(t *testing.T) {
err := enrichHTMLPublishAPIError(errors.New("connection reset by peer"))
problem := requireAppsProblem(t, err, errs.CategoryNetwork)
if problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNetworkTransport)
}
}
func TestEnrichHTMLPublishAPIError_PreservesClassificationAndAddsHint(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeUnknown, "build failed").
WithCode(errCodeBuildFailed).
WithLogID("logid-build-failed")
got := enrichHTMLPublishAPIError(err)
if got != err {
t.Fatalf("typed error should be enriched in place")
}
problem := requireAppsAPIProblem(t, got)
if problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeUnknown)
}
if problem.Code != errCodeBuildFailed {
t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
}
if problem.LogID != "logid-build-failed" {
t.Fatalf("log_id = %q, want preserved", problem.LogID)
}
if !strings.Contains(problem.Message, "html-publish failed") {
t.Fatalf("message = %q, want html-publish context", problem.Message)
}
if problem.Hint == "" {
t.Fatalf("expected known-code recovery hint")
}
}
func TestEnrichHTMLPublishAPIError_ClassifiesAppNotFoundLocally(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeUnknown, "app not found").WithCode(errCodeAppNotFound)
problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
if problem.Subtype != errs.SubtypeNotFound {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
}
}
func TestEnrichHTMLPublishAPIError_KeepsStrongerClassification(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeRateLimit, "throttled").WithCode(errCodeAppNotFound)
problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
if problem.Subtype != errs.SubtypeRateLimit {
t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeRateLimit)
}
}

View File

@@ -17,10 +17,11 @@ import (
var AppsGet = common.Shortcut{
Service: appsService,
Command: "+get",
Description: "Get a single app's detail by app ID (returns app_type, name, description, publish status, etc.)",
Description: "Get a single app's detail by app ID or meta token (returns app_type, name, description, publish status, etc.)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +get --app-id <app_id>",
"Example: lark-cli apps +get --app-id <meta_token>",
"Example: lark-cli apps +get --app-id <app_id> --dry-run",
"Tip: extract app type with --jq '.data.app.app_type'",
},
@@ -28,7 +29,7 @@ var AppsGet = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID", Required: true},
{Name: "app-id", Desc: "app ID or meta token", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
@@ -40,7 +41,7 @@ var AppsGet = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Get app detail (returns app_id, app_type, name, description, icon_url, created_at, updated_at, is_published)")
Desc("Get app detail (returns app_id, meta_token, app_type, name, description, icon_url, created_at, updated_at, is_published)")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
@@ -54,6 +55,9 @@ var AppsGet = common.Shortcut{
return
}
fmt.Fprintf(w, "app_id: %v\n", app["app_id"])
if mt, ok := app["meta_token"].(string); ok && mt != "" {
fmt.Fprintf(w, "meta_token: %s\n", mt)
}
fmt.Fprintf(w, "app_type: %v\n", app["app_type"])
fmt.Fprintf(w, "name: %v\n", app["name"])
if desc, ok := app["description"].(string); ok && desc != "" {

View File

@@ -14,7 +14,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -38,9 +37,13 @@ var AppsHTMLPublish = common.Shortcut{
{Name: "allow-sensitive", Type: "bool", Desc: "skip the credential-file scan (allow .env / .npmrc / .aws/credentials / etc. in the publish payload)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
appID := strings.TrimSpace(rctx.Str("app-id"))
if appID == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
if err := validateRealAppID(appID); err != nil {
return err
}
path := strings.TrimSpace(rctx.Str("path"))
if path == "" {
return appsValidationParamError("--path", "--path is required")
@@ -73,9 +76,11 @@ var AppsHTMLPublish = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
path := strings.TrimSpace(rctx.Str("path"))
dry := common.NewDryRunAPI()
dry.Desc("Pack tar.gz and publish HTML app (actual API path determined at runtime by app type; returns url or release_id)")
dry.POST(fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID))).
Set("content_type", "multipart/form-data")
dry.Desc("Pack tar.gz → GET pre_release for TOS upload URL → PUT tar.gz to TOS → POST release-create with tos_path; returns release_id")
dry.GET(fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, validate.EncodePathSegment(appID))).
PUT("<presigned_upload_url> (from pre_release response)").
POST(fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))).
Body(map[string]string{"tos_path": "<from pre_release response>"})
candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), path)
if err != nil {
@@ -123,16 +128,7 @@ var AppsHTMLPublish = common.Shortcut{
Path: strings.TrimSpace(rctx.Str("path")),
}
appType := queryAppType(ctx, rctx, spec.AppID)
var out map[string]interface{}
var err error
if appType == "modern_html" {
out, err = runHTMLPublishTOS(ctx, rctx, spec)
} else {
client := appsHTMLPublishAPI{runtime: rctx}
out, err = runHTMLPublish(ctx, rctx.FileIO(), client, spec)
}
out, err := runHTMLPublishTOS(ctx, rctx, spec)
if err != nil {
return err
}
@@ -264,25 +260,7 @@ func prepareHTMLPublishTarball(fio fileio.FileIO, path string) (*htmlPublishTarb
return tarball, nil
}
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
tarball, err := prepareHTMLPublishTarball(fio, spec.Path)
if err != nil {
return nil, err
}
resp, err := publisher.HTMLPublish(ctx, spec.AppID, tarball)
if err != nil {
return nil, client.WrapDoAPIError(err)
}
out := map[string]interface{}{}
if resp.URL != "" {
out["url"] = resp.URL
}
return out, nil
}
// runHTMLPublishTOS handles the modern_html publish path: validate → tar.gz →
// runHTMLPublishTOS handles the publish path: validate → tar.gz →
// call pre_release to get TOS upload URL → upload tar.gz to TOS → return
// tos_path for +release-create --tos-path.
func runHTMLPublishTOS(ctx context.Context, rctx *common.RuntimeContext, spec appsHTMLPublishSpec) (map[string]interface{}, error) {

View File

@@ -5,7 +5,6 @@ package apps
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
@@ -23,20 +22,6 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
type fakeAppsHTMLPublishClient struct {
resp *htmlPublishResponse
err error
calls []string
}
func (f *fakeAppsHTMLPublishClient) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) {
f.calls = append(f.calls, appID)
if f.err != nil {
return nil, f.err
}
return f.resp, nil
}
func writeAppsSampleSite(t *testing.T) string {
t.Helper()
dir := t.TempDir()
@@ -46,71 +31,19 @@ func writeAppsSampleSite(t *testing.T) string {
return dir
}
func TestRunHTMLPublish_HappyPath(t *testing.T) {
site := writeAppsSampleSite(t)
fake := &fakeAppsHTMLPublishClient{
resp: &htmlPublishResponse{URL: "https://miaoda/app_x"},
}
out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
if err != nil {
t.Fatalf("err=%v", err)
}
if out["url"] != "https://miaoda/app_x" {
t.Fatalf("url=%v", out["url"])
}
if len(fake.calls) != 1 || fake.calls[0] != "app_x" {
t.Fatalf("calls=%v", fake.calls)
}
}
func TestRunHTMLPublish_OnlyURLInEnvelope(t *testing.T) {
// Pin 概要设计 §5.3 不变量 4 "同步语义不会变成异步" (legacy html path only):
// envelope 只含 url未来若有人加 status / release_id 字段会被这个测试拦截。
site := writeAppsSampleSite(t)
fake := &fakeAppsHTMLPublishClient{
resp: &htmlPublishResponse{URL: "https://miaoda/app_x"},
}
out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
if err != nil {
t.Fatalf("err=%v", err)
}
if len(out) != 1 {
t.Fatalf("envelope should only contain 'url', got %d keys: %v", len(out), out)
}
if _, ok := out["url"]; !ok {
t.Fatalf("envelope missing 'url': %v", out)
}
}
func TestRunHTMLPublish_ClientErrorPropagated(t *testing.T) {
site := writeAppsSampleSite(t)
wantErr := errors.New("server timeout")
fake := &fakeAppsHTMLPublishClient{err: wantErr}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
if !errors.Is(err, wantErr) {
t.Fatalf("err=%v", err)
}
}
func TestRunHTMLPublish_PathNotFound(t *testing.T) {
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: "/nonexistent"})
func TestPrepareHTMLPublishTarball_PathNotFound(t *testing.T) {
_, err := prepareHTMLPublishTarball(newTestFIO(), "/nonexistent")
if err == nil {
t.Fatalf("expected error")
}
if len(fake.calls) != 0 {
t.Fatalf("client should not be called when path invalid")
}
}
func TestRunHTMLPublish_DirRequiresIndexHTML(t *testing.T) {
// 目录形态:缺 index.html 应该被拦
func TestPrepareHTMLPublishTarball_DirRequiresIndexHTML(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "foo.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
if err == nil {
t.Fatalf("expected error for missing index.html")
}
@@ -121,13 +54,9 @@ func TestRunHTMLPublish_DirRequiresIndexHTML(t *testing.T) {
if problem.Hint == "" {
t.Fatalf("expected non-empty hint")
}
if len(fake.calls) != 0 {
t.Fatalf("client should not be called when index.html missing")
}
}
func TestRunHTMLPublish_DirWithIndexHTMLPasses(t *testing.T) {
// 目录含 index.html 应该正常走完
func TestPrepareHTMLPublishTarball_DirWithIndexHTMLPasses(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
@@ -135,57 +64,49 @@ func TestRunHTMLPublish_DirWithIndexHTMLPasses(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "extra.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
tarball, err := prepareHTMLPublishTarball(newTestFIO(), dir)
if err != nil {
t.Fatalf("err=%v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("client should be called when index.html present")
if tarball == nil || tarball.Size == 0 {
t.Fatalf("expected non-empty tarball")
}
}
func TestRunHTMLPublish_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
// 单文件形态:文件名不是 index.html 也要拦
func TestPrepareHTMLPublishTarball_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
dir := t.TempDir()
single := filepath.Join(dir, "foo.html")
if err := os.WriteFile(single, []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single})
_, err := prepareHTMLPublishTarball(newTestFIO(), single)
if err == nil {
t.Fatalf("single-file path 'foo.html' should be rejected (not named index.html)")
}
requireAppsValidationProblem(t, err)
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when index.html missing")
}
}
func TestRunHTMLPublish_SingleFileNamedIndexPasses(t *testing.T) {
// 单文件形态:文件名恰好就是 index.html → 放行
func TestPrepareHTMLPublishTarball_SingleFileNamedIndexPasses(t *testing.T) {
dir := t.TempDir()
single := filepath.Join(dir, "index.html")
if err := os.WriteFile(single, []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single}); err != nil {
tarball, err := prepareHTMLPublishTarball(newTestFIO(), single)
if err != nil {
t.Fatalf("err=%v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("client should be called for single index.html")
if tarball == nil || tarball.Size == 0 {
t.Fatalf("expected non-empty tarball")
}
}
func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
// 把上限调到 100 字节验证拦截defer 恢复原值避免污染其它测试。
func TestPrepareHTMLPublishTarball_RejectsOversizeTarball(t *testing.T) {
orig := maxHTMLPublishTarballBytes
maxHTMLPublishTarballBytes = 100
defer func() { maxHTMLPublishTarballBytes = orig }()
dir := t.TempDir()
// 写 index.html满足新加的 index 校验)+ 大文件超 100 字节上限。
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
@@ -194,8 +115,7 @@ func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
t.Fatalf("write: %v", err)
}
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
if err == nil {
t.Fatalf("expected oversize error")
}
@@ -206,9 +126,6 @@ func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
if problem.Hint == "" {
t.Fatalf("expected non-empty hint")
}
if len(fake.calls) != 0 {
t.Fatalf("client should not be called when tarball oversize")
}
}
func TestMaxHTMLPublishTarballBytes_Default(t *testing.T) {
@@ -264,8 +181,17 @@ func TestAppsHTMLPublish_DryRunPrintsManifest(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code") {
t.Fatalf("dry-run missing endpoint: %s", got)
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/pre_release") {
t.Fatalf("dry-run missing pre_release endpoint: %s", got)
}
if !strings.Contains(got, "presigned_upload_url") {
t.Fatalf("dry-run missing TOS PUT step: %s", got)
}
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/releases") {
t.Fatalf("dry-run missing release-create endpoint: %s", got)
}
if !strings.Contains(got, "tos_path") {
t.Fatalf("dry-run missing tos_path in release-create body: %s", got)
}
if !strings.Contains(got, "index.html") {
t.Fatalf("dry-run missing file list: %s", got)
@@ -500,9 +426,7 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
t.Fatalf("write: %v", err)
}
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake,
appsHTMLPublishSpec{AppID: "app_x", Path: dir})
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
if err == nil {
t.Fatalf("expected raw-size cap to fire")
}
@@ -510,9 +434,6 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
if !strings.Contains(problem.Message, "raw") || !strings.Contains(problem.Message, "bytes") {
t.Fatalf("expected message to explain raw-byte cap, got %q", problem.Message)
}
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when raw cap hit")
}
}
func TestOversizeHTMLFiles(t *testing.T) {
@@ -555,8 +476,7 @@ func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
if err == nil {
t.Fatalf("expected per-file oversize error")
}
@@ -567,13 +487,9 @@ func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
if problem.Hint == "" {
t.Fatalf("expected non-empty hint")
}
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when an HTML file is oversize")
}
}
func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
// 单 .html 上限调小,但超限文件是 .png → 不被本护栏拦截,正常发布。
func TestPrepareHTMLPublishTarball_IgnoresOversizeNonHTML(t *testing.T) {
orig := maxHTMLPublishSingleHTMLFileBytes
maxHTMLPublishSingleHTMLFileBytes = 100
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
@@ -585,12 +501,12 @@ func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "big.png"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
tarball, err := prepareHTMLPublishTarball(newTestFIO(), dir)
if err != nil {
t.Fatalf("non-html oversize must not be blocked by the .html cap: %v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("client should be called; calls=%v", fake.calls)
if tarball == nil || tarball.Size == 0 {
t.Fatalf("expected non-empty tarball")
}
}

View File

@@ -74,15 +74,18 @@ type appTypePolicy struct {
// skipSkillsSync skips the conditional `npx ... skills sync --local` step on
// the non-empty (`app sync`) scaffold path.
skipSkillsSync bool
// skipAppSync skips `npx ... app sync` on the non-empty repo path.
skipAppSync bool
}
// appTypePolicies maps an app_type to its +init control strategy. Types absent
// from the map get the zero-value policy (install runs, env is pulled, skills
// are synced).
var appTypePolicies = map[string]appTypePolicy{
// modern_html is a static HTML site: no dependencies to install, no startup
// env vars to pull, and no steering skills to sync.
"modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true},
// modern_html / html are static HTML sites: no dependencies to install,
// no startup env vars to pull, no steering skills to sync, and no app sync.
"modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
"html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
}
// policyForAppType returns the +init control strategy for appType. Unlisted
@@ -122,9 +125,13 @@ var AppsInit = common.Shortcut{
{Name: "source-path", Desc: "path to existing source files (e.g. HTML output from an agent) to incorporate into the initialized project"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
appID := strings.TrimSpace(rctx.Str("app-id"))
if appID == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
if err := validateRealAppID(appID); err != nil {
return err
}
if sp := strings.TrimSpace(rctx.Str("source-path")); sp != "" {
if err := charcheck.RejectControlChars(sp, "--source-path"); err != nil {
return appsValidationParamError("--source-path", "%v", err).WithCause(err)
@@ -334,11 +341,19 @@ func ensureMetaAppID(dir, appID string) error {
// each is not already resolvable from local/global/system config, so a
// developer's existing identity is never overwritten. Each key is handled
// independently (a machine with only user.name set still gets a default email).
func ensureGitIdentity(ctx context.Context, dir string) error {
if err := ensureGitConfigValue(ctx, dir, "user.name", defaultGitUserName); err != nil {
func ensureGitIdentity(ctx context.Context, dir, authorName, authorEmail string) error {
name := strings.TrimSpace(authorName)
if name == "" {
name = defaultGitUserName
}
email := strings.TrimSpace(authorEmail)
if email == "" {
email = defaultGitUserEmail
}
if err := ensureGitConfigValue(ctx, dir, "user.name", name); err != nil {
return err
}
return ensureGitConfigValue(ctx, dir, "user.email", defaultGitUserEmail)
return ensureGitConfigValue(ctx, dir, "user.email", email)
}
// ensureGitConfigValue sets <key>=fallback in the repo-local git config when key
@@ -400,13 +415,16 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s
}
return scaffoldKindInit, nil
}
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil {
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
policy := policyForAppType(appType)
if !policy.skipAppSync {
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil {
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
}
}
if err := ensureMetaAppID(dir, appID); err != nil {
return "", err
}
if !policyForAppType(appType).skipSkillsSync && !hasSteeringSkills(dir) {
if !policy.skipSkillsSync && !hasSteeringSkills(dir) {
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
return "", appsExternalToolError(err, "npx skills sync failed: %s", gitErr(stderr, err))
}
@@ -436,26 +454,38 @@ func scaffoldInitArgs(appType, appID, sourcePath string) []string {
return base
}
// parseRepoURLFromEnvelope extracts data.repository_url from a lark-cli JSON
// envelope ({"ok":true,"data":{"repository_url":"..."}}). The field name
// matches the contract emitted by `apps +git-credential-init`.
func parseRepoURLFromEnvelope(stdout string) (string, error) {
// credentialInitResult holds the fields parsed from +git-credential-init output.
type credentialInitResult struct {
RepositoryURL string
CommitAuthorName string
CommitAuthorEmail string
}
// parseCredentialInitEnvelope extracts fields from a +git-credential-init JSON
// envelope ({"ok":true,"data":{"repository_url":"...","commit_author_name":"...","commit_author_email":"..."}}).
func parseCredentialInitEnvelope(stdout string) (credentialInitResult, error) {
var env struct {
OK bool `json:"ok"`
Data struct {
RepositoryURL string `json:"repository_url"`
RepositoryURL string `json:"repository_url"`
CommitAuthorName string `json:"commit_author_name"`
CommitAuthorEmail string `json:"commit_author_email"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
return "", appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err)
return credentialInitResult{}, appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err)
}
if !env.OK {
return "", appsSubprocessEnvelopeError("+git-credential-init reported failure")
return credentialInitResult{}, appsSubprocessEnvelopeError("+git-credential-init reported failure")
}
if strings.TrimSpace(env.Data.RepositoryURL) == "" {
return "", appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
return credentialInitResult{}, appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
}
return env.Data.RepositoryURL, nil
return credentialInitResult{
RepositoryURL: env.Data.RepositoryURL,
CommitAuthorName: env.Data.CommitAuthorName,
CommitAuthorEmail: env.Data.CommitAuthorEmail,
}, nil
}
// parseEnvFileFromEnvelope extracts data.env_file from a `+env-pull` success
@@ -527,7 +557,10 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
return err
}
appType := queryAppType(ctx, rctx, appID)
appType, err := queryAppType(ctx, rctx, appID)
if err != nil {
return err
}
policy := policyForAppType(appType)
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
@@ -595,16 +628,16 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
}
initLogf(rctx, "Issuing repository credentials for %s...", appID)
repoURL, err := issueCredentials(ctx, rctx, appID)
cred, err := issueCredentials(ctx, rctx, appID)
if err != nil {
return err
}
if err := validateRepoURLScheme(repoURL); err != nil {
if err := validateRepoURLScheme(cred.RepositoryURL); err != nil {
return err
}
initLogf(rctx, "Cloning into %s...", dir)
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", repoURL, dir); err != nil {
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", cred.RepositoryURL, dir); err != nil {
return appsExternalToolError(err, "git clone failed: %s", gitErr(stderr, err))
}
initLogf(rctx, "Checking out %s...", defaultInitBranch)
@@ -612,9 +645,10 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
return appsExternalToolError(err, "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
}
// Ensure a committer identity exists before the scaffold commit; only sets
// repo-local defaults when none is configured (existing identity is kept).
if err := ensureGitIdentity(ctx, dir); err != nil {
// Ensure a committer identity exists before the scaffold commit. Uses the
// author name/email from +git-credential-init when available; falls back
// to lark-cli-bot defaults when the server does not provide them.
if err := ensureGitIdentity(ctx, dir, cred.CommitAuthorName, cred.CommitAuthorEmail); err != nil {
return err
}
@@ -643,7 +677,7 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
out := map[string]interface{}{
"app_id": appID,
"repository_url": redactURLCredentials(repoURL),
"repository_url": redactURLCredentials(cred.RepositoryURL),
"branch": defaultInitBranch,
"clone_path": dir,
"scaffold": scaffold,
@@ -721,10 +755,10 @@ func pullEnv(ctx context.Context, rctx *common.RuntimeContext, appID, dir string
// issueCredentials runs `<self> apps +git-credential-init --app-id <id> --format json`
// and returns the repo_url it reports. Forwards --as when set.
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (string, error) {
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (credentialInitResult, error) {
self, err := os.Executable()
if err != nil {
return "", errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(err)
return credentialInitResult{}, errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(err)
}
args := []string{"apps", "+git-credential-init", "--app-id", appID, "--format", "json"}
if as := strings.TrimSpace(rctx.Str("as")); as != "" {
@@ -732,11 +766,11 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
}
stdout, stderr, err := initRunner.Run(ctx, "", self, args...)
if err != nil {
return "", appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)).
return credentialInitResult{}, appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)).
WithHint("ensure apps +git-credential-init is available and you are logged in").
WithCause(err)
}
return parseRepoURLFromEnvelope(stdout)
return parseCredentialInitEnvelope(stdout)
}
// commitAndPushIfDirty commits and pushes only when the working tree has

View File

@@ -21,6 +21,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/testutil/gitcmd"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -110,18 +111,24 @@ func TestDefaultCloneDir(t *testing.T) {
// --- pure-function tests ---
func TestParseRepoURL(t *testing.T) {
url, err := parseRepoURLFromEnvelope(`{"ok":true,"data":{"repository_url":"http://u:t@h/app_x.git"}}`)
result, err := parseCredentialInitEnvelope(`{"ok":true,"data":{"repository_url":"http://u:t@h/app_x.git","commit_author_name":"Alice","commit_author_email":"alice@example.com"}}`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if url != "http://u:t@h/app_x.git" {
t.Errorf("got %q", url)
if result.RepositoryURL != "http://u:t@h/app_x.git" {
t.Errorf("RepositoryURL got %q", result.RepositoryURL)
}
if result.CommitAuthorName != "Alice" {
t.Errorf("CommitAuthorName got %q", result.CommitAuthorName)
}
if result.CommitAuthorEmail != "alice@example.com" {
t.Errorf("CommitAuthorEmail got %q", result.CommitAuthorEmail)
}
}
func TestParseRepoURL_Errors(t *testing.T) {
for _, in := range []string{`not json`, `{"ok":false,"data":{}}`, `{"ok":true,"data":{}}`, `{"ok":true,"data":{"repository_url":""}}`} {
if _, err := parseRepoURLFromEnvelope(in); err == nil {
if _, err := parseCredentialInitEnvelope(in); err == nil {
t.Errorf("expected error for %q", in)
}
}
@@ -149,6 +156,22 @@ func withFakeRunner(t *testing.T, f *fakeCommandRunner) {
t.Cleanup(func() { initRunner = orig })
}
func stubAppType(reg *httpmock.Registry, appID, appType string) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/" + appID,
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": appID,
"app_type": appType,
},
},
},
})
}
func credInitOK(repoURL string) fakeCallResult {
return fakeCallResult{stdout: `{"ok":true,"data":{"repository_url":"` + repoURL + `"}}`}
}
@@ -313,7 +336,8 @@ func TestAppsInit_EmptyRepo_EndToEnd(t *testing.T) {
"git status": {stdout: " M src/app.ts\n"}, // scaffold produced changes
}}
withFakeRunner(t, f)
factory, stdout, _ := newAppsExecuteFactory(t)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
@@ -354,7 +378,8 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
}
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(filepath.Join(abs, ".env.local"))}}
withFakeRunner(t, f)
factory, stdout, _ := newAppsExecuteFactory(t)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
}
@@ -423,7 +448,8 @@ func TestAppsInit_HappyPathCleanTree(t *testing.T) {
"git status": {}, // clean tree after scaffold -> no commit/push
}}
withFakeRunner(t, f)
factory, stdout, _ := newAppsExecuteFactory(t)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
@@ -472,7 +498,8 @@ func TestAppsInit_DirtyTreeCommitPush(t *testing.T) {
"git status": {stdout: " M file.txt"},
}}
withFakeRunner(t, f)
factory, stdout, _ := newAppsExecuteFactory(t)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
@@ -542,7 +569,8 @@ func TestAppsInit_CloneFailure(t *testing.T) {
"git clone": {stderr: "fatal: unable to access 'http://u:t@h/r.git'", err: errors.New("exit 128")},
}}
withFakeRunner(t, f)
factory, stdout, _ := newAppsExecuteFactory(t)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
@@ -616,7 +644,8 @@ func TestAppsInit_AsPassthrough(t *testing.T) {
"git status": {},
}}
withFakeRunner(t, f)
factory, stdout, _ := newAppsExecuteFactory(t)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
// AppsInit.AuthTypes is ["user"], so the framework rejects --as bot. Use
@@ -722,7 +751,7 @@ func TestIsEmptyRepo(t *testing.T) {
// newAppsExecuteFactoryWithStderr mirrors newAppsExecuteFactory but also returns
// the stderr buffer, so tests can assert on the +init progress log lines that
// initLogf writes to IO().ErrOut.
func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
@@ -732,12 +761,12 @@ func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buf
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
}
factory, stdout, stderr, _ := cmdutil.TestFactory(t, cfg)
return factory, stdout, stderr
factory, stdout, stderr, reg := cmdutil.TestFactory(t, cfg)
return factory, stdout, stderr, reg
}
func TestAppsInit_Req1_Wording(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactoryWithStderr(t)
factory, stdout, _, _ := newAppsExecuteFactoryWithStderr(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
@@ -766,7 +795,8 @@ func TestAppsInit_Req1_Wording(t *testing.T) {
"git status": {},
}}
withFakeRunner(t, f)
factory2, stdout2, stderr2 := newAppsExecuteFactoryWithStderr(t)
factory2, stdout2, stderr2, reg2 := newAppsExecuteFactoryWithStderr(t)
stubAppType(reg2, "app_x", "FULL_STACK")
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory2, stdout2); err != nil {
t.Fatalf("run err=%v", err)
@@ -829,7 +859,8 @@ func TestAppsInit_EmptyRepo_TwoCommits(t *testing.T) {
"git status": {stdout: " A src/app.ts\n A .spark/meta.json\n A .agent/skills/steering/x.md\n"},
}}
withFakeRunner(t, f)
factory, stdout, _ := newAppsExecuteFactory(t)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
@@ -870,7 +901,8 @@ func TestAppsInit_EmptyRepo_AppCodeOnly_SingleCommit(t *testing.T) {
"git status": {stdout: " A src/app.ts\n"},
}}
withFakeRunner(t, f)
factory, stdout, _ := newAppsExecuteFactory(t)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
@@ -890,7 +922,8 @@ func TestAppsInit_EmptyRepo_ConfigOnly_SingleCommit(t *testing.T) {
"git status": {stdout: " A .spark/meta.json\n"},
}}
withFakeRunner(t, f)
factory, stdout, _ := newAppsExecuteFactory(t)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
@@ -910,7 +943,8 @@ func TestAppsInit_NonEmpty_SingleInitCommit(t *testing.T) {
"git status": {stdout: " M file.txt\n M .spark/meta.json\n"},
}}
withFakeRunner(t, f)
factory, stdout, _ := newAppsExecuteFactory(t)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
@@ -929,8 +963,7 @@ func TestAppsInit_NonEmpty_SingleInitCommit(t *testing.T) {
// gitMust runs a git command in dir with a real binary, failing the test on error.
func gitMust(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd := gitcmd.Command(dir, args...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v in %s failed: %v\n%s", args, dir, err, out)
@@ -946,6 +979,7 @@ func TestCommitAndPushIfDirty_RealGit_IgnoredAgentDir(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
gitcmd.SetSynchronousMaintenanceEnv(t)
// Bare remote so `git push origin sprint/default` succeeds.
remote := t.TempDir()
gitMust(t, remote, "init", "--bare", "-q", "--initial-branch", defaultInitBranch)
@@ -1067,6 +1101,7 @@ func TestCommitAndPushIfDirty_RealGit_NonEmptyUpgrade(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
gitcmd.SetSynchronousMaintenanceEnv(t)
remote := t.TempDir()
gitMust(t, remote, "init", "--bare", "-q", "--initial-branch", defaultInitBranch)
@@ -1289,7 +1324,8 @@ func TestAppsInit_EnvPull_Success(t *testing.T) {
"env-pull": envPullOK("/abs/app_x/.env.local"),
}}
withFakeRunner(t, f)
factory, stdout, _ := newAppsExecuteFactory(t)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -1327,7 +1363,8 @@ func TestAppsInit_EnvPull_NonFatal(t *testing.T) {
},
}}
withFakeRunner(t, f)
factory, stdout, _ := newAppsExecuteFactory(t)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("env-pull failure must be non-fatal, got: %v", err)
@@ -1366,7 +1403,8 @@ func TestAppsInit_AlreadyInitialized_RunsEnvPull(t *testing.T) {
envFile := filepath.Join(abs, ".env.local")
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(envFile)}}
withFakeRunner(t, f)
factory, stdout, _ := newAppsExecuteFactory(t)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -1413,7 +1451,8 @@ func TestAppsInit_AlreadyInitialized_EnvPullFailure_NonFatal(t *testing.T) {
},
}}
withFakeRunner(t, f)
factory, stdout, _ := newAppsExecuteFactory(t)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("env-pull failure must be non-fatal, got: %v", err)
}
@@ -1705,13 +1744,15 @@ func TestScaffoldInitArgs_WithAppType(t *testing.T) {
}
func TestPolicyForAppType(t *testing.T) {
// modern_html decouples all control points: skip install, env-pull, skills sync.
if p := policyForAppType("modern_html"); !p.skipInstall || !p.skipEnvPull || !p.skipSkillsSync {
t.Errorf("modern_html policy = %+v, want all skip flags set", p)
// modern_html and html decouple all control points: skip install, env-pull, skills sync, app sync.
for _, at := range []string{"modern_html", "html"} {
if p := policyForAppType(at); !p.skipInstall || !p.skipEnvPull || !p.skipSkillsSync || !p.skipAppSync {
t.Errorf("%s policy = %+v, want all skip flags set", at, p)
}
}
// Unlisted types (including "") get the zero-value policy: everything runs.
for _, at := range []string{"full_stack", "", "backend"} {
if p := policyForAppType(at); p.skipInstall || p.skipEnvPull || p.skipSkillsSync {
if p := policyForAppType(at); p.skipInstall || p.skipEnvPull || p.skipSkillsSync || p.skipAppSync {
t.Errorf("policy for %q = %+v, want zero value", at, p)
}
}
@@ -1757,7 +1798,7 @@ func configSetValue(calls [][]string, key string) (string, bool) {
func TestEnsureGitIdentity_SetsDefaultsWhenUnset(t *testing.T) {
f := &fakeCommandRunner{} // no "git config" result → `--get` returns empty stdout
withFakeRunner(t, f)
if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v, ok := configSetValue(f.calls, "user.name"); !ok || v != defaultGitUserName {
@@ -1774,7 +1815,7 @@ func TestEnsureGitIdentity_RespectsExisting(t *testing.T) {
"git config": {stdout: "Existing Dev\n"},
}}
withFakeRunner(t, f)
if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := configSetValue(f.calls, "user.name"); ok {
@@ -1790,7 +1831,7 @@ func TestEnsureGitIdentity_SetFailurePropagates(t *testing.T) {
"git config": {stderr: "boom", err: errors.New("exit 1")},
}}
withFakeRunner(t, f)
if err := ensureGitIdentity(context.Background(), "/repo"); err == nil {
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err == nil {
t.Error("expected error when git config set fails")
}
}

View File

@@ -13,22 +13,25 @@ import (
)
// queryAppType fetches the app's type string from the server via
// GET /open-apis/spark/v1/apps/{appID}. The server returns uppercase
// values ("HTML", "FULL_STACK", "MODERN_HTML"); this function normalizes
// to lowercase. Returns "" when the API is unavailable or returns an
// error — callers fall back to legacy behavior.
func queryAppType(ctx context.Context, rctx *common.RuntimeContext, appID string) string {
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))
// GET /open-apis/spark/v1/apps/{identifier}. The identifier can be either
// an app_id or a meta_token — the server resolves both. The server returns
// uppercase app_type values ("HTML", "FULL_STACK", "MODERN_HTML");
// this function normalizes to lowercase. Returns an error when the API
// is unavailable or the response is malformed — callers must not proceed
// with a fallback type to avoid creating the wrong project scaffold.
func queryAppType(ctx context.Context, rctx *common.RuntimeContext, identifier string) (string, error) {
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(identifier))
data, err := rctx.CallAPITyped("GET", path, nil, nil)
if err != nil {
fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: %v\n", err)
return ""
return "", err
}
appRaw, _ := data["app"].(map[string]interface{})
if appRaw == nil {
fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: response missing app object\n")
return ""
return "", appsSubprocessEnvelopeError("query app type: response missing app object")
}
appType, _ := appRaw["app_type"].(string)
return strings.ToLower(appType)
if strings.TrimSpace(appType) == "" {
return "", appsSubprocessEnvelopeError("query app type: response missing app_type")
}
return strings.ToLower(appType), nil
}

View File

@@ -43,7 +43,10 @@ func TestQueryAppType_Success(t *testing.T) {
},
})
result := queryAppType(context.Background(), rt, "app_test")
result, err := queryAppType(context.Background(), rt, "app_test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "modern_html" {
t.Errorf("queryAppType = %q, want modern_html", result)
}
@@ -65,7 +68,10 @@ func TestQueryAppType_FullStack(t *testing.T) {
},
})
result := queryAppType(context.Background(), rt, "app_fs")
result, err := queryAppType(context.Background(), rt, "app_fs")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "full_stack" {
t.Errorf("queryAppType = %q, want full_stack", result)
}
@@ -87,7 +93,10 @@ func TestQueryAppType_Html(t *testing.T) {
},
})
result := queryAppType(context.Background(), rt, "app_html")
result, err := queryAppType(context.Background(), rt, "app_html")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "html" {
t.Errorf("queryAppType = %q, want html", result)
}
@@ -102,9 +111,9 @@ func TestQueryAppType_APIError(t *testing.T) {
Body: map[string]interface{}{"code": float64(99999), "msg": "internal error"},
})
result := queryAppType(context.Background(), rt, "app_bad")
if result != "" {
t.Errorf("queryAppType = %q, want empty on error", result)
_, err := queryAppType(context.Background(), rt, "app_bad")
if err == nil {
t.Error("expected error on API failure")
}
}
@@ -119,9 +128,9 @@ func TestQueryAppType_MissingAppObject(t *testing.T) {
},
})
result := queryAppType(context.Background(), rt, "app_no")
if result != "" {
t.Errorf("queryAppType = %q, want empty when app object missing", result)
_, err := queryAppType(context.Background(), rt, "app_no")
if err == nil {
t.Error("expected error when app object missing")
}
}
@@ -141,8 +150,8 @@ func TestQueryAppType_EmptyAppType(t *testing.T) {
},
})
result := queryAppType(context.Background(), rt, "app_empty")
if result != "" {
t.Errorf("queryAppType = %q, want empty when app_type is empty", result)
_, err := queryAppType(context.Background(), rt, "app_empty")
if err == nil {
t.Error("expected error when app_type is empty")
}
}

View File

@@ -31,9 +31,13 @@ var AppsReleaseCreate = common.Shortcut{
{Name: "branch", Desc: "release branch (server uses default if omitted)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
appID := strings.TrimSpace(rctx.Str("app-id"))
if appID == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
if err := validateRealAppID(appID); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {

View File

@@ -30,9 +30,13 @@ var AppsReleaseGet = common.Shortcut{
{Name: "release-id", Desc: "release ID (the release_id returned by +release-create)", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
appID := strings.TrimSpace(rctx.Str("app-id"))
if appID == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
if err := validateRealAppID(appID); err != nil {
return err
}
if strings.TrimSpace(rctx.Str("release-id")) == "" {
return appsValidationParamError("--release-id", "--release-id is required")
}

View File

@@ -41,6 +41,21 @@ func withAppsHint(err error, hint string) error {
return err
}
// validateRealAppID checks that --app-id is a real app ID (app_ prefix).
// meta_token values are rejected with a hint to resolve via +get first.
func validateRealAppID(appID string) error {
if !strings.HasPrefix(appID, "app_") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
`--app-id must be an app_id starting with "app_".`,
).WithParam("--app-id").WithHint(
`If you have a meta_token or a /page/<token>/ link, first resolve it:
lark-cli apps +get --app-id <meta_token> -q '.data.app.app_id'
Then retry this command with the returned app_id.`,
)
}
return nil
}
// rejectOutputTraversal is a defense-in-depth pre-check on a user-supplied
// --output path. The authoritative guard is the local FileIO layer
// (validate.SafeOutputPath sandboxes every write to the cwd, resolving .. and

View File

@@ -75,6 +75,7 @@ var AppsGitCredentialInit = common.Shortcut{
"save the issued PAT in the local system credential store",
"write app-scoped git credential metadata",
"configure a URL-scoped Git credential helper in global git config when possible",
"return commit_author_name and commit_author_email for repo-local git identity",
}).
Params(gitCredentialIssueParams(appID))
},
@@ -90,6 +91,12 @@ var AppsGitCredentialInit = common.Shortcut{
"repository_url": result.GitHTTPURL,
"status": initStatus(result),
}
if result.CommitAuthorName != "" {
payload["commit_author_name"] = result.CommitAuthorName
}
if result.CommitAuthorEmail != "" {
payload["commit_author_email"] = result.CommitAuthorEmail
}
if result.ConfigWarning != "" {
payload["git_config_warning"] = result.ConfigWarning
}
@@ -461,11 +468,13 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC
}
}
issued := &gitcred.IssuedCredential{
AppID: firstString(source, "app_id", appID),
GitHTTPURL: firstString(source, "gitURL", "GitURL", "GitUrl", "gitUrl", "git_url", "git_http_url", "repository_url"),
Username: firstString(source, "username"),
PAT: firstString(source, "token", "Token", "pat", "password"),
ExpiresAt: firstInt64(source, "expiredTime", "ExpiredTime", "expired_time", "expires_at"),
AppID: firstString(source, "app_id", appID),
GitHTTPURL: firstString(source, "gitURL", "GitURL", "GitUrl", "gitUrl", "git_url", "git_http_url", "repository_url"),
Username: firstString(source, "username"),
PAT: firstString(source, "token", "Token", "pat", "password"),
ExpiresAt: firstInt64(source, "expiredTime", "ExpiredTime", "expired_time", "expires_at"),
CommitAuthorName: firstString(source, "commit_author_name"),
CommitAuthorEmail: firstString(source, "commit_author_email"),
}
if issued.AppID == "" {
issued.AppID = appID

View File

@@ -87,6 +87,7 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
"save the issued PAT in the local system credential store",
"write app-scoped git credential metadata",
"configure a URL-scoped Git credential helper in global git config when possible",
"return commit_author_name and commit_author_email for repo-local git identity",
})
}

View File

@@ -129,7 +129,13 @@ func (m *Manager) Init(ctx context.Context, profile ProfileContext, appID string
if previous != nil && previous.PATRef != "" && previous.PATRef != ref {
_ = m.Secrets.Remove(previous.PATRef)
}
result := &InitResult{AppID: appID, GitHTTPURL: url, Refreshed: previous != nil}
result := &InitResult{
AppID: appID,
GitHTTPURL: url,
Refreshed: previous != nil,
CommitAuthorName: issued.CommitAuthorName,
CommitAuthorEmail: issued.CommitAuthorEmail,
}
if m.GitConfig != nil {
if err := m.GitConfig.SetHelper(ctx, url, appID); err != nil {
result.ConfigWarning = err.Error()

View File

@@ -51,18 +51,22 @@ type CredentialRecord struct {
}
type IssuedCredential struct {
AppID string
GitHTTPURL string
Username string
PAT string
ExpiresAt int64
AppID string
GitHTTPURL string
Username string
PAT string
ExpiresAt int64
CommitAuthorName string
CommitAuthorEmail string
}
type InitResult struct {
AppID string
GitHTTPURL string
Refreshed bool
ConfigWarning string
AppID string
GitHTTPURL string
Refreshed bool
ConfigWarning string
CommitAuthorName string
CommitAuthorEmail string
}
type RemoveResult struct {

View File

@@ -1,73 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"fmt"
"net/http"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type htmlPublishResponse struct {
URL string
}
type appsHTMLPublishClient interface {
HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error)
}
type appsHTMLPublishAPI struct {
runtime *common.RuntimeContext
}
func (api appsHTMLPublishAPI) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) {
fd := larkcore.NewFormdata()
fd.AddFile("file", bytes.NewReader(tarball.Body))
apiResp, err := api.runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID)),
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return nil, client.WrapDoAPIError(err)
}
data, err := api.runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return nil, enrichHTMLPublishAPIError(err)
}
url, _ := data["url"].(string)
if url == "" {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"html-publish response is missing the published app url")
}
return &htmlPublishResponse{URL: url}, nil
}
// OAPI business error codes returned by the
// /apps/{id}/upload_and_release_html_code endpoint. Owned by the backend
// service; update when new codes are documented in the OAPI spec.
const (
errCodeBuildFailed = 90001 // tar.gz uploaded but server-side build failed
errCodeAppNotFound = 90002 // app_id unknown or caller lacks permission
)
func buildHTMLPublishFailureHint(code int) string {
switch code {
case errCodeBuildFailed:
return "server-side build failed: run `lark-cli apps +html-publish --app-id <your-app-id> --path <path> --dry-run` to inspect the packaged file list"
case errCodeAppNotFound:
return "the app does not exist or the caller has no access; ask the user to confirm the app_id (extract it from the app URL https://miaoda.feishu.cn/app/app_xxx after /app/, or take the app_xxx string directly)"
default:
return ""
}
}

View File

@@ -1,197 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"mime"
"mime/multipart"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func newAppsClientRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "test-app-" + strings.ToLower(t.Name()),
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
}
factory, _, _, reg := cmdutil.TestFactory(t, cfg)
rctx := common.TestNewRuntimeContextForAPI(context.Background(), nil, cfg, factory, core.AsUser)
return rctx, reg
}
func TestAppsHTMLPublishAPI_Success(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"url": "https://miaoda.feishu.cn/app/app_x",
},
},
}
reg.Register(stub)
api := appsHTMLPublishAPI{runtime: rctx}
tarball := &htmlPublishTarball{Body: []byte("fake"), Size: 4, SHA256: "abc"}
resp, err := api.HTMLPublish(context.Background(), "app_x", tarball)
if err != nil {
t.Fatalf("err=%v", err)
}
if resp.URL != "https://miaoda.feishu.cn/app/app_x" {
t.Fatalf("url=%q", resp.URL)
}
ct := stub.CapturedHeaders.Get("Content-Type")
mt, params, err := mime.ParseMediaType(ct)
if err != nil || mt != "multipart/form-data" {
t.Fatalf("content type %q wrong", ct)
}
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
saw := false
for {
p, err := mr.NextPart()
if err != nil {
break
}
if p.FormName() == "file" {
saw = true
}
}
if !saw {
t.Fatalf("multipart missing 'file' part")
}
}
func TestAppsHTMLPublishAPI_BusinessErrorHasHint(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
Body: map[string]interface{}{
"code": 90001,
"msg": "build failed: dependency conflict",
},
})
api := appsHTMLPublishAPI{runtime: rctx}
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
if err == nil {
t.Fatalf("expected error")
}
problem := requireAppsAPIProblem(t, err)
if problem.Code != errCodeBuildFailed {
t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
}
if problem.Hint == "" {
t.Fatalf("expected non-empty hint on code 90001")
}
if !strings.Contains(problem.Message, "build failed") {
t.Fatalf("missing failure message: %v", problem.Message)
}
}
func TestAppsHTMLPublishAPI_AppNotFoundClassified(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_missing/upload_and_release_html_code",
Body: map[string]interface{}{
"code": errCodeAppNotFound,
"msg": "app not found",
},
})
api := appsHTMLPublishAPI{runtime: rctx}
_, err := api.HTMLPublish(context.Background(), "app_missing", &htmlPublishTarball{Body: []byte("fake")})
problem := requireAppsAPIProblem(t, err)
if problem.Subtype != errs.SubtypeNotFound {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
}
if problem.Hint == "" {
t.Fatalf("expected app-not-found recovery hint")
}
}
func TestAppsHTMLPublishAPI_MissingURLIsInvalidResponse(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
api := appsHTMLPublishAPI{runtime: rctx}
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
problem := requireAppsProblem(t, err, errs.CategoryInternal)
if problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
}
}
func TestBuildHTMLPublishFailureHint_UnknownCodeReturnsEmpty(t *testing.T) {
// 默认分支:未识别的 code 返回空 hint让 Agent 用 message 兜底。
if hint := buildHTMLPublishFailureHint(99999); hint != "" {
t.Fatalf("unknown code should return empty hint, got %q", hint)
}
if hint := buildHTMLPublishFailureHint(0); hint != "" {
t.Fatalf("zero code should return empty hint, got %q", hint)
}
}
func TestBuildHTMLPublishFailureHint_KnownCodes(t *testing.T) {
if hint := buildHTMLPublishFailureHint(90001); hint == "" {
t.Fatalf("code 90001 should return non-empty hint")
}
if hint := buildHTMLPublishFailureHint(90002); hint == "" {
t.Fatalf("code 90002 should return non-empty hint")
}
}
func TestBuildHTMLPublishFailureHint_NotFoundHintNoLongerMentionsList(t *testing.T) {
hint := buildHTMLPublishFailureHint(90002)
if hint == "" {
t.Fatalf("code 90002 should return non-empty hint")
}
if strings.Contains(hint, "+list") {
t.Fatalf("hint must not point at hidden +list command, got: %q", hint)
}
if !strings.Contains(hint, "app_id") {
t.Fatalf("hint should reference app_id, got: %q", hint)
}
}
func TestAppsHTMLPublishAPI_MalformedResponseIsInvalidResponse(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
RawBody: []byte("{not json"),
})
api := appsHTMLPublishAPI{runtime: rctx}
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
problem := requireAppsProblem(t, err, errs.CategoryInternal)
if problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
}
}

View File

@@ -104,6 +104,22 @@ func TestDryRunFieldOps(t *testing.T) {
assertDryRunContains(t, dryRunFieldUpdate(ctx, rt), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
assertDryRunContains(t, dryRunFieldDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
assertDryRunContains(t, dryRunFieldSearchOptions(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1/options", "offset=3", "limit=30", "query=open")
autoNumberRT := newBaseTestRuntime(
map[string]string{
"base-token": "app_x",
"table-id": "tbl_1",
"field-id": "fld_1",
"json": `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`,
},
nil,
nil,
)
autoNumberDR := dryRunFieldUpdate(ctx, autoNumberRT)
assertDryRunContains(t, autoNumberDR, "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1", `"name":"编号"`, `"type":"auto_number"`, `"rules":[`, `"length":4`)
if out := autoNumberDR.Format(); strings.Contains(out, "auto_serial") || strings.Contains(out, "reformat_existing_records") || strings.Contains(out, "/open-apis/bitable/v1/") {
t.Fatalf("auto_number dry-run must stay on v3 field JSON, got:\n%s", out)
}
}
func TestDryRunRecordOps(t *testing.T) {
@@ -117,7 +133,7 @@ func TestDryRunRecordOps(t *testing.T) {
)
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
listFieldNamesAliasRT := newBaseTestRuntimeWithSlices(
listFieldNamesAliasRT := newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
map[string][]string{"field-names": {"Name", "Age"}},
nil,

View File

@@ -81,6 +81,37 @@ func runShortcutWithAuthTypes(t *testing.T, shortcut common.Shortcut, authTypes
return parent.ExecuteContext(context.Background())
}
func assertInvalidArgumentValidation(t *testing.T, err error, wantParam string, wantParams []string, messageContains string) {
t.Helper()
if err == nil {
t.Fatal("expected invalid-argument validation error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected invalid-argument validation problem, got %T %v", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T %v", err, err)
}
if validationErr.Param != wantParam {
t.Fatalf("param=%q, want %q", validationErr.Param, wantParam)
}
if wantParams != nil {
if len(validationErr.Params) != len(wantParams) {
t.Fatalf("params=%#v, want %v", validationErr.Params, wantParams)
}
for i, want := range wantParams {
if validationErr.Params[i].Name != want {
t.Fatalf("params=%#v, want %v", validationErr.Params, wantParams)
}
}
}
if messageContains != "" && !strings.Contains(err.Error(), messageContains) {
t.Fatalf("err=%v, want message containing %q", err, messageContains)
}
}
func TestBaseWorkspaceExecuteCreate(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer)
@@ -122,7 +153,7 @@ func TestBaseWorkspaceExecuteCreate(t *testing.T) {
if grant["user_open_id"] != "ou_testuser" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
}
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new base." {
if grant["message"] != "Granted the current CLI user full_access on the new base." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
@@ -469,9 +500,6 @@ func TestBaseWorkspaceExecuteCreateBotAutoGrantFailureDoesNotFailCreate(t *testi
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
}
if !strings.Contains(grant["message"].(string), "retry later") {
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
}
@@ -577,8 +605,9 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
t.Fatalf("stdout=%s", got)
wantDesc := "After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base."
if got := stdout.String(); !strings.Contains(got, wantDesc) {
t.Fatalf("stdout=%s, want desc %q", got, wantDesc)
}
})
@@ -587,8 +616,9 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
t.Fatalf("stdout=%s", got)
wantDesc := "After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base."
if got := stdout.String(); !strings.Contains(got, wantDesc) {
t.Fatalf("stdout=%s, want desc %q", got, wantDesc)
}
})
@@ -597,7 +627,7 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access") {
t.Fatalf("stdout=%s", got)
}
})
@@ -819,8 +849,189 @@ func TestBaseFieldExecuteUpdate(t *testing.T) {
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"fld_x"`) {
t.Fatalf("stdout=%s", got)
got := stdout.String()
for _, want := range []string{`"updated": true`, `"fld_x"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
}
func TestFieldUpdateResultAlwaysRecommendsReadback(t *testing.T) {
tests := []struct {
name string
field interface{}
submitted map[string]interface{}
hintContains []string
}{
{
name: "direct complex server type overrides simple submitted type",
field: map[string]interface{}{"type": "auto_number"},
submitted: map[string]interface{}{"type": "number"},
hintContains: []string{`submitted type "number"`, `server returned type "auto_number"`},
},
{
name: "nested simple server type still recommends readback",
field: map[string]interface{}{"field": map[string]interface{}{"type": "number"}},
submitted: map[string]interface{}{"type": "auto_number"},
hintContains: []string{`submitted type "auto_number"`, `server returned type "number"`},
},
{
name: "submitted simple type still recommends readback when response omits type",
field: map[string]interface{}{"id": "fld_x"},
submitted: map[string]interface{}{"type": "text"},
hintContains: []string{`type "text"`, "cannot determine the previous type"},
},
{
name: "missing type is conservative",
field: map[string]interface{}{"id": "fld_x"},
submitted: map[string]interface{}{"name": "Amount"},
hintContains: []string{"unknown or uncommon field type", "+field-get"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := fieldUpdateResult(map[string]interface{}{"field": tc.field, "updated": true}, tc.submitted)
if got["field_get_recommended"] != true || got["next_step"] != "field_get" {
t.Fatalf("result=%#v, want readback recommendation", got)
}
hint, _ := got["verification_hint"].(string)
for _, want := range tc.hintContains {
if !strings.Contains(hint, want) {
t.Fatalf("verification_hint=%q, want substring %q", hint, want)
}
}
})
}
}
func TestBaseFieldExecuteUpdateNoopReturnsAPIError(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
Body: map[string]interface{}{
"code": 800070003,
"msg": "no operation produced",
},
})
err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout)
if err == nil {
t.Fatal("expected the API no-op response to surface as an error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed API error, got %T %v", err, err)
}
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeUnknown || p.Code != 800070003 {
t.Fatalf("category/subtype/code=%s/%s/%d", p.Category, p.Subtype, p.Code)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T %v", err, err)
}
if got := stdout.String(); strings.TrimSpace(got) != "" {
t.Fatalf("no success envelope should be emitted on a no-op API error:\n%s", got)
}
}
func TestBaseFieldExecuteUpdateAutoNumberUsesV3FieldJSON(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"field": map[string]interface{}{"id": "fld_x", "name": "编号", "type": "auto_number"},
},
},
}
reg.Register(stub)
jsonBody := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
gotBody := string(stub.CapturedBody)
for _, want := range []string{
`"name":"编号"`,
`"type":"auto_number"`,
`"rules":[`,
`"date_format":"yyyyMM"`,
`"length":4`,
} {
if !strings.Contains(gotBody, want) {
t.Fatalf("request body missing %q:\n%s", want, gotBody)
}
}
for _, forbidden := range []string{"auto_serial", "reformat_existing_records", `"type":1005`} {
if strings.Contains(gotBody, forbidden) {
t.Fatalf("request body must not contain v1 field %q:\n%s", forbidden, gotBody)
}
}
got := stdout.String()
for _, want := range []string{`"updated": true`, `"fld_x"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
for _, forbidden := range []string{`"reformat_existing_records"`} {
if strings.Contains(got, forbidden) {
t.Fatalf("stdout must not expose %q:\n%s", forbidden, got)
}
}
}
func TestBaseFieldExecuteUpdateDoesNotRejectExtraJSONKeys(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_x", "name": "编号", "type": "auto_number"},
},
}
reg.Register(stub)
// Unknown v3 keys are forwarded unchanged; the server remains the source of
// truth for whether a field-update property is supported.
jsonBody := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"incremental_number","length":4}]},"reformat_existing_records":true}`
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if gotBody := string(stub.CapturedBody); !strings.Contains(gotBody, `"reformat_existing_records":true`) {
t.Fatalf("request body must preserve unknown v3 key:\n%s", gotBody)
}
if got := stdout.String(); !strings.Contains(got, `"updated": true`) {
t.Fatalf("expected successful update, got: %s", got)
}
}
func TestBaseFieldValidateAllowsRatingMaxAboveLimit(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
shortcut common.Shortcut
runtime *common.RuntimeContext
}{
{
name: "create",
shortcut: BaseFieldCreate,
runtime: newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_x", "json": `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`}, nil, nil),
},
{
name: "update",
shortcut: BaseFieldUpdate,
runtime: newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_x", "field-id": "fld_x", "json": `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`}, nil, nil),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if err := tc.shortcut.Validate(ctx, tc.runtime); err != nil {
t.Fatalf("rating max above 10 should not be blocked by CLI validation: %v", err)
}
})
}
}
@@ -1092,8 +1303,32 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"Status","type":"text"}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"fld_new"`) {
t.Fatalf("stdout=%s", got)
got := stdout.String()
for _, want := range []string{`"created": true`, `"fld_new"`, `"field_get_recommended": false`, `"next_step": "done"`, `"verification_hint"`} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
})
t.Run("create generated field recommends readback", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_auto", "name": "编号", "type": "auto_number"},
},
})
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"编号","type":"auto_number"}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
for _, want := range []string{`"created": true`, `"fld_auto"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
})
@@ -1140,11 +1375,58 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
if len(fields) != 2 {
t.Fatalf("fields len=%d output=%#v", len(fields), data)
}
if data["field_get_recommended"] != false || data["next_step"] != "done" || data["verification_hint"] == nil {
t.Fatalf("simple batch create must carry field_get_recommended:false + next_step:done + verification_hint: %#v", data)
}
if !strings.Contains(string(firstStub.CapturedBody), `"name":"A"`) || !strings.Contains(string(secondStub.CapturedBody), `"name":"B"`) {
t.Fatalf("unexpected request bodies: %s / %s", firstStub.CapturedBody, secondStub.CapturedBody)
}
})
t.Run("create array with generated field recommends readback", func(t *testing.T) {
oldDelay := fieldCreateBatchDelay
fieldCreateBatchDelay = 0
t.Cleanup(func() { fieldCreateBatchDelay = oldDelay })
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
BodyFilter: func(body []byte) bool {
return strings.Contains(string(body), `"name":"Title"`)
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_title", "name": "Title", "type": "text"},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
BodyFilter: func(body []byte) bool {
return strings.Contains(string(body), `"name":"编号"`)
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_no", "name": "编号", "type": "auto_number"},
},
})
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[{"name":"Title","type":"text"},{"name":"编号","type":"auto_number"}]`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["created"] != true || data["total"] != float64(2) {
t.Fatalf("unexpected output: %#v", data)
}
if _, ok := data["fields"].([]interface{}); !ok {
t.Fatalf("batch create must keep fields array: %#v", data)
}
if data["field_get_recommended"] != true || data["next_step"] != "field_get" || data["verification_hint"] == nil {
t.Fatalf("batch with auto_number must recommend readback: %#v", data)
}
})
t.Run("delete", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -1319,6 +1601,32 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list field names alias preserves quoted commas and at-sign names", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "field_id=A%2CB&field_id=%40Owner&limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"A,B", "@Owner"},
"record_id_list": []interface{}{"rec_alias_special"},
"data": []interface{}{[]interface{}{"value-1", "value-2"}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{
"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1",
"--field-names", `"A,B",@Owner`, "--format", "json",
}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_alias_special"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list json format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -1615,28 +1923,162 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list legacy fields flag rejected", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
t.Run("list fields alias accepts JSON array projection", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"record_id_list": []interface{}{"rec_fields"},
"data": []interface{}{[]interface{}{"Alice", 18}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--fields", `["Name","Age"]`, "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list field ids and field names alias are mutually exclusive", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "Name", "--field-names", "Age"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "--field-id and --field-names are mutually exclusive") {
t.Run("list field names alias accepts repeated projection", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"record_id_list": []interface{}{"rec_fields"},
"data": []interface{}{[]interface{}{"Alice", 18}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-names", "Name", "--field-names", "Age", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list legacy fields flag rejected in dry-run", func(t *testing.T) {
t.Run("list projection aliases report only supplied ambiguous inputs", func(t *testing.T) {
baseArgs := []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x"}
cases := []struct {
name string
args []string
wantParam string
wantParams []string
}{
{name: "canonical and fields alias", args: []string{"--field-id", "Name", "--fields", `["Age"]`}, wantParam: "--field-id", wantParams: []string{"--field-id", "--fields"}},
{name: "canonical and field names alias", args: []string{"--field-id", "Name", "--field-names", "Age"}, wantParam: "--field-id", wantParams: []string{"--field-id", "--field-names"}},
{name: "compatibility aliases", args: []string{"--fields", `["Name"]`, "--field-names", "Age"}, wantParam: "--fields", wantParams: []string{"--fields", "--field-names"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := append(append([]string{}, baseArgs...), tc.args...)
err := runShortcut(t, BaseRecordList, args, factory, stdout)
assertInvalidArgumentValidation(t, err, tc.wantParam, tc.wantParams, "mutually exclusive")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Hint != "Use only --field-id for projection." {
t.Fatalf("hint=%q, want canonical projection guidance", validationErr.Hint)
}
})
}
})
t.Run("search json conflict reports each supplied projection parameter", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
err := runShortcut(t, BaseRecordSearch, []string{
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
"--json", `{"keyword":"Alice","search_fields":["Name"]}`,
"--field-names", "Age",
}, factory, stdout)
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--field-names"}, "mutually exclusive")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || !strings.Contains(validationErr.Hint, "inside --json") {
t.Fatalf("hint=%q, want JSON-body guidance", validationErr.Hint)
}
})
t.Run("list canonical and alias projections reject duplicates consistently", func(t *testing.T) {
cases := []struct {
name string
args []string
param string
}{
{name: "canonical", args: []string{"--field-id", "Cost--USD", "--field-id", "Cost--USD"}, param: "--field-id"},
{name: "fields alias", args: []string{"--fields", `["Cost--USD","Cost--USD"]`}, param: "--fields"},
{name: "field names alias", args: []string{"--field-names", "Cost--USD", "--field-names", "Cost--USD"}, param: "--field-names"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := append([]string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x"}, tc.args...)
err := runShortcut(t, BaseRecordList, args, factory, stdout)
assertInvalidArgumentValidation(t, err, tc.param, []string{tc.param}, "duplicate field id")
})
}
})
t.Run("search fields alias accepts JSON array projection", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
searchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"record_id_list": []interface{}{"rec_search"},
"data": []interface{}{[]interface{}{"Alice", 18}},
},
},
}
reg.Register(searchStub)
if err := runShortcut(t, BaseRecordSearch, []string{
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
"--keyword", "Alice", "--search-field", "Name", "--fields", `["Name","Age"]`, "--format", "json",
}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if body := string(searchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
t.Fatalf("captured body=%s", body)
}
})
t.Run("get field names alias accepts repeated projection", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_1"},
"fields": []interface{}{"Name", "Age"},
"data": []interface{}{[]interface{}{"Alice", 18}},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{
"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1",
"--field-names", "Name", "--field-names", "Age", "--format", "json",
}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if body := string(batchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("get", func(t *testing.T) {
@@ -2015,16 +2457,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"record_id_list": []interface{}{"rec_1"},
"update": map[string]interface{}{"Status": "Done"},
"ignored_fields": []interface{}{"Formula"},
},
},
})
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Status":"Done"}}`}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"update_records":{"rec_1":{"Status":["Done"]}}}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"update"`) || !strings.Contains(got, `"Done"`) {
if got := stdout.String(); !strings.Contains(got, `"ignored_fields"`) || !strings.Contains(got, `"Formula"`) {
t.Fatalf("stdout=%s", got)
}
})
@@ -2036,20 +2476,16 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_update",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_1"},
},
"data": map[string]interface{}{},
},
}
reg.Register(updateStub)
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Name":"Alice","Status":"Done"}}`}, factory, stdout); err != nil {
input := `{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", input}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
t.Fatalf("stdout=%s", got)
}
body := string(updateStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_1"]`) || !strings.Contains(body, `"patch":{"Name":"Alice","Status":"Done"}`) {
if !strings.Contains(body, `"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}`) {
t.Fatalf("request body=%s", body)
}
})

View File

@@ -29,7 +29,7 @@ func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.D
Body(buildBaseCopyBody(runtime)).
Set("base_token", runtime.Str("base-token"))
if runtime.IsBot() {
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base.")
}
return d
}
@@ -37,7 +37,7 @@ func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.D
func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI()
if runtime.IsBot() {
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base.")
}
d.
POST("/open-apis/base/v3/bases").

View File

@@ -28,23 +28,16 @@ func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool
}
func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, stringArrayFlags, nil, boolFlags, intFlags)
}
func newBaseTestRuntimeWithSlices(stringFlags map[string]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, nil, stringSliceFlags, boolFlags, intFlags)
}
func newBaseTestRuntimeWithArraysAndSlices(stringFlags map[string]string, stringArrayFlags map[string][]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
for name := range stringFlags {
cmd.Flags().String(name, "", "")
}
for name := range stringArrayFlags {
cmd.Flags().StringArray(name, nil, "")
}
for name := range stringSliceFlags {
cmd.Flags().StringSlice(name, nil, "")
if name == "field-names" {
cmd.Flags().StringSlice(name, nil, "")
} else {
cmd.Flags().StringArray(name, nil, "")
}
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
@@ -61,11 +54,6 @@ func newBaseTestRuntimeWithArraysAndSlices(stringFlags map[string]string, string
_ = cmd.Flags().Set(name, value)
}
}
for name, values := range stringSliceFlags {
for _, value := range values {
_ = cmd.Flags().Set(name, value)
}
}
for name, value := range boolFlags {
if value {
_ = cmd.Flags().Set(name, "true")
@@ -477,6 +465,40 @@ func TestBaseLimitPageSizeAliasIsHidden(t *testing.T) {
}
}
func TestBaseRecordProjectionAliasesAreHidden(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
}{
{name: "record list", shortcut: BaseRecordList},
{name: "record search", shortcut: BaseRecordSearch},
{name: "record get", shortcut: BaseRecordGet},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
primary := cmd.Flags().Lookup("field-id")
if primary == nil || primary.Hidden {
t.Fatalf("public projection flag --field-id missing or hidden: %#v", primary)
}
help := cmd.Flags().FlagUsages()
for _, aliasName := range []string{"fields", "field-names"} {
alias := cmd.Flags().Lookup(aliasName)
if alias == nil || !alias.Hidden {
t.Fatalf("projection alias --%s should exist and be hidden: %#v", aliasName, alias)
}
if strings.Contains(help, "--"+aliasName) {
t.Fatalf("help should not include hidden --%s:\n%s", aliasName, help)
}
}
})
}
}
func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
@@ -786,7 +808,8 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
name: "record batch update json",
shortcut: BaseRecordBatchUpdate,
wantHelp: []string{
`batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`,
"update_records maps each record ID to its field map",
`{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`,
},
},
}
@@ -822,6 +845,10 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
"does not auto-upsert by business key",
"use +field-list to confirm real writable fields",
"do not write system fields, formula, lookup, or attachment fields",
"Sub-record/child-record path",
"set that link field to a parent record reference array",
`{"Parent Link":[{"id":"rec_xxx"}]}`,
"do not look for parent_record_id or a separate child-record API",
"CellValue happy path: text/phone/url",
"select -> \"Todo\"",
"multi-select -> [\"Tag A\",\"Tag B\"]",
@@ -854,9 +881,11 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
name: "record batch update",
shortcut: BaseRecordBatchUpdate,
wantTips: []string{
"Happy path fields: record_id_list is the target record IDs",
"patch is a field map applied unchanged to every target record",
"Do not use +record-batch-update for per-row different values",
"Happy path field: update_records",
"update_records maps each record ID to its own field map",
`{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`,
"contains only optional ignored_fields",
"does not check whether record IDs exist",
"use +field-list to confirm real writable fields",
"Batch update supports max 200 records per call",
"CellValue happy path: text/phone/url",
@@ -970,11 +999,17 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
if strings.Contains(help, "reformat-existing-records") {
t.Fatalf("+field-update must not expose a --reformat-existing-records flag:\n%s", help)
}
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
wantTips := []string{
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
`Example auto_number update: lark-cli base +field-update`,
`When --json.type is "auto_number", updating the numbering rules also reapplies them to existing numbers`,
"just submit the target field definition and do not add extra low-level parameters",
"full field-definition PUT semantics",
"Read the current field first with +field-get",
"Type conversion is allowlist-based",
@@ -987,6 +1022,9 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
if strings.Contains(tips, "--reformat-existing-records") {
t.Fatalf("+field-update tips must not ask agents to pass --reformat-existing-records:\n%s", tips)
}
}
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
@@ -1109,6 +1147,10 @@ func TestBaseFieldValidate(t *testing.T) {
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"f1","type":"formula"}`}, map[string]bool{"i-have-read-guide": true}, nil)); err != nil {
t.Fatalf("formula update validate err=%v", err)
}
autoNumberJSON := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"incremental_number","length":4}]}}`
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": autoNumberJSON}, nil, nil)); err != nil {
t.Fatalf("auto number update validate err=%v", err)
}
}
func TestBaseTableValidate(t *testing.T) {
@@ -1230,13 +1272,89 @@ func TestBaseRecordValidate(t *testing.T) {
)); err != nil {
t.Fatalf("record search json with sort-json validate err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "keyword": "Bob"},
nil,
nil,
)); err == nil || !strings.Contains(err.Error(), "--json is mutually exclusive") {
t.Fatalf("err=%v", err)
))
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--keyword"}, "mutually exclusive")
err = BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "fields": "Name"},
map[string][]string{"field-id": {"fld_name"}},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--field-id", "--fields"}, "mutually exclusive")
}
func TestBaseRecordSearchProjectionLimit(t *testing.T) {
ctx := context.Background()
fields := make([]string, 51)
for i := range fields {
fields[i] = "Field " + strconv.Itoa(i+1)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
map[string][]string{"search-field": {"Name"}, "field-id": fields[:50]},
nil,
nil,
)); err != nil {
t.Fatalf("50 projection fields should be accepted: %v", err)
}
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
map[string][]string{"search-field": {"Name"}, "field-id": fields},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--field-id", []string{"--field-id"}, "maximum limit of 50")
body, marshalErr := json.Marshal(map[string]interface{}{
"keyword": "Alice",
"search_fields": []string{"Name"},
"select_fields": fields,
})
if marshalErr != nil {
t.Fatalf("marshal search body: %v", marshalErr)
}
err = BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": string(body)},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "maximum limit of 50")
}
func TestRecordSearchJSONNullProjectionIsOmitted(t *testing.T) {
runtime := newBaseTestRuntime(map[string]string{
"json": `{"keyword":"Alice","search_fields":["Name"],"select_fields":null,"sort":{"sort_config":[{"field":"Updated","desc":true}]}}`,
}, nil, nil)
body, err := recordSearchJSONBody(runtime)
if err != nil {
t.Fatalf("recordSearchJSONBody() error = %v", err)
}
if _, exists := body["select_fields"]; exists {
t.Fatalf("select_fields:null must normalize to omitted, body=%#v", body)
}
if sortConfig, ok := body["sort"].([]interface{}); !ok || len(sortConfig) != 1 {
t.Fatalf("sort normalization must continue after omitting null select_fields, body=%#v", body)
}
}
func TestBaseRecordSearchJSONProjectionParamIgnoresFlagLikeFieldNames(t *testing.T) {
ctx := context.Background()
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{
"base-token": "b",
"table-id": "tbl_1",
"json": `{"keyword":"cost","search_fields":["Name"],"select_fields":["Cost--USD","Cost--USD"]}`,
},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "duplicate field id")
}
func TestBasePaginationValidationRejectsOutOfRange(t *testing.T) {

View File

@@ -5,6 +5,7 @@ package base
import (
"context"
"fmt"
"strings"
"time"
@@ -36,7 +37,10 @@ func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.D
func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
bodies, _ := parseFieldCreateBodies(pc, runtime.Str("json"))
bodies, err := parseFieldCreateBodies(pc, runtime.Str("json"))
if err != nil {
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
}
dr := common.NewDryRunAPI().
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime))
@@ -48,7 +52,10 @@ func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *commo
func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
if err != nil {
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
}
return common.NewDryRunAPI().
PUT("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
Body(body).
@@ -166,10 +173,10 @@ func executeFieldCreate(runtime *common.RuntimeContext) error {
fields = append(fields, data)
}
if len(fields) == 1 {
runtime.Out(map[string]interface{}{"field": fields[0], "created": true}, nil)
runtime.Out(fieldCreateResult(map[string]interface{}{"field": fields[0], "created": true}, bodies[0]), nil)
return nil
}
runtime.Out(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, nil)
runtime.Out(fieldCreateBatchResult(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, bodies), nil)
return nil
}
@@ -197,10 +204,101 @@ func executeFieldUpdate(runtime *common.RuntimeContext) error {
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"field": data, "updated": true}, nil)
runtime.Out(fieldUpdateResult(map[string]interface{}{"field": data, "updated": true}, body), nil)
return nil
}
func fieldCreateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} {
readbackRecommended, reason := fieldWriteReadbackRecommendation(submitted, "create")
return attachFieldReadbackRecommendation(result, readbackRecommended, reason)
}
// fieldCreateBatchResult attaches the same top-level readback contract to a
// multi-field create. It recommends +field-get when any submitted field is a
// computed/linked/generated (or unknown) type, so agents know when to verify
// server state without breaking the existing fields/total structure.
func fieldCreateBatchResult(result map[string]interface{}, submitted []map[string]interface{}) map[string]interface{} {
recommend := false
reason := "simple fields created successfully; use +field-get only when extra properties or explicit verification are needed"
for _, body := range submitted {
if rec, r := fieldWriteReadbackRecommendation(body, "create"); rec {
recommend = true
reason = r
break
}
}
return attachFieldReadbackRecommendation(result, recommend, reason)
}
func fieldUpdateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} {
returnedType := normalizeFieldType(fieldResultType(result["field"]))
submittedType := normalizeFieldType(common.GetString(submitted, "type"))
readbackRecommended, reason := fieldUpdateReadbackRecommendation(returnedType, submittedType)
return attachFieldReadbackRecommendation(result, readbackRecommended, reason)
}
func fieldUpdateReadbackRecommendation(returnedType, submittedType string) (bool, string) {
if returnedType != "" && submittedType != "" && returnedType != submittedType {
return true, fmt.Sprintf("field update submitted type %q but the server returned type %q; run +field-get and verify record values before declaring completion", submittedType, returnedType)
}
fieldType := returnedType
if fieldType == "" {
fieldType = submittedType
}
if recommended, reason := fieldTypeReadbackRecommendation(fieldType, "update"); recommended {
return true, reason + "; sample record values when generated, computed, or converted values are in scope"
}
return true, fmt.Sprintf("field update request succeeded for type %q, but +field-update cannot determine the previous type; run +field-get and sample record values if the type changed before declaring completion", fieldType)
}
func attachFieldReadbackRecommendation(result map[string]interface{}, readbackRecommended bool, reason string) map[string]interface{} {
result["field_get_recommended"] = readbackRecommended
result["verification_hint"] = reason
if readbackRecommended {
result["next_step"] = "field_get"
} else {
result["next_step"] = "done"
}
return result
}
func fieldWriteReadbackRecommendation(submitted map[string]interface{}, operation string) (bool, string) {
fieldType := normalizeFieldType(common.GetString(submitted, "type"))
return fieldTypeReadbackRecommendation(fieldType, operation)
}
func fieldTypeReadbackRecommendation(fieldType, operation string) (bool, string) {
fieldType = normalizeFieldType(fieldType)
switch fieldType {
case "formula", "lookup", "auto_number", "link":
return true, fmt.Sprintf("computed, linked, or generated field %s should be verified with +field-get before declaring completion", operation)
case "text", "number", "select", "datetime", "checkbox", "user", "group_chat", "attachment", "location":
return false, fmt.Sprintf("simple field %s returned successfully; use +field-get only when extra properties or explicit verification are needed", operation)
default:
return true, "unknown or uncommon field type; run +field-get to avoid assuming the submitted JSON fully describes server state"
}
}
func normalizeFieldType(fieldType string) string {
return strings.ToLower(strings.TrimSpace(fieldType))
}
func fieldResultType(value interface{}) string {
field, ok := value.(map[string]interface{})
if !ok {
return ""
}
if fieldType := strings.ToLower(strings.TrimSpace(common.GetString(field, "type"))); fieldType != "" {
return fieldType
}
nested, ok := field["field"].(map[string]interface{})
if !ok {
return ""
}
return strings.ToLower(strings.TrimSpace(common.GetString(nested, "type")))
}
func executeFieldDelete(runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")
tableIDValue := baseTableID(runtime)

View File

@@ -27,7 +27,9 @@ var BaseFieldUpdate = common.Shortcut{
baseHighRiskYesTip,
`Example text: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
`Example select: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}' --yes`,
`Example auto_number update: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "编号" --json '{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}' --yes`,
"Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.",
`When --json.type is "auto_number", updating the numbering rules also reapplies them to existing numbers; just submit the target field definition and do not add extra low-level parameters.`,
"Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.",
"Formula and lookup updates require reading the corresponding guide first.",
"Agent hint: use the lark-base skill's field-update guide for JSON shape, type-conversion rules, and limits.",

View File

@@ -238,14 +238,14 @@ func TestRecordSelectionHelpers(t *testing.T) {
t.Fatalf("err=%v", err)
}
fields, err = resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{"Name"}})
fields, err = resolveRecordGetSelectFields(nil, "--field-id", map[string]interface{}{"select_fields": []interface{}{"Name"}})
if err != nil || !reflect.DeepEqual(fields, []string{"Name"}) {
t.Fatalf("fields=%v err=%v", fields, err)
}
if _, err := resolveRecordGetSelectFields([]string{"Name"}, map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
if _, err := resolveRecordGetSelectFields([]string{"Name"}, "--field-id", map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v", err)
}
if _, err := resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
if _, err := resolveRecordGetSelectFields(nil, "--field-id", map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
t.Fatalf("err=%v", err)
}

View File

@@ -12,18 +12,19 @@ import (
var BaseRecordBatchUpdate = common.Shortcut{
Service: "base",
Command: "+record-batch-update",
Description: "Batch update records",
Description: "Batch update records with record-specific fields",
Risk: "write",
Scopes: []string{"base:record:update"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: `batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`, Required: true},
{Name: "json", Desc: `batch update JSON object; update_records maps each record ID to its field map, e.g. {"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`, Required: true},
},
Tips: append([]string{
"Happy path fields: record_id_list is the target record IDs; patch is a field map applied unchanged to every target record.",
"Do not use +record-batch-update for per-row different values; call +record-upsert per record or use another supported flow.",
"Happy path field: update_records maps each record ID to its own field map.",
`Example: {"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}.`,
"The response contains only optional ignored_fields and does not check whether record IDs exist; read records back when confirmation is required.",
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Batch update supports max 200 records per call; use the record-batch-update guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),

View File

@@ -21,7 +21,9 @@ var BaseRecordGet = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
{Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"},
recordProjectionFieldFlag("field ID or name to project; repeat to keep only needed columns"),
recordProjectionAliasFlag("fields"),
recordProjectionAliasFlag("field-names"),
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
recordReadFormatFlag(),
},

View File

@@ -20,8 +20,9 @@ var BaseRecordList = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
recordListFieldRefFlag(),
recordListFieldNamesAliasFlag(),
recordProjectionFieldFlag("field ID or name to include; repeat to project only needed fields"),
recordProjectionAliasFlag("fields"),
recordProjectionAliasFlag("field-names"),
recordListViewRefFlag(),
recordFilterFlag(),
recordSortFlag(),
@@ -44,9 +45,6 @@ var BaseRecordList = common.Shortcut{
"Use --field-id repeatedly to keep output small and aligned with the task.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateRecordListFieldAlias(runtime); err != nil {
return err
}
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
@@ -61,6 +59,9 @@ var BaseRecordList = common.Shortcut{
return err
}
}
if _, err := recordProjectionFields(runtime); err != nil {
return err
}
return validateRecordQueryOptions(runtime)
},
DryRun: dryRunRecordList,
@@ -72,22 +73,6 @@ var BaseRecordList = common.Shortcut{
},
}
func recordListFieldRefFlag() common.Flag {
flag := fieldRefFlag(false)
flag.Type = "string_array"
flag.Desc = "field ID or name to include; repeat to project only needed fields"
return flag
}
func recordListFieldNamesAliasFlag() common.Flag {
return common.Flag{
Name: "field-names",
Type: "string_slice",
Desc: "hidden alias for --field-id; accepts comma-separated field names",
Hidden: true,
}
}
func recordListViewRefFlag() common.Flag {
flag := viewRefFlag(false)
flag.Desc = "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view"
@@ -102,10 +87,3 @@ func recordReadFormatFlag() common.Flag {
Desc: "output format: markdown (default) | json",
}
}
func validateRecordListFieldAlias(runtime *common.RuntimeContext) error {
if runtime.Changed("field-id") && runtime.Changed("field-names") {
return baseFlagErrorf("--field-id and --field-names are mutually exclusive; use --field-id")
}
return nil
}

View File

@@ -5,15 +5,18 @@ package base
import (
"context"
"errors"
"net/url"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
const maxRecordSelectionCount = 200
const maxBatchGetSelectFieldCount = 100
const maxRecordSearchSelectFieldCount = 50
var recordCellValueHappyPathTips = []string{
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
@@ -46,7 +49,6 @@ func validateRecordSelection(runtime *common.RuntimeContext) error {
func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) {
recordIDs := runtime.StrArray("record-id")
fieldIDs := runtime.StrArray("field-id")
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if len(recordIDs) > 0 && jsonRaw != "" {
return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive")
@@ -69,7 +71,11 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
if err != nil {
return recordSelection{}, err
}
selectFields, err := resolveRecordGetSelectFields(fieldIDs, body)
projectionFields, err := recordProjectionFields(runtime)
if err != nil {
return recordSelection{}, err
}
selectFields, err := resolveRecordGetSelectFields(projectionFields, recordProjectionParam(runtime), body)
if err != nil {
return recordSelection{}, err
}
@@ -83,7 +89,11 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
if err != nil {
return recordSelection{}, err
}
selectFields, err := resolveRecordGetSelectFields(fieldIDs, nil)
projectionFields, err := recordProjectionFields(runtime)
if err != nil {
return recordSelection{}, err
}
selectFields, err := resolveRecordGetSelectFields(projectionFields, recordProjectionParam(runtime), nil)
if err != nil {
return recordSelection{}, err
}
@@ -104,20 +114,20 @@ func normalizeRecordIDs(values interface{}) ([]string, error) {
})
}
func resolveRecordGetSelectFields(flagFields []string, body map[string]interface{}) ([]string, error) {
func resolveRecordGetSelectFields(flagFields []string, projectionParam string, body map[string]interface{}) ([]string, error) {
fromFlags, err := normalizeRecordGetSelectFields(flagFields)
if err != nil {
return nil, err
return nil, withValidationParam(err, projectionParam)
}
if body == nil {
return fromFlags, nil
}
rawJSONFields, ok := body["select_fields"]
if !ok {
if !ok || rawJSONFields == nil {
return fromFlags, nil
}
if len(fromFlags) > 0 {
return nil, baseFlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
return nil, baseFlagErrorf(`%s and --json field "select_fields" are mutually exclusive`, projectionParam)
}
items, ok := rawJSONFields.([]interface{})
if !ok {
@@ -128,18 +138,26 @@ func resolveRecordGetSelectFields(flagFields []string, body map[string]interface
}
normalized, err := normalizeRecordGetSelectFields(items)
if err != nil {
return nil, err
return nil, withValidationParam(err, "--json")
}
return normalized, nil
}
func normalizeRecordGetSelectFields(values interface{}) ([]string, error) {
return normalizeRecordSelectFields(values, maxBatchGetSelectFieldCount)
}
func normalizeRecordSearchSelectFields(values interface{}) ([]string, error) {
return normalizeRecordSelectFields(values, maxRecordSearchSelectFieldCount)
}
func normalizeRecordSelectFields(values interface{}, max int) ([]string, error) {
return normalizeStringList(values, stringListNormalizeOptions{
typeError: "field selection must be a string array",
itemName: "field selection item",
duplicateName: "field id",
limitName: "field selection",
max: maxBatchGetSelectFieldCount,
max: max,
allowNil: true,
allowEmpty: true,
})
@@ -211,7 +229,11 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
params := url.Values{}
params.Set("offset", strconv.Itoa(offset))
params.Set("limit", strconv.Itoa(limit))
for _, field := range recordListFields(runtime) {
fields, err := recordProjectionFields(runtime)
if err != nil {
return common.NewDryRunAPI()
}
for _, field := range fields {
params.Add("field_id", field)
}
if viewID := runtime.Str("view-id"); viewID != "" {
@@ -375,11 +397,121 @@ func validateRecordJSON(runtime *common.RuntimeContext) error {
return err
}
func recordListFields(runtime *common.RuntimeContext) []string {
if runtime.Changed("field-names") {
return runtime.StrSlice("field-names")
func recordProjectionFieldFlag(desc string) common.Flag {
flag := fieldRefFlag(false)
flag.Type = "string_array"
flag.Desc = desc
return flag
}
func recordProjectionAliasFlag(name string) common.Flag {
flagType := "string_array"
if name == "field-names" {
// Preserve the original compatibility contract: --field-names uses
// pflag's CSV parser, including quoted commas, and treats @ literally.
flagType = "string_slice"
}
return runtime.StrArray("field-id")
return common.Flag{
Name: name,
Type: flagType,
Desc: "hidden alias for --field-id projection",
Hidden: true,
}
}
func recordProjectionParam(runtime *common.RuntimeContext) string {
switch {
case runtime.Changed("fields"):
return "--fields"
case runtime.Changed("field-names"):
return "--field-names"
default:
return "--field-id"
}
}
func withValidationParam(err error, param string) error {
if err == nil || param == "" {
return err
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
return err
}
reason := validationErr.Error()
// The caller knows which input produced this validation error. Replace any
// params inferred from the rendered message: field values such as Cost--USD
// must not be mistaken for a --USD flag.
validationErr.Param = param
validationErr.Params = []errs.InvalidParam{{Name: param, Reason: reason}}
return err
}
func recordProjectionFields(runtime *common.RuntimeContext) ([]string, error) {
return recordProjectionFieldsWithLimit(runtime, maxBatchGetSelectFieldCount)
}
func recordSearchProjectionFields(runtime *common.RuntimeContext) ([]string, error) {
return recordProjectionFieldsWithLimit(runtime, maxRecordSearchSelectFieldCount)
}
func recordProjectionFieldsWithLimit(runtime *common.RuntimeContext, max int) ([]string, error) {
fieldIDs := runtime.StrArray("field-id")
fieldIDsSet := runtime.Changed("field-id")
fieldsSet := runtime.Changed("fields")
fieldNamesSet := runtime.Changed("field-names")
projectionParams := make([]string, 0, 3)
if fieldIDsSet {
projectionParams = append(projectionParams, "--field-id")
}
if fieldsSet {
projectionParams = append(projectionParams, "--fields")
}
if fieldNamesSet {
projectionParams = append(projectionParams, "--field-names")
}
if len(projectionParams) > 1 {
invalidParams := make([]errs.InvalidParam, 0, len(projectionParams))
for _, param := range projectionParams {
invalidParams = append(invalidParams, errs.InvalidParam{Name: param, Reason: "mutually exclusive"})
}
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s are mutually exclusive", strings.Join(projectionParams, " and ")).
WithParam(projectionParams[0]).
WithParams(invalidParams...).
WithHint("Use only --field-id for projection.")
}
if fieldsSet {
return recordProjectionAliasFields(runtime, "fields", max)
}
if fieldNamesSet {
return recordProjectionAliasFields(runtime, "field-names", max)
}
fields, err := normalizeRecordSelectFields(fieldIDs, max)
return fields, withValidationParam(err, "--field-id")
}
func recordProjectionAliasFields(runtime *common.RuntimeContext, flagName string, max int) ([]string, error) {
var fields []string
if flagName == "field-names" {
fields = runtime.StrSlice(flagName)
} else {
pc := newParseCtx(runtime)
values := runtime.StrArray(flagName)
fields = make([]string, 0, len(values))
for _, raw := range values {
parsed, err := parseStringListFlexible(pc, raw, flagName)
if err != nil {
return nil, withValidationParam(err, "--"+flagName)
}
fields = append(fields, parsed...)
}
}
if len(fields) == 0 {
err := baseFlagErrorf("--%s must include at least one field", flagName)
return nil, withValidationParam(err, "--"+flagName)
}
normalized, err := normalizeRecordSelectFields(fields, max)
return normalized, withValidationParam(err, "--"+flagName)
}
func executeRecordList(runtime *common.RuntimeContext) error {
@@ -392,7 +524,10 @@ func executeRecordList(runtime *common.RuntimeContext) error {
}
limit := getPaginationLimit(runtime)
params := map[string]interface{}{"offset": offset, "limit": limit}
fields := recordListFields(runtime)
fields, err := recordProjectionFields(runtime)
if err != nil {
return err
}
if len(fields) > 0 {
params["field_id"] = fields
}

View File

@@ -9,6 +9,7 @@ import (
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -174,7 +175,10 @@ func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{
if len(searchFields) > 0 {
body["search_fields"] = searchFields
}
selectFields := recordListFields(runtime)
selectFields, err := recordSearchProjectionFields(runtime)
if err != nil {
return nil, err
}
if len(selectFields) > 0 {
body["select_fields"] = selectFields
}
@@ -203,6 +207,19 @@ func recordSearchJSONBody(runtime *common.RuntimeContext) (map[string]interface{
}
func normalizeRecordSearchJSONBody(body map[string]interface{}) error {
if rawSelectFields, ok := body["select_fields"]; ok {
if rawSelectFields == nil {
delete(body, "select_fields")
} else {
selectFields, err := normalizeRecordSearchSelectFields(rawSelectFields)
if err != nil {
return withValidationParam(err, "--json")
}
if len(selectFields) > 0 {
body["select_fields"] = selectFields
}
}
}
if rawSort, ok := body["sort"]; ok {
if sortConfig, err := normalizeRecordSortValue(rawSort, "--json.sort"); err == nil {
body["sort"] = sortConfig
@@ -219,8 +236,20 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
}
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if jsonRaw != "" {
if recordSearchHasJSONExclusiveFlagInputs(runtime) {
return baseFlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
if exclusiveParams := recordSearchJSONExclusiveFlagParams(runtime); len(exclusiveParams) > 0 {
allParams := append([]string{"--json"}, exclusiveParams...)
invalidParams := make([]errs.InvalidParam, 0, len(allParams))
for _, param := range allParams {
invalidParams = append(invalidParams, errs.InvalidParam{Name: param, Reason: "mutually exclusive"})
}
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--json is mutually exclusive with %s",
strings.Join(exclusiveParams, " and "),
).
WithParam("--json").
WithParams(invalidParams...).
WithHint("Put keyword, search, projection, view, and pagination fields inside --json, or omit --json.")
}
_, err := recordSearchJSONBody(runtime)
return err
@@ -242,17 +271,31 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
return err
}
}
if _, err := recordSearchProjectionFields(runtime); err != nil {
return err
}
return validateRecordQueryOptions(runtime)
}
func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool {
return strings.TrimSpace(runtime.Str("keyword")) != "" ||
len(runtime.StrArray("search-field")) > 0 ||
len(recordListFields(runtime)) > 0 ||
runtime.Str("view-id") != "" ||
runtime.Changed("offset") ||
runtime.Changed("limit") ||
runtime.Changed("page-size")
func recordSearchJSONExclusiveFlagParams(runtime *common.RuntimeContext) []string {
names := []string{
"keyword",
"search-field",
"field-id",
"fields",
"field-names",
"view-id",
"offset",
"limit",
"page-size",
}
params := make([]string, 0, len(names))
for _, name := range names {
if runtime.Changed(name) {
params = append(params, "--"+name)
}
}
return params
}
func formatRecordQueryPriorityTip() string {

View File

@@ -23,7 +23,9 @@ var BaseRecordSearch = common.Shortcut{
{Name: "json", Desc: `record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`},
{Name: "keyword", Desc: "keyword for record search; required unless --json is used"},
{Name: "search-field", Type: "string_array", Desc: "field ID or name to search; repeat for multiple fields; required unless --json is used"},
recordListFieldRefFlag(),
recordProjectionFieldFlag("field ID or name to include; repeat to project only needed fields"),
recordProjectionAliasFlag("fields"),
recordProjectionAliasFlag("field-names"),
recordListViewRefFlag(),
recordFilterFlag(),
recordSortFlag(),

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