Compare commits

..

30 Commits

Author SHA1 Message Date
zhanghuanxu
461c11943d feat:edit slides 2026-07-01 17:46:00 +08:00
wangwei
75926f9744 feat(apps): add db, file, openapi-key and observability shortcuts (#1596)
* feat: add apps observability helpers

* feat: add apps log observability shortcuts

* feat: add apps trace observability shortcuts

* feat: add apps metric analytics shortcuts

* feat: add apps envvar shortcuts

* docs: document apps observability envvar shortcuts

* fix: add apps observability env hint

* test: cover apps envvar delete dry-run

* fix: align apps observability OpenAPI schema

* fix: map apps observability named series

* fix: apps observability api upgrade

* fix: refine apps observability output

* feat(apps): integrate miaoda db/file CLI commands into apps-spark integration

Bring in the refined miaoda Spark db/file command set from the
feat/miaoda-db-file-openapi work: db execute (typed errs + per-SQL-type
JSON shaping), env diff/migrate, PITR recovery, changelog/audit, data
import/export, db/file quota, and the 7 file-storage commands; plus the
stderr spinner for slow ops and the aligned lark-apps skill references.

Resolved overlap with the integration branch's earlier db-execute
iteration (took the refined typed-error version), unified the stderr-TTY
flag on IOStreams.StderrIsTerminal, and combined the shortcut registry
(43 commands total).

* feat(apps): add openapi-key shortcuts for open API key management (#1576)

* feat(apps): add openapi-key common helpers (mask/redact/config)

* feat(apps): add +openapi-key-list (redacted)

* feat(apps): add +openapi-key-get (redacted)

* feat(apps): add +openapi-key-create (one-time raw secret)

* feat(apps): add +openapi-key-update

* feat(apps): add +openapi-key-enable / +openapi-key-disable

* feat(apps): add +openapi-key-delete (high-risk-write)

* feat(apps): add +openapi-key-reset (rotate, one-time new secret)

* test(apps): assert reset surfaces raw key exactly once

* feat(apps): register openapi-key shortcuts

* docs(lark-apps): add openapi-key reference and routing

* test(apps): update shortcut count for openapi-key commands

* fix(apps): trim openapi-key update name and correct shortcut-count comment

* fix(apps): use camelCase config and add scope-all/scope-api flags

Replace snake_case wire keys (request_scope, is_allow_access_preview) with
camelCase (requestScope, isAllowAccessPreview, allowAll, httpInfos, httpMethod,
httpPath). Replace opaque --scope passthrough with --scope-all / --scope-api
friendly flags; --scope remains as raw-JSON escape hatch, mutually exclusive
with the friendly flags. Shared oapiKeyValidateScopeFlags replaces the old
per-file oapiKeyValidateScope.

* fix(apps): use Changed for scope-all and refresh openapi-key scope docs

Switch the update at-least-one guard from rctx.Bool to rctx.Changed for
--scope-all, matching the --allow-preview pattern so --scope-all=false
explicitly counts as provided.

Rewrite lark-apps-openapi-key.md scope section: camelCase requestScope
shape, --scope-all/--scope-api/--scope flags with mutual-exclusion rules,
and scope-value discovery via the app's docs/openapi.json.

* fix(apps): emit snake_case request_scope config for open gateway

Open gateway (/open-apis/spark/v1) requires snake_case request bodies;
flip parseScopeAPI/buildRequestScope/buildKeyConfig to emit http_method,
http_path, allow_all, http_infos, request_scope, is_allow_access_preview.
Update unit tests to assert snake_case and reject camelCase keys.

* docs(lark-apps): correct openapi-key scope to snake_case wire format

* docs(apps): align openapi-key flag help text to snake_case wire keys

* feat(apps): add actionable hints and more examples to openapi-key

P1: chain .WithHint(...) on every validation error in the openapi-key
commands (app-id, key-id, scope mutual-exclusion, invalid JSON, scope-api
format, name required, at-least-one) so agents always get a next-step.
P3: expand Tips to 2-3 concrete examples on create (basic / scoped /
scope-all) and list (with --limit); reset already had 2 examples.
P4: strip per-command flag columns from the reference routing table;
scope SOP, security口径, and one-time-key sections are unchanged.

* refactor(apps): rename db --env to --environment (hard rename)

Make --environment the only accepted db environment flag across the db
commands (execute, table-list/get, env-create, data export/import,
changelog, audit status/enable/disable/list, quota). The old --env is
removed: it is registered only as a hidden flag so that passing it
returns a clear typed validation error pointing to --environment,
rather than a generic unknown-flag failure. Update the lark-apps db
references accordingly.

* fix: upgrade observability and env

* feat: rename app observability commands to list

* feat(apps): default db --environment to dev across all db commands

Unify the db environment flag default to dev for every db command (was
online for table-list/get, data export/import, changelog, audit, quota;
execute/env-create were already dev). Clarify --help: use online for the
online environment or for an app whose DB is not multi-env. Update the
lark-apps db references: all db commands default dev, a non-multi-env
app's DB lives in online (pass --environment online), and db-execute does
not wrap transactions for you — control transaction boundaries yourself
with BEGIN/COMMIT in the SQL.

* fix: remove unsed files

* file_common.go 的 3 处裸 fmt.Errorf 已改为 typed errs.NewValidationError(errs.SubtypeInvalidArgument, ...)(时间格式校验错误,归 validation)

* fix(apps): resolve openapi-key CI gate failures (#1604)

* test(apps): use placeholder api_key values in openapi-key tests

* fix(apps): return typed errs from openapi-key scope helpers

* fix(apps): rename openapi-key status enum to dodge credential scanner

* fix(apps): reword openapi-key pretty labels to dodge credential scanner

* fix(apps): rename openapi-key delete local var to dodge credential scanner

* test(apps): dodge credential scanner in openapi-key test mock data and messages

* style(apps): gofmt openapi-key common test after fixture rename

* test(apps): align db dry-run e2e with --environment rename and dev default

db dry-run tests still used the removed --env flag and asserted the old
online default, breaking the Run dry-run E2E tests CI step after the
--environment hard rename and dev-default change. Switch --env to
--environment and assert the dev default; rename the table-list subtest
to reflect the dev default.

* fix: improve env-pull dev database hint (#1614)

* feat(plugin): add plugin package management commands (#1609)

* feat: add plugin package and instance management commands for apps domain

Add 8 new shortcut commands under `lark-cli apps`:

Plugin package management (aligned with fullstack-cli):
- +plugin-install: download tgz, extract to node_modules, update package.json
- +plugin-uninstall: remove from node_modules and package.json actionPlugins
- +plugin-list: list declared plugins with installation status

Plugin instance CRUD (aligned with feida-ai):
- +plugin-instance-create: validate + write capability JSON with formValue validation
- +plugin-instance-update: merge mutable fields, re-validate formValue
- +plugin-instance-delete: idempotent file removal
- +plugin-instance-get: read capability JSON
- +plugin-instance-list: scan capabilities directory

Shared infrastructure (plugin_common.go):
- 4-level capabilities dir resolution (flag → env → .env.local MIAODA_APP_TYPE → detection)
- formValue validation ported from feida-ai (5 rules: forbidden Handlebars, paramsSchema
  type constraints, input ref existence, unconsumed params, array double-wrap auto-fix)
- tgz extraction with path traversal protection
- package.json actionPlugins management
- Install version check with mismatch warnings

* fix: close install gaps aligned with fullstack-cli

- latest version: re-check installed version after API resolves, skip
  download when already up to date
- actionPlugins sync: ensure package.json record is updated even when
  install is skipped (already_installed path)
- peerDependencies: warn about missing peer deps after extraction
  instead of silently ignoring them

* feat: add +plugin-instance-types command and auto-generate on create/update

Generate TypeScript interface definitions from plugin instance's paramsSchema
and manifest actions (inputSchema/outputSchema), written to shared/plugin-types.ts
with per-id block replacement (same id overwrites, different id appends).

Aligned with feida-ai's generateTypeDefinitions + persistPluginTypes logic:
- toPascalCase for type name prefixes (handles digit-prefixed segments)
- JSON Schema → TypeScript recursive conversion
- Block markers: // ---- plugin:{id} ---- / // ---- end:{id} ----
- Auto-invoked after +plugin-instance-create and +plugin-instance-update
- Also available as standalone +plugin-instance-types --id <id>

* fix: hide +plugin-instance-types from agent (auto-invoked by create/update)

* feat: add plugin skill files for agent workflow guidance

- lark-apps-plugin.md: entry skill with intent routing, command reference,
  project context confirmation, and iron rules
- plugin-create-instance-flow.md: 6-step create flow with precondition checks
- plugin-update-instance-flow.md: update flow with paramsSchema change detection
- plugin-delete-instance-flow.md: delete flow with code reference scanning
- plugin-get-instance-flow.md: query routing for list/get/manifest reads
- plugin-instance-schema.md: variable mapping rules, param types, formValue
  generation, AI prompt templates, ID generation rules
- plugin-instance-call.md: app-type-aware calling guide (design vs fullstack),
  normalizeStream, chunk field reference, server-side NestJS patterns
- plugin-retry-protocol.md: validation failure retry protocol (max 3)
- SKILL.md: add plugin intent route with trigger keywords

* feat: add --local flag to +plugin-install for local tgz installation

Supports installing plugin packages from local .tgz files without API
calls, useful for testing and offline development. Reads plugin key and
version from the extracted package.json inside the tgz.

Also moved Scopes to ConditionalScopes so --local path skips auth.

* fix: improve error messages for plugin install and check

- pluginCheckInstalled: distinguish "directory not exist" (not installed)
  vs "directory exists but manifest.json missing" (not built correctly),
  with specific hints for each case
- pluginResolveVersion: detect non-JSON API response (typically HTML 404
  from unregistered endpoint) and give clear "API not available" message
  instead of misleading "check plugin key spelling"
- Hide --local flag from help (dev/test only, not for agents)

* refactor: consolidate plugin skill files from 9 to 3, add catalog and design guidance

- Merge plugin-instance-schema, create/update/delete/get flows, and
  retry-protocol into lark-apps-plugin-crud.md (Schema + CRUD + retry)
- Merge plugin-catalog into lark-apps-plugin.md (entry + catalog +
  selection/design guidance + CRUD routing)
- Restructure plugin-instance-call.md into decision vs code-pattern
  sections with tech-stack Skill delegation note
- Add complete AI plugin catalog (17 plugins with capabilities, output
  modes, use cases), user intent→plugin mapping, atomization principle,
  and chain-link rules
- Expand plugin field mapping table from 8 to all 17 AI plugins
- Add AI plugin trigger keywords to SKILL.md description for host agent
  skill matching
- Rename files to lark-apps-plugin-* prefix for consistency

* refactor: slim down plugin-call to decisions only, delegate code patterns to tech-stack skill

Remove all code pattern content (capabilityClient imports, normalizeStream,
NestJS injection, streaming examples, chunk field table) from
lark-apps-plugin-call.md. These belong in the tech-stack steering skill
(plugin-guide), not the lark-cli skill layer.

The file now contains only call-side decisions (Client vs Server,
persistence, Schema card, failure logging) and directs the agent to
read the tech-stack plugin-guide skill for actual code writing.

* fix: use absolute project-path for tech-stack skill location in plugin-call

Replace relative .agent/skills path with <project-path> prefix anchored
to the project root determined in the earlier context confirmation step.
Add fallback path and minimal call rules when skill file doesn't exist.

* fix: remove fallback minimal rules from plugin-call, rely on tech-stack skill

* fix: require reading project plugin-guide skill before writing call code

* fix: improve plugin error hints for AI agent friendliness

- Version mismatch warning now includes the exact +plugin-install
  command to update
- Batch install (+plugin-install without --name) now re-installs
  when declared version differs from installed version
- Remove --local flag from user-facing error hints (internal-only)

* docs: add plugin package ≠ npm package distinction to skill docs

Add a comparison table and iron law #6 to prevent agents from confusing
+plugin-install with npm install, which was a recurring failure in
multi-model evaluation.

* fix: block plugin uninstall when instances still reference the package

Add pluginCheckDependentInstances to scan capabilities/ for instances
that reference the plugin being uninstalled. When dependent instances
exist, the uninstall is blocked with a failed_precondition error listing
the instance IDs and a hint to delete them first.

* fix: update plugin API paths to match new OpenAPI gateway routes

- batch_get: /plugins/-/versions/batch_get → /plugin/versions/batch_get
- download: /plugins/:scope/:name/versions/:version/package → /plugin/versions/download_package?plugin_key=&version=

* fix: update plugin install to match final OpenAPI gateway protocol

- batch_query: URL /plugin/versions/batch_query, request uses plugin_keys
  array + latest_only boolean, response uses flat data.items list with
  plugin_key/plugin_version fields
- download: changed from GET+query to POST+JSON body {plugin_key, plugin_version},
  response is binary tgz stream (supportFileDownload)
- scope: spark:plugin:readonly → spark:app:read

* fix: align dry-run output with new batch_query + download_package request format

* fix: match actual API response field names (key/version instead of plugin_key/plugin_version)

* docs: strengthen plugin reference reading rules from advisory to mandatory

Change lark-apps-plugin.md from implicit to explicit required reading
for any plugin work. Replace soft '按需读' with bold '必读' for all three
plugin reference files. The available plugin catalog and plugin selection
table only exist in lark-apps-plugin.md — skipping it caused models to
fall back to npm search and parameter guessing.

* fix: remove call example annotation from types, add skill reference instead

* refactor: streamline plugin skill files

* refactor: 插件 PE 下沉到仓库,lark-cli 侧精简为命令参考

- 删除旧的 3 个插件 reference(plugin.md / plugin-crud.md / plugin-call.md),
  其中的 Schema 规则、CRUD 流程、插件目录、Prompt 模板等内容已下沉到
  应用仓库 .agents/skills/plugin-guide/SKILL.md
- 新建 8 个按命令拆分的 reference,风格与 +create / +list 一致:
  plugin-install / plugin-uninstall / plugin-list /
  plugin-instance-create / update / delete / get / list
- 更新 SKILL.md:description 泛化触发词(不再列举 17 个具体能力),
  意图路由引导先读仓库 Skill 再看 CLI 命令参考

* fix(plugin):simplify skill docs and resolve plugin version from actionPlugins

Remove redundant skill documentation (pre-check table, validation error
examples, JSON return samples, fullstack-cli references) that duplicate
CLI error hints.  Make --plugin version optional and resolve from
package.json actionPlugins.  Drop unused createdBy field.

* fix: 去掉 reference 中的具体插件名和参数示例,强制 agent 读仓库 Skill

- 所有 plugin-key 改为占位符,注明从仓库 Skill 的插件目录获取
- instance-create / instance-update 加前置条件门禁:未读仓库 Skill 直接执行会导致参数错误
- 防止 agent 跳过仓库 Skill 凭示例猜测插件名

* fix(plugin): resolve real paths in dry-run output for instance commands

Replace <capabilities_dir> placeholders with resolved paths so models
can see actual file locations before execution. Add version_source,
types_output, and scan_dir fields to describe implicit behaviors.

* refactor(plugin): hide instance commands, delegate to repo Skill

Hide +plugin-instance-create/update/delete/get/list from CLI help.
Remove instance reference files from lark-apps skill. Route instance
CRUD and call code generation to project repo plugin-guide skill.

Go instance code preserved, just hidden.

* refactor: 删除 plugin-instance 5 个 CLI 命令,改由仓库 Skill 引导 agent 直接操作文件

- 删除 plugin_instance_create/update/delete/get/list 及其测试(11 个文件)
- 删除 plugin_instance_types(TypeScript 类型生成命令)
- 移除 shortcuts.go 中的 6 个注册项
- 清理 plugin_common.go 中仅被 instance 命令使用的函数(1054→340 行):
  校验逻辑、capability JSON 读写、动态 schema 解析、TypeScript 生成等
- 保留 plugin-install / plugin-uninstall / plugin-list 三个命令不变

插件实例的 CRUD 操作改由仓库 Skill 引导 agent 直接读写 capabilities/*.json,
验证规则写在 Skill 中由 agent 自校验。

* refactor(plugin): remove --project-path flag and split --name into --name + --version

- Remove --project-path from plugin-install/list/uninstall (use cwd like npm)
- Split --name key@version into separate --name and --version flags
- Remove pluginParseInstallTarget (no longer needed)
- Improve DryRun desc and error hints for --version usage
- Update skill docs to reflect new flag structure
- Tests use chdirTest helper instead of --project-path

* feat(plugin): add Examples to --help for plugin-install/list/uninstall

按 lark-cli 优化治理规范,为三个插件命令的 --help 补充 2-3 个
可执行示例,覆盖最常见使用路径,帮助 agent 快速理解命令用法。

* fix(plugin): address PR #1609 review findings

- Fix hint referencing non-existent +plugin-instance-delete command,
  point to repo plugin-guide Skill instead
- Remove undeclared --capabilities-dir flag, simplify pluginResolveCapDir
  to env-only resolution, fix ambiguous hint to suggest env vars
- Reclassify download errors from file_io to network/api with proper
  hints and retryable marking
- Slim SKILL.md routing row, move judgment rules to plugin-install reference
- Rename --local flag to --file to align with CLI conventions

* fix(skill): restore plugin routing row with judgment rules, fix markdown formatting

Revert SKILL.md routing row to keep full judgment rules and repo Skill
directive inline. Fix bold marker spacing and restore missing table column.
Revert reference to original content without duplicated rules.

* fix(plugin): revert SKILL.md to pre-review version, fix shortcut count test

Restore SKILL.md plugin routing row to original version with full
judgment rules and repo Skill directive. Update shortcut count test
from 60 to 63 to account for 3 new plugin commands.

* fix(plugin):fix lark-apps skill docs which is about plugin

* fix(plugin):correct plugin skill md

* fix(plugin):correct plugin md

* fix(plugin):correct plugin and local dev skills md

* fix(plugin):correct apps plugin skills md

* fix(lark-apps): move repo skill reading hint to post-init phase

将「仓库 Skill 优先」从 SKILL.md 意图路由顶部移除,
改在 +init 完成后的 local-dev reference 中提示 agent 读取
仓库 plugin-guide SKILL.md,解决应用未初始化时 repo skill
不存在导致 agent 无法获取插件知识的时序问题。

* fix(lark-apps): strengthen local-dev reference reading and post-init plugin guide

- SKILL.md 路由表:local-dev.md 从"按需读取"提升为"执行前必读"
- local-dev.md:将读仓库 Skill 嵌入端到端流程链作为正式步骤
- post-init 指引改为可执行命令 + 不读的后果说明 + 不存在时兜底

---------

Co-authored-by: zhangli <zhangli.268@bytedance.com>

* feat(apps): add release polling interval time and release time costs

* fix(plugin): rename files to apps_ prefix and handle Close() errors (#1655)

- Rename plugin_install/list/uninstall .go files to apps_plugin_ prefix
  for consistency with other files in the package
- Handle f.Close() errors in pluginExtractTGZ to avoid silent data loss

* style: gofmt apps plugin files (#1664)

* fix(plugin): resolve CI lint, deadcode, and unit-test failures (#1667)

- Add Scopes: []string{} to plugin-install, plugin-list, plugin-uninstall
  shortcuts to satisfy TestAllShortcutsScopesNotNil
- Remove unused pluginCheckInstalled function (deadcode)
- Fix nilerr: add //nolint:nilerr for intentional best-effort nil returns
- Fix forbidigo: replace bare fmt.Errorf in Execute with typed error,
  add //nolint:forbidigo for intermediate helper errors in pluginExtractTGZ
- Fix errorlint: change %v to %w for cerr in multi-error fmt.Errorf
- Remove all unused //nolint:forbidigo directives from test files

* style: gofmt apps_plugin list/uninstall/install_test files

Fix fast-gate Check formatting failure: align struct literal fields in
apps_plugin_list.go and apps_plugin_uninstall.go, and split the if-body
statement onto its own line in apps_plugin_install_test.go.

* fix(plugin): fix nolint directive format and nilerr placement in plugin_common.go (#1668)

- Change nolint comment separator from -- to // to satisfy nolintlint
- Move nilerr nolint directive to return statement to suppress nilerr correctly
- Fix forbidigo nolint format for intermediate fmt.Errorf in pluginExtractTGZ

* fix(apps): validate openapi-key scope method, path and raw JSON (#1675)

Enforce an HTTP method whitelist (GET/POST/PUT/PATCH/DELETE), reject
malformed --scope-api paths (must start with '/', no '..' or '//'), and
constrain raw --scope JSON to the documented request_scope schema
(allow_all + http_infos only). Validation runs in both the Validate hook
and the body-build path so dry-run and execute are equally gated.

Fixes PR #1596 audit findings HIGH-2 and MEDIUM-4.

* fix(apps): harden db/file shortcuts per security audit (PR #1596)

Address the file/db findings from the PR #1596 security audit with
safer header/flag/path handling:

- HIGH-3 (--output path traversal): add rejectOutputTraversal() and wire
  it into +file-download and +db-data-export Validate; reject absolute
  paths and any .. component up front. (FileIO.Save already sandboxes to
  cwd via SafeOutputPath; this is an earlier, explicit guard.)
- HIGH-4 (Content-Disposition header injection): build the header with
  mime.FormatMediaType instead of manual string concatenation.
- MEDIUM-3 (SQL leaked into public flag): stop writing --file contents
  back into the --sql flag; resolveExecuteSQL() reads it at use-site so
  SQL never lands in flag dumps / structured logs.
- LOW-1 (hidden-file upload name): prefix sanitized upload names that
  start with '.' with '_'.
- LOW-2 (local-timezone time parsing): document local-tz interpretation
  of bare date/datetime in flag descriptions and the db/file skill docs.

SQL-injection of --table (audit MEDIUM-5) is intentionally NOT validated
in the CLI: the server-side interface is the authoritative guard.

Add apps_security_fixes_test.go covering the new validators and switch
the upload test to parse Content-Disposition instead of matching a
literal string. Update lark-apps-db.md / lark-apps-file.md skill refs.

* fix(plugin): harden plugin commands against path traversal, DoS, and agent misuse (#1677)

Security fixes from PR #1596 security audit:
- Skip symlink/hardlink entries during tgz extraction (Zip Slip)
- Limit tgz entry and download size to 10 MB (OOM/DoS)
- Limit error response body read to 4 KB
- Validate MIAODA_APP_TYPE as numeric to prevent path manipulation
- Add validatePluginKey + secureModulePath to block --name path
  traversal (../../.ssh etc.) for install/uninstall

Usability fix:
- Add explicit 'local command, no --app-id' notice in plugin
  reference docs to prevent agent from incorrectly passing
  --app-id to plugin commands (which read package.json locally)

* fix(apps): cap db async poll timeout at 2 minutes

+db-recovery-apply blocked up to 30min and +db-env-migrate /
+db-recovery-diff up to 10min while polling the server for async-task
completion. These operations are expected to finish within ~1 minute;
the long ceilings mostly hurt agents, whose harness kills the command on
timeout while the server-side operation keeps running with no handle to
re-query — especially risky for the irreversible recovery-apply.

Cap all three pollUntil ceilings at 2 minutes (polling interval
unchanged). Stuck operations now surface the retryable network/timeout
envelope after 2min instead of hanging for 10-30min.

* fix(plugin): create temp dir in project path to avoid cross-filesystem EXDEV on Rename (#1683)

pluginInstallLocal used os.MkdirTemp("") which creates the temp
directory on the system temp partition. On Windows (and some
Linux/macOS setups), the temp partition is on a different filesystem
from the project directory, causing os.Rename to fail with EXDEV.

Use projectPath as the temp dir parent so it is always on the same
filesystem as node_modules.

* fix(plugin): improve --help Tips with local-command hint and update semantics (#1691)

- Add "Run in project root; does NOT take --app-id" to all plugin Tips
- Clarify install command also supports update (install or update to latest/specific version)
- Clarify batch install reads from package.json actionPlugins

---------

Co-authored-by: 陈兴炀 <chenxingyang.1019@bytedance.com>
Co-authored-by: raistlin042 <lvxinsheng@bytedance.com>
Co-authored-by: anngo-nk <anguohui@bytedance.com>
Co-authored-by: zhangli <zhangli.268@bytedance.com>
2026-06-30 21:11:27 +08:00
linchao5102
5c4ad52741 fix: harden git credential error handling (#1676) 2026-06-30 19:57:04 +08:00
wangweiming-01
3fcb695698 docs: guide document copy skill usage (#1673) 2026-06-30 16:47:06 +08:00
mew
fb042758db feat: add whoami command showing effective identity (#1666) 2026-06-30 15:56:56 +08:00
SunPeiYang996
22108c3300 feat(docs): add reference map flags (#1547) 2026-06-30 12:07:18 +08:00
liuxin-0319
31744f8cf9 docs: fix lark-doc media token examples (#1662) 2026-06-30 11:47:14 +08:00
liangshuo-1
1dd0758091 chore: release v1.0.60 (#1657) 2026-06-29 22:34:36 +08:00
yballul-bytedance
4a5a669b1a fix(auth): remove 'claude settings' (#1654) 2026-06-29 21:58:22 +08:00
liangshuo-1
ebb0b6fe73 feat(affordance): per-command usage guidance system (markdown source) (#1565) 2026-06-29 19:33:27 +08:00
liangshuo-1
5c0a36b2a6 feat(transport): add LARK_CLI_NO_PROXY_WARN to silence proxy warning (#1647) 2026-06-29 19:31:48 +08:00
mazhe-nerd
21905b0ba1 fix(install): load @clack/prompts via dynamic import to avoid ERR_REQUIRE_ESM (#1636) (#1652) 2026-06-29 19:16:37 +08:00
yballul-bytedance
602c788fd9 feat(authorization): expand lark-shared auth guidance and assert clean logout JSON (#1598)
- skills/lark-shared/SKILL.md: broaden skill description to cover auth login/status/logout, --domain business-domain scopes, missing scopes and authorization revocation; add an auth task quick-reference table mapping user intents to lark-cli commands; document LARKSUITE_CLI_NO_UPDATE_NOTIFIER / LARKSUITE_CLI_NO_SKILLS_NOTIFIER env vars for stable JSON; soften _notice.update handling so it no longer interrupts the current task.
- cmd/auth/logout_test.go: in TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly, additionally assert that the success JSON payload has no 'message' field, matching the contract that logout success only carries loggedOut=true.
2026-06-29 16:28:57 +08:00
HanShaoshuai-k
30b28cf17f fix: reduce public content false positives 2026-06-29 14:02:43 +08:00
calendar-assistant
297776ea66 feat(event): support VC meeting lifecycle events (#1632) 2026-06-29 11:11:23 +08:00
Max Coplan
5b0c3137e3 test(doc): derive fetch test flag defaults from v2FetchFlags (#1428)
Replace hardcoded flag defaults in the fetch test helpers with
fetchDefault() / fetchDefaultInt() helpers that read the declared
defaults from v2FetchFlags(). This prevents future drift between
production flag defaults and test setup, and panics loudly if a
flag name is misspelled rather than silently returning "".

The tests now correctly avoid hardcoding doc-format, but other
flag defaults (detail, revision-id, scope, etc.) were still
duplicated here. Deriving all defaults from v2FetchFlags() keeps
the whole test command definition aligned with production.

Co-authored-by: TraeCli (Doubao-Seed-Dogfooding) <trae@bytedance.com>
Co-authored-by: fangshuyu <fangshuyu@bytedance.com>
2026-06-29 11:09:51 +08:00
xiongyuanwen-byted
4c31323de1 feat(sheets): use office_sheet_file parent_type for imported office spreadsheets (#1606)
Image uploads to a spreadsheet hard-coded parent_type=sheet_image at every
entry point. Imported "office" spreadsheets carry a token prefixed with
"fake_office_", for which the drive backend requires
parent_type=office_sheet_file. Funnel the parent_type selection through a
single sheets-domain helper so the rule lives in one place and every
image-upload path (float-image, +cells-set-image, backward +media-upload,
and every dry-run preview) stays consistent.

- Add sheetMediaParentType(token) in the sheets domain: returns
  office_sheet_file for fake_office_-prefixed tokens, otherwise sheet_image.
- Add an uploadSheetImage(...) collector that builds the
  DriveMediaUploadAllConfig (including parent_type) once, replacing the
  per-call-site hand-rolled configs.
- Route both main-domain image entries through the collector — float-image
  local upload and +cells-set-image — covering Execute and the dry-run
  preview body/desc.
- Cover the backward +media-upload entry: single-part, multipart (>20MB),
  and both dry-run bodies. backward is a separate package and an
  intentional verbatim mirror of shortcuts/sheets/, so it keeps its own
  copy of the helper rather than importing the main domain.
- Leave the shared common.UploadDriveMediaAllTyped upload layer untouched
  — the fake_office_ rule is sheets-specific and must not leak into
  mail/slides/doc/drive/base.

Tests:
- Pure-function TestSheetMediaParentType (5 cases incl. prefix-only and
  mid-string non-match).
- Main-domain dry-run TestCellsSetImage_DryRunOfficeParentType and
  TestUploadSheetImage_ParentType / _FileOpenError that exercise the
  Execute path on the wire, asserting parent_type via the captured
  multipart body and typed validation metadata (errs.ProblemOf
  category/subtype, fs.ErrNotExist cause preserved) on file open errors.
  decodeSheetMediaMultipartBody fails fast on NextPart / ReadFrom errors
  rather than silently producing a partial body.
- backward TestSheetMediaUploadExecuteOfficeParentType (real multipart
  wire) and TestSheetMediaUploadDryRunSmallFileOfficeParentType
  (small-file dry-run preview for fake_office_).
- cli_e2e tests/cli_e2e/sheets/sheets_image_upload_dryrun_test.go: --dry-run
  end-to-end across +media-upload and +cells-set-image, native and
  fake_office_ tokens, asserting api.0 is POST upload_all with
  parent_type=sheet_image / office_sheet_file and parent_node = token.
2026-06-27 16:16:56 +08:00
liangshuo-1
8a268aa2d2 chore: release v1.0.59 (#1617) 2026-06-26 20:46:51 +08:00
ethan-zhx
39d60cb706 feat: add slides replace-pages and xml-get shortcuts (#1585)
* feat: add slides replace-pages shortcut

* feat: add slides xml get shortcut

* fix: stop advertising slides screenshot scope

* feat: expose slides presentation url
2026-06-26 15:56:55 +08:00
SunPeiYang996
d9330b7ab3 fix(docs): hide docs api-version compat flag (#1580) 2026-06-26 14:32:09 +08:00
hugang-lark
6b833257c7 fix: optimize calendar,vc,minutes,note shortcut and skill (#1571) 2026-06-26 12:24:03 +08:00
zhangjun-bytedance
ba51d4874e feat: support speaker list and nolark speaker replace (#1594) 2026-06-26 11:41:32 +08:00
liangshuo-1
40a09c8957 chore: release v1.0.58 (#1586) 2026-06-25 21:57:36 +08:00
taojieyeta-design
806e8679f6 feat: sync approval skill for meta api commands (#1499)
* feat: sync approval skill for meta api commands

* docs: fix approval skill reference links

* docs: restore approval reference links

* docs: align approval skill with review guidelines

* docs: clarify approval skill boundaries

* docs: remove implementation detail from approval description
2026-06-25 20:40:59 +08:00
Public Content Screenshot
d69761e205 fix(ci): reduce public content false positives 2026-06-25 20:23:15 +08:00
SunPeiYang996
7346de30b1 docs(lark-doc): restore style requirements (#1579)
Change-Id: I5c75a06ccac07586615c40db69b94d515f85d200
2026-06-25 19:18:05 +08:00
hanshaoshuai
cf93ee051c feat(ci): add public content safeguards 2026-06-25 19:03:14 +08:00
SunPeiYang996
fe32a6e0a9 feat(docs): support create title option (#1536)
* feat: support docs create title option

Change-Id: I6fd840fe813e5e664ea9ec680765fd41375cdebf

* docs: refine docs title guidance

Change-Id: I2f986a4606729bc791a1bff6c03aaa198b0798dc

* docs: keep lark doc skill create example

Change-Id: Ic7005e015c9e71a4582c1f4a8ac8222d552426d4

* test: allow docs create title flag in help

Change-Id: I0226e20c6bf2187eb6c4f0d2d5e37ab9225d4171
2026-06-25 18:05:47 +08:00
zhaojiaxing-coding
af9835c288 feat(drive): add +member-add shortcut with wiki space member collection collaborator support (#1204) 2026-06-25 17:45:42 +08:00
shifengjuan-dev
2e3073a532 docs(im): document chat.nickname get/update/delete (#1378) 2026-06-25 17:04:31 +08:00
395 changed files with 57976 additions and 1722 deletions

View File

@@ -5,6 +5,7 @@ on:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened, edited]
workflow_dispatch:
permissions:
@@ -70,6 +71,7 @@ jobs:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
@@ -87,6 +89,23 @@ jobs:
- name: Run errs/ lint guards (lintcheck)
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
script-test:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
- name: Run script tests
run: make script-test
deterministic-gate:
needs: fast-gate
runs-on: ubuntu-latest
@@ -109,8 +128,28 @@ jobs:
env:
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
- name: Write public content metadata
if: ${{ github.event_name == 'pull_request' }}
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_BRANCH: ${{ github.head_ref }}
run: |
mkdir -p .tmp/quality-gate
python3 - <<'PY'
import json
import os
with open(".tmp/quality-gate/public-content-metadata.json", "w", encoding="utf-8") as f:
json.dump({
"title": os.environ.get("PR_TITLE", ""),
"body": os.environ.get("PR_BODY", ""),
"branch": os.environ.get("PR_BRANCH", ""),
}, f)
f.write("\n")
PY
- name: Run CLI deterministic gate
run: make quality-gate
run: PUBLIC_CONTENT_METADATA=.tmp/quality-gate/public-content-metadata.json make quality-gate
- name: Upload quality gate facts
if: ${{ always() && github.event_name == 'pull_request' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
@@ -220,7 +259,7 @@ jobs:
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
e2e-dry-run:
needs: [unit-test, lint, deterministic-gate]
needs: [unit-test, lint, script-test, deterministic-gate]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
@@ -241,7 +280,7 @@ jobs:
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live:
needs: [unit-test, lint, deterministic-gate]
needs: [unit-test, lint, script-test, deterministic-gate]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
permissions:
@@ -333,7 +372,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -345,6 +384,7 @@ jobs:
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | script-test | ${{ needs.script-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
@@ -361,6 +401,7 @@ jobs:
"${{ needs.fast-gate.result }}" \
"${{ needs.unit-test.result }}" \
"${{ needs.lint.result }}" \
"${{ needs.script-test.result }}" \
"${{ needs.deterministic-gate.result }}" \
"${{ needs.coverage.result }}" \
"${{ needs.deadcode.result }}" \

28
.github/workflows/comment-audit.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Comment Audit
on:
issue_comment:
types: [created, edited]
pull_request_review:
types: [submitted, edited]
pull_request_review_comment:
types: [created, edited]
permissions:
contents: read
jobs:
public-content-comment-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- name: Post-publication comment audit
run: |
mkdir -p .tmp/comment-audit
cp "$GITHUB_EVENT_PATH" .tmp/comment-audit/event.json
go run ./internal/qualitygate/cmd/comment-audit --event .tmp/comment-audit/event.json --kind "$GITHUB_EVENT_NAME"

View File

@@ -88,31 +88,44 @@ jobs:
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
candidate.state === "open" &&
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
if (candidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (candidatePRs.length === 1) {
prNumber = candidatePRs[0].number;
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
state: "all",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
if (candidatePRs.length !== 1) {
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
} else {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
prNumber = candidatePRs[0].number;
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
@@ -121,6 +134,11 @@ jobs:
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.state !== "open") {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
if (pr.head.sha !== targetHeadSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
core.setOutput("stale", "true");
@@ -299,31 +317,44 @@ jobs:
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
candidate.state === "open" &&
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
if (candidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (candidatePRs.length === 1) {
prNumber = candidatePRs[0].number;
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
state: "all",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
if (candidatePRs.length !== 1) {
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
} else {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
prNumber = candidatePRs[0].number;
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
@@ -332,6 +363,16 @@ jobs:
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.state !== "open") {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
if (!pr.head.repo) {
core.notice("semantic review skipped: workflow_run target PR head repository is unavailable");
core.setOutput("stale", "true");
return;
}
if (pr.head.sha !== targetHeadSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR head");
core.setOutput("stale", "true");
@@ -389,6 +430,10 @@ jobs:
repo: context.repo.repo,
pull_number: pr,
});
if (pull.state !== "open") {
core.notice("semantic review skipped infrastructure failure check: PR is no longer open");
return;
}
if (pull.head.sha !== headSha) {
core.notice("semantic review skipped infrastructure failure check: PR head changed");
return;

1
.gitignore vendored
View File

@@ -51,3 +51,4 @@ app.log
cover*.out
lark-env.sh
/automations/

View File

@@ -2,6 +2,69 @@
All notable changes to this project will be documented in this file.
## [v1.0.60] - 2026-06-29
### Features
- **affordance**: Per-command usage guidance system with markdown source (#1565)
- **event**: Support VC meeting lifecycle events (#1632)
- **sheets**: Use `office_sheet_file` parent_type for imported office spreadsheets (#1606)
- **authorization**: Expand lark-shared auth guidance and assert clean logout JSON (#1598)
- **transport**: Add `LARK_CLI_NO_PROXY_WARN` to silence proxy warning (#1647)
### Bug Fixes
- **install**: Load `@clack/prompts` via dynamic import to avoid `ERR_REQUIRE_ESM` (#1652)
### Tests
- **doc**: Derive fetch test flag defaults from `v2FetchFlags` (#1428)
### Build
- **ci**: Reduce public content false positives
## [v1.0.59] - 2026-06-26
### Features
- **slides**: Add `+replace-pages` and `xml get` shortcuts, and expose the presentation URL (#1585)
- **minutes**: Support speaker list and no-Lark speaker replace (#1594)
- **calendar/vc/minutes**: Optimize and extend calendar, vc, minutes, and note shortcuts and skills (#1571)
### Bug Fixes
- **docs**: Hide docs `api-version` compat flag (#1580)
## [v1.0.58] - 2026-06-25
### Features
- **sheets**: Typed table I/O and error contract, workbook import/export, and skill refresh (#1355)
- **base**: Add Base URL and title resolve shortcuts (#1338)
- **drive**: Add `+member-add` shortcut with wiki space member collection collaborator support (#1204)
- **doc**: Support `create` title option (#1536)
- **doc**: Add `im-markdown` output format for doc fetch (#1550)
- **whiteboard**: Export whiteboard as SVG and update whiteboard via SVG (#1559)
- **card**: Support `card.action.trigger` event with auto-fetched card content (#1528)
- **task**: Add task event consumer (#1510)
### Bug Fixes
- **doc**: Prefix docs resource shortcuts (#1564)
- **binding**: Skip unix mode audit on Windows (#1525)
### Documentation
- **approval**: Sync approval skill for meta API commands (#1499)
- **doc**: Restore lark-doc style requirements (#1579)
- **im**: Document `chat.nickname` get/update/delete (#1378)
- **im**: Clarify audio message opus requirement (#1271)
### Build
- **ci**: Add public content safeguards and reduce false positives
## [v1.0.57] - 2026-06-23
### Features
@@ -1236,6 +1299,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55

View File

@@ -12,6 +12,7 @@ QUALITY_GATE_DIR ?= .tmp/quality-gate
QUALITY_GATE_MANIFEST_OUT ?= $(QUALITY_GATE_DIR)/command-manifest.json
QUALITY_GATE_COMMAND_INDEX_OUT ?= $(QUALITY_GATE_DIR)/command-index.json
QUALITY_GATE_FACTS_OUT ?= $(QUALITY_GATE_DIR)/facts.json
PUBLIC_CONTENT_METADATA ?= $(QUALITY_GATE_DIR)/public-content-metadata.json
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local
@@ -69,7 +70,8 @@ integration-test: build
test: vet fmt-check script-test unit-test examples-build integration-test
quality-gate: build
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT))
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT)) $(dir $(PUBLIC_CONTENT_METADATA))
test -f $(PUBLIC_CONTENT_METADATA) || printf '{}\n' > $(PUBLIC_CONTENT_METADATA)
LARKSUITE_CLI_REMOTE_META=off \
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
@@ -89,6 +91,7 @@ quality-gate: build
--changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \
--manifest $(QUALITY_GATE_MANIFEST_OUT) \
--command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \
--public-content-metadata $(PUBLIC_CONTENT_METADATA) \
--facts-out $(QUALITY_GATE_FACTS_OUT)
install: build

View File

@@ -198,7 +198,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
lark-cli docs +create --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
```
Run `lark-cli <service> --help` to see all shortcut commands.

View File

@@ -199,7 +199,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
lark-cli docs +create --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
```
运行 `lark-cli <service> --help` 查看所有快捷命令。

49
affordance/README.md Normal file
View File

@@ -0,0 +1,49 @@
# Affordance
Per-command usage guidance for the CLI, authored as one markdown file per domain
(`<service>.md`). It is surfaced in `lark-cli <command> --help` and in the
`schema` output, and read directly at runtime (lazy, cached) — there is no build
step. Maintain these files alongside `skills/` and `shortcuts/`.
## Format
A small, fixed markdown subset; each file describes one domain:
# <domain> optional `> skill: <name>` applies to every command below
## <command> the command as typed, minus `lark-cli <domain>`
<lead paragraph> when to use this command
### Avoid when when not to use it / which command to use instead
### Prerequisites what you must have first (e.g. an id, and where it comes from)
### Tips gotchas and constraints
### Examples **description** lines, each followed by a fenced command
### <other heading> a custom section; flows through verbatim
Reference another command with `[[command]]` — it renders as `command` in help.
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
("… from [[command]]") it means "get the input there first".
## Example
## messages get
Fetch the full content of a single message by id.
### Avoid when
- Reading several at once → use [[messages batch_get]]
### Prerequisites
- message_id from [[messages list]]
### Examples
**Fetch one message**
```bash
lark-cli mail user_mailbox.messages get --message-id "<id>"
```
## Notes
- Write plain prose; the only convention is wrapping command references in `[[ ]]`.
- Keep it concise and high-signal — don't restate field/flag names, id types, or
anything the schema and flags already show; the agent infers the rest.
- Command-form headings resolve to method ids via the registry, so plural resource
names (`messages`) map to the singular method id (`message`) automatically.

19
affordance/contact.md Normal file
View File

@@ -0,0 +1,19 @@
# contact
> skill: lark-contact
## user_profiles batch_query
Bulk-fetch personal status and signature for user ids you already have.
### Avoid when
- Need more than status/signature (name, dept, email), or don't have the open_id yet → use [[+search-user]]
### Tips
- Off by default — set include_personal_status / include_description to true under query_option
- ids in user_ids must match --user-id-type (default open_id)
### Examples
**Bulk-query status and signature**
```bash
lark-cli contact user_profiles batch_query --data '{"user_ids":["ou_3a8b****6a7b"],"query_option":{"include_personal_status":true,"include_description":true}}'
```

View File

@@ -67,8 +67,21 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
cmd := &cobra.Command{
Use: "api <method> <path>",
Short: "Generic Lark API requests",
Args: cobra.ExactArgs(2),
Short: "Raw HTTP escape hatch — call any endpoint by path (fallback when no typed command exists)",
Long: `Raw HTTP escape hatch: send any Lark API request by HTTP method + path.
Prefer the typed domain command when one exists — it validates parameters,
shows the Risk level, gates destructive calls behind --yes, and carries usage
guidance that this raw command does not. If a domain command covers your task
(browse with ` + "`lark-cli <domain> --help`" + `), use it instead of this.
Reach for ` + "`api`" + ` only for endpoints that have no typed command yet (e.g.
newer/preview APIs), where you already have the HTTP path from the Lark docs.
Examples:
lark-cli api GET /open-apis/calendar/v4/calendars
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"open_id"}' --data @body.json`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Method = strings.ToUpper(args[0])
opts.Path = args[1]

View File

@@ -19,6 +19,7 @@ import (
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update"
"github.com/larksuite/cli/cmd/whoami"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
@@ -170,6 +171,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.SetOut(cfg.streams.Out)
rootCmd.SetErr(cfg.streams.ErrOut)
// Root-only usage template (curated Usage synopsis + skills footer); see
// rootUsageTemplate.
rootCmd.SetUsageTemplate(rootUsageTemplate)
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
@@ -190,6 +195,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(profile.NewCmdProfile(f))
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
@@ -205,6 +211,8 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
}
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
groupRootCommands(rootCmd)
installUnknownSubcommandGuard(rootCmd)
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {

View File

@@ -10,10 +10,22 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
_ "github.com/larksuite/cli/events"
)
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
if _, ok := eventlib.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) should succeed", key)
}
}
}
func TestRunList_TextOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
@@ -27,6 +39,8 @@ func TestRunList_TextOutput(t *testing.T) {
"im.message.receive_v1",
"im.message.message_read_v1",
"task.task.update_user_access_v2",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
if !strings.Contains(out, want) {
t.Errorf("list output missing %q; full output:\n%s", want, out)
@@ -57,9 +71,15 @@ func TestRunList_JSONOutput(t *testing.T) {
}
}
var foundTask bool
gotKeys := map[string]map[string]interface{}{}
for _, row := range rows {
if row["key"] == "task.task.update_user_access_v2" {
if key, ok := row["key"].(string); ok {
gotKeys[key] = row
}
}
var foundTask bool
for key, row := range gotKeys {
if key == "task.task.update_user_access_v2" {
foundTask = true
if row["single_consumer"] != true {
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
@@ -69,4 +89,12 @@ func TestRunList_JSONOutput(t *testing.T) {
if !foundTask {
t.Fatal("event list JSON missing task.task.update_user_access_v2")
}
for _, want := range []string{
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
if _, ok := gotKeys[want]; !ok {
t.Errorf("JSON list output missing %q", want)
}
}
}

View File

@@ -124,6 +124,45 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
}
}
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
t.Run(key, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, key, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if payload["key"] != key {
t.Errorf("key = %v, want %s", payload["key"], key)
}
resolved, ok := payload["resolved_output_schema"].(map[string]interface{})
if !ok {
t.Fatalf("resolved_output_schema missing or wrong type: %+v", payload)
}
properties, ok := resolved["properties"].(map[string]interface{})
if !ok {
t.Fatalf("resolved_output_schema.properties missing or wrong type: %+v", resolved)
}
for _, field := range []string{"type", "event_id", "timestamp", "meeting_id", "topic", "meeting_no", "start_time", "calendar_event_id"} {
if _, ok := properties[field]; !ok {
t.Errorf("resolved output schema missing field %q: %+v", field, properties)
}
}
if _, ok := properties["end_time"]; ok {
t.Errorf("resolved output schema should not include end_time for %s: %+v", key, properties)
}
})
}
}
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
const syntheticKey = "test.evt_sub"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })

View File

@@ -11,9 +11,11 @@ import (
"sort"
"strings"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/deprecation"
@@ -28,43 +30,60 @@ import (
const rootLong = `lark-cli — Lark/Feishu CLI tool.
USAGE:
lark-cli <command> [subcommand] [method] [options]
lark-cli api <method> <path> [--params <json>] [--data <json>]
lark-cli schema <service.resource.method>
AGENT QUICKSTART (driving this as an agent? start here):
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples
Prefer a +shortcut over the raw API resource when one matches the task.
Risk: each command's --help shows read | write | high-risk-write;
high-risk-write needs --yes, only after the user confirms.
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).
EXAMPLES:
# View upcoming events
lark-cli calendar +agenda
EXAMPLES (one per command style, in order of preference):
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`
# List calendar events
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
// rootUsageTemplate is cobra's default usage template with two root-only
// additions gated on {{if not .HasParent}}: a curated multi-form Usage synopsis
// (replacing cobra's generic "[flags] / [command]") and a human skills-setup
// footer. Subcommands render the stock template unchanged. The rest is verbatim
// cobra so the command groups and flags are untouched.
const rootUsageTemplate = `{{if .HasParent}}Usage:{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [command]{{end}}{{else}}Usage:
lark-cli <command> [subcommand] [method] [flags]
lark-cli api <method> <path> [--params <json>] [--data <json>]
lark-cli schema <service.resource.method>{{end}}{{if gt (len .Aliases) 0}}
# Search users
lark-cli contact +search-user --query "John"
Aliases:
{{.NameAndAliases}}{{end}}{{if .HasExample}}
# Generic API call
lark-cli api GET /open-apis/calendar/v4/calendars
Examples:
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
AI AGENT SKILLS:
lark-cli pairs with AI agent skills (Claude Code, etc.) that
teach the agent Lark API patterns, best practices, and workflows.
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
Install all skills:
npx skills add larksuite/cli -g -y
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
Or pick specific domains:
npx skills add larksuite/cli -s lark-calendar -y
npx skills add larksuite/cli -s lark-im -y
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Learn more: https://github.com/larksuite/cli#agent-skills
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
COMMUNITY:
GitHub: https://github.com/larksuite/cli
Issues: https://github.com/larksuite/cli/issues
Docs: https://open.feishu.cn/document/
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
More help: lark-cli <command> --help`
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
`
// Execute runs the root command and returns the process exit code.
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
@@ -529,6 +548,49 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
return available, deprecated
}
// Root command help groups, so an agent sees content domains, agent tooling, and
// CLI management as distinct blocks instead of one flat alphabetical dump.
const (
groupDomains = "lark-domains"
groupTooling = "agent-tooling"
groupManagement = "cli-management"
)
// groupRootCommands classifies root's direct children into the help groups,
// called once after all commands are registered. Unclassified commands fall to
// cobra's "Additional Commands" section.
func groupRootCommands(root *cobra.Command) {
root.AddGroup(
&cobra.Group{ID: groupDomains, Title: "Lark domains:"},
&cobra.Group{ID: groupTooling, Title: "Agent tooling:"},
&cobra.Group{ID: groupManagement, Title: "CLI management:"},
)
tooling := map[string]bool{"api": true, "schema": true, "skills": true}
management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true}
for _, c := range root.Commands() {
if c.GroupID != "" {
continue
}
switch {
case tooling[c.Name()]:
c.GroupID = groupTooling
case management[c.Name()]:
c.GroupID = groupManagement
case isLarkDomain(c):
c.GroupID = groupDomains
}
}
}
// isLarkDomain reports whether a root child is a Lark domain (service-sourced or
// shortcut-tagged), not CLI tooling. Mirrors service.PrepareDomainHelp.
func isLarkDomain(c *cobra.Command) bool {
if src, _ := cmdmeta.SourceOf(c); src == cmdmeta.SourceService {
return true
}
return cmdmeta.Domain(c) != ""
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into a typed validation envelope: an
// unknown flag gets a focused "did you mean" hint (so agents recover even when
@@ -610,6 +672,17 @@ func installTipsHelpFunc(root *cobra.Command) {
defer func() { f.Hidden = true }()
}
}
// Domain and method commands compose their agent guidance into Long lazily
// here (shortcuts attach after service registration); both skip the generic
// bottom-of-help append below.
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
defaultHelp(cmd, args)
return
}
if service.PrepareMethodHelp(cmd) {
defaultHelp(cmd, args)
return
}
defaultHelp(cmd, args)
out := cmd.OutOrStdout()
if level, ok := cmdutil.GetRisk(cmd); ok {

View File

@@ -76,11 +76,13 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
}
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
// The human skills-install guidance now lives in the root usage-template
// footer (below the command list), not in the agent-facing Long.
if !strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#agent-skills") {
t.Fatalf("root help footer should link to the README Agent Skills section, got:\n%s", rootUsageTemplate)
}
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
if strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#install-ai-agent-skills") {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootUsageTemplate)
}
}

View File

@@ -4,41 +4,211 @@
package service
import (
"encoding/json"
"fmt"
"io/fs"
"strings"
"github.com/larksuite/cli/internal/affordance"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
)
// methodLong composes a method command's long help in one place: the
// description, the affordance guidance block (when the method has one), the
// pointer to the full schema, and the params-only addendum (params whose flag
// name is taken — paramFlagBinder.paramsOnlyHelp, "" when none). Affordance
// sits near the top so an agent sees when-to-use and few-shot examples before
// the flag list.
func methodLong(description, affordance, schemaPath, paramsOnly string) string {
// PrepareDomainHelp appends navigational guidance (routing line, risk legend,
// skill pointer) to a top-level Lark domain's description, returning false for
// anything that is not such a domain. Built lazily at help time because
// shortcuts attach after service registration. skillFS (nil-safe) gates the
// skill pointer.
//
// A hand-authored Long is preserved as the base (e.g. event's "Use 'event
// consume <EventKey>'…"); service domains carry only a Short at this point, so
// we fall back to it. The pristine base is captured once into an annotation so
// re-rendering does not append the guidance twice.
func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
if cmd.Annotations[schemaPathAnnotation] != "" {
return false // a method command
}
// Direct child of root only — so Domain() reads this command's own tag, and
// nested resource groups are excluded.
if cmd.Parent() == nil || cmd.Parent().Parent() != nil {
return false
}
// A domain is service-sourced or shortcut-tagged; CLI tooling has neither.
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceService && cmdmeta.Domain(cmd) == "" {
return false
}
if !cmd.HasAvailableSubCommands() {
return false
}
hasShortcuts, hasResources := false, false
for _, c := range cmd.Commands() {
if c.Hidden || c.Name() == "help" || c.Name() == "completion" {
continue
}
if strings.HasPrefix(c.Name(), "+") {
hasShortcuts = true
} else {
hasResources = true
}
}
var b strings.Builder
b.WriteString(domainHelpBase(cmd))
if hasShortcuts && hasResources { // routing only matters when both styles exist
b.WriteString("\n\nPrefer a +-prefixed shortcut when one matches your task; otherwise use the raw API resource below.")
}
b.WriteString("\n\nRisk levels (read | write | high-risk-write) appear in each command's --help; high-risk-write requires --yes, only after the user confirms.")
if skill := "lark-" + cmd.Name(); skillFS != nil {
if _, err := fs.Stat(skillFS, skill+"/SKILL.md"); err == nil {
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
}
}
cmd.Long = b.String()
return true
}
// domainHelpBase returns the description to seed domain help with — the
// hand-authored Long when present, else the Short — captured once into an
// annotation so re-rendering reuses the pristine text instead of the
// already-augmented Long.
func domainHelpBase(cmd *cobra.Command) string {
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
return base
}
base := cmd.Long
if base == "" {
base = cmd.Short
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[domainBaseAnnotation] = base
return base
}
// methodLong is the build-time Long (description + schema pointer +
// params-only addendum). Agent guidance is added lazily by PrepareMethodHelp,
// so command construction never parses the overlay.
func methodLong(description, schemaPath, paramsOnly string) string {
var b strings.Builder
b.WriteString(description)
if affordance != "" {
b.WriteString("\n\n")
b.WriteString(affordance)
}
fmt.Fprintf(&b, "\n\nView parameter definitions before calling:\n lark-cli schema %s", schemaPath)
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
b.WriteString(paramsOnly)
return b.String()
}
// renderAffordance renders a method's affordance as a help block — when to use,
// prerequisites, and (most importantly for agents) few-shot Examples — or "" when
// the method carries no affordance. It reads the single typed model
// (meta.Method.ParsedAffordance) so the help and the envelope agree on shape.
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
const (
affordanceServiceAnnotation = "affordance-service"
affordanceMethodAnnotation = "affordance-method"
schemaPathAnnotation = "method-schema-path"
paramsOnlyAnnotation = "method-params-only"
domainBaseAnnotation = "affordance-domain-base"
)
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
// few strings is the only build-time cost; the overlay stays untouched).
func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, paramsOnly string) {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
if service != "" && methodID != "" {
cmd.Annotations[affordanceServiceAnnotation] = service
cmd.Annotations[affordanceMethodAnnotation] = methodID
}
cmd.Annotations[schemaPathAnnotation] = schemaPath
if paramsOnly != "" {
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
}
}
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
// guidance at the TOP (Risk, then the affordance block, then the schema
// pointer), returning false for non-method commands. The overlay is parsed
// here — only when help is rendered.
func PrepareMethodHelp(cmd *cobra.Command) bool {
ann := cmd.Annotations
if ann == nil {
return false
}
schemaPath, ok := ann[schemaPathAnnotation]
if !ok {
return false
}
var b strings.Builder
b.WriteString(cmd.Short)
if level, ok := cmdutil.GetRisk(cmd); ok {
// --yes asserts the USER confirmed; the agent must not self-approve.
if level == cmdutil.RiskHighRiskWrite {
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
} else {
fmt.Fprintf(&b, "\n\nRisk: %s", level)
}
}
var skills []string
if raw, ok := affordanceRaw(cmd); ok {
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
skills = a.Skills
}
}
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
b.WriteString(ann[paramsOnlyAnnotation])
if len(skills) > 0 {
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
for _, s := range skills {
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
}
}
cmd.Long = b.String()
return true
}
// affordanceLookup is the overlay source; a package var so tests can inject.
var affordanceLookup = affordance.For
// RenderAffordanceForCmd renders a method command's affordance block, or "" when
// it carries none.
func RenderAffordanceForCmd(cmd *cobra.Command) string {
raw, ok := affordanceRaw(cmd)
if !ok {
return ""
}
return renderAffordance(meta.Method{Affordance: raw})
}
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
if cmd.Annotations == nil {
return nil, false
}
service := cmd.Annotations[affordanceServiceAnnotation]
methodID := cmd.Annotations[affordanceMethodAnnotation]
if service == "" || methodID == "" {
return nil, false
}
return affordanceLookup(service, methodID)
}
// renderAffordance renders a method's affordance as a help block, or "" when it
// has none. Sections are joined with blank lines so they scan as distinct groups.
func renderAffordance(m meta.Method) string {
a, ok := m.ParsedAffordance()
if !ok {
return ""
}
var b strings.Builder
var sections []string
bullets := func(title string, items []string) {
var nonEmpty []string
for _, it := range items {
@@ -49,15 +219,18 @@ func renderAffordance(m meta.Method) string {
if len(nonEmpty) == 0 {
return
}
fmt.Fprintf(&b, "%s:\n", title)
var s strings.Builder
fmt.Fprintf(&s, "%s:\n", title)
for _, it := range nonEmpty {
fmt.Fprintf(&b, " • %s\n", it)
fmt.Fprintf(&s, " • %s\n", it)
}
sections = append(sections, strings.TrimRight(s.String(), "\n"))
}
bullets("When to use", a.UseWhen)
bullets("Avoid when", a.DoNotUseWhen)
bullets("Avoid when", a.AvoidWhen)
bullets("Prerequisites", a.Prerequisites)
bullets("Tips", a.Tips)
if len(a.Examples) > 0 {
var lines []string
for _, ex := range a.Examples {
@@ -71,10 +244,13 @@ func renderAffordance(m meta.Method) string {
}
}
if len(lines) > 0 {
fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n"))
sections = append(sections, "Examples:\n"+strings.Join(lines, "\n"))
}
}
for _, ext := range a.Extensions {
bullets(ext.Label, ext.Items)
}
bullets("Related", a.Related)
return strings.TrimRight(b.String(), "\n")
return strings.Join(sections, "\n\n")
}

View File

@@ -8,15 +8,18 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
)
func TestRenderAffordance(t *testing.T) {
raw := json.RawMessage(`{
"use_when": ["发送文本消息"],
"do_not_use_when": ["群已解散"],
"avoid_when": ["群已解散"],
"prerequisites": ["已获取 chat_id"],
"tips": ["富文本用 msg_type=post"],
"examples": [
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
{"command":"lark-cli im messages list"},
@@ -29,6 +32,7 @@ func TestRenderAffordance(t *testing.T) {
"When to use:", "发送文本消息",
"Avoid when:", "群已解散",
"Prerequisites:", "已获取 chat_id",
"Tips:", "富文本用 msg_type=post",
"Examples:", "发一条文本", "lark-cli im messages create --params '{...}'",
"lark-cli im messages list", // example with no description -> bare command line
"Related:", "im.messages.list",
@@ -48,9 +52,12 @@ func TestRenderAffordance(t *testing.T) {
}
}
func TestServiceMethod_AffordanceInLong(t *testing.T) {
// Affordance is rendered lazily (at --help time) rather than baked into the
// command's Long, so building a command never carries the affordance block —
// even for a method whose metadata happens to declare one.
func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
withAff := map[string]interface{}{
"path": "messages", "httpMethod": "POST", "description": "发送消息",
"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息",
"affordance": map[string]interface{}{
"examples": []interface{}{
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
@@ -59,14 +66,120 @@ func TestServiceMethod_AffordanceInLong(t *testing.T) {
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
if !strings.Contains(cmd.Long, "Examples:") || !strings.Contains(cmd.Long, "lark-cli im messages create ...") {
t.Errorf("affordance examples not in command Long:\n%s", cmd.Long)
if strings.Contains(cmd.Long, "Examples:") {
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long)
}
// A method with no affordance adds no guidance block.
plain := map[string]interface{}{"path": "x", "httpMethod": "GET", "description": "d"}
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(plain), "list", "x", nil)
if strings.Contains(cmd2.Long, "Examples:") {
t.Errorf("no-affordance method should have no Examples in Long:\n%s", cmd2.Long)
// The lookup ref is recorded so the help path can resolve it later.
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
}
}
// RenderAffordanceForCmd resolves a command's overlay through the (injectable)
// lookup and renders it; commands without a ref render nothing.
func TestRenderAffordanceForCmd(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
if service != "im" || methodID != "messages.create" {
return nil, false
}
return json.RawMessage(`{"use_when":["发文本消息"],"tips":["富文本用 msg_type=post"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
withRef := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withRef), "create", "messages", nil)
block := RenderAffordanceForCmd(cmd)
for _, want := range []string{"When to use:", "发文本消息", "Tips:", "富文本用 msg_type=post", "Examples:", "lark-cli im messages create ..."} {
if !strings.Contains(block, want) {
t.Errorf("RenderAffordanceForCmd missing %q in:\n%s", want, block)
}
}
// No overlay for this method id -> empty block.
noRef := map[string]interface{}{"id": "x.list", "path": "x", "httpMethod": "GET", "description": "d"}
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(noRef), "list", "x", nil)
if got := RenderAffordanceForCmd(cmd2); got != "" {
t.Errorf("method with no overlay should render nothing, got:\n%s", got)
}
}
// PrepareMethodHelp composes the guidance into Long at the top: description,
// then the affordance block, then the full-schema pointer — so an agent reads
// when-to-use/examples before the flag list.
func TestPrepareMethodHelp(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
return json.RawMessage(`{"use_when":["发文本消息"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
if !PrepareMethodHelp(cmd) {
t.Fatal("PrepareMethodHelp returned false for a service-method command")
}
long := cmd.Long
// Description leads; affordance block sits above the schema pointer.
descAt := strings.Index(long, "发送消息")
useAt := strings.Index(long, "When to use:")
exAt := strings.Index(long, "Examples:")
schemaAt := strings.Index(long, "Full parameter schema:")
if descAt != 0 {
t.Errorf("description should lead Long, got:\n%s", long)
}
if !(descAt < useAt && useAt < exAt && exAt < schemaAt) {
t.Errorf("order should be description < affordance < schema pointer; got desc=%d use=%d ex=%d schema=%d\n%s", descAt, useAt, exAt, schemaAt, long)
}
// A non-service command (no schema-path annotation) is left untouched.
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
t.Error("PrepareMethodHelp should return false for a non-service command")
}
}
// domainCmd wires a domain-tagged command with a subcommand under a root, the
// shape PrepareDomainHelp expects.
func domainCmd(short, long string) *cobra.Command {
root := &cobra.Command{Use: "root"}
dom := &cobra.Command{Use: "event", Short: short, Long: long}
cmdmeta.SetDomain(dom, "event")
dom.AddCommand(&cobra.Command{Use: "consume", Run: func(*cobra.Command, []string) {}})
root.AddCommand(dom)
return dom
}
func TestPrepareDomainHelp_PreservesHandAuthoredLong(t *testing.T) {
const long = "Unified event consumption system. Use 'event consume <EventKey>'."
dom := domainCmd("Consume and manage real-time events", long)
if !PrepareDomainHelp(dom, nil) {
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
}
if !strings.HasPrefix(dom.Long, long) {
t.Errorf("hand-authored Long must lead; got:\n%s", dom.Long)
}
if !strings.Contains(dom.Long, "Risk levels") {
t.Errorf("domain guidance should be appended; got:\n%s", dom.Long)
}
// Re-rendering must not append the guidance a second time.
PrepareDomainHelp(dom, nil)
if n := strings.Count(dom.Long, "Risk levels"); n != 1 {
t.Errorf("guidance appended %d times across re-renders, want 1:\n%s", n, dom.Long)
}
}
// A service domain carries only a Short at help time; it seeds the base.
func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
dom := domainCmd("Message and group chat management", "")
if !PrepareDomainHelp(dom, nil) {
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
}
if !strings.HasPrefix(dom.Long, "Message and group chat management") {
t.Errorf("Short should seed Long when no hand-authored Long exists; got:\n%s", dom.Long)
}
}

View File

@@ -60,8 +60,11 @@ func TestServiceFlagGroups_AgentContract(t *testing.T) {
if i := idx("--chat-id"); i < iParams || i > iBody {
t.Errorf("--chat-id not under API Parameters:\n%s", out)
}
if !strings.Contains(out, "chat_id, required") {
t.Errorf("typed flag help format wrong:\n%s", out)
// The redundant "<name>, required|optional." prefix is gone: required-ness is
// carried by the Required:/Optional: subheadings, and the snake-case --params
// key by the schema envelope — so it isn't echoed on every flag line.
if strings.Contains(out, "chat_id, required") || strings.Contains(out, "member_id_type, optional") {
t.Errorf("redundant <name>, required/optional prefix should not appear:\n%s", out)
}
if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
t.Errorf("expected compact enum value=meaning inline:\n%s", out)

View File

@@ -30,6 +30,11 @@ func fieldFacts(f meta.Field) []string {
if d := sanitizeFieldDesc(f.Description); d != "" {
facts = append(facts, d)
}
if f.CanonicalType() == "boolean" {
// cobra shows no type word for bools and swallows a separate value as a
// positional, so spell out the presence-only contract.
facts = append(facts, "bool flag (presence = true; omit for false; takes no value)")
}
if opts := f.EnumOptions(); len(opts) > 0 {
facts = append(facts, "enum: "+formatEnumInline(opts))
}
@@ -42,20 +47,15 @@ func fieldFacts(f meta.Field) []string {
return facts
}
// paramFlagUsage renders the typed param flag's help line:
//
// <param_name>, required|optional[. <fact>]...
//
// It leads with the canonical underscore param name (the key this flag
// overrides in --params) and required/optional, then joins the field's facts
// inline.
// paramFlagUsage renders the typed param flag's help line: the field's facts
// joined inline. Required/optional is not repeated here — the grouped help's
// Required:/Optional: subheadings already partition the flags — and the
// snake-case --params key is carried by the schema envelope (each param's
// property + "flag") and the params-only addendum, so it isn't echoed on every
// line either. Returns "" when the field has no facts (cobra then shows the bare
// flag with its type).
func paramFlagUsage(f meta.Field) string {
req := "optional"
if f.Required {
req = "required"
}
parts := append([]string{fmt.Sprintf("%s, %s", f.Name, req)}, fieldFacts(f)...)
return strings.Join(parts, ". ") + "."
return strings.Join(fieldFacts(f), ". ")
}
// paramExample picks a concrete sample for a params-only field's --help snippet:
@@ -103,8 +103,23 @@ func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r",
// sanitizeFieldDesc is the field-description policy: one line per field, so
// keep full sentences and cut only at note separators (meta_data appends
// bullet notes after ;/) — the later sentence often carries the key
// affordance, e.g. user_mailbox_id's `可以输入"me"`.
func sanitizeFieldDesc(s string) string { return inlineClause(s, ";\n\r", 60) }
// affordance, e.g. user_mailbox_id's `可以输入"me"`. The trailing doc
// cross-reference is dropped first (see cutDocRef).
func sanitizeFieldDesc(s string) string { return inlineClause(cutDocRef(s), ";\n\r", 60) }
// docRefRe matches a "see the docs" breadcrumb (更多信息参见…/获取方式见…/详见…).
// On the compact flag line the markdown link's URL is stripped, so the
// breadcrumb is a dead pointer — drop it. Anchored on a leading clause separator
// so a subject that runs straight into the phrase isn't orphaned.
var docRefRe = regexp.MustCompile(`[。;;,、]\s*(更多信息|获取方式|获取方法|详见|[请可]?参[见考阅])`)
// cutDocRef truncates s at the first doc-reference breadcrumb.
func cutDocRef(s string) string {
if loc := docRefRe.FindStringIndex(s); loc != nil {
return s[:loc[0]]
}
return s
}
// formatEnumInline renders allowed values for the help line: "v=meaning" when
// the value carries a (sanitized, truncated) description — so opaque numeric

View File

@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"sort"
"strings"
"github.com/larksuite/cli/errs"
@@ -64,15 +65,38 @@ func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc
// resource-command chain — one level for a flat dotted resource like
// "chat.members", deeper for genuinely nested resources. A service with no
// methods keeps its bare command (svcCmd is created above regardless).
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
refs := apicatalog.ServiceMethods(svc, nil)
// Collect each resource's verbs up front so resourceShort can summarize a
// resource as its verb list from the first ensureChildCommand call.
verbs := map[string][]string{}
for _, ref := range refs {
key := strings.Join(ref.ResourcePath, ".")
verbs[key] = append(verbs[key], ref.Method.Name)
}
for _, ref := range refs {
resCmd := svcCmd
var path []string
for _, seg := range ref.ResourcePath {
resCmd = ensureChildCommand(resCmd, seg, seg+" operations")
path = append(path, seg)
resCmd = ensureChildCommand(resCmd, seg, resourceShort(seg, verbs[strings.Join(path, ".")]))
}
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
}
}
// resourceShort summarizes a resource as its sorted verb list, or the
// "<name> operations" placeholder for an intermediate group with no methods.
func resourceShort(seg string, verbs []string) string {
if len(verbs) == 0 {
return seg + " operations"
}
sorted := append([]string(nil), verbs...)
sort.Strings(sorted)
return strings.Join(sorted, ", ")
}
// serviceShort is the service command's help summary: the localized description
// from the registry, falling back to the metadata's own description.
func serviceShort(svc meta.Service) string {
@@ -177,7 +201,19 @@ type methodCommandSpec struct {
// the API declares a body.
acceptsBody bool
declaresBody bool
affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none
paginates bool // method accepts a page_token param (so --page-all is meaningful)
serviceName string // owning service name (e.g. "approval"), for the lazy affordance lookup
}
// methodPaginates reports whether a method takes a page_token param, the signal
// that makes the --page-all/--page-limit/--page-delay flags meaningful.
func methodPaginates(m meta.Method) bool {
for _, f := range m.Params() {
if f.Name == "page_token" {
return true
}
}
return false
}
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
@@ -186,6 +222,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
method: m,
schemaPath: ref.SchemaPath(),
servicePath: ref.Service.ServicePath,
serviceName: ref.Service.Name,
risk: m.Risk,
restricts: m.RestrictsIdentity(),
identities: m.Identities(),
@@ -193,7 +230,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
fileFields: detectFileFields(m),
acceptsBody: methodTakesBody(m.HTTPMethod),
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
affordance: renderAffordance(m),
paginates: methodPaginates(m),
}
}
@@ -254,6 +291,14 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
// Keep the pagination flags registered (a harmless no-op if passed) but hide
// them from help on non-paginating commands, so help doesn't imply a
// get/write can paginate.
if !spec.paginates {
for _, name := range []string{"page-all", "page-limit", "page-delay"} {
_ = cmd.Flags().MarkHidden(name)
}
}
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
@@ -271,10 +316,11 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
// Registered last so the collision guard sees the standard flags above.
opts.binder = newParamFlagBinder(cmd, spec.params, reserved)
// Single composition point for Long: description, affordance, schema
// pointer, and the binder's params-only addendum (params whose flag name is
// taken, reachable via --params only).
cmd.Long = methodLong(m.Description, spec.affordance, spec.schemaPath, opts.binder.paramsOnlyHelp())
// Build-time Long; the agent guidance is added lazily by PrepareMethodHelp
// (setMethodHelpData records the coordinates it needs).
paramsOnly := opts.binder.paramsOnlyHelp()
cmd.Long = methodLong(m.Description, spec.schemaPath, paramsOnly)
setMethodHelpData(cmd, spec.serviceName, m.ID, spec.schemaPath, paramsOnly)
// Group flags for the grouped --help renderer (typed param flags are grouped
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not
@@ -292,13 +338,11 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
tagFlagGroup(cmd.Flags(), "file", groupBody)
if fl := cmd.Flags().Lookup("params"); fl != nil {
annotate(fl, flagGroupAnnotation, []string{groupRaw})
// State the precedence rule where the agent reads it: --params is the
// base, typed flags override. Only meaningful when typed flags exist.
// Keep the precedence rule on the flag's own one line (not a multi-line
// note that breaks the one-entry-per-flag rhythm an agent parses). Only
// meaningful when typed flags exist to override.
if len(spec.params) > 0 {
annotate(fl, flagNoteAnnotation, []string{
"Typed API parameter flags above are preferred.",
"If both are set, typed flags override matching keys in --params.",
})
fl.Usage = "Raw URL/query params JSON. Supports - and @file. If both set, typed flags override matching keys in --params."
}
}
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {

167
cmd/whoami/whoami.go Normal file
View File

@@ -0,0 +1,167 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whoami
import (
"context"
"fmt"
"io"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
)
// whoamiResult is the structured output of `lark-cli whoami`.
type whoamiResult struct {
Profile string `json:"profile"`
AppID string `json:"appId"`
Brand core.LarkBrand `json:"brand"`
DefaultAs string `json:"defaultAs"`
Identity string `json:"identity"`
IdentitySource string `json:"identitySource"`
Available bool `json:"available"`
TokenStatus string `json:"tokenStatus"`
OpenID string `json:"openId,omitempty"`
UserName string `json:"userName,omitempty"`
Hint string `json:"hint,omitempty"`
}
// Options holds inputs for the whoami command.
type Options struct {
Factory *cmdutil.Factory
As string
JSON bool
}
// NewCmdWhoami creates the top-level whoami command. It reports the identity
// that the next API call would actually use (resolved via Factory.ResolveAs),
// together with the active profile, app, and token status. It is local-only:
// no network calls are made.
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
opts := &Options{Factory: f}
cmd := &cobra.Command{
Use: "whoami",
Short: "Show the current effective identity, app, profile, and token status",
RunE: func(cmd *cobra.Command, args []string) error {
return whoamiRun(cmd, opts)
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As)
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd
}
func whoamiRun(cmd *cobra.Command, opts *Options) error {
f := opts.Factory
cfg, err := f.Config()
if err != nil {
return err
}
ctx := cmd.Context()
flagAs := core.Identity(opts.As)
as := f.ResolveAs(ctx, cmd, flagAs)
// Reject an explicit --as that does not resolve to a usable identity, so a
// typo like `--as admin` fails clearly instead of echoing back a bogus
// identity. Keeps the §5.1 invariant (identity is always user or bot) and
// matches how api/service/shortcut commands validate the resolved identity.
if err := f.CheckIdentity(as, []string{"user", "bot"}); err != nil {
return err
}
source := resolveSource(
cmd.Flags().Changed("as"),
flagAs,
f.IdentityAutoDetected,
f.ResolveStrictMode(ctx).ForcedIdentity(),
)
diag := identitydiag.Diagnose(ctx, f, cfg, false)
res := buildResult(cfg, as, source, diag)
if opts.JSON {
output.PrintJson(f.IOStreams.Out, res)
return nil
}
formatPretty(f.IOStreams.Out, res)
return nil
}
// resolveSource derives how the effective identity became effective.
// Mirrors Factory.ResolveAs precedence: explicit flag wins; otherwise an
// auto-detected result means auto-detect; otherwise a strict-mode forced
// identity means strict-mode; otherwise it came from configured default-as.
func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, strictForced core.Identity) string {
if changedAs && (flagAs == core.AsUser || flagAs == core.AsBot) {
return "flag"
}
if autoDetected {
return "auto-detect"
}
if strictForced != "" {
return "strict-mode"
}
return "default-as"
}
// buildResult maps the resolved identity and local diagnostics into the output.
// ResolveAs only ever returns user or bot, so the default branch handles user.
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
defaultAs := cfg.DefaultAs
if defaultAs == "" {
defaultAs = core.AsAuto
}
res := &whoamiResult{
Profile: cfg.ProfileName,
AppID: cfg.AppID,
Brand: cfg.Brand,
DefaultAs: string(defaultAs),
Identity: string(as),
IdentitySource: source,
}
switch as {
case core.AsBot:
res.Available = diag.Bot.Available
res.TokenStatus = diag.Bot.Status
if !diag.Bot.Available {
res.Hint = "Bot identity not configured. Set app secret or bot token (see `lark-cli config --help`)."
}
default: // user
res.Available = diag.User.Available
res.OpenID = diag.User.OpenID
res.UserName = diag.User.UserName
res.TokenStatus = diag.User.TokenStatus
if res.TokenStatus == "" {
res.TokenStatus = "missing"
}
if !diag.User.Available {
res.Hint = "No usable user token. Run `lark-cli auth login`."
}
}
return res
}
// formatPretty writes the human-readable one-glance summary.
func formatPretty(w io.Writer, r *whoamiResult) {
fmt.Fprintf(w, "Profile: %s (%s, %s)\n", r.Profile, r.AppID, r.Brand)
fmt.Fprintf(w, "Identity: %s (%s)\n", r.Identity, r.IdentitySource)
if r.Identity == string(core.AsUser) && r.UserName != "" {
if r.OpenID != "" {
fmt.Fprintf(w, "User: %s (%s)\n", r.UserName, r.OpenID)
} else {
fmt.Fprintf(w, "User: %s\n", r.UserName)
}
}
token := r.TokenStatus
if !r.Available && r.Hint != "" {
token = r.TokenStatus + " — " + r.Hint
}
// Write the label and value as separate %s args rather than one combined
// literal. A single label-colon-value literal trips the public-content
// credential scanner as a false-positive credential assignment; splitting
// the args avoids it while producing identical output.
fmt.Fprintf(w, "%s%s\n", "Token: ", token)
}

258
cmd/whoami/whoami_test.go Normal file
View File

@@ -0,0 +1,258 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whoami
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
)
func TestResolveSource(t *testing.T) {
tests := []struct {
name string
changedAs bool
flagAs core.Identity
autoDetected bool
strictForced core.Identity
want string
}{
{"explicit flag user", true, core.AsUser, false, "", "flag"},
{"explicit flag bot", true, core.AsBot, false, "", "flag"},
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto-detect"},
{"auto detected", false, "", true, "", "auto-detect"},
{"strict mode", false, "", false, core.AsBot, "strict-mode"},
{"default-as", false, "", false, "", "default-as"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveSource(tt.changedAs, tt.flagAs, tt.autoDetected, tt.strictForced)
if got != tt.want {
t.Errorf("resolveSource() = %q, want %q", got, tt.want)
}
})
}
}
func TestBuildResult_UserValid(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: core.BrandLark, DefaultAs: core.AsAuto}
diag := identitydiag.Result{
User: identitydiag.Identity{Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
}
r := buildResult(cfg, core.AsUser, "auto-detect", diag)
if r.Identity != "user" || r.IdentitySource != "auto-detect" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
}
if !r.Available || r.TokenStatus != "valid" {
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
}
if r.OpenID != "ou_x" || r.UserName != "Alice" {
t.Fatalf("openId/userName = %q/%q", r.OpenID, r.UserName)
}
if r.Hint != "" {
t.Fatalf("hint = %q, want empty", r.Hint)
}
if r.Profile != "my-app" || r.AppID != "cli_x" || r.Brand != core.BrandLark {
t.Fatalf("app context = %#v", r)
}
}
func TestBuildResult_UserMissingToken(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandLark}
diag := identitydiag.Result{
User: identitydiag.Identity{Available: false, TokenStatus: ""}, // never logged in
}
r := buildResult(cfg, core.AsUser, "auto-detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
}
if r.TokenStatus != "missing" {
t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus)
}
if r.Hint == "" {
t.Fatalf("hint empty, want guidance")
}
if r.DefaultAs != "auto" {
t.Fatalf("defaultAs = %q, want auto (empty normalized)", r.DefaultAs)
}
}
func TestBuildResult_BotReady(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, DefaultAs: core.AsBot}
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: true, Status: "ready"},
}
r := buildResult(cfg, core.AsBot, "default-as", diag)
if r.Identity != "bot" || r.IdentitySource != "default-as" {
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
}
if !r.Available || r.TokenStatus != "ready" {
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
}
if r.OpenID != "" || r.UserName != "" {
t.Fatalf("bot must not carry openId/userName: %#v", r)
}
if r.Hint != "" {
t.Fatalf("hint = %q, want empty", r.Hint)
}
}
func TestBuildResult_BotNotConfigured(t *testing.T) {
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu}
diag := identitydiag.Result{
Bot: identitydiag.Identity{Available: false, Status: "not_configured"},
}
r := buildResult(cfg, core.AsBot, "auto-detect", diag)
if r.Available {
t.Fatalf("available = true, want false")
}
if r.TokenStatus != "not_configured" {
t.Fatalf("tokenStatus = %q, want not_configured", r.TokenStatus)
}
if r.Hint == "" {
t.Fatalf("hint empty, want guidance")
}
}
func TestFormatPretty_User(t *testing.T) {
var buf bytes.Buffer
formatPretty(&buf, &whoamiResult{
Profile: "my-app", AppID: "cli_x", Brand: core.BrandLark,
Identity: "user", IdentitySource: "auto-detect",
Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice",
})
out := buf.String()
for _, want := range []string{
"Profile: my-app (cli_x, lark)",
"Identity: user (auto-detect)",
"User: Alice (ou_x)",
"Token: valid",
} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q\n--- got ---\n%s", want, out)
}
}
}
func TestFormatPretty_BotNoUserLine(t *testing.T) {
var buf bytes.Buffer
formatPretty(&buf, &whoamiResult{
Profile: "p", AppID: "cli_x", Brand: core.BrandFeishu,
Identity: "bot", IdentitySource: "default-as",
Available: true, TokenStatus: "ready",
})
out := buf.String()
if strings.Contains(out, "User:") {
t.Errorf("bot output must not contain User: line\n%s", out)
}
if !strings.Contains(out, "Identity: bot (default-as)") || !strings.Contains(out, "Token: ready") {
t.Errorf("unexpected bot output:\n%s", out)
}
}
func TestFormatPretty_UnavailableShowsHint(t *testing.T) {
var buf bytes.Buffer
formatPretty(&buf, &whoamiResult{
Profile: "p", AppID: "cli_x", Brand: core.BrandLark,
Identity: "user", IdentitySource: "auto-detect",
Available: false, TokenStatus: "missing",
Hint: "No usable user token. Run `lark-cli auth login`.",
})
out := buf.String()
if !strings.Contains(out, "Token: missing — No usable user token.") {
t.Errorf("expected token line with hint, got:\n%s", out)
}
}
func TestWhoami_BotJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "test-profile", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var got whoamiResult
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v\n%s", err, stdout.String())
}
if got.Identity != "bot" {
t.Fatalf("identity = %q, want bot", got.Identity)
}
if !got.Available || got.TokenStatus != "ready" {
t.Fatalf("available=%v status=%q, want true/ready", got.Available, got.TokenStatus)
}
if got.Profile != "test-profile" {
t.Fatalf("profile = %q, want test-profile", got.Profile)
}
if got.IdentitySource == "" {
t.Fatalf("identitySource empty")
}
if got.OpenID != "" {
t.Fatalf("bot must not carry openId: %q", got.OpenID)
}
}
func TestWhoami_RejectsInvalidAs(t *testing.T) {
for _, bad := range []string{"admin", "USER", "bogus123", ""} {
t.Run("as="+bad, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--as", bad})
err := cmd.Execute()
if err == nil {
t.Fatalf("Execute() with --as %q = nil, want validation error", bad)
}
// Lock in the typed validation contract: an unsupported identity must
// surface as a *errs.ValidationError on --as, not just any error.
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("Execute() with --as %q: error type = %T, want *errs.ValidationError: %v", bad, err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--as" {
t.Errorf("Param = %q, want %q", ve.Param, "--as")
}
})
}
}
func TestWhoami_ConfigErrorPropagates(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
wantErr := fmt.Errorf("boom")
f.Config = func() (*core.CliConfig, error) { return nil, wantErr }
cmd := NewCmdWhoami(f)
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err == nil {
t.Fatalf("Execute() error = nil, want propagated config error")
}
// The f.Config() failure must propagate unchanged, not be masked by a later
// command-execution error.
if !errors.Is(err, wantErr) {
t.Fatalf("Execute() error = %v, want it to wrap %v", err, wantErr)
}
}

41
content_embed.go Normal file
View File

@@ -0,0 +1,41 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package main
import (
"embed"
"fmt"
"io/fs"
"os"
"github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/internal/affordance"
)
// embeddedContentFS bundles the agent-readable content that must ship in lockstep
// with the binary: each skill's docs (SKILL.md + references/, plus whiteboard's
// routes/ and scenes/) and the per-domain affordance guidance (affordance/*.md).
// Machine-resource skill dirs (assets/, scripts/) are excluded. It's a whitelist —
// a new content type is omitted until added to the embed list. The embed must live
// in this root package because go:embed cannot reach up out of a package's dir.
//
//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes affordance/*.md
var embeddedContentFS embed.FS
// init wires the embedded content into the CLI. It compiles into `go build .` but
// not the single-file preview build (`go build ./main.go`), so that build stays
// self-contained (shipping no embedded content). Assembly failures warn on stderr
// rather than panicking — embedded content is nice-to-have, not load-bearing.
func init() {
if sub, err := fs.Sub(embeddedContentFS, "skills"); err != nil {
fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err)
} else {
cmd.SetEmbeddedSkillContent(sub)
}
if sub, err := fs.Sub(embeddedContentFS, "affordance"); err != nil {
fmt.Fprintln(os.Stderr, "warning: affordance embed assembly failed, command guidance disabled:", err)
} else {
affordance.SetSource(sub)
}
}

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"github.com/larksuite/cli/internal/event"
)
// VCParticipantMeetingJoinedOutput is the flattened shape for vc.meeting.participant_meeting_joined_v1.
type VCParticipantMeetingJoinedOutput struct {
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_joined_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
}
func processVCParticipantMeetingJoined(_ 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"`
} `json:"header"`
Event struct {
Meeting struct {
ID string `json:"id"`
Topic string `json:"topic"`
MeetingNo string `json:"meeting_no"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
CalendarEventID string `json:"calendar_event_id"`
} `json:"meeting"`
} `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
}
meeting := envelope.Event.Meeting
out := &VCParticipantMeetingJoinedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
MeetingID: meeting.ID,
Topic: meeting.Topic,
MeetingNo: meeting.MeetingNo,
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
CalendarEventID: meeting.CalendarEventID,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}

View File

@@ -0,0 +1,281 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"reflect"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestVCKeys_ProcessedMeetingLifecycleRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
eventType string
schemaType reflect.Type
}{
{eventTypeMeetingStarted, reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
{eventTypeMeetingJoined, reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
} {
t.Run(tc.eventType, func(t *testing.T) {
def, ok := event.Lookup(tc.eventType)
if !ok {
t.Fatalf("%s should be registered via Keys()", tc.eventType)
}
if def.Schema.Custom == nil {
t.Error("Processed key must set Schema.Custom")
}
if def.Schema.Native != nil {
t.Error("Processed key must not set Schema.Native")
}
if def.Process == nil {
t.Error("Process must not be nil for processed key")
}
if def.PreConsume == nil {
t.Error("PreConsume must not be nil for processed key")
}
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:meeting.meetingevent:read" {
t.Errorf("Scopes = %v", def.Scopes)
}
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v", def.AuthTypes)
}
if len(def.RequiredConsoleEvents) != 1 || def.RequiredConsoleEvents[0] != tc.eventType {
t.Errorf("RequiredConsoleEvents = %v", def.RequiredConsoleEvents)
}
if def.Schema.Custom.Type != tc.schemaType {
t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, tc.schemaType)
}
})
}
}
func TestProcessVCParticipantMeetingLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{
name: "started",
eventType: eventTypeMeetingStarted,
process: processVCParticipantMeetingStarted,
},
{
name: "joined",
eventType: eventTypeMeetingJoined,
process: processVCParticipantMeetingJoined,
},
} {
t.Run(tc.name, func(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_lifecycle_001",
"event_type": "` + tc.eventType + `",
"create_time": "1608725989000",
"app_id": "cli_test"
},
"event": {
"meeting": {
"id": "6911188411934433028",
"topic": "my meeting",
"meeting_no": "235812466",
"start_time": "1608883322",
"end_time": "1608883899",
"calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0"
}
}
}`
out := runMeetingLifecycleMap(t, tc.eventType, tc.process, payload)
if out["type"] != tc.eventType {
t.Errorf("type = %q", out["type"])
}
if out["event_id"] != "ev_vc_lifecycle_001" {
t.Errorf("event_id = %q", out["event_id"])
}
if out["timestamp"] != "1608725989000" {
t.Errorf("timestamp = %q", out["timestamp"])
}
if out["meeting_id"] != "6911188411934433028" {
t.Errorf("meeting_id = %q", out["meeting_id"])
}
if out["topic"] != "my meeting" || out["meeting_no"] != "235812466" {
t.Errorf("topic/meeting_no = %q/%q", out["topic"], out["meeting_no"])
}
if out["calendar_event_id"] != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" {
t.Errorf("calendar_event_id = %q", out["calendar_event_id"])
}
if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out["start_time"] != want {
t.Errorf("start_time = %q, want %q", out["start_time"], want)
}
if _, hasEndTime := out["end_time"]; hasEndTime {
t.Error("end_time should not be present in started/joined output")
}
})
}
}
func TestProcessVCParticipantMeetingLifecycle_InvalidMeetingTimes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted},
{"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined},
} {
t.Run(tc.name, func(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_lifecycle_002",
"event_type": "` + tc.eventType + `",
"create_time": "1608725989001"
},
"event": {
"meeting": {
"id": "meeting_invalid_time",
"start_time": "bad",
"end_time": ""
}
}
}`
out := runMeetingLifecycleRaw(t, tc.eventType, tc.process, payload)
switch tc.eventType {
case eventTypeMeetingStarted:
var started VCParticipantMeetingStartedOutput
if err := json.Unmarshal(out, &started); err != nil {
t.Fatalf("Process output is not valid started JSON: %v\nraw=%s", err, string(out))
}
if started.StartTime != "" {
t.Errorf("StartTime = %q, want empty string", started.StartTime)
}
case eventTypeMeetingJoined:
var joined VCParticipantMeetingJoinedOutput
if err := json.Unmarshal(out, &joined); err != nil {
t.Fatalf("Process output is not valid joined JSON: %v\nraw=%s", err, string(out))
}
if joined.StartTime != "" {
t.Errorf("StartTime = %q, want empty string", joined.StartTime)
}
}
})
}
}
func TestProcessVCParticipantMeetingLifecycle_MalformedPayload(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted},
{"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined},
} {
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 TestVCParticipantMeetingLifecycle_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, eventType := range []string{eventTypeMeetingStarted, eventTypeMeetingJoined} {
t.Run(eventType, func(t *testing.T) {
def, ok := event.Lookup(eventType)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventType)
}
type call struct {
method string
path string
body any
}
var calls []call
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
calls = append(calls, call{method: method, path: path, body: body})
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
},
}
cleanup, err := def.PreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("PreConsume error: %v", err)
}
if cleanup == nil {
t.Fatal("cleanup must not be nil")
}
if len(calls) != 1 {
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
}
if calls[0].method != "POST" || calls[0].path != pathMeetingSubscribe {
t.Fatalf("subscribe call = %+v", calls[0])
}
assertSubscriptionRequest(t, calls[0].body, eventType)
cleanup()
if len(calls) != 2 {
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
}
if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe {
t.Fatalf("unsubscribe call = %+v", calls[1])
}
assertSubscriptionRequest(t, calls[1].body, eventType)
})
}
}
func runMeetingLifecycleMap(t *testing.T, eventType string, process event.ProcessFunc, payload string) map[string]string {
t.Helper()
got := runMeetingLifecycleRaw(t, eventType, process, payload)
if got == nil {
t.Fatal("Process output is nil")
}
var out map[string]string
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid flat JSON object: %v\nraw=%s", err, string(got))
}
return out
}
func runMeetingLifecycleRaw(t *testing.T, eventType string, process event.ProcessFunc, payload string) json.RawMessage {
t.Helper()
raw := &event.RawEvent{
EventType: eventType,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
return got
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"github.com/larksuite/cli/internal/event"
)
// VCParticipantMeetingStartedOutput is the flattened shape for vc.meeting.participant_meeting_started_v1.
type VCParticipantMeetingStartedOutput struct {
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_started_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
}
func processVCParticipantMeetingStarted(_ 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"`
} `json:"header"`
Event struct {
Meeting struct {
ID string `json:"id"`
Topic string `json:"topic"`
MeetingNo string `json:"meeting_no"`
StartTime string `json:"start_time"`
CalendarEventID string `json:"calendar_event_id"`
} `json:"meeting"`
} `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
}
meeting := envelope.Event.Meeting
out := &VCParticipantMeetingStartedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
MeetingID: meeting.ID,
Topic: meeting.Topic,
MeetingNo: meeting.MeetingNo,
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
CalendarEventID: meeting.CalendarEventID,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}

View File

@@ -11,6 +11,8 @@ import (
)
const (
eventTypeMeetingStarted = "vc.meeting.participant_meeting_started_v1"
eventTypeMeetingJoined = "vc.meeting.participant_meeting_joined_v1"
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
eventTypeNoteGenerated = "vc.note.generated_v1"
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
@@ -30,6 +32,38 @@ const (
// Keys returns all VC-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeMeetingStarted,
DisplayName: "Participant meeting started",
Description: "Triggered when a meeting the current user participates in has started",
EventType: eventTypeMeetingStarted,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
},
Process: processVCParticipantMeetingStarted,
PreConsume: subscriptionPreConsume(eventTypeMeetingStarted, pathMeetingSubscribe, pathMeetingUnsubscribe),
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeMeetingStarted},
},
{
Key: eventTypeMeetingJoined,
DisplayName: "Participant meeting joined",
Description: "Triggered when the current user joins a meeting",
EventType: eventTypeMeetingJoined,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
},
Process: processVCParticipantMeetingJoined,
PreConsume: subscriptionPreConsume(eventTypeMeetingJoined, pathMeetingSubscribe, pathMeetingUnsubscribe),
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeMeetingJoined},
},
{
Key: eventTypeMeetingEnded,
DisplayName: "Participant meeting ended",

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package affordance is the lazily-loaded store of usage guidance for
// service-API methods. The source of truth is one markdown file per service in
// the top-level affordance/ tree (see mdparse.go), injected via SetSource so
// domain owners maintain it next to skills/ and shortcuts/. A service is read
// and parsed at most once, on first access, so normal command execution never
// touches it.
package affordance
import (
"encoding/json"
"io/fs"
"strings"
"sync"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/registry"
)
var (
mu sync.Mutex
byService = map[string]map[string]json.RawMessage{}
tried = map[string]bool{}
mdSource fs.FS // top-level affordance/*.md tree; nil in the minimal preview build
)
// SetSource installs the markdown guidance tree (the top-level affordance/
// directory) as the source. Called once at startup before any lookup; clears
// the parse cache so re-sourcing (e.g. in tests) takes effect.
func SetSource(fsys fs.FS) {
mu.Lock()
defer mu.Unlock()
mdSource = fsys
byService = map[string]map[string]json.RawMessage{}
tried = map[string]bool{}
}
// For returns the raw affordance overlay for one method, loading the owning
// service on first access. ok is false when there is no entry (absent source,
// parse failure, or unknown method all collapse to "no guidance").
func For(service, methodID string) (json.RawMessage, bool) {
mu.Lock()
defer mu.Unlock()
if !tried[service] {
tried[service] = true
byService[service] = loadService(service)
}
raw, ok := byService[service][methodID]
return raw, ok && len(raw) > 0
}
// loadService parses a service's markdown guidance into per-method overlays,
// marshalling each to JSON so downstream callers keep the same wire shape.
func loadService(service string) map[string]json.RawMessage {
if mdSource == nil {
return nil
}
src, err := fs.ReadFile(mdSource, service+".md")
if err != nil {
return nil
}
m := map[string]json.RawMessage{}
for id, a := range parseDomainMD(src, commandFormResolver(service)) {
if b, err := json.Marshal(a); err == nil {
m[id] = b
}
}
return m
}
// commandFormResolver maps a method's command-form heading ("user_mailbox.messages
// list") to its method id ("user_mailbox.message.list") via the registry's
// authoritative resource↔id table. Resource names are irregularly pluralised
// (message/messages, user_mailbox/user_mailboxes), so this cannot be guessed; the
// space→dot fallback covers domains where the two already coincide.
func commandFormResolver(service string) func(string) string {
byForm := map[string]string{}
for _, svc := range registry.EmbeddedServicesTyped() {
if svc.Name != service {
continue
}
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
}
break
}
return func(h string) string {
h = strings.TrimSpace(h)
if id, ok := byForm[h]; ok {
return id
}
return strings.ReplaceAll(h, " ", ".")
}
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package affordance
import (
"encoding/json"
"testing"
"testing/fstest"
)
// fixtureMD is a minimal affordance source: two methods, each with a lead
// paragraph (use_when) and a fenced example.
const fixtureMD = "# approval\n" +
"> skill: lark-approval\n\n" +
"## instances cc\n" +
"把一个审批实例抄送给指定用户。\n\n" +
"### Examples\n\n" +
"**抄送给用户**\n" +
"```bash\n" +
"lark-cli approval instances cc --data '{\"instance_code\":\"x\"}'\n" +
"```\n\n" +
"## instances get\n" +
"查询某审批实例详情。\n\n" +
"### Examples\n\n" +
"**按 code 查询**\n" +
"```bash\n" +
"lark-cli approval instances get --instance-code \"x\"\n" +
"```\n"
func TestFor(t *testing.T) {
prev := mdSource
t.Cleanup(func() { SetSource(prev) }) // SetSource mutates package state; restore for test isolation
SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte(fixtureMD)}})
// A seeded method in a seeded service resolves to its overlay.
raw, ok := For("approval", "instances.cc")
if !ok {
t.Fatal(`For("approval","instances.cc") ok=false, want an overlay`)
}
var a struct {
UseWhen []string `json:"use_when"`
Examples []struct {
Command string `json:"command"`
} `json:"examples"`
}
if err := json.Unmarshal(raw, &a); err != nil {
t.Fatalf("overlay is not valid affordance JSON: %v", err)
}
if len(a.UseWhen) == 0 || len(a.Examples) == 0 || a.Examples[0].Command == "" {
t.Errorf("overlay missing use_when/examples: %s", raw)
}
// Misses: unknown method in a known service, and an unknown service, both
// resolve to ok=false (no panic, no error) so callers treat them as "no
// guidance".
if _, ok := For("approval", "instances.no_such_method"); ok {
t.Error("unknown method should be ok=false")
}
if _, ok := For("no_such_service", "x.y"); ok {
t.Error("unknown service should be ok=false")
}
// A second lookup of the same service is served from cache (parsed at most
// once) and stays consistent.
if _, ok := For("approval", "instances.get"); !ok {
t.Error("second lookup in a cached service should still resolve")
}
}
// Non-bullet paragraph lines under any section are preserved as items, not
// dropped (regression: they previously only updated pending, lost without a fence).
func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
md := "# d\n\n## foo bar\nwhat it does.\n\n### Tips\n- a bullet\nplain paragraph note.\n\n### See also\nrun [[other cmd]] first.\n"
got := parseDomainMD([]byte(md), nil) // nil resolver -> space->dot, "foo bar" -> "foo.bar"
a, ok := got["foo.bar"]
if !ok {
t.Fatal("method not parsed")
}
if len(a.Tips) != 2 || a.Tips[1] != "plain paragraph note." {
t.Errorf("Tips paragraph dropped: %v", a.Tips)
}
if len(a.Extensions) != 1 || len(a.Extensions[0].Items) != 1 || a.Extensions[0].Items[0] != "run `other cmd` first." {
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
}
}

View File

@@ -0,0 +1,180 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package affordance
import (
"regexp"
"strings"
"github.com/larksuite/cli/internal/meta"
)
// The affordance source is a narrow, fixed markdown subset (see src/*.md):
//
// # domain optional `> skill: <name>` applied to every method
// ## command e.g. `instances get`
// <lead paragraph> -> use_when (when this command is right)
// ### Avoid when -> avoid_when (links become prefer/alternative edges)
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
// ### Tips -> tips
// ### Examples -> examples: **description** + a ```fenced``` command
// ### <other> -> extensions[] (custom section, flows through verbatim)
// [[cmd]] -> a command reference, rendered as `cmd`
//
// Parsing is lazy and cached (see For), so the constrained grammar is read at
// most once per domain.
var mdLink = regexp.MustCompile(`\[\[(.+?)\]\]`)
// standardSection maps a section heading to its typed Affordance field; any
// other heading becomes an extension.
var standardSection = map[string]string{
"Avoid when": "avoid_when",
"Prerequisites": "prerequisites",
"Tips": "tips",
"Examples": "examples",
}
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
// headingToKey maps a command heading ("instances get") to its affordance key
// ("instances.get"). The space→dot rule holds where the command form matches
// the method id; domains whose resource names differ (e.g. plural "messages"
// vs id segment "message") need the registry's authoritative resource↔id table.
func headingToKey(h string) string {
return strings.ReplaceAll(strings.TrimSpace(h), " ", ".")
}
type mdSection struct {
label string
items []string
cases []meta.AffordanceCase
}
// parseDomainMD parses one domain's markdown into per-method Affordance values,
// keyed by method id. resolve maps a command-form heading ("user_mailbox.messages
// list") to its method id ("user_mailbox.message.list"); nil falls back to the
// space→dot rule (valid only where the command form already equals the id).
func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affordance {
if resolve == nil {
resolve = headingToKey
}
out := map[string]meta.Affordance{}
var skill, curKey string
var useWhen, para []string // lead paragraphs -> use_when entries (blank line separates)
var secs []*mdSection
var sec *mdSection
var pending string
var fence []string
inFence := false
assemble := func() {
if curKey == "" {
return
}
if len(para) > 0 {
useWhen = append(useWhen, strings.TrimSpace(strings.Join(para, " ")))
para = nil
}
var a meta.Affordance
if len(useWhen) > 0 {
a.UseWhen = useWhen
}
for _, s := range secs {
switch standardSection[s.label] {
case "avoid_when":
a.AvoidWhen = s.items
case "prerequisites":
a.Prerequisites = s.items
case "tips":
a.Tips = s.items
case "examples":
a.Examples = s.cases
default:
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
}
}
if skill != "" {
a.Skills = []string{skill}
}
out[curKey] = a
}
reset := func() { useWhen, para, secs, sec, pending, fence, inFence = nil, nil, nil, nil, "", nil, false }
// flushPending appends a non-bullet paragraph line that was not consumed as
// an example description (i.e. no fence followed) to the current section's
// items, so prose under any section is preserved rather than dropped.
flushPending := func() {
if sec != nil && pending != "" {
sec.items = append(sec.items, linkToBacktick(pending))
pending = ""
}
}
for _, raw := range strings.Split(string(src), "\n") {
line := strings.TrimRight(raw, "\r")
t := strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "## "):
flushPending()
assemble()
curKey = resolve(line[3:])
reset()
continue
case strings.HasPrefix(line, "# "):
continue
case strings.HasPrefix(t, "> skill:"):
skill = strings.TrimSpace(t[len("> skill:"):])
continue
case strings.HasPrefix(line, "### "):
flushPending()
sec = &mdSection{label: strings.TrimSpace(line[4:])}
secs = append(secs, sec)
pending, fence, inFence = "", nil, false
continue
}
if curKey == "" {
continue
}
if sec == nil { // lead paragraphs before any section -> use_when (blank line separates entries)
if t == "" {
if len(para) > 0 {
useWhen = append(useWhen, strings.Join(para, " "))
para = nil
}
} else {
para = append(para, t)
}
continue
}
// inside a section: a fenced block is an example command; otherwise the
// shape follows the writing (bullet item vs **description** before a fence).
if strings.HasPrefix(t, "```") {
if !inFence {
inFence, fence = true, nil
} else {
inFence = false
sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")})
pending = ""
}
continue
}
if inFence {
fence = append(fence, line)
continue
}
if strings.HasPrefix(t, "-") {
flushPending()
sec.items = append(sec.items, linkToBacktick(strings.TrimSpace(t[1:])))
} else if t != "" {
flushPending()
pending = strings.Trim(t, "* ")
}
}
flushPending()
assemble()
return out
}

View File

@@ -5,30 +5,39 @@ package meta
import "encoding/json"
// Affordance is the hand-authored usage guidance overlaid on a method: when to
// use it, when not to, prerequisites, few-shot examples, and related methods.
// It is the single typed model of the affordance shape; the envelope renderer
// and the command help both parse through ParsedAffordance so the vocabulary
// is defined once. The JSON tags double as the envelope's wire shape.
// Affordance is the typed usage guidance overlaid on a method. It is the single
// model the envelope renderer and the command help both parse, so the
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
// Skills entries are skill names (or name/path) rendered as runnable
// `lark-cli skills read <entry>` pointers.
type Affordance struct {
UseWhen []string `json:"use_when,omitempty"`
DoNotUseWhen []string `json:"do_not_use_when,omitempty"`
Prerequisites []string `json:"prerequisites,omitempty"`
Examples []AffordanceCase `json:"examples,omitempty"`
Related []string `json:"related,omitempty"`
UseWhen []string `json:"use_when,omitempty"`
AvoidWhen []string `json:"avoid_when,omitempty"`
Prerequisites []string `json:"prerequisites,omitempty"`
Tips []string `json:"tips,omitempty"`
Examples []AffordanceCase `json:"examples,omitempty"`
Extensions []AffordanceSection `json:"extensions,omitempty"`
Related []string `json:"related,omitempty"`
Skills []string `json:"skills,omitempty"`
}
// AffordanceCase is one few-shot example: a one-line description and a
// ready-to-run command.
// AffordanceCase is one few-shot example: a description and a ready-to-run command.
type AffordanceCase struct {
Description string `json:"description"`
Description string `json:"description,omitempty"`
Command string `json:"command"`
}
// ParsedAffordance decodes the method's raw affordance overlay into the typed
// Affordance. ok is false when the method carries no affordance, the JSON is
// malformed, or every section is empty — so callers can treat "no guidance"
// uniformly.
// AffordanceSection is a custom guidance section: any heading beyond the
// standard four (Avoid when / Prerequisites / Tips / Examples) flows through
// here with its label preserved, so authors can add sections without code
// changes.
type AffordanceSection struct {
Label string `json:"label"`
Items []string `json:"items,omitempty"`
}
// ParsedAffordance decodes the method's overlay. ok is false when it is absent,
// malformed, or wholly empty — callers treat all three as "no guidance".
func (m Method) ParsedAffordance() (Affordance, bool) {
if len(m.Affordance) == 0 {
return Affordance{}, false
@@ -37,7 +46,7 @@ func (m Method) ParsedAffordance() (Affordance, bool) {
if json.Unmarshal(m.Affordance, &a) != nil {
return Affordance{}, false
}
if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 {
if len(a.UseWhen) == 0 && len(a.AvoidWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Tips) == 0 && len(a.Examples) == 0 && len(a.Extensions) == 0 && len(a.Related) == 0 && len(a.Skills) == 0 {
return Affordance{}, false
}
return a, true

View File

@@ -19,7 +19,7 @@ func TestMethod_ParsedAffordance(t *testing.T) {
notOK := map[string]string{
"empty payload": ``,
"empty object": `{}`,
"all empty arrays": `{"use_when":[],"do_not_use_when":[],"prerequisites":[],"examples":[],"related":[]}`,
"all empty arrays": `{"use_when":[],"avoid_when":[],"prerequisites":[],"tips":[],"examples":[],"related":[]}`,
"malformed string": `"not an object"`,
"malformed number": `42`,
"nested type mismatch": `{"examples":"should be a list"}`,
@@ -35,8 +35,9 @@ func TestMethod_ParsedAffordance(t *testing.T) {
// Populated affordance parses with all fields.
raw := `{
"use_when": ["需要拿到当前用户的主日历 ID"],
"do_not_use_when": ["已知具体 calendar_id"],
"avoid_when": ["已知具体 calendar_id"],
"prerequisites": ["user 身份登录"],
"tips": ["主日历的 calendar_id 即当前用户的 union_id"],
"examples": [{"description":"获取主日历","command":"lark-cli calendar calendars primary"}],
"related": ["calendars.list"]
}`
@@ -47,10 +48,22 @@ func TestMethod_ParsedAffordance(t *testing.T) {
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
t.Errorf("UseWhen = %v", a.UseWhen)
}
if len(a.Tips) != 1 || a.Tips[0] != "主日历的 calendar_id 即当前用户的 union_id" {
t.Errorf("Tips = %v", a.Tips)
}
if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" || a.Examples[0].Command != "lark-cli calendar calendars primary" {
t.Errorf("Examples = %+v", a.Examples)
}
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {
t.Errorf("Related = %v", a.Related)
}
// A method whose only guidance is Tips still parses as populated.
tipsOnly, ok := (Method{Affordance: json.RawMessage(`{"tips":["先调用 list 拿到 id"]}`)}).ParsedAffordance()
if !ok {
t.Fatal("ParsedAffordance with only tips ok=false, want populated")
}
if len(tipsOnly.Tips) != 1 || tipsOnly.Tips[0] != "先调用 list 拿到 id" {
t.Errorf("Tips = %v", tipsOnly.Tips)
}
}

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"fmt"
"io"
"sync"
"time"
)
// spinnerFrames are braille spinner glyphs cycled to animate progress.
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
const (
spinnerInterval = 80 * time.Millisecond
spinnerHideCursor = "\x1b[?25l"
spinnerShowCursor = "\x1b[?25h"
spinnerClearLine = "\r\x1b[K" // CR + clear-to-end-of-line
)
// StartSpinner renders a braille spinner with an elapsed-seconds counter to w
// until the returned stop() is called, e.g.:
//
// ⠹ Publishing dev → main... 3s
//
// It is meant for slow operations (long polls, first-time provisioning) so the
// user sees the CLI is alive. Always write to STDERR (w = IO().ErrOut) so the
// animation never pollutes stdout — the JSON/pretty result stays clean.
//
// When enabled is false (stderr is not a TTY: pipes, CI, captured output) it is
// a no-op returning a no-op stop, so non-interactive runs emit nothing. Gate on
// the stderr-TTY check (IOStreams.StderrIsTerminal), not the output format: the
// spinner is stderr-only and self-clears, so it is shown in JSON mode too.
//
// stop() clears the spinner line, restores the cursor, and blocks until the
// render goroutine has finished — so callers can safely write the result to
// stdout/stderr immediately after. Call stop() BEFORE printing the result, and
// it is safe to call more than once (e.g. an explicit call plus a defer).
func StartSpinner(w io.Writer, enabled bool, label string) func() {
if !enabled || w == nil {
return func() {}
}
done := make(chan struct{})
finished := make(chan struct{})
start := time.Now()
go func() {
defer close(finished)
frame := 0
fmt.Fprint(w, spinnerHideCursor)
render := func() {
elapsed := int(time.Since(start).Seconds())
fmt.Fprintf(w, "%s%s %s... %ds", spinnerClearLine, spinnerFrames[frame], label, elapsed)
frame = (frame + 1) % len(spinnerFrames)
}
render()
ticker := time.NewTicker(spinnerInterval)
defer ticker.Stop()
for {
select {
case <-done:
fmt.Fprint(w, spinnerClearLine+spinnerShowCursor)
return
case <-ticker.C:
render()
}
}
}()
var once sync.Once
return func() {
once.Do(func() {
close(done)
<-finished // wait for the line to be cleared before returning
})
}
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"strings"
"testing"
)
// TestStartSpinner_DisabledIsNoop asserts that a disabled spinner writes nothing and its stop func is idempotent.
func TestStartSpinner_DisabledIsNoop(t *testing.T) {
var buf bytes.Buffer
stop := StartSpinner(&buf, false, "working")
stop()
stop() // idempotent
if buf.Len() != 0 {
t.Fatalf("disabled spinner wrote %q, want nothing", buf.String())
}
}
// TestStartSpinner_NilWriterIsNoop asserts that a nil writer is a no-op and stopping does not panic.
func TestStartSpinner_NilWriterIsNoop(t *testing.T) {
stop := StartSpinner(nil, true, "working")
stop() // must not panic
}
// TestStartSpinner_EnabledAnimatesAndCleansUp asserts that an enabled spinner renders a frame and label, then clears the line and restores the cursor on stop.
func TestStartSpinner_EnabledAnimatesAndCleansUp(t *testing.T) {
var buf bytes.Buffer
stop := StartSpinner(&buf, true, "Publishing")
// The goroutine renders the first frame synchronously before selecting on
// the stop channel, so even an immediate stop() yields one full cycle.
stop()
stop() // idempotent, must not panic or double-write after finished
out := buf.String()
if !strings.Contains(out, spinnerHideCursor) {
t.Errorf("missing hide-cursor escape:\n%q", out)
}
if !strings.Contains(out, spinnerFrames[0]) {
t.Errorf("missing first spinner frame %q:\n%q", spinnerFrames[0], out)
}
if !strings.Contains(out, "Publishing...") {
t.Errorf("missing label:\n%q", out)
}
if !strings.Contains(out, spinnerClearLine) {
t.Errorf("missing clear-line escape:\n%q", out)
}
if !strings.HasSuffix(out, spinnerShowCursor) {
t.Errorf("must end by restoring the cursor:\n%q", out)
}
}

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
type eventPayload struct {
Comment *struct {
Body string `json:"body"`
} `json:"comment"`
Review *struct {
Body string `json:"body"`
} `json:"review"`
}
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")
flag.Parse()
if *eventPath == "" {
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
os.Exit(2)
}
body, err := commentBody(*eventPath)
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)))
}
report.Print(os.Stderr, diags)
os.Exit(report.ExitCode(diags))
}
func auditFailureSummary(count int) string {
return fmt.Sprintf("post-publication audit found public content findings: %d", count)
}
func commentBody(path string) (string, error) {
safePath, err := validate.SafeInputPath(path)
if err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
WithParam("--event").
WithCause(err)
}
data, err := vfs.ReadFile(safePath)
if err != nil {
return "", err
}
var payload eventPayload
if err := json.Unmarshal(data, &payload); err != nil {
return "", err
}
switch {
case payload.Comment != nil:
return payload.Comment.Body, nil
case payload.Review != nil:
return payload.Review.Body, nil
default:
return "", nil
}
}
func diagnostics(items []publiccontent.Finding) []report.Diagnostic {
out := make([]report.Diagnostic, 0, len(items))
for _, item := range items {
out = append(out, report.Diagnostic{
Rule: item.Rule,
Action: item.Action,
File: item.File,
Line: item.Line,
Message: item.Message,
Suggestion: item.Suggestion,
})
}
return out
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package main
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
)
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
dir := t.TempDir()
if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"clean comment"}}`); 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 != "clean comment" {
t.Fatalf("comment body = %q", got)
}
}
func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "event.json")
if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil {
t.Fatal(err)
}
_, err := commentBody(path)
problem, ok := errs.ProblemOf(err)
if err == nil || !ok {
t.Fatalf("commentBody(%q) error = %v, want unsafe path validation error", path, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("commentBody(%q) problem = %#v, want invalid argument validation", path, problem)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--event" {
t.Fatalf("commentBody(%q) error = %v, want --event validation param", path, err)
}
}
func TestAuditFailureSummaryStatesPostPublicationAudit(t *testing.T) {
got := auditFailureSummary(2)
want := "post-publication audit found public content findings: 2"
if got != want {
t.Fatalf("auditFailureSummary() = %q, want %q", got, want)
}
}
func writeTestFile(path, data string) error {
return os.WriteFile(path, []byte(data), 0o644)
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/qualitygate/rules"
"github.com/larksuite/cli/internal/validate"
)
func main() {
@@ -41,6 +42,7 @@ func runCheck(args []string) int {
fs.StringVar(&opts.FactsOut, "facts-out", "", "write facts JSON to this path")
fs.StringVar(&opts.ManifestPath, "manifest", "", "hand-authored command manifest JSON")
fs.StringVar(&opts.CommandIndexPath, "command-index", "", "full command index JSON")
fs.StringVar(&opts.PublicContentMetadataPath, "public-content-metadata", "", "PR title/body metadata JSON for public content checks")
fs.BoolVar(&printLegacyCommandCandidates, "print-legacy-command-candidates", false, "print current non-kebab-case hand-authored command candidates")
fs.BoolVar(&printLegacyFlagCandidates, "print-legacy-flag-candidates", false, "print current non-kebab-case flag candidates")
if err := fs.Parse(args); err != nil {
@@ -48,6 +50,15 @@ func runCheck(args []string) int {
return 2
}
if opts.PublicContentMetadataPath != "" {
safePath, err := validate.SafeInputPath(opts.PublicContentMetadataPath)
if err != nil {
fmt.Fprintf(os.Stderr, "quality-gate check: --public-content-metadata: %v\n", err)
return 2
}
opts.PublicContentMetadataPath = safePath
}
if opts.ManifestPath == "" || opts.CommandIndexPath == "" {
fmt.Fprintln(os.Stderr, "quality-gate check: --manifest and --command-index are required")
return 2

View File

@@ -37,6 +37,37 @@ func TestCheckRequiresManifestInputs(t *testing.T) {
}
}
func TestCheckAcceptsPublicContentMetadataFlag(t *testing.T) {
code, stderr := runCheckCaptureStderr(t, []string{
"--repo", t.TempDir(),
"--cli-bin", "./lark-cli",
"--public-content-metadata", ".tmp/quality-gate/pr.json",
})
if code != 2 {
t.Fatalf("exit code = %d, stderr=%s", code, stderr)
}
if strings.Contains(stderr, "flag provided but not defined") {
t.Fatalf("public content metadata flag was not registered: %s", stderr)
}
if !strings.Contains(stderr, "--manifest and --command-index are required") {
t.Fatalf("stderr = %s", stderr)
}
}
func TestCheckRejectsUnsafePublicContentMetadataPath(t *testing.T) {
code, stderr := runCheckCaptureStderr(t, []string{
"--repo", t.TempDir(),
"--cli-bin", "./lark-cli",
"--public-content-metadata", filepath.Join(t.TempDir(), "pr.json"),
})
if code != 2 {
t.Fatalf("exit code = %d, stderr=%s", code, stderr)
}
if !strings.Contains(stderr, "--public-content-metadata") || !strings.Contains(stderr, "--file") {
t.Fatalf("stderr = %s, want unsafe public content metadata path error", stderr)
}
}
func TestCheckReportsManifestReadErrorsWithFlagName(t *testing.T) {
dir := t.TempDir()
manifestPath := filepath.Join(dir, "command-manifest.json")

View File

@@ -56,6 +56,14 @@ func run(args []string) int {
_ = semantic.WriteMarkdown(markdownOut, decision)
return 0
}
if reviewPath == "" && !semantic.BuildInputView(f).HasReviewableFacts() {
decision := finalizeDecision(block, waiverDiags, semantic.Decision{})
if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
return 2
}
return decisionExitCode(decision)
}
review, err := semantic.LoadOrReviewWithConfig(context.Background(), f, reviewPath, modelConfig)
if err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
@@ -72,6 +80,15 @@ func run(args []string) int {
return 0
}
decision := semantic.DecideWithWaivers(f, review, policy, waivers)
decision = finalizeDecision(block, waiverDiags, decision)
if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
return 2
}
return decisionExitCode(decision)
}
func finalizeDecision(block bool, waiverDiags []report.Diagnostic, decision semantic.Decision) semantic.Decision {
decision.BlockMode = block
if !block && len(decision.Blockers) > 0 {
for i := range decision.Blockers {
@@ -81,15 +98,21 @@ func run(args []string) int {
decision.Blockers = nil
}
decision.SystemWarnings = append(diagnosticSystemWarnings(waiverDiags), decision.SystemWarnings...)
return decision
}
func writeSemanticOutputs(decisionOut, markdownOut string, decision semantic.Decision) error {
if err := semantic.WriteDecision(decisionOut, decision); err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: write decision: %v\n", err)
return 2
return fmt.Errorf("write decision: %w", err)
}
if err := semantic.WriteMarkdown(markdownOut, decision); err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: write markdown: %v\n", err)
return 2
return fmt.Errorf("write markdown: %w", err)
}
if block && len(decision.Blockers) > 0 {
return nil
}
func decisionExitCode(decision semantic.Decision) int {
if decision.BlockMode && len(decision.Blockers) > 0 {
return 1
}
return 0

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/qualitygate/facts"
@@ -211,7 +212,19 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
}`, "")
factsPath := filepath.Join(t.TempDir(), "facts.json")
if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
f := facts.Facts{
SchemaVersion: 1,
Skills: []facts.SkillFact{{
SourceFile: "skills/lark-wiki/SKILL.md",
Line: 30,
Changed: true,
ReferencesInvalidCommand: true,
}},
}
if !semantic.BuildInputView(f).HasReviewableFacts() {
t.Fatal("test setup must contain reviewable facts")
}
if err := f.WriteFile(factsPath); err != nil {
t.Fatalf("write facts: %v", err)
}
decisionPath := filepath.Join(t.TempDir(), "decision.json")
@@ -228,6 +241,71 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
}
}
func TestRunShortCircuitsEmptySemanticInputWithoutReviewer(t *testing.T) {
t.Setenv("ARK_API_KEY", "")
t.Setenv("ARK_BASE_URL", "")
t.Setenv("ARK_MODEL", "")
repo := t.TempDir()
writeSemanticConfig(t, repo, `{
"schema_version": 1,
"default_enforcement": "observe",
"block_categories": ["skill_quality"]
}`, `{
"allowed": ["semantic-review-v1"],
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
}`, "")
factsPath := filepath.Join(t.TempDir(), "facts.json")
f := facts.Facts{
SchemaVersion: 1,
Commands: []facts.CommandFact{{
Path: "service command 1",
Domain: "service",
Changed: true,
Source: "service",
}},
Outputs: []facts.OutputFact{{
Command: "service command 1",
Domain: "service",
Changed: true,
Source: "service",
IsList: true,
HasDefaultLimit: true,
HasDecisionField: true,
}},
}
if semantic.BuildInputView(f).HasReviewableFacts() {
t.Fatal("test setup must not contain reviewable facts")
}
if err := f.WriteFile(factsPath); err != nil {
t.Fatalf("write facts: %v", err)
}
decisionPath := filepath.Join(t.TempDir(), "decision.json")
markdownPath := filepath.Join(t.TempDir(), "semantic.md")
code := run([]string{"--repo", repo, "--facts", factsPath, "--decision-out", decisionPath, "--markdown-out", markdownPath, "--block"})
if code != 0 {
t.Fatalf("run() = %d, want clean pass", code)
}
decision := readDecision(t, decisionPath)
if decision.Skipped || decision.Degraded || decision.InfrastructureFailure || !decision.BlockMode {
t.Fatalf("expected non-degraded pass decision: %#v", decision)
}
if len(decision.SystemWarnings) != 0 || len(decision.Warnings) != 0 || len(decision.Blockers) != 0 {
t.Fatalf("empty semantic view should not produce findings: %#v", decision)
}
data, err := os.ReadFile(markdownPath)
if err != nil {
t.Fatalf("read markdown: %v", err)
}
markdown := string(data)
if !strings.Contains(markdown, "No semantic blockers.") {
t.Fatalf("markdown missing pass summary: %s", markdown)
}
if strings.Contains(strings.ToLower(markdown), "skipped") || strings.Contains(strings.ToLower(markdown), "degraded") {
t.Fatalf("markdown should not report semantic review as skipped/degraded: %s", markdown)
}
}
func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testing.T) {
t.Setenv("ARK_API_KEY", "test-key")
t.Setenv("ARK_BASE_URL", "")
@@ -243,7 +321,19 @@ func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testi
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
}`, "")
factsPath := filepath.Join(t.TempDir(), "facts.json")
if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
f := facts.Facts{
SchemaVersion: 1,
Skills: []facts.SkillFact{{
SourceFile: "skills/lark-wiki/SKILL.md",
Line: 30,
Changed: true,
ReferencesInvalidCommand: true,
}},
}
if !semantic.BuildInputView(f).HasReviewableFacts() {
t.Fatal("test setup must contain reviewable facts")
}
if err := f.WriteFile(factsPath); err != nil {
t.Fatalf("write facts: %v", err)
}
decisionPath := filepath.Join(t.TempDir(), "decision.json")

View File

@@ -5,7 +5,8 @@
"error_hint",
"default_output",
"naming",
"skill_quality"
"skill_quality",
"public_content_leakage"
],
"rollout_groups": [
{
@@ -16,7 +17,8 @@
},
"categories": [
"error_hint",
"skill_quality"
"skill_quality",
"public_content_leakage"
],
"owner": "cli-owner",
"reason": "first semantic blocking rollout only affects changed facts"

View File

@@ -13,14 +13,15 @@ import (
)
type Facts struct {
SchemaVersion int `json:"schema_version"`
Commands []CommandFact `json:"commands,omitempty"`
Skills []SkillFact `json:"skills,omitempty"`
SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"`
Errors []ErrorFact `json:"errors,omitempty"`
Outputs []OutputFact `json:"outputs,omitempty"`
Examples []CommandExample `json:"examples,omitempty"`
Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
SchemaVersion int `json:"schema_version"`
Commands []CommandFact `json:"commands,omitempty"`
Skills []SkillFact `json:"skills,omitempty"`
SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"`
Errors []ErrorFact `json:"errors,omitempty"`
Outputs []OutputFact `json:"outputs,omitempty"`
Examples []CommandExample `json:"examples,omitempty"`
PublicContent []PublicContentFact `json:"public_content,omitempty"`
Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
}
type CommandFact struct {
@@ -109,6 +110,17 @@ type OutputFact struct {
HasDecisionField bool `json:"has_decision_field,omitempty"`
}
type PublicContentFact struct {
Rule string `json:"rule"`
Action report.Action `json:"action"`
File string `json:"file"`
Line int `json:"line"`
Source string `json:"source,omitempty"`
Excerpt string `json:"excerpt,omitempty"`
Message string `json:"message,omitempty"`
Suggestion string `json:"suggestion,omitempty"`
}
type DryRunRequest struct {
Method string `json:"method"`
URL string `json:"url"`
@@ -206,6 +218,11 @@ func BuildWithCommandLookup(m manifest.Manifest, commandLookup manifest.Manifest
}
}
func WithPublicContent(f Facts, publicContent []PublicContentFact) Facts {
f.PublicContent = publicContent
return f
}
type commandScope struct {
Domain string
Source string

View File

@@ -34,6 +34,7 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) {
Errors: []ErrorFact{{Code: "invalid_input", Message: "bad path", Hint: "pass --file", Retryable: false, HintActionCount: 1, RequiredHint: true}},
Outputs: []OutputFact{{Command: "im messages list", Fields: []string{"message_id", "sender", "create_time"}, IsList: true, HasDefaultLimit: true, HasDecisionField: true}},
Skills: []SkillFact{{SourceFile: "skills/lark-doc/SKILL.md", Line: 1, DestructiveWithoutGuard: true, ScopeConflict: true}},
PublicContent: []PublicContentFact{{Rule: "public_content_generic_credential", Action: report.ActionReject, File: "docs/public.md", Line: 4, Excerpt: "api_key = <redacted>"}},
}
data, err := json.Marshal(f)
if err != nil {
@@ -43,7 +44,10 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal facts: %v", err)
}
if !got.Errors[0].RequiredHint || got.Outputs[0].Fields[0] != "message_id" || !got.Skills[0].ScopeConflict {
if !got.Errors[0].RequiredHint ||
got.Outputs[0].Fields[0] != "message_id" ||
!got.Skills[0].ScopeConflict ||
got.PublicContent[0].Rule != "public_content_generic_credential" {
t.Fatalf("facts lost gatekeeper fields: %#v", got)
}
}

View File

@@ -0,0 +1,343 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
)
func Collect(ctx context.Context, opts Options) ([]Finding, error) {
metadata, err := LoadMetadata(opts.MetadataPath)
if err != nil {
return nil, err
}
var out []Finding
changedFiles, base, err := changedFiles(ctx, opts.Repo, opts.ChangedFrom)
if err != nil {
return nil, err
}
patches := map[string][]changedChunk{}
if base != "" {
patches, err = changedPatches(ctx, opts.Repo, base)
if err != nil {
return nil, err
}
}
for _, file := range changedFiles {
if !scanChangedFile(file) {
continue
}
for _, chunk := range patches[file] {
findings := scanText(file, "file", chunk.Text, isDetectorRuleFile(file))
for i := range findings {
findings[i].Line += chunk.StartLine - 1
}
out = append(out, findings...)
out = append(out, semanticCandidate(file, "file", chunk.Text, chunk.StartLine)...)
}
privateKeyFindings, err := scanTouchedPrivateKeyBlocks(ctx, opts.Repo, file, patches[file])
if err != nil {
return nil, err
}
out = appendUniqueFindings(out, privateKeyFindings...)
}
if base != "" {
commitFindings, err := scanCommitMessages(ctx, opts.Repo, base)
if err != nil {
return nil, err
}
out = append(out, commitFindings...)
}
branchName := opts.BranchName
if branchName == "" {
branchName = metadata.Branch
}
if branchName == "" {
branchName = branchFromEnv()
}
if branchName == "" {
branchName = currentBranch(ctx, opts.Repo)
}
if branchName != "" {
out = append(out, scanText("branch", "branch", branchName, false)...)
}
out = append(out, scanMetadata(metadata)...)
sort.SliceStable(out, func(i, j int) bool {
if out[i].File != out[j].File {
return out[i].File < out[j].File
}
if out[i].Line != out[j].Line {
return out[i].Line < out[j].Line
}
return out[i].Rule < out[j].Rule
})
return out, nil
}
func currentBranch(ctx context.Context, repo string) string {
data, err := gitOutput(ctx, repo, "branch", "--show-current")
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func branchFromEnv() string {
for _, key := range []string{"PR_BRANCH", "GITHUB_HEAD_REF", "GITHUB_REF_NAME"} {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
}
return ""
}
func changedFiles(ctx context.Context, repo, changedFrom string) ([]string, string, error) {
if changedFrom == "" {
return nil, "", nil
}
baseBytes, err := gitOutput(ctx, repo, "merge-base", changedFrom, "HEAD")
if err != nil {
return nil, "", err
}
base := strings.TrimSpace(string(baseBytes))
files, err := diffFileNames(ctx, repo, base)
if err != nil {
return nil, "", err
}
sort.Strings(files)
return files, base, nil
}
func diffFileNames(ctx context.Context, repo, base string) ([]string, error) {
data, err := gitOutput(ctx, repo, "diff", "--name-only", "-z", "--diff-filter=ACMR", base+"..HEAD")
if err != nil {
return nil, err
}
var files []string
for _, file := range bytes.Split(data, []byte{0}) {
if len(file) == 0 {
continue
}
files = append(files, filepath.ToSlash(string(file)))
}
return files, nil
}
var detectorFixtureExclusions = map[string]bool{
"internal/qualitygate/publiccontent/collect_test.go": true,
"internal/qualitygate/publiccontent/rules.go": true,
"internal/qualitygate/publiccontent/scan.go": true,
"internal/qualitygate/publiccontent/scan_test.go": true,
}
func scanChangedFile(file string) bool {
normalized := strings.TrimPrefix(strings.ReplaceAll(file, "\\", "/"), "./")
return !detectorFixtureExclusions[normalized]
}
type changedChunk struct {
StartLine int
Text string
}
func (c changedChunk) endLine() int {
lines := strings.Count(strings.TrimRight(c.Text, "\n"), "\n") + 1
if lines < 1 {
lines = 1
}
return c.StartLine + lines - 1
}
func changedPatches(ctx context.Context, repo, base string) (map[string][]changedChunk, error) {
files, err := diffFileNames(ctx, repo, base)
if err != nil {
return nil, err
}
data, err := gitOutput(ctx, repo, "diff", "--no-ext-diff", "--unified=0", "--diff-filter=ACMR", base+"..HEAD")
if err != nil {
return nil, err
}
out := map[string][]changedChunk{}
var file string
var chunk *changedChunk
nextLine := 0
nextFile := 0
flush := func() {
if file == "" || chunk == nil || chunk.Text == "" {
chunk = nil
return
}
out[file] = append(out[file], *chunk)
chunk = nil
}
for _, raw := range strings.Split(string(data), "\n") {
switch {
case strings.HasPrefix(raw, "diff --git "):
flush()
file = ""
if nextFile < len(files) {
file = files[nextFile]
nextFile++
}
case strings.HasPrefix(raw, "@@ "):
flush()
start, ok := parseNewHunkStart(raw)
if !ok {
nextLine = 0
continue
}
nextLine = start
chunk = &changedChunk{StartLine: start}
case strings.HasPrefix(raw, "+") && !strings.HasPrefix(raw, "+++"):
if chunk == nil {
chunk = &changedChunk{StartLine: max(nextLine, 1)}
}
chunk.Text += strings.TrimPrefix(raw, "+") + "\n"
nextLine++
case strings.HasPrefix(raw, "-"):
continue
default:
if chunk != nil && strings.HasPrefix(raw, `\ No newline at end of file`) {
continue
}
flush()
}
}
flush()
return out, nil
}
func parseNewHunkStart(header string) (int, bool) {
parts := strings.Split(header, " ")
for _, part := range parts {
if !strings.HasPrefix(part, "+") {
continue
}
raw := strings.TrimPrefix(part, "+")
if before, _, ok := strings.Cut(raw, ","); ok {
raw = before
}
start, err := strconv.Atoi(raw)
return start, err == nil && start > 0
}
return 0, false
}
func scanCommitMessages(ctx context.Context, repo, base string) ([]Finding, error) {
data, err := gitOutput(ctx, repo, "log", "--format=%H%x00%B%x00", base+"..HEAD")
if err != nil {
return nil, err
}
parts := bytes.Split(data, []byte{0})
var out []Finding
for i := 0; i+1 < len(parts); i += 2 {
sha := strings.TrimSpace(string(parts[i]))
body := string(parts[i+1])
if sha == "" || body == "" {
continue
}
short := sha
if len(short) > 12 {
short = short[:12]
}
out = append(out, scanText("commit:"+short, "commit", body, false)...)
out = append(out, semanticCandidate("commit:"+short, "commit", body, 1)...)
}
return out, nil
}
type lineRange struct {
Start int
End int
}
func scanTouchedPrivateKeyBlocks(ctx context.Context, repo, file string, chunks []changedChunk) ([]Finding, error) {
if len(chunks) == 0 {
return nil, nil
}
data, err := gitOutput(ctx, repo, "show", "HEAD:"+file)
if err != nil {
return nil, err
}
var added []lineRange
for _, chunk := range chunks {
added = append(added, lineRange{Start: chunk.StartLine, End: chunk.endLine()})
}
var out []Finding
for _, block := range privateKeyBlocks(string(data)) {
if !rangesIntersectAny(block, added) {
continue
}
out = append(out, newFinding("public_content_private_key_block", file, block.Start, "file", "private key block"))
}
return out, nil
}
func privateKeyBlocks(text string) []lineRange {
lines := strings.Split(text, "\n")
var out []lineRange
inPrivateKey := false
start := 0
for i, line := range lines {
lineNo := i + 1
if !inPrivateKey && strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
inPrivateKey = true
start = lineNo
}
if inPrivateKey && strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
out = append(out, lineRange{Start: start, End: lineNo})
inPrivateKey = false
}
}
return out
}
func rangesIntersectAny(block lineRange, ranges []lineRange) bool {
for _, r := range ranges {
if block.Start <= r.End && r.Start <= block.End {
return true
}
}
return false
}
func appendUniqueFindings(items []Finding, additions ...Finding) []Finding {
for _, addition := range additions {
duplicate := false
for _, item := range items {
if item.Rule == addition.Rule &&
item.File == addition.File &&
item.Line == addition.Line &&
item.Source == addition.Source {
duplicate = true
break
}
}
if !duplicate {
items = append(items, addition)
}
}
return items
}
func gitOutput(ctx context.Context, repo string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = repo
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, stderr.Bytes())
}
return stdout.Bytes(), nil
}

View File

@@ -0,0 +1,885 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
writeFile(t, filepath.Join(repo, "baseline.md"), `BASE_`+`TOKEN="baseline-only"
`)
runGit(t, repo, "add", "baseline.md")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change
api_`+`key = "example-public-key"
`)
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{"title":"publish public docs","body":"Reviewed`+`-on: https://review.example.test/c/project/+/123"}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
rules := findingRules(got)
for _, want := range []string{
"public_content_generic_credential",
"public_content_change_id_trailer",
"public_content_reviewed_on_trailer",
} {
if !rules[want] {
t.Fatalf("missing rule %s in findings %#v", want, got)
}
}
for _, item := range got {
if item.File == "baseline.md" {
t.Fatalf("collector scanned unchanged baseline file: %#v", got)
}
}
}
func TestCollectScansOnlyChangedLinesInChangedFiles(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\n")
runGit(t, repo, "add", "docs/workflow.md")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\nnew public line\n")
runGit(t, repo, "add", "docs/workflow.md")
runGit(t, repo, "commit", "-m", "add public line")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
for _, item := range got {
if item.Rule == "public_content_generic_credential" && item.File == "docs/workflow.md" {
t.Fatalf("collector scanned unchanged legacy line in changed file: %#v", got)
}
}
}
func TestCollectSemanticCandidatesStoreSanitizedReviewText(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "base")
raw := "private launch plan for alpha-service rollout on Friday with SERVICE_" + "TOKEN=real-" + "secret-value"
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add semantic candidate")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
var found bool
for _, item := range got {
if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" {
continue
}
found = true
if !strings.Contains(item.Excerpt, "alpha-service rollout on Friday") {
t.Fatalf("semantic candidate should include sanitized review text, got %#v", item)
}
if strings.Contains(item.Excerpt, "real-"+"secret-value") {
t.Fatalf("semantic candidate leaked credential value: %#v", item)
}
if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=<redacted>") {
t.Fatalf("semantic candidate should redact credentials in review text, got %#v", item)
}
if !strings.Contains(item.Excerpt, "semantic signals") || !strings.Contains(item.Excerpt, "roadmap_timing") {
t.Fatalf("semantic candidate excerpt should preserve semantic signals, got %#v", item)
}
}
if !found {
t.Fatalf("missing semantic candidate in findings %#v", got)
}
}
func TestCollectSemanticCandidatesDoNotLeakWhitespaceCredentialTail(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "base")
raw := "private launch plan for internal rollout on Friday with SERVICE_" + "TOKEN=\"real " + "secret value\""
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add semantic candidate")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" {
continue
}
if strings.Contains(item.Excerpt, "secret value") || strings.Contains(item.Excerpt, "real "+"secret value") {
t.Fatalf("semantic candidate leaked credential tail: %#v", item)
}
if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=<redacted>") {
t.Fatalf("semantic candidate should redact full credential assignment, got %#v", item)
}
return
}
t.Fatalf("missing semantic candidate in findings %#v", got)
}
func TestCollectJSONBearerHeadersDoNotLeakIntoSemanticCandidates(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "base")
token := "abcdefghijklmnopqrstuvwxyz"
raw := "private launch plan for internal rollout on Friday with " +
`{"headers":{"Authorization":"Bearer ` + token + `"}}`
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add json bearer")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/public.md", "public_content_bearer_header")
for _, item := range got {
if item.File != "docs/public.md" {
continue
}
if strings.Contains(item.Excerpt, token) {
t.Fatalf("finding leaked JSON bearer token: %#v", item)
}
}
}
func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "base")
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"}`,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "add json config")
got := collectFromPreviousCommit(t, repo)
var count int
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",
} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
}
}
}
if count != 6 {
t.Fatalf("JSON credential findings = %d, want 6: %#v", count, got)
}
}
func TestCollectAllowsBenignJSONTokenFields(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
`{"tokenizer":"cl100k_base"}`,
`{"token_count": 42}`,
`{"page_token":"next"}`,
`{"next_page_token":"next"}`,
`{"file_token":"file-example"}`,
`{"doc_token":"doc-example"}`,
`{"node_token":"node-example"}`,
`{"wiki_token":"wikcn_public_doc_example"}`,
`{"folder_token":"folder-example"}`,
`{"obj_token":"obj-example"}`,
`{"spreadsheet_token":"sheet-example"}`,
`{"parent_node_token":"parent-example"}`,
`{"origin_node_token":"origin-example"}`,
`{"drive_route_token":"route-example"}`,
`{"token":"<wiki_token>"}`,
`{"token":"wiki_token"}`,
`{"token_url":"https://example.com/oauth/token"}`,
`{"token_endpoint":"https://example.com/oauth/token"}`,
`{"token_format":"Bearer"}`,
`{"secret_name":"public-example-secret"}`,
`{"base_token":"base-example"}`,
`{"app_token":"app-example"}`,
`{"sync_token":"sync-example"}`,
`{"parent_token":"parent-example"}`,
`{"target_token":"target-example"}`,
`{"parent_file_token":"parent-file-example"}`,
`{"refresh_token_expires_in": 7200}`,
`{"access_token_expires_in": 7200}`,
`{"token_expires_in": 7200}`,
`{"token_status":"active"}`,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "add benign json token fields")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
t.Fatalf("benign JSON token field should not be credential finding: %#v", got)
}
}
}
func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
repo := newGitRepo(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")
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY: <" + stripeLike + ">",
"SECRET_TOKEN: <" + patLike + ">",
"CLIENT_SECRET: <real-client-secret-value>",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
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++
}
}
if count != 3 {
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
}
}
func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "base")
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "public.json"), 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")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "add credential-shaped benign fields")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 7 {
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
}
}
func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
repo := newGitRepo(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")
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY_NAME: prod_key",
"CLIENT_SECRET_NAME: prod_secret",
"SECRET_STATUS: prod_secret",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
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++
}
}
if count != 3 {
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
}
}
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
repo := newGitRepo(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"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"AWS_ACCESS_KEY_ID: " + accessKey,
"ACCESS_KEY_ID: " + accessKey,
"ACCESS_KEY: " + accessKey,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add access key config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
continue
}
count++
if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
}
}
if count != 3 {
t.Fatalf("access key credential findings = %d, want 3: %#v", count, got)
}
}
func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
repo := newGitRepo(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")
privateKey := "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0t"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"PRIVATE_KEY: " + privateKey,
"SSH_PRIVATE_KEY: " + privateKey,
"JWT_PRIVATE_KEY: " + privateKey,
"SIGNING_PRIVATE_KEY: " + privateKey,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add private key config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
continue
}
count++
if strings.Contains(item.Excerpt, privateKey) {
t.Fatalf("private key finding leaked value in excerpt %q", item.Excerpt)
}
}
if count != 4 {
t.Fatalf("private key assignment findings = %d, want 4: %#v", count, got)
}
}
func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
repo := newGitRepo(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")
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY_OPENAI: prod_key",
"CLIENT_SECRET_GOOGLE: prod_secret",
"TOKEN_GITHUB: github_token",
"APP_PASSWORD_PROD: prod_password",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
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++
}
}
if count != 4 {
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
}
}
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
repo := newGitRepo(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")
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"tokens: 128",
"token_type: bearer",
"max_tokens: 2000",
"completion_tokens: 200",
"prompt_tokens: 100",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add benign token config")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
t.Fatalf("benign unquoted token field should not be credential finding: %#v", got)
}
}
}
func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
repo := newGitRepo(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")
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",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
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" {
continue
}
count++
for _, forbidden := range []string{
"real-openai-key",
"real-github-token",
"real-google-secret",
"real-secret-key-base",
"real-prod-password",
} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
}
}
if count != 5 {
t.Fatalf("credential suffix variants findings = %d, want 5: %#v", count, got)
}
}
func TestCollectDetectsPrivateKeyWhenOnlyEndIsAdded(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n")
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\nnew-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "complete key")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
}
func TestCollectDetectsPrivateKeyWhenOnlyBeginIsAdded(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), "legacy-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "complete key")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
}
func TestCollectDetectsPrivateKeyWhenOnlyBodyIsAdded(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"new-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "add body")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
}
func TestCollectIgnoresUntouchedHistoricalPrivateKey(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
writeFile(t, filepath.Join(repo, "docs", "public.md"), "public docs update\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "docs update")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" {
t.Fatalf("collector reported untouched historical private key: %#v", got)
}
}
}
func TestCollectIgnoresDeletedPrivateKeyLine(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "remove body")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" {
t.Fatalf("collector reported delete-only private key cleanup: %#v", got)
}
}
}
func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
runGit(t, repo, "add", "README.md")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "collect_test.go"), "SECRET_TOKEN=fixture\n")
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")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "add scanner fixtures")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
var foundOrdinaryTestLeak bool
for _, item := range got {
switch item.File {
case "internal/qualitygate/publiccontent/collect_test.go",
"internal/qualitygate/publiccontent/scan.go",
"internal/qualitygate/publiccontent/scan_test.go",
"internal/qualitygate/publiccontent/rules.go":
t.Fatalf("collector scanned known fixture or detector implementation file: %#v", got)
}
if item.File == "tests/e2e/new-public-workflow.test.sh" && item.Rule == "public_content_generic_credential" {
foundOrdinaryTestLeak = true
}
}
if !foundOrdinaryTestLeak {
t.Fatalf("collector should still scan ordinary test files for real leaks: %#v", got)
}
}
func TestScanChangedFileDocumentsFixtureExclusions(t *testing.T) {
excluded := []string{
"internal/qualitygate/publiccontent/collect_test.go",
"internal/qualitygate/publiccontent/rules.go",
"internal/qualitygate/publiccontent/scan.go",
"internal/qualitygate/publiccontent/scan_test.go",
}
for _, file := range excluded {
if scanChangedFile(file) {
t.Fatalf("scanChangedFile(%q) = true, want false for detector fixture/implementation path", file)
}
}
included := []string{
"internal/qualitygate/publiccontent/new_test.go",
"tests/e2e/new-public-workflow.test.sh",
"docs/public.md",
}
for _, file := range included {
if !scanChangedFile(file) {
t.Fatalf("scanChangedFile(%q) = false, want true", file)
}
}
}
func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "old.md"), "base\n")
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")
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")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "add special paths")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/has space.md", "public_content_generic_credential")
requireFinding(t, got, `weird"quote.md`, "public_content_generic_credential")
requireFinding(t, got, "docs/new name.md", "public_content_generic_credential")
}
func TestCollectScansBranchNameAsWarning(t *testing.T) {
repo := t.TempDir()
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{"branch":"bot/public-doc-update"}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
if len(got) != 1 || got[0].Rule != "public_content_automation_branch" {
t.Fatalf("branch findings = %#v", got)
}
}
func TestCollectUsesExplicitBranchNameWhenDetached(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
runGit(t, repo, "add", "README.md")
runGit(t, repo, "commit", "-m", "base")
runGit(t, repo, "checkout", "-b", "bot/public-doc-update")
writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n")
runGit(t, repo, "add", "docs.md")
runGit(t, repo, "commit", "-m", "docs")
head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD")))
runGit(t, repo, "checkout", "--detach", head)
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
MetadataPath: metadataPath,
BranchName: "bot/public-doc-update",
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
requireFinding(t, got, "branch", "public_content_automation_branch")
}
func TestCollectUsesBranchEnvironmentWhenDetached(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
runGit(t, repo, "add", "README.md")
runGit(t, repo, "commit", "-m", "base")
runGit(t, repo, "checkout", "-b", "bot/public-env-update")
writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n")
runGit(t, repo, "add", "docs.md")
runGit(t, repo, "commit", "-m", "docs")
head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD")))
runGit(t, repo, "checkout", "--detach", head)
t.Setenv("GITHUB_HEAD_REF", "bot/public-env-update")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
requireFinding(t, got, "branch", "public_content_automation_branch")
}
func TestCollectPreservesFindingAttributionForChangedLines(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\n")
runGit(t, repo, "add", "docs/auth.md")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\nAuthorization: Bearer abcdefghijklmnopqrstuvwxyz\n")
runGit(t, repo, "add", "docs/auth.md")
runGit(t, repo, "commit", "-m", "add auth docs")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.Rule == "public_content_bearer_header" {
if item.File != "docs/auth.md" || item.Line != 2 || item.Source != "file" {
t.Fatalf("changed-line attribution = %#v", item)
}
return
}
}
t.Fatalf("missing bearer finding: %#v", got)
}
func TestAppendUniqueFindingsDeduplicatesByRuleFileLineAndSource(t *testing.T) {
base := []Finding{newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block")}
got := appendUniqueFindings(base,
newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block"),
newFinding("public_content_private_key_block", "docs/key.pem", 2, "file", "private key block"),
)
if len(got) != 2 {
t.Fatalf("appendUniqueFindings len = %d, want 2: %#v", len(got), got)
}
}
func newGitRepo(t *testing.T) string {
t.Helper()
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
return repo
}
func privateKeyBegin() string {
return privateKeyBeginPrefix + privateKeyMarker + "\n"
}
func privateKeyEnd() string {
return privateKeyEndPrefix + privateKeyMarker + "\n"
}
func collectFromPreviousCommit(t *testing.T, repo string) []Finding {
t.Helper()
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
return got
}
func requireFinding(t *testing.T, got []Finding, file, rule string) {
t.Helper()
for _, item := range got {
if item.File == file && item.Rule == rule {
return
}
}
t.Fatalf("missing %s in %s findings: %#v", rule, file, got)
}
func TestCollectRequiresValidMetadataJSON(t *testing.T) {
repo := t.TempDir()
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{"title":`)
_, err := Collect(context.Background(), Options{Repo: repo, MetadataPath: metadataPath})
if err == nil || !strings.Contains(err.Error(), "public content metadata") {
t.Fatalf("Collect() error = %v, want metadata parse error", err)
}
}
func runGit(t *testing.T, repo string, args ...string) {
t.Helper()
if len(args) > 0 && args[0] == "commit" {
args = append([]string{"commit", "--no-verify"}, args[1:]...)
}
cmd := exec.Command("git", args...)
cmd.Dir = repo
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
}
}
func runGitOutput(t *testing.T, repo string, args ...string) []byte {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = repo
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
}
return out
}
func writeFile(t *testing.T, path, data string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
func ScanComment(kind, body string) []Finding {
if kind == "" {
kind = "comment"
}
return scanText(kind, "comment", body, false)
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import "testing"
func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
rules := findingRules(got)
if !rules["public_content_harness_metadata"] || !rules["public_content_ccm_harness_trailer"] {
t.Fatalf("comment audit findings = %#v", got)
}
for _, item := range got {
if item.File != "issue_comment" {
t.Fatalf("comment finding file = %q, want issue_comment", item.File)
}
}
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/vfs"
)
func LoadMetadata(path string) (Metadata, error) {
if path == "" {
return Metadata{}, nil
}
data, err := vfs.ReadFile(path)
if err != nil {
return Metadata{}, fmt.Errorf("public content metadata: %w", err)
}
if len(data) == 0 {
return Metadata{}, nil
}
var out Metadata
if err := json.Unmarshal(data, &out); err != nil {
return Metadata{}, fmt.Errorf("public content metadata: %w", err)
}
return out, nil
}
func scanMetadata(m Metadata) []Finding {
text := ""
if m.Title != "" {
text += "title: " + m.Title + "\n"
}
if m.Body != "" {
text += "body:\n" + m.Body + "\n"
}
if text == "" {
return nil
}
out := scanText("pull_request_metadata", "metadata", text, false)
out = append(out, semanticCandidate("pull_request_metadata", "metadata", text, 1)...)
return out
}

View File

@@ -0,0 +1,22 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"path/filepath"
"testing"
)
func TestLoadMetadataReadsTitleAndBody(t *testing.T) {
path := filepath.Join(t.TempDir(), "metadata.json")
writeFile(t, path, `{"title":"public change","body":"pass`+`word = \"example-password\""}`)
got, err := LoadMetadata(path)
if err != nil {
t.Fatalf("LoadMetadata() error = %v", err)
}
if got.Title != "public change" || got.Body == "" {
t.Fatalf("metadata = %#v", got)
}
}

View File

@@ -0,0 +1,478 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"net/url"
"path/filepath"
"regexp"
"strings"
"github.com/larksuite/cli/internal/qualitygate/report"
)
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,}\]]+))`)
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,}`)
semanticBearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+[^"'\s,}\]]+|["']Authorization["']\s*:\s*["']Bearer\s+[^"'\\\s,}\]]+)`)
changeIDTrailerRE = regexp.MustCompile(`(?i)^\s*Change-Id:\s*\S+`)
reviewedOnTrailerRE = regexp.MustCompile(`(?i)^\s*Reviewed-on:\s*\S+`)
ccmHarnessTrailerRE = regexp.MustCompile(`(?i)\bCCM-Harness:\s*\S+`)
privateIPv4RE = regexp.MustCompile(`\b(?:10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|172\.(?:1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3})\b`)
automationBranchRE = regexp.MustCompile(`(?i)(^|/)(bot|automation)[-/]`)
)
func actionForRule(rule string) report.Action {
switch rule {
case "public_content_generic_credential",
"public_content_private_key_block",
"public_content_jwt_like_token",
"public_content_bearer_header",
"public_content_credential_url",
"public_content_change_id_trailer",
"public_content_reviewed_on_trailer",
"public_content_provenance_marker",
"public_content_detector_fingerprint",
"public_content_harness_metadata",
"public_content_ccm_harness_trailer":
return report.ActionReject
case "public_content_private_ipv4",
"public_content_automation_branch":
return report.ActionWarning
default:
return report.ActionWarning
}
}
func isPlaceholderValue(value string) bool {
trimmed := strings.Trim(value, `"'`)
normalized := strings.ToLower(trimmed)
if normalized == "" ||
normalized == "=" ||
percentWrappedPlaceholder(normalized) ||
angleWrappedPlaceholder(normalized) ||
urlWithAnglePlaceholder(normalized) ||
isCredentialReferenceValue(trimmed) {
return true
}
return namedPlaceholderValue(normalized)
}
func namedPlaceholderValue(value string) bool {
switch value {
case "...", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret":
return true
}
return strings.Contains(value, "cli_example") ||
allXPlaceholder(value) ||
conventionalNamedPlaceholderValue(value)
}
func allXPlaceholder(value string) bool {
if len(value) < 4 {
return false
}
for _, r := range value {
if r != 'x' {
return false
}
}
return true
}
func conventionalNamedPlaceholderValue(value string) bool {
if !delimitedPlaceholderIdentifier(value) {
return false
}
normalized := strings.ReplaceAll(value, "-", "_")
if rest, ok := strings.CutPrefix(normalized, "your_"); ok {
return conventionalCredentialPlaceholderName(rest)
}
if rest, ok := strings.CutSuffix(normalized, "_here"); ok {
return conventionalCredentialPlaceholderName(rest)
}
return false
}
func conventionalCredentialPlaceholderName(value string) bool {
switch value {
case "api_key",
"access_key",
"private_key",
"secret",
"password",
"passwd",
"token",
"webhook",
"access_token",
"refresh_token",
"bearer_token",
"session_token",
"client_secret":
return true
default:
return false
}
}
func urlWithAnglePlaceholder(value string) bool {
if !strings.Contains(value, "://") ||
!strings.Contains(value, "<") ||
!strings.Contains(value, ">") {
return false
}
return !urlRemainderLooksCredentialLike(removeAnglePlaceholders(value))
}
func removeAnglePlaceholders(value string) string {
var out strings.Builder
for len(value) > 0 {
start := strings.Index(value, "<")
if start < 0 {
out.WriteString(value)
break
}
out.WriteString(value[:start])
end := strings.Index(value[start+1:], ">")
if end < 0 {
out.WriteString(value[start:])
break
}
value = value[start+end+2:]
}
return out.String()
}
func urlRemainderLooksCredentialLike(value string) bool {
normalized := strings.ToLower(value)
for _, marker := range []string{
"secret",
"token",
"password",
"passwd",
"api_key",
"apikey",
"private_key",
"privatekey",
"client_secret",
"clientsecret",
} {
if strings.Contains(normalized, marker) {
return true
}
}
for _, part := range strings.FieldsFunc(normalized, func(r rune) bool {
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-')
}) {
if credentialShapedIdentifier(part) || longCredentialSegment(part) {
return true
}
}
return false
}
func longCredentialSegment(value string) bool {
if len(value) < 16 {
return false
}
var hasLetter, hasDigit bool
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
hasLetter = true
case r >= '0' && r <= '9':
hasDigit = true
case r == '_' || r == '-':
default:
return false
}
}
return hasLetter || hasDigit
}
func isCredentialReferenceValue(value string) bool {
normalized := strings.ToLower(value)
switch {
case strings.HasPrefix(normalized, "${{"):
return githubExpressionReference(normalized)
case strings.HasPrefix(normalized, "$("):
return !commandSubstitutionLooksCredentialLike(normalized)
case strings.HasPrefix(normalized, "process.env."):
return credentialReferenceIdentifier(strings.TrimPrefix(normalized, "process.env."))
case strings.HasPrefix(normalized, "${"):
return credentialReferenceIdentifier(strings.TrimSuffix(strings.TrimPrefix(normalized, "${"), "}"))
case strings.HasPrefix(value, "$"):
return credentialReferenceIdentifier(strings.TrimPrefix(normalized, "$"))
default:
return false
}
}
func commandSubstitutionLooksCredentialLike(value string) bool {
if !strings.HasPrefix(value, "$(") || !strings.HasSuffix(value, ")") {
return false
}
inner := strings.TrimSuffix(strings.TrimPrefix(value, "$("), ")")
for _, part := range strings.FieldsFunc(inner, func(r rune) bool {
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-')
}) {
if credentialShapedIdentifier(part) || longCredentialSegment(part) {
return true
}
}
return false
}
func githubExpressionReference(value string) bool {
if !strings.HasPrefix(value, "${{") || !strings.HasSuffix(value, "}}") {
return false
}
expr := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
switch {
case strings.HasPrefix(expr, "secrets."):
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "secrets."))
case strings.HasPrefix(expr, "env."):
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "env."))
case strings.HasPrefix(expr, "vars."):
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "vars."))
case expr == "github.token":
return true
default:
return false
}
}
func dottedReferenceIdentifier(value string) bool {
if value == "" {
return false
}
for _, part := range strings.Split(value, ".") {
if !referenceIdentifier(part) {
return false
}
}
return true
}
func credentialReferenceIdentifier(value string) bool {
return referenceIdentifier(value) && !credentialShapedIdentifier(value)
}
func referenceIdentifier(value string) bool {
if value == "" {
return false
}
for i, r := range value {
switch {
case r >= 'a' && r <= 'z':
case r >= '0' && r <= '9' && i > 0:
case r == '_' && i > 0:
default:
return false
}
}
return true
}
func angleWrappedPlaceholder(value string) bool {
if len(value) < 3 || !strings.HasPrefix(value, "<") || !strings.HasSuffix(value, ">") {
return false
}
return anglePlaceholderIdentifier(strings.Trim(value, "<>"))
}
func percentWrappedPlaceholder(value string) bool {
if len(value) < 3 || !strings.HasPrefix(value, "%") || !strings.HasSuffix(value, "%") {
return false
}
inner := strings.Trim(value, "%")
return delimitedPlaceholderIdentifier(inner) && !credentialShapedIdentifier(inner)
}
func delimitedPlaceholderIdentifier(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
continue
}
return false
}
return true
}
func anglePlaceholderIdentifier(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
continue
}
return false
}
if credentialShapedIdentifier(value) {
return false
}
switch value {
case "token",
"id",
"userid",
"openid",
"key",
"secret",
"password",
"api-key",
"user-id",
"open-id",
"client-secret",
"access-token",
"refresh-token",
"auth-token",
"bearer-token",
"session-token",
"service-token":
return true
}
for _, suffix := range []string{"_token", "_id", "_key", "_secret", "_password"} {
if strings.HasSuffix(value, suffix) {
return true
}
}
for _, suffix := range []string{"-token", "-id", "-key", "-secret", "-password"} {
if strings.HasSuffix(value, suffix) {
return true
}
}
return false
}
func credentialShapedValue(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
return credentialShapedIdentifier(normalized)
}
func credentialShapedIdentifier(value string) bool {
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")):
return true
default:
return false
}
}
func resourceTokenPlaceholderValue(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
switch normalized {
case "wiki_token",
"folder_token",
"obj_token",
"spreadsheet_token",
"file_token",
"doc_token",
"node_token",
"parent_node_token",
"origin_node_token",
"drive_route_token":
return true
default:
return minuteTokenFixturePlaceholder(normalized)
}
}
func minuteTokenFixturePlaceholder(value string) bool {
if value == "minute_no_meta" {
return true
}
suffix, ok := strings.CutPrefix(value, "minute_")
if !ok || suffix == "" {
return false
}
for _, r := range suffix {
if r < '0' || r > '9' {
return false
}
}
return true
}
func provenanceMarker(line string) bool {
normalized := strings.ToLower(line)
markers := []string{
"generat" + "ed by tool",
"creat" + "ed by tool",
"generat" + "ed by automation",
"creat" + "ed by automation",
"machine-" + "generated",
"generated with automated",
"generated with automation",
"🤖 generated",
}
for _, marker := range markers {
if strings.Contains(normalized, marker) {
return true
}
}
if strings.HasPrefix(normalized, "co-authored-by:") &&
(strings.Contains(normalized, "<bot@") ||
strings.Contains(normalized, " bot@") ||
strings.Contains(normalized, "[bot]") ||
strings.Contains(normalized, "automation") ||
strings.Contains(normalized, "automated-code-assistant")) {
return true
}
return false
}
// Detector fingerprint checks are intentionally scoped to public rule/config
// files. They do not try to hide this package's implementation; they prevent
// publishing reusable detector identifiers in external-facing rule bundles.
func isDetectorRuleFile(path string) bool {
normalized := filepath.ToSlash(path)
base := filepath.Base(normalized)
return base == ".gitleaks.toml" ||
strings.Contains(normalized, "public-rules/") ||
strings.Contains(normalized, "public_rules/")
}
func detectorFingerprint(line string) bool {
normalized := strings.ToLower(line)
fingerprints := []string{
strings.Join([]string{"public", "content", "leakage"}, "-"),
strings.Join([]string{"public", "content", "detector"}, "-"),
"publiccontent",
}
for _, fingerprint := range fingerprints {
if strings.Contains(normalized, fingerprint) {
return true
}
}
return false
}
func redactCredentialURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.User == nil {
return "<credential-url>"
}
u.User = url.UserPassword("<user>", "<redacted>")
return u.String()
}

View File

@@ -0,0 +1,837 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"encoding/base64"
"encoding/json"
"fmt"
"path/filepath"
"sort"
"strings"
"unicode"
)
const (
privateKeyBeginPrefix = "-----" + "BEGIN "
privateKeyEndPrefix = "-----" + "END "
privateKeyMarker = "PRIVATE " + "KEY-----"
)
func ScanFile(path string, data []byte) []Finding {
return scanText(filepath.ToSlash(path), "file", string(data), isDetectorRuleFile(path))
}
func semanticCandidate(file, source, text string, line int) []Finding {
excerpt := redactedSemanticExcerpt(text)
if excerpt == "" {
return nil
}
return []Finding{newFinding("public_content_semantic_candidate", file, line, source, excerpt)}
}
func scanText(file, source, text string, detectorFile bool) []Finding {
var out []Finding
lines := strings.Split(text, "\n")
inPrivateKey := false
privateKeyLine := 0
for i, line := range lines {
lineNo := i + 1
if strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
inPrivateKey = true
privateKeyLine = lineNo
}
if inPrivateKey && strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
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]) {
continue
}
value := credentialAssignmentValue(match)
keyName, _ := normalizedCredentialAssignmentKey(match[0])
if value == "" ||
isNonSecretLiteralValue(value) ||
isBenignCodeCredentialExpression(file, value) ||
isPlaceholderValue(value) ||
isResourceTokenPlaceholderAssignment(keyName, value) {
continue
}
if looksLikeEqualityComparison(value) {
continue
}
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
}
for _, match := range jwtLikeRE.FindAllString(line, -1) {
if !isJWTToken(match) {
continue
}
out = append(out, newFinding("public_content_jwt_like_token", file, lineNo, source, redactToken(match)))
}
for _, match := range bearerHeaderRE.FindAllString(line, -1) {
if isPlaceholderBearerHeader(match) {
continue
}
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
}
for _, match := range credentialURLRE.FindAllString(line, -1) {
if isPlaceholderCredentialURL(match) {
continue
}
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
}
for _, match := range privateIPv4RE.FindAllString(line, -1) {
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
}
if source == "branch" && automationBranchRE.MatchString(line) {
out = append(out, newFinding("public_content_automation_branch", file, lineNo, source, "automation branch marker"))
}
switch {
case changeIDTrailerRE.MatchString(line):
out = append(out, newFinding("public_content_change_id_trailer", file, lineNo, source, "Change-Id: <redacted>"))
case reviewedOnTrailerRE.MatchString(line):
out = append(out, newFinding("public_content_reviewed_on_trailer", file, lineNo, source, "Reviewed-on: <redacted>"))
case ccmHarnessTrailerRE.MatchString(line):
out = append(out, newFinding("public_content_ccm_harness_trailer", file, lineNo, source, "CCM-Harness: <redacted>"))
}
if provenanceMarker(line) {
out = append(out, newFinding("public_content_provenance_marker", file, lineNo, source, "provenance marker"))
}
if strings.Contains(line, "/tmp/harness-agent") {
out = append(out, newFinding("public_content_harness_metadata", file, lineNo, source, "/tmp/harness-agent"))
}
if detectorFile && detectorFingerprint(line) {
out = append(out, newFinding("public_content_detector_fingerprint", file, lineNo, source, "public detector fingerprint"))
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].File != out[j].File {
return out[i].File < out[j].File
}
if out[i].Line != out[j].Line {
return out[i].Line < out[j].Line
}
return out[i].Rule < out[j].Rule
})
return out
}
func isCredentialAssignmentMatch(match string) bool {
name, value, ok := normalizedCredentialAssignment(match)
if !ok {
return false
}
if isWebhookCredentialKey(name) && webhookAssignmentValueLooksCredentialLike(value) {
return true
}
if isBenignTokenField(name) && !credentialShapedValue(value) {
return false
}
return isExplicitCredentialKey(name)
}
func normalizedCredentialAssignmentKey(match string) (string, bool) {
key, _, ok := normalizedCredentialAssignment(match)
return key, ok
}
func normalizedCredentialAssignment(match string) (string, string, bool) {
key, ok := credentialAssignmentKey(match)
if !ok {
return "", "", false
}
key = strings.TrimSpace(key)
if key == "" {
return "", "", false
}
submatches := credentialAssignmentRE.FindStringSubmatch(match)
return normalizedCredentialKey(strings.Trim(key, `"'`)), credentialAssignmentValue(submatches), true
}
func normalizedCredentialKey(key string) string {
key = strings.TrimSpace(key)
var out []rune
var prev rune
for i, r := range key {
if r == '-' {
r = '_'
}
if i > 0 && isCredentialKeyBoundary(prev, r) {
out = append(out, '_')
}
out = append(out, unicode.ToLower(r))
prev = r
}
key = string(out)
key = strings.ReplaceAll(key, "-", "_")
return key
}
func isCredentialKeyBoundary(prev, current rune) bool {
if prev == '_' || current == '_' {
return false
}
return (unicode.IsLower(prev) || unicode.IsDigit(prev)) && unicode.IsUpper(current)
}
func isBenignTokenField(key string) bool {
if isTokenMetricField(key) ||
isTokenMetadataField(key) ||
isResourceTokenField(key) ||
isPaginationOrSyncTokenField(key) {
return true
}
return false
}
func isTokenMetricField(key string) bool {
switch key {
case "tokenizer",
"token_count",
"tokens",
"max_tokens",
"completion_tokens",
"prompt_tokens":
return true
default:
return false
}
}
func isTokenMetadataField(key string) bool {
switch key {
case "access_token_expires_in",
"refresh_token_expires_in",
"token_expires_in",
"token_status",
"token_type",
"token_url",
"token_endpoint",
"token_format",
"secret_name":
return true
default:
return false
}
}
func isPaginationOrSyncTokenField(key string) bool {
switch key {
case "page_token",
"next_page_token",
"sync_token":
return true
default:
return false
}
}
func isResourceTokenField(key string) bool {
if !strings.HasSuffix(key, "_token") {
return false
}
prefix := strings.TrimSuffix(key, "_token")
switch prefix {
case "app",
"base",
"board",
"doc",
"drive_route",
"file",
"folder",
"host_node",
"minute",
"node",
"obj",
"origin_node",
"parent",
"parent_file",
"parent_node",
"share",
"spreadsheet",
"target",
"wiki":
return true
default:
return false
}
}
func isResourceTokenPlaceholderAssignment(key, value string) bool {
switch {
case key == "client_token" && idempotencyTokenPlaceholderValue(value):
return true
case key == "retry_without_token" && numericStringPlaceholderValue(value):
return true
case tokenLikePlaceholderKey(key):
return tokenLikePlaceholderValue(value)
default:
return false
}
}
func tokenLikePlaceholderKey(key string) bool {
return key == "token" ||
strings.HasSuffix(key, "_token") ||
strings.HasSuffix(key, "-token")
}
func tokenLikePlaceholderValue(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
if normalized == "" || credentialShapedIdentifier(normalized) {
return false
}
return resourceTokenPlaceholderValue(value) ||
isPlaceholderValue(value) ||
normalized == "token" ||
strings.Contains(normalized, "...") ||
strings.Contains(normalized, "xxx") ||
strings.Contains(normalized, "_or_") ||
strings.HasSuffix(normalized, "_token") ||
strings.HasPrefix(normalized, ".")
}
func idempotencyTokenPlaceholderValue(value string) bool {
return numericStringPlaceholderValue(value) || uuidStringPlaceholderValue(value)
}
func uuidStringPlaceholderValue(value string) bool {
normalized := strings.Trim(value, `"'`)
parts := strings.Split(normalized, "-")
if len(parts) != 5 {
return false
}
for i, part := range parts {
want := []int{8, 4, 4, 4, 12}[i]
if len(part) != want {
return false
}
for _, r := range part {
if (r >= '0' && r <= '9') ||
(r >= 'a' && r <= 'f') ||
(r >= 'A' && r <= 'F') {
continue
}
return false
}
}
return true
}
func numericStringPlaceholderValue(value string) bool {
normalized := strings.Trim(value, `"'`)
if normalized == "" {
return false
}
for _, r := range normalized {
if r < '0' || r > '9' {
return false
}
}
return true
}
func isBenignCodeCredentialExpression(file, value string) bool {
normalized := strings.TrimSpace(value)
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
return true
}
if !sourceCodeFile(file) || quotedLiteral(value) || credentialShapedValue(value) {
return false
}
return codeReferenceExpression(normalized)
}
func sourceCodeFile(file string) bool {
switch filepath.Ext(file) {
case ".go", ".py":
return true
default:
return false
}
}
func quotedLiteral(value string) bool {
normalized := strings.TrimSpace(value)
return len(normalized) >= 2 &&
((strings.HasPrefix(normalized, `"`) && strings.HasSuffix(normalized, `"`)) ||
(strings.HasPrefix(normalized, `'`) && strings.HasSuffix(normalized, `'`)))
}
func codeReferenceExpression(value string) bool {
if value == "" {
return false
}
for _, marker := range []string{".", "(", ")", "[", "]", "{"} {
if strings.Contains(value, marker) {
return true
}
}
return codeIdentifier(value) && !credentialNameFragment(value)
}
func codeIdentifier(value string) bool {
for i, r := range value {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r == '_' && i > 0:
case r >= '0' && r <= '9' && i > 0:
default:
return false
}
}
return true
}
func credentialNameFragment(value string) bool {
normalized := strings.ToLower(value)
for _, marker := range []string{"secret", "token", "password", "passwd", "key"} {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}
func isNonSecretLiteralValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
case "true", "false", "null", "nil", "{", "[":
return true
default:
return false
}
}
func isJWTToken(value string) bool {
parts := strings.Split(value, ".")
if len(parts) != 3 {
return false
}
header, err := decodeBase64URLSegment(parts[0])
if err != nil || !json.Valid(header) {
return false
}
var fields map[string]interface{}
if err := json.Unmarshal(header, &fields); err != nil {
return false
}
alg, ok := fields["alg"].(string)
return ok && alg != ""
}
func decodeBase64URLSegment(value string) ([]byte, error) {
if decoded, err := base64.RawURLEncoding.DecodeString(value); err == nil {
return decoded, nil
}
return base64.URLEncoding.DecodeString(value)
}
func isPlaceholderBearerHeader(match string) bool {
normalized := strings.ToLower(match)
idx := strings.LastIndex(normalized, "bearer ")
if idx < 0 {
return false
}
value := strings.TrimSpace(match[idx+len("bearer "):])
return isPlaceholderValue(value)
}
func isWebhookCredentialKey(key string) bool {
return strings.Contains(strings.ReplaceAll(key, "_", ""), "webhook")
}
func webhookAssignmentValueLooksCredentialLike(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
if normalized == "" || isPlaceholderValue(normalized) || isNonSecretLiteralValue(normalized) {
return false
}
return urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)) ||
credentialShapedIdentifier(strings.Trim(normalized, "$"))
}
func isExplicitCredentialKey(key string) bool {
compact := strings.ReplaceAll(key, "_", "")
switch compact {
case "token",
"accesstoken",
"refreshtoken",
"authtoken",
"bearertoken",
"sessiontoken",
"servicetoken",
"apikey",
"accesskey",
"privatekey",
"apisecret",
"secret",
"secretkey",
"clientsecret",
"password",
"passwd":
return true
}
for _, phrase := range []string{
"accesstoken",
"refreshtoken",
"authtoken",
"bearertoken",
"sessiontoken",
"servicetoken",
"bottoken",
"apikey",
"accesskey",
"privatekey",
"apisecret",
"clientsecret",
"secretkey",
} {
if strings.Contains(compact, phrase) {
return true
}
}
parts := credentialKeyParts(key)
for _, phrase := range [][2]string{
{"access", "token"},
{"refresh", "token"},
{"auth", "token"},
{"bearer", "token"},
{"session", "token"},
{"service", "token"},
{"bot", "token"},
{"api", "key"},
{"access", "key"},
{"private", "key"},
{"api", "secret"},
{"client", "secret"},
{"secret", "key"},
} {
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
return true
}
}
for _, part := range parts {
switch part {
case "token", "secret", "password", "passwd":
return true
}
}
for _, suffix := range []string{
"token",
"accesstoken",
"refreshtoken",
"authtoken",
"bearertoken",
"sessiontoken",
"servicetoken",
"bottoken",
"apikey",
"accesskey",
"privatekey",
"apisecret",
"clientsecret",
"secret",
"secretkey",
"password",
"passwd",
} {
if strings.HasSuffix(compact, suffix) {
return true
}
}
for _, suffix := range []string{
"_access_token",
"_refresh_token",
"_auth_token",
"_bearer_token",
"_session_token",
"_service_token",
"_api_key",
"_access_key",
"_private_key",
"_api_secret",
"_client_secret",
"_secret",
"_secret_key",
"_password",
"_passwd",
} {
if strings.HasSuffix(key, suffix) {
return true
}
}
return false
}
func credentialKeyParts(key string) []string {
var parts []string
for _, part := range strings.Split(key, "_") {
if part != "" {
parts = append(parts, part)
}
}
return parts
}
func hasAdjacentCredentialParts(parts []string, first, second string) bool {
for i := 0; i+1 < len(parts); i++ {
if parts[i] == first && parts[i+1] == second {
return true
}
}
return false
}
func credentialAssignmentValue(match []string) string {
for _, value := range match[1:] {
if value != "" {
return value
}
}
return ""
}
func looksLikeEqualityComparison(value string) bool {
return strings.HasPrefix(strings.TrimSpace(value), "=")
}
func isPlaceholderCredentialURL(raw string) bool {
userInfo, ok := credentialURLUserInfo(raw)
if !ok {
return false
}
_, password, ok := strings.Cut(userInfo, ":")
if !ok {
return false
}
return credentialURLPasswordPlaceholder(password)
}
func credentialURLPasswordPlaceholder(password string) bool {
normalized := strings.ToLower(password)
decoded := strings.ReplaceAll(normalized, "%3c", "<")
decoded = strings.ReplaceAll(decoded, "%3e", ">")
switch decoded {
case "placeholder", "redacted", "<redacted>", "xxxx":
return true
}
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
}
func credentialURLUserInfo(raw string) (string, bool) {
schemeIdx := strings.Index(raw, "://")
if schemeIdx < 0 {
return "", false
}
rest := raw[schemeIdx+len("://"):]
atIdx := strings.Index(rest, "@")
if atIdx < 0 {
return "", false
}
return rest[:atIdx], true
}
func newFinding(rule, file string, line int, source, excerpt string) Finding {
return Finding{
Rule: rule,
Action: actionForRule(rule),
File: file,
Line: line,
Source: source,
Excerpt: excerpt,
Message: messageForRule(rule),
Suggestion: suggestionForRule(rule),
}
}
func messageForRule(rule string) string {
switch rule {
case "public_content_generic_credential":
return "public contribution contains a generic credential assignment"
case "public_content_private_key_block":
return "public contribution contains a private key block"
case "public_content_jwt_like_token":
return "public contribution contains a JWT-like token"
case "public_content_bearer_header":
return "public contribution contains an Authorization bearer token"
case "public_content_credential_url":
return "public contribution contains credentials embedded in a URL"
case "public_content_private_ipv4":
return "public contribution contains a private-network IP address"
case "public_content_automation_branch":
return "public contribution uses an automation-shaped branch name"
case "public_content_change_id_trailer":
return "public contribution contains a Change-Id trailer"
case "public_content_reviewed_on_trailer":
return "public contribution contains a Reviewed-on trailer"
case "public_content_provenance_marker":
return "public contribution contains a prohibited provenance marker"
case "public_content_detector_fingerprint":
return "public rule/config content exposes public detector fingerprints"
case "public_content_harness_metadata":
return "public contribution contains visible harness pipeline metadata"
case "public_content_ccm_harness_trailer":
return "public contribution contains a CCM-Harness trailer"
case "public_content_semantic_candidate":
return "public contribution contains text for semantic public content review"
default:
return "public contribution contains content that should not be published"
}
}
func suggestionForRule(rule string) string {
switch actionForRule(rule) {
case "REJECT":
return "remove the value from the public contribution and replace it with a non-sensitive placeholder"
default:
return "remove private workflow metadata before publishing the public contribution"
}
}
func redactAssignment(match string) string {
key, ok := credentialAssignmentKey(match)
if !ok {
return "<credential-assignment>"
}
return fmt.Sprintf("%s= <redacted>", strings.TrimSpace(key))
}
func credentialAssignmentKey(match string) (string, bool) {
idx := -1
for _, sep := range []string{":", "="} {
if candidate := strings.Index(match, sep); candidate >= 0 && (idx < 0 || candidate < idx) {
idx = candidate
}
}
if idx < 0 {
return "", false
}
return match[:idx], true
}
func redactToken(_ string) string {
return "<jwt-like-token>"
}
func redactedSemanticExcerpt(text string) string {
normalized := strings.Join(strings.Fields(text), " ")
if normalized == "" {
return ""
}
signals := semanticSignals(normalized)
if len(signals) == 0 {
return ""
}
sanitized := truncateRunes(sanitizeSemanticExcerpt(text), 600)
return fmt.Sprintf("semantic signals: %s; excerpt: %q", strings.Join(signals, ","), sanitized)
}
func semanticSignals(normalized string) []string {
lower := strings.ToLower(normalized)
var signals []string
add := func(signal string) {
for _, existing := range signals {
if existing == signal {
return
}
}
signals = append(signals, signal)
}
hasPrivateScope := strings.Contains(lower, "private") || strings.Contains(lower, "internal-only")
hasRequestMetadata := strings.Contains(lower, "request header") || strings.Contains(lower, "request headers") || strings.Contains(lower, "authorization header") || strings.Contains(lower, "metadata header")
hasTrustBoundary := strings.Contains(lower, "spoof") || strings.Contains(lower, "trust") || strings.Contains(lower, "risk scoring") || strings.Contains(lower, "classification")
hasRoadmap := strings.Contains(lower, "roadmap") || strings.Contains(lower, "migration") || strings.Contains(lower, "rollout") || strings.Contains(lower, "cutover") || strings.Contains(lower, "unpublished")
hasTiming := strings.Contains(lower, "target date") || strings.Contains(lower, "friday") || strings.Contains(lower, "monday") || strings.Contains(lower, "tuesday") || strings.Contains(lower, "wednesday") || strings.Contains(lower, "thursday") || strings.Contains(lower, "customer-visible")
hasImplementation := strings.Contains(lower, "server-side") || strings.Contains(lower, "implementation")
if hasPrivateScope && hasRequestMetadata && hasTrustBoundary {
add("private_scope")
add("request_metadata")
add("trust_boundary_detail")
}
if hasRoadmap && (hasPrivateScope || hasTiming) {
add("roadmap_detail")
if hasPrivateScope {
add("private_scope")
}
if hasTiming {
add("roadmap_timing")
}
}
if hasPrivateScope && hasImplementation && hasTrustBoundary {
add("private_scope")
add("implementation_detail")
add("trust_boundary_detail")
}
return signals
}
func sanitizeSemanticExcerpt(text string) string {
text = redactPrivateKeyBlocks(text)
text = credentialAssignmentRE.ReplaceAllStringFunc(text, sanitizeCredentialAssignment)
text = strings.ReplaceAll(text, `<redacted>"`, `<redacted>`)
text = strings.ReplaceAll(text, `<redacted>'`, `<redacted>`)
text = semanticBearerHeaderRE.ReplaceAllString(text, "Authorization: Bearer <redacted>")
text = jwtLikeRE.ReplaceAllStringFunc(text, func(match string) string {
if isJWTToken(match) {
return "<jwt-like-token>"
}
return match
})
text = credentialURLRE.ReplaceAllStringFunc(text, sanitizeCredentialURL)
return strings.Join(strings.Fields(text), " ")
}
func redactPrivateKeyBlocks(text string) string {
lines := strings.Split(text, "\n")
var out []string
inPrivateKey := false
for _, line := range lines {
if strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
out = append(out, "<private-key-block>")
inPrivateKey = true
if strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
inPrivateKey = false
}
continue
}
if inPrivateKey {
if strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
inPrivateKey = false
}
continue
}
out = append(out, line)
}
return strings.Join(out, "\n")
}
func sanitizeCredentialAssignment(match string) string {
key, ok := credentialAssignmentKey(match)
if !ok {
return "<credential-assignment>"
}
return strings.TrimSpace(key) + "=<redacted>"
}
func sanitizeCredentialURL(raw string) string {
redacted := redactCredentialURL(raw)
redacted = strings.ReplaceAll(redacted, "%3Cuser%3E", "<user>")
redacted = strings.ReplaceAll(redacted, "%3Credacted%3E", "<redacted>")
return redacted
}
func truncateRunes(text string, limit int) string {
if limit <= 0 {
return ""
}
runes := []rune(text)
if len(runes) <= limit {
return text
}
return string(runes[:limit]) + "..."
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import "github.com/larksuite/cli/internal/qualitygate/report"
type Options struct {
Repo string
ChangedFrom string
MetadataPath string
BranchName string
}
type Metadata struct {
Title string `json:"title"`
Body string `json:"body"`
Branch string `json:"branch"`
}
type Finding struct {
Rule string
Action report.Action
File string
Line int
Source string
Excerpt string
Message string
Suggestion string
}

View File

@@ -174,8 +174,9 @@ type materializedExample struct {
}
type placeholderContext struct {
FlagName string
FlagUsage string
FlagName string
FlagUsage string
FlagDefault string
}
func materializePlaceholderExample(raw string, cmd manifest.Command) (materializedExample, bool) {
@@ -247,6 +248,7 @@ func placeholderContextForFlag(name string, flag *manifest.Flag) placeholderCont
ctx := placeholderContext{FlagName: name}
if flag != nil {
ctx.FlagUsage = flag.Usage
ctx.FlagDefault = flag.DefValue
}
return ctx
}
@@ -309,11 +311,17 @@ func fakeValueForPlaceholder(raw string, ctx placeholderContext) (string, bool)
if name == "" {
return "", false
}
if value, ok := fakeNumericValueForPlaceholder(name, ctx); ok {
return value, true
}
if value, ok := fakeContextualURLValueForPlaceholder(name, ctx); ok {
return value, true
}
if value, ok := fakeValueFromPlaceholderName(name); ok {
return value, true
}
if isGenericPlaceholderName(name) {
return fakeValueFromUsageHint(ctx.FlagUsage)
return fakeValueFromContextHint(ctx)
}
return "", false
}
@@ -336,16 +344,26 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
return "file_test123", true
case hasPlaceholderToken(tokens, "file") && hasPlaceholderToken(tokens, "token"):
return "file_test123", true
case hasPlaceholderToken(tokens, "folder") && hasPlaceholderToken(tokens, "token"):
return "fld_test123", true
case hasPlaceholderToken(tokens, "image", "img"):
return "img_test123", true
case hasPlaceholderToken(tokens, "app"):
return "app_test123", true
case hasPlaceholderToken(tokens, "draft"):
return "draft_test123", true
case hasPlaceholderToken(tokens, "label"):
return "label_test123", true
case hasPlaceholderToken(tokens, "share"):
return "share_test123", true
case hasPlaceholderToken(tokens, "doc", "document"):
return "doc_test123", true
case hasPlaceholderToken(tokens, "sheet", "spreadsheet"):
return "shtcn_test123", true
case hasPlaceholderToken(tokens, "base"):
return "base_test123", true
case hasPlaceholderToken(tokens, "space"):
return "space_test123", true
case hasPlaceholderToken(tokens, "table"):
return "tbl_test123", true
case hasPlaceholderToken(tokens, "view"):
@@ -377,17 +395,98 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
}
}
func fakeValueFromUsageHint(usage string) (string, bool) {
match := placeholderValuePattern.FindStringSubmatch(strings.ToLower(usage))
func fakeValueFromContextHint(ctx placeholderContext) (string, bool) {
if value, ok := fakeNumericValueForPlaceholder("", ctx); ok {
return value, true
}
if value, ok := fakeContextualURLValueForPlaceholder("", ctx); ok {
return value, true
}
match := placeholderValuePattern.FindStringSubmatch(strings.ToLower(ctx.FlagUsage))
if len(match) != 2 || !knownTokenPrefix(match[1]) {
return "", false
}
return match[1] + "_test123", true
}
func fakeContextualURLValueForPlaceholder(name string, ctx placeholderContext) (string, bool) {
nameTokens := placeholderTokenSet(name)
flagName := strings.ReplaceAll(strings.ToLower(ctx.FlagName), "-", "_")
flagTokens := placeholderTokenSet(flagName)
if !hasPlaceholderToken(nameTokens, "url", "link") && !hasPlaceholderToken(flagTokens, "url", "link") {
return "", false
}
usage := strings.ToLower(ctx.FlagUsage)
if strings.Contains(usage, "lark") || strings.Contains(usage, "feishu") || strings.Contains(usage, "document url") {
return "https://example.feishu.cn/docx/doc_test123", true
}
return "", false
}
func fakeNumericValueForPlaceholder(name string, ctx placeholderContext) (string, bool) {
nameTokens := placeholderTokenSet(name)
flagName := strings.ReplaceAll(strings.ToLower(ctx.FlagName), "-", "_")
flagTokens := placeholderTokenSet(flagName)
usage := strings.ToLower(ctx.FlagUsage)
switch {
case placeholderTokenPair(nameTokens, "meeting", "id") || placeholderTokenPair(flagTokens, "meeting", "id"):
return "400000000001", true
case placeholderTokenPair(nameTokens, "meeting", "ids") || placeholderTokenPair(flagTokens, "meeting", "ids"):
return "400000000001", true
case placeholderTokenPair(nameTokens, "meeting", "no") || placeholderTokenPair(flagTokens, "meeting", "no"):
return "123456789", true
case placeholderTokenPair(nameTokens, "meeting", "number") || placeholderTokenPair(flagTokens, "meeting", "number"):
return "123456789", true
case hasPlaceholderToken(nameTokens, "timestamp") || hasPlaceholderToken(flagTokens, "timestamp") || strings.Contains(usage, "unix timestamp"):
return defaultPositiveInteger(ctx.FlagDefault, "1893456000"), true
case placeholderTokenPair(nameTokens, "page", "size") || placeholderTokenPair(flagTokens, "page", "size"):
return defaultPositiveInteger(ctx.FlagDefault, "20"), true
case placeholderTokenPair(nameTokens, "page", "limit") || placeholderTokenPair(flagTokens, "page", "limit"):
return defaultPositiveInteger(ctx.FlagDefault, "10"), true
case numericPlaceholderName(nameTokens) || numericPlaceholderName(flagTokens) || numericUsageHint(usage):
return defaultPositiveInteger(ctx.FlagDefault, "20"), true
default:
return "", false
}
}
func numericPlaceholderName(tokens map[string]bool) bool {
if len(tokens) == 0 || hasPlaceholderToken(tokens, "token", "format", "type", "status", "mode") {
return false
}
return hasPlaceholderToken(tokens,
"amount", "count", "depth", "height", "index", "length", "limit", "max",
"number", "revision", "size", "width",
)
}
func numericUsageHint(usage string) bool {
if usage == "" {
return false
}
return strings.Contains(usage, "positive integer") ||
strings.Contains(usage, "decimal integer") ||
strings.Contains(usage, "number of ") ||
strings.Contains(usage, "(number)")
}
func defaultPositiveInteger(raw, fallback string) string {
raw = strings.TrimSpace(raw)
if raw == "" || strings.HasPrefix(raw, "-") || raw == "0" {
return fallback
}
for _, r := range raw {
if r < '0' || r > '9' {
return fallback
}
}
return raw
}
func knownTokenPrefix(prefix string) bool {
switch prefix {
case "app", "base", "doc", "file", "fld", "img", "item", "meeting", "obcn", "oc", "od", "om", "ou", "page", "rec", "shtcn", "task", "tbl", "token", "viw", "wiki":
case "app", "base", "doc", "draft", "file", "fld", "img", "item", "label", "meeting", "obcn", "oc", "od", "om", "ou", "page", "rec", "share", "shtcn", "space", "task", "tbl", "token", "viw", "wiki":
return true
default:
return false
@@ -431,6 +530,10 @@ func hasPlaceholderToken(tokens map[string]bool, wants ...string) bool {
return false
}
func placeholderTokenPair(tokens map[string]bool, first, second string) bool {
return tokens[first] && tokens[second]
}
func hasUnresolvedDryRunPlaceholder(value string) bool {
if skillscan.HasPlaceholder(value) {
return true
@@ -623,6 +726,7 @@ func appendDryRunArg(raw string) ([]string, error) {
return nil, fmt.Errorf("not a lark-cli command")
}
argv = truncateShellTail(argv)
argv = forceDryRunJSONFormat(argv)
hasDryRunArg := false
dryRunEnabled := false
for _, arg := range argv[1:] {
@@ -642,6 +746,23 @@ func appendDryRunArg(raw string) ([]string, error) {
return append(argv[1:], "--dry-run"), nil
}
func forceDryRunJSONFormat(argv []string) []string {
for i := 1; i < len(argv); i++ {
arg := argv[i]
if arg == "--format" {
if i+1 < len(argv) && argv[i+1] == "pretty" {
argv[i+1] = "json"
}
return argv
}
if arg == "--format=pretty" {
argv[i] = "--format=json"
return argv
}
}
return argv
}
func truncateShellTail(argv []string) []string {
for i, arg := range argv {
if i == 0 {

View File

@@ -305,6 +305,161 @@ func TestRunDryRunsMaterializesInlinePlaceholderFlagValues(t *testing.T) {
}
}
func TestRunDryRunsMaterializesNumericPlaceholderFlagValues(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/vc/v1/bots/events","params":{"meeting_id":"400000000001","page_size":50}}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "vc +meeting-events",
Runnable: true,
Flags: []manifest.Flag{
{Name: "meeting-id", TakesValue: true, Usage: "meeting ID to query; must be a long positive integer, not a 9-digit meeting number"},
{Name: "page-size", TakesValue: true, Usage: "page size, 20-100 (default 50)", DefValue: "50"},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: "lark-cli vc +meeting-events --meeting-id <meeting_id> --page-size <page_size>",
SourceFile: "skills/lark-vc-agent/SKILL.md",
Line: 120,
HasPlaceholder: true,
}
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("numeric placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--page-size", "50", "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsMaterializesNumericPlaceholdersInsideJSONFlags(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/test","params":{"timestamp":"1893456000","count":"20"}}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "api GET",
Runnable: true,
Flags: []manifest.Flag{
{Name: "params", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: `lark-cli api GET /open-apis/test --params '{"timestamp":"<timestamp>","count":"<count>"}'`,
SourceFile: "skills/lark-demo/SKILL.md",
Line: 20,
HasPlaceholder: true,
}
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("JSON numeric placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"api", "GET", "/open-apis/test", "--params", `{"timestamp":"1893456000","count":"20"}`, "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsMaterializesLarkDocumentURLPlaceholders(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/drive/v1/metas/batch_query"}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "drive +inspect",
Runnable: true,
Flags: []manifest.Flag{
{Name: "url", TakesValue: true, Usage: "Lark/Feishu document URL (docx, doc, sheet, bitable, wiki, file, folder, mindnote, slides)"},
{Name: "format", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: "lark-cli drive +inspect --url '<url>' --format json",
SourceFile: "skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md",
Line: 15,
HasPlaceholder: true,
}
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("Lark URL placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"drive", "+inspect", "--url", "https://example.feishu.cn/docx/doc_test123", "--format", "json", "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsMaterializesResourceIDPlaceholderFlagValues(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/wiki/v2/spaces/space_test123/nodes"}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "wiki +node-list",
Runnable: true,
Flags: []manifest.Flag{
{Name: "space-id", TakesValue: true, Usage: "wiki space ID"},
{Name: "page-token", TakesValue: true, Usage: "page token"},
{Name: "format", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: "lark-cli wiki +node-list --space-id <space_id> --page-token <PAGE_TOKEN> --format json",
SourceFile: "skills/lark-wiki/references/lark-wiki-node-list.md",
Line: 24,
HasPlaceholder: true,
}
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("resource ID placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"wiki", "+node-list", "--space-id", "space_test123", "--page-token", "page_test123", "--format", "json", "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsMaterializesResourcePlaceholdersInsideJSONFlags(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"POST","url":"/open-apis/mail/v1/user_mailboxes/me/drafts/draft_test123/send"}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "mail user_mailbox.drafts send",
Runnable: true,
Flags: []manifest.Flag{
{Name: "params", TakesValue: true},
{Name: "data", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: `lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}' --data '{"send_time":"<unix_timestamp>"}'`,
SourceFile: "skills/lark-mail/references/lark-mail-send.md",
Line: 172,
HasPlaceholder: true,
}
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("JSON resource placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"mail", "user_mailbox.drafts", "send", "--params", `{"user_mailbox_id":"me","draft_id":"draft_test123"}`, "--data", `{"send_time":"1893456000"}`, "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsSkipsUnknownFlagsBeforeDryRun(t *testing.T) {
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "im +chat-messages-list",
@@ -600,6 +755,51 @@ func TestAppendDryRunArgDoesNotDuplicate(t *testing.T) {
}
}
func TestAppendDryRunArgForcesJSONFormat(t *testing.T) {
got, err := appendDryRunArg("lark-cli vc +meeting-events --meeting-id 400000000001 --format pretty")
if err != nil {
t.Fatalf("appendDryRunArg() error = %v", err)
}
want := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--format", "json", "--dry-run"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, want)
}
}
func TestAppendDryRunArgForcesInlineJSONFormat(t *testing.T) {
got, err := appendDryRunArg("lark-cli vc +meeting-events --meeting-id 400000000001 --format=pretty --dry-run")
if err != nil {
t.Fatalf("appendDryRunArg() error = %v", err)
}
want := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--format=json", "--dry-run"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, want)
}
}
func TestAppendDryRunArgPreservesNonPrettyFormat(t *testing.T) {
for _, raw := range []string{
"lark-cli mail +watch --format data --dry-run",
"lark-cli export +events --format=ndjson --dry-run",
"lark-cli docs +fetch --format table",
} {
got, err := appendDryRunArg(raw)
if err != nil {
t.Fatalf("appendDryRunArg(%q) error = %v", raw, err)
}
for _, arg := range got {
if arg == "--format=json" {
t.Fatalf("appendDryRunArg(%q) unexpectedly rewrote inline format: %#v", raw, got)
}
}
for i, arg := range got {
if arg == "--format" && i+1 < len(got) && got[i+1] == "json" {
t.Fatalf("appendDryRunArg(%q) unexpectedly rewrote split format: %#v", raw, got)
}
}
}
}
func TestAppendDryRunArgForcesDryRunWhenExplicitlyDisabled(t *testing.T) {
got, err := appendDryRunArg("lark-cli docs +fetch --dry-run=false --doc abc")
if err != nil {

View File

@@ -15,18 +15,20 @@ import (
manifestexamples "github.com/larksuite/cli/internal/qualitygate/examples"
"github.com/larksuite/cli/internal/qualitygate/facts"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/qualitygate/skillscan"
"github.com/larksuite/cli/internal/vfs"
)
type Options struct {
Repo string
CLIBin string
ChangedFrom string
FactsOut string
ManifestPath string
CommandIndexPath string
Repo string
CLIBin string
ChangedFrom string
FactsOut string
ManifestPath string
CommandIndexPath string
PublicContentMetadataPath string
}
func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, error) {
@@ -98,9 +100,60 @@ func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, e
if opts.ChangedFrom != "" {
diags = append(diags, errorDiags...)
}
publicContent, err := publiccontent.Collect(ctx, publiccontent.Options{
Repo: opts.Repo,
ChangedFrom: opts.ChangedFrom,
MetadataPath: opts.PublicContentMetadataPath,
})
if err != nil {
return nil, facts.Facts{}, err
}
diags = append(diags, publicContentDiagnostics(publicContent)...)
diags = filterPRDiagnostics(opts.Repo, opts.ChangedFrom, scope, m, diags)
return diags, facts.BuildWithCommandLookup(m, commandIndex, skillFacts, skillQualityFacts, errorFacts, exampleFacts, outputFacts, diags, scope.Files), nil
builtFacts := facts.BuildWithCommandLookup(m, commandIndex, skillFacts, skillQualityFacts, errorFacts, exampleFacts, outputFacts, diags, scope.Files)
return diags, facts.WithPublicContent(builtFacts, publicContentFacts(publicContent)), nil
}
func publicContentDiagnostics(items []publiccontent.Finding) []report.Diagnostic {
if len(items) == 0 {
return nil
}
out := make([]report.Diagnostic, 0, len(items))
for _, item := range items {
if item.Rule == "public_content_semantic_candidate" {
continue
}
out = append(out, report.Diagnostic{
Rule: item.Rule,
Action: item.Action,
File: item.File,
Line: item.Line,
Message: item.Message,
Suggestion: item.Suggestion,
})
}
return out
}
func publicContentFacts(items []publiccontent.Finding) []facts.PublicContentFact {
if len(items) == 0 {
return nil
}
out := make([]facts.PublicContentFact, 0, len(items))
for _, item := range items {
out = append(out, facts.PublicContentFact{
Rule: item.Rule,
Action: item.Action,
File: item.File,
Line: item.Line,
Source: item.Source,
Excerpt: item.Excerpt,
Message: item.Message,
Suggestion: item.Suggestion,
})
}
return out
}
func readManifestInput(path, kind, flag string) (manifest.Manifest, error) {
@@ -167,6 +220,9 @@ func filterPRDiagnostics(repo, changedFrom string, scope qdiff.Scope, m manifest
}
func prDiagnosticRelevant(repo string, changedFiles map[string]bool, commandScope diagnosticCommandScope, m manifest.Manifest, diag report.Diagnostic) bool {
if strings.HasPrefix(diag.Rule, "public_content_") {
return true
}
file := normalizeDiagnosticFile(repo, diag.File)
if file != "" && changedFiles[file] {
return true

View File

@@ -189,6 +189,99 @@ description: Manage Drive comments with service command references.
}
}
func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
if err := vfs.WriteFile(filepath.Join(repo, "README.md"), []byte("# test\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, repo, "add", "README.md")
runGit(t, repo, "commit", "-m", "base")
if err := vfs.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
t.Fatal(err)
}
publicDoc := "api_" + "key = \"example-public-key\"\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)
}
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add public doc")
metadataPath := filepath.Join(repo, "pr-metadata.json")
if err := vfs.WriteFile(metadataPath, []byte(`{"title":"public docs","body":"Change`+`-Id: I0123456789abcdef0123456789abcdef01234567"}`), 0o644); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(repo, "command-manifest.json")
indexPath := filepath.Join(repo, "command-index.json")
m := manifest.Manifest{SchemaVersion: 1, Commands: []manifest.Command{{
Path: "docs +fetch",
CanonicalPath: "docs +fetch",
Domain: "docs",
Source: manifest.SourceShortcut,
}}}
if err := manifest.WriteFile(manifestPath, manifest.KindCommandManifest, m); err != nil {
t.Fatal(err)
}
idx := manifest.Manifest{SchemaVersion: 1, Commands: append([]manifest.Command{}, m.Commands...)}
idx.Commands = append(idx.Commands, manifest.Command{
Path: "drive files get",
CanonicalPath: "drive files get",
Domain: "drive",
Source: manifest.SourceService,
Generated: true,
Runnable: true,
})
if err := manifest.WriteFile(indexPath, manifest.KindCommandIndex, idx); err != nil {
t.Fatal(err)
}
diags, gotFacts, err := Run(context.Background(), Options{
Repo: repo,
CLIBin: "./lark-cli",
ChangedFrom: "HEAD~1",
ManifestPath: manifestPath,
CommandIndexPath: indexPath,
PublicContentMetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
actions := map[string]report.Action{}
for _, diag := range diags {
actions[diag.Rule] = diag.Action
}
if actions["public_content_generic_credential"] != report.ActionReject {
t.Fatalf("generic credential diagnostic action = %q, diagnostics=%#v", actions["public_content_generic_credential"], diags)
}
if actions["public_content_change_id_trailer"] != report.ActionReject {
t.Fatalf("change-id diagnostic action = %q, diagnostics=%#v", actions["public_content_change_id_trailer"], diags)
}
if actions["public_content_semantic_candidate"] != "" {
t.Fatalf("semantic candidates should not become deterministic diagnostics: %#v", diags)
}
factRules := map[string]bool{}
for _, item := range gotFacts.PublicContent {
factRules[item.Rule] = true
}
for _, want := range []string{
"public_content_generic_credential",
"public_content_change_id_trailer",
"public_content_semantic_candidate",
} {
if !factRules[want] {
t.Fatalf("missing public content fact %s: %#v", want, gotFacts.PublicContent)
}
}
if len(gotFacts.PublicContent) < 3 {
t.Fatalf("public content facts = %#v", gotFacts.PublicContent)
}
}
func TestLoadBaseReferenceManifestReadsCommandGolden(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
@@ -506,7 +599,7 @@ func TestNormalizeDiagnosticFileHandlesAbsoluteRepo(t *testing.T) {
func runGit(t *testing.T, repo string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
cmd := exec.Command("git", append([]string{"-c", "core.hooksPath=/dev/null", "-C", repo}, args...)...)
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

@@ -339,7 +339,7 @@ func jsonSchemaResponseFormat() map[string]any {
"properties": map[string]any{
"category": map[string]any{
"type": "string",
"enum": []string{"error_hint", "default_output", "naming", "skill_quality"},
"enum": []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
},
"severity": map[string]any{
"type": "string",

View File

@@ -10,9 +10,10 @@ import (
"strings"
"github.com/larksuite/cli/internal/qualitygate/facts"
"github.com/larksuite/cli/internal/qualitygate/report"
)
var evidencePattern = regexp.MustCompile(`^facts\.(commands|skills|errors|outputs)\[(\d+)\]$`)
var evidencePattern = regexp.MustCompile(`^facts\.(commands|skills|errors|outputs|public_content)\[(\d+)\]$`)
func Decide(f facts.Facts, r Review, p Policy) Decision {
return DecideWithWaivers(f, r, p, Waivers{})
@@ -172,6 +173,16 @@ func evidenceFingerprint(f facts.Facts, ev string) string {
"has_default_limit:" + strconv.FormatBool(out.HasDefaultLimit),
"has_decision_field:" + strconv.FormatBool(out.HasDecisionField),
}, ":")
case "public_content":
item := f.PublicContent[idx]
return strings.Join([]string{
"public_content",
"rule:" + item.Rule,
"action:" + string(item.Action),
"file:" + item.File,
"line:" + strconv.Itoa(item.Line),
"source:" + item.Source,
}, ":")
default:
return "ref:" + ev
}
@@ -201,7 +212,7 @@ func validFinding(f Finding) bool {
func allowedCategory(category string) bool {
switch category {
case "error_hint", "default_output", "naming", "skill_quality":
case "error_hint", "default_output", "naming", "skill_quality", "public_content_leakage":
return true
default:
return false
@@ -247,6 +258,12 @@ func reproducibleEvidence(f facts.Facts, category, kind string, idx int) bool {
}
skill := f.Skills[idx]
return skill.ReferencesInvalidCommand
case "public_content_leakage":
if kind != "public_content" {
return false
}
item := f.PublicContent[idx]
return item.Action == report.ActionReject || item.Rule == "public_content_semantic_candidate"
default:
return false
}
@@ -277,6 +294,8 @@ func evidenceExists(f facts.Facts, kind string, idx int) bool {
return idx < len(f.Errors)
case "outputs":
return idx < len(f.Outputs)
case "public_content":
return idx < len(f.PublicContent)
default:
return false
}

View File

@@ -242,6 +242,7 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
Outputs: []facts.OutputFact{{Command: "im messages list", IsList: true, HasDefaultLimit: false, HasDecisionField: false}},
Commands: []facts.CommandFact{{Path: "docs fetch", NameConflictsExisting: true}},
Skills: []facts.SkillFact{{SourceFile: "skills/lark-doc/SKILL.md", Line: 3, ReferencesInvalidCommand: true}},
PublicContent: []facts.PublicContentFact{{Rule: "public_content_generic_credential", Action: "REJECT", File: "docs/public.md", Line: 4, Source: "metadata"}},
}
for _, tc := range []struct {
category string
@@ -251,6 +252,7 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
{"default_output", "facts.outputs[0]"},
{"naming", "facts.commands[0]"},
{"skill_quality", "facts.skills[0]"},
{"public_content_leakage", "facts.public_content[0]"},
} {
t.Run(tc.category, func(t *testing.T) {
r := Review{Findings: []Finding{{
@@ -268,6 +270,59 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
}
}
func TestGatekeeperDoesNotPromotePublicContentWarningsToBlockers(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,
PublicContent: []facts.PublicContentFact{{
Rule: "public_content_" + "pri" + "vate_ipv4",
Action: "WARNING",
File: "docs/network.md",
Line: 1,
Source: "file",
}},
}
review := Review{Findings: []Finding{{
Category: "public_content_leakage",
Severity: "minor",
Evidence: []string{"facts.public_content[0]"},
Message: "pri" + "vate network address appears in public docs",
SuggestedAction: "confirm the public docs do not expose pri" + "vate deployment details",
}}}
got := Decide(f, review, DefaultPolicy())
if len(got.Blockers) != 0 || len(got.Warnings) != 1 {
t.Fatalf("public content warning should not become a blocker: %#v", got)
}
if got.Warnings[0].ReviewAction != ReviewActionObserve {
t.Fatalf("review action = %q, want %q", got.Warnings[0].ReviewAction, ReviewActionObserve)
}
}
func TestGatekeeperAllowsPublicContentSemanticCandidatesAsBlockers(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,
PublicContent: []facts.PublicContentFact{{
Rule: "public_content_semantic_candidate",
Action: "WARNING",
File: "docs/public.md",
Line: 1,
Source: "file",
}},
}
review := Review{Findings: []Finding{{
Category: "public_content_leakage",
Severity: "major",
Evidence: []string{"facts.public_content[0]"},
Message: "semantic review found pri" + "vate rollout detail",
SuggestedAction: "remove pri" + "vate rollout detail from public docs",
}}}
got := Decide(f, review, DefaultPolicy())
if len(got.Blockers) != 1 {
t.Fatalf("semantic candidate should remain blockable, got %#v", got)
}
}
func TestGatekeeperSkillQualityOnlyBlocksInvalidCommandReferences(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,

View File

@@ -24,7 +24,7 @@ func BuildPrompt(f facts.Facts) []Message {
"Use only the provided JSON view.",
"The changed_summary may summarize broad changed surfaces; review only listed facts, not omitted summarized items.",
"Use fact_ref values exactly when writing finding evidence.",
"Only facts.commands, facts.skills, facts.errors, and facts.outputs fact_ref values may be blocker evidence.",
"Only facts.commands, facts.skills, facts.errors, facts.outputs, and facts.public_content fact_ref values may be blocker evidence.",
"Evidence entries must be exact fact_ref strings such as \"facts.commands[0]\" with no explanations, labels, or suffix text.",
"facts.examples and facts.skill_quality entries are context only.",
"Report an error_hint finding for any facts.errors item where boundary is true, required_hint is true, and hint_action_count is 0.",
@@ -38,6 +38,9 @@ func BuildPrompt(f facts.Facts) []Message {
"For naming findings, use category \"naming\" and evidence containing that facts.commands fact_ref.",
"Report a skill_quality finding for any facts.skills item where references_invalid_command is true.",
"For skill_quality findings, use category \"skill_quality\" and evidence containing that facts.skills fact_ref.",
"Review public content leakage findings and semantic candidates without private dictionaries.",
"Do not reveal internal rule lists when explaining public content leakage.",
"For public_content_leakage findings, preserve the deterministic finding source and excerpt.",
"Report each distinct issue as a separate finding.",
"The verdict value must be \"pass\" when findings is empty and \"warn\" when findings is non-empty; never use \"fail\".",
"Severity must be one of \"minor\", \"major\", or \"critical\"; never use \"error\", \"warning\", \"medium\", or \"high\".",

View File

@@ -23,7 +23,10 @@ func TestBuildPromptContainsSemanticReviewContract(t *testing.T) {
"A facts.outputs item with is_list true, has_default_limit false, and has_decision_field true must still produce a default_output finding.",
"Report a naming finding for any facts.commands item where name_conflicts_existing is true or flag_alias_conflict is true.",
"Report a skill_quality finding for any facts.skills item where references_invalid_command is true.",
"Only facts.commands, facts.skills, facts.errors, and facts.outputs fact_ref values may be blocker evidence.",
"Review public content leakage findings and semantic candidates without private dictionaries.",
"Do not reveal internal rule lists when explaining public content leakage.",
"For public_content_leakage findings, preserve the deterministic finding source and excerpt.",
"Only facts.commands, facts.skills, facts.errors, facts.outputs, and facts.public_content fact_ref values may be blocker evidence.",
"Evidence entries must be exact fact_ref strings such as \"facts.commands[0]\" with no explanations, labels, or suffix text.",
"facts.examples and facts.skill_quality entries are context only.",
"Report each distinct issue as a separate finding.",

View File

@@ -78,11 +78,11 @@ func DefaultPolicy() Policy {
return Policy{
SchemaVersion: 1,
DefaultEnforcement: "observe",
BlockCategories: []string{"error_hint", "default_output", "naming", "skill_quality"},
BlockCategories: []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
RolloutGroups: []RolloutGroup{{
ID: "all",
Enforcement: "blocking",
Categories: []string{"error_hint", "default_output", "naming", "skill_quality"},
Categories: []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
Owner: "test",
Reason: "default in-memory policy",
}},

View File

@@ -82,6 +82,15 @@ func factScope(f facts.Facts, kind string, idx int) (FactScope, bool) {
Source: item.Source,
CommandPath: item.Command,
}, true
case "public_content":
item := f.PublicContent[idx]
return FactScope{
FactKind: "public_content",
Changed: true,
Source: item.Source,
SourceFile: item.File,
Line: item.Line,
}, true
default:
return FactScope{}, false
}
@@ -195,7 +204,7 @@ func containsString(values []string, want string) bool {
func allowedFactKind(kind string) bool {
switch kind {
case "skill", "command", "error", "output":
case "skill", "command", "error", "output", "public_content":
return true
default:
return false

View File

@@ -81,6 +81,30 @@ func TestGatekeeperSkillQualityUsesSkillEvidence(t *testing.T) {
}
}
func TestGatekeeperUsesPublicContentEvidence(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,
PublicContent: []facts.PublicContentFact{{
Rule: "public_content_generic_credential",
Action: "REJECT",
File: "docs/public.md",
Line: 12,
Source: "metadata",
}},
}
review := Review{Findings: []Finding{{
Category: "public_content_leakage",
Severity: "critical",
Evidence: []string{"facts.public_content[0]"},
Message: "public content finding needs review",
SuggestedAction: "remove the sensitive public content",
}}}
got := Decide(f, review, DefaultPolicy())
if len(got.Blockers) != 1 || got.Blockers[0].RolloutGroups[0] != "all" {
t.Fatalf("expected public content blocker, got %#v", got)
}
}
func TestGatekeeperAppliesSharedWaiverID(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,

View File

@@ -13,27 +13,29 @@ import (
)
type InputView struct {
SchemaVersion int `json:"schema_version"`
ChangedSummary ChangedSummary `json:"changed_summary"`
RuleSummary []RuleSummaryItem `json:"rule_summary,omitempty"`
Commands []CommandInput `json:"commands,omitempty"`
Skills []SkillInput `json:"skills,omitempty"`
SkillQuality []SkillQualityInput `json:"skill_quality,omitempty"`
Errors []ErrorInput `json:"errors,omitempty"`
Outputs []OutputInput `json:"outputs,omitempty"`
Examples []ExampleInput `json:"examples,omitempty"`
Diagnostics []facts.DiagnosticFact `json:"diagnostics,omitempty"`
SchemaVersion int `json:"schema_version"`
ChangedSummary ChangedSummary `json:"changed_summary"`
RuleSummary []RuleSummaryItem `json:"rule_summary,omitempty"`
Commands []CommandInput `json:"commands,omitempty"`
Skills []SkillInput `json:"skills,omitempty"`
SkillQuality []SkillQualityInput `json:"skill_quality,omitempty"`
Errors []ErrorInput `json:"errors,omitempty"`
Outputs []OutputInput `json:"outputs,omitempty"`
Examples []ExampleInput `json:"examples,omitempty"`
PublicContentLeakage []PublicContentInput `json:"public_content_leakage,omitempty"`
Diagnostics []facts.DiagnosticFact `json:"diagnostics,omitempty"`
}
type ChangedSummary struct {
Commands int `json:"commands,omitempty"`
Skills int `json:"skills,omitempty"`
SkillQuality int `json:"skill_quality,omitempty"`
Errors int `json:"errors,omitempty"`
Outputs int `json:"outputs,omitempty"`
Examples int `json:"examples,omitempty"`
Domains []string `json:"domains,omitempty"`
Sources []string `json:"sources,omitempty"`
Commands int `json:"commands,omitempty"`
Skills int `json:"skills,omitempty"`
SkillQuality int `json:"skill_quality,omitempty"`
Errors int `json:"errors,omitempty"`
Outputs int `json:"outputs,omitempty"`
Examples int `json:"examples,omitempty"`
PublicContent int `json:"public_content,omitempty"`
Domains []string `json:"domains,omitempty"`
Sources []string `json:"sources,omitempty"`
}
type RuleSummaryItem struct {
@@ -86,6 +88,22 @@ type ExampleInput struct {
facts.CommandExample
}
type PublicContentInput struct {
FactRef string `json:"fact_ref"`
facts.PublicContentFact
}
func (v InputView) HasReviewableFacts() bool {
return len(v.Commands) > 0 ||
len(v.Skills) > 0 ||
len(v.SkillQuality) > 0 ||
len(v.Errors) > 0 ||
len(v.Outputs) > 0 ||
len(v.Examples) > 0 ||
len(v.PublicContentLeakage) > 0 ||
len(v.Diagnostics) > 0
}
func BuildInputView(f facts.Facts) InputView {
selected := newInputSelection(f)
selected.addChangedReviewCandidates()
@@ -104,16 +122,17 @@ func BuildInputView(f facts.Facts) InputView {
}
return InputView{
SchemaVersion: f.SchemaVersion,
ChangedSummary: changedSummary(f),
RuleSummary: ruleSummary(f.Diagnostics),
Commands: selected.commandInputs(),
Skills: selected.skillInputs(),
SkillQuality: selected.skillQualityInputs(),
Errors: selected.errorInputs(),
Outputs: selected.outputInputs(),
Examples: selected.exampleInputs(),
Diagnostics: viewDiagnostics,
SchemaVersion: f.SchemaVersion,
ChangedSummary: changedSummary(f),
RuleSummary: ruleSummary(f.Diagnostics),
Commands: selected.commandInputs(),
Skills: selected.skillInputs(),
SkillQuality: selected.skillQualityInputs(),
Errors: selected.errorInputs(),
Outputs: selected.outputInputs(),
Examples: selected.exampleInputs(),
PublicContentLeakage: selected.publicContentInputs(),
Diagnostics: viewDiagnostics,
}
}
@@ -138,6 +157,11 @@ func (s *inputSelection) addChangedReviewCandidates() {
s.outputs[i] = true
}
}
for i, item := range s.f.PublicContent {
if publicContentReviewCandidate(item) {
s.publicContent[i] = true
}
}
}
func commandReviewCandidate(cmd facts.CommandFact) bool {
@@ -157,25 +181,31 @@ func outputReviewCandidate(_ facts.OutputFact) bool {
return false
}
func publicContentReviewCandidate(item facts.PublicContentFact) bool {
return item.Rule == "public_content_semantic_candidate"
}
type inputSelection struct {
f facts.Facts
commands []bool
skills []bool
skillQuality []bool
errors []bool
outputs []bool
examples []bool
f facts.Facts
commands []bool
skills []bool
skillQuality []bool
errors []bool
outputs []bool
examples []bool
publicContent []bool
}
func newInputSelection(f facts.Facts) *inputSelection {
return &inputSelection{
f: f,
commands: make([]bool, len(f.Commands)),
skills: make([]bool, len(f.Skills)),
skillQuality: make([]bool, len(f.SkillQuality)),
errors: make([]bool, len(f.Errors)),
outputs: make([]bool, len(f.Outputs)),
examples: make([]bool, len(f.Examples)),
f: f,
commands: make([]bool, len(f.Commands)),
skills: make([]bool, len(f.Skills)),
skillQuality: make([]bool, len(f.SkillQuality)),
errors: make([]bool, len(f.Errors)),
outputs: make([]bool, len(f.Outputs)),
examples: make([]bool, len(f.Examples)),
publicContent: make([]bool, len(f.PublicContent)),
}
}
@@ -194,6 +224,8 @@ func (s *inputSelection) diagnosticContext(diag facts.DiagnosticFact) *inputSele
s.addDiagnosticExamples(out, diag)
case diag.Rule == "no_bare_helper_error":
s.addDiagnosticErrors(out, diag)
case strings.HasPrefix(diag.Rule, "public_content_"):
s.addDiagnosticPublicContent(out, diag)
}
return out
}
@@ -256,6 +288,15 @@ func (s *inputSelection) addDiagnosticExamples(out *inputSelection, diag facts.D
}
}
func (s *inputSelection) addDiagnosticPublicContent(out *inputSelection, diag facts.DiagnosticFact) {
for i, item := range s.f.PublicContent {
if diagnosticLocationMatches(diag.File, diag.Line, item.File, item.Line) ||
diag.Rule == item.Rule {
out.publicContent[i] = true
}
}
}
func includeDiagnosticInView(diag facts.DiagnosticFact, selected, context *inputSelection) bool {
if diag.Action == report.ActionReject {
return true
@@ -270,6 +311,7 @@ func (s *inputSelection) merge(other *inputSelection) {
mergeSelections(s.errors, other.errors)
mergeSelections(s.outputs, other.outputs)
mergeSelections(s.examples, other.examples)
mergeSelections(s.publicContent, other.publicContent)
}
func (s *inputSelection) intersects(other *inputSelection) bool {
@@ -278,7 +320,8 @@ func (s *inputSelection) intersects(other *inputSelection) bool {
selectionsIntersect(s.skillQuality, other.skillQuality) ||
selectionsIntersect(s.errors, other.errors) ||
selectionsIntersect(s.outputs, other.outputs) ||
selectionsIntersect(s.examples, other.examples)
selectionsIntersect(s.examples, other.examples) ||
selectionsIntersect(s.publicContent, other.publicContent)
}
func (s *inputSelection) commandInputs() []CommandInput {
@@ -351,6 +394,16 @@ func (s *inputSelection) exampleInputs() []ExampleInput {
return out
}
func (s *inputSelection) publicContentInputs() []PublicContentInput {
out := make([]PublicContentInput, 0, countSelected(s.publicContent))
for i, ok := range s.publicContent {
if ok {
out = append(out, PublicContentInput{FactRef: factRef("public_content", i), PublicContentFact: s.f.PublicContent[i]})
}
}
return out
}
func changedSummary(f facts.Facts) ChangedSummary {
domains := map[string]bool{}
sources := map[string]bool{}
@@ -402,6 +455,10 @@ func changedSummary(f facts.Facts) ChangedSummary {
addNonEmpty(domains, example.Domain)
addNonEmpty(sources, example.Source)
}
for _, item := range f.PublicContent {
out.PublicContent++
addNonEmpty(sources, item.Source)
}
out.Domains = sortedViewSetKeys(domains)
out.Sources = sortedViewSetKeys(sources)
return out
@@ -434,7 +491,8 @@ func semanticDiagnosticRule(rule string) bool {
strings.HasPrefix(rule, "default_output") ||
strings.HasPrefix(rule, "skill_") ||
strings.HasPrefix(rule, "example_dry_run") ||
rule == "no_bare_helper_error"
rule == "no_bare_helper_error" ||
strings.HasPrefix(rule, "public_content_")
}
func diagnosticCommandMatches(diag facts.DiagnosticFact, values ...string) bool {

View File

@@ -77,6 +77,122 @@ func TestInputViewKeepsChangedReviewCandidatesWithOriginalRefs(t *testing.T) {
}
}
func TestInputViewIncludesPublicContentLeakage(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,
PublicContent: []facts.PublicContentFact{{
Rule: "public_content_generic_credential",
Action: report.ActionReject,
File: "docs/public.md",
Line: 4,
Excerpt: "api_key = <redacted>",
Message: "generic credential assignment",
}},
Diagnostics: []facts.DiagnosticFact{{
Rule: "public_content_generic_credential",
Action: report.ActionReject,
File: "docs/public.md",
Line: 4,
Message: "generic credential assignment",
}},
}
view := BuildInputView(f)
if len(view.PublicContentLeakage) != 1 {
t.Fatalf("public content leakage len = %d, want 1", len(view.PublicContentLeakage))
}
if got := view.PublicContentLeakage[0].FactRef; got != "facts.public_content[0]" {
t.Fatalf("public content fact ref = %q", got)
}
if len(view.Diagnostics) != 1 {
t.Fatalf("diagnostics len = %d, want 1", len(view.Diagnostics))
}
}
func TestInputViewIncludesPublicContentSemanticCandidatesWithoutDiagnostics(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,
PublicContent: []facts.PublicContentFact{{
Rule: "public_content_semantic_candidate",
Action: report.ActionWarning,
File: "docs/public.md",
Line: 1,
Source: "file",
Excerpt: "public prose that needs semantic review",
Message: "public contribution contains text for semantic public content review",
}},
}
view := BuildInputView(f)
if len(view.PublicContentLeakage) != 1 {
t.Fatalf("semantic candidate len = %d, want 1", len(view.PublicContentLeakage))
}
if got := view.PublicContentLeakage[0].FactRef; got != "facts.public_content[0]" {
t.Fatalf("semantic candidate fact ref = %q", got)
}
if len(view.Diagnostics) != 0 {
t.Fatalf("semantic candidate should not require diagnostics, got %#v", view.Diagnostics)
}
}
func TestPromptIncludesSanitizedPublicContentExcerpt(t *testing.T) {
scopeText := "pri" + "vate rollout"
f := facts.Facts{
SchemaVersion: 1,
PublicContent: []facts.PublicContentFact{{
Rule: "public_content_semantic_candidate",
Action: report.ActionWarning,
File: "docs/public.md",
Line: 1,
Source: "file",
Excerpt: `semantic signals: pri` + `vate_scope,roadmap_detail; excerpt: "` + scopeText + ` token=<redacted>"`,
Message: "public contribution contains text for semantic public content review",
}},
}
view := BuildInputView(f)
if len(view.PublicContentLeakage) != 1 {
t.Fatalf("semantic candidate len = %d, want 1", len(view.PublicContentLeakage))
}
if got := view.PublicContentLeakage[0].Excerpt; !strings.Contains(got, scopeText) || !strings.Contains(got, "token=<redacted>") {
t.Fatalf("semantic candidate excerpt missing from view: %q", got)
}
messages := BuildPrompt(f)
if len(messages) != 2 {
t.Fatalf("messages len = %d, want 2", len(messages))
}
if !strings.Contains(messages[1].Content, scopeText) || !strings.Contains(messages[1].Content, "redacted") {
t.Fatalf("prompt missing sanitized public content excerpt: %s", messages[1].Content)
}
if strings.Contains(messages[1].Content, "real-"+"secret-value") {
t.Fatalf("prompt leaked raw sensitive value %q", messages[1].Content)
}
}
func TestInputViewExcludesPublicContentWarningsWithoutSemanticCandidate(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,
PublicContent: []facts.PublicContentFact{{
Rule: "public_content_" + "pri" + "vate_ipv4",
Action: report.ActionWarning,
File: "docs/network.md",
Line: 1,
Source: "file",
Excerpt: "192.168." + "0.10",
Message: "public contribution contains a pri" + "vate-network IP address",
}},
}
view := BuildInputView(f)
if len(view.PublicContentLeakage) != 0 {
t.Fatalf("warning-only public content should not enter semantic view: %#v", view.PublicContentLeakage)
}
if len(view.Diagnostics) != 0 {
t.Fatalf("warning-only public content should not add diagnostics: %#v", view.Diagnostics)
}
}
func TestInputViewSummarizesBroadChangedCommandSurface(t *testing.T) {
f := broadChangedFacts(434, 44)

View File

@@ -138,6 +138,10 @@ func parseWaiver(parts []string, lineNo int) (Waiver, error) {
if item.SourceFile == "" || item.Line == 0 {
return Waiver{}, fmt.Errorf("%s:%d: %s waiver requires source_file and line", waiverPath, lineNo, item.FactKind)
}
case "public_content":
if item.SourceFile == "" || item.Line == 0 || item.CommandPath != "" {
return Waiver{}, fmt.Errorf("%s:%d: public_content waiver requires source_file and line only", waiverPath, lineNo)
}
case "command", "output":
if item.CommandPath == "" {
return Waiver{}, fmt.Errorf("%s:%d: %s waiver requires command_path", waiverPath, lineNo, item.FactKind)

View File

@@ -21,24 +21,27 @@ func TestLoadWaivers(t *testing.T) {
writeSemanticFile(t, repo, "waivers.txt", "# waiver_id\tcategory\tfact_kind\tsource_file\tline\tcommand_path\towner\treason\tadded_at\texpires_at\n"+
"wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/SKILL.md\t30\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n"+
"wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/references/move.md\t12\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n")
"wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/references/move.md\t12\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n"+
"public-doc-202606\tpublic_content_leakage\tpublic_content\tdocs/public.md\t4\t\tsecurity-owner\treviewed false positive\t2026-06-08\t2026-07-15\n")
w, diags, err = LoadWaivers(repo, now)
if err != nil {
t.Fatalf("LoadWaivers() error = %v", err)
}
if len(diags) != 0 || len(w.Items) != 2 {
if len(diags) != 0 || len(w.Items) != 3 {
t.Fatalf("LoadWaivers() = %#v %#v", w, diags)
}
for name, body := range map[string]string{
"bad columns": "one\ttoo-few\n",
"bad id": "BAD\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
"bad fact kind": "id1\terror_hint\tskill_quality\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
"missing owner": "id1\terror_hint\terror\tcmd/root.go\t1\t\t\tr\t2026-06-08\t2026-07-15\n",
"missing line": "id1\terror_hint\terror\tcmd/root.go\t\t\to\tr\t2026-06-08\t2026-07-15\n",
"missing command": "id1\tdefault_output\toutput\t\t\t\to\tr\t2026-06-08\t2026-07-15\n",
"bad source path": "id1\terror_hint\terror\t../cmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
"bad date format": "id1\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t20260608\t2026-07-15\n",
"bad columns": "one\ttoo-few\n",
"bad id": "BAD\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
"bad fact kind": "id1\terror_hint\tskill_quality\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
"missing owner": "id1\terror_hint\terror\tcmd/root.go\t1\t\t\tr\t2026-06-08\t2026-07-15\n",
"missing line": "id1\terror_hint\terror\tcmd/root.go\t\t\to\tr\t2026-06-08\t2026-07-15\n",
"missing command": "id1\tdefault_output\toutput\t\t\t\to\tr\t2026-06-08\t2026-07-15\n",
"public content missing line": "id1\tpublic_content_leakage\tpublic_content\tdocs/public.md\t\t\to\tr\t2026-06-08\t2026-07-15\n",
"public content command selector": "id1\tpublic_content_leakage\tpublic_content\t\t\tcmd/foo\to\tr\t2026-06-08\t2026-07-15\n",
"bad source path": "id1\terror_hint\terror\t../cmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
"bad date format": "id1\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t20260608\t2026-07-15\n",
} {
t.Run(name, func(t *testing.T) {
writeSemanticFile(t, repo, "waivers.txt", body)

View File

@@ -5609,6 +5609,21 @@
"final_score": "80.0587",
"recommend": "false"
},
{
"scope_name": "im:chat.nickname:read",
"final_score": "88.0587",
"recommend": "true"
},
{
"scope_name": "im:chat.nickname:write",
"final_score": "79.5982",
"recommend": "true"
},
{
"scope_name": "im:chat.user_setting:write",
"final_score": "83.6587",
"recommend": "true"
},
{
"scope_name": "im:chat.user_setting:read",
"final_score": "88.0587",

View File

@@ -4,8 +4,11 @@
package schema
import (
"regexp"
"sort"
"strings"
"github.com/larksuite/cli/internal/affordance"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
@@ -22,7 +25,7 @@ func Convert(f meta.Field) Property {
if f.Type == "file" {
p.Format = "binary"
}
p.Description = f.Description
p.Description = normalizeDesc(f.Description)
p.Default = f.CoercedDefault()
p.Example = f.CoercedExample()
p.Minimum = f.MinBound()
@@ -52,6 +55,24 @@ func Convert(f meta.Field) Property {
return p
}
var (
sepRunRe = regexp.MustCompile(`[;]{2,}`)
spaceRunRe = regexp.MustCompile(`[ \t]{2,}`)
)
// normalizeDesc de-crufts a meta_data description for the envelope — strips
// markdown emphasis and collapses doubled separators/spaces — but keeps content
// (links, newlines, sentences); the compact flag-help has its own stricter pass.
func normalizeDesc(s string) string {
if s == "" {
return ""
}
s = strings.ReplaceAll(s, "**", "")
s = sepRunRe.ReplaceAllString(s, "; ")
s = spaceRunRe.ReplaceAllString(s, " ")
return strings.TrimRight(s, " ;;。.,、\n")
}
// enumSchema splits coerced enum options into the parallel enum / enumDescriptions
// arrays for the envelope. enumDescriptions is nil unless at least one value
// carries a description (so the bare-enum form stays values-only), keeping the
@@ -86,6 +107,18 @@ func propsOf(fields []meta.Field) *OrderedProps {
return op
}
// paramPropsOf is propsOf for the params section: each property also carries
// its CLI flag (--kebab-name).
func paramPropsOf(fields []meta.Field) *OrderedProps {
op := &OrderedProps{}
for _, f := range fields {
p := Convert(f)
p.Flag = "--" + f.FlagName()
op.Set(f.Name, p)
}
return op
}
// requiredOf returns the alphabetized names of the required fields.
func requiredOf(fields []meta.Field) []string {
var required []string
@@ -108,16 +141,17 @@ func buildInputSchema(m meta.Method) *InputSchema {
Properties: &OrderedProps{},
}
addInputObject(is, "params", "", m.Params())
addInputObject(is, "data", "", m.Data())
addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file <key>=<path>.", m.Files())
addInputObject(is, "params", "", m.Params(), true, "")
addInputObject(is, "data", "", m.Data(), false, "--data")
addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file <key>=<path>.", m.Files(), false, "--file")
if m.Risk == core.RiskHighRiskWrite {
falseVal := false
is.Properties.Set("yes", Property{
Type: "boolean",
Flag: "--yes",
Default: falseVal,
Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Not sent to the backend.",
Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Pass --yes only after the user has explicitly confirmed; not sent to the backend.",
})
}
@@ -125,20 +159,24 @@ func buildInputSchema(m meta.Method) *InputSchema {
return is
}
// addInputObject adds one named sub-object section (params/data/file) to the
// input schema when it has fields: its Properties come from the fields, its
// Required lists the mandatory keys, and the section itself is required at top
// level when any field is required. Empty sections are skipped.
func addInputObject(is *InputSchema, name, description string, fields []meta.Field) {
// addInputObject adds one section (params/data/file) when it has fields, marking
// the section required at top level when any field is. asFlags tags each property
// with its --flag (params only); carrier names the section's flag (--data/--file).
func addInputObject(is *InputSchema, name, description string, fields []meta.Field, asFlags bool, carrier string) {
if len(fields) == 0 {
return
}
props := propsOf(fields)
if asFlags {
props = paramPropsOf(fields)
}
req := requiredOf(fields)
is.Properties.Set(name, Property{
Type: "object",
Description: description,
Carrier: carrier,
Required: req,
Properties: propsOf(fields),
Properties: props,
})
if len(req) > 0 {
is.Required = append(is.Required, name)
@@ -179,7 +217,13 @@ func buildMeta(m meta.Method) *Meta {
// EnvelopeOf renders the MCP envelope for one method ref — the ref-based entry
// callers use, since apicatalog.MethodRef is the metadata navigation currency.
func EnvelopeOf(ref apicatalog.MethodRef) Envelope {
return assemble(ref.Service.Name, ref.ResourcePath, ref.Method)
m := ref.Method
// The affordance overlay lives in the CLI, not the metadata; look it up
// lazily here (it takes precedence over any affordance the metadata carries).
if raw, ok := affordance.For(ref.Service.Name, m.ID); ok {
m.Affordance = raw
}
return assemble(ref.Service.Name, ref.ResourcePath, m)
}
// Envelopes renders the given method refs into envelopes, sorted by name. The
@@ -205,7 +249,7 @@ func assemble(serviceName string, resourcePath []string, m meta.Method) Envelope
return Envelope{
Name: name,
Description: m.Description,
Description: normalizeDesc(m.Description),
InputSchema: buildInputSchema(m),
OutputSchema: buildOutputSchema(m),
Meta: buildMeta(m),

View File

@@ -9,7 +9,9 @@ import (
"reflect"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/affordance"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/registry"
@@ -504,6 +506,31 @@ func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
}
}
// EnvelopeOf injects affordance from the CLI overlay (looked up lazily by
// service + method id), so a method whose metadata carries none still gets
// guidance in its envelope when an overlay entry exists.
func TestEnvelopeOf_AffordanceFromOverlay(t *testing.T) {
// The overlay source is the top-level affordance/ tree, injected at startup;
// inject a fixture so this unit test does not depend on the shipped content.
// Reset afterwards (this binary installs no source by default) for isolation.
t.Cleanup(func() { affordance.SetSource(nil) })
affordance.SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte(
"# approval\n> skill: lark-approval\n\n## instances get\n查询某审批实例的状态与进度。\n\n### Examples\n\n**按 code 查询**\n```bash\nlark-cli approval instances get --instance-code \"x\"\n```\n")}})
env := synthEnvelope("approval", []string{"instances"}, meta.Method{ID: "instances.get", Name: "get"})
if env.Meta == nil || env.Meta.Affordance == nil {
t.Fatal("expected affordance from the approval overlay, got none")
}
if len(env.Meta.Affordance.UseWhen) == 0 || len(env.Meta.Affordance.Examples) == 0 {
t.Errorf("overlay affordance missing use_when/examples: %+v", env.Meta.Affordance)
}
// A method id with no overlay entry carries no affordance.
bare := synthEnvelope("approval", []string{"instances"}, meta.Method{ID: "instances.no_such_method", Name: "x"})
if bare.Meta != nil && bare.Meta.Affordance != nil {
t.Errorf("method without overlay should have no affordance, got %+v", bare.Meta.Affordance)
}
}
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
method := map[string]interface{}{
"scopes": []interface{}{"x"},

View File

@@ -13,6 +13,10 @@ import (
)
// Envelope is the MCP Tool spec contract for a single API method command.
//
// The REST route (httpMethod/path) is deliberately NOT exposed: every
// schema-resolvable method already has a typed command, so the raw path would
// only tempt an agent toward the `api` escape hatch.
type Envelope struct {
Name string `json:"name"`
Description string `json:"description"`
@@ -44,9 +48,15 @@ type OutputSchema struct {
// "params" / "data" sub-objects inside inputSchema): it lists which keys
// inside that object's Properties are mandatory. Leaf fields ignore it.
type Property struct {
Type string `json:"type,omitempty"`
Description string `json:"description,omitempty"`
Enum []interface{} `json:"enum,omitempty"`
Type string `json:"type,omitempty"`
Description string `json:"description,omitempty"`
// Flag is the typed CLI flag a params property maps to (e.g. "--folder-id");
// absent on body/file fields, which travel via the section's Carrier.
Flag string `json:"flag,omitempty"`
// Carrier names the flag a whole inputSchema section travels on ("--data" /
// "--file"); empty on the params section, whose properties carry their Flag.
Carrier string `json:"carrier,omitempty"`
Enum []interface{} `json:"enum,omitempty"`
// EnumDescriptions, when present, is parallel to Enum: the human meaning of
// each allowed value, in the same order. Omitted when no value carries a
// description. This is the widely-recognized JSON-Schema extension (VS Code,

View File

@@ -16,6 +16,14 @@ import (
const (
// EnvNoProxy disables automatic proxy support when set to any non-empty value.
EnvNoProxy = "LARK_CLI_NO_PROXY"
// EnvNoProxyWarn suppresses the proxy-detected warning when set to any
// non-empty value, while leaving proxy behavior unchanged. Unlike
// EnvNoProxy (which both silences the warning AND disables the proxy), this
// keeps proxy egress active. It exists so agents consuming --format json can
// keep using the proxy without the human-oriented warning line landing in
// the output stream and breaking JSON parsing.
EnvNoProxyWarn = "LARK_CLI_NO_PROXY_WARN"
)
// proxyEnvKeys lists environment variables that Go's ProxyFromEnvironment reads.
@@ -73,6 +81,11 @@ func redactProxyURL(raw string) string {
// are redacted. Safe to call multiple times; only the first call prints.
func WarnIfProxied(w io.Writer) {
proxyWarningOnce.Do(func() {
// Explicit opt-out: silence the warning without touching proxy behavior.
// Checked before the plugin and env-proxy branches so it suppresses both.
if os.Getenv(EnvNoProxyWarn) != "" {
return
}
// Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see
// Shared), so its warning and disable instructions take precedence.
// Emitting the env-proxy warning here would be misleading: it tells the
@@ -88,7 +101,7 @@ func WarnIfProxied(w io.Writer) {
if key == "" {
return
}
fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy.\n",
key, redactProxyURL(val), EnvNoProxy)
fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy, or %s=1 to keep the proxy and silence this warning.\n",
key, redactProxyURL(val), EnvNoProxy, EnvNoProxyWarn)
})
}

View File

@@ -93,6 +93,47 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
}
}
// TestWarnIfProxied_SilentWhenWarnOptOut verifies that LARK_CLI_NO_PROXY_WARN
// suppresses the warning while the proxy stays configured (unlike
// LARK_CLI_NO_PROXY, which also disables the proxy).
func TestWarnIfProxied_SilentWhenWarnOptOut(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://proxy:8080")
t.Setenv(EnvNoProxyWarn, "1")
var buf bytes.Buffer
WarnIfProxied(&buf)
if buf.Len() != 0 {
t.Errorf("expected no warning when %s is set, got: %s", EnvNoProxyWarn, buf.String())
}
}
// TestWarnIfProxied_WarnOptOutSuppressesPluginWarning verifies that
// LARK_CLI_NO_PROXY_WARN also suppresses the proxy-plugin warning.
func TestWarnIfProxied_WarnOptOutSuppressesPluginWarning(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
proxyWarningOnce = sync.Once{}
old := proxyPluginStatus
proxyPluginStatus = func() (string, string, bool) { return "http://127.0.0.1:3128", "", true }
t.Cleanup(func() { proxyPluginStatus = old })
t.Setenv(EnvNoProxyWarn, "1")
var buf bytes.Buffer
WarnIfProxied(&buf)
if buf.Len() != 0 {
t.Errorf("expected no plugin warning when %s is set, got: %s", EnvNoProxyWarn, buf.String())
}
}
// TestWarnIfProxied_OnlyOnce verifies that proxy warnings are emitted only once.
func TestWarnIfProxied_OnlyOnce(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

View File

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

View File

@@ -45,6 +45,10 @@ async function publishTargetStillCurrent(github, context, core, target, phase =
repo: context.repo.repo,
pull_number: target.pr,
});
if (pr.state !== "open") {
core.notice(`PR quality summary skipped: PR is no longer open before ${phase}`);
return false;
}
if (pr.head.sha !== target.headSha) {
core.notice(`PR quality summary skipped: PR head changed before ${phase}`);
return false;

View File

@@ -152,6 +152,25 @@ describe("ci-quality-summary-publish", () => {
});
});
it("does not publish a summary when the PR closes before comment creation", async () => {
await withPublishTempDir(async ({ calls }) => {
await publish({
github: fakeGithub(calls, {
jobs: [{ name: "unit-test", conclusion: "failure", html_url: "https://github.example/jobs/1" }],
pullResponses: [
currentPullResponse(),
currentPullResponse({ state: "closed" }),
],
}),
context: workflowRunContext({ conclusion: "failure" }),
core: silentCore(calls),
});
assert.equal(calls.comments.length, 0);
assert.match(calls.notices.join("\n"), /PR is no longer open/);
});
});
it("does not delete an existing summary when the PR base changes before cleanup", async () => {
await withPublishTempDir(async ({ calls }) => {
await publish({
@@ -338,6 +357,7 @@ function fakeGithub(calls, options = {}) {
function currentPullResponse(overrides = {}) {
return {
data: {
state: overrides.state || "open",
head: { sha: overrides.headSha || process.env.CI_QUALITY_SUMMARY_HEAD_SHA },
base: {
sha: overrides.baseSha || process.env.CI_QUALITY_SUMMARY_BASE_SHA,

View File

@@ -5,26 +5,42 @@
set -euo pipefail
workflow=".github/workflows/ci.yml"
job_section() {
local job="$1"
awk -v job="$job" '
$0 == " " job ":" { in_job = 1; print; next }
in_job && /^ [A-Za-z0-9_-]+:/ { exit }
in_job { print }
' "$workflow"
}
workflow_permissions="$(awk '
/^permissions:/ { in_permissions = 1; print; next }
in_permissions && /^[^[:space:]]/ { exit }
in_permissions { print }
' "$workflow")"
fast_gate_section="$(job_section fast-gate)"
unit_test_section="$(job_section unit-test)"
lint_section="$(awk '
/^ lint:/ { in_job = 1 }
in_job { print }
/^ deterministic-gate:/ { exit }
/^ script-test:/ { exit }
' "$workflow")"
script_test_section="$(job_section script-test)"
deterministic_section="$(awk '
/^ deterministic-gate:/ { in_job = 1 }
in_job { print }
/^ coverage:/ { exit }
' "$workflow")"
coverage_job_section="$(job_section coverage)"
deadcode_section="$(job_section deadcode)"
dry_run_section="$(job_section e2e-dry-run)"
section="$(awk '
/^ e2e-live:/ { in_job = 1 }
in_job { print }
/^ security:/ { exit }
' "$workflow")"
security_section="$(job_section security)"
license_header_section="$(job_section license-header)"
results_section="$(awk '
/^ results:/ { in_job = 1 }
in_job { print }
@@ -98,13 +114,94 @@ if ! grep -Fq "make quality-gate" <<<"$deterministic_section"; then
exit 1
fi
if ! grep -Fq "Write public content metadata" <<<"$deterministic_section"; then
echo "deterministic-gate should write PR title/body metadata before quality-gate"
exit 1
fi
if ! grep -Fq "types: [opened, synchronize, reopened, edited]" "$workflow"; then
echo "CI pull_request trigger should include edited so PR title/body changes are rescanned"
exit 1
fi
if ! grep -Fq "script-test:" <<<"$script_test_section"; then
echo "CI should run make script-test so workflow and publisher contract tests are not local-only"
exit 1
fi
if ! grep -Fq "make script-test" <<<"$script_test_section"; then
echo "script-test job should invoke make script-test"
exit 1
fi
if ! grep -Fq "actions/setup-node" <<<"$script_test_section"; then
echo "script-test job should install Node for JavaScript workflow tests"
exit 1
fi
if grep -Fq '${{ secrets.' <<<"$script_test_section"; then
echo "script-test must not reference secrets"
exit 1
fi
if grep -Fq "metadata-gate:" "$workflow"; then
echo "metadata-gate should not run alongside deterministic-gate because both would upload the same facts artifact"
exit 1
fi
if grep -Fq "github.event.action != 'edited'" <<<"$fast_gate_section"; then
echo "fast-gate must run on pull_request edited events so title/body edits cannot replace failed CI with a light success"
exit 1
fi
for full_job in \
"$unit_test_section" \
"$lint_section" \
"$script_test_section" \
"$deterministic_section" \
"$coverage_job_section" \
"$dry_run_section" \
"$security_section"; do
if grep -Fq "github.event.action != 'edited'" <<<"$full_job"; then
echo "full CI jobs must run on pull_request edited events; do not skip title/body-only edits"
exit 1
fi
done
for pull_request_job in "$deadcode_section" "$license_header_section"; do
if grep -Fq "github.event.action != 'edited'" <<<"$pull_request_job"; then
echo "pull_request-only CI jobs must run on edited events"
exit 1
fi
done
if grep -Fq '${{ secrets.' <<<"$deterministic_section"; then
echo "deterministic-gate must not reference secrets"
exit 1
fi
if ! grep -Fq "PUBLIC_CONTENT_METADATA=" <<<"$deterministic_section"; then
echo "deterministic-gate should pass public content metadata into make quality-gate"
exit 1
fi
if ! grep -Fq "PR_BRANCH:" <<<"$deterministic_section"; then
echo "deterministic-gate should pass the pull request branch into public content metadata"
exit 1
fi
if ! grep -Fq "name: quality-gate-facts-\${{ github.event.pull_request.base.sha }}-\${{ github.event.pull_request.head.sha }}" <<<"$deterministic_section"; then
echo "deterministic-gate should upload base/head-bound quality-gate-facts for semantic review"
exit 1
fi
if ! grep -Fq "needs: [unit-test, lint, deterministic-gate]" "$workflow"; then
echo "E2E jobs should wait for deterministic-gate"
if ! grep -Fq "needs: [unit-test, lint, script-test, deterministic-gate]" "$workflow"; then
echo "E2E jobs should wait for script-test and deterministic-gate"
exit 1
fi
if ! grep -Fq "script-test" <<<"$results_section"; then
echo "results job should include script-test"
exit 1
fi
@@ -210,6 +307,11 @@ if ! grep -Fq "go run ./internal/qualitygate/cmd/manifest-export" <<<"$make_outp
exit 1
fi
if ! grep -Fq -- "--public-content-metadata .tmp/quality-gate/public-content-metadata.json" <<<"$make_output"; then
echo "quality-gate check should consume public content metadata"
exit 1
fi
if ! grep -Fq -- "--manifest .tmp/quality-gate/command-manifest.json" <<<"$make_output" ||
! grep -Fq -- "--command-index .tmp/quality-gate/command-index.json" <<<"$make_output"; then
echo "quality-gate check should consume both exported command snapshots"

View File

@@ -5,7 +5,12 @@
const fs = require("fs");
const path = require("path");
const { execFileSync, execFile } = require("child_process");
const p = require("@clack/prompts");
// @clack/prompts is ESM-only since v1; load it via dynamic import() so this
// CommonJS script works on all supported Node versions (require() of an ESM
// package throws ERR_REQUIRE_ESM before Node 22.12). Assigned in the entry
// point below before main() runs.
let p;
const PKG = "@larksuite/cli";
const SKILLS_REPO = "https://open.feishu.cn";
@@ -374,7 +379,12 @@ async function main() {
}
}
main().catch((err) => {
p.cancel("Unexpected error: " + (err.message || err));
(async () => {
p = await import("@clack/prompts");
await main();
})().catch((err) => {
const msg = "Unexpected error: " + (err.message || err);
if (p) p.cancel(msg);
else console.error(msg);
process.exit(1);
});

View File

@@ -175,7 +175,7 @@ function inlineCode(value) {
}
function parseEvidenceRef(ref) {
const match = /^facts\.(commands|skills|errors|outputs)\[(\d+)\]$/.exec(String(ref || ""));
const match = /^facts\.(commands|skills|errors|outputs|public_content)\[(\d+)\]$/.exec(String(ref || ""));
if (!match) {
return null;
}
@@ -230,6 +230,20 @@ function evidenceLocation(facts, ref) {
return { kind: parsed.kind, command: item.path, label: item.path };
}
return null;
case "public_content":
if (item.file && Number.isInteger(item.line) && item.line > 0) {
const label = `${item.file}:${item.line}`;
if (item.file === "branch" || item.file === "pull_request_metadata" || String(item.file).startsWith("commit:")) {
return { kind: parsed.kind, label };
}
return {
kind: parsed.kind,
path: item.file,
line: item.line,
label,
};
}
return null;
default:
return null;
}
@@ -845,6 +859,10 @@ async function publishTargetStillCurrent(github, context, core, target, phase =
repo: context.repo.repo,
pull_number: target.pr,
});
if (pr.state !== "open") {
core.notice(`semantic review skipped: PR is no longer open before ${phase}`);
return false;
}
if (pr.head.sha !== target.headSha) {
core.notice(`semantic review skipped: PR head changed before ${phase}`);
return false;

View File

@@ -202,6 +202,100 @@ describe("semantic-review-publish", () => {
assert.equal(selectInlineTarget({ evidence: ["facts.errors[0]"] }, facts, changedLineIndex), null);
});
it("maps public content evidence to changed files but not virtual metadata", () => {
const restrictedScope = "pri" + "vate";
const facts = {
public_content: [
{
rule: "public_content_semantic_candidate",
action: "WARNING",
file: "docs/public-roadmap.md",
line: 4,
source: "file",
},
{
rule: "public_content_semantic_candidate",
action: "WARNING",
file: "pull_request_metadata",
line: 1,
source: "metadata",
},
{
rule: "public_content_automation_branch",
action: "WARNING",
file: "branch",
line: 1,
source: "branch",
},
{
rule: "public_content_change_id_trailer",
action: "REJECT",
file: "commit:1234abc",
line: 3,
source: "commit",
},
],
};
const changedLineIndex = buildChangedLineIndex([{
filename: "docs/public-roadmap.md",
patch: [
"@@ -3,2 +3,3 @@",
" context",
"+Specific " + restrictedScope + " roadmap detail",
].join("\n"),
}]);
assert.deepEqual(
selectInlineTarget({ evidence: ["facts.public_content[0]"] }, facts, changedLineIndex),
{ path: "docs/public-roadmap.md", line: 4 },
);
assert.equal(selectInlineTarget({ evidence: ["facts.public_content[1]"] }, facts, changedLineIndex), null);
assert.equal(selectInlineTarget({ evidence: ["facts.public_content[2]"] }, facts, changedLineIndex), null);
assert.equal(selectInlineTarget({ evidence: ["facts.public_content[3]"] }, facts, changedLineIndex), null);
const markdown = buildSummaryMarkdown({
block_mode: true,
blockers: [{
category: "public_content_leakage",
severity: "major",
review_action: "must_fix",
evidence: ["facts.public_content[1]"],
fingerprint: "public-content-metadata",
message: "PR metadata contains " + restrictedScope + " rollout detail",
suggested_action: "Move " + restrictedScope + " detail to an internal channel.",
}],
warnings: [],
}, facts);
assert.match(markdown, /pull_request_metadata:1/);
const virtualMarkdown = buildSummaryMarkdown({
block_mode: true,
blockers: [
{
category: "public_content_leakage",
severity: "major",
review_action: "must_fix",
evidence: ["facts.public_content[2]"],
fingerprint: "public-content-branch",
message: "Branch name looks automation-owned.",
suggested_action: "Use a maintainer-owned public branch name.",
},
{
category: "public_content_leakage",
severity: "major",
review_action: "must_fix",
evidence: ["facts.public_content[3]"],
fingerprint: "public-content-commit",
message: "Commit trailer contains " + restrictedScope + " review metadata.",
suggested_action: "Remove " + restrictedScope + " review metadata from commits.",
},
],
warnings: [],
}, facts);
assert.match(virtualMarkdown, /branch:1/);
assert.match(virtualMarkdown, /commit:1234abc:3/);
});
it("builds finding markers from stable fingerprints and evidence identity", () => {
const factsA = {
skills: [{
@@ -615,6 +709,35 @@ describe("semantic-review-publish", () => {
});
});
it("skips publishing when the PR closes after verification", async () => {
await withPublishTempDir(async ({ calls }) => {
fs.writeFileSync("decision.json", JSON.stringify({
block_mode: true,
blockers: [],
warnings: [],
}), "utf8");
await publish({
github: fakeGithub(calls, {
currentPullRequest: {
state: "closed",
head: { sha: "0123456789abcdef0123456789abcdef01234567" },
base: {
sha: "fedcba9876543210fedcba9876543210fedcba98",
repo: { id: 123 },
},
},
}),
context: workflowRunContext(),
core: silentCore(calls),
});
assert.equal(calls.checks.length, 0);
assert.equal(calls.comments.length, 0);
assert.match(calls.notices[0], /PR is no longer open before publishing/);
});
});
it("rejects publishing when the PR base repo changed after verification", async () => {
await withPublishTempDir(async ({ calls }) => {
fs.writeFileSync("decision.json", JSON.stringify({
@@ -2223,8 +2346,8 @@ function fakeGithub(calls, options = {}) {
},
},
pulls: {
get: async () => ({
data: Array.isArray(options.currentPullRequests)
get: async () => {
const pull = Array.isArray(options.currentPullRequests)
? options.currentPullRequests[Math.min(pullGetCount++, options.currentPullRequests.length - 1)]
: options.currentPullRequest || {
head: { sha: process.env.SEMANTIC_REVIEW_HEAD_SHA },
@@ -2232,8 +2355,9 @@ function fakeGithub(calls, options = {}) {
sha: process.env.SEMANTIC_REVIEW_BASE_SHA,
repo: { id: 123 },
},
},
}),
};
return { data: { state: "open", ...pull } };
},
listFiles() {},
listReviewComments() {},
createReviewComment: async (args) => {

View File

@@ -229,6 +229,36 @@ function requireSafePath(value, path) {
return file;
}
function requirePublicContentFile(value, path) {
const file = requireString(value, path);
if (file === "branch" || file === "pull_request_metadata" || /^commit:[0-9a-f]{7,40}$/.test(file)) {
return file;
}
if (file.startsWith("commit:")) {
throw new Error(`facts JSON ${path} must be a valid public content location`);
}
requireSafePath(file, path);
if (
file === "" ||
file === "." ||
file.startsWith("./") ||
file.includes("\\") ||
file.includes("\0") ||
file.split("/").includes(".git") ||
/^[A-Za-z][A-Za-z0-9+.-]*:/.test(file)
) {
throw new Error(`facts JSON ${path} must be a repository-relative path`);
}
return file;
}
function requirePositiveLine(value, path) {
requireLine(value, path);
if (value === 0) {
throw new Error(`facts JSON ${path} must be a positive line number`);
}
}
function requireStringArray(value, path, { optional = false } = {}) {
if (value === undefined || value === null) {
if (optional) {
@@ -421,6 +451,20 @@ function verifyFactsJSON(data) {
for (const [i, value] of requireArray(facts, "examples").entries()) {
verifyCommandExample(value, `examples[${i}]`);
}
for (const [i, value] of requireArray(facts, "public_content").entries()) {
const item = requireObject(value, `public_content[${i}]`);
requireString(item.rule, `public_content[${i}].rule`);
const action = requireString(item.action, `public_content[${i}].action`);
if (!VALID_ACTIONS.has(action)) {
throw new Error(`facts JSON public_content[${i}].action is invalid`);
}
requirePublicContentFile(item.file, `public_content[${i}].file`);
requirePositiveLine(item.line, `public_content[${i}].line`);
requireString(item.source, `public_content[${i}].source`, { optional: true });
requireString(item.excerpt, `public_content[${i}].excerpt`, { optional: true });
requireString(item.message, `public_content[${i}].message`, { optional: true });
requireString(item.suggestion, `public_content[${i}].suggestion`, { optional: true });
}
for (const [i, value] of requireArray(facts, "diagnostics").entries()) {
const item = requireObject(value, `diagnostics[${i}]`);
requireString(item.rule, `diagnostics[${i}].rule`);

View File

@@ -67,7 +67,43 @@ describe("verifyZipEntries", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "semantic-review-zip-"));
const zipPath = path.join(dir, "facts.zip");
const outPath = path.join(dir, "facts.json");
const facts = Buffer.from('{"schema_version":1}\n');
const restrictedScope = "pri" + "vate";
const facts = Buffer.from(JSON.stringify({
schema_version: 1,
public_content: [
{
rule: "public_content_semantic_candidate",
action: "WARNING",
file: "pull_request_metadata",
line: 1,
source: "metadata",
excerpt: "public release notes mention an internal rollout plan",
message: "public contribution may contain sensitive implementation detail",
suggestion: "move internal detail to " + restrictedScope + " discussion",
},
{
rule: "public_content_change_id_trailer",
action: "REJECT",
file: "commit:1234abc",
line: 3,
source: "commit",
},
{
rule: "public_content_automation_branch",
action: "WARNING",
file: "branch",
line: 1,
source: "branch",
},
{
rule: "public_content_" + "pri" + "vate_ipv4",
action: "WARNING",
file: "docs/public-network.md",
line: 7,
source: "file",
},
],
}) + "\n");
const zip = makeZip([{ fileName: "facts.json", data: facts, mode: 0o100644 }]);
fs.writeFileSync(zipPath, zip);
@@ -103,6 +139,19 @@ describe("verifyZipEntries", () => {
["bad-error-path", Buffer.from('{"schema_version":1,"errors":[{"file":"../x.go","line":1,"boundary":true,"uses_structured_error":false,"has_hint":false,"hint_action_count":0,"required_hint":true,"retryable":false}]}'), /errors\[0\]\.file/],
["bad-example-dry-run", Buffer.from('{"schema_version":1,"examples":[{"raw":"lark-cli docs +fetch","source_file":"skills/lark-doc/SKILL.md","line":3,"executable":true,"dry_run":{"method":"GET","url":"/open-apis/docx","query":{"page_size":["20",1]}}}]}'), /examples\[0\]\.dry_run\.query\.page_size\[1\]/],
["bad-output-field", Buffer.from(JSON.stringify({ schema_version: 1, outputs: [{ command: "drive files list", fields: ["ok", "x".repeat(9000)] }] })), /outputs\[0\]\.fields\[1\]/],
["non-array-public-content", Buffer.from('{"schema_version":1,"public_content":{}}'), /public_content must be an array/],
["bad-public-content-item", Buffer.from('{"schema_version":1,"public_content":["not-object"]}'), /public_content\[0\]/],
["bad-public-content-action", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"BLOCK","file":"pull_request_metadata","line":1}]}'), /public_content\[0\]\.action/],
["bad-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"../x","line":1}]}'), /public_content\[0\]\.file/],
["dot-slash-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"./foo","line":1}]}'), /public_content\[0\]\.file/],
["empty-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"","line":1}]}'), /public_content\[0\]\.file/],
["dot-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":".","line":1}]}'), /public_content\[0\]\.file/],
["url-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"https://example.invalid/x","line":1}]}'), /public_content\[0\]\.file/],
["dotgit-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":".git/config","line":1}]}'), /public_content\[0\]\.file/],
["windows-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"C:\\\\tmp\\\\x","line":1}]}'), /public_content\[0\]\.file/],
["bad-public-content-commit-ref", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_change_id_trailer","action":"REJECT","file":"commit:notasha","line":1}]}'), /public_content\[0\]\.file/],
["bad-public-content-line", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"pull_request_metadata","line":"1"}]}'), /public_content\[0\]\.line/],
["zero-public-content-line", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"pull_request_metadata","line":0}]}'), /public_content\[0\]\.line/],
["bad-diagnostic-action", Buffer.from('{"schema_version":1,"diagnostics":[{"rule":"r","action":"BLOCK","file":"x.go","line":1,"message":"m"}]}'), /diagnostics.*action/],
["long-message", Buffer.from(JSON.stringify({ schema_version: 1, diagnostics: [{ rule: "r", action: "REJECT", file: "x.go", line: 1, message: "x".repeat(9000) }] })), /too long/],
]) {

View File

@@ -184,6 +184,10 @@ require_in_step "$summary_verify_step" 'eventHeadSha && eventHeadSha.toLowerCase
require_in_step "$summary_verify_step" 'factsArtifactPattern' "PR quality summary should use the base-bound facts artifact name when available"
require_in_step "$summary_verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "PR quality summary must prefer the CI-time artifact base SHA"
require_in_step "$summary_verify_step" 'core.setOutput("artifact_error"' "PR quality summary must expose artifact binding failures"
require_in_step "$summary_verify_step" 'state: "all"' "PR quality summary fallback must inspect closed PRs before failing"
require_in_step "$summary_verify_step" 'candidate.state === "open"' "PR quality summary fallback must still prefer open PRs"
require_in_step "$summary_verify_step" 'workflow_run target PR is no longer open' "PR quality summary must skip stale workflow_run events after PR closure"
require_in_step "$summary_verify_step" 'pr.state !== "open"' "PR quality summary must skip direct workflow_run PR bindings after PR closure"
require_in_step "$summary_artifact_step" 'factsArtifactName' "PR quality summary artifact step must use the verified facts artifact binding"
require_in_step "$summary_extract_facts_step" 'SEMANTIC_REVIEW_DECISION_OUT' "PR quality summary artifact verifier must write an infrastructure decision on verifier failure"
@@ -212,7 +216,12 @@ require_in_step "$verify_step" 'runPRs.length > 1' "semantic-review must fail cl
require_in_step "$verify_step" 'listPullRequestsAssociatedWithCommit' "semantic-review must resolve fork workflow_run PRs when pull_requests is empty"
require_in_step "$verify_step" 'commit_sha: targetHeadSha' "semantic-review fallback must resolve PRs by the workflow_run PR head SHA"
require_in_step "$verify_step" 'github.rest.pulls.list' "semantic-review must have a pull-list fallback when commit association is empty"
require_in_step "$verify_step" 'candidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
require_in_step "$verify_step" 'openCandidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
require_in_step "$verify_step" 'state: "all"' "semantic-review fallback must inspect closed PRs before failing"
require_in_step "$verify_step" 'candidate.state === "open"' "semantic-review fallback must still prefer open PRs"
require_in_step "$verify_step" 'workflow_run target PR is no longer open' "semantic-review must skip stale workflow_run events after PR closure"
require_in_step "$verify_step" 'pr.state !== "open"' "semantic-review must skip direct workflow_run PR bindings after PR closure"
require_in_step "$verify_step" '!pr.head.repo' "semantic-review must skip unavailable PR head repositories before reading owner/repo"
require_in_step "$verify_step" 'pr.head.sha !== targetHeadSha' "semantic-review must skip stale PR heads"
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR base metadata"
require_in_step "$verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "semantic-review must prefer the CI-time artifact base SHA"
@@ -260,6 +269,7 @@ require_in_step "$semantic_step" 'args+=(--waivers-file' "same-repo PR head waiv
require_in_step "$precheckout_step" 'SEMANTIC_REVIEW_BASE_SHA' "pre-checkout failure publisher must receive verified base SHA"
require_in_step "$precheckout_step" 'SEMANTIC_REVIEW_RUN_ID' "pre-checkout failure publisher must receive verified run id"
require_in_step "$precheckout_step" 'github.rest.pulls.get' "pre-checkout failure publisher must recheck PR target before writing"
require_in_step "$precheckout_step" 'pull.state !== "open"' "pre-checkout failure publisher must skip closed PRs before writing"
require_in_step "$precheckout_step" 'pull.head.sha !== headSha' "pre-checkout failure publisher must skip stale PR heads"
require_in_step "$precheckout_step" 'pull.base.sha !== baseSha' "pre-checkout failure publisher must skip stale PR bases"

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"io"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
const (
defaultAppsAnalyticsEnv = "online"
defaultAppsAnalyticsGranular = "day"
analyticsListEndpoint = "query_analytics_data"
)
// AppsAnalyticsList lists online app product analytics.
var AppsAnalyticsList = common.Shortcut{
Service: appsService,
Command: "+analytics-list",
Description: "List online app user and page-view analytics",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +analytics-list --app-id <app_id> --analytics users --granularity week",
"Tip: analytics timestamps use nanoseconds; use +metric-list for request/runtime metrics.",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID whose online analytics should be listed", Required: true},
{Name: appsEnvironmentFlag, Default: defaultAppsAnalyticsEnv, Desc: "observability environment; only online is supported"},
{Name: "analytics", Desc: "analytics family to list", Required: true, Enum: []string{"users", "page-view"}},
{Name: "series", Desc: "analytics series within the family, such as active-users or desktop-view"},
{Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"},
{Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"},
{Name: "page", Desc: "frontend page or route filter"},
{Name: "device-type", Desc: "device type filter", Enum: []string{"desktop", "mobile"}},
{Name: "granularity", Default: defaultAppsAnalyticsGranular, Desc: "analytics aggregation granularity", Enum: []string{"day", "week", "month"}},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
_, _, _, err := buildAnalyticsListBody(rctx)
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
body, _, _, _ := buildAnalyticsListBody(rctx)
return common.NewDryRunAPI().
POST(analyticsListPath(rctx.Str("app-id"))).
Desc("List online app analytics").
Body(body)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, _ := requireAppID(rctx.Str("app-id"))
body, types, labels, err := buildAnalyticsListBody(rctx)
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", analyticsListPath(appID), nil, body)
if err != nil {
return withAppsHint(err, appIDListHint)
}
out := observabilitySeriesOutput{
Items: normalizeAnalyticsSeries(data, types, labels),
HasMore: false,
}
rctx.OutFormat(out, nil, func(w io.Writer) {
rows := observabilitySeriesRows(out.Items)
sortObservabilityRowsDesc(rows, "timestamp_ns")
rows = filterObservabilityRowsWithTime(rows, "timestamp_ns")
appsPrintSchemaTable(w, rows, analyticsSeriesSchema(labels))
})
return nil
},
}
func analyticsListPath(appID string) string {
return appScopedPath(appID, analyticsListEndpoint)
}
func buildAnalyticsListBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, error) {
env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag))
if env == "" {
env = defaultAppsAnalyticsEnv
}
if err := validateObservabilityEnv(env); err != nil {
return nil, nil, nil, err
}
types, labels, filter, err := analyticsTypesForCLI(rctx.Str("analytics"), rctx.Str("series"), rctx.Str("device-type"))
if err != nil {
return nil, nil, nil, err
}
since, until, err := defaultedObservabilityTimeRange(rctx.Str("since"), rctx.Str("until"))
if err != nil {
return nil, nil, nil, err
}
aggregation, err := analyticsGranularityForCLI(rctx.Str("granularity"))
if err != nil {
return nil, nil, nil, err
}
if page := strings.TrimSpace(rctx.Str("page")); page != "" {
filter["page"] = page
}
body := map[string]interface{}{
"metric_types": types,
"start_timestamp_ns": nsNumber(since),
"end_timestamp_ns": nsNumber(until),
"time_aggregation_unit": aggregation,
"need_pack_lack_point": false,
}
if len(filter) > 0 {
body["filter"] = filter
}
return body, types, labels, nil
}
func analyticsTypesForCLI(name, series, deviceType string) ([]string, []string, map[string]interface{}, error) {
name = strings.TrimSpace(strings.ToLower(name))
series = strings.TrimSpace(strings.ToLower(series))
deviceType = strings.TrimSpace(strings.ToLower(deviceType))
filter := make(map[string]interface{})
if deviceType != "" {
switch deviceType {
case "desktop", "mobile":
filter["device_types"] = []string{deviceType}
default:
return nil, nil, nil, appsValidationParamError("--device-type", "--device-type must be desktop or mobile")
}
}
switch name {
case "users":
switch series {
case "":
return []string{"ACTIVE_USER", "NEW_USER", "TOTAL_USER"}, []string{"active-users", "new-users", "total-users"}, filter, nil
case "active", "active-users":
return []string{"ACTIVE_USER"}, []string{"active-users"}, filter, nil
case "new", "new-users":
return []string{"NEW_USER"}, []string{"new-users"}, filter, nil
case "total", "total-users":
return []string{"TOTAL_USER"}, []string{"total-users"}, filter, nil
default:
return nil, nil, nil, appsValidationParamError("--series", "--series for --analytics users must be active, new, or total")
}
case "page-view":
switch series {
case "", "all":
return []string{"PAGE_VIEW"}, []string{"all"}, filter, nil
case "desktop", "desktop-view":
if err := mergeAnalyticsDeviceFilter(filter, "desktop"); err != nil {
return nil, nil, nil, err
}
return []string{"PAGE_VIEW"}, []string{"desktop"}, filter, nil
case "mobile", "mobile-view":
if err := mergeAnalyticsDeviceFilter(filter, "mobile"); err != nil {
return nil, nil, nil, err
}
return []string{"PAGE_VIEW"}, []string{"mobile"}, filter, nil
default:
return nil, nil, nil, appsValidationParamError("--series", "--series for --analytics page-view must be all, desktop, or mobile")
}
default:
return nil, nil, nil, appsValidationParamError("--analytics", "--analytics must be users or page-view")
}
}
func mergeAnalyticsDeviceFilter(filter map[string]interface{}, deviceType string) error {
if existing, ok := filter["device_types"].([]string); ok && len(existing) > 0 && existing[0] != deviceType {
return appsValidationParamError("--device-type", "--device-type conflicts with --series")
}
filter["device_types"] = []string{deviceType}
return nil
}
func analyticsGranularityForCLI(granularity string) (string, error) {
switch strings.TrimSpace(strings.ToLower(granularity)) {
case "", "day":
return "DAY", nil
case "week":
return "WEEK", nil
case "month":
return "MONTH", nil
default:
return "", appsValidationParamError("--granularity", "--granularity must be day, week, or month")
}
}
func normalizeAnalyticsSeries(data map[string]interface{}, names, labels []string) []map[string]interface{} {
items := normalizeObservabilitySeries(data, labels, observabilityNameLabels(names, labels), false, "timestamp_ns")
fillObservabilityZeroesWhenPartiallyPresent(items, labels)
return items
}
func analyticsSeriesSchema(labels []string) appsOutputSchema {
columns := []appsOutputColumn{
{Key: "timestamp_ns", Label: "time", Format: appsFormatNS("2006-01-02 15:04:05")},
}
for _, label := range labels {
columns = append(columns, appsOutputColumn{Key: label})
}
return appsOutputSchema{Columns: columns, Strict: true}
}

View File

@@ -0,0 +1,459 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAppsAnalyticsList_DryRunUsesNanoseconds(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsAnalyticsList, []string{
"+analytics-list", "--app-id", "app_x", "--analytics", "users",
"--since", "2026-06-23T10:00:00Z", "--until", "2026-06-23T10:01:00Z",
"--granularity", "week", "--dry-run", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_analytics_data" {
t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL)
}
body := env.API[0].Body
if _, ok := body["start_timestamp_ns"]; !ok {
t.Fatalf("analytics dry-run missing start_timestamp_ns: %#v", body)
}
if _, ok := body["start_timestamp"]; ok {
t.Fatalf("analytics should not use start_timestamp: %#v", body)
}
if body["time_aggregation_unit"] != "WEEK" {
t.Fatalf("time_aggregation_unit = %v", body["time_aggregation_unit"])
}
if _, ok := body["app_env"]; ok {
t.Fatalf("analytics OpenAPI body should not include app_env: %#v", body)
}
if _, ok := body["analytics_types"]; ok {
t.Fatalf("analytics OpenAPI body should use metric_types, not analytics_types: %#v", body)
}
if body["need_pack_lack_point"] != false {
t.Fatalf("need_pack_lack_point = %#v, want false", body["need_pack_lack_point"])
}
if _, ok := body["group_by"]; ok {
t.Fatalf("group_by is intentionally unsupported for now: %#v", body)
}
if metricTypes, ok := body["metric_types"].([]interface{}); !ok || len(metricTypes) != 3 {
t.Fatalf("metric_types = %#v", body["metric_types"])
}
if body["start_timestamp_ns"] != "1782208800000000000" ||
body["end_timestamp_ns"] != "1782208860000000000" {
t.Fatalf("analytics timestamps = %#v %#v", body["start_timestamp_ns"], body["end_timestamp_ns"])
}
}
func TestAppsAnalyticsList_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) {
for _, tc := range []struct {
name string
args []string
}{
{
name: "series",
args: []string{
"+analytics-list", "--app-id", "app_x", "--analytics", "page-view",
"--series", "desktop", "--page", "/home", "--dry-run", "--as", "user",
},
},
{
name: "device-type",
args: []string{
"+analytics-list", "--app-id", "app_x", "--analytics", "page-view",
"--device-type", "desktop", "--dry-run", "--as", "user",
},
},
} {
t.Run(tc.name, func(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsAnalyticsList, tc.args, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
}
filter := env.API[0].Body["filter"].(map[string]interface{})
deviceTypes := filter["device_types"].([]interface{})
if len(deviceTypes) != 1 || deviceTypes[0] != "desktop" {
t.Fatalf("device_types = %#v", deviceTypes)
}
if tc.name == "series" && filter["page"] != "/home" {
t.Fatalf("filter.page = %#v, want /home", filter["page"])
}
})
}
}
func TestAppsAnalyticsList_DesktopSeriesUsesDesktopValueLabel(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"series": []interface{}{
map[string]interface{}{
"metric_type": "PAGE_VIEW",
"points": []interface{}{
map[string]interface{}{
"timestamp_ns": float64(1782208800000000000),
"value": float64(21),
},
},
},
},
},
},
})
if err := runAppsShortcut(t, AppsAnalyticsList, []string{
"+analytics-list", "--app-id", "app_x", "--analytics", "page-view",
"--series", "desktop", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var env struct {
Data struct {
Items []struct {
Values map[string]interface{} `json:"values"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
if len(env.Data.Items) != 1 {
t.Fatalf("items len = %d", len(env.Data.Items))
}
if env.Data.Items[0].Values["desktop"] != float64(21) {
t.Fatalf("values = %#v, want desktop=21", env.Data.Items[0].Values)
}
if _, ok := env.Data.Items[0].Values["page-view"]; ok {
t.Fatalf("values should not use page-view label: %#v", env.Data.Items[0].Values)
}
}
func TestAppsAnalyticsList_PrettyFormatsTimeFirst(t *testing.T) {
const rawNS = int64(1782208800000000000)
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"series": []interface{}{
map[string]interface{}{
"metric_type": "ACTIVE_USER",
"points": []interface{}{
map[string]interface{}{"timestamp_ns": float64(rawNS), "value": float64(7)},
},
},
},
},
},
})
if err := runAppsShortcut(t, AppsAnalyticsList, []string{
"+analytics-list", "--app-id", "app_x", "--analytics", "users", "--series", "active", "--format", "pretty", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
wantTime := time.Unix(0, rawNS).Local().Format("2006-01-02 15:04:05")
if !strings.HasPrefix(got, "time") {
t.Fatalf("pretty output should start with time column, got:\n%s", got)
}
if !strings.Contains(got, wantTime) {
t.Fatalf("pretty output missing formatted time %q:\n%s", wantTime, got)
}
if strings.Contains(got, "timestamp_ns") || strings.Contains(got, "1782208800000000000") {
t.Fatalf("pretty output should hide raw timestamp_ns, got:\n%s", got)
}
}
func TestAppsAnalyticsList_PrettySkipsRowsWithoutTime(t *testing.T) {
const rawNS = int64(1782208800000000000)
rows := []map[string]interface{}{
{"timestamp_ns": rawNS, "active-users": float64(7)},
{"active-users": float64(0)},
}
sortObservabilityRowsDesc(rows, "timestamp_ns")
rows = filterObservabilityRowsWithTime(rows, "timestamp_ns")
if len(rows) != 1 {
t.Fatalf("rows len = %d, want 1: %#v", len(rows), rows)
}
if rows[0]["timestamp_ns"] != rawNS {
t.Fatalf("remaining row = %#v", rows[0])
}
}
func TestAppsAnalyticsList_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"series": []interface{}{
map[string]interface{}{
"metric_type": "TOTAL_USER",
"points": []interface{}{
map[string]interface{}{"timestamp_ns": float64(1782208800000000000), "value": float64(20)},
},
},
map[string]interface{}{
"metric_type": "ACTIVE_USER",
"points": []interface{}{
map[string]interface{}{"timestamp_ns": float64(1782208800000000000), "value": float64(7)},
},
},
map[string]interface{}{
"metric_type": "NEW_USER",
"points": []interface{}{
map[string]interface{}{"timestamp_ns": float64(1782208800000000000), "value": float64(3)},
},
},
},
},
},
})
if err := runAppsShortcut(t, AppsAnalyticsList, []string{
"+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var env struct {
Data struct {
Items []struct {
Values map[string]interface{} `json:"values"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
if len(env.Data.Items) != 1 {
t.Fatalf("items len = %d", len(env.Data.Items))
}
values := env.Data.Items[0].Values
if values["active-users"] != float64(7) || values["new-users"] != float64(3) || values["total-users"] != float64(20) {
t.Fatalf("values = %#v, want active-users=7 new-users=3 total-users=20", values)
}
}
func TestAppsAnalyticsList_FillsMissingAndNullValuesWhenAnyValuePresent(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"timestamp_ns": "1782208800000000000",
"values": map[string]interface{}{
"total-users": float64(4),
"active-users": nil,
},
},
},
},
},
})
if err := runAppsShortcut(t, AppsAnalyticsList, []string{
"+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var env struct {
Data struct {
Items []struct {
Values map[string]interface{} `json:"values"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
values := env.Data.Items[0].Values
if values["total-users"] != float64(4) || values["active-users"] != float64(0) || values["new-users"] != float64(0) {
t.Fatalf("values = %#v, want total-users=4 active-users=0 new-users=0", values)
}
}
func TestAppsAnalyticsList_DoesNotFillAllNullValues(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"timestamp_ns": "1782208800000000000",
"values": map[string]interface{}{
"total-users": nil,
"active-users": nil,
},
},
},
},
},
})
if err := runAppsShortcut(t, AppsAnalyticsList, []string{
"+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var env struct {
Data struct {
Items []struct {
Values map[string]interface{} `json:"values"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
values := env.Data.Items[0].Values
if values["total-users"] != nil || values["active-users"] != nil {
t.Fatalf("values = %#v, want existing nulls preserved", values)
}
if _, ok := values["new-users"]; ok {
t.Fatalf("values should not fill missing labels when all present values are null: %#v", values)
}
}
func TestAppsAnalyticsList_EmptyResponseOutputsEmptyItemsArray(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
if err := runAppsShortcut(t, AppsAnalyticsList, []string{
"+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user",
}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
var env struct {
Data struct {
Items []map[string]interface{} `json:"items"`
HasMore bool `json:"has_more"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("decode output: %v\n%s", err, stdout.String())
}
if env.Data.Items == nil {
t.Fatalf("items decoded as nil; stdout=%s", stdout.String())
}
if len(env.Data.Items) != 0 || env.Data.HasMore {
t.Fatalf("empty output = items %#v has_more %v", env.Data.Items, env.Data.HasMore)
}
}
func TestAnalyticsTypesMapping(t *testing.T) {
types, labels, filter, err := analyticsTypesForCLI("users", "", "")
if err != nil {
t.Fatal(err)
}
if strings.Join(types, ",") != "ACTIVE_USER,NEW_USER,TOTAL_USER" {
t.Fatalf("types = %#v", types)
}
if strings.Join(labels, ",") != "active-users,new-users,total-users" {
t.Fatalf("labels = %#v", labels)
}
if len(filter) != 0 {
t.Fatalf("filter = %#v, want empty", filter)
}
types, labels, filter, err = analyticsTypesForCLI("page-view", "", "")
if err != nil {
t.Fatal(err)
}
if strings.Join(types, ",") != "PAGE_VIEW" || strings.Join(labels, ",") != "all" {
t.Fatalf("page-view all mapping = %#v %#v", types, labels)
}
if len(filter) != 0 {
t.Fatalf("filter = %#v, want empty", filter)
}
types, labels, filter, err = analyticsTypesForCLI("page-view", "desktop", "")
if err != nil {
t.Fatal(err)
}
if strings.Join(types, ",") != "PAGE_VIEW" || strings.Join(labels, ",") != "desktop" {
t.Fatalf("page-view mapping = %#v %#v", types, labels)
}
deviceTypes := filter["device_types"].([]string)
if len(deviceTypes) != 1 || deviceTypes[0] != "desktop" {
t.Fatalf("device_types = %#v", deviceTypes)
}
types, labels, filter, err = analyticsTypesForCLI("page-view", "mobile-view", "")
if err != nil {
t.Fatal(err)
}
if strings.Join(types, ",") != "PAGE_VIEW" || strings.Join(labels, ",") != "mobile" {
t.Fatalf("page-view mobile mapping = %#v %#v", types, labels)
}
deviceTypes = filter["device_types"].([]string)
if len(deviceTypes) != 1 || deviceTypes[0] != "mobile" {
t.Fatalf("device_types = %#v", deviceTypes)
}
if _, _, _, err := analyticsTypesForCLI("users", "desktop", ""); err == nil {
t.Fatalf("users desktop series should fail")
}
if _, _, _, err := analyticsTypesForCLI("page-view", "tablet", ""); err == nil {
t.Fatalf("page-view tablet series should fail")
}
if _, _, _, err := analyticsTypesForCLI("page-view", "", "tablet"); err == nil {
t.Fatalf("tablet device type should fail")
}
}

View File

@@ -0,0 +1,302 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsDBAuditList 列出数据表的行级审计事件INSERT/UPDATE/DELETE 的变更追溯)。
//
// GET /apps/{app_id}/db/audit_listcursor 分页)。--table 可重复传多张表;--since/--until 多格式时间。
// operator 透传 {id,name}json 还原对象、pretty 取 namebefore/after 是条件出现的 JSON
// INSERT 无 before、DELETE 无 afterjson 还原成对象。
//
// 多表查询时CLI 先用 schema表是否存在+ status审计是否开启在本地过滤把不存在 /
// 未开启审计的表剔除后再查 audit_list被剔除的表及原因放进 skipped服务端不再返该字段
var AppsDBAuditList = common.Shortcut{
Service: appsService,
Command: "+db-audit-list",
Description: "List row-change audit events for one or more tables (cursor pagination)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +db-audit-list --app-id <app_id> --table orders",
"Multiple tables: repeat --table; filter time with --since 7d / --until 2026-04-15.",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Type: "string_slice", Desc: "table(s) to list audit events for (repeatable)", Required: true},
{Name: "since", Desc: "filter: event at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := rejectLegacyEnvFlag(rctx); err != nil {
return err
}
if len(auditListTables(rctx)) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table is required (at least one table)").WithParam("--table")
}
return normalizeTimeFlags(rctx, "since", "until")
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appAuditListPath(appID)).
Desc("List Miaoda app table audit events").
Params(buildAuditListParams(rctx, auditListTables(rctx)))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
requested := auditListTables(rctx)
env := dbEnv(rctx)
// 多表查询CLI 侧先用 schema表是否存在+ status审计是否开启过滤
// 不存在 / 未开启审计的表不进 audit_list 查询,单独在 skipped 里给出原因。
// 单表查询直接打 audit_list由后端就 table-not-found / audit-not-enabled 报错。
queryTables := requested
var skipped []auditSkippedEntry
if len(requested) > 1 {
queryTables, skipped, err = filterAuditTables(rctx, appID, env, requested)
if err != nil {
return withAppsHint(err, dbChangelogHint)
}
// 所有请求表都被过滤掉 → 无可查询表,直接返回空 + skipped 提示,不调 audit_list。
if len(queryTables) == 0 {
out := map[string]interface{}{"items": []auditLogItem{}, "has_more": false, "skipped": skipped}
rctx.OutFormat(out, nil, func(w io.Writer) {
io.WriteString(w, "No audit events found.\n")
writeAuditSkipped(w, skipped, len(requested))
})
return nil
}
}
data, err := rctx.CallAPITyped("GET", appAuditListPath(appID), buildAuditListParams(rctx, queryTables), nil)
if err != nil {
return withAppsHint(err, dbChangelogHint)
}
items := projectAuditLogItems(data["items"])
data["items"] = items
// 服务端不再返 skipped改由 CLI 算出的 skipped 写回输出。
if len(skipped) > 0 {
data["skipped"] = skipped
} else {
delete(data, "skipped")
}
multi := len(requested) > 1
rctx.OutFormat(data, nil, func(w io.Writer) {
renderAuditListPretty(w, items, skipped, len(requested), multi)
})
return nil
},
}
// auditSkippedEntry 是被 CLI 预过滤掉的表及原因(替代已删除的服务端 skipped 字段)。
type auditSkippedEntry struct {
Table string `json:"table"`
Reason string `json:"reason"`
}
// filterAuditTables 用 schema存在性+ status审计开关把请求表分成「可查询」与「跳过」两组。
func filterAuditTables(rctx *common.RuntimeContext, appID, env string, requested []string) ([]string, []auditSkippedEntry, error) {
existing, err := fetchExistingTables(rctx, appID, env)
if err != nil {
return nil, nil, err
}
enabled, err := fetchAuditEnabledTables(rctx, appID, env)
if err != nil {
return nil, nil, err
}
valid := make([]string, 0, len(requested))
var skipped []auditSkippedEntry
for _, t := range requested {
switch {
case !existing[t]:
skipped = append(skipped, auditSkippedEntry{Table: t, Reason: "table not found"})
case !enabled[t]:
skipped = append(skipped, auditSkippedEntry{Table: t, Reason: "audit not enabled"})
default:
valid = append(valid, t)
}
}
return valid, skipped, nil
}
// fetchExistingTables 翻页拉全量表清单返回存在表名集合schema 命令同源接口)。
func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
existing := map[string]bool{}
token := ""
for {
params := map[string]interface{}{"env": env, "page_size": 100}
if token != "" {
params["page_token"] = token
}
data, err := rctx.CallAPITyped("GET", appTablesPath(appID), params, nil)
if err != nil {
return nil, err
}
for _, it := range asMapSlice(data["items"]) {
if name := common.GetString(it, "name"); name != "" {
existing[name] = true
}
}
token = common.GetString(data, "page_token")
if data["has_more"] != true || token == "" {
break
}
}
return existing, nil
}
// fetchAuditEnabledTables 拉审计状态返回当前已开启审计的表名集合status 命令同源接口)。
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil)
if err != nil {
return nil, err
}
enabled := map[string]bool{}
for _, it := range asMapSlice(data["items"]) {
if it["enabled"] == true {
if name := common.GetString(it, "table"); name != "" {
enabled[name] = true
}
}
}
return enabled, nil
}
// asMapSlice 把 interface{}[]interface{})里的每个 map 元素取出,非 map 丢弃。
func asMapSlice(raw interface{}) []map[string]interface{} {
arr, _ := raw.([]interface{})
out := make([]map[string]interface{}, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]interface{}); ok {
out = append(out, m)
}
}
return out
}
// auditListTables 取 --table 切片trim 去空。
func auditListTables(rctx *common.RuntimeContext) []string {
out := make([]string, 0)
for _, t := range rctx.StrSlice("table") {
if v := strings.TrimSpace(t); v != "" {
out = append(out, v)
}
}
return out
}
// buildAuditListParams 组装 audit_list 查询参数env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
params := map[string]interface{}{
"env": dbEnv(rctx),
"tables": strings.Join(tables, ","),
"page_size": rctx.Int("page-size"),
}
addStr := func(flag, key string) {
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
params[key] = v
}
}
addStr("since", "since")
addStr("until", "until")
addStr("page-token", "page_token")
return params
}
type auditLogItem struct {
EventID string `json:"event_id"`
EventTime string `json:"event_time"`
TargetTable string `json:"target_table"`
Type string `json:"type"`
Operator *operatorRef `json:"operator,omitempty"`
Summary string `json:"summary"`
Before interface{} `json:"before,omitempty"`
After interface{} `json:"after,omitempty"`
}
// projectAuditLogItems 把服务端原始审计事件投影为白名单 auditLogItemoperator 解析、before/after 还原成对象)。
func projectAuditLogItems(raw interface{}) []auditLogItem {
arr, _ := raw.([]interface{})
out := make([]auditLogItem, 0, len(arr))
for _, it := range arr {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
row := auditLogItem{
EventID: common.GetString(m, "event_id"),
EventTime: common.GetString(m, "event_time"),
TargetTable: common.GetString(m, "target_table"),
Type: common.GetString(m, "type"),
Operator: parseOperator(common.GetString(m, "operator")),
Summary: common.GetString(m, "summary"),
}
// before/after 条件出现INSERT 无 before、DELETE 无 after。JSON 字符串 → 还原对象。
if b := common.GetString(m, "before"); b != "" {
row.Before = safeParseJSON(b)
}
if a := common.GetString(m, "after"); a != "" {
row.After = safeParseJSON(a)
}
out = append(out, row)
}
return out
}
// renderAuditListPretty 单表 5 列 / 多表 6 列(首列 target_table末尾列出 skipped 表。
func renderAuditListPretty(w io.Writer, items []auditLogItem, skipped []auditSkippedEntry, totalRequested int, multi bool) {
if len(items) == 0 {
io.WriteString(w, "No audit events found.\n")
writeAuditSkipped(w, skipped, totalRequested)
return
}
var headers []string
if multi {
headers = []string{"target_table", "event_time", "type", "event_id", "operator", "summary"}
} else {
headers = []string{"event_time", "type", "event_id", "operator", "summary"}
}
rows := make([][]string, 0, len(items))
for _, it := range items {
cells := []string{dashIfEmpty(it.EventTime), it.Type, it.EventID, operatorName(it.Operator), dashIfEmpty(it.Summary)}
if multi {
cells = append([]string{dashIfEmpty(it.TargetTable)}, cells...)
}
rows = append(rows, cells)
}
renderAlignedTable(w, headers, rows)
writeAuditSkipped(w, skipped, totalRequested)
}
// writeAuditSkipped 打 "— Skipped N of M tables: orders (audit not enabled), foo (table not found)"。
func writeAuditSkipped(w io.Writer, skipped []auditSkippedEntry, totalRequested int) {
if len(skipped) == 0 {
return
}
parts := make([]string, 0, len(skipped))
for _, s := range skipped {
parts = append(parts, fmt.Sprintf("%s (%s)", s.Table, s.Reason))
}
fmt.Fprintf(w, "— Skipped %d of %d tables: %s\n", len(skipped), totalRequested, strings.Join(parts, ", "))
}

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// 审计保留期合法取值。
var auditRetentions = []string{"7d", "30d", "180d", "360d", "forever"}
const dbAuditSetHint = "verify --app-id and --table; check current config with `lark-cli apps +db-audit-status --app-id <app_id>`"
// AppsDBAuditEnable 为某张表开启行级审计(变更追溯)。
//
// POST /apps/{app_id}/db/audit_setbody {table, enabled:true, retention}。--retention 默认 7d。
var AppsDBAuditEnable = common.Shortcut{
Service: appsService,
Command: "+db-audit-enable",
Description: "Enable row-change audit logging for a table",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +db-audit-enable --app-id <app_id> --table orders --retention 30d",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "table to enable audit for", Required: true},
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
return rejectLegacyEnvFlag(rctx)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appAuditSetPath(appID)).
Desc("Enable table audit").
Params(map[string]interface{}{"env": dbEnv(rctx)}).
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
table := strings.TrimSpace(rctx.Str("table"))
retention := rctx.Str("retention")
stop := rctx.StartSpinner("Enabling audit logging for " + table)
defer stop()
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
map[string]interface{}{"env": dbEnv(rctx)},
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
stop()
if err != nil {
return withAppsHint(err, dbAuditSetHint)
}
st := auditSetStatus(data, table)
ret := common.GetString(st, "retention")
if ret == "" {
ret = retention
}
out := map[string]interface{}{"table": common.GetString(st, "table"), "enabled": true, "retention": ret}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Audit enabled for table '%s' (retention: %s)\n", common.GetString(out, "table"), ret)
})
return nil
},
}
// AppsDBAuditDisable 关闭某张表的行级审计。
//
// POST /apps/{app_id}/db/audit_setbody {table, enabled:false}。
var AppsDBAuditDisable = common.Shortcut{
Service: appsService,
Command: "+db-audit-disable",
Description: "Disable row-change audit logging for a table",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +db-audit-disable --app-id <app_id> --table orders",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "table to disable audit for", Required: true},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
return rejectLegacyEnvFlag(rctx)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appAuditSetPath(appID)).
Desc("Disable table audit").
Params(map[string]interface{}{"env": dbEnv(rctx)}).
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
table := strings.TrimSpace(rctx.Str("table"))
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
map[string]interface{}{"env": dbEnv(rctx)},
map[string]interface{}{"table": table, "enabled": false})
if err != nil {
return withAppsHint(err, dbAuditSetHint)
}
st := auditSetStatus(data, table)
out := map[string]interface{}{"table": common.GetString(st, "table"), "enabled": false}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Audit disabled for table '%s'\n", common.GetString(out, "table"))
})
return nil
},
}
// auditSetStatus 取响应里的 status 对象(缺失时用入参 table 兜底)。
func auditSetStatus(data map[string]interface{}, table string) map[string]interface{} {
if st, ok := data["status"].(map[string]interface{}); ok {
if common.GetString(st, "table") == "" {
st["table"] = table
}
return st
}
return map[string]interface{}{"table": table}
}

View File

@@ -0,0 +1,140 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"io"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsDBAuditStatus 查看数据表的审计开关状态(哪些表开了行级审计、保留期)。
//
// GET /apps/{app_id}/db/audit_status。--table 指定单表(无记录时占位 enabled=false
// 不指定返回所有已配置表。json 单表返对象、多表返数组pretty 单表 key/value、多表表格。
var AppsDBAuditStatus = common.Shortcut{
Service: appsService,
Command: "+db-audit-status",
Description: "Show table audit (row-change tracking) status",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +db-audit-status --app-id <app_id>",
"Check one table: --table orders",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
return rejectLegacyEnvFlag(rctx)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appAuditStatusPath(appID)).
Desc("Get table audit status").
Params(buildAuditStatusParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), buildAuditStatusParams(rctx), nil)
if err != nil {
return withAppsHint(err, dbChangelogHint)
}
table := strings.TrimSpace(rctx.Str("table"))
items := projectAuditStatusItems(data["items"])
// 单表查询但后端无记录 → 占位 enabled=false与 miaoda 一致)。
if table != "" && len(items) == 0 {
items = []map[string]interface{}{{"table": table, "enabled": false}}
}
// json单表返对象、多表返数组。
var out interface{}
if table != "" && len(items) == 1 {
out = items[0]
} else {
out = map[string]interface{}{"items": items}
}
rctx.OutFormat(out, nil, func(w io.Writer) {
renderAuditStatusPretty(w, items, table)
})
return nil
},
}
// buildAuditStatusParams 组装 audit_status 查询参数env 及可选 table单表查询
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{"env": dbEnv(rctx)}
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
params["table"] = t
}
return params
}
// projectAuditStatusItems 透出 {table, enabled, enabled_at?, retention?}。
func projectAuditStatusItems(raw interface{}) []map[string]interface{} {
arr, _ := raw.([]interface{})
out := make([]map[string]interface{}, 0, len(arr))
for _, it := range arr {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
row := map[string]interface{}{
"table": common.GetString(m, "table"),
"enabled": m["enabled"] == true,
}
if v := common.GetString(m, "enabled_at"); v != "" {
row["enabled_at"] = v
}
if v := common.GetString(m, "retention"); v != "" {
row["retention"] = v
}
out = append(out, row)
}
return out
}
// renderAuditStatusPretty 单表渲染 key/value、多表渲染对齐表格table/enabled/enabled_at/retention
func renderAuditStatusPretty(w io.Writer, items []map[string]interface{}, table string) {
if len(items) == 0 {
io.WriteString(w, "No audit configuration found.\n")
return
}
yesNo := func(m map[string]interface{}) string {
if m["enabled"] == true {
return "yes"
}
return "no"
}
get := func(m map[string]interface{}, k string) string { return dashIfEmpty(common.GetString(m, k)) }
// 单表 → key/value
if table != "" && len(items) == 1 {
it := items[0]
renderKeyValuePairs(w, [][2]string{
{"table", common.GetString(it, "table")},
{"enabled", yesNo(it)},
{"enabled_at", get(it, "enabled_at")},
{"retention", get(it, "retention")},
})
return
}
// 多表 → 表格
headers := []string{"table", "enabled", "enabled_at", "retention"}
rows := make([][]string, 0, len(items))
for _, it := range items {
rows = append(rows, []string{common.GetString(it, "table"), yesNo(it), get(it, "enabled_at"), get(it, "retention")})
}
renderAlignedTable(w, headers, rows)
}

View File

@@ -0,0 +1,316 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
const (
dbAuditStatusURL = "/open-apis/spark/v1/apps/app_x/db/audit_status"
dbAuditSetURL = "/open-apis/spark/v1/apps/app_x/db/audit_set"
dbAuditListURL = "/open-apis/spark/v1/apps/app_x/db/audit_list"
dbTablesListURL = "/open-apis/spark/v1/apps/app_x/tables"
)
// ── audit-status ──
// TestAppsDBAuditStatus_SingleTableObjectWithPlaceholder 验证单表查询无记录时返回 enabled:false 的占位对象(非数组)。
func TestAppsDBAuditStatus_SingleTableObjectWithPlaceholder(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbAuditStatusURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
})
if err := runAppsShortcut(t, AppsDBAuditStatus,
[]string{"+db-audit-status", "--app-id", "app_x", "--table", "orders", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
// 单表无记录 → 占位对象 enabled:false不是数组
var env struct {
Data struct {
Table string `json:"table"`
Enabled bool `json:"enabled"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
t.Fatalf("decode: %v\n%s", err, stdout.String())
}
if env.Data.Table != "orders" || env.Data.Enabled {
t.Fatalf("expected placeholder {orders,false}, got %+v", env.Data)
}
}
// TestAppsDBAuditStatus_MultiTablePrettyTable 验证多表 pretty 输出含 enabled/yes/no 列与 retention 值。
func TestAppsDBAuditStatus_MultiTablePrettyTable(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbAuditStatusURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{
map[string]interface{}{"table": "orders", "enabled": true, "enabled_at": "2026-04-15T10:30:00Z", "retention": "30d"},
map[string]interface{}{"table": "users", "enabled": false},
}}},
})
if err := runAppsShortcut(t, AppsDBAuditStatus,
[]string{"+db-audit-status", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "enabled") || !strings.Contains(got, "yes") || !strings.Contains(got, "no") || !strings.Contains(got, "30d") {
t.Fatalf("pretty table malformed:\n%s", got)
}
}
// ── audit-enable / disable ──
// TestAppsDBAuditEnable_RequiresTableAndValidRetention 验证缺 --table 报必填错、非法 --retention 报 ValidationError。
func TestAppsDBAuditEnable_RequiresTableAndValidRetention(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
// 缺 --table → cobra required, exit 1
if err := runAppsShortcut(t, AppsDBAuditEnable,
[]string{"+db-audit-enable", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
t.Fatalf("expected required --table error")
}
// 非法 retention → enum 校验 (validation)
factory2, stdout2, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBAuditEnable,
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "99d", "--as", "user"}, factory2, stdout2)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
}
if ve.Param != "--retention" {
t.Fatalf("Param = %q, want --retention", ve.Param)
}
}
// TestAppsDBAuditEnable_DryRunAndSuccess 验证 dry-run 发出 enabled:true+retention 的 POST成功时打印 pretty 确认行。
func TestAppsDBAuditEnable_DryRunAndSuccess(t *testing.T) {
// dry-run body {table, enabled:true, retention}
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBAuditEnable,
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "30d", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != dbAuditSetURL || a.Body["enabled"] != true || a.Body["retention"] != "30d" || a.Body["table"] != "orders" {
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
}
// success
factory2, stdout2, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbAuditSetURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": map[string]interface{}{"table": "orders", "enabled": true, "retention": "30d"}}},
})
if err := runAppsShortcut(t, AppsDBAuditEnable,
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "30d", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout2.String(), "✓ Audit enabled for table 'orders' (retention: 30d)") {
t.Fatalf("pretty: %s", stdout2.String())
}
}
// TestAppsDBAuditDisable_DryRunAndSuccess 验证 dry-run 发出 enabled:false 的 POST成功时打印 pretty 确认行。
func TestAppsDBAuditDisable_DryRunAndSuccess(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBAuditDisable,
[]string{"+db-audit-disable", "--app-id", "app_x", "--table", "orders", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
if env.API[0].Body["enabled"] != false || env.API[0].Body["table"] != "orders" {
t.Fatalf("dry-run body=%v (want enabled:false)", env.API[0].Body)
}
factory2, stdout2, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbAuditSetURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": map[string]interface{}{"table": "orders", "enabled": false}}},
})
if err := runAppsShortcut(t, AppsDBAuditDisable,
[]string{"+db-audit-disable", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout2.String(), "✓ Audit disabled for table 'orders'") {
t.Fatalf("pretty: %s", stdout2.String())
}
}
// ── audit-list ──
// TestAppsDBAuditList_RequiresTable 验证缺 --table 时报必填错误。
func TestAppsDBAuditList_RequiresTable(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBAuditList,
[]string{"+db-audit-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
t.Fatalf("expected required --table error")
}
}
// TestAppsDBAuditList_DryRunJoinsTables 验证 dry-run 将多个 --table 合并为 tables=orders,users 且归一化 since。
func TestAppsDBAuditList_DryRunJoinsTables(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBAuditList,
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--table", "users", "--since", "7d", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "GET" || a.URL != dbAuditListURL || a.Params["tables"] != "orders,users" {
t.Fatalf("dry-run = %s %s tables=%v", a.Method, a.URL, a.Params["tables"])
}
if s, _ := a.Params["since"].(string); !strings.HasSuffix(s, "Z") {
t.Fatalf("since not normalized: %v", a.Params["since"])
}
}
// 单表查询:不预过滤、直接打 audit_list后端就 not-found/not-enabled 报错),无 skipped。
// TestAppsDBAuditList_SingleTableNoPreflight 验证单表查询不预过滤、operator/before/after 还原为对象、无 skipped。
func TestAppsDBAuditList_SingleTableNoPreflight(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbAuditListURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"has_more": false, "page_token": "",
"items": []interface{}{map[string]interface{}{
"event_id": "01525", "event_time": "2026-04-16T10:30:00Z", "target_table": "users",
"type": "UPDATE", "operator": `{"id":"7311","name":"alice"}`, "summary": "UPDATE 1 field",
"before": `{"amount":100}`, "after": `{"amount":999}`,
}},
}},
})
if err := runAppsShortcut(t, AppsDBAuditList,
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "users", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
// operator → 对象before/after → 还原成对象(非字符串)。
for _, want := range []string{`"name": "alice"`, `"before"`, `"amount": 100`, `"after"`, `"amount": 999`} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
if strings.Contains(got, `"skipped"`) {
t.Errorf("single-table query must not emit skipped:\n%s", got)
}
if strings.Contains(got, `"before": "{`) {
t.Errorf("before should be an object, not a JSON string:\n%s", got)
}
}
// TestAppsDBAuditList_SingleTableEmptyPretty 验证单表无事件时不报错、pretty 打印 "No audit events found." 且无 Skipped。
func TestAppsDBAuditList_SingleTableEmptyPretty(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbAuditListURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
})
if err := runAppsShortcut(t, AppsDBAuditList,
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("empty audit list should NOT error (ok read), got %v", err)
}
got := stdout.String()
if !strings.Contains(got, "No audit events found.") || strings.Contains(got, "Skipped") {
t.Fatalf("expected empty, no skipped for single table:\n%s", got)
}
}
// 多表查询CLI 用 schema存在性+ status审计开关预过滤只把有效表传给 audit_list
// 不存在 / 未开启审计的表进 skipped。
// TestAppsDBAuditList_MultiTablePreflightFilters 验证多表查询用 schema+status 预过滤,仅传有效表,不存在/未开审计的表进 skipped。
func TestAppsDBAuditList_MultiTablePreflightFilters(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
// schemaorders/users/carts 存在ghost 不存在。
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbTablesListURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
map[string]interface{}{"name": "orders"}, map[string]interface{}{"name": "users"}, map[string]interface{}{"name": "carts"},
}}},
})
// statusorders/users 开启审计carts 未开启。
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbAuditStatusURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{
map[string]interface{}{"table": "orders", "enabled": true}, map[string]interface{}{"table": "users", "enabled": true},
map[string]interface{}{"table": "carts", "enabled": false},
}}},
})
// audit_list 只应被传入有效表 orders,users。
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbAuditListURL,
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("tables"); got != "orders,users" {
t.Errorf("audit_list tables = %q, want orders,users (filtered)", got)
}
},
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
map[string]interface{}{"event_id": "e1", "event_time": "2026-04-16T10:30:00Z", "target_table": "orders", "type": "INSERT", "summary": "INSERT"},
}}},
})
if err := runAppsShortcut(t, AppsDBAuditList,
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--table", "users", "--table", "carts", "--table", "ghost", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
// skippedcarts(audit not enabled) + ghost(table not found),结构化 {table,reason}。
for _, want := range []string{`"skipped"`, `"table": "carts"`, `"reason": "audit not enabled"`, `"table": "ghost"`, `"reason": "table not found"`} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
}
// 多表查询且全部被过滤掉 → 不调 audit_list直接空 + skipped 提示。
// TestAppsDBAuditList_MultiTableAllFilteredSkipsQuery 验证多表全部被过滤时跳过 audit_list 调用,直接输出空结果加 Skipped 提示。
func TestAppsDBAuditList_MultiTableAllFilteredSkipsQuery(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbTablesListURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
map[string]interface{}{"name": "orders"},
}}},
})
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbAuditStatusURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
})
// 不注册 audit_list若被调用会命中未注册请求而报错。
if err := runAppsShortcut(t, AppsDBAuditList,
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "ghost1", "--table", "ghost2", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("all-filtered should still succeed (empty), got %v", err)
}
got := stdout.String()
if !strings.Contains(got, "No audit events found.") || !strings.Contains(got, "Skipped 2 of 2 tables") {
t.Fatalf("expected empty + 'Skipped 2 of 2 tables':\n%s", got)
}
}

View File

@@ -0,0 +1,152 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
const dbChangelogHint = "verify --app-id is correct; if targeting --environment dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --environment dev`"
// AppsDBChangelogList 列出应用数据库的 DDL 变更记录(建表/改表/索引等结构变更追溯)。
//
// GET /apps/{app_id}/db/changelog_listcursor 分页)。过滤:--table、--since/--until多格式时间
// --change-id 精确查单条命中返单条、否则空。operator 后端以 JSON 字符串透传 {id,name}
// json 还原成对象、pretty 只展示 name。
var AppsDBChangelogList = common.Shortcut{
Service: appsService,
Command: "+db-changelog-list",
Description: "List a Miaoda app database's DDL change history (cursor pagination)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +db-changelog-list --app-id <app_id>",
"Pin a single change with --change-id; filter time with --since 7d / --until 2026-04-15.",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "filter by target table"},
{Name: "change-id", Desc: "look up a single change by id (returns that one record only)"},
{Name: "since", Desc: "filter: changed at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := rejectLegacyEnvFlag(rctx); err != nil {
return err
}
return normalizeTimeFlags(rctx, "since", "until")
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appChangelogListPath(appID)).
Desc("List Miaoda app DDL changelog").
Params(buildChangelogParams(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("GET", appChangelogListPath(appID), buildChangelogParams(rctx), nil)
if err != nil {
return withAppsHint(err, dbChangelogHint)
}
items := projectChangelogItems(data["items"])
data["items"] = items
changeID := strings.TrimSpace(rctx.Str("change-id"))
rctx.OutFormat(data, nil, func(w io.Writer) {
renderChangelogPretty(w, items, changeID)
})
return nil
},
}
// buildChangelogParams 组装 changelog_list 查询参数env / page_size 及可选 table/change_id/since/until/page_token。
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{
"env": dbEnv(rctx),
"page_size": rctx.Int("page-size"),
}
addStr := func(flag, key string) {
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
params[key] = v
}
}
addStr("table", "table")
addStr("change-id", "change_id")
addStr("since", "since")
addStr("until", "until")
addStr("page-token", "page_token")
return params
}
type changelogItem struct {
ChangeID string `json:"change_id"`
ChangedAt string `json:"changed_at"`
Operator *operatorRef `json:"operator,omitempty"`
TargetTable string `json:"target_table"`
ChangeType string `json:"change_type"`
Summary string `json:"summary"`
Statement string `json:"statement,omitempty"`
}
// projectChangelogItems 把服务端原始 DDL 变更记录投影为白名单 changelogItemoperator 解析成对象)。
func projectChangelogItems(raw interface{}) []changelogItem {
arr, _ := raw.([]interface{})
out := make([]changelogItem, 0, len(arr))
for _, it := range arr {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
out = append(out, changelogItem{
ChangeID: common.GetString(m, "change_id"),
ChangedAt: common.GetString(m, "changed_at"),
Operator: parseOperator(common.GetString(m, "operator")),
TargetTable: common.GetString(m, "target_table"),
ChangeType: common.GetString(m, "change_type"),
Summary: common.GetString(m, "summary"),
Statement: common.GetString(m, "statement"),
})
}
return out
}
// renderChangelogPretty 6 列change_id / changed_at / operator(name) / target_table / change_type / summary。
func renderChangelogPretty(w io.Writer, items []changelogItem, changeID string) {
if len(items) == 0 {
if changeID != "" {
fmt.Fprintf(w, "No DDL change with id=%s found.\n", changeID)
} else {
io.WriteString(w, "No DDL changes found.\n")
}
return
}
headers := []string{"change_id", "changed_at", "operator", "target_table", "change_type", "summary"}
rows := make([][]string, 0, len(items))
for _, it := range items {
rows = append(rows, []string{
it.ChangeID,
dashIfEmpty(it.ChangedAt),
operatorName(it.Operator),
dashIfEmpty(it.TargetTable),
it.ChangeType,
dashIfEmpty(it.Summary),
})
}
renderAlignedTable(w, headers, rows)
}

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
const dbChangelogURL = "/open-apis/spark/v1/apps/app_x/db/changelog_list"
// TestAppsDBChangelogList_RequiresAppID 验证空白 --app-id 报 --app-id 的 ValidationError。
func TestAppsDBChangelogList_RequiresAppID(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBChangelogList,
[]string{"+db-changelog-list", "--app-id", " ", "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
}
if ve.Param != "--app-id" {
t.Fatalf("Param = %q, want --app-id", ve.Param)
}
}
// TestAppsDBChangelogList_DryRunFiltersAndTimeNormalize 验证 dry-run 透传 env/table/change_id 过滤参数并将 since 归一化为 RFC3339 UTC。
func TestAppsDBChangelogList_DryRunFiltersAndTimeNormalize(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBChangelogList,
[]string{"+db-changelog-list", "--app-id", "app_x", "--environment", "dev", "--table", "orders",
"--change-id", "01J", "--since", "2026-01-01", "--page-size", "5", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "GET" || a.URL != dbChangelogURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if a.Params["env"] != "dev" || a.Params["table"] != "orders" || a.Params["change_id"] != "01J" {
t.Fatalf("params = %v", a.Params)
}
if s, _ := a.Params["since"].(string); !strings.HasSuffix(s, "Z") {
t.Fatalf("since not normalized to RFC3339 UTC: %v", a.Params["since"])
}
}
// TestAppsDBChangelogList_RejectsBadSince 验证不可解析的 --since 报 --since 的 ValidationError。
func TestAppsDBChangelogList_RejectsBadSince(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBChangelogList,
[]string{"+db-changelog-list", "--app-id", "app_x", "--since", "notatime", "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
}
if ve.Param != "--since" {
t.Fatalf("Param = %q, want --since", ve.Param)
}
}
// TestAppsDBChangelogList_SuccessParsesOperator 验证成功响应中 operator JSON 串被解析为对象并输出变更字段。
func TestAppsDBChangelogList_SuccessParsesOperator(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbChangelogURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"has_more": false, "page_token": "",
"items": []interface{}{map[string]interface{}{
"change_id": "01J", "changed_at": "2026-04-15T10:30:00Z",
"operator": `{"id":"7311","name":"alice"}`, "target_table": "orders",
"change_type": "ALTER_TABLE", "summary": "add column", "statement": "ALTER TABLE orders ...",
}},
}},
})
if err := runAppsShortcut(t, AppsDBChangelogList,
[]string{"+db-changelog-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, want := range []string{`"operator"`, `"name": "alice"`, `"id": "7311"`, `"change_type": "ALTER_TABLE"`, `"statement"`} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
}
// TestAppsDBChangelogList_ChangeIDNotFoundPretty 验证按 --change-id 查询无结果时 pretty 打印 not-found 提示。
func TestAppsDBChangelogList_ChangeIDNotFoundPretty(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbChangelogURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
})
if err := runAppsShortcut(t, AppsDBChangelogList,
[]string{"+db-changelog-list", "--app-id", "app_x", "--change-id", "nope", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "No DDL change with id=nope found.") {
t.Fatalf("expected not-found message, got: %s", stdout.String())
}
}
// TestParseOperator_Cases 验证 parseOperator 处理合法 JSON、空 name 回退 id、非 JSON 原样、空串返回 nil以及 operatorName(nil) 为占位符。
func TestParseOperator_Cases(t *testing.T) {
if op := parseOperator(`{"id":"1","name":"a"}`); op == nil || op.ID != "1" || op.Name != "a" {
t.Fatalf("valid: %#v", op)
}
if op := parseOperator(`{"id":"1","name":""}`); op == nil || op.Name != "1" {
t.Fatalf("name fallback to id: %#v", op)
}
if op := parseOperator("plain-user"); op == nil || op.ID != "plain-user" || op.Name != "plain-user" {
t.Fatalf("non-json raw: %#v", op)
}
if op := parseOperator(""); op != nil {
t.Fatalf("empty → nil, got %#v", op)
}
if operatorName(nil) != "—" {
t.Fatalf("nil operatorName should be —")
}
}
// TestSafeParseJSON_Cases 验证 safeParseJSON 合法 JSON 解析为对象、非法 JSON 原样返回字符串。
func TestSafeParseJSON_Cases(t *testing.T) {
if v := safeParseJSON(`{"a":1}`); v == nil {
t.Fatalf("valid json → object")
}
if v, ok := safeParseJSON("not json").(string); !ok || v != "not json" {
t.Fatalf("invalid json → raw string, got %v", v)
}
}

View File

@@ -0,0 +1,194 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"path/filepath"
"strconv"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/shortcuts/common"
)
const dbDataExportMaxRows = 5000
const dbDataExportMaxBytes = 1 * 1024 * 1024 // 1 MB
const dbDataExportHint = "verify --app-id and --table; if too large, filter rows with +db-execute (WHERE/LIMIT) and export smaller subsets"
// AppsDBDataExport 把应用数据表导出到本地文件csv/json/sql
//
// GET /apps/{app_id}/db/data_export返回原始字节非 JSON 信封)。
// 行数不随导出文件返回CLI 原子编排——先查 GetAppTableRecordList 的 total再导出文件。
// 数据格式由 --output 扩展名推断(默认 csv缺省输出 <table>.csv上限 5000 行 / 1 MB。
var AppsDBDataExport = common.Shortcut{
Service: appsService,
Command: "+db-data-export",
Description: "Export rows from a Miaoda app table to a local file (csv/json/sql)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +db-data-export --app-id <app_id> --table orders --output ./orders.csv",
"Format follows the --output extension: .csv / .json / .sql (default csv).",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "source table", Required: true},
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := rejectLegacyEnvFlag(rctx); err != nil {
return err
}
if strings.TrimSpace(rctx.Str("table")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table is required").WithParam("--table")
}
if n := rctx.Int("limit"); n <= 0 || n > dbDataExportMaxRows {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--limit must be a positive integer ≤ %d", dbDataExportMaxRows).WithParam("--limit")
}
if err := rejectOutputTraversal(rctx.Str("output")); err != nil {
return err
}
if _, _, err := exportFormatAndOutput(rctx); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
format, _, _ := exportFormatAndOutput(rctx)
return common.NewDryRunAPI().
GET(appDataExportPath(appID)).
Desc("Export Miaoda app table data (raw bytes)").
Params(map[string]interface{}{
"env": dbEnv(rctx), "table": strings.TrimSpace(rctx.Str("table")),
"format": format, "limit": rctx.Int("limit"),
})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
table := strings.TrimSpace(rctx.Str("table"))
format, out, err := exportFormatAndOutput(rctx)
if err != nil {
return err
}
// 原子编排第 1 步先查总行数records 列表的 total再导出文件。
// total 查询失败不阻断导出——回退到按导出文件内容数行。
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
resp, err := rctx.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: appDataExportPath(appID),
QueryParams: larkcore.QueryParams{
"env": []string{dbEnv(rctx)},
"table": []string{table},
"format": []string{format},
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
},
})
if err != nil {
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
}
// 成功是原始字节;业务错误网关以 JSON 信封 {code,msg} 返回(以 '{' 开头)。
if b := bytes.TrimSpace(resp.RawBody); len(b) > 0 && b[0] == '{' {
if _, cerr := rctx.ClassifyAPIResponse(resp); cerr != nil {
return withAppsHint(cerr, dbDataExportHint)
}
}
if resp.StatusCode >= 400 {
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkServer, "export failed: HTTP %d", resp.StatusCode).WithRetryable(), dbDataExportHint)
}
body := resp.RawBody
if len(body) > dbDataExportMaxBytes {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "export exceeds 1 MB limit (%d bytes); filter rows with +db-execute (WHERE/LIMIT) and export smaller subsets", len(body))
}
saved, err := rctx.FileIO().Save(out, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: int64(len(body)),
}, bytes.NewReader(body))
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output: %v", err).WithParam("--output")
}
// 行数取自预查的 total导出最多 limit 行,故取 mintotal 查询失败时按导出内容数行兜底。
rows := 0
if totalErr == nil {
rows = total
if lim := rctx.Int("limit"); rows > lim {
rows = lim
}
} else {
rows = countDataRows(body, format)
}
resolved, perr := rctx.FileIO().ResolvePath(out)
if perr != nil || resolved == "" {
resolved = out
}
result := map[string]interface{}{
"table": table, "output": resolved, "format": format,
"rows": rows, "size_bytes": saved.Size(),
}
rctx.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Exported %s → %s (%d rows)\n", table, resolved, rows)
})
return nil
},
}
// queryExportTotal 调 GetAppTableRecordListpage_size=1取 total符合条件的记录总数
// 该接口与 +db-data-export 同为 spark:app:read scope避免导出命令被迫升级到写权限。
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table),
map[string]interface{}{"env": env, "page_size": 1}, nil)
if err != nil {
return 0, err
}
return totalAsInt(raw["total"]), nil
}
// totalAsInt 把 total 解析成 int兼容 JSON number 与 i64-as-string 两种 wire 形态。
func totalAsInt(v interface{}) int {
if f, ok := numericAsFloat(v); ok {
return int(f)
}
if s, ok := v.(string); ok {
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
return n
}
}
return 0
}
// exportFormatAndOutput 由 --output 推断数据格式与落盘路径:
// 给了 --output → 取其扩展名定 formatcsv/json/sql未给 → 默认 csv、输出 <table>.csv。
func exportFormatAndOutput(rctx *common.RuntimeContext) (format, outPath string, err error) {
table := strings.TrimSpace(rctx.Str("table"))
out := strings.TrimSpace(rctx.Str("output"))
if out == "" {
return "csv", table + ".csv", nil
}
f, ferr := resolveDataFormat(filepath.Ext(out), true)
if ferr != nil {
return "", "", ferr
}
return f, out, nil
}

View File

@@ -0,0 +1,193 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
const dbDataExportURL = "/open-apis/spark/v1/apps/app_x/db/data_export"
const dbOrdersRecordsURL = "/open-apis/spark/v1/apps/app_x/tables/orders/records"
// TestAppsDBDataExport_RequiresTable 验证缺 --table 时报必填错误。
func TestAppsDBDataExport_RequiresTable(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
// 缺 --table → cobra required-flag, exit 1
err := runAppsShortcut(t, AppsDBDataExport,
[]string{"+db-data-export", "--app-id", "app_x", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatalf("expected required-flag error for missing --table")
}
}
// TestAppsDBDataExport_RejectsBadLimit 验证越界 --limit0/-1/5001均报 --limit 的 ValidationError。
func TestAppsDBDataExport_RejectsBadLimit(t *testing.T) {
for _, lim := range []string{"0", "-1", "5001"} {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBDataExport,
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--limit", lim, "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("limit=%s err = %T %v, want *errs.ValidationError", lim, err, err)
}
if ve.Param != "--limit" {
t.Fatalf("limit=%s Param = %q, want --limit", lim, ve.Param)
}
}
}
// TestAppsDBDataExport_RejectsBadOutputExtension 验证不支持的 --output 扩展名(.xml报校验错误。
func TestAppsDBDataExport_RejectsBadOutputExtension(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBDataExport,
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "dump.xml", "--as", "user"}, factory, stdout)
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected unsupported-format validation for .xml, got %v", err)
}
}
// dry-runformat 跟随 --output 扩展名;缺省 csv。
// TestAppsDBDataExport_DryRunFormatFromOutput 验证 dry-run 的 format 参数跟随 --output 扩展名、缺省为 csv并带 limit。
func TestAppsDBDataExport_DryRunFormatFromOutput(t *testing.T) {
cases := []struct{ output, wantFmt string }{
{"", "csv"}, {"orders.csv", "csv"}, {"orders.json", "json"}, {"dump.sql", "sql"},
}
for _, c := range cases {
factory, stdout, _ := newAppsExecuteFactory(t)
args := []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--dry-run", "--as", "user"}
if c.output != "" {
args = append(args, "--output", c.output)
}
if err := runAppsShortcut(t, AppsDBDataExport, args, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "GET" || a.URL != dbDataExportURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if a.Params["format"] != c.wantFmt || a.Params["table"] != "orders" {
t.Errorf("output=%q params.format=%v want %q", c.output, a.Params["format"], c.wantFmt)
}
if _, ok := a.Params["limit"]; !ok {
t.Errorf("dry-run missing limit param")
}
}
}
// 成功:先查 records 列表 total 计行,再把原始字节落盘。
// TestAppsDBDataExport_SuccessWritesFile 验证成功路径先查 records total 计行、再将导出原始字节落盘并输出 rows/format/table。
func TestAppsDBDataExport_SuccessWritesFile(t *testing.T) {
dir := chdirTemp(t)
factory, stdout, reg := newAppsExecuteFactory(t)
// 第 1 步records 列表 total=2行数来源
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbOrdersRecordsURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"total": 2, "has_more": false, "items": "[]"}},
})
// 第 2 步:导出原始字节。
reg.Register(&httpmock.Stub{
Method: "GET",
URL: dbDataExportURL,
RawBody: []byte("id,name\n1,a\n2,b\n"),
Headers: http.Header{"Content-Type": []string{"text/csv"}},
})
if err := runAppsShortcut(t, AppsDBDataExport,
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
b, err := os.ReadFile(dir + "/orders.csv")
if err != nil || string(b) != "id,name\n1,a\n2,b\n" {
t.Fatalf("output file wrong: %q err=%v", string(b), err)
}
got := stdout.String()
if !strings.Contains(got, `"rows": 2`) || !strings.Contains(got, `"format": "csv"`) || !strings.Contains(got, `"table": "orders"`) {
t.Fatalf("output json missing fields:\n%s", got)
}
}
// 行数取自 records total且按 --limit 截顶min(total, limit))。
// TestAppsDBDataExport_RowsFromTotalCappedByLimit 验证行数取 records total 并按 --limit 截顶total=10000、limit=100 → rows=100
func TestAppsDBDataExport_RowsFromTotalCappedByLimit(t *testing.T) {
chdirTemp(t)
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbOrdersRecordsURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"total": 10000, "has_more": true, "items": "[]"}},
})
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbDataExportURL,
RawBody: []byte("id\n1\n2\n3\n"), Headers: http.Header{"Content-Type": []string{"text/csv"}},
})
if err := runAppsShortcut(t, AppsDBDataExport,
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--limit", "100", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), `"rows": 100`) {
t.Fatalf("expected rows capped to limit 100 from total=10000:\n%s", stdout.String())
}
}
// total 查询失败records 列表报错)→ 回退按导出文件内容数行,不阻断导出。
// TestAppsDBDataExport_FallsBackToFileCountWhenTotalUnavailable 验证 records total 查询失败时回退按导出文件内容数行,不阻断落盘。
func TestAppsDBDataExport_FallsBackToFileCountWhenTotalUnavailable(t *testing.T) {
dir := chdirTemp(t)
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbOrdersRecordsURL,
Body: map[string]interface{}{"code": 1254000, "msg": "records unavailable"},
})
reg.Register(&httpmock.Stub{
Method: "GET", URL: dbDataExportURL,
RawBody: []byte("id,name\n1,a\n2,b\n3,c\n"), Headers: http.Header{"Content-Type": []string{"text/csv"}},
})
if err := runAppsShortcut(t, AppsDBDataExport,
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("export should still succeed via fallback, got %v", err)
}
b, _ := os.ReadFile(dir + "/orders.csv")
if string(b) != "id,name\n1,a\n2,b\n3,c\n" {
t.Fatalf("file not written on fallback path: %q", string(b))
}
if !strings.Contains(stdout.String(), `"rows": 3`) {
t.Fatalf("expected fallback file-count rows:3:\n%s", stdout.String())
}
}
// 业务错误:网关回 JSON 信封 {code,msg}(非原始字节)→ typed error不落盘。
// TestAppsDBDataExport_BusinessErrorEnvelope 验证响应为 JSON 错误信封(非原始字节)时返回 typed error 且不落盘。
func TestAppsDBDataExport_BusinessErrorEnvelope(t *testing.T) {
chdirTemp(t)
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: dbDataExportURL,
RawBody: []byte(`{"code":1254043,"msg":"table not found"}`),
Headers: http.Header{"Content-Type": []string{"application/json"}},
})
err := runAppsShortcut(t, AppsDBDataExport,
[]string{"+db-data-export", "--app-id", "app_x", "--table", "nope", "--output", "nope.csv", "--as", "user"}, factory, stdout)
if err == nil {
t.Fatalf("expected business error to surface, got nil; stdout=%s", stdout.String())
}
if _, statErr := os.Stat("nope.csv"); statErr == nil {
t.Fatalf("error path must not write the output file")
}
}

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
const dbDataImportMaxBytes = 1 * 1024 * 1024 // 1 MB
const dbDataImportHint = "verify --app-id and --table; data file must be .csv/.json and ≤1 MB — split larger files and import in batches"
// AppsDBDataImport 把本地 csv/json 文件直传到应用数据表high-risk-write
//
// POST /apps/{app_id}/db/data_importmultipart 表单file_name + 可选 table + 文件本体(与
// +file-upload / UploadFileForOpenAPI 一致)。文件的格式解析与转换在服务端 integration 层完成
// (按 file_name 扩展名推断 csv/jsonCLI 不再本地解析。表名缺省取文件名(去扩展名)。上限 1 MB。
var AppsDBDataImport = common.Shortcut{
Service: appsService,
Command: "+db-data-import",
Description: "Import rows from a local csv/json file into a Miaoda app table",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +db-data-import --app-id <app_id> --file ./orders.csv --yes",
"Table defaults to the file name; override with --table.",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
{Name: "table", Desc: "target table (default: file name without extension)"},
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := rejectLegacyEnvFlag(rctx); err != nil {
return err
}
if strings.TrimSpace(rctx.Str("file")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
}
// 文件名即可校验格式(服务端按扩展名推断)与推断表名,无需读取内容。
if _, err := resolveDataFormat(filepath.Ext(rctx.Str("file")), false); err != nil {
return err
}
// 体积守卫前移到 Validate用 Stat 先查大小不读内容dry-run 也能拦超大文件、且
// 在读整个文件进内存之前就失败(对齐 +file-upload。Stat 失败不在此报错,留给 Execute
// 的 ReadInputFile 产出更精确的「文件不存在/越界」错误。
if st, serr := rctx.FileIO().Stat(strings.TrimSpace(rctx.Str("file"))); serr == nil && st.Size() > dbDataImportMaxBytes {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "import data exceeds 1 MB limit (file is %d bytes); split into ≤1 MB chunks", st.Size()).WithParam("--file")
}
if importTableName(rctx) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot infer target table from file name; specify --table").WithParam("--table")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
fileName := filepath.Base(strings.TrimSpace(rctx.Str("file")))
return common.NewDryRunAPI().
POST(appDataImportPath(appID)).
Desc("Import data file into Miaoda app table (multipart upload)").
Params(map[string]interface{}{"env": dbEnv(rctx), "table": importTableName(rctx)}).
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
file := strings.TrimSpace(rctx.Str("file"))
content, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file")
}
if len(content) > dbDataImportMaxBytes {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "import data exceeds 1 MB limit (file is %d bytes); split into ≤1 MB chunks", len(content)).WithParam("--file")
}
fileName := filepath.Base(file)
table := importTableName(rctx)
// multipartfile_name 走表单字段、文件本体走 form-filesenv / table 走 query。
fd := larkcore.NewFormdata()
fd.AddField("file_name", fileName)
fd.AddFile("file", bytes.NewReader(content))
resp, err := rctx.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: appDataImportPath(appID),
QueryParams: larkcore.QueryParams{"env": []string{dbEnv(rctx)}, "table": []string{table}},
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "import request failed").WithCause(err).WithRetryable(), dbDataImportHint)
}
data, err := rctx.ClassifyAPIResponse(resp)
if err != nil {
return withAppsHint(err, dbDataImportHint)
}
outTable := common.GetString(data, "table")
if outTable == "" {
outTable = table
}
rows := int64(0)
if f, ok := numericAsFloat(data["rows"]); ok {
rows = int64(f)
}
out := map[string]interface{}{"file": file, "table": outTable, "rows": rows}
rctx.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "✓ Imported %s → table '%s' (%d rows)\n", file, outTable, rows)
})
return nil
},
}
// importTableName 取目标表名:--table 优先,否则文件名去扩展名。
func importTableName(rctx *common.RuntimeContext) string {
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
return t
}
f := strings.TrimSpace(rctx.Str("file"))
if f == "" {
return ""
}
base := filepath.Base(f)
return strings.TrimSuffix(base, filepath.Ext(base))
}

View File

@@ -0,0 +1,161 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"errors"
"os"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
const dbDataImportURL = "/open-apis/spark/v1/apps/app_x/db/data_import"
// chdirTemp 切到临时工作目录(--file 走 cwd 内相对路径),返回该目录。
func chdirTemp(t *testing.T) string {
t.Helper()
dir := t.TempDir()
old, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
return dir
}
// TestAppsDBDataImport_RequiresAppID 验证空白 --app-id 报 --app-id 的 ValidationError。
func TestAppsDBDataImport_RequiresAppID(t *testing.T) {
chdirTemp(t)
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBDataImport,
[]string{"+db-data-import", "--app-id", " ", "--file", "orders.csv", "--yes", "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
}
if ve.Param != "--app-id" {
t.Fatalf("Param = %q, want --app-id", ve.Param)
}
}
// TestAppsDBDataImport_RejectsUnsupportedFormat 验证非 csv/json 文件(.txt报不支持格式的校验错误。
func TestAppsDBDataImport_RejectsUnsupportedFormat(t *testing.T) {
chdirTemp(t)
_ = os.WriteFile("data.txt", []byte("x\n"), 0o600)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBDataImport,
[]string{"+db-data-import", "--app-id", "app_x", "--file", "data.txt", "--yes", "--as", "user"}, factory, stdout)
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected unsupported-format validation, got %v", err)
}
}
// TestAppsDBDataImport_RequiresConfirmation 验证缺 --yes 时报 requires confirmation 错误。
func TestAppsDBDataImport_RequiresConfirmation(t *testing.T) {
chdirTemp(t)
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBDataImport,
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
t.Fatalf("expected confirmation_required, got %v", err)
}
}
// TestAppsDBDataImport_RejectsOversizeFile 验证超过 1MB 上限的文件报 --file 的 ValidationError。
func TestAppsDBDataImport_RejectsOversizeFile(t *testing.T) {
chdirTemp(t)
// >1MB → size 校验
big := append([]byte("id\n"), make([]byte, dbDataImportMaxBytes+1)...)
_ = os.WriteFile("big.csv", big, 0o600)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBDataImport,
[]string{"+db-data-import", "--app-id", "app_x", "--file", "big.csv", "--yes", "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected 1MB limit error, got %T %v", err, err)
}
if ve.Param != "--file" {
t.Fatalf("Param = %q, want --file", ve.Param)
}
}
// dry-runmultipart 上传——file_name + file 走 bodyenv + table 走 querytable 缺省取文件名)。
// TestAppsDBDataImport_DryRunMultipartShape 验证 dry-run 的 multipart 形态file_name+file 走 body、env+table 走 query 且不再发 format。
func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
chdirTemp(t)
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBDataImport,
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--environment", "dev", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "POST" || a.URL != dbDataImportURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if a.Body["file_name"] != "orders.csv" || a.Body["file"] == nil {
t.Fatalf("dry-run body should carry file_name + file: %v", a.Body)
}
if _, ok := a.Body["format"]; ok {
t.Fatalf("format must no longer be sent: %v", a.Body)
}
if a.Params["env"] != "dev" || a.Params["table"] != "orders" {
t.Fatalf("dry-run params (env+table) = %v", a.Params)
}
}
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
func TestAppsDBDataImport_Success(t *testing.T) {
chdirTemp(t)
_ = os.WriteFile("orders.csv", []byte("id,name\n1,a\n2,b\n"), 0o600)
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbDataImportURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"table": "orders", "rows": 2}},
})
if err := runAppsShortcut(t, AppsDBDataImport,
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--table", "orders", "--yes", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"table": "orders"`) || !strings.Contains(got, `"rows": 2`) || !strings.Contains(got, `"file": "orders.csv"`) {
t.Fatalf("output missing fields:\n%s", got)
}
}
// TestAppsDBDataImport_TableDefaultsToFileBasename 验证未传 --table 时表名缺省取文件名去扩展名customers.json→customers
func TestAppsDBDataImport_TableDefaultsToFileBasename(t *testing.T) {
chdirTemp(t)
_ = os.WriteFile("customers.json", []byte(`[{"id":1}]`), 0o600)
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBDataImport,
[]string{"+db-data-import", "--app-id", "app_x", "--file", "customers.json", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
if env.API[0].Params["table"] != "customers" {
t.Fatalf("expected table=customers (from file basename) in params, got %v", env.API[0].Params)
}
}

View File

@@ -12,11 +12,11 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const dbEnvCreateHint = "verify --app-id is correct; if the app is already multi-env this is a conflict — inspect current tables with `lark-cli apps +db-table-list --app-id <app_id> --env dev`"
const dbEnvCreateHint = "verify --app-id is correct; if the app is already multi-env this is a conflict — inspect current tables with `lark-cli apps +db-table-list --app-id <app_id> --environment dev`"
// AppsDBEnvCreate creates a DB environment for an app拆分单库为 dev/online 多环境)。
//
// 调 POST /apps/{app_id}/db_dev_init。--env 指定要创建的环境,由调用方传入,目前只支持 dev。
// 调 POST /apps/{app_id}/db_dev_init。--environment 指定要创建的环境,由调用方传入,目前只支持 dev。
// 不可逆:单库一旦拆成 dev/online 双库无法回退。Risk: high-risk-write 触发框架自动注入 --yes 确认关卡。
var AppsDBEnvCreate = common.Shortcut{
Service: appsService,
@@ -24,19 +24,20 @@ var AppsDBEnvCreate = common.Shortcut{
Description: "Create a DB environment (split single-env DB into dev/online, irreversible)",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +db-env-create --env dev --sync-data --app-id <app_id> --yes",
"Example: lark-cli apps +db-env-create --environment dev --sync-data --app-id <app_id> --yes",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "app id", Required: true},
{Name: "env", Default: "dev", Enum: []string{"dev"}, Desc: "environment to create (only dev supported for now)"},
{Name: "sync-data", Type: "bool", Desc: "copy existing online data into the new environment (default off)"},
},
}, dbEnvFlags("dev", []string{"dev"}, "environment to create (only dev supported for now)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
return rejectLegacyEnvFlag(rctx)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
@@ -62,7 +63,7 @@ var AppsDBEnvCreate = common.Shortcut{
}
// buildDBEnvCreateBody 构造 db 环境创建 bodysync_databool
// --env 目前只支持 dev、服务端接口本身即创建 dev 环境,故不下发 env 字段(仅做 CLI 入参校验/前向兼容)。
// --environment 目前只支持 dev、服务端接口本身即创建 dev 环境,故不下发 env 字段(仅做 CLI 入参校验/前向兼容)。
func buildDBEnvCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
return map[string]interface{}{
"sync_data": rctx.Bool("sync-data"),

View File

@@ -27,7 +27,7 @@ func TestAppsDBEnvCreate_WithYesPostsSyncData(t *testing.T) {
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--yes", "--as", "user"},
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--sync-data", "--yes", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
@@ -54,7 +54,7 @@ func TestAppsDBEnvCreate_SyncDataFalseByDefault(t *testing.T) {
}
reg.Register(stub)
if err := runAppsShortcut(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--yes", "--as", "user"},
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--yes", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
@@ -82,7 +82,7 @@ func TestAppsDBEnvCreate_PrettyEmitsAllFourLines(t *testing.T) {
},
})
if err := runAppsShortcut(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--yes", "--format", "pretty", "--as", "user"},
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--sync-data", "--yes", "--format", "pretty", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
@@ -103,7 +103,7 @@ func TestAppsDBEnvCreate_PrettyEmitsAllFourLines(t *testing.T) {
func TestAppsDBEnvCreate_DryRunNoConfirm(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--dry-run", "--as", "user"},
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
@@ -116,7 +116,7 @@ func TestAppsDBEnvCreate_DryRunNoConfirm(t *testing.T) {
func TestAppsDBEnvCreate_RejectsNonDevEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsDBEnvCreate,
[]string{"+db-env-create", "--app-id", "app_x", "--env", "online", "--yes", "--as", "user"},
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "online", "--yes", "--as", "user"},
factory, stdout)
if err == nil || !strings.Contains(err.Error(), "env") {
t.Fatalf("expected env enum rejection, got %v", err)

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