Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ea8bad074 |
@@ -1,36 +0,0 @@
|
||||
# Release fragments
|
||||
|
||||
普通功能、修复和面向用户的行为变更不要再修改根目录 `CHANGELOG.md` 的
|
||||
`Unreleased` 区域。每个 PR 在本目录新增一个独立的 Markdown fragment,避免
|
||||
并行 PR 争用同一文件。
|
||||
|
||||
文件名使用能唯一定位变更的短名,通常是 PR 号,例如
|
||||
`1234-chat-reply-mentions.md`。文件名必须匹配
|
||||
`^[a-z0-9][a-z0-9._-]*\.md$`,且必须是普通文件,不能是符号链接。本目录顶层
|
||||
只接受 `README.md`、`released/` 和符合该规则的 fragment:fragment 一律平铺在
|
||||
顶层,不接受任何其它子目录,本目录自身也不能被替换成文件或符号链接。其余条目
|
||||
会被 CI 直接拒绝而不是忽略,以免非法条目跳过校验后拖垮下一个 PR。文件格式
|
||||
严格如下:
|
||||
|
||||
```markdown
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Chat reply mentions** (#1234) — supports mentioning selected members.
|
||||
```
|
||||
|
||||
`category` 只能是 `Added`、`Changed`、`Deprecated`、`Removed`、`Fixed` 或
|
||||
`Security`。正文至少包含一个 Markdown 列表项,且不得包含 `TODO` 或 `TBD`。
|
||||
|
||||
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
|
||||
将未归档 fragments 汇总为唯一的版本章节,并移动到
|
||||
`.changes/released/<version>/`。beta 发布后若有新 fragments 合入并直接准备 stable,
|
||||
stable 封板会把它们追加到明确的 post-beta 小节,并归档到正式版本目录;没有新
|
||||
fragments 时仍只生成原有 beta 晋级模板。因此 release-seal PR 是唯一会修改
|
||||
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
|
||||
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
|
||||
删除或重写已归档文件。
|
||||
|
||||
无需面向用户发布说明的改动不添加 fragment。评审者根据改动是否可见来判断该
|
||||
例外是否成立。
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Command typo guidance** — returns a validation error with up to three nearest command suggestions and the parent `--help` entry instead of printing the full command list.
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Agent version and extended context passthrough** (Aone 85384225) — adds
|
||||
validated `DWS_AGENT_VER` and sensitive JSON `DWS_AGENT_EXT` metadata to
|
||||
ordinary non-plugin MCP requests without forwarding it to A2A, OAuth,
|
||||
Discovery, or third-party plugins.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Chat message send help** - Clarifies Markdown image syntax for inline mixed text and images.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Drive file comments** (#961) — adds `dws drive comment list` and `dws drive comment create` for comments on ordinary preview files.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Chat automatic pagination controls** (#970) — adds bounded `--max-items` and cancellable `--page-delay` support to the core IM list shortcuts, with safe continuation metadata and truncation reporting.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Doc/drive/wiki routing descriptions** — clarifies the document-space container-vs-content boundary across the doc, drive, and wiki skill descriptions for more predictable first-round Agent selection, without changing CLI behavior.
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Drive `--latest` refuses incomplete Top-N** (#899) — `dws drive list --latest` used to
|
||||
exit 0 with a "Top-N" computed over a partially scanned tree whenever a directory read
|
||||
failed mid-recursion (permission denied, API error), letting an incomplete set pose as the
|
||||
globally newest files. Truncation at the 2000-item scan cap and mid-recursion directory
|
||||
failures now both fail closed (`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`), report
|
||||
the first failing folder with its depth and reason, and emit a recovery command that
|
||||
reproduces the original candidate set — query domain, `--folder`, `--pattern`, `--type`,
|
||||
`--start` and `--end` are all carried over. On POSIX shells each user-supplied value is
|
||||
quoted so a URL query string or a shell metacharacter cannot change how the copied command
|
||||
parses. On Windows no quoting form is safe for both `cmd.exe` and PowerShell, so values
|
||||
containing metacharacters are not inlined at all: the command carries a placeholder and the
|
||||
original value is shown on a separate line marked as data rather than an executable command.
|
||||
Unrecoverable errors under `--latest` return the root cause instead of a partial result.
|
||||
Remote-controlled folder names and server error text are stripped of ANSI escapes and
|
||||
control characters before they reach the plain-text stderr message. The internal `sortTime`
|
||||
sort key no longer leaks into `drive list --depth` output on any path.
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Drive list type/time filtering** (#942) — `dws drive list` gains `--type
|
||||
file|folder`, `--start`, and `--end` for client-side filtering by node type
|
||||
and modification time on both the pan and workspace routes. Filtering runs
|
||||
a bounded full scan of the target directory (2000-entry cap, reported via
|
||||
`truncated=true`), composes with `--latest`/`--pattern`/`--depth`, and is
|
||||
mutually exclusive with `--versions`/`--cursor`/`--order-by`/`--order`/
|
||||
`--limit`. Time values accept relative forms (`24h`/`7d`/`2w`), RFC 3339,
|
||||
zone-less ISO 8601 (Asia/Shanghai), or a plain date.
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Drive list pattern filtering** (#942) — `dws drive list --pattern` on the
|
||||
single-layer pan route now filters the returned page by name pattern; the
|
||||
flag was previously accepted but silently ignored.
|
||||
|
||||
- **Drive list `--type folder --latest` composition** (#942) — `--latest` now
|
||||
ranks the filtered entries (folders included when `--type folder` is set)
|
||||
instead of unconditionally dropping folders, so the documented combination
|
||||
returns the most recently modified folders rather than an empty list.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Chat message time defaults** (#973) — default omitted `chat message list-all` time bounds in `Asia/Shanghai` when emitting timezone-less `yyyy-MM-dd HH:mm:ss` values, matching parsing semantics and rejecting reversed windows.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Doc and Drive parameter aliases** — normalizes reviewed identifier, pagination, path, version, and role synonyms while blocking ambiguous values before dispatch.
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Drive folder synchronization** — adds `dws drive status`, `dws drive pull`,
|
||||
`dws drive push`, and `dws drive sync` for file-level comparison and transfer
|
||||
between a local folder and a Drive folder. Differences come from exact MD5 by
|
||||
default or from modification time with `--quick`; `status` is read-only, `pull`
|
||||
and `push` are one-directional with `--if-exists skip|smart|overwrite`, and
|
||||
`sync` is bidirectional with `--on-conflict remote-wins|local-wins|keep-both|ask`.
|
||||
Only regular files are transferred — online documents and shortcuts are skipped,
|
||||
neither side deletes extra files, downloads are staged through a temporary file
|
||||
and committed with an atomic rename, and remote names that would escape
|
||||
`--local-folder` are reported as failures instead of being written. Every command
|
||||
prints a structured summary on stdout and exits non-zero when any item fails.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **International DingTalk region support** — adds `.io` login and MCP routing, pre-release endpoint overrides, and profile-aware gateway selection while preserving the existing `.com` flow.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Chat identity routing** — validates explicit `openDingTalkId` inputs and improves name, `userId`, and `openDingTalkId` routing for message shortcuts.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Privacy-safe CLI telemetry** (#1009) — reports reviewed command outcomes and profile identity dimensions while excluding command arguments, output, paths, device fingerprints, and automatic system dimensions; `DO_NOT_TRACK=1` disables reporting.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Feedback survey entry in root help** (#1019) — `dws --help` now closes with a Feedback section linking the user-experience survey form.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Chat IM ID flags** (#954) — standardizes chat command entry points on `--conversation-id` for conversation IDs and `--message-id` for message IDs, so help, Schema, and Agent recommendations use the same canonical flags.
|
||||
- **Legacy chat flag compatibility** (#954) — keeps older chat IM ID flags such as `--group`, `--id`, `--chat`, `--open-conversation-id`, `--msg-id`, and `--open-message-id` working as compatibility aliases where applicable, while hiding migrated aliases from recommended help and Schema surfaces.
|
||||
- **Chat group bots target flag** (#954) — keeps `dws chat group bots` on the visible `--group` flag; this command does not register `--group-name`, and `--group` accepts either an openConversationId or a uniquely resolved group name.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Chat card update evidence** — distinguishes an accepted update request from an independently verified visible update, preserving the real `bizId` and warning callers not to repeat an unverified write.
|
||||
- **Chat command guidance** — splits message and group references by task and explains that `--from` is ambiguous between sender and time-range intent.
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Faster Schema Catalog assembly** — projects typed values into payload JSON
|
||||
without re-running a validation scan over documents `json.Marshal` has just
|
||||
produced, cutting roughly a third of the projection work across the full tool
|
||||
set. Untrusted JSON input keeps its existing validation.
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Wiki Shortcut workflows** — publishes 20 reviewed space, member, node, and
|
||||
activity shortcuts with strict collection validation, cursor handling,
|
||||
write-terminal evidence, safe read-backs where the backend supports them,
|
||||
task-oriented routing, and documented backend
|
||||
boundaries.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Aitable pagination and Minutes unshare verification** (#1006) — keeps
|
||||
record queries on the service's 20-record page boundary so multi-page reads
|
||||
and mutation readbacks no longer report false retryable failures, preserves
|
||||
`totalCount` when supplied, validates `--dry-run` plans before transport,
|
||||
follows active deletion readback continuations before proving absence, and
|
||||
rejects Minutes unshare success until the listening note exists and the
|
||||
service acknowledges the exact task and member targets.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Robot group reference replies** (#928) — `chat message send-by-bot` supports paired `--reply` and `--ref-sender` flags for Markdown replies that quote an existing group message.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Document write verification** (#960) — avoids false partial-success results when normalized Markdown, paginated blocks, inline images, or version reverts are confirmed by server readback. Document reverts and media inserts now require explicit readback evidence and report partial success when the server cannot prove the requested result.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **AI Table parameter aliases** — accepts reviewed equivalent spellings for Base, table, workflow, search, pagination, and description parameters while keeping role-changing or semantically ambiguous inputs blocked.
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **AI Table server-side statistics** — adds `dws aitable record stats` for
|
||||
ungrouped record-set metrics through `query_records_stats`, plus `dws aitable
|
||||
record group-stats` for grouped, distinct, and advanced aggregation through
|
||||
`query_stats`; both commands validate their JSON aggregation contracts before
|
||||
dispatch.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Calendar event share-info** (#980) — adds `dws calendar event share-info` to fetch a calendar event's share info (title, organizer, location, join info) for sharing with others; supports `--calendar-id` and `--language`.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Calendar and To-do Shortcut workflows** — aligns 47 public task-oriented
|
||||
entries with lark-cli where the DingTalk backend supports equivalent
|
||||
semantics, rejects malformed or missing collections instead of returning
|
||||
false empty success, preserves truthful pagination, and requires stable
|
||||
identifiers plus read-back or explicit terminal receipts for writes. Adds
|
||||
deterministic contract coverage, a PII-safe live E2E runner, and a sanitized
|
||||
capability review with documented platform boundaries.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Chat sender identity guards** — preserves unverified mixed sender inputs after exact message `senderId` matches and aligns `--sender-query` Skill guidance with fail-closed Runtime behavior.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Doc/drive description scope** — restates the `dingtalk-doc` description as document-entity-and-content operations with an explicit exclusion list, and narrows `dingtalk-drive` to file-level management of DingTalk documents, so first-round Agent selection separates content work from file management without changing CLI behavior.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Doc and Sheet comment lifecycle commands** — adds `comment batch-query`,
|
||||
`comment resolve`, `comment restore`, and the lightweight
|
||||
`comment react-reply` to both `dws doc` and `dws sheet`. The two domains share
|
||||
the same `doc-comment` MCP capabilities; batch queries preserve input order
|
||||
for repeated `topicId:commentKey` references, while reaction replies require
|
||||
DingTalk reaction names such as `憨笑` or `鼓掌` rather than raw Unicode emoji.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Sheet SourceRange dropdowns** — supports range-backed dropdowns across direct, cell, and batch write paths, with structured readback for valid and invalid references. Batch `set-dropdown` now rejects unsupported top-level `colors` / `source-colors`; Inline colors belong in `options[].color`, while SourceRange color writes remain unsupported.
|
||||
- **Sheet read completion metadata** — documents and preserves returned ranges, truncation reasons, and partial-read status for large range and CSV reads.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Windows event bus lifecycle** — start event consumers without unsupported inherited file descriptors, stop buses through local IPC with a termination fallback, and preserve subscription cleanup when startup fails.
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Attendance and Mail Shortcuts** (#1045) — publishes only capabilities with
|
||||
strict response, identity, pagination, and real-data verification while
|
||||
retaining historical CLI discovery and argument compatibility for commands
|
||||
that remain unavailable to agents. Mailbox auto-resolution now accepts both
|
||||
reviewed string and object response shapes, and Attendance date ranges cover
|
||||
the complete requested end date without dropping cross-midnight punches whose
|
||||
actual check time is inside the requested range. The schedule query remains
|
||||
CLI-compatible but is withheld from the Agent catalog because its downstream
|
||||
service returns a successful process exit with a null body for both populated
|
||||
and empty ranges.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Chat group roles** (#1058) — exposes the single-value `--role-id` flag for assigning one custom group role while preserving hidden `--role-ids` compatibility.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **招聘职位管理** (#976) — 新增招聘职位列表、详情查询和职位创建命令。
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Chat user mentions** — preserves literal `<@openDingTalkId>` tokens in current-user Markdown messages and rejects mismatches between message-body mentions and mention flags before sending.
|
||||
- **Chat direct media** — uses the IM upload target field for current-user direct file, audio, and video uploads, then uses the Chat receiver field for final message delivery.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **CLI compatibility governance** — adds a reviewed two-stage path for hiding retained legacy commands or optional `NoOpt=true` boolean flags from Help and Schema when their activated capability moves to a dedicated command, with legacy-leaf, complete parameter/constant mapping, durable runtime constant evidence, protected framework bridges, dry-run preservation, parameter-collision, and fail-closed required-parameter checks.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **OA admin approval query** — `oa approval list-by-admin` queries approval instances of a template with admin scope, with simple flags and an advanced `--request` mode; `startTime`/`endTime` use `yyyy-MM-dd HH:mm:ss` strings per the 2026-08 MCP contract update (ISO-8601 flag inputs auto-convert), and pageSize/time format are validated client-side with localized errors.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Shortcut functional workflows** (#1050) — fixes truthful Drive push/sync previews, strict AITable write verification and deletion accounting, lossless Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel.
|
||||
- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures.
|
||||
- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **OAuth refresh falls back to the organization mirror** — when the server rejects the
|
||||
current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound`
|
||||
business code, `dws` now retries once with the still-valid token mirrored in the same
|
||||
organization's slot (same corp, matching or backfilled user identity) before giving up,
|
||||
and writes the rotated credential back to both the identity and the organization slots so
|
||||
the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP
|
||||
rejections without a reviewed business code do not trigger the fallback.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Drive permission get-setting** (#1056) — adds `dws drive permission get-setting --node <ID>` to inspect a document-space node's permission settings (permission mode, share scope, and permission policies) in one call.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification.
|
||||
- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts.
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Permission error guidance and error rendering** (#1085) —
|
||||
permission-denied responses now exit with the `AUTH_PERMISSION_DENIED` code
|
||||
instead of a generic business-error rendering; document/wiki-specific errors
|
||||
(the drive-specific codes `forbidden.accessDenied` / `forbidden.no.auth`,
|
||||
or the role-threshold wording like
|
||||
“需要您具备 MANAGER 及以上角色”) carry apply-permission guidance
|
||||
(`dws drive permission apply-info` / `dws drive permission apply`), while
|
||||
permission failures carrying only generic code names (`FORBIDDEN`,
|
||||
`NO_PERMISSION` — also returned by attendance and event-subscription tools)
|
||||
or other products' wording keep their product-specific or
|
||||
product-neutral suggestion instead of a misleading document-permission hint;
|
||||
member-validation failures such as
|
||||
“用户不存在/不属于当前组织” are classified as tool errors with a
|
||||
`--members`-with-`corpId` suggestion instead of a misleading
|
||||
resource-not-found error; business error output now surfaces the backend
|
||||
message with `code`/`logId` appended for traceability; and the
|
||||
`update_permission` / `remove_permission` / `update_member` /
|
||||
`remove_member` tools — whose servers return a literal `null` on successful
|
||||
no-payload writes — now render `{}` so downstream JSON consumers do not fail
|
||||
parsing `null`; other tools keep raw `null` output unchanged.
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Permission and member list pagination** (#1085) — `drive/doc permission
|
||||
list` and `wiki member list` now accept `--next-token` to follow the
|
||||
server-side cursor (output carries `totalCount`/`hasMore`/`nextToken`) and
|
||||
map `--limit` to `pageSize` capped at 50 instead of the rejected `maxResults
|
||||
200` path; `permission add/update/remove` and `wiki member add/update/remove`
|
||||
additionally accept a `--members` JSON array covering USER/DEPT/CONVERSATION/TAG
|
||||
grantee types. The optional `--notify` defaults to `false` and is omitted from
|
||||
the server request unless passed explicitly, so member grants no longer notify
|
||||
recipients by default. These commands also declare cursor pagination
|
||||
(`next-token`) in the Agent schema contract, mirroring the internal CLI parity
|
||||
change. Because a single batch remove can revoke access for up to 30
|
||||
USER/DEPT/CONVERSATION/TAG members — where departments, chats, and role
|
||||
groups can indirectly affect many more users — `drive/doc permission
|
||||
remove` and `wiki member remove` now declare
|
||||
`confirmation=user_required` and gate the actual tool call behind user
|
||||
confirmation (`--yes`, an interactive yes, or `--dry-run` preview); their
|
||||
confirmation-gate failure now also passes through verbatim instead of being
|
||||
reclassified as a permission-denied or unclassified error.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Agoal scorecard search-entities** — `dws agoal scorecard search-entities` searches scorecard metrics and key items by keyword, returning matching entity info (scorecard ID, entity ID, entity type, title, owning team) with optional `--page`/`--page-size` pagination.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **AITable datasource shortcuts** — adds 7 shortcuts for datasource sync management (`+datasource-create`, `+datasource-update`, `+datasource-sync`, `+datasource-sync-status`, `+datasource-get-config`, `+datasource-list-sources`, `+datasource-get-fields`) and updates the `dingtalk-aitable` skill with routing rules and a new `aitable-datasource.md` reference guide.
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Doc public-link and historical-version reads** — `dws doc read` forwards
|
||||
the reviewed `password` (internet-public documents with password protection)
|
||||
and `historyVersion` (read content as of a listed historical version; `0`
|
||||
denotes the document's initial version) parameters on the markdown, JSONML,
|
||||
and scope read paths via `--password` / `--version`; `dws doc +fetch` gains
|
||||
`--password` and `--version` with the same `historyVersion` forwarding, while
|
||||
`--revision` stays rejected with explicit guidance: revision is the document
|
||||
edit revision returned by JSONML reads for `+update --expected-revision`
|
||||
conditional writes, not a historical version number.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Edu & College vendor extensions** — adds five hidden vendor extension commands for education scenarios: `dws edu-contact` (school/class/family/teacher contact management), `dws edu-group` (student/class group lifecycle), `dws edu-app` (homework, notices, report cards, diplomas, class circles), `dws edu-familygroup` (family group management, child binding, app permissions), and `dws college-contact` (university dept/employee/alumni/graduate management). All route to dedicated MCP servers via `callMCPToolOnServer`.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **OA approval attachment upload** — `dws oa approval attachment upload --file <path>` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets.
|
||||
@@ -19,12 +19,3 @@
|
||||
|
||||
# Cache directory (optional, defaults to ~/.dws/cache)
|
||||
# DWS_CACHE_DIR=
|
||||
|
||||
# Agent integration metadata (optional; ordinary non-plugin MCP requests only)
|
||||
# DWS_AGENT_PRODUCT=example-agent
|
||||
# DWS_AGENT_HOST=cloud
|
||||
# DWS_AGENT_VER=0.1.5
|
||||
# DWS_AGENT_EXT='{"umt":"example-redacted","miniwua":"example-redacted","ua":"ExampleAgent/0.1.5"}'
|
||||
# The outer single quotes above are shell syntax and are not part of the value.
|
||||
# DWS_AGENT_EXT is sensitive caller-declared JSON (max 8 KiB); never put real
|
||||
# tokens in committed files or use this metadata alone for authentication.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Default code owners for all files
|
||||
# These users will be automatically requested for review on PRs.
|
||||
|
||||
* @DingTalk-Real-AI/cli-maintainers
|
||||
@@ -3,43 +3,15 @@
|
||||
- What changed?
|
||||
- Why is this change needed?
|
||||
|
||||
## Risk tier
|
||||
|
||||
- [ ] Documentation-only: prose/assets only; no executable, generated, workflow,
|
||||
packaging, or interface behavior changed
|
||||
- [ ] Standard: ordinary implementation change with a stable package graph
|
||||
- [ ] High-risk: workflow/policy, package graph, generated Schema/registry,
|
||||
platform, auth/keychain, installer, packaging, release, transport, recovery,
|
||||
or another fail-closed infrastructure change
|
||||
|
||||
## Verification
|
||||
|
||||
Record the smallest targeted evidence that proves the changed behavior. Do not
|
||||
repeat the entire CI suite locally only to fill this checklist: CI expands the
|
||||
selected tier from documentation checks, through affected-package tests, to
|
||||
the complete high-risk suite.
|
||||
|
||||
- [ ] Release fragment added for a user-visible behavior/interface change (otherwise `N/A`):
|
||||
`.changes/<unique-name>.md`; ordinary PRs must not edit `CHANGELOG.md`.
|
||||
- [ ] Release-seal validation (otherwise `N/A`):
|
||||
`./scripts/policy/check-changelog-pr.sh --content-only "$(git merge-base HEAD origin/main)" HEAD`
|
||||
- [ ] Targeted test/check commands and results:
|
||||
- [ ] Behavior evidence (test name, CLI output shape, or before/after result):
|
||||
- [ ] Documentation links/content/rendering checked (documentation-only, otherwise
|
||||
`N/A`)
|
||||
- [ ] Full local suite run because the change is high-risk (optional for other
|
||||
tiers; record command/result or `N/A`)
|
||||
- [ ] `make build`
|
||||
- [ ] `make lint`
|
||||
- [ ] `make test`
|
||||
- [ ] `make policy`
|
||||
- [ ] `./scripts/policy/check-generated-drift.sh`
|
||||
(when generator inputs or generated artifacts may change)
|
||||
- [ ] `./scripts/policy/check-command-surface.sh --strict` (if command surface changed)
|
||||
- [ ] `./scripts/release/verify-package-managers.sh`
|
||||
(after `make package`, if packaging or installer surfaces changed)
|
||||
|
||||
## Notes
|
||||
|
||||
- Any risks, follow-up work, or intentional scope cuts
|
||||
|
||||
The repository automatically requests one eligible peer reviewer, including
|
||||
after a new head push when another review is needed. Once the latest push has
|
||||
peer approval and all nine required checks are current and green, auto-merge
|
||||
completes the PR; authors do not need to coordinate a separate routine merge.
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
paths:
|
||||
.github/workflows/release.yml:
|
||||
ignore:
|
||||
# GitHub Actions added concurrency.queue in 2026. actionlint v1.7.12's
|
||||
# bundled workflow schema has not caught up with the platform syntax.
|
||||
- 'unexpected key "queue" for "concurrency" section'
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="114" height="20" role="img" aria-label="coverage: 100.0%"><title>coverage: 100.0%</title><filter id="blur"><feGaussianBlur stdDeviation="16"/></filter><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="114" height="20" rx="3"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="53" height="20" fill="#4b0"/><rect width="114" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><g transform="scale(.1)"><g aria-hidden="true" fill="#010101"><text x="315" y="150" fill-opacity=".8" filter="url(#blur)" textLength="510">coverage</text><text x="315" y="150" fill-opacity=".3" textLength="510">coverage</text></g><text x="315" y="140" textLength="510">coverage</text></g><g transform="scale(.1)"><g aria-hidden="true" fill="#010101"><text x="865" y="150" fill-opacity=".8" filter="url(#blur)" textLength="430">100.0%</text><text x="865" y="150" fill-opacity=".3" textLength="430">100.0%</text></g><text x="865" y="140" textLength="430">100.0%</text></g></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 48.7%"><title>coverage: 48.7%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">48.7%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">48.7%</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,61 +0,0 @@
|
||||
# /eval 自助触发允许名单
|
||||
#
|
||||
# 名单内的 GitHub 登录名可对【自己创建的 PR】触发 /eval 评测;
|
||||
# 对任意 PR 触发仍需仓库 write/maintain/admin 权限(维护者背书)。
|
||||
# 授权读取的始终是默认分支上的本文件,PR 无法修改自身授权。
|
||||
#
|
||||
# 变更本文件必须走 PR 评审。每行一个 GitHub login,# 开头为注释。
|
||||
|
||||
aftersss
|
||||
notable-open
|
||||
EdgarWang0925
|
||||
ayunya
|
||||
yutongshe
|
||||
qingyang1014
|
||||
caiTriumph
|
||||
xlb1130
|
||||
Anonymity-0
|
||||
FuShu-Yang
|
||||
guimingyue
|
||||
AlwaysLee
|
||||
TaoJikun
|
||||
zengyoulingzyl-stack
|
||||
liyuan333
|
||||
huangyoo
|
||||
lifeihong
|
||||
nitonitori
|
||||
cywan1998
|
||||
gangwn
|
||||
junlonghuo2
|
||||
aqruan
|
||||
Freda0909
|
||||
ShawnWhite777
|
||||
PeterGuy326
|
||||
abucraft
|
||||
pengzhihan47-star
|
||||
rainyak8
|
||||
gongrongyun
|
||||
huangyuanzhuo-coder
|
||||
ybcstudy
|
||||
bigqy
|
||||
liwang-ai
|
||||
meng93
|
||||
wxianfeng
|
||||
Patrick-Star-CN
|
||||
rossluo28-hz
|
||||
dxy704330469
|
||||
gtezg30062
|
||||
Neige-Premaire
|
||||
zhuoyu20
|
||||
avicii-chen
|
||||
typefield
|
||||
Haofeng0705
|
||||
Huwenjiao
|
||||
liuzeyang
|
||||
maoqxxmm
|
||||
FloralTide
|
||||
lingyun9833
|
||||
dxb121
|
||||
C0922
|
||||
xiaoji121
|
||||
H3java
|
||||
@@ -1,285 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// 评审归属是受保护分支上的声明式规则;未知路径不猜测,交给工作流负载均衡兜底。
|
||||
const REVIEWER_POOL = ['wxianfeng', 'typefield', 'haofeng0705', 'hlzjsong'];
|
||||
|
||||
const PRODUCT_GROUPS = [
|
||||
{
|
||||
primary: 'wxianfeng',
|
||||
backup: 'typefield',
|
||||
products: ['chat', 'contact', 'ding', 'event', 'mail', 'live', 'conference', 'dev', 'devapp', 'mcp', 'aiapp'],
|
||||
},
|
||||
{
|
||||
primary: 'typefield',
|
||||
backup: 'wxianfeng',
|
||||
products: ['doc', 'drive', 'wiki', 'markdown', 'docparse', 'aidesign', 'devdoc', 'blackboard', 'finance', 'law', 'credit'],
|
||||
},
|
||||
{
|
||||
primary: 'haofeng0705',
|
||||
backup: 'typefield',
|
||||
products: ['minutes', 'sheet', 'aitable', 'calendar', 'todo', 'oa', 'attendance', 'report', 'agoal', 'aisearch', 'yida', 'hrbrain'],
|
||||
},
|
||||
];
|
||||
|
||||
const pathStartsWith = (prefixes) => (path) => prefixes.some((prefix) => path.startsWith(prefix));
|
||||
|
||||
const MODULES = [
|
||||
{
|
||||
id: 'security',
|
||||
label: '登录、认证、权限、安全',
|
||||
primary: 'hlzjsong',
|
||||
backup: 'typefield',
|
||||
requiresSecondary: true,
|
||||
matches: pathStartsWith([
|
||||
'internal/auth/',
|
||||
'internal/keychain/',
|
||||
'internal/audit/',
|
||||
'internal/pat/',
|
||||
'internal/security/',
|
||||
'internal/safety/',
|
||||
'pkg/edition/',
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'delivery',
|
||||
label: 'CI、测试、发布、安装',
|
||||
primary: 'haofeng0705',
|
||||
backup: 'wxianfeng',
|
||||
requiresSecondary: true,
|
||||
matches: (path) =>
|
||||
path.startsWith('.github/') ||
|
||||
path.startsWith('scripts/release/') ||
|
||||
path.startsWith('scripts/policy/') ||
|
||||
path.startsWith('scripts/dev/') ||
|
||||
path.startsWith('scripts/install') ||
|
||||
path.startsWith('Formula/') ||
|
||||
path.startsWith('build/') ||
|
||||
path.startsWith('internal/upgrade/') ||
|
||||
path.startsWith('internal/app/upgrade') ||
|
||||
path.startsWith('test/') ||
|
||||
path.startsWith('verify/') ||
|
||||
path.startsWith('.workflow/') ||
|
||||
path === 'coverage.txt' ||
|
||||
path === 'coverage-base.txt' ||
|
||||
path === '.goreleaser.yaml' ||
|
||||
path === 'package.json' ||
|
||||
path === 'package-lock.json' ||
|
||||
path === 'docs/releasing.md',
|
||||
},
|
||||
{
|
||||
id: 'architecture',
|
||||
label: 'DWS 架构、公共内核',
|
||||
primary: 'wxianfeng',
|
||||
backup: 'typefield',
|
||||
requiresSecondary: true,
|
||||
matches: pathStartsWith([
|
||||
'cmd/',
|
||||
'internal/apiclient/',
|
||||
'internal/app/',
|
||||
'internal/cli/',
|
||||
'internal/cobracmd/',
|
||||
'internal/corecmd/',
|
||||
'internal/errors/',
|
||||
'internal/executor/',
|
||||
'internal/generator/',
|
||||
'internal/i18n/',
|
||||
'internal/interfacesnapshot/',
|
||||
'internal/jsonutil/',
|
||||
'internal/localio/',
|
||||
'internal/logging/',
|
||||
'internal/output/',
|
||||
'internal/pipeline/',
|
||||
'internal/plugin/',
|
||||
'internal/profilectx/',
|
||||
'internal/registry/',
|
||||
'internal/syncdata/',
|
||||
'internal/testseam/',
|
||||
'internal/transport/',
|
||||
'pkg/',
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'compatibility',
|
||||
label: '兼容性',
|
||||
primary: 'wxianfeng',
|
||||
backup: 'typefield',
|
||||
requiresSecondary: true,
|
||||
matches: (path) =>
|
||||
/(?:^|[/_.-])compat(?:ibility)?(?=$|[/_.-])/.test(path) ||
|
||||
path.includes('schema_compat'),
|
||||
},
|
||||
];
|
||||
|
||||
function productMatches(path, product) {
|
||||
const aliases = product === 'blackboard' ? ['blackboard', 'whiteboard'] : [product];
|
||||
return aliases.some((alias) => new RegExp(`(?:^|[/_.-])${alias}(?=$|[/_.-])`).test(path));
|
||||
}
|
||||
|
||||
const PRODUCT_MODULES = PRODUCT_GROUPS.flatMap((group) =>
|
||||
group.products.map((product) => ({
|
||||
id: `product:${product}`,
|
||||
label: `产品:${product}`,
|
||||
primary: group.primary,
|
||||
backup: group.backup,
|
||||
requiresSecondary: false,
|
||||
matches: (path) => productMatches(path, product),
|
||||
})),
|
||||
);
|
||||
|
||||
const ALL_MODULES = [MODULES[0], MODULES[1], ...PRODUCT_MODULES, MODULES[2], MODULES[3]];
|
||||
|
||||
function normalizedPaths(file) {
|
||||
return [file?.filename, file?.previous_filename]
|
||||
.filter((path) => typeof path === 'string' && path !== '')
|
||||
.map((path) => path.toLowerCase());
|
||||
}
|
||||
|
||||
function compareStats(left, right) {
|
||||
return right.files - left.files || left.module.order - right.module.order || left.module.id.localeCompare(right.module.id);
|
||||
}
|
||||
|
||||
function classifyFiles(files) {
|
||||
const counts = new Map();
|
||||
for (const file of files || []) {
|
||||
const matchingModules = new Set();
|
||||
for (const path of normalizedPaths(file)) {
|
||||
const matches = ALL_MODULES.filter((module) => module.matches(path));
|
||||
const securityOrDelivery = matches.filter(
|
||||
(module) => module.id === 'security' || module.id === 'delivery',
|
||||
);
|
||||
const effectiveMatches = securityOrDelivery.length > 0
|
||||
? [...securityOrDelivery, ...matches.filter((module) => module.id === 'compatibility')]
|
||||
: matches;
|
||||
for (const match of effectiveMatches) {
|
||||
matchingModules.add(match.id);
|
||||
}
|
||||
if (
|
||||
effectiveMatches.length === 0 &&
|
||||
(path.startsWith('internal/helpers/') || path.startsWith('internal/shortcut/'))
|
||||
) {
|
||||
matchingModules.add('architecture');
|
||||
}
|
||||
}
|
||||
for (const moduleID of matchingModules) {
|
||||
counts.set(moduleID, (counts.get(moduleID) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return [...counts.entries()]
|
||||
.map(([id, files]) => {
|
||||
const index = ALL_MODULES.findIndex((module) => module.id === id);
|
||||
return {module: {...ALL_MODULES[index], order: index}, files};
|
||||
})
|
||||
.sort(compareStats);
|
||||
}
|
||||
|
||||
function chooseModuleReviewer(module, unavailable) {
|
||||
return [module.primary, module.backup].find(
|
||||
(reviewer) => REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer),
|
||||
);
|
||||
}
|
||||
|
||||
function addReviewer(reviewers, reviewer) {
|
||||
if (reviewer && !reviewers.includes(reviewer)) {
|
||||
reviewers.push(reviewer);
|
||||
}
|
||||
}
|
||||
|
||||
function reviewerCandidates({preferredReviewers, fallbackReviewers, eligibleReviewers}) {
|
||||
const eligible = new Set(eligibleReviewers.map((reviewer) => reviewer.toLowerCase()));
|
||||
const candidates = [];
|
||||
for (const reviewer of [...preferredReviewers, ...fallbackReviewers]) {
|
||||
if (
|
||||
eligible.has(reviewer.toLowerCase()) &&
|
||||
!candidates.some((candidate) => candidate.toLowerCase() === reviewer.toLowerCase())
|
||||
) {
|
||||
candidates.push(reviewer);
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function requestReviewersWithFallback({
|
||||
candidates,
|
||||
requiredReviewers,
|
||||
satisfiedReviewers = [],
|
||||
requestReviewer,
|
||||
onFailure = () => {},
|
||||
}) {
|
||||
const alreadySatisfied = new Set(
|
||||
satisfiedReviewers.map((reviewer) => reviewer.toLowerCase()),
|
||||
);
|
||||
const satisfied = new Set();
|
||||
const requested = [];
|
||||
|
||||
for (const reviewer of candidates) {
|
||||
if (satisfied.size >= requiredReviewers) {
|
||||
break;
|
||||
}
|
||||
const normalizedReviewer = reviewer.toLowerCase();
|
||||
if (alreadySatisfied.has(normalizedReviewer)) {
|
||||
satisfied.add(normalizedReviewer);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const shouldContinue = await requestReviewer(reviewer);
|
||||
if (shouldContinue === false) {
|
||||
return {requested, satisfiedReviewers: [...satisfied], aborted: true};
|
||||
}
|
||||
requested.push(reviewer);
|
||||
satisfied.add(normalizedReviewer);
|
||||
} catch (error) {
|
||||
onFailure(reviewer, error);
|
||||
}
|
||||
}
|
||||
|
||||
return {requested, satisfiedReviewers: [...satisfied], aborted: false};
|
||||
}
|
||||
|
||||
function resolveReviewRouting({files, author, latestPusher, fallbackReviewers = REVIEWER_POOL}) {
|
||||
const modules = classifyFiles(files);
|
||||
const unavailable = new Set([author, latestPusher].filter(Boolean).map((login) => login.toLowerCase()));
|
||||
const reviewers = [];
|
||||
const primaryModule = modules[0];
|
||||
|
||||
if (!primaryModule) {
|
||||
return {modules: [], reviewers, requiredReviewers: 1, reason: 'unknown_paths'};
|
||||
}
|
||||
|
||||
addReviewer(reviewers, chooseModuleReviewer(primaryModule.module, unavailable));
|
||||
|
||||
const requiresSecondary =
|
||||
modules.length > 1 || modules.some(({module}) => module.requiresSecondary);
|
||||
const secondaryModule = modules.find(({module}) => module.id !== primaryModule.module.id) || primaryModule;
|
||||
if (requiresSecondary) {
|
||||
addReviewer(
|
||||
reviewers,
|
||||
chooseModuleReviewer(secondaryModule.module, new Set([...unavailable, ...reviewers])),
|
||||
);
|
||||
}
|
||||
|
||||
for (const reviewer of fallbackReviewers) {
|
||||
if (reviewers.length >= (requiresSecondary ? 2 : 1)) {
|
||||
break;
|
||||
}
|
||||
if (REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer)) {
|
||||
addReviewer(reviewers, reviewer);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
modules: modules.map(({module, files}) => ({id: module.id, label: module.label, files})),
|
||||
reviewers,
|
||||
requiredReviewers: requiresSecondary ? 2 : 1,
|
||||
reason: requiresSecondary ? 'cross_or_sensitive' : 'single_module',
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
REVIEWER_POOL,
|
||||
classifyFiles,
|
||||
requestReviewersWithFallback,
|
||||
resolveReviewRouting,
|
||||
reviewerCandidates,
|
||||
};
|
||||
@@ -1,148 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
requestReviewersWithFallback,
|
||||
resolveReviewRouting,
|
||||
reviewerCandidates,
|
||||
} = require('./reviewer-routing');
|
||||
|
||||
function route(files, author = 'author', latestPusher = author) {
|
||||
return resolveReviewRouting({files: files.map((filename) => ({filename})), author, latestPusher});
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/helpers/chat_toolbar.go']);
|
||||
assert.deepEqual(result.reviewers, ['wxianfeng']);
|
||||
assert.equal(result.requiredReviewers, 1);
|
||||
assert.deepEqual(result.modules.map((module) => module.id), ['product:chat']);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/helpers/chat_toolbar.go', 'internal/helpers/doc_style.go']);
|
||||
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['.github/workflows/ci.yml']);
|
||||
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
assert.equal(result.reason, 'cross_or_sensitive');
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/auth/login.go'], 'hlzjsong');
|
||||
assert.deepEqual(result.reviewers, ['typefield', 'wxianfeng']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/upgrade/downloader.go']);
|
||||
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/app/upgrade.go', 'scripts/dev/test-release.sh']);
|
||||
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['pkg/edition/edition.go']);
|
||||
assert.deepEqual(result.reviewers, ['hlzjsong', 'typefield']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/shortcut/chat/compatibility_coverage_test.go']);
|
||||
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
assert.deepEqual(result.modules.map((module) => module.id), ['product:chat', 'compatibility']);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/helpers/leaf_dispatch.go']);
|
||||
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['docs/unknown-area.md']);
|
||||
assert.deepEqual(result.reviewers, []);
|
||||
assert.equal(result.reason, 'unknown_paths');
|
||||
}
|
||||
|
||||
async function testSingleReviewerFallback() {
|
||||
const candidates = reviewerCandidates({
|
||||
preferredReviewers: ['wxianfeng'],
|
||||
fallbackReviewers: ['wxianfeng', 'typefield', 'haofeng0705'],
|
||||
eligibleReviewers: ['wxianfeng', 'typefield', 'haofeng0705'],
|
||||
});
|
||||
const attempts = [];
|
||||
const result = await requestReviewersWithFallback({
|
||||
candidates,
|
||||
requiredReviewers: 1,
|
||||
requestReviewer: async (reviewer) => {
|
||||
attempts.push(reviewer);
|
||||
if (reviewer === 'wxianfeng') {
|
||||
throw Object.assign(new Error('cannot request primary'), {status: 422});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ['wxianfeng', 'typefield']);
|
||||
assert.deepEqual(result.requested, ['typefield']);
|
||||
assert.equal(result.satisfiedReviewers.length, 1);
|
||||
}
|
||||
|
||||
async function testTwoReviewerFallback() {
|
||||
const candidates = reviewerCandidates({
|
||||
preferredReviewers: ['haofeng0705', 'wxianfeng'],
|
||||
fallbackReviewers: ['haofeng0705', 'wxianfeng', 'typefield', 'hlzjsong'],
|
||||
eligibleReviewers: ['haofeng0705', 'wxianfeng', 'typefield', 'hlzjsong'],
|
||||
});
|
||||
const attempts = [];
|
||||
const result = await requestReviewersWithFallback({
|
||||
candidates,
|
||||
requiredReviewers: 2,
|
||||
requestReviewer: async (reviewer) => {
|
||||
attempts.push(reviewer);
|
||||
if (reviewer === 'wxianfeng') {
|
||||
throw Object.assign(new Error('temporary failure'), {status: 503});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ['haofeng0705', 'wxianfeng', 'typefield']);
|
||||
assert.deepEqual(result.requested, ['haofeng0705', 'typefield']);
|
||||
assert.equal(result.satisfiedReviewers.length, 2);
|
||||
}
|
||||
|
||||
async function testLowerPriorityExistingRequestDoesNotReplaceOwner() {
|
||||
const attempts = [];
|
||||
const result = await requestReviewersWithFallback({
|
||||
candidates: ['wxianfeng', 'typefield'],
|
||||
requiredReviewers: 1,
|
||||
satisfiedReviewers: ['typefield'],
|
||||
requestReviewer: async (reviewer) => {
|
||||
attempts.push(reviewer);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ['wxianfeng']);
|
||||
assert.deepEqual(result.requested, ['wxianfeng']);
|
||||
assert.deepEqual(result.satisfiedReviewers, ['wxianfeng']);
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
testSingleReviewerFallback(),
|
||||
testTwoReviewerFallback(),
|
||||
testLowerPriorityExistingRequestDoesNotReplaceOwner(),
|
||||
])
|
||||
.then(() => console.log('reviewer routing policy tests passed'))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
name: Code Admission — AI Behavior
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
ai-behavior-check:
|
||||
name: AI Behavior
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# Deliberately do not check out or execute pull-request code here.
|
||||
# pull_request_target keeps this policy anchored to the base branch.
|
||||
- name: Check AI-generated PR boundaries
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pullRequest = context.payload.pull_request;
|
||||
const sha = context.eventName === 'push' ? context.sha : pullRequest.head.sha;
|
||||
const setStatus = (state, description) =>
|
||||
github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
sha,
|
||||
state,
|
||||
context: 'AI Behavior',
|
||||
description,
|
||||
});
|
||||
|
||||
await setStatus('pending', 'Evaluating AI-generated PR boundaries');
|
||||
|
||||
if (context.eventName === 'push') {
|
||||
await setStatus('success', 'Not applicable to the protected main push');
|
||||
core.notice('AI Behavior is a PR policy; the main push context is sealed.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const expectedHead = pullRequest.head.sha;
|
||||
const expectedBase = pullRequest.base.sha;
|
||||
const currentPull = async (phase) => {
|
||||
const { data: pull } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
});
|
||||
if (pull.head.sha !== expectedHead || pull.base.sha !== expectedBase) {
|
||||
throw new Error(
|
||||
`Pull request revision changed during ${phase}: ` +
|
||||
`expected base/head ${expectedBase}/${expectedHead}, ` +
|
||||
`got ${pull.base.sha}/${pull.head.sha}`
|
||||
);
|
||||
}
|
||||
return pull;
|
||||
};
|
||||
|
||||
const before = await currentPull('pre-policy check');
|
||||
const labels = before.labels.map(({ name }) => name);
|
||||
if (!labels.includes('ai-generated')) {
|
||||
await setStatus('success', 'Not labeled ai-generated');
|
||||
core.notice('Not an ai-generated PR; no AI-only policy applied.');
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
await currentPull('post-policy check');
|
||||
|
||||
const maxChangedFiles = 30;
|
||||
if (files.length > maxChangedFiles) {
|
||||
await setStatus(
|
||||
'failure',
|
||||
`Changes ${files.length} files; limit is ${maxChangedFiles}`
|
||||
);
|
||||
core.setFailed(
|
||||
`AI-generated PR changes ${files.length} files; limit is ${maxChangedFiles}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const isProtectedPath = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('.github/workflows/') ||
|
||||
filename.startsWith('scripts/ci/') ||
|
||||
filename.startsWith('scripts/policy/') ||
|
||||
filename.startsWith('scripts/release/') ||
|
||||
filename === 'test/fixtures/cli-interface-baseline.txt' ||
|
||||
filename === '.goreleaser.yaml' ||
|
||||
filename === 'Makefile'
|
||||
);
|
||||
const protectedPaths = [...new Set(
|
||||
files
|
||||
.flatMap(({ filename, previous_filename }) => [filename, previous_filename])
|
||||
.filter(isProtectedPath)
|
||||
)];
|
||||
|
||||
if (protectedPaths.length > 0) {
|
||||
await setStatus('failure', 'Modifies protected release/CI infrastructure');
|
||||
core.setFailed(
|
||||
'AI-generated PR modifies protected release/CI infrastructure:\n' +
|
||||
protectedPaths.map((filename) => ` - ${filename}`).join('\n') +
|
||||
'\nSplit these changes into a human-owned PR with explicit review.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await setStatus(
|
||||
'success',
|
||||
`Passed with ${files.length} changed files (limit ${maxChangedFiles})`
|
||||
);
|
||||
core.notice(
|
||||
`AI behavior check passed (${files.length} changed files; limit ${maxChangedFiles}).`
|
||||
);
|
||||
} catch (error) {
|
||||
await setStatus('error', 'Could not evaluate the exact pull request revision');
|
||||
throw error;
|
||||
}
|
||||
+76
-1801
File diff suppressed because it is too large
Load Diff
@@ -1,296 +0,0 @@
|
||||
name: PR Eval Dispatch
|
||||
|
||||
# `/eval <products> [sha=<full-head-sha>] [cases=<ref>]` PR 评论 → 生成可验证的评测请求,报告由 bot 回贴。
|
||||
# 本 workflow 只在默认分支上下文运行,不 checkout、不执行 PR 代码。
|
||||
# 审核 SHA 规则:评测他人 PR 必须显式携带 sha=(审阅背书凭据,验证
|
||||
# 其恰为当前 open head);评测自己创建的 PR 可省略,自动钉住派发时刻
|
||||
# 的当前 head(作者自背书,无第三方偷换窗口);受控评测执行端另以
|
||||
# FETCH_HEAD 校验兜底派发后的变更。
|
||||
# 授权两级:仓库 write/maintain/admin 可派发任意 PR;默认分支
|
||||
# .github/eval-allowlist.txt 名单内的用户仅可派发自己创建的 PR。
|
||||
# 触发通道:workflow 先创建占位评论,再上传与本次 run/comment 绑定的
|
||||
# 不可变 manifest artifact,最后把 artifact 指针写回同一评论。评论仅是
|
||||
# 不可信通知;受控评测服务必须验证成功 run、artifact 与 manifest,并在
|
||||
# 触发评测前原子占用 manifest.idempotency_key,重复占用只能 no-op。
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types:
|
||||
- created
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: eval-dispatch-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
name: Dispatch internal evaluation
|
||||
if: >-
|
||||
github.event.issue.pull_request &&
|
||||
startsWith(github.event.comment.body, '/eval')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
# 该 job 仅处理 PR;评论写入也限定在 PR Conversation 这一权限域。
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out default branch tooling
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Verify commenter dispatch authorization
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
COMMENTER: ${{ github.event.comment.user.login }}
|
||||
PR_AUTHOR: ${{ github.event.issue.user.login }}
|
||||
EVAL_ALLOWLIST_PATH: .github/eval-allowlist.txt
|
||||
run: |
|
||||
# 不用 --fail:非协作者查权限返回 404 错误体,交由 guard 走名单分支;硬网络错误降级为空对象同样 fail-closed
|
||||
permission_json="$(curl --silent --show-error \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/collaborators/${COMMENTER}/permission")" || permission_json='{}'
|
||||
printf '%s' "$permission_json" | python3 scripts/ci/eval_dispatch_guard.py permission
|
||||
|
||||
- name: Parse /eval command
|
||||
id: parse
|
||||
continue-on-error: true
|
||||
env:
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
run: python3 scripts/ci/eval_comment_parse.py
|
||||
|
||||
- name: Reply usage on parse failure
|
||||
if: steps.parse.outcome == 'failure'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
PARSE_ERROR: ${{ steps.parse.outputs.error }}
|
||||
run: |
|
||||
body="❌ /eval 命令解析失败:${PARSE_ERROR}"
|
||||
gh api --method POST \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
|
||||
--raw-field body="$body" \
|
||||
> /dev/null
|
||||
exit 1
|
||||
|
||||
- name: Verify reviewed PR head
|
||||
id: pr
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
EXPECTED_PR_NUMBER: ${{ github.event.issue.number }}
|
||||
REVIEWED_SHA: ${{ steps.parse.outputs.reviewed_sha }}
|
||||
COMMENTER: ${{ github.event.comment.user.login }}
|
||||
run: |
|
||||
pr_json="$(curl --fail --silent --show-error \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")"
|
||||
printf '%s' "$pr_json" \
|
||||
| python3 scripts/ci/eval_dispatch_guard.py head \
|
||||
>> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create dispatch placeholder
|
||||
id: placeholder
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
placeholder_body="🛰️ /eval 请求已通过权限与版本校验,正在生成可验证的评测请求。"
|
||||
response="$(
|
||||
gh api --method POST \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
|
||||
--raw-field body="$placeholder_body"
|
||||
)"
|
||||
comment_id="$(
|
||||
printf '%s' "$response" \
|
||||
| jq -er \
|
||||
--arg issue_url "https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}" \
|
||||
'select(.issue_url == $issue_url) | .id | tostring | select(test("^[1-9][0-9]*$"))'
|
||||
)"
|
||||
printf 'comment_id=%s\n' "$comment_id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build dispatch request manifest
|
||||
env:
|
||||
REPOSITORY_ID: '1187709537'
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
WORKFLOW_ID: '331725458'
|
||||
WORKFLOW_PATH: .github/workflows/eval-dispatch.yml
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
SOURCE_COMMENT_ID: ${{ github.event.comment.id }}
|
||||
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
|
||||
ACTOR_ID: ${{ github.event.comment.user.id }}
|
||||
ACTOR_LOGIN: ${{ github.event.comment.user.login }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
PRODUCTS: ${{ steps.parse.outputs.products }}
|
||||
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
|
||||
SOURCE_BODY: ${{ github.event.comment.body }}
|
||||
MANIFEST_PATH: ${{ runner.temp }}/eval-dispatch-request.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$REPOSITORY" != "DingTalk-Real-AI/dingtalk-workspace-cli" ]; then
|
||||
echo "unexpected repository: ${REPOSITORY}" >&2
|
||||
exit 1
|
||||
fi
|
||||
for value in \
|
||||
"$REPOSITORY_ID" \
|
||||
"$WORKFLOW_ID" \
|
||||
"$RUN_ID" \
|
||||
"$RUN_ATTEMPT" \
|
||||
"$SOURCE_COMMENT_ID" \
|
||||
"$DISPATCH_COMMENT_ID" \
|
||||
"$ACTOR_ID" \
|
||||
"$PR_NUMBER"; do
|
||||
if [[ ! "$value" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "dispatch manifest contains a non-canonical identifier" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [[ ! "$PR_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "dispatch manifest contains an invalid PR head SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
hash_output="$(printf '%s' "$SOURCE_BODY" | sha256sum)"
|
||||
source_body_sha256="${hash_output%% *}"
|
||||
if [[ ! "$source_body_sha256" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "failed to hash source comment" >&2
|
||||
exit 1
|
||||
fi
|
||||
idempotency_key="${REPOSITORY_ID}:${SOURCE_COMMENT_ID}"
|
||||
|
||||
umask 077
|
||||
jq -n \
|
||||
--arg repository_id "$REPOSITORY_ID" \
|
||||
--arg repository "$REPOSITORY" \
|
||||
--arg workflow_id "$WORKFLOW_ID" \
|
||||
--arg workflow_path "$WORKFLOW_PATH" \
|
||||
--arg run_id "$RUN_ID" \
|
||||
--arg run_attempt "$RUN_ATTEMPT" \
|
||||
--arg source_comment_id "$SOURCE_COMMENT_ID" \
|
||||
--arg dispatch_comment_id "$DISPATCH_COMMENT_ID" \
|
||||
--arg actor_id "$ACTOR_ID" \
|
||||
--arg actor_login "$ACTOR_LOGIN" \
|
||||
--arg pr_number "$PR_NUMBER" \
|
||||
--arg pr_head_sha "$PR_HEAD_SHA" \
|
||||
--arg products "$PRODUCTS" \
|
||||
--arg cases_ref "$CASES_REF" \
|
||||
--arg source_body_sha256 "$source_body_sha256" \
|
||||
--arg idempotency_key "$idempotency_key" \
|
||||
'{
|
||||
schema_version: 1,
|
||||
repository_id: $repository_id,
|
||||
repository: $repository,
|
||||
workflow_id: $workflow_id,
|
||||
workflow_path: $workflow_path,
|
||||
run_id: $run_id,
|
||||
run_attempt: $run_attempt,
|
||||
source_comment_id: $source_comment_id,
|
||||
dispatch_comment_id: $dispatch_comment_id,
|
||||
actor_id: $actor_id,
|
||||
actor_login: $actor_login,
|
||||
pr_number: $pr_number,
|
||||
pr_head_sha: $pr_head_sha,
|
||||
products: $products,
|
||||
cases_ref: $cases_ref,
|
||||
source_body_sha256: $source_body_sha256,
|
||||
idempotency_key: $idempotency_key
|
||||
}' > "$MANIFEST_PATH"
|
||||
|
||||
- name: Upload dispatch request manifest
|
||||
id: artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: eval-dispatch-request-${{ github.run_id }}-${{ github.run_attempt }}-${{ steps.placeholder.outputs.comment_id }}
|
||||
path: ${{ runner.temp }}/eval-dispatch-request.json
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
overwrite: false
|
||||
|
||||
- name: Finalize dispatch marker
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
|
||||
REPOSITORY_ID: '1187709537'
|
||||
WORKFLOW_ID: '331725458'
|
||||
WORKFLOW_PATH: .github/workflows/eval-dispatch.yml
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }}
|
||||
ARTIFACT_DIGEST: ${{ steps.artifact.outputs.artifact-digest }}
|
||||
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
PRODUCTS: ${{ steps.parse.outputs.products }}
|
||||
CASES_REF: ${{ steps.parse.outputs.cases_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "$DISPATCH_COMMENT_ID" =~ ^[1-9][0-9]*$ ]] || \
|
||||
[[ ! "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "artifact marker contains a non-canonical identifier" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
artifact_digest="${ARTIFACT_DIGEST,,}"
|
||||
if [[ "$artifact_digest" != sha256:* ]]; then
|
||||
artifact_digest="sha256:${artifact_digest}"
|
||||
fi
|
||||
if [[ ! "$artifact_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then
|
||||
echo "artifact marker contains an invalid digest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
marker_json="$(
|
||||
jq -nc \
|
||||
--arg repository_id "$REPOSITORY_ID" \
|
||||
--arg workflow_id "$WORKFLOW_ID" \
|
||||
--arg workflow_path "$WORKFLOW_PATH" \
|
||||
--arg run_id "$RUN_ID" \
|
||||
--arg run_attempt "$RUN_ATTEMPT" \
|
||||
--arg dispatch_comment_id "$DISPATCH_COMMENT_ID" \
|
||||
--arg artifact_id "$ARTIFACT_ID" \
|
||||
--arg artifact_digest "$artifact_digest" \
|
||||
'{
|
||||
schema_version: 1,
|
||||
repository_id: $repository_id,
|
||||
workflow_id: $workflow_id,
|
||||
workflow_path: $workflow_path,
|
||||
run_id: $run_id,
|
||||
run_attempt: $run_attempt,
|
||||
dispatch_comment_id: $dispatch_comment_id,
|
||||
artifact_id: $artifact_id,
|
||||
artifact_digest: $artifact_digest
|
||||
}'
|
||||
)"
|
||||
cases_note=""
|
||||
if [ -n "$CASES_REF" ]; then
|
||||
cases_note=",用例版本 \`${CASES_REF}\`"
|
||||
fi
|
||||
body="<!-- eval-dispatch: ${marker_json} -->"$'\n'"🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。"$'\n'"受控评测服务将在数分钟内处理,完成后由 bot 回贴报告。"
|
||||
response="$(
|
||||
gh api --method PATCH \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
|
||||
--raw-field body="$body"
|
||||
)"
|
||||
printf '%s' "$response" \
|
||||
| jq -e \
|
||||
--arg comment_id "$DISPATCH_COMMENT_ID" \
|
||||
--arg body "$body" \
|
||||
'((.id | tostring) == $comment_id) and (.body == $body)' \
|
||||
> /dev/null
|
||||
|
||||
- name: Mark dispatch preparation failure
|
||||
if: ${{ failure() && steps.placeholder.outputs.comment_id != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
|
||||
run: |
|
||||
failure_body="❌ /eval 请求准备失败,未生成可消费的评测请求。请稍后重试。"
|
||||
gh api --method PATCH \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
|
||||
--raw-field body="$failure_body" \
|
||||
> /dev/null \
|
||||
|| true
|
||||
@@ -14,12 +14,9 @@ jobs:
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const webhooks = [
|
||||
process.env.DINGTALK_WEBHOOK,
|
||||
process.env.DINGTALK_WEBHOOK_SECONDARY
|
||||
].filter(Boolean);
|
||||
if (webhooks.length === 0) {
|
||||
console.log('⚠️ No DingTalk webhook configured, skipping notification');
|
||||
const webhook = process.env.DINGTALK_WEBHOOK;
|
||||
if (!webhook) {
|
||||
console.log('⚠️ DINGTALK_WEBHOOK not set, skipping notification');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,15 +39,12 @@ jobs:
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(webhooks.map(webhook =>
|
||||
fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message)
|
||||
})
|
||||
));
|
||||
await fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message)
|
||||
});
|
||||
|
||||
console.log(`✅ DingTalk notification sent to ${webhooks.length} webhook(s)`);
|
||||
console.log('✅ DingTalk notification sent');
|
||||
env:
|
||||
DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }}
|
||||
DINGTALK_WEBHOOK_SECONDARY: ${{ secrets.DINGTALK_WEBHOOK_SECONDARY }}
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
# 把本仓库 main 代码自动镜像到 Gitee,供国内用户访问 raw 脚本入口。
|
||||
# Release tag 与附件只由 release.yml 的受控 publication queue 发布。
|
||||
# 用 HTTPS + 令牌直接 git push(无需 SSH key),复用已配置的 secret:
|
||||
# GITEE_TOKEN —— Gitee 私人令牌(勾 projects)
|
||||
# GITEE_USER —— 令牌所属 Gitee 用户名(用于 https 推送鉴权)
|
||||
# GITEE_REPO —— "owner/repo",如 DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
# 未配置 GITEE_TOKEN 时(如 fork)自动跳过,不报红叉。
|
||||
name: Mirror code to Gitee
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
- cron: '0 18 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sync_release_version:
|
||||
description: "Sync a specific release version's assets to Gitee (e.g. v1.0.55-beta.3)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: gitee-code-mirror
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.ref_name == github.event.repository.default_branch && github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
env:
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
steps:
|
||||
- name: Checkout main history
|
||||
if: env.GITEE_TOKEN != ''
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Push main + tags to Gitee (with README localization)
|
||||
if: env.GITEE_TOKEN != ''
|
||||
run: |
|
||||
set -eu
|
||||
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
|
||||
|
||||
git fetch --force origin 'refs/heads/main:refs/remotes/origin/main'
|
||||
|
||||
# Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。
|
||||
# GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。
|
||||
git checkout -B gitee-main origin/main
|
||||
git config user.email "actions@github.com"
|
||||
git config user.name "github-actions[bot]"
|
||||
|
||||
# 1) 安装命令本地化:raw.githubusercontent → gitee raw(国内可达)。
|
||||
for f in README.md README_zh.md; do
|
||||
[ -f "$f" ] || continue
|
||||
sed -i "s#raw.githubusercontent.com/${GITEE_REPO}/main#gitee.com/${GITEE_REPO}/raw/main#g" "$f"
|
||||
done
|
||||
|
||||
# 2) coverage 徽章:仓库内相对路径 svg 在 Gitee 渲染不出来(gitee raw 对 svg
|
||||
# 返回需签名、会过期的 URL,且 content-type 为 text/plain)。改成 shields.io
|
||||
# 静态徽章——数值取自仓库 coverage.svg,颜色按覆盖率阈值。
|
||||
SVG=".github/badges/coverage.svg"
|
||||
if [ -f "$SVG" ]; then
|
||||
PCT="$(grep -oE '[0-9]+(\.[0-9]+)?%' "$SVG" | head -1)"
|
||||
NUM="${PCT%\%}"; INT="${NUM%.*}"
|
||||
if [ "${INT:-0}" -ge 80 ]; then C=brightgreen; elif [ "${INT:-0}" -ge 60 ]; then C=yellow; else C=red; fi
|
||||
BADGE="https://img.shields.io/badge/coverage-${NUM}%25-${C}"
|
||||
for f in README.md README_zh.md; do
|
||||
[ -f "$f" ] || continue
|
||||
sed -i "s#\.github/badges/coverage\.svg#${BADGE}#g" "$f"
|
||||
done
|
||||
fi
|
||||
|
||||
git add README.md README_zh.md 2>/dev/null || true
|
||||
git commit -m "docs(gitee): localize install commands + coverage badge for Gitee mirror" || true
|
||||
|
||||
# main 镜像对齐;release tag 由 release.yml 单独校验后创建,禁止在这里 force。
|
||||
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
|
||||
echo "✅ 已镜像 main(含 Gitee README 本地化)到 Gitee ${GITEE_REPO}"
|
||||
|
||||
- name: Download GitHub Release assets
|
||||
if: ${{ inputs.sync_release_version != '' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.sync_release_version }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
echo "📥 Downloading release assets for ${VERSION}"
|
||||
mkdir -p dist
|
||||
gh release download "$VERSION" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
ls -la dist/
|
||||
|
||||
- name: Verify release artifacts
|
||||
if: ${{ inputs.sync_release_version != '' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.sync_release_version }}
|
||||
run: |
|
||||
set -eu
|
||||
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
|
||||
./scripts/release/verify-release-artifacts.sh "$VERSION"
|
||||
|
||||
- name: Sync release assets to Gitee
|
||||
if: ${{ inputs.sync_release_version != '' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.sync_release_version }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
DIST_DIR: ${{ github.workspace }}/dist
|
||||
run: |
|
||||
set -eu
|
||||
echo "📦 Syncing release assets for ${VERSION} to Gitee ${GITEE_REPO}"
|
||||
./scripts/release/sync-to-gitee.sh
|
||||
@@ -1,56 +0,0 @@
|
||||
name: Main Integration — 主干集成
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: multi-profile-e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
multi-profile-e2e:
|
||||
name: Multi-profile E2E
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
MULTI_PROFILE_E2E_LOG: .tmp-bin/multi-profile-e2e.log
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run isolated multi-profile chain
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
mkdir -p .tmp-bin
|
||||
bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir | tee "$MULTI_PROFILE_E2E_LOG"
|
||||
{
|
||||
echo "### Multi-profile E2E"
|
||||
echo "- Command: \`bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir\`"
|
||||
echo "- Scope: isolated auth/profile storage, profile switch/use, one-shot profile override, CSV multi-profile aggregation, legacy migration"
|
||||
echo "- Result: passed"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload debug artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: multi-profile-e2e-debug
|
||||
path: |
|
||||
.tmp-bin/multi-profile-e2e.*/out
|
||||
.tmp-bin/multi-profile-e2e.log
|
||||
include-hidden-files: true
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
@@ -1,38 +0,0 @@
|
||||
name: Main Integration — Wukong Overlay
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- CI
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
notify-downstream:
|
||||
name: Notify Wukong Overlay
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Trigger downstream CI
|
||||
env:
|
||||
UPSTREAM_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
WUKONG_TRIGGER_TOKEN: ${{ secrets.WUKONG_TRIGGER_TOKEN }}
|
||||
WUKONG_TRIGGER_URL: ${{ secrets.WUKONG_TRIGGER_URL }}
|
||||
run: |
|
||||
if [ -n "$WUKONG_TRIGGER_TOKEN" ]; then
|
||||
curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-F "token=$WUKONG_TRIGGER_TOKEN" \
|
||||
-F "ref=main" \
|
||||
-F "variables[UPSTREAM_SHA]=$UPSTREAM_SHA" \
|
||||
"$WUKONG_TRIGGER_URL"
|
||||
echo "Downstream CI triggered."
|
||||
else
|
||||
echo "No WUKONG_TRIGGER_TOKEN configured, skipping downstream notification."
|
||||
fi
|
||||
+17
-3275
File diff suppressed because it is too large
Load Diff
@@ -1,301 +0,0 @@
|
||||
name: Reviewer routing
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
# pull_request_target deliberately runs only this workflow from the protected
|
||||
# base branch. Never check out or execute pull-request code here.
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: reviewer-router-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
route:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out trusted routing policy
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
- name: Route review and enable auto-merge
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const pullNumber = context.payload.pull_request.number;
|
||||
const eventHeadSha = context.payload.pull_request.head.sha;
|
||||
const {
|
||||
REVIEWER_POOL,
|
||||
requestReviewersWithFallback,
|
||||
resolveReviewRouting,
|
||||
reviewerCandidates,
|
||||
} = require('./.github/reviewer-routing.js');
|
||||
const reviewerPool = REVIEWER_POOL;
|
||||
|
||||
async function getReadyEventPull(phase) {
|
||||
const {data: currentPull} = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
if (
|
||||
currentPull.head.sha !== eventHeadSha ||
|
||||
currentPull.state !== 'open' ||
|
||||
currentPull.draft ||
|
||||
currentPull.base.ref !== 'main'
|
||||
) {
|
||||
core.info(
|
||||
`PR #${pullNumber} state or revision no longer matches this ready-main event during ${phase}; routing stopped.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return currentPull;
|
||||
}
|
||||
const pullRequest = await getReadyEventPull('initial read');
|
||||
if (!pullRequest) {
|
||||
return;
|
||||
}
|
||||
const author = pullRequest.user.login.toLowerCase();
|
||||
const headSha = pullRequest.head.sha;
|
||||
const latestPusher =
|
||||
context.payload.action === 'synchronize'
|
||||
? context.payload.sender?.login?.toLowerCase()
|
||||
: author;
|
||||
|
||||
async function routeReview() {
|
||||
let changedFiles;
|
||||
try {
|
||||
changedFiles = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not inspect changed files for PR #${pullNumber}; using load-balanced fallback (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
changedFiles = [];
|
||||
}
|
||||
|
||||
const eligible = reviewerPool.filter(
|
||||
reviewer =>
|
||||
reviewer.toLowerCase() !== author &&
|
||||
reviewer.toLowerCase() !== latestPusher,
|
||||
);
|
||||
if (eligible.length === 0) {
|
||||
core.warning(`No eligible reviewer remains for PR #${pullNumber}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingRequestedReviewers = new Set(
|
||||
(pullRequest.requested_reviewers || []).map(({login}) => login.toLowerCase()),
|
||||
);
|
||||
|
||||
let reviews;
|
||||
try {
|
||||
reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not inspect existing reviews for PR #${pullNumber}; skipping reviewer routing to avoid a duplicate request (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const latestDecisionByLogin = new Map();
|
||||
for (const review of reviews) {
|
||||
const login = review.user?.login?.toLowerCase();
|
||||
if (
|
||||
!login ||
|
||||
!['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(
|
||||
review.state,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const previous = latestDecisionByLogin.get(login);
|
||||
if (!previous || review.id > previous.id) {
|
||||
latestDecisionByLogin.set(login, review);
|
||||
}
|
||||
}
|
||||
const currentHeadReviewers = new Set(
|
||||
[...latestDecisionByLogin.values()]
|
||||
.filter(
|
||||
review =>
|
||||
review.commit_id === headSha &&
|
||||
eligible.some(
|
||||
reviewer => reviewer.toLowerCase() === review.user.login.toLowerCase(),
|
||||
) &&
|
||||
['APPROVED', 'CHANGES_REQUESTED'].includes(review.state),
|
||||
)
|
||||
.map(review => review.user.login.toLowerCase()),
|
||||
);
|
||||
|
||||
const loads = new Map(eligible.map(reviewer => [reviewer, 0]));
|
||||
try {
|
||||
const openPullRequests = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
for (const openPullRequest of openPullRequests) {
|
||||
for (const reviewer of openPullRequest.requested_reviewers || []) {
|
||||
const candidate = eligible.find(
|
||||
login => login.toLowerCase() === reviewer.login.toLowerCase(),
|
||||
);
|
||||
if (candidate) {
|
||||
loads.set(candidate, loads.get(candidate) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not read current reviewer load; using deterministic rotation (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const offset = pullNumber % eligible.length;
|
||||
const rotated = eligible.slice(offset).concat(eligible.slice(0, offset));
|
||||
const tieOrder = new Map(rotated.map((reviewer, index) => [reviewer, index]));
|
||||
const staleChangeRequester = [...latestDecisionByLogin.values()]
|
||||
.filter(review => review.state === 'CHANGES_REQUESTED')
|
||||
.sort((left, right) => right.id - left.id)
|
||||
.map(review =>
|
||||
eligible.find(
|
||||
reviewer =>
|
||||
reviewer.toLowerCase() === review.user.login.toLowerCase(),
|
||||
),
|
||||
)
|
||||
.find(Boolean);
|
||||
const ranked = [...eligible].sort(
|
||||
(left, right) =>
|
||||
Number(right === staleChangeRequester) -
|
||||
Number(left === staleChangeRequester) ||
|
||||
loads.get(left) - loads.get(right) ||
|
||||
tieOrder.get(left) - tieOrder.get(right),
|
||||
);
|
||||
|
||||
const routing = resolveReviewRouting({
|
||||
files: changedFiles,
|
||||
author,
|
||||
latestPusher,
|
||||
fallbackReviewers: ranked,
|
||||
});
|
||||
const candidates = reviewerCandidates({
|
||||
preferredReviewers: routing.reviewers,
|
||||
fallbackReviewers: ranked,
|
||||
eligibleReviewers: eligible,
|
||||
});
|
||||
const desiredReviewers = candidates.slice(0, routing.requiredReviewers);
|
||||
if (routing.reason === 'unknown_paths' && currentHeadReviewers.size > 0) {
|
||||
core.info(
|
||||
`PR #${pullNumber} has a current-head review for unknown paths; leaving manual ownership unchanged.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
core.info(
|
||||
`PR #${pullNumber} routing: ${routing.reason}; modules=${routing.modules.map(module => module.id).join(',') || 'unknown'}; reviewers=${desiredReviewers.join(',') || 'load-balanced fallback'}.`,
|
||||
);
|
||||
|
||||
const satisfiedReviewers = new Set([
|
||||
...currentHeadReviewers,
|
||||
...[...existingRequestedReviewers].filter((reviewer) =>
|
||||
candidates.some((candidate) => candidate.toLowerCase() === reviewer),
|
||||
),
|
||||
]);
|
||||
const requestResult = await requestReviewersWithFallback({
|
||||
candidates,
|
||||
requiredReviewers: routing.requiredReviewers,
|
||||
satisfiedReviewers: [...satisfiedReviewers],
|
||||
requestReviewer: async (reviewer) => {
|
||||
const currentPull = await getReadyEventPull('review request');
|
||||
if (!currentPull) {
|
||||
return false;
|
||||
}
|
||||
await github.rest.pulls.requestReviewers({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
reviewers: [reviewer],
|
||||
});
|
||||
core.info(
|
||||
`Requested @${reviewer} for PR #${pullNumber} (open request load: ${loads.get(reviewer)}).`,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
onFailure: (reviewer, error) => {
|
||||
core.warning(
|
||||
`Could not request @${reviewer} for PR #${pullNumber}; trying the next candidate (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
},
|
||||
});
|
||||
if (requestResult.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestResult.satisfiedReviewers.length < routing.requiredReviewers) {
|
||||
core.warning(
|
||||
`Only ${requestResult.satisfiedReviewers.length} of ${routing.requiredReviewers} required reviewers could be satisfied for PR #${pullNumber}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function enableAutoMerge() {
|
||||
try {
|
||||
const currentPull = await getReadyEventPull('auto-merge enable');
|
||||
if (!currentPull) {
|
||||
return;
|
||||
}
|
||||
if (currentPull.auto_merge) {
|
||||
core.info(`Auto-merge is already enabled for PR #${pullNumber}.`);
|
||||
return;
|
||||
}
|
||||
await github.graphql(
|
||||
`mutation EnableAutoMerge($pullRequestId: ID!) {
|
||||
enablePullRequestAutoMerge(
|
||||
input: {
|
||||
pullRequestId: $pullRequestId
|
||||
mergeMethod: MERGE
|
||||
}
|
||||
) {
|
||||
pullRequest {
|
||||
autoMergeRequest {
|
||||
enabledAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{pullRequestId: currentPull.node_id},
|
||||
);
|
||||
core.info(`Enabled native auto-merge for PR #${pullNumber}.`);
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not enable auto-merge for PR #${pullNumber}; checks and review can continue normally (${error.message}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await routeReview();
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Reviewer routing hit an unexpected error for PR #${pullNumber}; review can still proceed manually (${error.message}).`,
|
||||
);
|
||||
}
|
||||
await enableAutoMerge();
|
||||
@@ -1,148 +0,0 @@
|
||||
name: Withdraw release
|
||||
run-name: Withdraw ${{ inputs.version }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Exact published version to withdraw (vX.Y.Z or vX.Y.Z-beta.N)"
|
||||
required: true
|
||||
type: string
|
||||
reason:
|
||||
description: "Public, single-line withdrawal reason (8-300 characters)"
|
||||
required: true
|
||||
type: string
|
||||
confirmation:
|
||||
description: "Type WITHDRAW followed by a space and the exact version"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Share the publication lock with release.yml. A withdrawal and a publication
|
||||
# must never mutate channel pointers concurrently.
|
||||
concurrency:
|
||||
group: dws-release-publication
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
withdraw:
|
||||
name: Withdraw release from every distribution channel
|
||||
environment: release-withdrawal
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
steps:
|
||||
- name: Verify withdrawal environment protection
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const response = await github.request(
|
||||
"GET /repos/{owner}/{repo}/environments/{environment_name}",
|
||||
{ owner, repo, environment_name: "release-withdrawal" },
|
||||
);
|
||||
const reviewerRule = response.data.protection_rules.find(
|
||||
(rule) => rule.type === "required_reviewers",
|
||||
);
|
||||
if (
|
||||
!reviewerRule ||
|
||||
reviewerRule.prevent_self_review !== true ||
|
||||
!Array.isArray(reviewerRule.reviewers) ||
|
||||
reviewerRule.reviewers.length === 0
|
||||
) {
|
||||
core.setFailed("release-withdrawal must require a reviewer and prevent self-review");
|
||||
return;
|
||||
}
|
||||
if (response.data.deployment_branch_policy?.protected_branches !== true) {
|
||||
core.setFailed("release-withdrawal must allow only protected branches");
|
||||
}
|
||||
if (response.data.can_admins_bypass !== false) {
|
||||
core.setFailed("release-withdrawal must not allow administrator bypass");
|
||||
}
|
||||
|
||||
- name: Require the exact current official default-branch commit
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const expectedRepository = "DingTalk-Real-AI/dingtalk-workspace-cli";
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
if (context.eventName !== "workflow_dispatch") {
|
||||
core.setFailed("release withdrawal accepts workflow_dispatch only");
|
||||
return;
|
||||
}
|
||||
if (`${context.repo.owner}/${context.repo.repo}` !== expectedRepository) {
|
||||
core.setFailed(`release withdrawal is restricted to ${expectedRepository}`);
|
||||
return;
|
||||
}
|
||||
if (context.ref !== `refs/heads/${defaultBranch}`) {
|
||||
core.setFailed(`release withdrawal must be dispatched from ${defaultBranch}`);
|
||||
return;
|
||||
}
|
||||
const branch = await github.rest.git.getRef({
|
||||
...context.repo,
|
||||
ref: `heads/${defaultBranch}`,
|
||||
});
|
||||
if (branch.data.object.sha !== context.sha) {
|
||||
core.setFailed(
|
||||
`default branch advanced to ${branch.data.object.sha}; re-dispatch from the new head`,
|
||||
);
|
||||
}
|
||||
|
||||
- name: Check out trusted withdrawal tooling
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js for npm channel withdrawal
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Withdraw immutable release and roll back channels
|
||||
id: withdrawal
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
GITHUB_EVENT_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
WITHDRAW_VERSION: ${{ inputs.version }}
|
||||
WITHDRAW_REASON: ${{ inputs.reason }}
|
||||
WITHDRAW_CONFIRMATION: ${{ inputs.confirmation }}
|
||||
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
|
||||
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
||||
OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}
|
||||
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
|
||||
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
DWS_GITEE_ENABLED: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && 'true' || 'false' }}
|
||||
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
|
||||
run: |
|
||||
./scripts/release/withdraw-release.sh \
|
||||
"$WITHDRAW_VERSION" \
|
||||
"$WITHDRAW_REASON" \
|
||||
"$WITHDRAW_CONFIRMATION"
|
||||
|
||||
- name: Report withdrawal boundary
|
||||
if: ${{ always() }}
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
RESULT: ${{ steps.withdrawal.outcome }}
|
||||
run: |
|
||||
{
|
||||
echo "### Release withdrawal: ${VERSION}"
|
||||
echo
|
||||
echo "- Workflow result: ${RESULT}"
|
||||
echo "- Success means every configured channel was verified and the permanent withdrawn/${VERSION} tombstone remains as the version-reuse barrier."
|
||||
echo "- Failure may occur before or after the tombstone/channel mutations; inspect the failed step and rerun the exact same inputs after fixing the cause."
|
||||
echo "- The problem GitHub Release and original tag are removed after npm and every tag-enabled/configured mirror are rolled back, so GitHub installers stop resolving the bad version while the Homebrew rollback PR is reviewed."
|
||||
echo "- npm is deprecated rather than unpublished; already-installed clients cannot be remotely downgraded."
|
||||
echo "- If a Homebrew rollback PR was opened, this run remains failed until that PR is independently reviewed, merged, and the workflow is rerun."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
-45
@@ -16,14 +16,8 @@ dws
|
||||
test/cli/testdata/
|
||||
tmp/
|
||||
test/cli_compat/testdata/
|
||||
/internal/compat/testdata/*
|
||||
.gitignore
|
||||
.worktrees/
|
||||
.qoder/
|
||||
_logs/
|
||||
_docs/
|
||||
_output/
|
||||
vendor/
|
||||
|
||||
# Secrets & credentials
|
||||
.env
|
||||
@@ -31,45 +25,6 @@ vendor/
|
||||
*.pem
|
||||
*.key
|
||||
credentials*
|
||||
!skills/**/credentials.md
|
||||
plans
|
||||
_docs
|
||||
dws.zip
|
||||
*.code-workspace
|
||||
/dingtalk-workspace.zip
|
||||
|
||||
# envelope/discovery.pre.json synced via Portal, not git-tracked
|
||||
/envelope/discovery.pre.json
|
||||
|
||||
# local/pre-release MCP service configs may contain personal gateway keys
|
||||
/docs/mcp/serviceconfig-pre*
|
||||
|
||||
# 功能测试运行产物
|
||||
results.jsonl
|
||||
test/dev_functional/results.jsonl
|
||||
/auto-test/
|
||||
/eval-runs/
|
||||
/.qoder/
|
||||
.vercel
|
||||
.env*
|
||||
# Local Go coverage output
|
||||
/coverage.txt
|
||||
/coverage-base.txt
|
||||
/coverage-policy.txt
|
||||
/coverage.html
|
||||
dwsbin
|
||||
|
||||
# Local shortcut eval / real-backend capture artifacts — may contain real PII
|
||||
# (employee names/emails, userIds, conversation & message IDs). Never commit.
|
||||
/docs/shortcut-real-read-results.json
|
||||
/docs/shortcut-real-write-results.json
|
||||
/docs/shortcut-comparison.html
|
||||
/docs/shortcut-gsb-eval.*
|
||||
/scripts/run_shortcut_real_read_matrix.py
|
||||
|
||||
# Local coverage artifacts
|
||||
coverage-shortcut.txt
|
||||
coverage-*.txt
|
||||
|
||||
# stray compiled generator binary (source lives in internal/generator/cmd_param_aliases/)
|
||||
/cmd_param_aliases
|
||||
|
||||
+9
-7
@@ -1,14 +1,19 @@
|
||||
# GoReleaser configuration for dws
|
||||
# Docs: https://goreleaser.com
|
||||
#
|
||||
# To release, use scripts/release/release.sh. It seals main, validates the
|
||||
# CHANGELOG and packages, then pushes the annotated tag for CI/CD to publish.
|
||||
# To release:
|
||||
# git tag -a v0.1.0 -m "Release v0.1.0"
|
||||
# git push origin v0.1.0
|
||||
#
|
||||
# To test locally (no publish):
|
||||
# goreleaser release --snapshot --clean
|
||||
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
- main: ./cmd
|
||||
binary: dws
|
||||
@@ -59,12 +64,9 @@ changelog:
|
||||
|
||||
release:
|
||||
github:
|
||||
# 用当前运行 CI 的仓库 owner: fork CI 发到 fork, 官方 CI 发到官方, 两边都对
|
||||
owner: "{{ .Env.GITHUB_REPOSITORY_OWNER }}"
|
||||
owner: DingTalk-Real-AI
|
||||
name: dingtalk-workspace-cli
|
||||
# Keep the release private until post-processing has replaced the Darwin
|
||||
# archives and verified every finalized asset digest.
|
||||
draft: true
|
||||
draft: false
|
||||
prerelease: auto
|
||||
name_template: "v{{.Version}}"
|
||||
mode: replace
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
name: Gitee Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release tag to build on Gitee, e.g. v1.0.48"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install packaging tools
|
||||
run: |
|
||||
set -eu
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip curl
|
||||
|
||||
- name: Install rcodesign
|
||||
run: |
|
||||
set -eu
|
||||
RCS_VERSION="0.27.0"
|
||||
curl -fsSL -o /tmp/rcodesign.tar.gz \
|
||||
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
mkdir -p /tmp/rcodesign
|
||||
tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rcodesign --strip-components=1
|
||||
sudo install -m 0755 /tmp/rcodesign/rcodesign /usr/local/bin/rcodesign
|
||||
rcodesign --version
|
||||
|
||||
- name: Build and publish Gitee release
|
||||
env:
|
||||
VERSION: ${{ inputs.version || github.ref_name }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_REPO: DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
run: ./scripts/release/build-and-publish-gitee.sh
|
||||
@@ -1,617 +0,0 @@
|
||||
# Repository Agent Guide
|
||||
|
||||
This file applies to the entire repository. Keep changes scoped, preserve
|
||||
unrelated work, and use `gofmt` for every modified Go file.
|
||||
|
||||
## Build and test
|
||||
|
||||
- Build: `make build` (wraps `scripts/dev/build.sh` → `go build -o dws ./cmd`; bare `go build ./cmd` fails because output name `cmd` collides with the directory)
|
||||
- Full test suite: `DWS_PACKAGE_VERSION=0.0.0-test go test ./...`
|
||||
- Param aliases generate: `go generate ./internal/cli` (entry point: `internal/cli/gen.go`; Catalog is not generated)
|
||||
- Optional diagnostic MCP dump (not a Schema pin): `make fetch-mcp-metadata` (requires `dws auth login`; writes under `artifacts/`)
|
||||
- Check generated drift + assembly determinism: `./scripts/policy/check-generated-drift.sh`
|
||||
- Check the Schema contract: `./scripts/policy/check-schema-catalog.sh`
|
||||
- Coverage-gate test naming: tests that carry coverage for the macOS platform
|
||||
gate must be named `TestCrossPlatformCoverage*` (or `TestAllShortcuts*`);
|
||||
`scripts/policy/run-platform-coverage-gate.sh` only selects those prefixes,
|
||||
so a covering test with any other name silently leaves its target uncovered.
|
||||
- Package-var injection seams (e.g. `pipelineBuildEffectiveRegistry`): swap
|
||||
them in tests only via `testseam.Swap(t, &seam, stub)` from
|
||||
`internal/testseam` — it restores the previous value through `t.Cleanup`
|
||||
structurally. Like the manual pattern it replaces, Swap mutates global state
|
||||
and is **not** safe for `t.Parallel` tests.
|
||||
- Cross-package test helpers (e.g. `StoreProductDeclRawForTest`) live in
|
||||
per-package `fortest.go` files, never scattered through production files;
|
||||
the `ForTest` suffix is the boundary and production code must not call them.
|
||||
|
||||
Schema Catalog delivery is **声明即 Catalog**: production assembles via
|
||||
`RegisterSchemaSourceRoot` → `ResolveSchemaBuild` (factory registered in
|
||||
`internal/app`). There is no
|
||||
`cmd_schema_catalog` `//go:generate` delivery step. `dws schema -f json` remains
|
||||
the wire projection. `cmd_schema_catalog` produces CI/local dumps only;
|
||||
`internal/cli/schema_catalog/`, `internal/cli/schema_meta_index.gob`, and
|
||||
`internal/cli/schema_meta_index.json` must not be committed. `schema_agent_metadata/` is retired: if that directory
|
||||
(or `schema_agent_metadata_audit.json`) is present, policy fails.
|
||||
Command identity is no longer a file input: it is collected from
|
||||
`ContractFinal.Identity` on the live Cobra leaves
|
||||
(`internal/cli/schema_identity_collect.go` → `BuildEffectiveCommandRegistry`).
|
||||
The reviewed `schema_command_registry/` was retired together with that
|
||||
switchover and must not reappear; identity changes happen by editing the leaf
|
||||
declaration. The remaining **reviewed inputs** under `internal/cli` (see Agent
|
||||
Schema contract) keep separate authorities — do not merge them with
|
||||
`param_concepts.json` or promote any of them into Catalog declaration.
|
||||
|
||||
## Command framework declaration
|
||||
|
||||
- Framework definition: `docs/rfc-command-framework-convergence.md` **§5.0**
|
||||
- Today (leaf): `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New`
|
||||
- Today (non-leaf): owning Cobra command → complete `corecmd.GroupPolicy{Mode, Positionals, Recovery}` → `corecmd.ApplyGroupPolicy`; the final assembled-tree gate rejects undeclared groups and stale group declarations on leaves
|
||||
- **Declare = final Schema source**: `Flags` / `Constraints` / `Safety` / `ConstParams` / `Contract` (`corecmd.ContractDecl`; nested fields are `contract.*`)
|
||||
- Naming: `ContractDecl` is the authoring leaf declaration. "Schema" means Catalog / `ToolSpec` delivery — do not reintroduce `SchemaDecl`.
|
||||
- `Safety` uses `contract.SafetySpec` (`internal/corecmd/contract` only — no `cli.*` type alias). Its `confirmation` drives the runtime gate; `effect` / `risk` / `idempotency` are published unchanged. When `Contract` is set, convert once via `contractfinal.RegisterRuntimeContractFinal` (all callers — `corecmd.New` registers internally); assembly **pass-throughs** Final.
|
||||
- Package seam:
|
||||
- types / ProductDecl → `corecmd/contract` (DTO only; **no** Cobra-keyed ContractFinal store)
|
||||
- AnnotateRuntime* writers → `internal/corecmd/runtimeannotate` (framework-owned)
|
||||
- ContractFinal cobra store + Register → `internal/corecmd/contractfinal` (framework-owned)
|
||||
- homology gates → `internal/cli/homology`
|
||||
- Catalog assembly / `ResolveMeta` (`RegisterSchemaSourceRoot` → `ResolveSchemaBuild`); go:embed only for reviewed inputs → `internal/cli` root (package-local aliases for annotate/store APIs live in `runtime_schema_seam.go`; the former `cli/runtimeannotate` / `cli/contractfinal` shim packages are removed — import `corecmd/*` directly)
|
||||
- **Hard rule**: `internal/corecmd` (and its subpackages) must **not** import any `internal/cli` package
|
||||
- Authoring tiers (current, not aspirational):
|
||||
- **Tier1** — `corecmd.New` / `helpers.NewLeafCommand` (fully managed declare + execute)
|
||||
- **Tier2** — `DeclareLeafMetadata` (helpers migration; **Shortcut may also use this path — acceptable**)
|
||||
- **Tier3** — bare Cobra (should shrink over time; reviewed exclusions where needed)
|
||||
- Long-term outlook only: broader mcpbind / fewer hand-written `Execute` bodies. **Not** a current hard requirement to delete `Shortcut.Execute` or force mcpbind.
|
||||
- Group policy is separate from the leaf tiers: `corecmd.Spec` remains leaf-only. `ApplyGroupPolicy` must not infer or enable `TraverseChildren`; parent local-flag inheritance remains an explicit owning-command surface.
|
||||
- Description declare vs delivery: construction requires `ContractDecl.Description` (evidence). Catalog delivery prefers Cobra Long → provenance `cobra_help`; without Long, declared text → `contract_final`. Title: declared first, then Short, then MCP. Do **not** read this as "declare = wire final" or dual authority.
|
||||
- **Execute** = hooks (`Validate` / `Call` / `RunE` / `PostMount`) — not a second surface authority
|
||||
- Declaration path has **no reviewed parallel fields**; migration-only `runtime_gate` annotate until `Safety` is declared
|
||||
- **Do not add** new production `AnnotateRuntimeRisk` / `AnnotateRuntimeGate`
|
||||
(`runtime_gate`) call sites; migrate leaves to declared `Safety` /
|
||||
`ContractDecl` instead. Existing annotate sites may remain until migrated.
|
||||
|
||||
## flag / help / schema homology
|
||||
|
||||
- Decision (path A — Contract/LeafSpec is CLI-surface authority **and must embed into Schema**): `docs/flag-help-schema-homology.md`
|
||||
- Hard rule: every help/Schema fact is **declared** **or** **annotated**; never inference-only (§1.1–§1.3; framework §5.0).
|
||||
- Embed path: `corecmd.New` → `dws.schema.*` annotations → Schema catalog assembly
|
||||
- MCP metadata must not create CLI flags; optional 1:1 passthrough is a gated subset only.
|
||||
- Gate IDs: `HOM-P*`, `HOM-S*`, `HOM-I1`, `HOM-D1` (see that doc §3–§4). `HOM-P1`/`HOM-D1`/`HOM-S1`/`HOM-S2` are on the `check-schema-catalog.sh` policy whitelist; remaining IDs land incrementally.
|
||||
|
||||
## Agent Schema contract
|
||||
|
||||
The Schema data flow is one way:
|
||||
|
||||
```text
|
||||
1. app.NewRootCommand()
|
||||
└─ builds the real Cobra command tree and flags
|
||||
└─ leaf Safety / Contract / contract.ParamDecl declare ContractFinal (declare-or-annotate)
|
||||
|
||||
2. CollectIdentitySpecs (ContractFinal.Identity on live Cobra leaves)
|
||||
└─ forms EffectiveCommandRegistry
|
||||
└─ binds exactly to real Cobra leaves and aliases
|
||||
|
||||
3. Parameter resolution
|
||||
Cobra flags
|
||||
+ contract.ParamDecl.Property / native annotations (primary property authority)
|
||||
+ schema_parameter_mapping_ledger.go (mapping_exclusions / removals only;
|
||||
active bindings JSON retired after Track 1 Phase 2)
|
||||
└─ produces ParameterSpec and constraints
|
||||
|
||||
4. Agent and interface semantics
|
||||
ProductDecl + leaf ContractFinal Selection / Safety / Interface
|
||||
+ contract.ParamDecl (interface_type / property)
|
||||
└─ resolves Agent metadata by source precedence
|
||||
Markdown is evidence only; it is not concatenated into final prose
|
||||
└─ schema_hints/ and schema_mcp_metadata.json are fully retired
|
||||
|
||||
5. One typed hub
|
||||
BoundCommandRegistry
|
||||
+ ParameterSpec
|
||||
+ Agent metadata
|
||||
+ Interface metadata
|
||||
└─ resolves every command exactly once into ToolSpec
|
||||
└─ aggregates SchemaRegistry + SchemaIndex
|
||||
└─ ResolveSchemaBuild assembles at runtime; deliverySchemaCatalog wraps it (lazy, sync.Once)
|
||||
|
||||
6. Runtime delivery (no generate-written Catalog authority)
|
||||
SchemaRegistry
|
||||
└─ dws schema list/product/group/leaf/--all (-f json wire)
|
||||
└─ ResolveMeta projects Identity/Safety/Selection from the same registry
|
||||
└─ CI may dump Catalog via cmd_schema_catalog for jq gates / determinism
|
||||
```
|
||||
|
||||
**Reviewed inputs / 评审输入** (organizational family under `internal/cli`;
|
||||
parallel peers, not one merged authority). These are assembly inputs only —
|
||||
never Catalog declaration authority, never leaf `Contract` / `ProductDecl`
|
||||
substitutes. Keep them side-by-side; do **not** fold one into another:
|
||||
|
||||
| Input | Path | Owns |
|
||||
|---|---|---|
|
||||
| Command identity | collected from `ContractFinal.Identity` on live Cobra leaves (`schema_identity_collect.go`; not a file input) | stable identity, primary CLI path, aliases, navigation |
|
||||
| Param concepts | `param_concepts.json` (+ `.schema.json`) | argv synonym / concept dictionary (reduced to `param_aliases_generated.go`) |
|
||||
| Exclusions | `schema_command_exclusions.go` | exact reviewed CLI paths excluded from Schema (non-empty reason) |
|
||||
| Mapping ledger | `schema_parameter_mapping_ledger.go` | `mapping_exclusions` / removals (CLI flags with no direct RPC property); active bindings JSON retired |
|
||||
|
||||
`schema_mcp_metadata.json` is retired and must not reappear. Interface facts
|
||||
(`interface_ref`, `interface_type`, …) declare on leaf `Contract` /
|
||||
`contract.ParamDecl`. Retiring the pin cleared MCP-sourced `interface_type`
|
||||
values from the wire; schema-compat deliberately accepts clearing (missing =
|
||||
unknown for consumers) while still rejecting any change to a different
|
||||
non-empty value. Re-populating a value requires an explicit `ParamDecl`
|
||||
declaration, not a new pin.
|
||||
|
||||
**Aliases are three distinct layers** (do not conflate):
|
||||
|
||||
| Layer | Owns |
|
||||
|---|---|
|
||||
| `FlagSpec.Aliases` / Cobra flag aliases | executable flag synonyms on a leaf |
|
||||
| `ContractFinal.Identity` `aliases` | reviewed CLI-path aliases for the same command identity |
|
||||
| `param_concepts.json` | argv synonym / concept dictionary (central preparse normalization) |
|
||||
|
||||
**Visibility vs exclusions:** collected identity `visibility` is dormant (all
|
||||
entries default `public`); “runnable but not Agent-visible” belongs in
|
||||
`schema_command_exclusions.go`, not new `visibility` values. Native identity
|
||||
annotations are consistency assertions only — they must agree with the
|
||||
collected identity and never materialize or override it.
|
||||
|
||||
Leaf declare (`Contract` / `ParamDecl` / `Safety` / `ProductDecl`) and the live
|
||||
Cobra tree remain separate from this table: declare owns semantics; Cobra owns
|
||||
executability and flags.
|
||||
|
||||
After binding there is no second identity source and no identity precedence
|
||||
winner. The binder must reject a missing/non-runnable Cobra path, an alias
|
||||
collision, and any native identity annotation that disagrees with the effective
|
||||
registry. A missing native identity annotation is allowed because annotations
|
||||
are implementation-side assertions, not identity fallbacks.
|
||||
|
||||
The assembler resolves every bound command exactly once into one `ToolSpec`.
|
||||
CI determinism (`check-schema-assembly.sh`) and policy jq gates consume a
|
||||
fresh assembly dump; runtime consumes the same `ResolveSchemaBuild` path via
|
||||
`RegisterSchemaSourceRoot`. Neither path may reopen annotations, merge source
|
||||
records, or use a previous Catalog JSON as a source.
|
||||
|
||||
### Assembly vs consumption
|
||||
|
||||
**Assembly** (declare → typed registry; CI + runtime):
|
||||
- Runtime entry: `RegisterSchemaSourceRoot` (`internal/app`) →
|
||||
`ResolveSchemaBuild` / `deliverySchemaCatalog` (lazy, sync.Once).
|
||||
- CI tool: `cmd_schema_catalog` dumps an assembled Catalog for jq/determinism;
|
||||
it is **not** a `//go:generate` or committed delivery step.
|
||||
- `gen.go` only generates `param_aliases_generated.go`.
|
||||
- Inputs: **reviewed inputs** (param_concepts / exclusions / mapping ledger —
|
||||
see table above) + ProductDecl/ContractFinal (identity is collected from
|
||||
`ContractFinal.Identity`) + live Cobra tree.
|
||||
`schema_hints/`, `schema_agent_metadata/`, `schema_command_registry/`, and
|
||||
`schema_mcp_metadata.json` must not reappear.
|
||||
- Gates: `make generate-schema` (param aliases + assembly determinism),
|
||||
`check-generated-drift.sh`, `check-schema-catalog.sh`.
|
||||
|
||||
**Consumption** (runtime, unified API):
|
||||
- Entry point: `ResolveMeta(cliPath) → CommandMeta{Identity, Safety, Selection}`
|
||||
in `internal/cli/command_meta.go` — projected from the assembled registry
|
||||
when the app factory is registered.
|
||||
- Consumers: `--help` (Safety annotation via `RenderSafetyAnnotation`),
|
||||
agent selection, future skill generation; `dws schema` uses the same
|
||||
assembled Catalog (`-f json` wire unchanged).
|
||||
- `SafetyForCLIPath` delegates to `ResolveMeta` (backward compatible).
|
||||
|
||||
This split is architecturally isomorphic to Lark's typed metadata registry,
|
||||
navigation catalog, and schema renderer. DWS intentionally preserves its
|
||||
existing flat JSON wire contract for compatibility; do not treat architectural
|
||||
alignment as permission to make an unversioned wire-format change.
|
||||
|
||||
The identity collected from `ContractFinal.Identity` (via
|
||||
`CollectIdentitySpecs`) is the sole source of stable command identity and
|
||||
navigation. The executable Cobra tree remains the source of truth for whether
|
||||
a CLI path exists, is runnable, and which flags it accepts. Schema coverage is
|
||||
bidirectional:
|
||||
|
||||
1. Every final `SchemaRegistry` tool, including its serialized Catalog
|
||||
projection, must resolve to an executable Cobra command.
|
||||
2. Every public runnable Cobra leaf must either resolve to Schema or appear as
|
||||
an exact, reviewed exclusion with a non-empty reason in
|
||||
`internal/cli/schema_command_exclusions.go` (central Go groups; not JSON).
|
||||
|
||||
Do not use prefix or wildcard exclusions: they can silently hide future
|
||||
commands. Remove an exclusion when its command enters Schema; stale, invalid,
|
||||
or duplicate exclusions must fail generation and CI.
|
||||
|
||||
When adding or changing an Agent-visible command, review all relevant inputs:
|
||||
|
||||
- Leaf `ContractFinal.Identity` for canonical identity, primary CLI path,
|
||||
aliases, and stable navigation. Identity is collected from the live Cobra
|
||||
leaves (`CollectIdentitySpecs`); there is no separate identity file. Invalid
|
||||
canonical paths, alias collisions, stale paths, and drift fail collection,
|
||||
binding, and policy.
|
||||
- Leaf `Safety` / `Contract` (`corecmd.ContractDecl`) / `contract.ParamDecl`
|
||||
(helpers `LeafSpec` or shortcut `Contract`) for parameter facts, interface
|
||||
disposition, safety, and Agent selection prose. Delivered provenance is
|
||||
`contract_final` from `corecmd.contract` (description may stamp `cobra_help`
|
||||
when Cobra Long wins). Product routing uses `ProductDecl`
|
||||
(`internal/corecmd/contract`; provenance label remains `cli.product_decl`).
|
||||
- `internal/cli/schema_hints/` is fully retired. Do not reintroduce HintFiles,
|
||||
audit JSON, or `imported/` baselines; declare on ProductDecl / the owning
|
||||
leaf instead.
|
||||
- Native Runtime Schema identity annotations, when present, as consistency
|
||||
assertions against `EffectiveCommandRegistry`. They must agree exactly and
|
||||
must never materialize, infer, or override registry identity.
|
||||
- Flag-to-interface property mappings and required/default semantics.
|
||||
- Do not expect generate-written Catalog delivery. Run
|
||||
`make generate-schema` only to refresh param aliases and prove assembly
|
||||
determinism. Do not expect or commit `schema_agent_metadata/`.
|
||||
|
||||
Run the reverse-completeness tests whenever the Cobra tree changes. A command
|
||||
that works through `dws <path>` but cannot be found through the matching
|
||||
`dws schema` lookup is a contract failure unless it has a reviewed exact
|
||||
exclusion.
|
||||
|
||||
`RegisterSchemaHints` / `ToolSchemaHint` overlays are fully removed. Parameter
|
||||
and selection facts must be declared on the owning leaf (`contract.ParamDecl` /
|
||||
`Contract`) or via `ProductDecl`; do not reintroduce overlay registries.
|
||||
|
||||
For Agent-authored selection edits:
|
||||
|
||||
1. Confirm the exact command and flag names in the current Cobra tree.
|
||||
2. Declare selection prose on the owning leaf (`Contract.Selection` /
|
||||
`DeclareLeafMetadata`) and product routing via `ProductDecl`; declare
|
||||
safety / parameters / interface on the same leaf.
|
||||
3. Do not copy generated Catalog fields into source inputs.
|
||||
4. Run generation, drift, Schema policy, and the focused CLI tests before
|
||||
proposing the change.
|
||||
|
||||
## Agent curation workflow
|
||||
|
||||
Use this workflow when refreshing Agent selection prose and confirmation
|
||||
alignment. Prefer **agent-authored review** over bulk merge scripts that dump
|
||||
Skill Markdown into Catalog fields.
|
||||
|
||||
Human-authored inputs:
|
||||
|
||||
| Block | Path | Owns |
|
||||
|---|---|---|
|
||||
| **declaration** | helpers / shortcut `Safety` + `Contract` / `contract.ParamDecl` + `ProductDecl` | `effect` / `risk` / `confirmation` / `idempotency` / `interface_*` / parameter facts / selection prose (`contract_final`) |
|
||||
|
||||
`schema_hints/` is fully retired. Do not reintroduce HintFiles or audit JSON.
|
||||
|
||||
### Goals
|
||||
|
||||
1. **Selection prose** is decision-oriented (Feishu/Lark style): trigger intent,
|
||||
sibling-command routing, and outcome shape — not a restatement of the
|
||||
summary. Delivered Catalog provenance is `contract_final` from leaf
|
||||
`Contract.Selection` / `ProductDecl`.
|
||||
2. **Safety** follows Runtime: `confirmation=user_required` when the leaf
|
||||
Contract/Safety (or remaining `runtime_gate` annotate) requires a user gate
|
||||
(for example `confirm_delete`, `typed_yes`, `confirm_dangerous`).
|
||||
3. **Parameter facts** are declared on the leaf (`contract.ParamDecl` /
|
||||
`Contract.Parameters` / FlagSpec). Do not reintroduce HintFile or
|
||||
`RegisterSchemaHints` overlays.
|
||||
|
||||
### Authoring
|
||||
|
||||
For every curated tool:
|
||||
|
||||
1. Declare safety/interface/parameters/selection on the owning leaf
|
||||
(`DeclareLeafMetadata` / `Shortcut.Contract` / `contract.ParamDecl`) and product routing
|
||||
via `ProductDecl` when needed.
|
||||
2. Run `make generate-schema` (param aliases + assembly determinism). Do not
|
||||
create or commit `schema_catalog/` or Schema meta-index fixtures.
|
||||
|
||||
### Pull live MCP descriptions (personal token)
|
||||
|
||||
Schema delivery no longer embeds a pinned MCP JSON. Prefer live Schema from a
|
||||
logged-in personal session when reviewing interface facts before declaring them
|
||||
on the leaf:
|
||||
|
||||
```bash
|
||||
dws auth status # token_valid should be true
|
||||
dws schema <mcp-canonical> --jq '{canonical_path,interface_ref,parameters}' -f json
|
||||
# or CLI path: dws schema --cli-path "drive copy" --jq '{canonical_path,interface_ref,parameters}' -f json
|
||||
```
|
||||
|
||||
Resolve MCP identity via declared `interface_ref` when CLI canonical ≠ MCP path
|
||||
(example: CLI `drive.copy_document` → live `doc.copy_document`). On pull
|
||||
failure, fall back to Skill + Cobra Help, and record evidence
|
||||
(for example `live-dws-schema:<path>#FAILED`). Never print or commit tokens.
|
||||
`make fetch-mcp-metadata` writes an optional diagnostic dump under `artifacts/`
|
||||
only — do not commit it as a Schema pin.
|
||||
|
||||
Precedence when sources disagree: **Runtime/Cobra / leaf Contract > live MCP >
|
||||
Skill (evidence only)**.
|
||||
|
||||
### Parallel product agents
|
||||
|
||||
Split work by product groups. Each agent must:
|
||||
|
||||
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live
|
||||
`dws schema <leaf> --compact` for its tools. Mapping/interface/provenance
|
||||
audits may query the full leaf only through a narrow `--jq` / `--fields`
|
||||
projection; do not load an entire full leaf into Agent context.
|
||||
- Hand-write selection prose and leaf Contract / ProductDecl declarations;
|
||||
forbid wholesale JSON merges from review dumps.
|
||||
- Edit only its product’s leaf declarations (and `ProductDecl` when needed).
|
||||
- **Never** `git checkout` unrelated product files to “clean scope”.
|
||||
|
||||
### Regenerate and gates
|
||||
|
||||
```bash
|
||||
make generate-schema
|
||||
./scripts/policy/check-runtime-confirmation-truth.sh
|
||||
go test ./internal/app -run '^TestSheetFinalSchemaConfirmationMatchesRuntimeGuards$' -count=1
|
||||
```
|
||||
|
||||
`check-runtime-confirmation-truth.sh` compares live ContractFinal.Safety with the assembled ToolSpec `confirmation=user_required` and probes the runtime gate.
|
||||
`schema_hints/` must stay absent.
|
||||
|
||||
Example rules (fail generation otherwise):
|
||||
|
||||
- At most two examples per tool; no `--yes` in stored examples.
|
||||
- Examples must match live Cobra argv (path, flags, required groups).
|
||||
- No shell comments in examples.
|
||||
|
||||
After generation, spot-check Catalog: selection and safety/interface
|
||||
provenance are `contract_final` from ProductDecl / leaf declarations
|
||||
(`user_required` must match Runtime confirmation gates).
|
||||
|
||||
`make generate-schema` refreshes `param_aliases_generated.go` and runs
|
||||
assembly determinism (`check-schema-assembly.sh`). It does not rewrite a
|
||||
committed Catalog as delivery authority — runtime reassembles from
|
||||
declarations. Byte guards fail if generation mutates parameter-concept
|
||||
inputs; policy fails if the retired `schema_command_registry/` reappears.
|
||||
|
||||
Selection prose may choose a more or less restrictive recommendation. It cannot
|
||||
create a Cobra command or flag, change parameter facts, invent an
|
||||
RPC/interface, alter safety metadata, or bypass command completeness. Examples
|
||||
must use an executable primary/alias path and flags accepted by the live Cobra
|
||||
command; never add `--yes` to stored examples.
|
||||
|
||||
Every example is always checked against its real `BoundCommand`: exact path,
|
||||
accepted flags, Cobra required flags/positionals, and the effective
|
||||
`require_one_of`, `require_together`, and `mutually_exclusive` constraints must
|
||||
all pass before execution eligibility is considered. A missing required value,
|
||||
constraint failure, runtime error, or MCP resolution error is a contract bug;
|
||||
none is a valid reason to skip an example.
|
||||
|
||||
Example execution defaults to contract validation only. Runtime execution is
|
||||
opt-in: an example enters `dry_run` only when its final `ToolSpec` publishes an
|
||||
explicit reviewed dry-run capability. The test never injects `--yes`, and
|
||||
`risk`/`confirmation` values do not manufacture preview support. A narrow
|
||||
runtime precondition that cannot be derived from the typed contract may use an
|
||||
exact zero-based `example_dispositions` entry with `mode=contract_only`,
|
||||
`reviewed=true`, one of the schema-enumerated reason codes, and a concrete
|
||||
non-empty reason. Such a disposition may only narrow an explicit dry-run
|
||||
capability; it cannot turn an ordinary contract-only example into a skip.
|
||||
Duplicate, missing, and out-of-range indexes fail validation. Never catch a
|
||||
dry-run failure and dynamically downgrade it to `contract_only`.
|
||||
|
||||
Normal Go tests run the exhaustive contract gate. Run
|
||||
`make test-schema-agent-examples` to additionally execute the eligible subset
|
||||
through the real Cobra `--dry-run` path with isolated HOME and blocked proxies.
|
||||
The test reports stable `total`, `contract`, `dry_run`, `contract_only`,
|
||||
`reviewed_manual`, and per-reason counts; changing those counts requires a
|
||||
review of the corresponding typed dry-run capability or manual disposition.
|
||||
This target is also part of `make policy`.
|
||||
|
||||
Treat every tool `use_when` entry as a reviewed positive selection scenario
|
||||
whose expected result is that tool's canonical path, and every `avoid_when`
|
||||
entry as a reviewed negative scenario that must not choose that tool. The
|
||||
deterministic gate derives a typed evaluation fixture from these same fields;
|
||||
it requires exact tool coverage, a real runnable `BoundCommandRegistry`
|
||||
primary command, at least one positive and negative assertion per tool, and no
|
||||
literal contradictory expectations. It does not claim that string matching
|
||||
proves natural-language understanding.
|
||||
|
||||
Semantic selection is an explicit opt-in live-model check. Run the smoke set
|
||||
(one positive and one negative scenario per product) with
|
||||
`DWS_AGENT_SELECTION_LIVE=1 ARK_API_KEY=... ARK_BASE_URL=... ARK_MODEL=... go test ./internal/app -run TestAgentSelectionArkLive -count=1`.
|
||||
Add `DWS_AGENT_SELECTION_FULL=1` to evaluate every committed tool scenario, or
|
||||
set `DWS_AGENT_SELECTION_CASES` to comma-separated fixture case IDs. Normal CI
|
||||
never calls a model; its blockers remain the reproducible fixture, binding,
|
||||
example, provenance, and final-delivery facts.
|
||||
|
||||
The live evaluator sends only case IDs/scenarios plus one same-product
|
||||
candidate table; expected/forbidden assertions stay local and must never be
|
||||
included in the model prompt. Built-in Ark HTTPS bases are allowlisted. A
|
||||
different HTTPS provider requires its exact base in
|
||||
`DWS_AGENT_SELECTION_ALLOWED_BASE_URLS`; plaintext HTTP is accepted only for a
|
||||
loopback test server so API credentials are never sent to an arbitrary clear
|
||||
text endpoint.
|
||||
|
||||
## Safety metadata
|
||||
|
||||
Parameter and safety resolution is mostly source-precedence based and
|
||||
value-neutral: do not choose a winner because one value looks stricter. A
|
||||
higher-priority reviewed metadata/explicit source may intentionally raise or
|
||||
lower description, mapping, `effect`, `risk`, `confirmation`, or `idempotency`.
|
||||
Preserve all candidates and the selected source in provenance, and fail
|
||||
same-precedence conflicts rather than silently merging them.
|
||||
|
||||
`required` is the exception. Cobra `MarkFlagRequired` is a hard floor: the
|
||||
final Agent projection must keep `required=true` and cannot be lowered by a
|
||||
lower-precedence source. A higher-precedence declaration may still raise an
|
||||
optional flag to required. `cli_required` continues to mirror the executable
|
||||
Cobra marker.
|
||||
|
||||
For command-level description: **declare required, delivery Long may win**.
|
||||
`ContractDecl.Description` is mandatory at construction (declaration evidence).
|
||||
Catalog delivery prefers Cobra Long when present (provenance `cobra_help`,
|
||||
resolution `cobra_help_preferred`); without Long, the declared Description is
|
||||
delivered as `contract_final`. Title keeps declared ContractDecl /
|
||||
ContractFinal first, then Cobra Short, then MCP metadata. This is one authority
|
||||
chain with an explicit delivery preference — not two competing sources.
|
||||
Generic RPC prose may remain an unselected provenance candidate (and
|
||||
parameter-level `interface_description`); it must not overwrite a specialized
|
||||
leaf's title or description.
|
||||
|
||||
For every delivered `ToolSpec` and `ParameterSpec` field, the provenance
|
||||
winner value must exactly equal the delivered value. Checking only source,
|
||||
count, presence, or hash is not a sufficient final-delivery invariant.
|
||||
|
||||
The same resolved `ToolSpec` must drive every projection. The full leaf payload
|
||||
must equal the corresponding tool in `schema --all` and the full Catalog tool.
|
||||
Overview/product/group summaries and Catalog summaries must equal
|
||||
`ToolSpec.ToSummaryPayload()`. An alias lookup may change only the view fields
|
||||
`cli_path` and `is_alias`; it must not re-resolve or mutate the command
|
||||
contract.
|
||||
|
||||
This build-time rule is distinct from runtime drift handling. If shipped Help
|
||||
and leaf Schema disagree, pass only flags accepted by Cobra. For conflicting
|
||||
safety information, do not silently take the less restrictive behavior: use
|
||||
the safer interpretation or stop and report the contract drift.
|
||||
|
||||
Do not infer one safety field from another. In particular, `effect=destructive`
|
||||
or `risk=high` does not mechanically rewrite `confirmation`; the final
|
||||
precedence winner for each field is authoritative. When
|
||||
`confirmation=user_required`, obtain confirmation before adding `--yes`.
|
||||
Keep CLI confirmation behavior and Schema metadata consistent, and add a
|
||||
semantic regression test through the final embedded loader/query delivery
|
||||
path; a generator unit test or JSON count alone is insufficient.
|
||||
|
||||
## Unified result Schema and performance
|
||||
|
||||
The unified runtime envelope and the per-command Schema result declaration are
|
||||
related but distinct contracts:
|
||||
|
||||
- Runtime owns the outer machine envelope (`ok`, `outcome`, `data`, `error`,
|
||||
`meta`) and derives it through `internal/output`. Business commands return a
|
||||
`CommandResult`; they must not hand-author the outer JSON shape.
|
||||
- A leaf `Contract.Result` / `contract.ResultSpec` describes the reviewed
|
||||
business value inside `data`. It may declare `outcomes`, `data_schema`, and
|
||||
`sensitive_paths`. `Contract.Pagination` is a separate command capability
|
||||
because pagination is emitted under envelope `meta`, not inside `data`.
|
||||
- `outcomes` is the set of results a command may produce; it is not the outcome
|
||||
of the current invocation. `data_schema` is a JSON Schema object for business
|
||||
data and must not duplicate the framework envelope.
|
||||
- Result declarations are delivered in the full leaf and in the reviewed
|
||||
`--compact` Agent projection. Compact retains the normalized `result` object
|
||||
verbatim but still omits provenance, interface bindings, and other audit-only
|
||||
fields. Product/group summaries remain navigation views and need not repeat
|
||||
every leaf Result. When an Agent needs return-shape facts, query the compact
|
||||
leaf directly; do not load the whole full Catalog.
|
||||
- A missing `result` means “no reviewed return-value declaration is published
|
||||
for this leaf.” It does **not** prove that the runtime is legacy, and it must
|
||||
not be filled by inference from examples, MCP samples, or previous command
|
||||
output. Runtime rollout remains an internal per-command fact.
|
||||
- The public contract has no `contract_version`, no `--output-contract`, and no
|
||||
Agent-selectable protocol alias. Agents continue to request machine output
|
||||
with `--format json`; migrated commands use the unified result directly and
|
||||
unmigrated commands retain their current legacy output.
|
||||
- Existing `dev` / `devapp` pilot coverage is gradual. Active reviewed
|
||||
`devapp` shortcuts are gated on a non-empty Result declaration, while `dev`
|
||||
currently has representative Result coverage. Do not describe that as
|
||||
repository-wide coverage. Any newly activated Agent-visible command should
|
||||
add and test its Result declaration; the remaining pilot gaps should shrink,
|
||||
not expand.
|
||||
|
||||
The compact/full leaf `result` object has one stable shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"outcomes": ["success", "pending", "partial_failure", "failure"],
|
||||
"data_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Stable resource ID"},
|
||||
"name": {"type": "string", "description": "Display name"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensitive_paths": ["credential.secret"]
|
||||
},
|
||||
"pagination": {
|
||||
"kind": "cursor",
|
||||
"cursor_parameter": "cursor",
|
||||
"meta_path": "meta.pagination",
|
||||
"endpoint_exhausted_path": "meta.pagination.endpoint_exhausted",
|
||||
"next_token_path": "meta.pagination.next_token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Field rules:
|
||||
|
||||
| Field | Required | Contract |
|
||||
|---|---|---|
|
||||
| `outcomes` | yes | Non-empty unique subset of `success`, `pending`, `partial_failure`, `failure`; normalization publishes canonical order. |
|
||||
| `data_schema` | yes | One recursive JSON Schema **object** describing only the runtime envelope's `data` value. Every named `properties` child must have a non-empty `description`. It must not duplicate `ok`, `outcome`, `error`, or `meta`. |
|
||||
| `sensitive_paths` | no | Unique safe dot paths relative to `data`; renderers/redaction consumers must not treat them as shell/JQ expressions. |
|
||||
|
||||
Optional members are omitted, never emitted as `null`. A leaf without a
|
||||
reviewed Result omits the entire `result` key. Compact must preserve the same
|
||||
normalized Result value as the full leaf; it must not summarize, infer, rename,
|
||||
or independently rebuild any Result field. Product/group summaries do not
|
||||
aggregate child Result objects.
|
||||
|
||||
`pagination` is a sibling of `result`, not a child. It declares the canonical
|
||||
CLI cursor parameter and the fixed framework paths under `meta.pagination`.
|
||||
Product response fields used to derive that metadata remain mapper internals;
|
||||
they are not part of `result.data_schema`. Do not execute a second request to
|
||||
derive pagination metadata.
|
||||
|
||||
Invalid result declarations fail closed during normalization: unknown or
|
||||
duplicate outcomes, a non-object/multiple `data_schema`, unsafe or duplicate
|
||||
sensitive paths, unsupported pagination kinds, attempts to override framework
|
||||
meta paths, and an invalid cursor parameter must be rejected rather than
|
||||
silently removed.
|
||||
Full-leaf wire round trips must
|
||||
preserve the normalized Result exactly. Do not commit generated Schema JSON as
|
||||
evidence; tests construct contracts in Go and runtime/CI assemble the Catalog
|
||||
from declarations.
|
||||
|
||||
### Performance model and rules
|
||||
|
||||
- Catalog construction is declaration-driven and cached through the existing
|
||||
lazy `sync.Once` delivery path. Do not reassemble or reopen annotations per
|
||||
command invocation, per leaf lookup, or per renderer.
|
||||
- Normalizing one Result declaration is linear in the size of that declaration.
|
||||
Full `schema --all` is linear in tools + parameters + Result schema bytes and
|
||||
is an audit/compatibility export, not the normal Agent discovery path.
|
||||
Overview → compact product/group → compact leaf remains the normal route;
|
||||
only the final leaf carries its Result declaration.
|
||||
- Constructing a `CommandResult` defensively clones result data and validates
|
||||
invariants; rendering is buffer-first and then writes once. Both CPU cost and
|
||||
transient memory are O(payload size), with roughly one additional in-memory
|
||||
rendered copy. This buys immutability and prevents partial JSON leakage, but
|
||||
it is not free.
|
||||
- Large list/search commands must use bounded pages and publish continuation
|
||||
facts. The current emitter buffers one command result/page before publishing;
|
||||
pagination is the memory bound. Continuous event streams are a separate,
|
||||
command-specific protocol and are not described by `ResultSpec`.
|
||||
- A `dual_validate` command must execute the business request exactly once,
|
||||
validate a shadow unified result, and preserve legacy bytes. Never obtain
|
||||
validation by issuing a second network or write request.
|
||||
- Filters and alternate formats are render-time work over the same in-memory
|
||||
result. They must not rerun the business operation or rebuild Schema.
|
||||
- Performance changes must preserve the one-result, buffer-first, fail-closed,
|
||||
and atomic `--output` guarantees. Do not trade correctness for a microbenchmark
|
||||
improvement. For a material hot-path change, benchmark representative small
|
||||
and page-sized payloads and report allocations/bytes as well as latency.
|
||||
|
||||
## Current Schema boundaries
|
||||
|
||||
- `schema list` remains a progressive overview. `schema --all` is the stable
|
||||
full-export contract: every final `SchemaIndex` tool must contain its
|
||||
complete leaf parameters, constraints, and safety semantics, including an empty
|
||||
`parameters` object for commands without flags. Keep it suitable for the #602
|
||||
compatibility baseline and fail rather than silently emitting a partial
|
||||
export.
|
||||
- `schema --all` is not normal command discovery. Use overview -> compact
|
||||
product/group -> compact leaf for routine Agent work. `--compact` is the
|
||||
reviewed positive-field allowlist for Agent context: new full/audit fields
|
||||
must not appear there until explicitly reviewed. A compact full export is not
|
||||
a complete compatibility baseline.
|
||||
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
|
||||
executable accepts. A compact leaf defines Agent selection, CLI parameters,
|
||||
constraints, safety/confirmation semantics, and any reviewed `result`
|
||||
contract. Full leaf fields such as `property`, `interface_ref`, and
|
||||
provenance are audit facts. A conflict is contract drift, not permission to
|
||||
guess.
|
||||
- Schema and Help describe commands; neither returns DingTalk business data.
|
||||
After discovery, execute the real read/search/list command to obtain data.
|
||||
-2093
File diff suppressed because one or more lines are too long
+9
-58
@@ -27,9 +27,7 @@ notes that are intentionally kept out of the repository root.
|
||||
|
||||
## Local Checks
|
||||
|
||||
Run the verification commands that match the surface you changed before you
|
||||
hand work back. The goal is useful, change-specific evidence, not a second
|
||||
local execution of every CI job.
|
||||
Run the verification commands that match the surface you changed before you hand work back.
|
||||
|
||||
Common repository checks already used here include:
|
||||
|
||||
@@ -38,74 +36,27 @@ Common repository checks already used here include:
|
||||
./scripts/policy/check-open-source-assets.sh
|
||||
go test ./...
|
||||
make test
|
||||
make test-plan
|
||||
make lint
|
||||
bash test/scripts/run_all_tests.sh --jobs 8
|
||||
./scripts/policy/check-generated-drift.sh
|
||||
./scripts/policy/check-command-surface.sh --strict
|
||||
./scripts/release/verify-package-managers.sh
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Select the PR risk tier before choosing checks:
|
||||
|
||||
| Tier | Typical scope | Developer evidence | CI expansion |
|
||||
|---|---|---|---|
|
||||
| Documentation-only | Prose and documentation assets with no executable, generated, workflow, packaging, or interface change | Links/content/rendering plus repository asset checks | Lightweight documentation validation; all nine named contexts still report |
|
||||
| Standard | Ordinary implementation work with a stable package graph | Focused unit/integration tests and observable behavior for the changed path | Race tests for changed packages and their reverse dependencies, scope-matched HEAD/base coverage, and representative Darwin/Windows compilation |
|
||||
| High-risk | Workflow/policy, package graph, generated Schema/registry, platform, auth/keychain, installer, packaging, release, transport, recovery, or an unprovable infrastructure change | Relevant full or domain suite plus focused behavior evidence | Complete race suite, native platform tests, and all affected domain gates; protected `main` uses this tier |
|
||||
|
||||
Classification fails closed: an incomplete diff, package add/remove/rename, or
|
||||
uncertain dependency graph selects the high-risk suite. Native changed-code
|
||||
coverage is additionally selected for platform-sensitive code.
|
||||
|
||||
## Pull Request Checklist
|
||||
|
||||
1. Keep implementation and tests in sync.
|
||||
2. Select the documentation-only, standard, or high-risk tier and run the
|
||||
smallest checks that prove the change. Use `./scripts/dev/ci-local.sh` when
|
||||
a complete local pass is warranted; it is not required for every ordinary
|
||||
PR.
|
||||
3. Include both the commands/results and user-visible or contract-level
|
||||
behavior evidence in the PR description.
|
||||
4. Run `./scripts/policy/check-command-surface.sh --strict` when command
|
||||
paths/flags change. CI resolves the exact merge-base, latest reachable
|
||||
non-withdrawn stable GA tag, and committed candidate SHA, then enters the single compatibility
|
||||
decision seam through
|
||||
`make authoritative-interface-integrity BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`.
|
||||
The Make target delegates to the authoritative wrapper; CI does not invoke a
|
||||
second comparator or the legacy fixture checker. See
|
||||
[CLI Help / Schema compatibility migration governance](docs/cli-interface-flag-migrations.md)
|
||||
for the reviewed two-stage `pending` → `consumed` lifecycle.
|
||||
Agent-visible flag or command-path migrations must also run
|
||||
`make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`;
|
||||
it consumes the same base-owned ledger rather than a second exception list.
|
||||
5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may
|
||||
change.
|
||||
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
|
||||
installer surfaces change (run `make package` first).
|
||||
7. Update docs and add one `.changes/<unique-name>.md` release fragment for
|
||||
behavior/interface changes. Do not edit `CHANGELOG.md` in an ordinary PR;
|
||||
the release-seal workflow renders and archives fragments into the versioned
|
||||
changelog section.
|
||||
2. Run `./scripts/dev/ci-local.sh`.
|
||||
3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change.
|
||||
4. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may change.
|
||||
5. Run `./scripts/release/verify-package-managers.sh` when packaging or installer surfaces change (run `make package` first).
|
||||
6. Update docs and `CHANGELOG.md` for behavior/interface changes.
|
||||
7. Include verification evidence in your PR description.
|
||||
|
||||
## Submission Flow
|
||||
|
||||
1. Make the smallest atomic change that satisfies the task.
|
||||
2. Keep doc edits factual and limited to implemented behavior.
|
||||
3. Run the relevant verification commands.
|
||||
4. Report the validation results and risk tier with the handoff.
|
||||
5. Open a ready PR against `main`. Base-owned automation assigns one eligible
|
||||
peer reviewer, balancing the current open-review load and excluding the
|
||||
author. A new head push re-enters the same routing flow when the latest
|
||||
revision still needs review.
|
||||
6. After the latest push has one peer approval and the exact nine required
|
||||
contexts are current and green, auto-merge completes the PR. If `main`
|
||||
advances first, strict status checks revalidate the branch; no separate
|
||||
routine merge request is needed.
|
||||
|
||||
Contributors without repository write access stop at the PR flow. Explicitly
|
||||
authorized collaborators with `write`, `maintain`, or `admin` access can use
|
||||
[Actions → Release](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/release.yml)
|
||||
to publish beta releases without manual approval. The same internal roles may
|
||||
start a stable release, but a different repository administrator must approve
|
||||
the `release-stable` Environment deployment before publication continues.
|
||||
4. Report the validation results with the handoff.
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.60-beta.2"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "e7776807f0664cbf0d0728cc236f2415c0981eb8d6557a897d2eeee708641b1d"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "3004474df3cfb529719348f02c9f2f39afa88f0fca469fe8303a9ebe0f3a0034"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "6386885d10f149c8c555031dda4cf07bf34e1e9daad61d4cd948b92d3c7b7bad"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "5c94c2af269d2fe5a79a400d4fa3af267a86d6ab21b01a24ede1d29514a6eaef"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-skills.zip"
|
||||
sha256 "c3bd917f1b44a978ba2a9fbe95c5d0910ccf75f870f1c9b0dc356262ab1080c5"
|
||||
end
|
||||
|
||||
def install
|
||||
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
|
||||
binary = File.join(root, "dws")
|
||||
raise "binary not found: #{binary}" unless File.exist?(binary)
|
||||
|
||||
bin.install binary => "dws"
|
||||
|
||||
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
|
||||
source = File.join(root, name)
|
||||
pkgshare.install source if File.exist?(source)
|
||||
end
|
||||
|
||||
skill_dest = pkgshare/"skills/dws"
|
||||
skill_dest.mkpath
|
||||
resource("skills").stage do
|
||||
cp_r(Dir["*"], skill_dest)
|
||||
end
|
||||
end
|
||||
|
||||
def caveats
|
||||
<<~EOS
|
||||
Agent Skills are bundled in #{pkgshare}/skills/dws.
|
||||
Run `dws skill setup` to install them into your Agent directories.
|
||||
This beta is keg-only. Add #{opt_bin} to PATH to use its `dws` binary.
|
||||
EOS
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/dws version")
|
||||
end
|
||||
end
|
||||
@@ -1,63 +0,0 @@
|
||||
class DingtalkWorkspaceCli < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.59"
|
||||
license "Apache-2.0"
|
||||
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-arm64.tar.gz"
|
||||
sha256 "61135a2a9286204ce060847e653c63c1e9784a0fa631bb7e0563b90628762a35"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-amd64.tar.gz"
|
||||
sha256 "fd14b0b1a1475891fb243bf6453857a1044ab5a40bcf7dc1c7c795f57e5b03ba"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-arm64.tar.gz"
|
||||
sha256 "5bfe9ac7d1798b028f0fad579bbdffec5898e2fb16ee36f5766ab58e208abd50"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-amd64.tar.gz"
|
||||
sha256 "be1eb9a1f8fc5048e578b5b0bde212fc90baca0f289236c7c333d824bd869cf3"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-skills.zip"
|
||||
sha256 "7ce5c3ab6f6a367407f64971bc5ff96cfcdfade2c1a10d326144b17c7b25a57e"
|
||||
end
|
||||
|
||||
def install
|
||||
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
|
||||
binary = File.join(root, "dws")
|
||||
raise "binary not found: #{binary}" unless File.exist?(binary)
|
||||
|
||||
bin.install binary => "dws"
|
||||
|
||||
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
|
||||
source = File.join(root, name)
|
||||
pkgshare.install source if File.exist?(source)
|
||||
end
|
||||
|
||||
skill_dest = pkgshare/"skills/dws"
|
||||
skill_dest.mkpath
|
||||
resource("skills").stage do
|
||||
cp_r(Dir["*"], skill_dest)
|
||||
end
|
||||
end
|
||||
|
||||
def caveats
|
||||
<<~EOS
|
||||
Agent Skills are bundled in #{pkgshare}/skills/dws.
|
||||
Run `dws skill setup` to install them into your Agent directories.
|
||||
|
||||
EOS
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/dws version")
|
||||
end
|
||||
end
|
||||
@@ -1,16 +1,6 @@
|
||||
GO ?= go
|
||||
DWS_PACKAGE_VERSION ?= 0.0.0-test
|
||||
REMOTE ?=
|
||||
PUBLISH ?= 0
|
||||
YES ?= 0
|
||||
DWS_POLICY_TMPDIR ?= $(CURDIR)/.worktrees/policy-tmp
|
||||
POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
|
||||
SCHEMA_CATALOG_OUTPUT ?= artifacts/schema_catalog
|
||||
SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
|
||||
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
|
||||
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
|
||||
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat shortcut-public-e2e-proof lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test lint fmt policy edition-test package release publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -18,33 +8,11 @@ help:
|
||||
@printf "Available targets:\n"
|
||||
@printf " make build - Build the dws CLI binary\n"
|
||||
@printf " make test - Run the Go test suite\n"
|
||||
@printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\n"
|
||||
@printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n"
|
||||
@printf " make shortcut-public-e2e-proof - Prove every reviewed Devdoc/HRbrain/PAT public Shortcut through exact and owning raw execution\n"
|
||||
@printf " make lint - Run formatting checks, go vet, and staticcheck\n"
|
||||
@printf " make format-check - Check all repository Go source files with gofmt\n"
|
||||
@printf " make fmt - Format all repository Go source files\n"
|
||||
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
|
||||
@printf " make interface-integrity [BASE_REF=<ref>] [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check authoritative CLI history\n"
|
||||
@printf " make authoritative-interface-integrity BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check Git-owned CLI history\n"
|
||||
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and 100%% changed-code coverage\n"
|
||||
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce 100%% native changed-code coverage\n"
|
||||
@printf " make update-interface-baseline - Update the non-authoritative CLI smoke fixture\n"
|
||||
@printf " make reset-interface-baseline - DANGEROUS: replace the non-authoritative CLI smoke fixture\n"
|
||||
@printf " make schema-compatibility BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check the authoritative Schema history\n"
|
||||
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
|
||||
@printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n"
|
||||
@printf " make multi-im-skill-chain-integrity - Check reviewed IM intents keep one default Skill route\n"
|
||||
@printf " make cli-smoke - Verify help for every public top-level command\n"
|
||||
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
|
||||
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
|
||||
@printf " make generate-schema - Refresh param_aliases + verify Schema assembly determinism\n"
|
||||
@printf " make generate-schema-catalog - Optional assembled Catalog dump under artifacts/ (not a delivery step)\n"
|
||||
@printf " make package - Build all release artifacts locally\n"
|
||||
@printf " make changelog-pre VERSION=vX.Y.Z-beta.N - Prepare prerelease notes\n"
|
||||
@printf " make changelog-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Prepare stable notes\n"
|
||||
@printf " make release-pre VERSION=vX.Y.Z-beta.N - Validate prerelease; publish official releases from Actions\n"
|
||||
@printf " make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Validate stable; publish official releases from Actions\n"
|
||||
@printf " make lint - Run formatting checks and golangci-lint when available\n"
|
||||
@printf " make fmt - Format Go source files\n"
|
||||
@printf " make policy - Run open-source asset and command-surface checks\n"
|
||||
@printf " make package - Build all release artifacts locally (goreleaser snapshot)\n"
|
||||
@printf " make release - Build and publish a release via goreleaser\n"
|
||||
@printf " make publish-homebrew-formula - Push dist/homebrew/dingtalk-workspace-cli.rb to a tap repo\n"
|
||||
|
||||
build:
|
||||
@@ -54,181 +22,24 @@ rebuild:
|
||||
@./scripts/dev/build.sh
|
||||
|
||||
test:
|
||||
@DWS_PACKAGE_VERSION="$(DWS_PACKAGE_VERSION)" $(GO) test -count=1 -timeout=10m ./...
|
||||
|
||||
test-plan:
|
||||
@./scripts/ci/test-packages.sh verify
|
||||
|
||||
test-auth-legacy-compat:
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@GO="$(GO)" $(POLICY_ENV) ./scripts/policy/check-auth-legacy-compat.sh
|
||||
|
||||
shortcut-public-e2e-proof: build
|
||||
@GO="$(GO)" DWS_PACKAGE_VERSION="$(DWS_PACKAGE_VERSION)" ./scripts/policy/check-shortcut-public-e2e-proof.sh
|
||||
@./test/scripts/run_all_tests.sh
|
||||
|
||||
lint:
|
||||
@./scripts/dev/lint.sh
|
||||
|
||||
format-check:
|
||||
@set -eu; \
|
||||
go_files="$$(mktemp "$${TMPDIR:-/tmp}/dws-go-files.XXXXXX")"; \
|
||||
trap 'rm -f "$$go_files"' EXIT HUP INT TERM; \
|
||||
$(GO_SOURCE_LIST) > "$$go_files"; \
|
||||
unformatted="$$(xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -l -- "$$@"; fi' sh < "$$go_files")"; \
|
||||
if [ -n "$$unformatted" ]; then \
|
||||
printf '%s\n' "$$unformatted"; \
|
||||
printf '%s\n' "Go files are not formatted. Run 'make fmt'." >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
fmt:
|
||||
@set -eu; \
|
||||
go_files="$$(mktemp "$${TMPDIR:-/tmp}/dws-go-files.XXXXXX")"; \
|
||||
trap 'rm -f "$$go_files"' EXIT HUP INT TERM; \
|
||||
$(GO_SOURCE_LIST) > "$$go_files"; \
|
||||
xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files"
|
||||
@find cmd internal test -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -w
|
||||
|
||||
policy: test-auth-legacy-compat shortcut-public-e2e-proof
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-multi-im-skill-chain.sh
|
||||
@python3 scripts/run_chat_shortcut_live_audit_test.py
|
||||
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
|
||||
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-param-alias-cooccurrence.sh
|
||||
@$(POLICY_ENV) $(GO) test -count=1 ./internal/app -run '^(TestParamAlias(FixtureThroughEmbeddedDeliveryPath|ReadCommandFinalPayload|WriteCommandFinalPayload|CanonicalConflictFailsBeforeRunE|BlockedFlagReachesReviewedFinalError)|TestFlagConflictErrorFormattingIsDeterministic)$$'
|
||||
@$(POLICY_ENV) ./scripts/policy/check-schema-catalog.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-schema-binary.sh
|
||||
@$(POLICY_ENV) $(MAKE) test-schema-agent-examples
|
||||
policy:
|
||||
@./scripts/policy/check-open-source-assets.sh
|
||||
@./scripts/policy/check-command-surface.sh --strict
|
||||
|
||||
edition-test:
|
||||
$(GO) test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
interface-integrity:
|
||||
@base_ref="$(BASE_REF)"; \
|
||||
candidate_ref="$(CANDIDATE_REF)"; \
|
||||
if [ -z "$$base_ref" ]; then base_ref="origin/main"; fi; \
|
||||
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
|
||||
./scripts/policy/check-authoritative-interface-baselines.sh \
|
||||
--base-ref "$$base_ref" \
|
||||
--stable-ref "$(STABLE_REF)" \
|
||||
--candidate-ref "$$candidate_ref"
|
||||
|
||||
authoritative-interface-integrity:
|
||||
@candidate_ref="$(CANDIDATE_REF)"; \
|
||||
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
|
||||
./scripts/policy/check-authoritative-interface-baselines.sh \
|
||||
--base-ref "$(BASE_REF)" \
|
||||
--stable-ref "$(STABLE_REF)" \
|
||||
--candidate-ref "$$candidate_ref"
|
||||
|
||||
coverage-gate:
|
||||
@./scripts/policy/check-coverage-gate.sh --base-ref "$(BASE_REF)" --scope-buildable
|
||||
|
||||
coverage-gate-platform:
|
||||
@./scripts/policy/run-platform-coverage-gate.sh --base-ref "$(BASE_REF)" --profile "$(PROFILE)"
|
||||
|
||||
update-interface-baseline:
|
||||
@./scripts/policy/check-interface-baseline.sh --update
|
||||
|
||||
reset-interface-baseline:
|
||||
@./scripts/policy/check-interface-baseline.sh --reset
|
||||
|
||||
schema-compatibility:
|
||||
@candidate_ref="$(CANDIDATE_REF)"; \
|
||||
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
|
||||
./scripts/policy/check-authoritative-schema-compatibility.sh \
|
||||
--base-ref "$(BASE_REF)" \
|
||||
--stable-ref "$(STABLE_REF)" \
|
||||
--candidate-ref "$$candidate_ref"
|
||||
|
||||
skill-command-integrity:
|
||||
@./scripts/policy/check-skill-commands.sh
|
||||
|
||||
skill-context-budget:
|
||||
@./scripts/policy/check-skill-context-budget.sh
|
||||
|
||||
multi-im-skill-chain-integrity:
|
||||
@./scripts/policy/check-multi-im-skill-chain.sh
|
||||
|
||||
skill-mono-multi-content:
|
||||
@./scripts/policy/check-mono-multi-skill-content.sh
|
||||
|
||||
cli-smoke:
|
||||
@./scripts/policy/check-cli-smoke.sh
|
||||
|
||||
mock-mcp-smoke:
|
||||
$(GO) test -v -count=1 -run '^(TestHTTPClientEndToEnd|TestStdioClientEndToEnd)$$' ./internal/transport
|
||||
|
||||
test-schema-agent-examples:
|
||||
DWS_AGENT_EXAMPLES_DRY_RUN=1 $(GO) test -v -count=1 ./internal/app -run '^TestAgentExamplesDryRun$$'
|
||||
|
||||
# generate-schema refreshes param_aliases_generated.go and verifies that
|
||||
# ResolveSchemaBuild assembly is deterministic. Catalog is runtime-assembled
|
||||
# (声明即 Catalog); cmd_schema_catalog is not a committed delivery step.
|
||||
# schema_agent_metadata/ and schema_hints/ must stay absent.
|
||||
generate-schema:
|
||||
@set -e; \
|
||||
concepts_guard=$$(mktemp); \
|
||||
concepts_schema_guard=$$(mktemp); \
|
||||
command_fallbacks_guard=$$(mktemp); \
|
||||
command_fallbacks_schema_guard=$$(mktemp); \
|
||||
trap 'rm -f "$$concepts_guard" "$$concepts_schema_guard" "$$command_fallbacks_guard" "$$command_fallbacks_schema_guard"' EXIT HUP INT TERM; \
|
||||
cp internal/cli/param_concepts.json "$$concepts_guard"; \
|
||||
cp internal/cli/param_concepts.schema.json "$$concepts_schema_guard"; \
|
||||
cp internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard"; \
|
||||
cp internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard"; \
|
||||
$(GO) generate ./internal/cli; \
|
||||
rm -rf internal/cli/schema_agent_metadata internal/cli/schema_agent_metadata_audit.json; \
|
||||
rm -f internal/cli/schema_meta_index.json; \
|
||||
if [ -e internal/cli/schema_command_registry ]; then \
|
||||
printf '%s\n' 'retired schema_command_registry/ must not reappear after generation' >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
cmp -s internal/cli/param_concepts.json "$$concepts_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
cmp -s internal/cli/param_concepts.schema.json "$$concepts_schema_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
cmp -s internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
cmp -s internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.schema.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
if [ -e internal/cli/schema_hints ]; then \
|
||||
printf '%s\n' 'retired schema_hints/ must not reappear after generation' >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
if [ -e internal/cli/schema_meta_index.json ]; then \
|
||||
printf '%s\n' 'retired schema_meta_index.json must not remain after generation' >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
./scripts/policy/check-schema-assembly.sh
|
||||
|
||||
# Optional local/CI dump of an assembled Catalog under artifacts/ by default.
|
||||
# Override SCHEMA_CATALOG_OUTPUT and SCHEMA_META_INDEX_OUTPUT as needed. This
|
||||
# is not a go:generate or production delivery step.
|
||||
generate-schema-catalog:
|
||||
$(GO) run -a ./internal/generator/cmd_schema_catalog \
|
||||
-root . \
|
||||
-output "$(SCHEMA_CATALOG_OUTPUT)" \
|
||||
-meta-index "$(SCHEMA_META_INDEX_OUTPUT)"
|
||||
|
||||
fetch-mcp-metadata:
|
||||
@printf ' %sFetching diagnostic MCP dump (not a Schema pin)%s\n' "$(COLOR_RUN)" "$(COLOR_RESET)"
|
||||
@./scripts/dev/fetch_mcp_metadata.sh
|
||||
|
||||
package:
|
||||
@version="$(if $(VERSION),$(VERSION),v0.0.0-SNAPSHOT)"; VERSION="$${version#v}" ./scripts/dev/build-all.sh
|
||||
@version="$(if $(VERSION),$(VERSION),v0.0.0-SNAPSHOT)"; DWS_PACKAGE_VERSION="$$version" ./scripts/release/post-goreleaser.sh
|
||||
@./scripts/dev/build-all.sh
|
||||
@./scripts/release/post-goreleaser.sh
|
||||
|
||||
publish-homebrew-formula:
|
||||
@./scripts/release/publish-homebrew-formula.sh
|
||||
@@ -236,32 +47,6 @@ publish-homebrew-formula:
|
||||
setup-hooks:
|
||||
@git config core.hooksPath scripts/hooks 2>/dev/null || true
|
||||
|
||||
changelog-pre:
|
||||
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3-beta.1\n' >&2; exit 2)
|
||||
@./scripts/release/prepare-changelog.sh prerelease "$(VERSION)"
|
||||
|
||||
changelog-stable:
|
||||
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3\n' >&2; exit 2)
|
||||
@test -n "$(FROM_BETA)" || (printf 'FROM_BETA is required, e.g. v1.2.3-beta.2\n' >&2; exit 2)
|
||||
@./scripts/release/prepare-changelog.sh stable "$(VERSION)" --from-beta "$(FROM_BETA)"
|
||||
|
||||
release-pre:
|
||||
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3-beta.1\n' >&2; exit 2)
|
||||
@test -n "$(REMOTE)" || (printf 'REMOTE is required, e.g. origin\n' >&2; exit 2)
|
||||
@args=""; \
|
||||
if [ "$(PUBLISH)" = "1" ]; then args="$$args --publish"; fi; \
|
||||
if [ "$(YES)" = "1" ]; then args="$$args --yes"; fi; \
|
||||
./scripts/release/release.sh prerelease "$(VERSION)" --remote "$(REMOTE)" $$args
|
||||
|
||||
release-stable:
|
||||
@test -n "$(VERSION)" || (printf 'VERSION is required, e.g. v1.2.3\n' >&2; exit 2)
|
||||
@test -n "$(FROM_BETA)" || (printf 'FROM_BETA is required, e.g. v1.2.3-beta.2\n' >&2; exit 2)
|
||||
@test -n "$(REMOTE)" || (printf 'REMOTE is required, e.g. origin\n' >&2; exit 2)
|
||||
@args=""; \
|
||||
if [ "$(PUBLISH)" = "1" ]; then args="$$args --publish"; fi; \
|
||||
if [ "$(YES)" = "1" ]; then args="$$args --yes"; fi; \
|
||||
./scripts/release/release.sh stable "$(VERSION)" --from-beta "$(FROM_BETA)" --remote "$(REMOTE)" $$args
|
||||
|
||||
release:
|
||||
@printf 'Use make release-pre or make release-stable; direct goreleaser publishing is disabled.\n' >&2
|
||||
@exit 2
|
||||
goreleaser release --clean
|
||||
@./scripts/release/post-goreleaser.sh
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
> [!IMPORTANT]
|
||||
> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Join the DingTalk DWS co-creation group for support and updates. See [Getting Started](#getting-started) below.
|
||||
>
|
||||
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws Open Source Community DingTalk Group QR Code" width="150">
|
||||
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
|
||||
|
||||
<details>
|
||||
<summary><strong>Table of Contents</strong></summary>
|
||||
@@ -63,27 +63,6 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>Skill mode: mono vs multi</strong></summary>
|
||||
|
||||
The installer ships skills in one of two layouts. CLI commands (`dws aitable ...`, `dws calendar ...`) are identical in both modes — only the agent-side skill layout differs.
|
||||
|
||||
| Mode | What gets installed | Best for |
|
||||
|------|----------------------|----------|
|
||||
| **multi** (default) | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **mono** (legacy) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
|
||||
> Installs and upgrades default to `multi`. `mono` remains available via `DWS_SKILL_MODE=mono` or `dws skill setup --mode mono`. File issues if you hit problems.
|
||||
|
||||
How to pick:
|
||||
|
||||
- **Quick install** (one-liner above): non-interactive, installs `multi`.
|
||||
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) multi 2) mono` (default 1).
|
||||
- **Override via env**: `DWS_SKILL_MODE=mono curl -fsSL ... | sh`.
|
||||
- **Switch later**: `dws skill setup --mode mono` (or `--mode multi`) — review the listed paths and confirm interactively.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Other install methods</summary>
|
||||
|
||||
@@ -93,30 +72,6 @@ How to pick:
|
||||
npm install -g dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
Install the latest beta:
|
||||
|
||||
```bash
|
||||
npm install -g dingtalk-workspace-cli@beta
|
||||
```
|
||||
|
||||
**Homebrew** (macOS / Linux):
|
||||
|
||||
```bash
|
||||
brew tap DingTalk-Real-AI/dingtalk-workspace-cli https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
|
||||
brew install dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
> The Formula lives in this repository, so the first `tap` command must include the explicit repository URL. Afterwards, use `brew upgrade dingtalk-workspace-cli` normally.
|
||||
|
||||
Install the keg-only Homebrew beta without replacing the stable Formula:
|
||||
|
||||
```bash
|
||||
brew install dingtalk-workspace-cli-beta
|
||||
$(brew --prefix dingtalk-workspace-cli-beta)/bin/dws version
|
||||
```
|
||||
|
||||
To make the beta `dws` the default for the current shell, prepend `$(brew --prefix dingtalk-workspace-cli-beta)/bin` to PATH.
|
||||
|
||||
**Pre-built binary**: download from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases).
|
||||
|
||||
> **macOS users**: If you see "cannot be opened because Apple cannot check it for malicious software", run:
|
||||
@@ -133,44 +88,10 @@ go build -o dws ./cmd # build to current directory
|
||||
cp dws ~/.local/bin/ # install to PATH
|
||||
```
|
||||
|
||||
Static endpoint data is generated from the Wukong baseline and committed in this
|
||||
repository under `internal/syncdata`, so source builds do not require a sibling
|
||||
data checkout.
|
||||
|
||||
> Requires Go 1.25+. Use `make package` to cross-compile for all platforms (macOS / Linux / Windows x amd64 / arm64).
|
||||
|
||||
</details>
|
||||
|
||||
## China mirror
|
||||
|
||||
For users in mainland China, the following channels avoid GitHub network issues. By default (without setting these environment variables) the installer pulls from GitHub.
|
||||
|
||||
**1. Install script + pre-built binary (Gitee mirror):**
|
||||
|
||||
Repository mirror: `https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli`
|
||||
|
||||
```bash
|
||||
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
> With `DWS_GITEE_REPO` set, the installer resolves the latest version and every release asset (binary, checksums, skills) from the Gitee API instead of GitHub. If it is unset, installation defaults to GitHub.
|
||||
|
||||
**2. npm package (npmmirror mirror):**
|
||||
|
||||
```bash
|
||||
npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
|
||||
```
|
||||
|
||||
> npmmirror automatically syncs public packages from the public npm registry, so this works directly in China.
|
||||
|
||||
**3. Skills only (Gitee mirror):**
|
||||
|
||||
```bash
|
||||
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> With `DWS_GITEE_REPO` set, `install-skills.sh` resolves the version and skills package from Gitee; it also auto-falls back to the Gitee mirror when GitHub is unreachable.
|
||||
|
||||
## Upgrade
|
||||
|
||||
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
|
||||
@@ -180,46 +101,27 @@ dws has built-in self-upgrade capability. Updates are pulled directly from [GitH
|
||||
```bash
|
||||
dws upgrade # interactive upgrade to latest version
|
||||
dws upgrade --check # check for new versions without installing
|
||||
dws upgrade --list # list stable release versions
|
||||
dws upgrade --beta # upgrade to the latest beta pre-release
|
||||
dws upgrade --check --beta # check the beta track without installing
|
||||
dws upgrade --list --beta # list beta pre-release versions
|
||||
dws upgrade --list # list all available versions
|
||||
dws upgrade --version v1.0.7 # upgrade to a specific version
|
||||
dws upgrade --version v1.0.8-beta.1 # upgrade to a specific beta version
|
||||
dws upgrade --rollback # rollback to the previous version
|
||||
dws upgrade -y # skip confirmation prompt
|
||||
```
|
||||
|
||||
By default, `dws upgrade` follows the stable release track. Use `--beta` only when you explicitly want the newest GitHub pre-release build.
|
||||
|
||||
### Six-channel post-release verification
|
||||
|
||||
Maintainers and release validators can run the release-quality smoke checks for curl, PowerShell, npm stable, npm beta, Homebrew, and `dws upgrade`:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git /tmp/dws-verify
|
||||
cd /tmp/dws-verify/verify
|
||||
bash verify-all-channels.sh
|
||||
```
|
||||
|
||||
The verifier uses isolated directories and does not replace the `dws` on the current PATH. It reports `PASS`, `FAIL`, and `SKIP`; a platform skip is not a pass and must be covered on the matching host. See [`verify/README.md`](verify/README.md) for the platform matrix.
|
||||
|
||||
<details>
|
||||
<summary><strong>How it works</strong></summary>
|
||||
|
||||
The upgrade process follows a two-phase atomic flow to ensure consistency:
|
||||
|
||||
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
|
||||
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into the canonical `~/.agents/skills` root. Agents classified by the pinned compatibility registry as supporting the universal root read it directly; other detected Agents receive links to the canonical copy, with a direct-copy fallback when links are unavailable. Older DWS-managed agent-specific copies are backed up and retired so the same Skill is not discovered twice.
|
||||
2. **Apply** — only after all preparations succeed, the binary is replaced and skill packages are installed to all detected agent directories (`~/.agents/skills/dws`, `~/.claude/skills/dws`, `~/.cursor/skills/dws`, etc.).
|
||||
|
||||
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--check` | Check for updates without installing |
|
||||
| `--list` | List available stable release versions with changelogs |
|
||||
| `--beta` | Use the beta pre-release track for `upgrade`, `--check`, or `--list` |
|
||||
| `--version` | Upgrade to a specific version (e.g. `v1.0.7` or `v1.0.8-beta.1`) |
|
||||
| `--list` | List all available versions with changelogs |
|
||||
| `--version` | Upgrade to a specific version (e.g. `v1.0.7`) |
|
||||
| `--rollback` | Rollback to the previous backed-up version |
|
||||
| `--force` | Force reinstall even if already on the latest version |
|
||||
| `--skip-skills` | Skip skill package update |
|
||||
@@ -280,75 +182,15 @@ Credentials are securely persisted after first login (Keychain). Subsequent runs
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Multiple organizations (profiles)</strong></summary>
|
||||
|
||||
`dws` can stay logged in to several DingTalk accounts at once, including multiple accounts in the same organization. A profile is uniquely identified by `corpId:userId`; the current profile decides which identity a command runs as.
|
||||
|
||||
```bash
|
||||
dws auth login # add or refresh one account
|
||||
dws profile list # list every logged-in account
|
||||
dws profile switch <corpId:userId> # persistently switch; use - to toggle back
|
||||
dws profile switch "<corpName>:<userName>" # friendly input; names must be unique
|
||||
dws --profile <corpId> contact user search --query "..." # use that org's explicitly recorded current account
|
||||
dws --profile <corpId:userId> contact user search --query "..." # use one exact account without changing the default
|
||||
```
|
||||
|
||||
Selectors support `corpId:userId`, `corpId:userName`, `corpName:userId`, and `corpName:userName`. Friendly names are input aliases only; use the stable `profile` value returned by `profile list` for automation. Duplicate organization or account names fail with explicit `corpId:userId` candidates. If an organization has multiple accounts but no recorded current account, `--profile <corpId>` fails instead of choosing the first or most recently used account.
|
||||
|
||||
`currentProfile`, `previousProfile`, and per-organization defaults are stored as exact identities. `primaryProfile` remains in JSON only for compatibility and is not used for selection. `profile list` reads status and expiry from each real identity Token without refreshing it. `auth logout --profile <corpId>` removes all local accounts in that organization; an exact selector or local profile name removes one account.
|
||||
|
||||
Cross-org reads are orchestrated by the agent rather than a built-in `--all-orgs`: list profiles, group by `corpId`, and use the unique `isOrgCurrent=true` account for each organization. If a multi-account organization has no default, ask the user to choose an account first. Writes default to the current account — confirm both organization and account before cross-org writes.
|
||||
|
||||
On macOS, an unreadable registered token slot blocks a new OAuth login rather than risking a mixed Keychain/file-DEK state. If normal terminal commands can still read the login while a sandbox using `DWS_DISABLE_KEYCHAIN=1` cannot, migrate the legacy and profile auth entries without exposing tokens:
|
||||
|
||||
```bash
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json
|
||||
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
|
||||
```
|
||||
|
||||
The migration validates every selected auth ciphertext before writing, ignores unrelated application secrets, and can be rerun after an interrupted commit. If validation identifies genuinely damaged ciphertext, remove only the affected account with `dws auth logout --profile <corpId:userId>`, or all accounts in one organization with `--profile <corpId>`, then log in again. Use `dws auth reset` only when you intend to discard every local profile.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Migrate auth between Linux sandboxes</strong></summary>
|
||||
|
||||
Copying only `~/.dws/app.json` does not carry the refresh token; access tokens expire after ~2 hours. Use the official export/import flow:
|
||||
|
||||
```bash
|
||||
# Sandbox A (already logged in)
|
||||
dws auth export -o /tmp/dws-auth.tar.gz
|
||||
# Or for copy/paste: dws auth export --base64 -o /tmp/dws-auth.b64
|
||||
|
||||
# Sandbox B
|
||||
dws auth import -i /tmp/dws-auth.tar.gz
|
||||
# Or: dws auth import -i /tmp/dws-auth.b64 --base64
|
||||
dws auth status # confirm "Refresh Token: valid"
|
||||
```
|
||||
|
||||
The bundle includes the encrypted keychain under `~/.local/share/dws-cli` (with `auth-token.enc` and `dek`) plus required `~/.dws` config files.
|
||||
Windows export and import are intentionally rejected before credentials or
|
||||
bundles are read: Windows stores credentials as DPAPI-protected HKCU Registry
|
||||
values, and the current file-DEK bundle has no safe DPAPI-to-portable conversion.
|
||||
|
||||
</details>
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
dws contact user search --query "engineering" # search contacts
|
||||
dws calendar event list # list today's calendar events
|
||||
dws doc search --query "quarterly" # search DingTalk Docs
|
||||
dws minutes list mine # list AI meeting notes I created
|
||||
dws drive list # list DingTalk drive files
|
||||
dws contact user search --keyword "engineering" # search contacts
|
||||
dws calendar event list # list calendar events
|
||||
dws todo task create --title "Quarterly report" --executors "<your-userId>" # create a todo (replace <your-userId>)
|
||||
dws todo task list --dry-run # preview without executing
|
||||
```
|
||||
|
||||
> **Full command list**: [`docs/command-index.md`](./docs/command-index.md) — all commands with descriptions and when-to-use guidance.
|
||||
|
||||
## Using with Agents
|
||||
|
||||
dws is designed as an AI-native CLI. Complete [Installation](#installation) and [Getting Started](#getting-started) first, then configure your agent:
|
||||
@@ -360,99 +202,49 @@ dws is designed as an AI-native CLI. Complete [Installation](#installation) and
|
||||
dws todo task create --title "Review PR" --executors "<your-userId>" --yes
|
||||
|
||||
# Use --dry-run to preview operations (safe execution)
|
||||
dws contact user search --query "engineering" --dry-run
|
||||
dws contact user search --keyword "engineering" --dry-run
|
||||
|
||||
# Use --jq to extract precisely (save tokens)
|
||||
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
|
||||
```
|
||||
|
||||
### Command Help and Schema
|
||||
### Schema Discovery
|
||||
|
||||
Use Cobra help and Schema for different parts of the command contract:
|
||||
|
||||
- `dws <path> --help` is the source of truth for whether a command exists and which flags the binary accepts.
|
||||
- `dws schema "<path>" --compact` is the normative Agent view for command selection, CLI parameters and constraints, risk, and confirmation; use a full leaf with a narrow `--jq` projection for mapping or provenance audits.
|
||||
- If Help and Schema disagree, treat it as contract drift: pass only flags accepted by Cobra and use the more conservative safety semantics.
|
||||
- Schema describes commands; it does not read or search DingTalk business data. Execute the real product command after discovery.
|
||||
Agents don't need pre-built knowledge of every command. Use `dws schema` to dynamically discover capabilities:
|
||||
|
||||
```bash
|
||||
# Confirm that the command exists and inspect accepted flags
|
||||
dws aitable record query --help
|
||||
# Step 1: Discover all available products
|
||||
dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
|
||||
|
||||
# Discover within a product, then inspect the selected leaf contract
|
||||
dws schema aitable --compact
|
||||
dws schema "aitable record query" --compact
|
||||
# Step 2: Inspect target tool's parameter schema
|
||||
dws schema aitable.query_records --jq '.tool.parameters'
|
||||
|
||||
# Execute the real business query
|
||||
# Step 3: Construct the correct call
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should query progressively with `--compact`; its positive field allowlist prevents new full/audit fields from silently expanding Agent context.
|
||||
|
||||
### Agent Skills
|
||||
|
||||
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
|
||||
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), legacy.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. Default layout.
|
||||
|
||||
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
|
||||
|
||||
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
|
||||
The repo ships a complete Agent Skill system (`skills/`). After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
|
||||
|
||||
```bash
|
||||
# Install skills into current project (defaults to multi; DWS_SKILL_MODE=mono switches back)
|
||||
# Install skills into current project
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> Installers use `$HOME/.agents/skills/` as the canonical global store, following the universal `.agents/skills` convention. Agents classified by the pinned compatibility registry as universal read that root directly; detected non-universal Agents receive links to it (or copies when links are unavailable). Multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
|
||||
>
|
||||
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
|
||||
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
|
||||
|
||||
**Switching or re-installing with `dws skill setup`:**
|
||||
|
||||
```bash
|
||||
# Interactive: prompts for mode + target agents
|
||||
dws skill setup
|
||||
|
||||
# Preview the exact directories that mono setup would back up and replace
|
||||
dws skill setup --mode mono --target all --dry-run
|
||||
|
||||
# Run interactively and confirm the listed directories
|
||||
dws skill setup --mode mono --target all
|
||||
|
||||
# Preview, then install multi skills to a single agent home with interactive confirmation
|
||||
dws skill setup --mode multi --target cursor --dry-run
|
||||
dws skill setup --mode multi --target cursor
|
||||
|
||||
# Point at a local source tree (e.g. a fork or work-in-progress), preview first
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
```
|
||||
|
||||
| Flag | Values | Description |
|
||||
|------|--------|-------------|
|
||||
| `--mode` | `mono` \| `multi` | Skill layout; defaults to interactive prompt |
|
||||
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `zcode` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home, including ZCode at `~/.zcode/skills` |
|
||||
| `--source` | path | Local source directory (overrides bundled skills) |
|
||||
| `--yes` | — | Scripting-only: skip the confirmation prompt. Removals are still backed up to `~/.dws/skill-backups/` first |
|
||||
|
||||
> The setup command can remove the opposite-mode layout (`dws/` for multi, DWS-managed multi Skills for mono) and stale managed Skills not in the bundle. DWS records ownership, installer version, source, and content digest centrally in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Exact official names shipped before the centralized state remain a frozen migration list. A `dingtalk-*` prefix alone never authorizes cleanup, so other same-prefix market/user Skills are preserved. Every removal is previewed before confirmation and preserved under `~/.dws/skill-backups/<timestamp>/`; a directory that cannot be backed up is never removed. In a non-interactive shell, first run `--dry-run` and inspect its output; only then may the caller explicitly choose the scripting-only confirmation bypass.
|
||||
|
||||
After a multi setup or upgrade, DWS stores the official bundle snapshot and centralized ownership metadata in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Every upgrade installs and overwrites the complete bundled Skill set from that release. Deleting or excluding a bundled Skill is not sticky: the next upgrade restores it. `dws upgrade --force` additionally allows reinstalling the current CLI version when no newer version is available.
|
||||
|
||||
Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.ps1`), `DWS_SKILL_SOURCE=<path>`.
|
||||
|
||||
**What's included (mono layout):**
|
||||
**What's included:**
|
||||
|
||||
| Component | Path | Description |
|
||||
|-----------|------|-------------|
|
||||
| Master Skill | `skills/mono/SKILL.md` | Intent routing, decision tree, safety rules, error handling |
|
||||
| Product references | `skills/mono/references/products/*.md` | Per-product command reference (aitable, chat, calendar, etc.) |
|
||||
| Intent guide | `skills/mono/references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
|
||||
| Global reference | `skills/mono/references/global-reference.md` | Auth, output formats, global flags |
|
||||
| Error codes | `skills/mono/references/error-codes.md` | Error codes + debugging workflows |
|
||||
| Ready-made scripts | `skills/mono/scripts/*.py` | 13 batch operation scripts (see below) |
|
||||
| Master Skill | `SKILL.md` | Intent routing, decision tree, safety rules, error handling |
|
||||
| Product references | `references/products/*.md` | Per-product command reference (aitable, chat, calendar, etc.) |
|
||||
| Intent guide | `references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
|
||||
| Global reference | `references/global-reference.md` | Auth, output formats, global flags |
|
||||
| Error codes | `references/error-codes.md` | Error codes + debugging workflows |
|
||||
| Recovery guide | `references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
|
||||
| Ready-made scripts | `scripts/*.py` | 13 batch operation scripts (see below) |
|
||||
|
||||
<details>
|
||||
<summary><strong>Ready-made scripts</strong> — 13 Python scripts for common multi-step workflows</summary>
|
||||
@@ -479,135 +271,6 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
|
||||
|
||||
## Features
|
||||
|
||||
<details>
|
||||
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
|
||||
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and seven OA approval task/instance events.
|
||||
|
||||
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
|
||||
|
||||
> **Prerequisite**: run `dws auth login`. Personal identity is resolved from the OAuth token and cannot be supplied through command-line identity flags.
|
||||
|
||||
For an event-focused installation, use the official convenience installer:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
|
||||
|
||||
# Or install the standalone multi skill from an existing dws installation
|
||||
dws skill setup --mode multi -s event
|
||||
```
|
||||
|
||||
```bash
|
||||
# Inspect the public personal event catalog and schema
|
||||
dws event list
|
||||
dws event schema user_im_message_receive_o2o --flatten
|
||||
dws event list --category oa
|
||||
dws event schema user_oa_approval_task_created --flatten
|
||||
|
||||
# Listen for messages that mention the current user
|
||||
dws event +listen-im --kind at-me -f ndjson
|
||||
|
||||
# Listen for messages from a specified sender
|
||||
dws event +listen-im --kind sender --user <userId> -f ndjson
|
||||
|
||||
# Listen by openDingtalkId (external contact, bot, or cross-organization identity)
|
||||
dws event +listen-im --kind sender --open-dingtalk-id <openDingtalkId> -f ndjson
|
||||
|
||||
# Listen for messages in a specified group
|
||||
dws event +listen-im --kind group --chat-id <openConversationId> -f ndjson
|
||||
|
||||
# Listen for all one-to-one or all group messages
|
||||
dws event +listen-im --kind all-direct -f ndjson
|
||||
dws event +listen-im --kind all-group -f ndjson
|
||||
|
||||
# Listen for a specified group's title changes, member changes, or disband event
|
||||
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_group_member_added --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
|
||||
|
||||
# Listen for messages, reads, and recalls from the same sender in one process
|
||||
dws event +listen-im --kind sender --user <userId> \
|
||||
--events message,read,recall -f ndjson
|
||||
|
||||
# Listen for all seven public OA approval events in one process
|
||||
dws event consume \
|
||||
user_oa_approval_task_created \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_cc \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten -f ndjson
|
||||
|
||||
# Inspect local consumers and cancel a subscription
|
||||
dws event status
|
||||
dws event stop <subscribe_id>
|
||||
```
|
||||
|
||||
For one-to-one and specified-sender events, use exactly one target identity: `--user` for an internal `userId`, or `--open-dingtalk-id` for an `openDingtalkId`. The CLI does not infer or convert between these identity types.
|
||||
|
||||
| Feature | Details |
|
||||
|---------|---------|
|
||||
| Managed lifecycle | `consume` creates or reuses the personal subscription; `stop` cancels it and cleans local state |
|
||||
| Shared connection | Consumers for the same user share one local bus and cloud connection |
|
||||
| Multi-event process | One consume process can listen for compatible events for the same target while retaining one subscription per event |
|
||||
| Subscription isolation | Normal consumers match both event type and `subscribe_id` |
|
||||
| Agent-friendly output | Stream events are written to stdout as NDJSON; status and diagnostics use stderr |
|
||||
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
|
||||
| Cross-platform | Unix Socket on macOS/Linux, Windows Named Pipe on Windows |
|
||||
|
||||
See `skills/multi/dingtalk-event/SKILL.md` for the Agent workflow and supported event parameters.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Raw API Access</strong> — call any DingTalk OpenAPI directly</summary>
|
||||
|
||||
`dws api` lets you call any DingTalk OpenAPI without an SDK. Tokens are automatically acquired and refreshed.
|
||||
|
||||
> **Prerequisite**: Must login with your own app credentials (see [Custom App mode](#getting-started)). Encrypted tokens from MCP default-credential login are not supported for raw API calls.
|
||||
|
||||
```bash
|
||||
# Login (first time only)
|
||||
dws auth login --client-id <APP_KEY> --client-secret <APP_SECRET>
|
||||
|
||||
# === api.dingtalk.com ===
|
||||
|
||||
# List all enterprise apps
|
||||
dws api GET /v1.0/microApp/allApps
|
||||
|
||||
# Search users (POST + JSON body)
|
||||
dws api POST /v1.0/contact/users/search \
|
||||
--data '{"queryWord":"engineering","offset":0,"size":10}'
|
||||
|
||||
# === oapi.dingtalk.com ===
|
||||
|
||||
# Get user details (use --base-url to specify domain)
|
||||
dws api POST /topapi/v2/user/get \
|
||||
--base-url https://oapi.dingtalk.com \
|
||||
--data '{"userid":"<USER_ID>"}'
|
||||
|
||||
# Or use the full URL directly
|
||||
dws api POST https://oapi.dingtalk.com/topapi/v2/user/get \
|
||||
--data '{"userid":"<USER_ID>"}'
|
||||
|
||||
# === General ===
|
||||
dws api GET /v1.0/microApp/allApps --page-all # auto-paginate
|
||||
dws api GET /v1.0/microApp/allApps --dry-run # preview request
|
||||
dws api GET /v1.0/microApp/allApps --jq '.agentId' # jq filtering
|
||||
```
|
||||
|
||||
| Feature | Details |
|
||||
|---------|----------|
|
||||
| Dual-form auto-detection | Automatically selects api.dingtalk.com (header auth) or oapi.dingtalk.com (query-param auth) based on URL |
|
||||
| Automatic token management | App-level accessToken is fetched on first call, cached while valid, auto-refreshed on expiry |
|
||||
| Domain allowlist | Only `api.dingtalk.com` and `oapi.dingtalk.com` permitted — prevents token leakage |
|
||||
| Auto-pagination | `--page-all` iterates all pages. `--page-limit` caps the maximum (default 10, set to 0 for unlimited, hard cap at 500 to prevent infinite loops) |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Smart Input Correction</strong> — auto-corrects common AI model parameter mistakes</summary>
|
||||
|
||||
@@ -618,7 +281,7 @@ Built-in pipeline engine that normalizes flag names, splits sticky arguments, an
|
||||
dws aitable record query --baseId BASE_ID --tableId TABLE_ID # auto-corrected to --base-id --table-id
|
||||
|
||||
# Sticky argument splitting
|
||||
dws contact user search --query "engineering" --timeout30 # auto-split to --timeout 30
|
||||
dws contact user search --keyword "engineering" --timeout30 # auto-split to --timeout 30
|
||||
|
||||
# Fuzzy flag name matching
|
||||
dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-id -> --table-id
|
||||
@@ -643,7 +306,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
|
||||
```bash
|
||||
# Built-in jq expressions
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
|
||||
dws schema "dev app create" --jq '.parameters'
|
||||
dws schema --jq '.products[] | {id, tools: (.tools | length)}'
|
||||
|
||||
# Return only specific fields
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
|
||||
@@ -652,13 +315,13 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Schema Introspection</strong> — Agent command discovery and execution contracts</summary>
|
||||
<summary><strong>Schema Introspection</strong> — query parameter schemas before making calls</summary>
|
||||
|
||||
```bash
|
||||
dws schema aitable --compact # discover product commands
|
||||
dws schema "aitable record query" --compact # view the selected Agent leaf contract
|
||||
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # view required fields
|
||||
dws schema --all # full export for CI/audit/baselines
|
||||
dws schema # list all products and tools
|
||||
dws schema aitable.query_records # view parameter schema
|
||||
dws schema aitable.query_records --jq '.tool.required' # view required fields
|
||||
dws schema --jq '.products[].id' # extract all product IDs
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -680,65 +343,31 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "Weekly Report" --text @-
|
||||
```
|
||||
|
||||
> **Note**: `@` is treated as the `@<path>` file-injection prefix only when the next character is an ASCII path-shaped character (`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`), or `@-` for stdin. Chat-bot payloads like `--text "@所有人 周报"` or `--text "@张三 看一下"` pass through unchanged, so literal mentions reach the API as-is.
|
||||
|
||||
</details>
|
||||
|
||||
## DingTalk bot — connect a robot to your local AI
|
||||
|
||||
`dws dev connect` bridges a DingTalk robot to a local AI CLI (Claude Code / Codex / opencode / Qoder / Gemini, or any tool via `--agent-cmd`): @-mention the bot in a chat and it answers using your local agent, keeping per-conversation multi-turn memory.
|
||||
|
||||
```bash
|
||||
dws dev connect --channel auto --unified-app-id <unifiedAppId>
|
||||
```
|
||||
|
||||
> `--unified-app-id` resolves `clientSecret` at runtime via `dev app credentials get`,
|
||||
> so the secret never appears in argv (`ps` / journald / shell history). The
|
||||
> legacy `--robot-client-id <id> --robot-client-secret <secret>` still works but
|
||||
> the CLI will warn you.
|
||||
|
||||
In-chat **session commands** (send the bare command as the whole message — no agent turn, no tokens):
|
||||
|
||||
| Command | Effect |
|
||||
|---------|--------|
|
||||
| `/new` (aliases `/start`, `/reset`) | Start a fresh session; the previous one is left intact (resumable where the agent supports it) |
|
||||
| `/clear` | Wipe the current session — disposed through the agent's real session op (opencode issues `DELETE /session/:id`); channels whose agent exposes no delete primitive fall back to a reset |
|
||||
|
||||
See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step walkthrough (install → create robot → connect → add to a group).
|
||||
|
||||
## Key Services
|
||||
|
||||
| Service | Command | Capabilities |
|
||||
|---------|---------|--------------|
|
||||
| Contact | `contact` | Look up users, departments, labels, roster profiles and dismissals; create enterprises and enterprise accounts; invite employees |
|
||||
| Chat / IM | `chat` (`im`) | Send / reply / search messages, group & member management, bot & webhook messaging, reactions, recall |
|
||||
| Calendar | `calendar` | Events CRUD, attendees, meeting rooms, free/busy & time suggestions |
|
||||
| Todo | `todo` | Create / list / update / complete tasks and comments |
|
||||
| Approval | `oa` | Approve / reject / revoke / transfer; query pending / initiated / CC instances and forms |
|
||||
| Attendance | `attendance` | Clock-in records, shifts, summaries, group rules (read-only) |
|
||||
| Ding | `ding` | Send / recall DING messages |
|
||||
| Report | `report` | Create / submit logs, inbox & outbox, templates, statistics |
|
||||
| AI Tables | `aitable` | Bases / tables / records / fields / views, permissions & roles, automation, charts & dashboards, import / export |
|
||||
| Doc | `doc` | Search / read / write docs, block-level editing, comments, permissions, media, up / download |
|
||||
| Drive | `drive` | List / search / download, folders, upload, copy / move / rename, permissions |
|
||||
| Minutes | `minutes` | AI meeting notes: list, summary / keywords / transcription / todos, mind map, speakers, tags |
|
||||
| Mail | `mail` | Mailboxes, KQL search, read / send, drafts, folders, templates, contacts |
|
||||
| Sheet | `sheet` | Online spreadsheets: worksheet & range read / write, filters, conditional format, images, CSV |
|
||||
| Wiki | `wiki` | Knowledge bases: spaces, members, node tree, docs & files |
|
||||
| DevDoc | `devdoc` | Search the Open Platform docs and diagnose API errors |
|
||||
| AI Search | `aisearch` | Enterprise people search by name / dept / role / duty / supervisor / phone / job-number |
|
||||
| Live | `live` | List my live streams |
|
||||
| Raw API | `api` | Call any DingTalk OpenAPI directly, with managed app-level token |
|
||||
| Service | Command | Commands | Subcommands | Description |
|
||||
|---------|---------|:--------:|-------------|-------------|
|
||||
| Contact | `contact` | 6 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
|
||||
| Chat | `chat` | 10 | `message` `group` `search` | Group CRUD, member management, bot messaging, webhook |
|
||||
| Bot | `chat bot` | 6 | `bot` `group` `message` `search` | Robot creation/search, group/single messaging, webhook, message recall |
|
||||
| Calendar | `calendar` | 13 | `event` `room` `participant` `busy` | Events CRUD, meeting room booking, free-busy query, participant management |
|
||||
| Todo | `todo` | 6 | `task` | Create, list, update, done, get detail, delete |
|
||||
| Approval | `oa` | 9 | `approval` | Approve/reject/revoke, pending tasks, initiated instances, process list |
|
||||
| Attendance | `attendance` | 4 | `record` `shift` `summary` `rules` | Clock-in records, shift schedules, attendance summary, group rules |
|
||||
| Ding | `ding` | 2 | `message` | Send/recall DING messages |
|
||||
| Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics |
|
||||
| AITable | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | Full CRUD for bases/tables/records/fields, templates |
|
||||
| Workbench | `workbench` | 2 | `app` | Batch query app details |
|
||||
| DevDoc | `devdoc` | 1 | `article` | Search platform docs and error codes |
|
||||
|
||||
> Full command listing with usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for any service's subcommands.
|
||||
|
||||
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
|
||||
> 86 commands across 12 products. Run `dws --help` for the full list, or `dws <service> --help` for subcommands.
|
||||
|
||||
<details>
|
||||
<summary>Coming soon</summary>
|
||||
|
||||
- `conference` (video meetings)
|
||||
- Multi-skill mode (default) — per-product skills under `skills/multi/`; installs and upgrades default to it, `dws skill setup --mode mono` switches back after interactive confirmation
|
||||
`doc` (documents) · `mail` (email) · `minutes` (AI transcription) · `drive` (cloud drive) · `conference` (video) · `tb` (Teambition) · `aiapp` (AI apps) · `live` (streaming) · `skill` (marketplace)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -787,11 +416,8 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
|
||||
|
||||
## Reference & Docs
|
||||
|
||||
- [International DingTalk (`.io`) guide](./docs/international-region-guide.md) — international login, domestic/international profile switching, isolated testing, and troubleshooting
|
||||
- [Command Index](./docs/command-index.md) — every runtime command with description and when-to-use guidance
|
||||
- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
|
||||
- [Architecture](./docs/architecture.md) — static endpoint pipeline, command surface, transport layer
|
||||
- [Open Platform App Command Routing](./docs/dev-yulan-command-routing.md) — yulan dev app command design, MCP overlay, permission flow, and Agent routing
|
||||
- [Architecture](./docs/architecture.md) — discovery-driven pipeline, IR, transport layer
|
||||
- [Changelog](./CHANGELOG.md) — release history and migration notes
|
||||
|
||||
## Contributing
|
||||
|
||||
+51
-414
@@ -19,9 +19,9 @@
|
||||
</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **钉钉 DWS CLI 已全面开放,欢迎使用**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
|
||||
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
|
||||
>
|
||||
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
|
||||
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
|
||||
|
||||
<details>
|
||||
<summary><strong>目录</strong></summary>
|
||||
@@ -63,27 +63,6 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>Skill 模式:mono 与 multi</strong></summary>
|
||||
|
||||
安装时可以选择两种 skill 组织方式。两种模式下 CLI 命令完全一样(`dws aitable ...` / `dws calendar ...`),区别只在 Agent 那边读到的 skill 文档结构。
|
||||
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **multi**(默认) | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **mono**(legacy) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
|
||||
> 安装与升级默认均为 multi。mono 仍可通过 `DWS_SKILL_MODE=mono` 或 `dws skill setup --mode mono` 使用。问题请提 issue 反馈。
|
||||
|
||||
怎么选:
|
||||
|
||||
- **快速安装**(上方一行 curl):非交互,默认装 `multi`。
|
||||
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) multi 2) mono` 选项(默认 1)。
|
||||
- **环境变量覆盖**:`DWS_SKILL_MODE=mono curl -fsSL ... | sh`。
|
||||
- **装完之后再切换**:`dws skill setup --mode mono`(或 `--mode multi`),核对列出的路径后交互确认。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>其他安装方式</summary>
|
||||
|
||||
@@ -93,30 +72,6 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
npm install -g dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
安装最新 beta:
|
||||
|
||||
```bash
|
||||
npm install -g dingtalk-workspace-cli@beta
|
||||
```
|
||||
|
||||
**Homebrew**(macOS / Linux):
|
||||
|
||||
```bash
|
||||
brew tap DingTalk-Real-AI/dingtalk-workspace-cli https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
|
||||
brew install dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
> Formula 与代码位于同一个仓库,因此首次 `tap` 需要显式指定仓库 URL。后续可直接使用 `brew upgrade dingtalk-workspace-cli`。
|
||||
|
||||
安装 Homebrew beta(keg-only,不覆盖稳定版):
|
||||
|
||||
```bash
|
||||
brew install dingtalk-workspace-cli-beta
|
||||
$(brew --prefix dingtalk-workspace-cli-beta)/bin/dws version
|
||||
```
|
||||
|
||||
如需让 beta 的 `dws` 成为当前 shell 默认版本,将 `$(brew --prefix dingtalk-workspace-cli-beta)/bin` 放到 PATH 最前面。
|
||||
|
||||
**预编译二进制文件**:从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 下载。
|
||||
|
||||
> **macOS 用户注意**:如果提示“无法打开,因为 Apple 无法检查其是否包含恶意软件”,请执行:
|
||||
@@ -134,40 +89,9 @@ cp dws ~/.local/bin/ # 安装到 PATH
|
||||
```
|
||||
|
||||
> 需要 Go 1.25+。也可以用 `make package` 构建所有平台产物(macOS / Linux / Windows × amd64 / arm64)。
|
||||
> 静态端点数据由悟空基线生成并提交在本仓库 `internal/syncdata`,源码构建不需要额外 checkout 数据仓库。
|
||||
|
||||
</details>
|
||||
|
||||
## 国内加速安装
|
||||
|
||||
国内用户可使用以下通道,避免 GitHub 网络问题。默认(不设置这些环境变量)走 GitHub。
|
||||
|
||||
**1. 安装脚本 + 预编译二进制(Gitee 镜像):**
|
||||
|
||||
仓库镜像地址:`https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli`
|
||||
|
||||
```bash
|
||||
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
> 设置 `DWS_GITEE_REPO` 后,安装脚本会改从 Gitee API 解析最新版本和各个 release 产物(二进制、校验和、skills 包),而不是走 GitHub。不设置时默认从 GitHub 安装。
|
||||
|
||||
**2. npm 包(npmmirror 镜像):**
|
||||
|
||||
```bash
|
||||
npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
|
||||
```
|
||||
|
||||
> npmmirror 会自动同步公网 npm 的公开包,国内可直接使用。
|
||||
|
||||
**3. 单独安装 Skills(Gitee 镜像):**
|
||||
|
||||
```bash
|
||||
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> 同样设置 `DWS_GITEE_REPO`,`install-skills.sh` 会从 Gitee 解析版本和 skills 包;GitHub 不可达时也会自动回退到 Gitee 镜像。
|
||||
|
||||
## 升级
|
||||
|
||||
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
|
||||
@@ -177,46 +101,27 @@ dws 内置自升级能力,直接从 [GitHub Releases](https://github.com/DingT
|
||||
```bash
|
||||
dws upgrade # 交互式升级到最新版本
|
||||
dws upgrade --check # 仅检查是否有新版本
|
||||
dws upgrade --list # 列出正式 release 版本
|
||||
dws upgrade --beta # 升级到最新 beta 预发布版本
|
||||
dws upgrade --check --beta # 仅检查 beta 轨道是否有新版本
|
||||
dws upgrade --list --beta # 列出 beta 预发布版本
|
||||
dws upgrade --list # 列出所有可用版本
|
||||
dws upgrade --version v1.0.7 # 升级到指定版本
|
||||
dws upgrade --version v1.0.8-beta.1 # 升级到指定 beta 版本
|
||||
dws upgrade --rollback # 回滚到上一版本
|
||||
dws upgrade -y # 跳过确认直接升级
|
||||
```
|
||||
|
||||
默认情况下,`dws upgrade` 只跟随正式 release 轨道。只有显式传入 `--beta` 时,才会选择 GitHub pre-release 里的 beta 构建。
|
||||
|
||||
### 六渠道发布后验证
|
||||
|
||||
维护者和验证同学可按发版质量保障 SOP,对 curl、PowerShell、npm stable、npm beta、Homebrew、`dws upgrade` 执行安装与冒烟验证:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git /tmp/dws-verify
|
||||
cd /tmp/dws-verify/verify
|
||||
bash verify-all-channels.sh
|
||||
```
|
||||
|
||||
脚本使用隔离目录,不会替换当前 PATH 中的 `dws`;输出 `PASS`、`FAIL`、`SKIP` 汇总。跨平台渠道必须由对应平台补测,`SKIP` 不计为通过。验证范围和平台矩阵见 [`verify/README.md`](verify/README.md)。
|
||||
|
||||
<details>
|
||||
<summary><strong>工作原理</strong></summary>
|
||||
|
||||
升级过程采用两阶段原子流程,确保一致性:
|
||||
|
||||
1. **准备阶段** — 将平台对应的二进制文件和技能包下载到临时目录,校验 SHA256 校验和,解压并验证所有文件。任何步骤失败则立即中止,不会修改现有安装。
|
||||
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包平铺到已检测到的具体 Agent 目录(例如 `~/.codex/skills/dingtalk-chat`、`~/.claude/skills/dingtalk-chat`)。只有未检测到具体 Agent 时才使用 `~/.agents/skills`;检测到具体 Agent 后会备份迁走旧的 DWS 通用副本,避免同一 Skill 被重复发现。
|
||||
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包安装到所有已检测到的 Agent 目录(`~/.agents/skills/dws`、`~/.claude/skills/dws`、`~/.cursor/skills/dws` 等)。
|
||||
|
||||
每次升级前自动备份当前版本,可通过 `dws upgrade --rollback` 随时回滚。
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--check` | 仅检查更新,不安装 |
|
||||
| `--list` | 列出正式 release 版本及更新日志 |
|
||||
| `--beta` | 对 `upgrade`、`--check`、`--list` 使用 beta 预发布轨道 |
|
||||
| `--version` | 升级到指定版本(如 `v1.0.7` 或 `v1.0.8-beta.1`) |
|
||||
| `--list` | 列出所有可用版本及更新日志 |
|
||||
| `--version` | 升级到指定版本(如 `v1.0.7`) |
|
||||
| `--rollback` | 回滚到上一个备份版本 |
|
||||
| `--force` | 强制重新安装,即使已是最新版本 |
|
||||
| `--skip-skills` | 跳过技能包更新 |
|
||||
@@ -277,72 +182,15 @@ dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>多组织(profile)</strong></summary>
|
||||
|
||||
`dws` 可以同时登录多个钉钉账号,同一组织也能保留多个账号。一个 profile 由 `corpId + userId` 唯一确定。
|
||||
|
||||
```bash
|
||||
dws auth login # 新增或刷新一个账号
|
||||
dws profile list # 列出全部账号,profile 字段是稳定的 corpId:userId
|
||||
dws profile switch <corpId:userId> # 持久切换账号;用 - 切回上一个
|
||||
dws profile switch "组织名:用户名" # 名称输入要求唯一
|
||||
dws --profile <corpId> contact user search --query "..." # 使用该组织明确记录的当前账号
|
||||
dws --profile <corpId:userId> contact user search --query "..." # 单次精确指定账号,不改默认账号
|
||||
```
|
||||
|
||||
支持 `corpId:userId`、`corpId:userName`、`corpName:userId`、`corpName:userName`。名称只用于输入,自动化应使用 `profile list` 返回的稳定 `profile`。组织名或用户名重名时会列出候选并报错;同组织多账号但没有明确当前账号时,只传组织也会报错,不会选择第一项或最近使用账号。
|
||||
|
||||
`currentProfile`、`previousProfile` 和组织默认账号都保存精确身份。`primaryProfile` 只为 JSON 兼容保留,不再参与选择。`profile list` 直接读取各身份 Token 计算状态和到期时间,不触发刷新。`auth logout --profile <corpId>` 退出该组织全部账号;精确选择器或本地 profile 名只退出一个账号。
|
||||
|
||||
跨组织读取由 agent 编排,而非内置 `--all-orgs`:先 `dws profile list`,每个组织使用唯一的 `isOrgCurrent=true` 账号;若多账号组织没有默认账号,先让用户指定账号。写操作默认只在当前账号执行——跨组织写之前先确认目标组织和账号。
|
||||
|
||||
macOS 下,如果已登记的 token slot 无法解密,为避免把系统 Keychain 和 file-DEK 写成混合状态,新的 OAuth 登录会直接拒绝。如果普通终端仍能读取登录态、只有设置 `DWS_DISABLE_KEYCHAIN=1` 的沙箱读不到,可在不暴露 token 的情况下迁移 legacy 与各 profile 的认证条目:
|
||||
|
||||
```bash
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json
|
||||
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
|
||||
```
|
||||
|
||||
迁移会先验证全部认证密文再写入、忽略无关的应用密钥;提交中断后可安全重跑。如果预检确认是密文本身损坏,优先使用 `dws auth logout --profile <corpId:userId>` 只清理受影响账号;只有确认要丢弃全部本地 profile 时才用 `dws auth reset`。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>沙箱间迁移登录态(Linux)</strong></summary>
|
||||
|
||||
仅拷贝 `~/.dws/app.json` 无法带走 refresh token;access token 约 2 小时后会失效。请使用官方导出/导入:
|
||||
|
||||
```bash
|
||||
# A 沙箱(已登录)
|
||||
dws auth export -o /tmp/dws-auth.tar.gz
|
||||
# 或便于分片复制:dws auth export --base64 -o /tmp/dws-auth.b64
|
||||
|
||||
# B 沙箱
|
||||
dws auth import -i /tmp/dws-auth.tar.gz
|
||||
# 或:dws auth import -i /tmp/dws-auth.b64 --base64
|
||||
dws auth status # 确认 Refresh Token: 有效
|
||||
```
|
||||
|
||||
包内包含 `~/.local/share/dws-cli` 加密 keychain(含 `auth-token.enc` 与 `dek`)及 `~/.dws` 必要配置。
|
||||
|
||||
</details>
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
dws contact user search --query "悟空" # 搜索联系人
|
||||
dws calendar event list # 查看今天的日程
|
||||
dws doc search --query "季度" # 搜索钉钉文档
|
||||
dws minutes list mine # 列出我创建的 AI 听记
|
||||
dws drive list # 列出钉盘文件
|
||||
dws contact user search --keyword "悟空" # 搜索联系人
|
||||
dws calendar event list # 查看日历日程
|
||||
dws todo task create --title "季度汇报" --executors "<your-userId>" # 创建待办(请替换为真实 userId)
|
||||
dws todo task list --dry-run # 预览操作但不执行
|
||||
```
|
||||
|
||||
> **完整命令列表**:[`docs/command-index.md`](./docs/command-index.md) — 全部命令,带描述和使用场景。
|
||||
|
||||
## 在 Agent 中使用
|
||||
|
||||
dws 是为 AI Agent 设计的 CLI 工具。请先完成[安装](#安装)和[开始使用](#开始使用),然后配置 Agent 环境:
|
||||
@@ -354,99 +202,49 @@ dws 是为 AI Agent 设计的 CLI 工具。请先完成[安装](#安装)和[开
|
||||
dws todo task create --title "Review PR" --executors "<your-userId>" --yes
|
||||
|
||||
# 使用 --dry-run 预览操作(安全执行)
|
||||
dws contact user search --query "张三" --dry-run
|
||||
dws contact user search --keyword "张三" --dry-run
|
||||
|
||||
# 使用 --jq 精确提取(节省 token)
|
||||
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
|
||||
```
|
||||
|
||||
### 命令帮助与 Schema
|
||||
### Schema 发现
|
||||
|
||||
命令帮助和 Schema 分别负责命令契约的不同部分:
|
||||
|
||||
- `dws <path> --help` 是命令是否存在、当前二进制接受哪些 flags 的事实源。
|
||||
- `dws schema "<path>" --compact` 是 Agent 选命令、CLI 参数与约束、风险和确认语义的规范视图;映射或 provenance 审计使用 full leaf 配合 `--jq` 精确投影。
|
||||
- Help 与 Schema 冲突时视为契约漂移:执行只传 Cobra 接受的参数,安全语义取更保守值。
|
||||
- Schema 只描述命令,不读取或搜索钉钉业务数据;发现命令后仍需执行真实产品命令。
|
||||
Agent 无需预置所有命令知识,通过 `dws schema` 动态发现可用能力:
|
||||
|
||||
```bash
|
||||
# 确认命令存在并查看当前接受的 flags
|
||||
dws aitable record query --help
|
||||
# 第一步:发现所有可用产品
|
||||
dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
|
||||
|
||||
# 先在产品内发现命令,再查看选中 leaf 的契约
|
||||
dws schema aitable --compact
|
||||
dws schema "aitable record query" --compact
|
||||
# 第二步:查看目标工具的参数结构
|
||||
dws schema aitable.query_records --jq '.tool.parameters'
|
||||
|
||||
# 执行真实业务查询
|
||||
# 第三步:构造正确的调用
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应使用 `--compact` 渐进查询;该视图采用正向字段白名单,full 新增的审计字段不会自动进入 Agent 上下文。
|
||||
|
||||
### Agent Skills
|
||||
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
|
||||
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),legacy。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。默认布局。
|
||||
|
||||
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
|
||||
|
||||
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
|
||||
仓库内置完整的 Agent Skill 体系(`skills/`),安装后 Claude Code / Cursor 等 AI 工具可通过自然语言直接操作钉钉:
|
||||
|
||||
```bash
|
||||
# 安装 skills 到当前项目(默认 multi;DWS_SKILL_MODE=mono 可切回)
|
||||
# 安装 skills 到当前项目
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> 安装器优先使用检测到的具体 Agent 根目录(如 `$HOME/.codex/skills/`);仅在未检测到具体 Agent 时回退到 `.agents/skills/`。multi 为按产品平铺,mono 为 `dws/` 子目录。
|
||||
>
|
||||
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
|
||||
|
||||
**用 `dws skill setup` 切换或重装:**
|
||||
|
||||
```bash
|
||||
# 交互式:提示选模式 + 目标 Agent
|
||||
dws skill setup
|
||||
|
||||
# 先预览 mono setup 将备份和替换的精确目录
|
||||
dws skill setup --mode mono --target all --dry-run
|
||||
|
||||
# 交互执行并确认列出的目录
|
||||
dws skill setup --mode mono --target all
|
||||
|
||||
# 先预览,再交互确认装到某一个 Agent home
|
||||
dws skill setup --mode multi --target cursor --dry-run
|
||||
dws skill setup --mode multi --target cursor
|
||||
|
||||
# 指定本地源目录(比如 fork 或正在改的版本),先预览
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
```
|
||||
|
||||
| 参数 | 取值 | 说明 |
|
||||
|------|------|------|
|
||||
| `--mode` | `mono` \| `multi` | skill 布局,不指定则交互式询问 |
|
||||
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `zcode` \| `opencode` \| `qoder` | 安装目标;`all` 表示铺到检测到的具体 Agent home(ZCode 为 `~/.zcode/skills`),仅在未检测到具体 Agent 时回退到 `~/.agents/skills` |
|
||||
| `--source` | 路径 | 本地源目录(覆盖内置 skills) |
|
||||
| `--yes` | — | 仅供脚本使用:跳过确认提示。删除操作仍会先备份到 `~/.dws/skill-backups/` |
|
||||
|
||||
> setup 命令可能移除对面模式残留(装 multi 删 `dws/`,装 mono 清理统一状态中登记或属于状态上线前精确官方名称集合的 multi Skill)以及不在 bundle 内的过期受管 Skill。DWS 在 `~/.dws/skills-state.json`(或 `$DWS_CONFIG_DIR/skills-state.json`)集中记录所有权、安装版本、来源和内容摘要。仅有 `dingtalk-*` 前缀不能触发清理,因此其他同前缀市场/用户 Skill 会保留。所有删除都会先列入确认预览,并备份到 `~/.dws/skill-backups/<时间戳>/`;备份失败的目录会保留原样、绝不删除。非交互环境应先用 `--dry-run` 核对输出,再由调用方显式决定是否使用仅供脚本的确认跳过参数。
|
||||
|
||||
multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权元数据写入 `~/.dws/skills-state.json`(或 `$DWS_CONFIG_DIR/skills-state.json`)。每次 upgrade 都会安装并覆盖该版本的全部预制 Skill;手工删除或通过 setup 排除预制 Skill 不会永久保留,下次 upgrade 会恢复。`dws upgrade --force` 还允许在没有新版本时重装当前 CLI 版本。
|
||||
|
||||
环境变量:`DWS_SKILL_MODE=mono|multi`(`install.sh` / `install.ps1` 也认)、`DWS_SKILL_SOURCE=<路径>`。
|
||||
|
||||
**包含内容(mono 布局):**
|
||||
**包含内容:**
|
||||
|
||||
| 组件 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| 主 Skill | `skills/mono/SKILL.md` | 意图路由、决策树、安全规则、错误处理 |
|
||||
| 产品参考 | `skills/mono/references/products/*.md` | 各产品命令详细参考(aitable、chat、calendar 等) |
|
||||
| 意图指南 | `skills/mono/references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
|
||||
| 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag |
|
||||
| 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 |
|
||||
| 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
|
||||
| 主 Skill | `SKILL.md` | 意图路由、决策树、安全规则、错误处理 |
|
||||
| 产品参考 | `references/products/*.md` | 各产品命令详细参考(aitable、chat、calendar 等) |
|
||||
| 意图指南 | `references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
|
||||
| 全局参考 | `references/global-reference.md` | 认证、输出格式、全局 flag |
|
||||
| 错误码 | `references/error-codes.md` | 错误码 + 调试流程 |
|
||||
| Recovery 指南 | `references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
|
||||
| 现成脚本 | `scripts/*.py` | 13 个批量操作脚本(见下方) |
|
||||
|
||||
<details>
|
||||
<summary><strong>现成脚本</strong> — 13 个 Python 脚本,覆盖常见多步工作流</summary>
|
||||
@@ -473,135 +271,6 @@ multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权
|
||||
|
||||
## 功能特性
|
||||
|
||||
<details>
|
||||
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
|
||||
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及七个 OA 审批任务/实例事件。
|
||||
|
||||
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
|
||||
|
||||
> **前置条件**:先运行 `dws auth login`。个人身份从 OAuth token 解析,不允许通过命令行伪造。
|
||||
|
||||
只需要 event 能力时,可以使用官方便捷安装脚本:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
|
||||
|
||||
# 或在已有 dws 环境中安装独立的 multi skill
|
||||
dws skill setup --mode multi -s event
|
||||
```
|
||||
|
||||
```bash
|
||||
# 查看公开个人事件目录和 schema
|
||||
dws event list
|
||||
dws event schema user_im_message_receive_o2o --flatten
|
||||
dws event list --category oa
|
||||
dws event schema user_oa_approval_task_created --flatten
|
||||
|
||||
# 监听当前用户被 @ 的消息
|
||||
dws event +listen-im --kind at-me -f ndjson
|
||||
|
||||
# 监听指定发送人的消息
|
||||
dws event +listen-im --kind sender --user <userId> -f ndjson
|
||||
|
||||
# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份
|
||||
dws event +listen-im --kind sender --open-dingtalk-id <openDingtalkId> -f ndjson
|
||||
|
||||
# 监听指定群的消息
|
||||
dws event +listen-im --kind group --chat-id <openConversationId> -f ndjson
|
||||
|
||||
# 监听所有单聊或所有群消息
|
||||
dws event +listen-im --kind all-direct -f ndjson
|
||||
dws event +listen-im --kind all-group -f ndjson
|
||||
|
||||
# 监听指定群标题变更、成员进退群或群解散
|
||||
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_group_member_added --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
|
||||
|
||||
# 一个进程监听同一发送人的消息、已读和撤回
|
||||
dws event +listen-im --kind sender --user <userId> \
|
||||
--events message,read,recall -f ndjson
|
||||
|
||||
# 一个进程监听全部七个公开 OA 审批事件
|
||||
dws event consume \
|
||||
user_oa_approval_task_created \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_cc \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten -f ndjson
|
||||
|
||||
# 查看本地 consume,并取消指定订阅
|
||||
dws event status
|
||||
dws event stop <subscribe_id>
|
||||
```
|
||||
|
||||
单聊和指定发送人事件必须且只能选择一种目标身份:企业内部 `userId` 使用 `--user`,`openDingtalkId` 使用 `--open-dingtalk-id`。CLI 不会自动猜测或转换身份类型。
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 自动编排 | `consume` 创建或复用个人订阅,`stop` 取消订阅并清理本地状态 |
|
||||
| 共享连接 | 同一用户的多个 consumer 共享本地 bus 和云端长连接 |
|
||||
| 多事件进程 | 同一目标的兼容事件可由一个 consume 进程监听,每个事件仍有独立订阅 |
|
||||
| 订阅隔离 | 正常 consumer 同时按事件类型和 `subscribe_id` 匹配 |
|
||||
| Agent 友好输出 | Stream 事件写入 stdout,连接状态和诊断信息写入 stderr |
|
||||
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
|
||||
| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe |
|
||||
|
||||
Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Raw API 调用</strong> — 直接调用钉钉 OpenAPI</summary>
|
||||
|
||||
`dws api` 让你直接调用任意钉钉 OpenAPI,无需 SDK,Token 自动获取和刷新。
|
||||
|
||||
> **前置条件**:必须使用自有应用凭证登录(见[自建应用模式](#开始使用))。通过 MCP 默认凭证登录 不支持 raw API 调用。
|
||||
|
||||
```bash
|
||||
# 登录(仅首次)
|
||||
dws auth login --client-id <APP_KEY> --client-secret <APP_SECRET>
|
||||
|
||||
# === api.dingtalk.com ===
|
||||
|
||||
# 获取企业所有应用列表
|
||||
dws api GET /v1.0/microApp/allApps
|
||||
|
||||
# 搜索用户 (POST + JSON body)
|
||||
dws api POST /v1.0/contact/users/search \
|
||||
--data '{"queryWord":"张三","offset":0,"size":10}'
|
||||
|
||||
# === oapi.dingtalk.com ===
|
||||
|
||||
# 获取用户详情(使用 --base-url 指定域名)
|
||||
dws api POST /topapi/v2/user/get \
|
||||
--base-url https://oapi.dingtalk.com \
|
||||
--data '{"userid":"<USER_ID>"}'
|
||||
|
||||
# 也可以直接使用完整 URL
|
||||
dws api POST https://oapi.dingtalk.com/topapi/v2/user/get \
|
||||
--data '{"userid":"<USER_ID>"}'
|
||||
|
||||
# === 通用功能 ===
|
||||
dws api GET /v1.0/microApp/allApps --page-all # 自动翻页
|
||||
dws api GET /v1.0/microApp/allApps --dry-run # 预览请求
|
||||
dws api GET /v1.0/microApp/allApps --jq '.agentId' # jq 过滤
|
||||
```
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| 双形态自动识别 | 根据 URL 自动选择 api.dingtalk.com(Header 认证)或 oapi.dingtalk.com(Query 参数认证) |
|
||||
| Token 自动管理 | 首次调用自动获取应用级 accessToken,有效期内缓存,过期自动刷新 |
|
||||
| 域名白名单 | 仅允许 `api.dingtalk.com` 和 `oapi.dingtalk.com`,防止 Token 泄露 |
|
||||
| 自动分页 | `--page-all` 自动遍历所有分页。`--page-limit` 控制翻页上限(默认 10,设为 0 不限制,硬上限 500 防止死循环) |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>智能输入纠错</strong> — 自动修正 AI 模型常见的参数错误</summary>
|
||||
|
||||
@@ -612,7 +281,7 @@ dws api GET /v1.0/microApp/allApps --jq '.agentId' # jq 过滤
|
||||
dws aitable record query --baseId BASE_ID --tableId TABLE_ID # 自动纠正为 --base-id --table-id
|
||||
|
||||
# 粘连参数自动拆分
|
||||
dws contact user search --query "张三" --timeout30 # 自动拆分为 --timeout 30
|
||||
dws contact user search --keyword "张三" --timeout30 # 自动拆分为 --timeout 30
|
||||
|
||||
# 拼写错误模糊匹配
|
||||
dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-id → --table-id
|
||||
@@ -637,7 +306,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
|
||||
```bash
|
||||
# 内置 jq 表达式
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
|
||||
dws schema "dev app create" --jq '.parameters'
|
||||
dws schema --jq '.products[] | {id, tools: (.tools | length)}'
|
||||
|
||||
# 只返回指定字段
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
|
||||
@@ -646,13 +315,13 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Schema 自省</strong> — Agent 命令发现与执行契约</summary>
|
||||
<summary><strong>Schema 自省</strong> — 调用前查询任意工具的参数结构</summary>
|
||||
|
||||
```bash
|
||||
dws schema aitable --compact # 发现产品命令
|
||||
dws schema "aitable record query" --compact # 查看 Agent leaf 契约
|
||||
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # 定向查看必填字段
|
||||
dws schema --all # CI/审计/基线的全量导出
|
||||
dws schema # 列出所有产品和工具
|
||||
dws schema aitable.query_records # 查看参数 Schema
|
||||
dws schema aitable.query_records --jq '.tool.required' # 查看必填字段
|
||||
dws schema --jq '.products[].id' # 提取所有产品 ID
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -674,60 +343,31 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "周报" --text @-
|
||||
```
|
||||
|
||||
> **说明**:`@` 仅在其后是 ASCII 路径前缀字符(`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`)或 `@-`(stdin)时,才会被识别为 `@<path>` 文件注入语法。`--text "@所有人 周报"` / `--text "@张三 看一下"` 这类机器人消息中的字面 `@` 提及会原样透传到 API。
|
||||
|
||||
</details>
|
||||
|
||||
## 钉钉机器人 —— 把机器人接到你本地的 AI
|
||||
|
||||
`dws dev connect` 把一个钉钉机器人接到本地 AI CLI(Claude Code / Codex / opencode / Qoder / Gemini,或用 `--agent-cmd` 接任意工具):群里 @ 机器人提问,它用你本地的 agent 回答,按会话保留多轮上下文。
|
||||
|
||||
```bash
|
||||
dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <secret>
|
||||
```
|
||||
|
||||
聊天里的**会话指令**(整条消息就是指令时生效,不消耗一次 AI 调用):
|
||||
|
||||
| 指令 | 作用 |
|
||||
|------|------|
|
||||
| `/new`(别名 `/start`、`/reset`) | 开启新会话;旧会话保留(agent 支持的话仍可回溯) |
|
||||
| `/clear` | 清空当前会话 —— 调 agent 真实会话原语真删(opencode 走 `DELETE /session/:id`);驱动接口没有删除原语的渠道退化为重置 |
|
||||
|
||||
完整四步教程见 [`docs/robot-quickstart.md`](./docs/robot-quickstart.md)(装工具 → 建机器人 → 接上 AI → 拉进群)。
|
||||
|
||||
## 核心服务
|
||||
|
||||
| 服务 | 命令 | 能力 |
|
||||
|------|------|------|
|
||||
| 通讯录 | `contact` | 按姓名 / 手机号 / 工号查人,部门、角色标签、花名册与离职;创建企业、企业账号及邀请员工 |
|
||||
| 群聊 | `chat`(`im`)| 发送 / 回复 / 搜索消息,群与成员管理,机器人与 Webhook 发消息,表情反应,撤回 |
|
||||
| 日历 | `calendar` | 日程 CRUD、参与者、会议室、闲忙与时间建议 |
|
||||
| 待办 | `todo` | 创建 / 列表 / 修改 / 完成待办及评论 |
|
||||
| 审批 | `oa` | 同意 / 拒绝 / 撤销 / 转交,查待办 / 已发起 / 抄送及表单 |
|
||||
| 考勤 | `attendance` | 打卡记录、排班、考勤摘要、考勤组规则(只读) |
|
||||
| DING | `ding` | 发送 / 撤回 DING 消息 |
|
||||
| 日志 | `report` | 创建 / 提交日志,收发件箱,模版,统计 |
|
||||
| AI 表格 | `aitable` | Base / 数据表 / 记录 / 字段 / 视图,权限与角色,自动化,图表与仪表盘,导入导出 |
|
||||
| 文档 | `doc` | 搜索 / 读写文档,块级编辑,评论,权限,媒体,上传 / 下载 |
|
||||
| 钉盘 | `drive` | 列表 / 搜索 / 下载,文件夹,上传,复制 / 移动 / 重命名,权限 |
|
||||
| AI 听记 | `minutes` | 听记列表、摘要 / 关键词 / 转写 / 待办、思维导图、发言人、标签 |
|
||||
| 邮箱 | `mail` | 邮箱、KQL 搜索、读 / 发、草稿、文件夹、模版、联系人 |
|
||||
| 在线电子表格 | `sheet` | 在线表格:工作表与区域读写、筛选、条件格式、图片、CSV |
|
||||
| 知识库 | `wiki` | 知识库:空间、成员、节点树、文档与文件 |
|
||||
| 开发者文档 | `devdoc` | 搜索开放平台文档并排查 API 错误 |
|
||||
| AI 搜问 | `aisearch` | 企业人员搜索:按姓名 / 部门 / 角色 / 职责 / 上下级 / 手机号 / 工号 |
|
||||
| 直播 | `live` | 查看我的直播列表 |
|
||||
| Raw API | `api` | 直接调用任意钉钉 OpenAPI,自动管理应用级 Token |
|
||||
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|
||||
|------|------|:------:|--------|------|
|
||||
| 通讯录 | `contact` | 6 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
|
||||
| 群聊 | `chat` | 10 | `message` `group` `search` | 群增删改查、成员管理、机器人消息、Webhook |
|
||||
| 机器人 | `chat bot` | 6 | `bot` `group` `message` `search` | 机器人创建/搜索、群聊/单聊消息、Webhook、消息撤回 |
|
||||
| 日历 | `calendar` | 13 | `event` `room` `participant` `busy` | 日程增删改查、会议室预订、闲忙查询、参与者管理 |
|
||||
| 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 |
|
||||
| 审批 | `oa` | 9 | `approval` | 同意/拒绝/撤销、待我审批、我发起的、流程列表 |
|
||||
| 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 |
|
||||
| DING | `ding` | 2 | `message` | 发送/撤回 DING 消息 |
|
||||
| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、统计 |
|
||||
| 智能表格 | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | 多维表/数据表/记录/字段全量 CRUD、模板 |
|
||||
| 工作台 | `workbench` | 2 | `app` | 批量查询应用详情 |
|
||||
| 开发者文档 | `devdoc` | 1 | `article` | 搜索开放平台文档与错误码 |
|
||||
|
||||
> 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看任一服务的子命令。
|
||||
|
||||
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
|
||||
> 12 个产品,86 个命令。运行 `dws --help` 查看完整列表,或 `dws <service> --help` 查看子命令。
|
||||
|
||||
<details>
|
||||
<summary>即将推出</summary>
|
||||
|
||||
- `conference`(视频会议)
|
||||
- 多 skill 模式(默认)— 每产品一个独立 skill,位于 `skills/multi/`,安装与升级默认启用;`dws skill setup --mode mono` 交互确认后可切回单 skill
|
||||
`doc`(文档)· `mail`(邮箱)· `minutes`(AI 听记)· `drive`(钉盘)· `conference`(视频会议)· `tb`(Teambition)· `aiapp`(AI 应用)· `live`(直播)· `skill`(技能市场)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -778,11 +418,8 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
|
||||
|
||||
## 参考与文档
|
||||
|
||||
- [国际版(`.io`)使用手册](./docs/international-region-guide.zh-CN.md) — 国际版登录、国内/国际 profile 切换、隔离验证与排障
|
||||
- [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景
|
||||
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
|
||||
- [架构设计](./docs/architecture.md) — 静态端点管道、命令面、Transport 层
|
||||
- [开放平台应用指令设计](./docs/dev-yulan-command-routing.md) — yulan dev app 应用侧命令、MCP overlay、权限流程与 Agent 路由
|
||||
- [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层
|
||||
- [更新日志](./CHANGELOG.md) — 版本历史与迁移说明
|
||||
|
||||
## 贡献指南
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
class __CLASS_NAME__ < Formula
|
||||
desc "__DESCRIPTION__"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "__VERSION__"
|
||||
license "Apache-2.0"
|
||||
__KEG_ONLY_LINE__
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "__DARWIN_ARM64_URL__"
|
||||
sha256 "__DARWIN_ARM64_SHA256__"
|
||||
else
|
||||
url "__DARWIN_AMD64_URL__"
|
||||
sha256 "__DARWIN_AMD64_SHA256__"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "__LINUX_ARM64_URL__"
|
||||
sha256 "__LINUX_ARM64_SHA256__"
|
||||
else
|
||||
url "__LINUX_AMD64_URL__"
|
||||
sha256 "__LINUX_AMD64_SHA256__"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "__SKILLS_URL__"
|
||||
sha256 "__SKILLS_SHA256__"
|
||||
end
|
||||
|
||||
def install
|
||||
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
|
||||
binary = File.join(root, "dws")
|
||||
raise "binary not found: #{binary}" unless File.exist?(binary)
|
||||
|
||||
bin.install binary => "dws"
|
||||
|
||||
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
|
||||
source = File.join(root, name)
|
||||
pkgshare.install source if File.exist?(source)
|
||||
end
|
||||
|
||||
skill_dest = pkgshare/"skills/dws"
|
||||
skill_dest.mkpath
|
||||
resource("skills").stage do
|
||||
cp_r(Dir["*"], skill_dest)
|
||||
end
|
||||
end
|
||||
|
||||
def caveats
|
||||
<<~EOS
|
||||
Agent Skills are bundled in #{pkgshare}/skills/dws.
|
||||
Run `dws skill setup` to install them into your Agent directories.
|
||||
__CHANNEL_CAVEAT__
|
||||
EOS
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/dws version")
|
||||
end
|
||||
end
|
||||
+35
-7
@@ -1,5 +1,5 @@
|
||||
class __CLASS_NAME__ < Formula
|
||||
desc "Install locally built DingTalk workspace CLI artifacts for verification"
|
||||
desc "DingTalk Workspace CLI"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
url "__ARCHIVE_URL__"
|
||||
sha256 "__ARCHIVE_SHA256__"
|
||||
@@ -12,6 +12,8 @@ __KEG_ONLY_LINE__
|
||||
end
|
||||
|
||||
def install
|
||||
require "fileutils"
|
||||
|
||||
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
|
||||
binary = File.join(root, "dws")
|
||||
raise "binary not found: #{binary}" unless File.exist?(binary)
|
||||
@@ -26,15 +28,41 @@ __KEG_ONLY_LINE__
|
||||
skill_dest = pkgshare/"skills/dws"
|
||||
skill_dest.mkpath
|
||||
resource("skills").stage do
|
||||
cp_r(Dir["*"], skill_dest)
|
||||
FileUtils.cp_r(Dir["*"], skill_dest)
|
||||
end
|
||||
end
|
||||
|
||||
def caveats
|
||||
<<~EOS
|
||||
Agent Skills are bundled in #{pkgshare}/skills/dws.
|
||||
Run `dws skill setup` to install them into your Agent directories.
|
||||
EOS
|
||||
def post_install
|
||||
require "fileutils"
|
||||
|
||||
skill_root = pkgshare/"skills/dws"
|
||||
entries = Dir["#{skill_root}/*"]
|
||||
return if entries.empty?
|
||||
|
||||
targets = [
|
||||
Pathname.new(File.join(Dir.home, ".agents/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".claude/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".cursor/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".gemini/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".codex/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".github/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".windsurf/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".augment/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".cline/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".amp/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".kiro/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".trae/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".openclaw/skills/dws")),
|
||||
]
|
||||
|
||||
targets.each_with_index do |dest, index|
|
||||
parent_gate = dest.parent.parent
|
||||
next if index > 0 && !parent_gate.directory?
|
||||
|
||||
FileUtils.rm_rf(dest)
|
||||
FileUtils.mkdir_p(dest)
|
||||
FileUtils.cp_r(entries, dest)
|
||||
end
|
||||
end
|
||||
|
||||
test do
|
||||
|
||||
+5
-63
@@ -13,71 +13,13 @@ if (!fs.existsSync(binaryPath)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Interactive commands must remain in the terminal's foreground session so
|
||||
// prompts can use /dev/tty. Non-interactive launches use a separate process
|
||||
// group, allowing a signal sent only to this wrapper to reach the full vendor
|
||||
// process tree exactly once.
|
||||
const isolateVendorProcessGroup = process.platform !== "win32" && !process.stdin.isTTY;
|
||||
|
||||
const child = childProcess.spawn(binaryPath, process.argv.slice(2), {
|
||||
const result = childProcess.spawnSync(binaryPath, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
detached: isolateVendorProcessGroup,
|
||||
});
|
||||
|
||||
let spawnFailed = false;
|
||||
let forwardedSignal = null;
|
||||
const forwardedSignals = ["SIGINT", "SIGTERM"];
|
||||
|
||||
function forwardSignal(signal) {
|
||||
forwardedSignal = signal;
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
if (process.platform === "win32") {
|
||||
child.kill(signal);
|
||||
return;
|
||||
}
|
||||
if (!isolateVendorProcessGroup) {
|
||||
// Ctrl-C is generated for the whole foreground process group, including
|
||||
// the vendor. SIGTERM is not terminal-generated and still needs an
|
||||
// explicit handoff when a process manager targets only this wrapper.
|
||||
if (signal === "SIGTERM") {
|
||||
child.kill(signal);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// detached makes the vendor PID the leader of its POSIX process group.
|
||||
// Signal the whole group so any subprocesses inherit the same shutdown.
|
||||
process.kill(-child.pid, signal);
|
||||
} catch (error) {
|
||||
// The group may have completed between the state check and kill.
|
||||
if (error.code !== "ESRCH") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.error) {
|
||||
console.error(result.error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const signalHandlers = new Map(
|
||||
forwardedSignals.map((signal) => [signal, () => forwardSignal(signal)]),
|
||||
);
|
||||
for (const signal of forwardedSignals) {
|
||||
process.on(signal, signalHandlers.get(signal));
|
||||
}
|
||||
|
||||
child.on("error", (error) => {
|
||||
spawnFailed = true;
|
||||
console.error(error.message);
|
||||
});
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
for (const forwarded of forwardedSignals) {
|
||||
process.removeListener(forwarded, signalHandlers.get(forwarded));
|
||||
}
|
||||
|
||||
const exitSignal = forwardedSignal || signal;
|
||||
if (exitSignal && process.platform !== "win32") {
|
||||
process.kill(process.pid, exitSignal);
|
||||
return;
|
||||
}
|
||||
process.exitCode = spawnFailed || code === null ? 1 : code;
|
||||
});
|
||||
process.exit(result.status === null ? 1 : result.status);
|
||||
|
||||
+29
-1773
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,6 @@
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.7.0"
|
||||
"node": ">=16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,413 +0,0 @@
|
||||
// Command fetch_mcp_metadata pulls tools/list from ALL live MCP server endpoints
|
||||
// and writes a local diagnostic dump. It is NOT a Schema delivery refresh:
|
||||
// schema_mcp_metadata.json is retired; production Catalog assembles from
|
||||
// Contract/ParamDecl/Interface + Cobra only.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// dws auth login # ensure valid auth
|
||||
// make fetch-mcp-metadata # writes artifacts/mcp_metadata_diagnostic.json
|
||||
//
|
||||
// The tool loads auth from the DWS keychain, iterates static server endpoints
|
||||
// (internal/syncdata.StaticServers), calls tools/list on each, merges results,
|
||||
// and writes the requested -output path (refuses the retired pin path).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
// toolLister is the tools/list capability consumed by run; production code
|
||||
// uses transport.Client, tests inject fakes.
|
||||
type toolLister interface {
|
||||
ListTools(ctx context.Context, endpoint string) (transport.ToolsListResult, error)
|
||||
}
|
||||
|
||||
// Injection points so run() is fully testable without network/keychain/exit.
|
||||
var (
|
||||
osExit = os.Exit
|
||||
getenv = os.Getenv
|
||||
loadTokenData = auth.LoadTokenDataKeychain
|
||||
staticServers = syncdata.StaticServers
|
||||
registrySource = collectedIdentityInterfaceRefs
|
||||
collectIdentitySpecs = cli.CollectIdentitySpecs
|
||||
listToolsTimeout = 30 * time.Second
|
||||
gitHeadPath = ".git/HEAD"
|
||||
newToolLister = func(token string) toolLister {
|
||||
return transport.NewClient(&http.Client{Timeout: 60 * time.Second}).WithAuth(token, nil)
|
||||
}
|
||||
)
|
||||
|
||||
func main() {
|
||||
osExit(run(os.Args[1:], os.Stderr))
|
||||
}
|
||||
|
||||
func run(args []string, stderr io.Writer) int {
|
||||
flags := flag.NewFlagSet("fetch_mcp_metadata", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
output := flags.String("output", "artifacts/mcp_metadata_diagnostic.json", "diagnostic dump path (not a Schema pin)")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
if retiredPinnedMCPMetadataPath(*output) {
|
||||
fmt.Fprintln(stderr, "fetch_mcp_metadata: refusing to write retired Schema pin internal/cli/schema_mcp_metadata.json")
|
||||
return 2
|
||||
}
|
||||
|
||||
token := resolveToken(stderr)
|
||||
if token == "" {
|
||||
fmt.Fprintln(stderr, "fetch_mcp_metadata: no auth token. Run 'dws auth login' first.")
|
||||
return 1
|
||||
}
|
||||
|
||||
client := newToolLister(token)
|
||||
|
||||
// Iterate ALL static server endpoints (26 servers covering all products).
|
||||
servers := staticServers()
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: querying %d server endpoints\n", len(servers))
|
||||
|
||||
// Collect command identity to build tool_name → interface_ref mapping.
|
||||
registryMap := loadRegistryInterfaceRefs(stderr)
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: registry mapping: %d entries\n", len(registryMap))
|
||||
|
||||
// Load a previous diagnostic dump (if any) to preserve hand-curated
|
||||
// cross-server interface_ref mappings that automated matching can't derive.
|
||||
prevData, prevErr := os.ReadFile(*output)
|
||||
prevTools := map[string]map[string]any{}
|
||||
if prevErr == nil {
|
||||
var prev struct {
|
||||
Tools map[string]map[string]any `json:"tools"`
|
||||
}
|
||||
if json.Unmarshal(prevData, &prev) == nil {
|
||||
prevTools = prev.Tools
|
||||
}
|
||||
}
|
||||
|
||||
// Start from previous data (preserves cross-server refs), then overwrite
|
||||
// with fresh MCP data where available.
|
||||
allTools := make(map[string]map[string]any)
|
||||
for k, v := range prevTools {
|
||||
allTools[k] = v
|
||||
}
|
||||
|
||||
// Reviewed cross-server interface_refs live only in the previous snapshot
|
||||
// (the registry stores canonical paths, not MCP identities). Build a
|
||||
// live-key → canonicals index so those tools get refreshed instead of
|
||||
// being skipped and frozen at the previous snapshot forever.
|
||||
crossRefs := buildCrossServerRefs(prevTools, registryMap)
|
||||
if len(crossRefs) > 0 {
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: cross-server ref index: %d live keys\n", len(crossRefs))
|
||||
}
|
||||
// Canonicals with a reviewed cross-server identity must only be fed by
|
||||
// that identity; a same-named tool on another server is a coincidence,
|
||||
// not a data source.
|
||||
crossOwned := map[string]bool{}
|
||||
for _, canonicals := range crossRefs {
|
||||
for _, canonical := range canonicals {
|
||||
crossOwned[canonical] = true
|
||||
}
|
||||
}
|
||||
totalRaw := 0
|
||||
failedServices := []string{}
|
||||
|
||||
for _, srv := range servers {
|
||||
endpoint := strings.TrimSpace(srv.Endpoint)
|
||||
if endpoint == "" {
|
||||
continue
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), listToolsTimeout)
|
||||
result, err := client.ListTools(ctx, endpoint)
|
||||
cancel()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, " [skip] %s: %v\n", srv.ID, err)
|
||||
failedServices = append(failedServices, srv.ID)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(stderr, " [ok] %s: %d tools\n", srv.ID, len(result.Tools))
|
||||
totalRaw += len(result.Tools)
|
||||
for _, tool := range result.Tools {
|
||||
name := strings.TrimSpace(tool.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
// Direct match: CLI canonical equals server-prefixed tool name
|
||||
// (e.g., "doc.copy_document"). Cross-owned canonicals are skipped
|
||||
// here — their reviewed identity feeds them below.
|
||||
canonicalKey := srv.ID + "." + name
|
||||
if ref, hasRef := registryMap[canonicalKey]; hasRef && !crossOwned[canonicalKey] {
|
||||
mergeLiveMCPTool(allTools, canonicalKey, tool, ref)
|
||||
}
|
||||
// Cross-server match: registry canonicals whose reviewed
|
||||
// interface_ref points at this live tool (one live tool may feed
|
||||
// several canonicals, e.g. advperm_enable/disable → set_advanced_permission).
|
||||
for _, canonical := range crossRefs[canonicalKey] {
|
||||
mergeLiveMCPTool(allTools, canonical, tool, registryMap[canonical])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matched := 0
|
||||
for _, t := range allTools {
|
||||
if _, ok := t["interface_ref"]; ok {
|
||||
matched++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: MCP matched=%d, with interface_ref=%d\n", len(allTools), matched)
|
||||
|
||||
// Fill gaps: for registry canonicals not covered by MCP tools/list OR
|
||||
// previous data, add stub entries (interface_ref only).
|
||||
stubs := 0
|
||||
for canonicalKey, ref := range registryMap {
|
||||
if _, exists := allTools[canonicalKey]; exists {
|
||||
continue
|
||||
}
|
||||
allTools[canonicalKey] = map[string]any{
|
||||
"interface_ref": ref,
|
||||
}
|
||||
stubs++
|
||||
}
|
||||
if stubs > 0 {
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: added %d registry stubs (no MCP data, interface_ref only)\n", stubs)
|
||||
}
|
||||
|
||||
// Compute coverage fields required by check-schema-catalog.sh. Failed
|
||||
// services must be reported honestly so policy can spot snapshot gaps.
|
||||
if len(failedServices) > 0 {
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: %d/%d services unreachable: %s\n",
|
||||
len(failedServices), len(servers), strings.Join(failedServices, ", "))
|
||||
}
|
||||
|
||||
metadata := map[string]any{
|
||||
"version": 1,
|
||||
"source": "mcp-tools-list+cli-registry",
|
||||
"coverage": buildCoverage(len(servers), failedServices, totalRaw, len(allTools), stubs),
|
||||
"tools": allTools,
|
||||
}
|
||||
|
||||
// source_revision: git commit hash (proves provenance).
|
||||
if rev, err := os.ReadFile(gitHeadPath); err == nil {
|
||||
metadata["source_revision"] = strings.TrimSpace(string(rev))
|
||||
}
|
||||
|
||||
if err := writeMetadata(*output, metadata); err != nil {
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: wrote %d tools to %s\n", len(allTools), *output)
|
||||
return 0
|
||||
}
|
||||
|
||||
// resolveToken returns the access token from DWS_ACCESS_TOKEN or, as a
|
||||
// fallback, the DWS keychain.
|
||||
func resolveToken(stderr io.Writer) string {
|
||||
token := strings.TrimSpace(getenv("DWS_ACCESS_TOKEN"))
|
||||
if token != "" {
|
||||
return token
|
||||
}
|
||||
td, err := loadTokenData()
|
||||
if err != nil || td == nil || td.AccessToken == "" {
|
||||
return ""
|
||||
}
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: loaded token from keychain (%d chars)\n", len(td.AccessToken))
|
||||
return td.AccessToken
|
||||
}
|
||||
|
||||
// writeMetadata marshals the snapshot and writes it to the output path.
|
||||
func writeMetadata(path string, metadata map[string]any) error {
|
||||
data, err := json.MarshalIndent(metadata, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal failed: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return fmt.Errorf("write %s failed: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildCoverage reports snapshot coverage honestly: snapshot_services only
|
||||
// counts services whose tools/list succeeded, missing_services names the
|
||||
// failures, and matched_tools excludes registry stubs (entries carrying no
|
||||
// live MCP metadata) so a stub-heavy snapshot cannot claim full matching.
|
||||
func buildCoverage(sourceServices int, failedServices []string, sourceTools, surfaceTools, stubs int) map[string]any {
|
||||
missing := failedServices
|
||||
if missing == nil {
|
||||
missing = []string{}
|
||||
}
|
||||
return map[string]any{
|
||||
"surface_scope": "source_revision",
|
||||
"source_services": sourceServices,
|
||||
"snapshot_services": sourceServices - len(missing),
|
||||
"missing_services": missing,
|
||||
"source_tools": sourceTools,
|
||||
"surface_tools": surfaceTools,
|
||||
"matched_tools": surfaceTools - stubs,
|
||||
"aliased_tools": 0,
|
||||
"unmatched_tools": stubs,
|
||||
}
|
||||
}
|
||||
|
||||
// mergeLiveMCPTool replaces stale live-derived fields while retaining an
|
||||
// existing reviewed interface_ref. Some CLI canonicals intentionally route to
|
||||
// a differently named product/RPC, so the previous cross-server mapping must
|
||||
// survive even though title, description, and parameters are refreshed.
|
||||
func mergeLiveMCPTool(allTools map[string]map[string]any, canonicalKey string, tool transport.ToolDescriptor, fallbackRef map[string]string) {
|
||||
interfaceRef := any(fallbackRef)
|
||||
if previous := allTools[canonicalKey]; previous != nil {
|
||||
if reviewedRef, ok := previous["interface_ref"]; ok && reviewedRef != nil {
|
||||
interfaceRef = reviewedRef
|
||||
}
|
||||
}
|
||||
|
||||
entry := map[string]any{
|
||||
"title": tool.Title,
|
||||
"description": tool.Description,
|
||||
"interface_ref": interfaceRef,
|
||||
}
|
||||
if tool.InputSchema != nil {
|
||||
entry["parameters"] = extractParams(tool.InputSchema)
|
||||
}
|
||||
allTools[canonicalKey] = entry
|
||||
}
|
||||
|
||||
// buildCrossServerRefs indexes reviewed cross-server mappings from the
|
||||
// previous snapshot: for every registry canonical whose interface_ref names a
|
||||
// different MCP identity (product_id.rpc_name != canonical), the live key is
|
||||
// mapped back to that canonical. One live tool may serve several canonicals,
|
||||
// so values are slices, sorted for deterministic merge order.
|
||||
func buildCrossServerRefs(prevTools map[string]map[string]any, registryMap map[string]map[string]string) map[string][]string {
|
||||
index := map[string][]string{}
|
||||
for canonical, entry := range prevTools {
|
||||
if _, inRegistry := registryMap[canonical]; !inRegistry {
|
||||
continue
|
||||
}
|
||||
ref, ok := entry["interface_ref"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
productID, _ := ref["product_id"].(string)
|
||||
rpcName, _ := ref["rpc_name"].(string)
|
||||
if productID == "" || rpcName == "" {
|
||||
continue
|
||||
}
|
||||
liveKey := productID + "." + rpcName
|
||||
if liveKey == canonical {
|
||||
continue
|
||||
}
|
||||
index[liveKey] = append(index[liveKey], canonical)
|
||||
}
|
||||
for _, canonicals := range index {
|
||||
sort.Strings(canonicals)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// collectedIdentityInterfaceRefs collects command identity from the live
|
||||
// command tree — the replacement for the retired reviewed CommandRegistry —
|
||||
// and derives the canonical_path → {product_id, rpc_name} mapping used for
|
||||
// interface_ref injection.
|
||||
func collectedIdentityInterfaceRefs() (map[string]map[string]string, error) {
|
||||
root := app.NewSchemaSourceRootCommand()
|
||||
specs, _, err := collectIdentitySpecs(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("collect command identity: %w", err)
|
||||
}
|
||||
out := make(map[string]map[string]string, len(specs))
|
||||
for _, spec := range specs {
|
||||
cp := strings.TrimSpace(spec.CanonicalPath)
|
||||
if cp == "" || !strings.Contains(cp, ".") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(cp, ".", 2)
|
||||
out[cp] = map[string]string{
|
||||
"product_id": parts[0],
|
||||
"rpc_name": parts[1],
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadRegistryInterfaceRefs builds the canonical_path → interface_ref mapping
|
||||
// from the collected command identity. 与旧实现同等告警:静默返回空映射会让
|
||||
// 所有 live tool 被丢弃、产出 stub-only 快照且零提示(P1#1 的故障模式)。
|
||||
func loadRegistryInterfaceRefs(stderr io.Writer) map[string]map[string]string {
|
||||
refs, err := registrySource()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: warning: cannot collect command identity: %v\n", err)
|
||||
return map[string]map[string]string{}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func retiredPinnedMCPMetadataPath(path string) bool {
|
||||
cleaned := strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")
|
||||
return cleaned == "internal/cli/schema_mcp_metadata.json" ||
|
||||
strings.HasSuffix(cleaned, "/internal/cli/schema_mcp_metadata.json")
|
||||
}
|
||||
|
||||
// extractParams converts a JSON Schema inputSchema (from MCP tools/list) into
|
||||
// the flat param-name → metadata map used by diagnostic dumps.
|
||||
func extractParams(inputSchema map[string]any) map[string]map[string]any {
|
||||
if inputSchema == nil {
|
||||
return nil
|
||||
}
|
||||
properties, ok := inputSchema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
requiredSet := map[string]bool{}
|
||||
if req, ok := inputSchema["required"].([]any); ok {
|
||||
for _, r := range req {
|
||||
if s, ok := r.(string); ok {
|
||||
requiredSet[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
params := make(map[string]map[string]any, len(properties))
|
||||
for name, raw := range properties {
|
||||
prop, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
meta := map[string]any{}
|
||||
if t, ok := prop["type"].(string); ok {
|
||||
meta["type"] = t
|
||||
}
|
||||
if d, ok := prop["description"].(string); ok {
|
||||
meta["description"] = d
|
||||
}
|
||||
if d, ok := prop["default"].(string); ok {
|
||||
meta["default"] = d
|
||||
}
|
||||
if e, ok := prop["enum"].([]any); ok {
|
||||
enums := make([]string, 0, len(e))
|
||||
for _, v := range e {
|
||||
if s, ok := v.(string); ok {
|
||||
enums = append(enums, s)
|
||||
}
|
||||
}
|
||||
if len(enums) > 0 {
|
||||
meta["enum"] = enums
|
||||
}
|
||||
}
|
||||
meta["required"] = requiredSet[name]
|
||||
params[name] = meta
|
||||
}
|
||||
return params
|
||||
}
|
||||
@@ -1,612 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageLoadRegistryInterfaceRefsCollectsIdentity(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
refs := loadRegistryInterfaceRefs(&stderr)
|
||||
if len(refs) == 0 {
|
||||
t.Fatal("loadRegistryInterfaceRefs() returned no collected commands")
|
||||
}
|
||||
|
||||
got, ok := refs["calendar.list_calendars"]
|
||||
if !ok {
|
||||
t.Fatal("calendar.list_calendars missing from collected command identity")
|
||||
}
|
||||
if got["product_id"] != "calendar" || got["rpc_name"] != "list_calendars" {
|
||||
t.Fatalf("calendar.list_calendars ref = %#v", got)
|
||||
}
|
||||
|
||||
direct, err := collectedIdentityInterfaceRefs()
|
||||
if err != nil {
|
||||
t.Fatalf("collectedIdentityInterfaceRefs() error = %v", err)
|
||||
}
|
||||
if len(direct) == 0 || direct["calendar.list_calendars"]["rpc_name"] != "list_calendars" {
|
||||
t.Fatalf("collectedIdentityInterfaceRefs() = %#v", direct["calendar.list_calendars"])
|
||||
}
|
||||
|
||||
prevRegistry := registrySource
|
||||
prevCollect := collectIdentitySpecs
|
||||
t.Cleanup(func() {
|
||||
registrySource = prevRegistry
|
||||
collectIdentitySpecs = prevCollect
|
||||
})
|
||||
registrySource = func() (map[string]map[string]string, error) {
|
||||
return nil, errors.New("collect boom")
|
||||
}
|
||||
stderr.Reset()
|
||||
if got := loadRegistryInterfaceRefs(&stderr); len(got) != 0 || !strings.Contains(stderr.String(), "cannot collect command identity") {
|
||||
t.Fatalf("loadRegistryInterfaceRefs error path = %#v stderr=%q", got, stderr.String())
|
||||
}
|
||||
|
||||
collectIdentitySpecs = func(*cobra.Command) ([]cli.CommandSpec, cli.IdentityCollectionReport, error) {
|
||||
return nil, cli.IdentityCollectionReport{}, errors.New("walk boom")
|
||||
}
|
||||
if _, err := collectedIdentityInterfaceRefs(); err == nil || !strings.Contains(err.Error(), "collect command identity") {
|
||||
t.Fatalf("collectedIdentityInterfaceRefs wrap error = %v", err)
|
||||
}
|
||||
collectIdentitySpecs = func(*cobra.Command) ([]cli.CommandSpec, cli.IdentityCollectionReport, error) {
|
||||
return []cli.CommandSpec{
|
||||
{CanonicalPath: ""},
|
||||
{CanonicalPath: "nodot"},
|
||||
{CanonicalPath: "doc.create"},
|
||||
}, cli.IdentityCollectionReport{}, nil
|
||||
}
|
||||
gotRefs, err := collectedIdentityInterfaceRefs()
|
||||
if err != nil || len(gotRefs) != 1 || gotRefs["doc.create"]["rpc_name"] != "create" {
|
||||
t.Fatalf("collectedIdentityInterfaceRefs skip = %#v err=%v", gotRefs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCrossServerRefs(t *testing.T) {
|
||||
registryMap := map[string]map[string]string{
|
||||
"aitable.advperm_enable": {"product_id": "aitable", "rpc_name": "advperm_enable"},
|
||||
"aitable.advperm_disable": {"product_id": "aitable", "rpc_name": "advperm_disable"},
|
||||
"doc.copy_document": {"product_id": "doc", "rpc_name": "copy_document"},
|
||||
}
|
||||
prevTools := map[string]map[string]any{
|
||||
// Fan-out: two canonicals share one live tool; insertion order must
|
||||
// not affect the sorted result.
|
||||
"aitable.advperm_enable": {
|
||||
"interface_ref": map[string]any{"product_id": "aitable-helper", "rpc_name": "set_advanced_permission"},
|
||||
},
|
||||
"aitable.advperm_disable": {
|
||||
"interface_ref": map[string]any{"product_id": "aitable-helper", "rpc_name": "set_advanced_permission"},
|
||||
},
|
||||
// Identity ref (live key == canonical) needs no cross entry.
|
||||
"doc.copy_document": {
|
||||
"interface_ref": map[string]any{"product_id": "doc", "rpc_name": "copy_document"},
|
||||
},
|
||||
// Not in the registry: must be ignored.
|
||||
"ghost.tool": {
|
||||
"interface_ref": map[string]any{"product_id": "ghost-helper", "rpc_name": "haunt"},
|
||||
},
|
||||
}
|
||||
got := buildCrossServerRefs(prevTools, registryMap)
|
||||
want := map[string][]string{
|
||||
"aitable-helper.set_advanced_permission": {"aitable.advperm_disable", "aitable.advperm_enable"},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("index = %#v, want %#v", got, want)
|
||||
}
|
||||
for k, v := range want {
|
||||
if gv := got[k]; len(gv) != len(v) || gv[0] != v[0] || gv[1] != v[1] {
|
||||
t.Fatalf("index[%q] = %v, want %v", k, gv, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCrossServerRefsSkipsMalformedRefs(t *testing.T) {
|
||||
registryMap := map[string]map[string]string{
|
||||
"a.x": {"product_id": "a", "rpc_name": "x"},
|
||||
"a.y": {"product_id": "a", "rpc_name": "y"},
|
||||
"a.z": {"product_id": "a", "rpc_name": "z"},
|
||||
}
|
||||
prevTools := map[string]map[string]any{
|
||||
"a.x": {"interface_ref": "not-a-map"},
|
||||
"a.y": {"interface_ref": map[string]any{"product_id": "", "rpc_name": "r"}},
|
||||
"a.z": {"title": "no ref at all"},
|
||||
}
|
||||
if got := buildCrossServerRefs(prevTools, registryMap); len(got) != 0 {
|
||||
t.Fatalf("index = %#v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRefreshesCrossServerTools(t *testing.T) {
|
||||
registry := func() (map[string]map[string]string, error) {
|
||||
return map[string]map[string]string{
|
||||
"aitable.advperm_enable": {"product_id": "aitable", "rpc_name": "advperm_enable"},
|
||||
"aitable.advperm_disable": {"product_id": "aitable", "rpc_name": "advperm_disable"},
|
||||
}, nil
|
||||
}
|
||||
servers := []syncdata.ServerInfo{{ID: "aitable-helper", Endpoint: "https://helper.example"}}
|
||||
lister := &fakeLister{
|
||||
results: map[string]transport.ToolsListResult{
|
||||
"https://helper.example": {Tools: []transport.ToolDescriptor{
|
||||
{Name: "set_advanced_permission", Title: "live title", Description: "live desc"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
stubDeps(t, "env-token", nil, servers, lister, registry)
|
||||
|
||||
output := filepath.Join(t.TempDir(), "snapshot.json")
|
||||
prev := `{"tools":{
|
||||
"aitable.advperm_enable":{"title":"stale","interface_ref":{"product_id":"aitable-helper","rpc_name":"set_advanced_permission"}},
|
||||
"aitable.advperm_disable":{"title":"stale","interface_ref":{"product_id":"aitable-helper","rpc_name":"set_advanced_permission"}}
|
||||
}}`
|
||||
if err := os.WriteFile(output, []byte(prev), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--output", output}, &stderr); code != 0 {
|
||||
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "cross-server ref index: 1 live keys") {
|
||||
t.Fatalf("stderr = %q, want cross-server index log", stderr.String())
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var snapshot struct {
|
||||
Tools map[string]map[string]any `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, canonical := range []string{"aitable.advperm_enable", "aitable.advperm_disable"} {
|
||||
entry := snapshot.Tools[canonical]
|
||||
if entry["title"] != "live title" || entry["description"] != "live desc" {
|
||||
t.Fatalf("%s = %#v, want live refresh", canonical, entry)
|
||||
}
|
||||
ref := entry["interface_ref"].(map[string]any)
|
||||
if ref["product_id"] != "aitable-helper" || ref["rpc_name"] != "set_advanced_permission" {
|
||||
t.Fatalf("%s reviewed ref lost: %#v", canonical, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunCrossOwnedCanonicalIgnoresNameCoincidence:canonical 拥有评审过的
|
||||
// 跨 server 身份时,另一 server 上恰好同名的工具不得直连覆盖其元数据——
|
||||
// 数据源只能是评审身份指向的 live 工具。
|
||||
func TestRunCrossOwnedCanonicalIgnoresNameCoincidence(t *testing.T) {
|
||||
registry := func() (map[string]map[string]string, error) {
|
||||
return map[string]map[string]string{
|
||||
"aitable.advperm_enable": {"product_id": "aitable", "rpc_name": "advperm_enable"},
|
||||
}, nil
|
||||
}
|
||||
servers := []syncdata.ServerInfo{
|
||||
{ID: "aitable", Endpoint: "https://aitable.example"},
|
||||
{ID: "aitable-helper", Endpoint: "https://helper.example"},
|
||||
}
|
||||
lister := &fakeLister{
|
||||
results: map[string]transport.ToolsListResult{
|
||||
// 同名巧合:aitable server 上恰好也有 advperm_enable。
|
||||
"https://aitable.example": {Tools: []transport.ToolDescriptor{
|
||||
{Name: "advperm_enable", Title: "coincidence title", Description: "coincidence desc"},
|
||||
}},
|
||||
"https://helper.example": {Tools: []transport.ToolDescriptor{
|
||||
{Name: "set_advanced_permission", Title: "owner title", Description: "owner desc"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
stubDeps(t, "env-token", nil, servers, lister, registry)
|
||||
|
||||
output := filepath.Join(t.TempDir(), "snapshot.json")
|
||||
prev := `{"tools":{"aitable.advperm_enable":{"title":"stale","interface_ref":{"product_id":"aitable-helper","rpc_name":"set_advanced_permission"}}}}`
|
||||
if err := os.WriteFile(output, []byte(prev), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--output", output}, &stderr); code != 0 {
|
||||
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var snapshot struct {
|
||||
Tools map[string]map[string]any `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := snapshot.Tools["aitable.advperm_enable"]
|
||||
if entry["title"] != "owner title" || entry["description"] != "owner desc" {
|
||||
t.Fatalf("entry = %#v, want reviewed-identity source to win over name coincidence", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeLiveMCPToolRefreshesExistingMetadata(t *testing.T) {
|
||||
const canonical = "calendar.list_calendars"
|
||||
reviewedRef := map[string]any{
|
||||
"product_id": "calendar-helper",
|
||||
"rpc_name": "list_user_calendars",
|
||||
}
|
||||
allTools := map[string]map[string]any{
|
||||
canonical: {
|
||||
"title": "old title",
|
||||
"description": "old description",
|
||||
"interface_ref": reviewedRef,
|
||||
"parameters": map[string]any{
|
||||
"stale": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
}
|
||||
live := transport.ToolDescriptor{
|
||||
Name: "list_calendars",
|
||||
Title: "new title",
|
||||
Description: "new description",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"cursor": map[string]any{
|
||||
"type": "string",
|
||||
"description": "next page cursor",
|
||||
},
|
||||
},
|
||||
"required": []any{"cursor"},
|
||||
},
|
||||
}
|
||||
fallbackRef := map[string]string{
|
||||
"product_id": "calendar",
|
||||
"rpc_name": "list_calendars",
|
||||
}
|
||||
|
||||
mergeLiveMCPTool(allTools, canonical, live, fallbackRef)
|
||||
|
||||
got := allTools[canonical]
|
||||
if got["title"] != "new title" || got["description"] != "new description" {
|
||||
t.Fatalf("live metadata was not refreshed: %#v", got)
|
||||
}
|
||||
if !reflect.DeepEqual(got["interface_ref"], reviewedRef) {
|
||||
t.Fatalf("interface_ref = %#v, want reviewed mapping %#v", got["interface_ref"], reviewedRef)
|
||||
}
|
||||
params, ok := got["parameters"].(map[string]map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("parameters type = %T, want refreshed parameter map", got["parameters"])
|
||||
}
|
||||
if _, stale := params["stale"]; stale {
|
||||
t.Fatalf("stale parameter survived refresh: %#v", params)
|
||||
}
|
||||
if cursor := params["cursor"]; cursor["type"] != "string" || cursor["description"] != "next page cursor" || cursor["required"] != true {
|
||||
t.Fatalf("cursor parameter = %#v", cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCoverageReportsFailedServices(t *testing.T) {
|
||||
got := buildCoverage(26, []string{"doc", "sheet"}, 800, 813, 40)
|
||||
if got["source_services"] != 26 {
|
||||
t.Fatalf("source_services = %v, want 26", got["source_services"])
|
||||
}
|
||||
if got["snapshot_services"] != 24 {
|
||||
t.Fatalf("snapshot_services = %v, want 24 (26 sources - 2 failures)", got["snapshot_services"])
|
||||
}
|
||||
if !reflect.DeepEqual(got["missing_services"], []string{"doc", "sheet"}) {
|
||||
t.Fatalf("missing_services = %#v, want failed service IDs", got["missing_services"])
|
||||
}
|
||||
// matched 必须剔除 stub 占位,unmatched 据实等于 stub 数。
|
||||
if got["matched_tools"] != 773 || got["unmatched_tools"] != 40 {
|
||||
t.Fatalf("matched/unmatched = %v/%v, want 773/40 (813 surface - 40 stubs)", got["matched_tools"], got["unmatched_tools"])
|
||||
}
|
||||
if got["source_tools"] != 800 || got["surface_tools"] != 813 {
|
||||
t.Fatalf("tool counts = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCoverageFullSnapshotHasNoMissingServices(t *testing.T) {
|
||||
got := buildCoverage(26, nil, 813, 813, 0)
|
||||
if got["snapshot_services"] != 26 {
|
||||
t.Fatalf("snapshot_services = %v, want 26", got["snapshot_services"])
|
||||
}
|
||||
if !reflect.DeepEqual(got["missing_services"], []string{}) {
|
||||
t.Fatalf("missing_services = %#v, want empty non-nil slice", got["missing_services"])
|
||||
}
|
||||
if got["matched_tools"] != 813 || got["unmatched_tools"] != 0 {
|
||||
t.Fatalf("matched/unmatched = %v/%v, want 813/0 for stub-free snapshot", got["matched_tools"], got["unmatched_tools"])
|
||||
}
|
||||
}
|
||||
|
||||
// fakeLister returns canned tools/list results per endpoint.
|
||||
type fakeLister struct {
|
||||
results map[string]transport.ToolsListResult
|
||||
errs map[string]error
|
||||
}
|
||||
|
||||
func (f *fakeLister) ListTools(_ context.Context, endpoint string) (transport.ToolsListResult, error) {
|
||||
if err := f.errs[endpoint]; err != nil {
|
||||
return transport.ToolsListResult{}, err
|
||||
}
|
||||
return f.results[endpoint], nil
|
||||
}
|
||||
|
||||
// stubDeps swaps every injection point for the duration of one test.
|
||||
func stubDeps(t *testing.T, token string, keychain func() (*auth.TokenData, error), servers []syncdata.ServerInfo, lister toolLister, registry func() (map[string]map[string]string, error)) {
|
||||
t.Helper()
|
||||
origGetenv, origLoad, origServers, origNew, origRegistry := getenv, loadTokenData, staticServers, newToolLister, registrySource
|
||||
t.Cleanup(func() {
|
||||
getenv, loadTokenData, staticServers, newToolLister, registrySource = origGetenv, origLoad, origServers, origNew, origRegistry
|
||||
})
|
||||
getenv = func(key string) string {
|
||||
if key == "DWS_ACCESS_TOKEN" {
|
||||
return token
|
||||
}
|
||||
return ""
|
||||
}
|
||||
loadTokenData = keychain
|
||||
staticServers = func() []syncdata.ServerInfo { return servers }
|
||||
newToolLister = func(string) toolLister { return lister }
|
||||
registrySource = registry
|
||||
}
|
||||
|
||||
func testRegistryRefs() (map[string]map[string]string, error) {
|
||||
return map[string]map[string]string{
|
||||
"doc.copy_document": {"product_id": "doc", "rpc_name": "copy_document"},
|
||||
"doc.get_document": {"product_id": "doc", "rpc_name": "get_document"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunRefusesRetiredPinnedMCPMetadataPath(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
code := run([]string{"--output", "internal/cli/schema_mcp_metadata.json"}, &stderr)
|
||||
if code != 2 {
|
||||
t.Fatalf("run(retired pin) = %d, want 2", code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "refusing to write retired Schema pin") {
|
||||
t.Fatalf("stderr = %q, want retired-pin refusal", stderr.String())
|
||||
}
|
||||
if !retiredPinnedMCPMetadataPath("internal/cli/schema_mcp_metadata.json") ||
|
||||
!retiredPinnedMCPMetadataPath("/tmp/repo/internal/cli/schema_mcp_metadata.json") ||
|
||||
retiredPinnedMCPMetadataPath("artifacts/mcp_metadata_diagnostic.json") {
|
||||
t.Fatal("retiredPinnedMCPMetadataPath classification is incorrect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoTokenFails(t *testing.T) {
|
||||
stubDeps(t, "", func() (*auth.TokenData, error) { return nil, errors.New("no keychain") }, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
if code := run(nil, &stderr); code != 1 {
|
||||
t.Fatalf("run() = %d, want 1", code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "no auth token") {
|
||||
t.Fatalf("stderr = %q, want no-auth-token hint", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInvalidFlagFails(t *testing.T) {
|
||||
stubDeps(t, "tok", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--nonexistent"}, &stderr); code != 2 {
|
||||
t.Fatalf("run() = %d, want 2", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTokenKeychainFallback(t *testing.T) {
|
||||
stubDeps(t, "", func() (*auth.TokenData, error) {
|
||||
return &auth.TokenData{AccessToken: "kc-token"}, nil
|
||||
}, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
if got := resolveToken(&stderr); got != "kc-token" {
|
||||
t.Fatalf("resolveToken() = %q, want kc-token", got)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "loaded token from keychain") {
|
||||
t.Fatalf("stderr = %q, want keychain log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTokenEmptyKeychainToken(t *testing.T) {
|
||||
stubDeps(t, "", func() (*auth.TokenData, error) { return &auth.TokenData{}, nil }, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
if got := resolveToken(&stderr); got != "" {
|
||||
t.Fatalf("resolveToken() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunWritesSnapshotWithHonestCoverage(t *testing.T) {
|
||||
servers := []syncdata.ServerInfo{
|
||||
{ID: "doc", Endpoint: "https://doc.example"},
|
||||
{ID: "sheet", Endpoint: "https://sheet.example"},
|
||||
{ID: "blank", Endpoint: " "},
|
||||
}
|
||||
lister := &fakeLister{
|
||||
results: map[string]transport.ToolsListResult{
|
||||
"https://doc.example": {Tools: []transport.ToolDescriptor{
|
||||
{Name: "copy_document", Title: "复制文档", Description: "copy", InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"doc_id": map[string]any{"type": "string", "description": "文档 ID", "default": "d", "enum": []any{"a", "b", 3}},
|
||||
"bogus": "not-a-map",
|
||||
},
|
||||
"required": []any{"doc_id", 42},
|
||||
}},
|
||||
{Name: " "},
|
||||
{Name: "not_in_registry"},
|
||||
}},
|
||||
},
|
||||
errs: map[string]error{"https://sheet.example": errors.New("boom")},
|
||||
}
|
||||
stubDeps(t, "env-token", nil, servers, lister, testRegistryRefs)
|
||||
|
||||
dir := t.TempDir()
|
||||
output := filepath.Join(dir, "snapshot.json")
|
||||
prev := `{"tools":{"doc.get_document":{"interface_ref":{"product_id":"doc-helper","rpc_name":"fetch_document"}}}}`
|
||||
if err := os.WriteFile(output, []byte(prev), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--output", output}, &stderr); code != 0 {
|
||||
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var snapshot struct {
|
||||
Version int `json:"version"`
|
||||
Coverage map[string]any `json:"coverage"`
|
||||
Tools map[string]map[string]any
|
||||
}
|
||||
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.Version != 1 {
|
||||
t.Fatalf("version = %d", snapshot.Version)
|
||||
}
|
||||
if got := snapshot.Coverage["snapshot_services"].(float64); got != 2 {
|
||||
t.Fatalf("snapshot_services = %v, want 2 (3 servers - 1 failed; blank endpoint not counted as failed)", got)
|
||||
}
|
||||
if got := snapshot.Coverage["missing_services"].([]any); len(got) != 1 || got[0] != "sheet" {
|
||||
t.Fatalf("missing_services = %v, want [sheet]", got)
|
||||
}
|
||||
live := snapshot.Tools["doc.copy_document"]
|
||||
if live == nil || live["title"] != "复制文档" {
|
||||
t.Fatalf("doc.copy_document = %#v, want live metadata", live)
|
||||
}
|
||||
params := live["parameters"].(map[string]any)
|
||||
docID := params["doc_id"].(map[string]any)
|
||||
if docID["type"] != "string" || docID["required"] != true || docID["default"] != "d" {
|
||||
t.Fatalf("doc_id = %#v", docID)
|
||||
}
|
||||
if enum := docID["enum"].([]any); len(enum) != 2 {
|
||||
t.Fatalf("enum = %v, want the 2 string members only", enum)
|
||||
}
|
||||
if _, ok := params["bogus"]; ok {
|
||||
t.Fatal("non-map property should be skipped")
|
||||
}
|
||||
prevRef := snapshot.Tools["doc.get_document"]["interface_ref"].(map[string]any)
|
||||
if prevRef["product_id"] != "doc-helper" {
|
||||
t.Fatalf("previous reviewed ref lost: %#v", prevRef)
|
||||
}
|
||||
if _, ok := snapshot.Tools["not_in_registry"]; ok {
|
||||
t.Fatal("tools outside the registry must be dropped")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "services unreachable: sheet") {
|
||||
t.Fatalf("stderr = %q, want unreachable log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunIgnoresCorruptPreviousSnapshot(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
output := filepath.Join(t.TempDir(), "snapshot.json")
|
||||
if err := os.WriteFile(output, []byte("{corrupt"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--output", output}, &stderr); code != 0 {
|
||||
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunRegistryLoadFailureStillWritesStublessSnapshot(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() (map[string]map[string]string, error) { return nil, errors.New("no identity") })
|
||||
output := filepath.Join(t.TempDir(), "snapshot.json")
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--output", output}, &stderr); code != 0 {
|
||||
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "cannot collect command identity") {
|
||||
t.Fatalf("stderr = %q, want identity collection warning", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWriteFailure(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
badPath := filepath.Join(t.TempDir(), "missing-dir", "snapshot.json")
|
||||
if code := run([]string{"--output", badPath}, &stderr); code != 1 {
|
||||
t.Fatalf("run() = %d, want 1 on write failure", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMetadataMarshalFailure(t *testing.T) {
|
||||
err := writeMetadata(filepath.Join(t.TempDir(), "out.json"), map[string]any{"bad": math.NaN()})
|
||||
if err == nil || !strings.Contains(err.Error(), "marshal failed") {
|
||||
t.Fatalf("err = %v, want marshal failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainDelegatesToRun(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
origExit, origArgs := osExit, os.Args
|
||||
t.Cleanup(func() { osExit, os.Args = origExit, origArgs })
|
||||
exitCode := -1
|
||||
osExit = func(code int) { exitCode = code }
|
||||
os.Args = []string{"fetch_mcp_metadata", "--output", filepath.Join(t.TempDir(), "snapshot.json")}
|
||||
main()
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("main() exited with %d, want 0", exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractParamsNilAndNonObjectSchemas(t *testing.T) {
|
||||
if got := extractParams(nil); got != nil {
|
||||
t.Fatalf("extractParams(nil) = %v, want nil", got)
|
||||
}
|
||||
if got := extractParams(map[string]any{"type": "object"}); got != nil {
|
||||
t.Fatalf("extractParams(no properties) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewToolListerBuildsAuthedClient(t *testing.T) {
|
||||
if lister := newToolLister("tok"); lister == nil {
|
||||
t.Fatal("newToolLister returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRecordsSourceRevision(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
dir := t.TempDir()
|
||||
head := filepath.Join(dir, "HEAD")
|
||||
if err := os.WriteFile(head, []byte("ref: refs/heads/feature\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
origHead := gitHeadPath
|
||||
t.Cleanup(func() { gitHeadPath = origHead })
|
||||
gitHeadPath = head
|
||||
|
||||
output := filepath.Join(dir, "snapshot.json")
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--output", output}, &stderr); code != 0 {
|
||||
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
data, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var snapshot struct {
|
||||
SourceRevision string `json:"source_revision"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.SourceRevision != "ref: refs/heads/feature" {
|
||||
t.Fatalf("source_revision = %q", snapshot.SourceRevision)
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// interface-snapshot is an internal CI helper. It is intentionally a separate
|
||||
// binary so it can be copied into a temporary worktree and compiled against an
|
||||
// older revision's real Cobra root.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var newRootCommand = func() *cobra.Command { return app.NewRootCommand() }
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
func run(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
printUsage(stderr)
|
||||
return 2
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "generate":
|
||||
if err := runGenerate(args[1:], stdout, stderr); err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
case "compare":
|
||||
compatible, err := runCompare(args[1:], stdout, stderr)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 2
|
||||
}
|
||||
if !compatible {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(stderr, "unknown command %q\n", args[0])
|
||||
printUsage(stderr)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func runGenerate(args []string, stdout, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("generate", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
output := flags.String("output", "-", "snapshot output path, or - for stdout")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return fmt.Errorf("generate accepts no positional arguments")
|
||||
}
|
||||
|
||||
home, err := os.MkdirTemp("", "dws-interface-snapshot-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create isolated home: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(home)
|
||||
|
||||
environment := map[string]string{
|
||||
"DWS_CONFIG_DIR": home,
|
||||
"DWS_LANG": "en",
|
||||
"HOME": home,
|
||||
"NO_COLOR": "1",
|
||||
"USERPROFILE": home,
|
||||
}
|
||||
type previousEnv struct {
|
||||
value string
|
||||
set bool
|
||||
}
|
||||
previous := make(map[string]previousEnv, len(environment))
|
||||
for key, value := range environment {
|
||||
oldValue, wasSet := os.LookupEnv(key)
|
||||
previous[key] = previousEnv{value: oldValue, set: wasSet}
|
||||
if err := os.Setenv(key, value); err != nil {
|
||||
return fmt.Errorf("set %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
for key, old := range previous {
|
||||
if old.set {
|
||||
_ = os.Setenv(key, old.value)
|
||||
} else {
|
||||
_ = os.Unsetenv(key)
|
||||
}
|
||||
}
|
||||
}()
|
||||
previousLang := i18n.Lang()
|
||||
defer i18n.SetLang(previousLang)
|
||||
i18n.SetLang("en")
|
||||
|
||||
root := newRootCommand()
|
||||
snapshot := interfacesnapshot.Capture(root)
|
||||
if err := validateHelpRendering(root, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
if *output == "-" {
|
||||
return interfacesnapshot.Write(stdout, snapshot)
|
||||
}
|
||||
|
||||
file, err := os.Create(filepath.Clean(*output))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create snapshot %q: %w", *output, err)
|
||||
}
|
||||
writeErr := interfacesnapshot.Write(file, snapshot)
|
||||
closeErr := file.Close()
|
||||
if writeErr != nil {
|
||||
return fmt.Errorf("write snapshot %q: %w", *output, writeErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close snapshot %q: %w", *output, closeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
flags := flag.NewFlagSet("compare", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
currentPath := flags.String("current", "", "candidate snapshot path")
|
||||
basePath := flags.String("base", "", "target main/development baseline snapshot path")
|
||||
stablePath := flags.String("stable", "", "latest stable GA snapshot path")
|
||||
approvedMigrationsPath := flags.String(
|
||||
"approved-flag-migrations",
|
||||
"",
|
||||
"merge-base-owned approved flag migration manifest",
|
||||
)
|
||||
candidateMigrationsPath := flags.String(
|
||||
"candidate-flag-migrations",
|
||||
"",
|
||||
"candidate flag migration manifest",
|
||||
)
|
||||
approvedCommandMigrationsPath := flags.String(
|
||||
"approved-command-migrations",
|
||||
"",
|
||||
"merge-base-owned approved command migration manifest",
|
||||
)
|
||||
candidateCommandMigrationsPath := flags.String(
|
||||
"candidate-command-migrations",
|
||||
"",
|
||||
"candidate command migration manifest",
|
||||
)
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return false, fmt.Errorf("compare accepts no positional arguments")
|
||||
}
|
||||
if *currentPath == "" {
|
||||
return false, fmt.Errorf("compare requires --current")
|
||||
}
|
||||
if *basePath == "" && *stablePath == "" {
|
||||
return false, fmt.Errorf("compare requires --base, --stable, or both")
|
||||
}
|
||||
if (*approvedMigrationsPath == "") != (*candidateMigrationsPath == "") {
|
||||
return false, fmt.Errorf(
|
||||
"--approved-flag-migrations and --candidate-flag-migrations must be provided together",
|
||||
)
|
||||
}
|
||||
if (*approvedCommandMigrationsPath == "") != (*candidateCommandMigrationsPath == "") {
|
||||
return false, fmt.Errorf(
|
||||
"--approved-command-migrations and --candidate-command-migrations must be provided together",
|
||||
)
|
||||
}
|
||||
if (*approvedMigrationsPath != "" || *approvedCommandMigrationsPath != "") && (*basePath == "" || *stablePath == "") {
|
||||
return false, fmt.Errorf("migration compare requires both --base and --stable")
|
||||
}
|
||||
|
||||
current, err := readSnapshot(*currentPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read current snapshot: %w", err)
|
||||
}
|
||||
references := make(map[string]interfacesnapshot.Snapshot, 2)
|
||||
if *basePath != "" {
|
||||
references["main"], err = readSnapshot(*basePath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read main/development baseline snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
if *stablePath != "" {
|
||||
references["stable"], err = readSnapshot(*stablePath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read stable snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
report := interfacesnapshot.CompareAll(current, references)
|
||||
if *approvedCommandMigrationsPath != "" {
|
||||
flagApproved := interfacesnapshot.FlagMigrationManifest{Version: interfacesnapshot.FlagMigrationManifestVersion, Migrations: []interfacesnapshot.FlagMigration{}}
|
||||
flagCandidate := flagApproved
|
||||
if *approvedMigrationsPath != "" {
|
||||
flagApproved, err = readFlagMigrationManifest(*approvedMigrationsPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read approved flag migrations: %w", err)
|
||||
}
|
||||
flagCandidate, err = readFlagMigrationManifest(*candidateMigrationsPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read candidate flag migrations: %w", err)
|
||||
}
|
||||
}
|
||||
commandApproved, readErr := readCommandMigrationManifest(*approvedCommandMigrationsPath)
|
||||
if readErr != nil {
|
||||
return false, fmt.Errorf("read approved command migrations: %w", readErr)
|
||||
}
|
||||
commandCandidate, readErr := readCommandMigrationManifest(*candidateCommandMigrationsPath)
|
||||
if readErr != nil {
|
||||
return false, fmt.Errorf("read candidate command migrations: %w", readErr)
|
||||
}
|
||||
report, err = interfacesnapshot.CompareAllWithInterfaceMigrations(
|
||||
current,
|
||||
references,
|
||||
flagApproved,
|
||||
flagCandidate,
|
||||
commandApproved,
|
||||
commandCandidate,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("validate interface migration lifecycle: %w", err)
|
||||
}
|
||||
} else if *approvedMigrationsPath != "" {
|
||||
approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath)
|
||||
if readErr != nil {
|
||||
return false, fmt.Errorf("read approved flag migrations: %w", readErr)
|
||||
}
|
||||
candidate, readErr := readFlagMigrationManifest(*candidateMigrationsPath)
|
||||
if readErr != nil {
|
||||
return false, fmt.Errorf("read candidate flag migrations: %w", readErr)
|
||||
}
|
||||
report, err = interfacesnapshot.CompareAllWithFlagMigrations(
|
||||
current,
|
||||
references,
|
||||
approved,
|
||||
candidate,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("validate flag migration lifecycle: %w", err)
|
||||
}
|
||||
}
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetEscapeHTML(false)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(report); err != nil {
|
||||
return false, fmt.Errorf("write comparison report: %w", err)
|
||||
}
|
||||
return report.Compatible, nil
|
||||
}
|
||||
|
||||
func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationManifest, error) {
|
||||
file, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
return interfacesnapshot.FlagMigrationManifest{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
return interfacesnapshot.ReadFlagMigrationManifest(file)
|
||||
}
|
||||
|
||||
func readCommandMigrationManifest(path string) (interfacesnapshot.CommandMigrationManifest, error) {
|
||||
file, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
return interfacesnapshot.CommandMigrationManifest{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
return interfacesnapshot.ReadCommandMigrationManifest(file)
|
||||
}
|
||||
|
||||
func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error {
|
||||
for _, command := range snapshot.Commands {
|
||||
path := strings.TrimPrefix(command.Path, "dws")
|
||||
resolved, remaining, err := root.Find(strings.Fields(path))
|
||||
if err != nil || len(remaining) != 0 || resolved == nil {
|
||||
return fmt.Errorf("resolve %q before help rendering: remaining=%v error=%v", command.Path, remaining, err)
|
||||
}
|
||||
if err := renderCommandHelp(resolved); err != nil {
|
||||
return fmt.Errorf("render %q help: %w", command.Path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderCommandHelp(command *cobra.Command) (err error) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
command.InitDefaultHelpFlag()
|
||||
command.SetOut(&stdout)
|
||||
command.SetErr(&stderr)
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = fmt.Errorf("help renderer panicked: %v", recovered)
|
||||
}
|
||||
}()
|
||||
command.HelpFunc()(command, []string{})
|
||||
if stderr.Len() > 0 {
|
||||
return fmt.Errorf("help renderer wrote an error: %s", strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
if stdout.Len() == 0 {
|
||||
return fmt.Errorf("help renderer produced empty output")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
|
||||
file, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
return interfacesnapshot.Snapshot{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
return interfacesnapshot.Read(file)
|
||||
}
|
||||
|
||||
func printUsage(w io.Writer) {
|
||||
fmt.Fprintln(w, "usage:")
|
||||
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
|
||||
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE] [--approved-command-migrations FILE --candidate-command-migrations FILE]")
|
||||
}
|
||||
@@ -1,700 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 0 {
|
||||
t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
snapshot, err := interfacesnapshot.Read(bytes.NewReader(stdout.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("decode generated snapshot: %v", err)
|
||||
}
|
||||
|
||||
commands := make(map[string]interfacesnapshot.Command, len(snapshot.Commands))
|
||||
for _, command := range snapshot.Commands {
|
||||
commands[command.Path] = command
|
||||
}
|
||||
for _, path := range []string{"dws", "dws chat", "dws dev app create"} {
|
||||
if _, ok := commands[path]; !ok {
|
||||
t.Errorf("actual root snapshot is missing %q", path)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"dws completion", "dws help"} {
|
||||
if _, ok := commands[path]; ok {
|
||||
t.Errorf("framework-noise path %q leaked into snapshot", path)
|
||||
}
|
||||
}
|
||||
|
||||
create := commands["dws dev app create"]
|
||||
if !hasFlag(create.LocalFlags, "name", "string") {
|
||||
t.Errorf("dev app create local flags do not contain --name string: %#v", create.LocalFlags)
|
||||
}
|
||||
if !hasFlag(create.InheritedFlags, "profile", "string") {
|
||||
t.Errorf("dev app create inherited flags do not contain --profile string: %#v", create.InheritedFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateRejectsHelpRenderingFailure(t *testing.T) {
|
||||
testseam.Swap(t, &newRootCommand, func() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
|
||||
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
|
||||
})
|
||||
return root
|
||||
})
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 2 {
|
||||
t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "injected help failure") {
|
||||
t.Fatalf("run(generate) stderr=%q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *testing.T) {
|
||||
current := commandSnapshot("dws")
|
||||
mergeBase := commandSnapshot("dws")
|
||||
stable := commandSnapshot("dws", "dws legacy")
|
||||
|
||||
dir := t.TempDir()
|
||||
currentPath := writeSnapshot(t, dir, "current.json", current)
|
||||
mergeBasePath := writeSnapshot(t, dir, "base.json", mergeBase)
|
||||
stablePath := writeSnapshot(t, dir, "stable.json", stable)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", currentPath,
|
||||
"--base", mergeBasePath,
|
||||
"--stable", stablePath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("run(compare) exit=%d, want 1; stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"reference": "main"`)) ||
|
||||
!bytes.Contains(stdout.Bytes(), []byte(`"reference": "stable"`)) ||
|
||||
!bytes.Contains(stdout.Bytes(), []byte(`"kind": "command_removed"`)) {
|
||||
t.Fatalf("comparison report does not contain both references and the blocking change:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareEnforcesBaseOwnedFlagMigrationLifecycle(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
before := flagMigrationSnapshot(false)
|
||||
after := flagMigrationSnapshot(true)
|
||||
currentPath := writeSnapshot(t, dir, "current.json", after)
|
||||
basePath := writeSnapshot(t, dir, "base.json", before)
|
||||
stablePath := writeSnapshot(t, dir, "stable.json", before)
|
||||
approvedPath := writeManifest(t, dir, "approved.json", flagMigrationManifestJSON("pending"))
|
||||
candidatePath := writeManifest(t, dir, "candidate.json", flagMigrationManifestJSON("consumed"))
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", currentPath,
|
||||
"--base", basePath,
|
||||
"--stable", stablePath,
|
||||
"--approved-flag-migrations", approvedPath,
|
||||
"--candidate-flag-migrations", candidatePath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exact base-owned migration exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"compatible": true`)) {
|
||||
t.Fatalf("exact migration report is not compatible:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
emptyApproved := writeManifest(t, dir, "empty-approved.json", `{"version":1,"migrations":[]}`)
|
||||
exitCode = run([]string{
|
||||
"compare",
|
||||
"--current", currentPath,
|
||||
"--base", basePath,
|
||||
"--stable", stablePath,
|
||||
"--approved-flag-migrations", emptyApproved,
|
||||
"--candidate-flag-migrations", candidatePath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "must start pending") {
|
||||
t.Fatalf("candidate self-approval exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareRequiresBothFlagMigrationInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
|
||||
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
|
||||
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", currentPath,
|
||||
"--base", basePath,
|
||||
"--approved-flag-migrations", approvedPath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "must be provided together") {
|
||||
t.Fatalf("one-sided migration input exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareCommandMigrationInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
|
||||
emptyFlag := writeManifest(t, dir, "empty-flags.json", `{"version":1,"migrations":[]}`)
|
||||
emptyCommand := writeManifest(t, dir, "empty-commands.json", `{"version":1,"migrations":[]}`)
|
||||
invalid := writeManifest(t, dir, "invalid-commands.json", `{`)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
args := []string{
|
||||
"compare",
|
||||
"--current", snapshotPath,
|
||||
"--base", snapshotPath,
|
||||
"--stable", snapshotPath,
|
||||
"--approved-flag-migrations", emptyFlag,
|
||||
"--candidate-flag-migrations", emptyFlag,
|
||||
"--approved-command-migrations", emptyCommand,
|
||||
"--candidate-command-migrations", emptyCommand,
|
||||
}
|
||||
if exitCode := run(args, &stdout, &stderr); exitCode != 0 {
|
||||
t.Fatalf("combined migration compare exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
approved string
|
||||
candidate string
|
||||
want string
|
||||
}{
|
||||
{"approved flag", invalid, emptyFlag, "read approved flag migrations"},
|
||||
{"candidate flag", emptyFlag, invalid, "read candidate flag migrations"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
testArgs := []string{
|
||||
"compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath,
|
||||
"--approved-flag-migrations", test.approved,
|
||||
"--candidate-flag-migrations", test.candidate,
|
||||
"--approved-command-migrations", emptyCommand,
|
||||
"--candidate-command-migrations", emptyCommand,
|
||||
}
|
||||
if exitCode := run(testArgs, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("combined flag error exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
approved string
|
||||
candidate string
|
||||
want string
|
||||
}{
|
||||
{"approved", invalid, emptyCommand, "read approved command migrations"},
|
||||
{"candidate", emptyCommand, invalid, "read candidate command migrations"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
testArgs := []string{
|
||||
"compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath,
|
||||
"--approved-command-migrations", test.approved,
|
||||
"--candidate-command-migrations", test.candidate,
|
||||
}
|
||||
if exitCode := run(testArgs, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("command manifest error exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
stderr.Reset()
|
||||
if exitCode := run([]string{
|
||||
"compare", "--current", snapshotPath, "--base", snapshotPath,
|
||||
"--approved-command-migrations", emptyCommand,
|
||||
}, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), "provided together") {
|
||||
t.Fatalf("one-sided command manifest exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
|
||||
if _, err := readCommandMigrationManifest(filepath.Join(dir, "missing.json")); err == nil {
|
||||
t.Fatal("missing command migration manifest unexpectedly read")
|
||||
}
|
||||
if _, err := readCommandMigrationManifest(invalid); err == nil {
|
||||
t.Fatal("invalid command migration manifest unexpectedly read")
|
||||
}
|
||||
|
||||
pending := writeManifest(t, dir, "pending-command.json", commandMigrationManifestJSON("pending"))
|
||||
consumed := writeManifest(t, dir, "consumed-command.json", commandMigrationManifestJSON("consumed"))
|
||||
stderr.Reset()
|
||||
if exitCode := run([]string{
|
||||
"compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath,
|
||||
"--approved-command-migrations", pending,
|
||||
"--candidate-command-migrations", consumed,
|
||||
}, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), "validate interface migration lifecycle") {
|
||||
t.Fatalf("command lifecycle error exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareRequiresBothReferencesForFlagMigrations(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
|
||||
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
|
||||
stablePath := writeSnapshot(t, dir, "stable.json", commandSnapshot("dws"))
|
||||
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
|
||||
candidatePath := writeManifest(t, dir, "candidate.json", `{"version":1,"migrations":[]}`)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "missing stable",
|
||||
args: []string{"--base", basePath},
|
||||
},
|
||||
{
|
||||
name: "missing base",
|
||||
args: []string{"--stable", stablePath},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := []string{"compare", "--current", currentPath}
|
||||
args = append(args, test.args...)
|
||||
args = append(args,
|
||||
"--approved-flag-migrations", approvedPath,
|
||||
"--candidate-flag-migrations", candidatePath,
|
||||
)
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run(args, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "requires both --base and --stable") {
|
||||
t.Fatalf("one-reference migration compare exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPrintsUsageForMissingAndUnknownCommands(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantStderr []string
|
||||
}{
|
||||
{
|
||||
name: "missing command",
|
||||
args: nil,
|
||||
wantStderr: []string{"usage:", "interface-snapshot generate", "--approved-flag-migrations"},
|
||||
},
|
||||
{
|
||||
name: "unknown command",
|
||||
args: []string{"unknown"},
|
||||
wantStderr: []string{`unknown command "unknown"`, "usage:", "interface-snapshot compare"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
|
||||
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("run(%v) unexpectedly wrote stdout: %s", test.args, stdout.String())
|
||||
}
|
||||
for _, want := range test.wantStderr {
|
||||
if !strings.Contains(stderr.String(), want) {
|
||||
t.Errorf("run(%v) stderr missing %q:\n%s", test.args, want, stderr.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunRejectsInvalidSubcommandArguments(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "generate unknown flag", args: []string{"generate", "--unknown"}, want: "flag provided but not defined"},
|
||||
{name: "generate positional", args: []string{"generate", "unexpected"}, want: "generate accepts no positional arguments"},
|
||||
{name: "compare unknown flag", args: []string{"compare", "--unknown"}, want: "flag provided but not defined"},
|
||||
{name: "compare positional", args: []string{"compare", "unexpected"}, want: "compare accepts no positional arguments"},
|
||||
{name: "compare missing current", args: []string{"compare", "--base", "base.json"}, want: "compare requires --current"},
|
||||
{name: "compare missing reference", args: []string{"compare", "--current", "current.json"}, want: "compare requires --base, --stable, or both"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
|
||||
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("run(%v) stderr missing %q:\n%s", test.args, test.want, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateRejectsUnsafeOutputPath(t *testing.T) {
|
||||
outputDirectory := t.TempDir()
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{"generate", "--output", outputDirectory}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "create snapshot") {
|
||||
t.Fatalf("directory output exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateReportsTemporaryDirectoryFailure(t *testing.T) {
|
||||
missingTempRoot := filepath.Join(t.TempDir(), "missing")
|
||||
for _, name := range []string{"TMPDIR", "TMP", "TEMP"} {
|
||||
t.Setenv(name, missingTempRoot)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{"generate"}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "create isolated home") {
|
||||
t.Fatalf("invalid temporary root exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareReportsSnapshotReadFailures(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
validPath := writeSnapshot(t, dir, "valid.json", commandSnapshot("dws"))
|
||||
missingPath := filepath.Join(dir, "missing.json")
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "current",
|
||||
args: []string{"compare", "--current", missingPath, "--base", validPath},
|
||||
want: "read current snapshot",
|
||||
},
|
||||
{
|
||||
name: "main",
|
||||
args: []string{"compare", "--current", validPath, "--base", missingPath},
|
||||
want: "read main/development baseline snapshot",
|
||||
},
|
||||
{
|
||||
name: "stable",
|
||||
args: []string{"compare", "--current", validPath, "--stable", missingPath},
|
||||
want: "read stable snapshot",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
|
||||
t.Fatalf("run(compare) exit=%d, want 2", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("stderr missing %q:\n%s", test.want, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareReportsEachManifestReadFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
|
||||
validManifest := writeManifest(t, dir, "valid-manifest.json", `{"version":1,"migrations":[]}`)
|
||||
invalidManifest := writeManifest(t, dir, "invalid-manifest.json", `{`)
|
||||
tests := []struct {
|
||||
name string
|
||||
approved string
|
||||
candidate string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "approved manifest",
|
||||
approved: invalidManifest,
|
||||
candidate: validManifest,
|
||||
want: "read approved flag migrations",
|
||||
},
|
||||
{
|
||||
name: "candidate manifest",
|
||||
approved: validManifest,
|
||||
candidate: invalidManifest,
|
||||
want: "read candidate flag migrations",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", snapshotPath,
|
||||
"--base", snapshotPath,
|
||||
"--stable", snapshotPath,
|
||||
"--approved-flag-migrations", test.approved,
|
||||
"--candidate-flag-migrations", test.candidate,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("%s exit=%d stdout=%s stderr=%s", test.name, exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareReportsOutputFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
|
||||
var stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", snapshotPath,
|
||||
"--base", snapshotPath,
|
||||
}, failingWriter{}, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "write comparison report") {
|
||||
t.Fatalf("comparison output failure exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadHelpersRejectMissingAndInvalidInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
missingPath := filepath.Join(dir, "missing.json")
|
||||
invalidPath := filepath.Join(dir, "invalid.json")
|
||||
if err := os.WriteFile(invalidPath, []byte(`{`), 0o600); err != nil {
|
||||
t.Fatalf("write invalid fixture: %v", err)
|
||||
}
|
||||
|
||||
if _, err := readSnapshot(missingPath); err == nil {
|
||||
t.Fatal("readSnapshot(missing) unexpectedly succeeded")
|
||||
}
|
||||
if _, err := readSnapshot(invalidPath); err == nil {
|
||||
t.Fatal("readSnapshot(invalid) unexpectedly succeeded")
|
||||
}
|
||||
if _, err := readFlagMigrationManifest(missingPath); err == nil {
|
||||
t.Fatal("readFlagMigrationManifest(missing) unexpectedly succeeded")
|
||||
}
|
||||
if _, err := readFlagMigrationManifest(invalidPath); err == nil {
|
||||
t.Fatal("readFlagMigrationManifest(invalid) unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingReportsResolveError(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
err := validateHelpRendering(root, commandSnapshot("dws missing"))
|
||||
if err == nil || !strings.Contains(err.Error(), `resolve "dws missing" before help rendering`) {
|
||||
t.Fatalf("validateHelpRendering resolve error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingReportsTemplateError(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpTemplate(`{{index .Commands 99}}`)
|
||||
err := validateHelpRendering(root, commandSnapshot("dws"))
|
||||
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
|
||||
t.Fatalf("validateHelpRendering template error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingRecoversTemplatePanic(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpTemplate("{{")
|
||||
err := validateHelpRendering(root, commandSnapshot("dws"))
|
||||
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
|
||||
t.Fatalf("validateHelpRendering template panic = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingRejectsCustomHelpStderr(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
|
||||
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
|
||||
})
|
||||
err := validateHelpRendering(root, commandSnapshot("dws"))
|
||||
if err == nil || !strings.Contains(err.Error(), "injected help failure") {
|
||||
t.Fatalf("validateHelpRendering custom stderr = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingRejectsEmptyOutput(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpFunc(func(*cobra.Command, []string) {})
|
||||
err := validateHelpRendering(root, commandSnapshot("dws"))
|
||||
if err == nil || !strings.Contains(err.Error(), "empty") {
|
||||
t.Fatalf("validateHelpRendering empty output = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingAcceptsNormalOutput(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws", Short: "root command"}
|
||||
if err := validateHelpRendering(root, commandSnapshot("dws")); err != nil {
|
||||
t.Fatalf("validateHelpRendering normal output: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type failingWriter struct{}
|
||||
|
||||
func (failingWriter) Write([]byte) (int, error) {
|
||||
return 0, errors.New("injected write failure")
|
||||
}
|
||||
|
||||
var _ io.Writer = failingWriter{}
|
||||
|
||||
func commandSnapshot(paths ...string) interfacesnapshot.Snapshot {
|
||||
commands := make([]interfacesnapshot.Command, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
commands = append(commands, interfacesnapshot.Command{
|
||||
Path: path,
|
||||
Aliases: []string{},
|
||||
LocalFlags: []interfacesnapshot.Flag{},
|
||||
InheritedFlags: []interfacesnapshot.Flag{},
|
||||
})
|
||||
}
|
||||
return interfacesnapshot.Snapshot{
|
||||
SchemaVersion: interfacesnapshot.SchemaVersion,
|
||||
Rules: interfacesnapshot.Rules{
|
||||
ExcludedCommandSubtrees: []string{},
|
||||
ExcludedFlags: []string{},
|
||||
},
|
||||
Commands: commands,
|
||||
}
|
||||
}
|
||||
|
||||
func writeSnapshot(t *testing.T, dir, name string, snapshot interfacesnapshot.Snapshot) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create %s: %v", path, err)
|
||||
}
|
||||
if err := interfacesnapshot.Write(file, snapshot); err != nil {
|
||||
file.Close()
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("close %s: %v", path, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func writeManifest(t *testing.T, dir, name, contents string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func flagMigrationSnapshot(after bool) interfacesnapshot.Snapshot {
|
||||
legacy := interfacesnapshot.Flag{
|
||||
Name: "legacy-id",
|
||||
Shorthand: "l",
|
||||
Type: "string",
|
||||
Default: "",
|
||||
NoOpt: "auto",
|
||||
Required: true,
|
||||
}
|
||||
flags := []interfacesnapshot.Flag{legacy}
|
||||
if after {
|
||||
legacy.Required = false
|
||||
legacy.Hidden = true
|
||||
legacy.AliasOf = "message-id"
|
||||
flags = []interfacesnapshot.Flag{
|
||||
legacy,
|
||||
{Name: "message-id", Type: "string", Default: "", Required: true},
|
||||
}
|
||||
}
|
||||
return interfacesnapshot.Snapshot{
|
||||
SchemaVersion: interfacesnapshot.SchemaVersion,
|
||||
Rules: interfacesnapshot.Rules{
|
||||
ExcludedCommandSubtrees: []string{},
|
||||
ExcludedFlags: []string{},
|
||||
},
|
||||
Commands: []interfacesnapshot.Command{
|
||||
{Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
|
||||
{Path: "dws chat send", Runnable: true, Aliases: []string{}, LocalFlags: flags, InheritedFlags: []interfacesnapshot.Flag{}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func flagMigrationManifestJSON(state string) string {
|
||||
return strings.Replace(`{
|
||||
"version": 1,
|
||||
"migrations": [{
|
||||
"command": "dws chat send",
|
||||
"legacy": {
|
||||
"name": "legacy-id",
|
||||
"before": {"present": true, "type": "string", "required": true, "shorthand": "l", "no_opt": "auto", "scope": "local"},
|
||||
"after": {"present": true, "type": "string", "hidden": true, "shorthand": "l", "no_opt": "auto", "scope": "local", "alias_of": "message-id"}
|
||||
},
|
||||
"canonical": {
|
||||
"name": "message-id",
|
||||
"before": {"present": false},
|
||||
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
|
||||
},
|
||||
"state": "STATE",
|
||||
"reason": "reviewed exact migration"
|
||||
}]
|
||||
}`, "STATE", state, 1)
|
||||
}
|
||||
|
||||
func commandMigrationManifestJSON(state string) string {
|
||||
return strings.Replace(`{
|
||||
"version": 1,
|
||||
"migrations": [{
|
||||
"kind": "command_move",
|
||||
"legacy": {
|
||||
"command": "dws chat message old",
|
||||
"before": {"present": true, "runnable": true},
|
||||
"after": {"present": true, "runnable": true, "hidden": true}
|
||||
},
|
||||
"replacement": {
|
||||
"command": "dws chat topic new",
|
||||
"before": {"present": false},
|
||||
"after": {"present": true, "runnable": true}
|
||||
},
|
||||
"schema": {
|
||||
"product_id": "chat",
|
||||
"source_tool_id": "chat.move",
|
||||
"replacement_tool_id": "chat.move",
|
||||
"parameters": []
|
||||
},
|
||||
"state": "STATE",
|
||||
"reason": "reviewed command migration"
|
||||
}]
|
||||
}`, "STATE", state, 1)
|
||||
}
|
||||
|
||||
func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name && flag.Type == flagType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+1
-67
@@ -15,76 +15,10 @@ package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/clitrack"
|
||||
)
|
||||
|
||||
var (
|
||||
appExecute = app.ExecuteWithTelemetry
|
||||
resolveTelemetryIdentity = app.ResolveTelemetryIdentity
|
||||
trackRun = func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
clitrack.New(cfg).Run(execute, exitCode)
|
||||
}
|
||||
)
|
||||
|
||||
// trackedExitError tells clitrack that the command failed without asking it to
|
||||
// print the error a second time. The already-rendered message is published via
|
||||
// ExtraFields c5, while app.Execute remains the sole owner of presentation.
|
||||
type trackedExitError struct{}
|
||||
|
||||
func (trackedExitError) Error() string { return "" }
|
||||
|
||||
func trackerConfig(identity app.TelemetryIdentity, commandPath, errorMessage *string) clitrack.Config {
|
||||
return clitrack.Config{
|
||||
PID: "wcCRwZ",
|
||||
App: "dws",
|
||||
Version: app.RawVersion(),
|
||||
UID: identity.UserID,
|
||||
Username: identity.UserName,
|
||||
NoCommandLine: true,
|
||||
NoCwd: true,
|
||||
NoAutomaticDimensions: true,
|
||||
ExtraFields: func() map[string]string {
|
||||
fields := map[string]string{"c9": *commandPath}
|
||||
if identity.CorpID != "" {
|
||||
fields["c10"] = identity.CorpID
|
||||
}
|
||||
if *errorMessage != "" {
|
||||
fields["c5"] = *errorMessage
|
||||
}
|
||||
return fields
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func telemetryOptedOut() bool {
|
||||
return strings.TrimSpace(os.Getenv("DO_NOT_TRACK")) != ""
|
||||
}
|
||||
|
||||
func main() {
|
||||
optedOut := telemetryOptedOut()
|
||||
identity := app.TelemetryIdentity{}
|
||||
if !optedOut {
|
||||
identity = resolveTelemetryIdentity(os.Args[1:])
|
||||
}
|
||||
exitCode := 0
|
||||
commandPath := "dws"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(identity, &commandPath, &errorMessage)
|
||||
if optedOut {
|
||||
cfg.PID = ""
|
||||
}
|
||||
trackRun(
|
||||
cfg,
|
||||
func() error {
|
||||
exitCode, commandPath, errorMessage = appExecute()
|
||||
if exitCode != 0 {
|
||||
return trackedExitError{}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
func(error) int { return exitCode },
|
||||
)
|
||||
os.Exit(app.Execute())
|
||||
}
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/clitrack"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMainRunsThroughCLITracker(t *testing.T) {
|
||||
for _, wantCode := range []int{0, 1, 3, 5} {
|
||||
t.Run(fmt.Sprintf("exit_%d", wantCode), func(t *testing.T) {
|
||||
t.Setenv("DO_NOT_TRACK", "")
|
||||
wantError := ""
|
||||
if wantCode != 0 {
|
||||
wantError = "synthetic failure"
|
||||
}
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "sheet", "read", "--profile", "corp-a"})
|
||||
testseam.Swap(t, &resolveTelemetryIdentity, func(args []string) app.TelemetryIdentity {
|
||||
if strings.Join(args, " ") != "sheet read --profile corp-a" {
|
||||
t.Fatalf("telemetry identity args = %#v", args)
|
||||
}
|
||||
return app.TelemetryIdentity{UserID: "user-1", UserName: "Alice", CorpID: "corp-1"}
|
||||
})
|
||||
testseam.Swap(t, &appExecute, func() (int, string, string) { return wantCode, "sheet read", wantError })
|
||||
called := false
|
||||
testseam.Swap(t, &trackRun, func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
called = true
|
||||
if cfg.PID != "wcCRwZ" || cfg.App != "dws" {
|
||||
t.Fatalf("tracker identity = PID %q App %q", cfg.PID, cfg.App)
|
||||
}
|
||||
if cfg.Version != app.RawVersion() {
|
||||
t.Fatalf("tracker Version = %q, want %q", cfg.Version, app.RawVersion())
|
||||
}
|
||||
if !cfg.NoCommandLine || !cfg.NoCwd || !cfg.NoAutomaticDimensions || cfg.CaptureOutput {
|
||||
t.Fatalf("tracker privacy config = NoCommandLine %v NoCwd %v NoAutomaticDimensions %v CaptureOutput %v", cfg.NoCommandLine, cfg.NoCwd, cfg.NoAutomaticDimensions, cfg.CaptureOutput)
|
||||
}
|
||||
if cfg.Env != "" || cfg.EventID != "" || cfg.Endpoint != "" || cfg.FlushTimeout != 0 || cfg.OutputMaxLen != 0 {
|
||||
t.Fatalf("tracker SDK defaults were overridden: %#v", cfg)
|
||||
}
|
||||
if cfg.UID != "user-1" || cfg.Username != "Alice" || cfg.UserType != "" {
|
||||
t.Fatalf("tracker user identity = UID %q Username %q UserType %q", cfg.UID, cfg.Username, cfg.UserType)
|
||||
}
|
||||
|
||||
err := execute()
|
||||
if wantCode == 0 && err != nil {
|
||||
t.Fatalf("successful tracked execute error = %v", err)
|
||||
}
|
||||
if wantCode != 0 && (err == nil || err.Error() != "") {
|
||||
t.Fatalf("failed tracked execute error = %#v, want empty sentinel", err)
|
||||
}
|
||||
if gotCode := exitCode(err); gotCode != wantCode {
|
||||
t.Fatalf("tracked exit code = %d, want %d", gotCode, wantCode)
|
||||
}
|
||||
fields := cfg.ExtraFields()
|
||||
if fields["c9"] != "sheet read" || fields["c10"] != "corp-1" || fields["c5"] != wantError {
|
||||
t.Fatalf("tracker extra fields = %#v, want command path, corp ID, and error %q", fields, wantError)
|
||||
}
|
||||
if (wantError == "" && len(fields) != 2) || (wantError != "" && len(fields) != 3) {
|
||||
t.Fatalf("tracker extra field count = %d for error %q", len(fields), wantError)
|
||||
}
|
||||
})
|
||||
|
||||
main()
|
||||
if !called {
|
||||
t.Fatalf("trackRun was not called for exit code %d", wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTrackerConfigOmitsEmptyOrganization(t *testing.T) {
|
||||
commandPath := "version"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(app.TelemetryIdentity{}, &commandPath, &errorMessage)
|
||||
if cfg.UID != "" {
|
||||
t.Fatalf("empty identity UID = %q", cfg.UID)
|
||||
}
|
||||
if cfg.Username != "" {
|
||||
t.Fatalf("empty identity Username = %q", cfg.Username)
|
||||
}
|
||||
if fields := cfg.ExtraFields(); len(fields) != 1 || fields["c9"] != "version" {
|
||||
t.Fatalf("empty organization fields = %#v", fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDefaultTrackRunNoopTracker(t *testing.T) {
|
||||
called := false
|
||||
trackRun(clitrack.Config{}, func() error {
|
||||
called = true
|
||||
return nil
|
||||
}, nil)
|
||||
if !called {
|
||||
t.Fatal("default tracker did not execute callback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMainRespectsDoNotTrack(t *testing.T) {
|
||||
t.Setenv("DO_NOT_TRACK", "1")
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "version"})
|
||||
testseam.Swap(t, &resolveTelemetryIdentity, func([]string) app.TelemetryIdentity {
|
||||
t.Fatal("DO_NOT_TRACK must skip telemetry identity reads")
|
||||
return app.TelemetryIdentity{}
|
||||
})
|
||||
testseam.Swap(t, &appExecute, func() (int, string, string) { return 0, "version", "" })
|
||||
testseam.Swap(t, &trackRun, func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
if cfg.PID != "" || cfg.UID != "" || cfg.Username != "" {
|
||||
t.Fatalf("opted-out tracker config = %#v", cfg)
|
||||
}
|
||||
if err := execute(); err != nil {
|
||||
t.Fatalf("opted-out execution failed: %v", err)
|
||||
}
|
||||
if code := exitCode(nil); code != 0 {
|
||||
t.Fatalf("opted-out exit code = %d, want 0", code)
|
||||
}
|
||||
})
|
||||
|
||||
main()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTrackerPayloadUsesReviewedFieldWhitelist(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "sheet", "read", "--access-token", "must-not-leak"}
|
||||
t.Setenv("SHELL", "/bin/zsh")
|
||||
t.Setenv("TERM_SESSION_ID", "stable-session")
|
||||
t.Setenv("TMUX_PANE", "%42")
|
||||
t.Setenv("LANG", "zh_CN.UTF-8")
|
||||
t.Setenv("LC_ALL", "zh_CN.UTF-8")
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
requestBody := make(chan []byte, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
requestBody <- body
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
commandPath := "sheet read"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(app.TelemetryIdentity{UserID: "user-1", UserName: "Alice", CorpID: "corp-1"}, &commandPath, &errorMessage)
|
||||
cfg.Endpoint = server.URL
|
||||
cfg.FlushTimeout = time.Second
|
||||
clitrack.New(cfg).Run(func() error { return nil }, nil)
|
||||
|
||||
var body []byte
|
||||
select {
|
||||
case body = <-requestBody:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for telemetry request")
|
||||
}
|
||||
var envelope map[string]string
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
t.Fatalf("decode telemetry request %q: %v", body, err)
|
||||
}
|
||||
decoded, err := url.QueryUnescape(envelope["gokey"])
|
||||
if err != nil {
|
||||
t.Fatalf("decode gokey: %v", err)
|
||||
}
|
||||
globalFields, err := url.ParseQuery(decoded)
|
||||
if err != nil {
|
||||
t.Fatalf("parse global telemetry fields: %v", err)
|
||||
}
|
||||
eventFields, err := url.ParseQuery(globalFields.Get("msg"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse event telemetry fields: %v", err)
|
||||
}
|
||||
|
||||
assertTelemetryKeys(t, globalFields, []string{"app_name", "app_version", "env", "msg", "pid", "platform", "uid", "username", "version"})
|
||||
assertTelemetryKeys(t, eventFields, []string{"c1", "c10", "c3", "c4", "c9", "p1", "p4", "ts", "type"})
|
||||
for key, want := range map[string]string{
|
||||
"app_name": "dws", "app_version": app.RawVersion(), "env": "prod", "pid": "wcCRwZ",
|
||||
"platform": "cli", "uid": "user-1", "username": "Alice", "version": app.RawVersion(),
|
||||
} {
|
||||
if got := globalFields.Get(key); got != want {
|
||||
t.Fatalf("global telemetry field %s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"type": "event", "p1": "cli.exec", "p4": "SYS", "c1": "dws", "c3": "0", "c9": "sheet read", "c10": "corp-1",
|
||||
} {
|
||||
if got := eventFields.Get(key); got != want {
|
||||
t.Fatalf("event telemetry field %s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"device_id", "ext", "os", "os_version", "pv_id", "sdk_version", "sid", "timezone_offset"} {
|
||||
if globalFields.Has(key) {
|
||||
t.Fatalf("global telemetry leaked %s: %q", key, decoded)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"c2", "c5", "c6", "c7", "c8"} {
|
||||
if eventFields.Has(key) {
|
||||
t.Fatalf("event telemetry leaked %s: %q", key, globalFields.Get("msg"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertTelemetryKeys(t *testing.T, fields url.Values, want []string) {
|
||||
t.Helper()
|
||||
got := make([]string, 0, len(fields))
|
||||
for key := range fields {
|
||||
got = append(got, key)
|
||||
}
|
||||
sort.Strings(got)
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("telemetry keys = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user