Compare commits
347
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2f33dd9c4 | ||
|
|
0ebdcb455d | ||
|
|
29a0dde9d4 | ||
|
|
6096227511 | ||
|
|
aa265acd50 | ||
|
|
7210e5677c | ||
|
|
1c90623e31 | ||
|
|
e7236e27d4 | ||
|
|
e73ebab2db | ||
|
|
1b6a592387 | ||
|
|
ce7f66ff8e | ||
|
|
e58f255476 | ||
|
|
79f4f66d34 | ||
|
|
66061bb0b0 | ||
|
|
1d14258121 | ||
|
|
cbbc9a9f90 | ||
|
|
723d43f660 | ||
|
|
12d60d264d | ||
|
|
7bb2019dfe | ||
|
|
9e692ce0bf | ||
|
|
e2b390217a | ||
|
|
09ecaaa7a1 | ||
|
|
95ecc99c41 | ||
|
|
6df6b3f8dd | ||
|
|
227861a71d | ||
|
|
20a01aaeff | ||
|
|
7844b59a8c | ||
|
|
2a875eee1d | ||
|
|
c75eda1a2b | ||
|
|
662352202c | ||
|
|
fa2d54fd42 | ||
|
|
c5d58ec49d | ||
|
|
adbc9aac3e | ||
|
|
82a87bf9ce | ||
|
|
0bfb1ff1de | ||
|
|
426ad50927 | ||
|
|
b57cecb62d | ||
|
|
8dc52d052c | ||
|
|
f8e3f0b12a | ||
|
|
aab67202a0 | ||
|
|
2e60b8e121 | ||
|
|
6147021ae8 | ||
|
|
f84fc1d684 | ||
|
|
ae31d371de | ||
|
|
c20a53cd8a | ||
|
|
2dc52afd60 | ||
|
|
21bca3025f | ||
|
|
874b5b091a | ||
|
|
1bb56364c2 | ||
|
|
cd93b7fdc2 | ||
|
|
758b0f875e | ||
|
|
fdb5fc19f5 | ||
|
|
583d31141d | ||
|
|
19be571574 | ||
|
|
38360969e7 | ||
|
|
f9277ae1d3 | ||
|
|
232d9dee6e | ||
|
|
c2528f1fc8 | ||
|
|
1b22b79fa9 | ||
|
|
f3e98d55e8 | ||
|
|
b5a287ae71 | ||
|
|
da6f867dfa | ||
|
|
82dc2b5e5e | ||
|
|
9265fd4cb8 | ||
|
|
e324ef9d4a | ||
|
|
fb1847d62e | ||
|
|
331681e82b | ||
|
|
1633888290 | ||
|
|
206f33ae1c | ||
|
|
9259477372 | ||
|
|
137151b38c | ||
|
|
081220f15e | ||
|
|
b8216380de | ||
|
|
83bc213b7f | ||
|
|
804b9a3142 | ||
|
|
cfdb0d0556 | ||
|
|
d8686122ab | ||
|
|
222a0230ae | ||
|
|
f7befc7943 | ||
|
|
1dc924af1b | ||
|
|
0e8d6e00cf | ||
|
|
0028ed1570 | ||
|
|
8b5d9a59b0 | ||
|
|
01d663f597 | ||
|
|
2bf314c625 | ||
|
|
8c98d1abe4 | ||
|
|
5e4a65513b | ||
|
|
4ae0e0ffc3 | ||
|
|
1c9a977d08 | ||
|
|
78fabec4bb | ||
|
|
6d6404993a | ||
|
|
008d50bb3d | ||
|
|
f871689960 | ||
|
|
b15a21de93 | ||
|
|
58e8e35948 | ||
|
|
a8b0d8895b | ||
|
|
546c2d2eb2 | ||
|
|
5bd0ea7c53 | ||
|
|
4833b39071 | ||
|
|
8a4e49dbf2 | ||
|
|
7c924c54ca | ||
|
|
59d0b6dd75 | ||
|
|
74f7bbc980 | ||
|
|
2b7d5a2c5f | ||
|
|
fcfead71cb | ||
|
|
0beb1c6b0c | ||
|
|
3b38d4c8da | ||
|
|
d68e340a5b | ||
|
|
98799effba | ||
|
|
9239f9070a | ||
|
|
202c5ce697 | ||
|
|
8ab2ac5e7c | ||
|
|
b85a342e9f | ||
|
|
ad72cf4b3d | ||
|
|
89154b3952 | ||
|
|
b01febf52e | ||
|
|
74b7690cbb | ||
|
|
8312c4f30e | ||
|
|
35c6fd95e1 | ||
|
|
564ff8563f | ||
|
|
c7510cd1a1 | ||
|
|
1c3477c087 | ||
|
|
93d450a9bf | ||
|
|
cf39768095 | ||
|
|
c096258b0f | ||
|
|
5ba8ac6775 | ||
|
|
66fee5ef5f | ||
|
|
d9b9c5c7da | ||
|
|
684411e54e | ||
|
|
de5ba029d4 | ||
|
|
15f139e32d | ||
|
|
768c1ce494 | ||
|
|
966fd60e2f | ||
|
|
7825c3c7e0 | ||
|
|
10d44615d1 | ||
|
|
1e88612e43 | ||
|
|
5934ccac7f | ||
|
|
d9365f3fff | ||
|
|
15d93698bd | ||
|
|
7e69a3d6fe | ||
|
|
94b4958038 | ||
|
|
97a99ba04f | ||
|
|
5e1e5cbb84 | ||
|
|
e04e886c50 | ||
|
|
1acde9b766 | ||
|
|
852efe56aa | ||
|
|
6c287bcb4d | ||
|
|
df24d53886 | ||
|
|
49cecabb12 | ||
|
|
09993ad82c | ||
|
|
0efaf6c82f | ||
|
|
26002637f4 | ||
|
|
f7229091ae | ||
|
|
77aa813467 | ||
|
|
1f595571c0 | ||
|
|
cd90d1c322 | ||
|
|
49ab53ea0d | ||
|
|
78433198fb | ||
|
|
4863152a4a | ||
|
|
2057fec3b0 | ||
|
|
1308d08862 | ||
|
|
8b56e9bc9e | ||
|
|
87e141f2de | ||
|
|
9b521f0392 | ||
|
|
4dcd528bc1 | ||
|
|
0bbb3a9d32 | ||
|
|
0ebd840ba9 | ||
|
|
9d356cd664 | ||
|
|
b00f43ee06 | ||
|
|
23167ef974 | ||
|
|
8b003aef16 | ||
|
|
11934eed05 | ||
|
|
d552c59d11 | ||
|
|
23e1085a37 | ||
|
|
a352615e77 | ||
|
|
d210da501a | ||
|
|
6288199a93 | ||
|
|
6d9781fe15 | ||
|
|
5b34ed1a7e | ||
|
|
3d9a469347 | ||
|
|
7640ba7614 | ||
|
|
c790fe3c3b | ||
|
|
4886bdb3f5 | ||
|
|
4aef07ccd3 | ||
|
|
e0c49377d6 | ||
|
|
dc37dd2c34 | ||
|
|
02aad9020a | ||
|
|
d59d1091dc | ||
|
|
60d6bfeec4 | ||
|
|
bf89acb3d2 | ||
|
|
35d6f47bd6 | ||
|
|
d24b71614a | ||
|
|
765b961f4d | ||
|
|
9ab4bd10a5 | ||
|
|
14c5bed4fc | ||
|
|
c1bd6dcf64 | ||
|
|
1c8b83ec2f | ||
|
|
bb6f470df5 | ||
|
|
01af71a5ae | ||
|
|
d4ff8a5f4f | ||
|
|
47e3c2b3c5 | ||
|
|
e8ecff586a | ||
|
|
11a9ad5d49 | ||
|
|
23940e4752 | ||
|
|
8cb0f64477 | ||
|
|
bbaf033618 | ||
|
|
57cca7ef71 | ||
|
|
2d38beb7be | ||
|
|
4372a8c5ba | ||
|
|
422dc0fde3 | ||
|
|
3d59411a1a | ||
|
|
fe66ac18a4 | ||
|
|
bda408d966 | ||
|
|
33631502c9 | ||
|
|
f2a608d146 | ||
|
|
378b9f67a2 | ||
|
|
43ba466783 | ||
|
|
5c9f738012 | ||
|
|
56c5fb35b8 | ||
|
|
b19c52f61b | ||
|
|
c0641dbf64 | ||
|
|
3b5cb3b0cb | ||
|
|
fd2ed2174f | ||
|
|
5bbaa304e3 | ||
|
|
db938778af | ||
|
|
1155b9b5c0 | ||
|
|
8fa93ef030 | ||
|
|
f4ad1a15f5 | ||
|
|
d2cbd928e2 | ||
|
|
2346dfba4e | ||
|
|
f6ad2fa01a | ||
|
|
7bc56f3dea | ||
|
|
03001eb4e0 | ||
|
|
91974ce981 | ||
|
|
c6417e3527 | ||
|
|
161687ae4c | ||
|
|
4da5a07d1d | ||
|
|
2cb83d388b | ||
|
|
ba9c0f624e | ||
|
|
ff65f80c98 | ||
|
|
42240f5e9e | ||
|
|
e6b06b561d | ||
|
|
11cbc30a10 | ||
|
|
60a474f30c | ||
|
|
6e8fec5684 | ||
|
|
470aa42d7b | ||
|
|
a74d96bb96 | ||
|
|
9798a60728 | ||
|
|
e028d443d5 | ||
|
|
74859b966b | ||
|
|
76a5559ca2 | ||
|
|
c4a1018213 | ||
|
|
62d72ad84c | ||
|
|
e6fe475aea | ||
|
|
e95ac52ff4 | ||
|
|
61f8140f91 | ||
|
|
09c0cd7849 | ||
|
|
5a368a9ab8 | ||
|
|
fbcd8887ee | ||
|
|
c0838e7e41 | ||
|
|
9c6ab99bf1 | ||
|
|
87ab311764 | ||
|
|
7f4318a10d | ||
|
|
5232f632c8 | ||
|
|
615a775fdf | ||
|
|
b10da77e09 | ||
|
|
cefc5c005c | ||
|
|
15c075e6a6 | ||
|
|
f6d1e685e0 | ||
|
|
81108e150b | ||
|
|
dad9aefefa | ||
|
|
71d49cb12b | ||
|
|
f7e2efaaa2 | ||
|
|
557208e16b | ||
|
|
53401dbb0c | ||
|
|
6c52ac37dd | ||
|
|
95a17a3ffc | ||
|
|
3318741508 | ||
|
|
3d7ab2690c | ||
|
|
17eefcd24b | ||
|
|
6f62ce7997 | ||
|
|
2228a32d1a | ||
|
|
a6f79e951b | ||
|
|
096dfd48f0 | ||
|
|
3922970bfc | ||
|
|
9b0441ca56 | ||
|
|
2ab0edd5c6 | ||
|
|
4b3272bcd4 | ||
|
|
b6eaf3c5af | ||
|
|
80d5d24637 | ||
|
|
e40397e239 | ||
|
|
15bc7fdc3f | ||
|
|
da049be58d | ||
|
|
6ec64e8a03 | ||
|
|
bca56cbba6 | ||
|
|
103b05413e | ||
|
|
bb48aa0cc8 | ||
|
|
6ffb4bcb93 | ||
|
|
169bbe88c0 | ||
|
|
08595594d7 | ||
|
|
30782020ad | ||
|
|
540bbac35b | ||
|
|
c4d5595ca9 | ||
|
|
86d1eb8030 | ||
|
|
ab529e5ee5 | ||
|
|
15cb1f4311 | ||
|
|
97ca00868f | ||
|
|
df8885c350 | ||
|
|
d87cdef00b | ||
|
|
4e27a3a84a | ||
|
|
8685464c53 | ||
|
|
26b5939f9f | ||
|
|
95bcace6bd | ||
|
|
ce57cdf260 | ||
|
|
17741851f5 | ||
|
|
999e7a7b9d | ||
|
|
d10446bea7 | ||
|
|
94cee4388e | ||
|
|
3ec138ba99 | ||
|
|
dcc7e72ec1 | ||
|
|
0ed05a2c5d | ||
|
|
ae1edefee6 | ||
|
|
6dbc7ed82b | ||
|
|
f419c0f96d | ||
|
|
17101a8901 | ||
|
|
66aa00fb50 | ||
|
|
9d6e151a6f | ||
|
|
0df41d3eff | ||
|
|
2a1ed8cc7a | ||
|
|
c5951a10ff | ||
|
|
3dbd29ab50 | ||
|
|
1b50c7a5b4 | ||
|
|
cbd70d1b88 | ||
|
|
0ae8949d40 | ||
|
|
0975d970d1 | ||
|
|
7808673431 | ||
|
|
50ed921ca1 | ||
|
|
b34c29ec35 | ||
|
|
97e5ded043 | ||
|
|
7ceeafbae8 | ||
|
|
54b4a24a14 | ||
|
|
e1bfb343f4 | ||
|
|
7da423bf3c | ||
|
|
fa83ee579c | ||
|
|
25c694aa2a | ||
|
|
e064d394ba | ||
|
|
5a001f33b6 |
+3
-1
@@ -25,7 +25,9 @@ category: Added
|
||||
|
||||
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
|
||||
将未归档 fragments 汇总为唯一的版本章节,并移动到
|
||||
`.changes/released/<version>/`。因此 release-seal PR 是唯一会修改
|
||||
`.changes/released/<version>/`。beta 发布后若有新 fragments 合入并直接准备 stable,
|
||||
stable 封板会把它们追加到明确的 post-beta 小节,并归档到正式版本目录;没有新
|
||||
fragments 时仍只生成原有 beta 晋级模板。因此 release-seal PR 是唯一会修改
|
||||
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
|
||||
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
|
||||
删除或重写已归档文件。
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
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`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
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.
|
||||
+53
-10
@@ -534,7 +534,8 @@ jobs:
|
||||
# where the Schema partition alone owned most of the wall clock. The
|
||||
# app-<partition> names are pinned to the helper's partition set by
|
||||
# TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose
|
||||
# its job silently.
|
||||
# its job silently. The CrossPlatformCoverage-heavy C range is split again
|
||||
# to retain headroom on runners reclaimed near the five-minute mark.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -542,7 +543,11 @@ jobs:
|
||||
shard:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c
|
||||
- app-c-a-l
|
||||
- app-c-m-o
|
||||
- app-c-p-r
|
||||
- app-c-s-z
|
||||
- app-c-other
|
||||
- app-d-r
|
||||
- app-s-z-example-fuzz
|
||||
- generators
|
||||
@@ -617,7 +622,13 @@ jobs:
|
||||
|
||||
- name: Install archive tooling
|
||||
if: ${{ matrix.shard == 'release-scripts' && steps.select.outputs.affected == 'true' }}
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Test shard with Race Detection
|
||||
if: ${{ steps.select.outputs.affected == 'true' }}
|
||||
@@ -672,7 +683,8 @@ jobs:
|
||||
# partitions run concurrently and each releases its framework registries
|
||||
# when the process exits; cli/smoke need headroom beyond go test -timeout for
|
||||
# setup + assembly. The app-<partition> names are pinned to the helper's
|
||||
# partition set by TestCIAppRacePartitionMatrixMatchesHelper.
|
||||
# partition set by TestCIAppRacePartitionMatrixMatchesHelper. The
|
||||
# CrossPlatformCoverage-heavy C range is split again for runner headroom.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -680,7 +692,11 @@ jobs:
|
||||
shard:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c
|
||||
- app-c-a-l
|
||||
- app-c-m-o
|
||||
- app-c-p-r
|
||||
- app-c-s-z
|
||||
- app-c-other
|
||||
- app-d-r
|
||||
- app-s-z-example-fuzz
|
||||
- generators
|
||||
@@ -757,7 +773,13 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Test release scripts
|
||||
shell: bash
|
||||
@@ -1135,7 +1157,13 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
@@ -1186,7 +1214,13 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Run policy and shortcut coverage
|
||||
run: |
|
||||
@@ -1267,7 +1301,13 @@ jobs:
|
||||
|
||||
- name: Install archive tooling
|
||||
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Run baseline unit tests with coverage
|
||||
if: steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
@@ -1559,7 +1599,10 @@ jobs:
|
||||
name: Policy
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Full policy regenerates and validates the runtime Schema several times.
|
||||
# Keep job-level headroom for large reviewed command-surface additions;
|
||||
# individual policy gates retain their own fail-closed checks.
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -14,9 +14,12 @@ jobs:
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const webhook = process.env.DINGTALK_WEBHOOK;
|
||||
if (!webhook) {
|
||||
console.log('⚠️ DINGTALK_WEBHOOK not set, skipping notification');
|
||||
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');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,12 +42,15 @@ jobs:
|
||||
}
|
||||
};
|
||||
|
||||
await fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message)
|
||||
});
|
||||
await Promise.all(webhooks.map(webhook =>
|
||||
fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message)
|
||||
})
|
||||
));
|
||||
|
||||
console.log('✅ DingTalk notification sent');
|
||||
console.log(`✅ DingTalk notification sent to ${webhooks.length} webhook(s)`);
|
||||
env:
|
||||
DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }}
|
||||
DINGTALK_WEBHOOK_SECONDARY: ${{ secrets.DINGTALK_WEBHOOK_SECONDARY }}
|
||||
|
||||
@@ -2698,9 +2698,11 @@ jobs:
|
||||
;;
|
||||
compatibility)
|
||||
test -n "$PREVIOUS_STABLE"
|
||||
./scripts/policy/check-command-compatibility.sh \
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh" \
|
||||
--repo-root "$GITHUB_WORKSPACE" \
|
||||
--base-ref HEAD \
|
||||
--stable-ref "$PREVIOUS_STABLE"
|
||||
--stable-ref "$PREVIOUS_STABLE" \
|
||||
--candidate-ref HEAD
|
||||
;;
|
||||
e2e)
|
||||
bash scripts/dev/test-multi-profile-e2e.sh
|
||||
|
||||
+128
@@ -6,6 +6,134 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.60-beta.2] - 2026-08-24
|
||||
|
||||
### 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.
|
||||
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
|
||||
- **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`.
|
||||
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
|
||||
### 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.
|
||||
|
||||
- **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.
|
||||
|
||||
### 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.0.60-beta.1] - 2026-08-21
|
||||
|
||||
### 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.
|
||||
|
||||
- **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.
|
||||
|
||||
### 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.
|
||||
|
||||
- **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.0.59] - 2026-08-20
|
||||
|
||||
This release promotes the sealed `v1.0.59-beta.5` contents to stable.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat personal emotions** — adds commands to list, send, and favorite the current user's personal favorite emotions.
|
||||
|
||||
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage.
|
||||
|
||||
- **Shortcut functional workflows** — fixes Drive preview accuracy, AITable write verification and deletion accounting, Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
|
||||
|
||||
## [1.0.59-beta.5] - 2026-08-20
|
||||
|
||||
### 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.
|
||||
|
||||
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
|
||||
|
||||
### 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.0.59-beta.4] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
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.59-beta.3"
|
||||
version "1.0.60-beta.1"
|
||||
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.59-beta.3/dws-darwin-arm64.tar.gz"
|
||||
sha256 "9c99adcefd9104368eb443f0a1b4af8e7aceaa1ffdd4462e486854c1692bb6ce"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-arm64.tar.gz"
|
||||
sha256 "8ef11c79b5c86ec275dd82334232e7582f9e2ba99a66307d7681e42e8f53767b"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-amd64.tar.gz"
|
||||
sha256 "f5cc8efb1f982d68ae549190fd683292359c2ab542b532fa52bb35e6b5c049af"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-amd64.tar.gz"
|
||||
sha256 "67612f1dac735984b026c7f8a0dc057beec4cdd029f0a97798bf90aa923eb2d3"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-arm64.tar.gz"
|
||||
sha256 "7a4efd04b417ce8013b1e431274b396179958da244164f59974358ba327ff093"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-arm64.tar.gz"
|
||||
sha256 "67a8d4f4e0a7d22a9cc53cb91d8c97ecd1152665ce669f68560d86cec5987dd2"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-amd64.tar.gz"
|
||||
sha256 "90181e8f2e9010c1943a5773c3d45d7d3ac85d6bc93e18a9aaa7c69909e553d7"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-amd64.tar.gz"
|
||||
sha256 "a5fae548b495842779df4291cbcf06d8a2e5ddddf68a41cad1bab1e5c64a1d59"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-skills.zip"
|
||||
sha256 "e7028914a4a826af9b18ed4922d68fa8f279473817fed4f305465bc8a7aad363"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-skills.zip"
|
||||
sha256 "9fe12683139a626d32a801dd44158a698f142b61339282e0fc24d4e3a5e97e87"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCli < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.58"
|
||||
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.58/dws-darwin-arm64.tar.gz"
|
||||
sha256 "7d98599f90cae9d42b51ff2863efc87dbfb4a3176ff3c84fc2216110c0157a70"
|
||||
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.58/dws-darwin-amd64.tar.gz"
|
||||
sha256 "4c12e35e5bf7e0905812cd42dc94a5345068a2c16e306bb50b13c5c78b5cb95d"
|
||||
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.58/dws-linux-arm64.tar.gz"
|
||||
sha256 "5ef6bde24bc3db6a11a0f1d0b3343a048956b2cbcf6cd3409a037fb6ba425489"
|
||||
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.58/dws-linux-amd64.tar.gz"
|
||||
sha256 "3ccadcc6f070a39d2b2ba20429a4fcdc2f21639bf79f34361dc7d16f501bfda6"
|
||||
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.58/dws-skills.zip"
|
||||
sha256 "2626debc21c3daadfd155b4c167b2219b97e801398fe4441a8b48138960ab264"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-skills.zip"
|
||||
sha256 "7ce5c3ab6f6a367407f64971bc5ff96cfcdfade2c1a10d326144b17c7b25a57e"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 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 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
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -20,6 +20,7 @@ help:
|
||||
@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"
|
||||
@@ -62,6 +63,9 @@ 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
|
||||
|
||||
lint:
|
||||
@./scripts/dev/lint.sh
|
||||
|
||||
@@ -84,7 +88,7 @@ fmt:
|
||||
$(GO_SOURCE_LIST) > "$$go_files"; \
|
||||
xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files"
|
||||
|
||||
policy: test-auth-legacy-compat
|
||||
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
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
|
||||
> **钉钉 DWS CLI 已全面开放,欢迎使用**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
|
||||
>
|
||||
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
|
||||
|
||||
|
||||
+63
-5
@@ -13,13 +13,71 @@ if (!fs.existsSync(binaryPath)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = childProcess.spawnSync(binaryPath, process.argv.slice(2), {
|
||||
// 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), {
|
||||
stdio: "inherit",
|
||||
detached: isolateVendorProcessGroup,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(result.error.message);
|
||||
process.exit(1);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(result.status === null ? 1 : result.status);
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# CLI Help / Schema 兼容迁移治理
|
||||
|
||||
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。
|
||||
本文定义两种受控 flag 迁移:
|
||||
|
||||
1. `flag_rename`:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口;rename 必须保持原 flag 的 requiredness,optional 只能迁到 optional,required 只能迁到 required。
|
||||
2. `requiredness_change`:同一个公开 flag 从 optional 精确提升为 required;flag 的名称、类型、作用域、可见性、shorthand、`no_opt` 与 alias 关系必须保持不变。
|
||||
|
||||
两种原语都只放行清单精确登记的变化,不是通用 breaking-change 豁免,也不得在同一 command/flag 上叠加以绕过 rename 的 requiredness 保持规则。
|
||||
|
||||
同一套 base-owned lifecycle 也治理两类跨命令迁移:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;或把旧命令中的一个可选 flag 拆成新的专用命令。跨命令迁移只允许清单精确声明的 `command_became_hidden` / `flag_became_hidden` 及其 Schema 投影,不是通用 command-path breaking-change 豁免。
|
||||
|
||||
@@ -44,7 +49,9 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
scripts/policy/interface-migrations/approved-command-migrations-v1.json
|
||||
```
|
||||
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。历史未声明 `kind` 的记录按 `flag_rename` 解释;新增同名 requiredness 迁移必须显式写 `kind: requiredness_change` 和单一 `flag` before/after。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
|
||||
|
||||
首次引入一个旧 merge-base 不认识的新 `kind` 时,机制 PR 不得同时写入该 kind 的 pending 记录,因为旧的 base-owned 严格解析器会拒绝未知字段。必须先合入 parser、lifecycle、CLI/Schema adapter 与 hostile tests;待这些实现成为新的 merge-base authority 后,再用独立治理审批 PR 新增 pending,最后才由产品 PR 消费。
|
||||
|
||||
## 跨命令迁移原语
|
||||
|
||||
@@ -68,6 +75,17 @@ optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。
|
||||
`replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即
|
||||
`true` 或固定 `false` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。
|
||||
|
||||
如果 `command_move` 的参数 `from` 在更早 stable 中仍使用另一历史名称,Schema adapter
|
||||
只能把同一 legacy command 上、已经由 base-owned lifecycle 返回且
|
||||
`state=consumed` 的 flag rename 回执作为前驱边。例如
|
||||
`group → conversation-id` 与 `conversation-id → open-topic-id` 可以组合,但不能把
|
||||
candidate 自增的 pending 记录、其他命令的同名参数、参数概念词典或 CLI alias 当作证据。
|
||||
首次消费 pending command 回执时,merge-base 的 normalized Schema 必须真实发布中间参数,
|
||||
并逐跳验证参数签名和 constraints;command 回执合入为 consumed 后,中间 Schema 已从 main
|
||||
消失,此时保留的两份 consumed 回执可继续对 stable 做受限重放,直到 stable 也达到 after
|
||||
并让回执转为惰性记录或由独立 PR 清理。两种阶段都拒绝残留 predecessor/intermediate、字段漂移、环、分叉、
|
||||
target 碰撞或 primary path/tool identity 不唯一;positionals 不在该组合授权面内。
|
||||
|
||||
`replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot
|
||||
必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、
|
||||
值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create`
|
||||
@@ -86,22 +104,22 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
|
||||
|
||||
两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。
|
||||
|
||||
跨命令清单复用下文同一套 `pending → consumed → cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
|
||||
跨命令清单复用下文同一套 `pending → consumed → inert/cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
|
||||
|
||||
当前首批 pending 记录覆盖 `chat topic` 收口:`chat group create --thread` 拆到 `chat topic create`,以及 `chat message list-topic-replies` / `forward-topic` 迁到对应的 `chat topic` 命令。前一条完整登记 `name` / `type` / `users` 的同名承接,以及 `thread` → `convThreadEnabled=true` 的常量承接。产品 PR 消费这些记录时只能把三条 `state` 改为 `consumed`,不得改写其 before、after、Schema mapping、constant 或 reason。
|
||||
|
||||
## 两阶段迁移与回执清理
|
||||
|
||||
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
|
||||
rename 以 `(kind, command, legacy flag, canonical flag)` 为唯一精确键;requiredness change 以 `(kind, command, flag)` 为唯一精确键。二者经历同一生命周期:
|
||||
|
||||
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|
||||
|---|---|---|
|
||||
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
|
||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 |
|
||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | rename 的 legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`,canonical requiredness 保持不变;requiredness change 只把同名 flag 从 optional 提升为 required |
|
||||
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
|
||||
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
|
||||
| 4. 惰性保留或清理 | 当 merge-base 和 stable 都已经是 `after`,该记录不再提供任何授权;后续 PR 可以原样保留或删除 | 两份参考快照均精确匹配 `after`;保留时仍必须是不可改写的 `consumed`,接口偏离 `after` 继续失败 |
|
||||
|
||||
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
|
||||
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。stable 发布只会让已经追平的 `consumed` 回执变成无授权效果的审计记录,不会在没有代码变更时让后续业务 PR 失去合规性;清理仍可作为独立的账本压缩动作,但不再是下一个 PR 的强制前置条件。
|
||||
|
||||
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
|
||||
|
||||
@@ -146,6 +164,27 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
|
||||
|
||||
产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。
|
||||
|
||||
同名 flag requiredness 迁移的清单结构如下;示例不代表已经审批:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"migrations": [
|
||||
{
|
||||
"kind": "requiredness_change",
|
||||
"command": "dws report entry submit",
|
||||
"flag": {
|
||||
"name": "to-user-ids",
|
||||
"before": {"present": true, "type": "string", "scope": "local"},
|
||||
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Reject report submissions that have no visible recipient."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## `alias_of` 是框架来源的受评审关系证据
|
||||
|
||||
`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证:
|
||||
@@ -166,10 +205,11 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
|
||||
|
||||
## 豁免边界
|
||||
|
||||
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
|
||||
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下三类预期 finding:
|
||||
|
||||
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
|
||||
2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。
|
||||
3. `requiredness_change` 中同名 flag 从 optional 提升为 required 时产生的 `flag_became_required`。
|
||||
|
||||
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
|
||||
|
||||
@@ -177,6 +217,7 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
|
||||
- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移;
|
||||
- `alias_of` 缺失、指向变化或 alias chain;
|
||||
- 命令路径及任何无关的阻塞性接口变化;
|
||||
- requiredness change 同时发生的 rename、隐藏、类型、scope、shorthand、`no_opt` 或 alias 漂移;
|
||||
- 不精确、部分完成、超出记录范围的 surface 变化。
|
||||
|
||||
## Schema 投影边界
|
||||
@@ -204,6 +245,12 @@ adapter 先构造经过上述验证的历史 contract 副本,再调用原 Sche
|
||||
canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker
|
||||
验证 candidate 是否仍与该 canonical contract 兼容。
|
||||
|
||||
`requiredness_change` 的 Schema adapter 只把历史同名 parameter 的 `required` 与
|
||||
`cli_required` 提升到 candidate 的 `true` 值,并要求 candidate 两者都为 `true`。parameter
|
||||
不存在、tool/path 不匹配时不制造 Schema surface;type、property、interface type、default、
|
||||
format、enum、`required_when`、constraints、positionals 与 safety 等全部字段仍交给原 checker,
|
||||
任何不相干漂移继续阻塞。
|
||||
|
||||
## 本地验证
|
||||
|
||||
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
|
||||
|
||||
- **Products**: 13
|
||||
- **Total commands**: 160
|
||||
- **Total commands**: 163
|
||||
- **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
|
||||
|
||||
> Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
|
||||
@@ -33,7 +33,7 @@ Every command inherits these flags (documented here once, not repeated per comma
|
||||
- [`dws aitable` — AI Tables](#dws-aitable) · 41 commands
|
||||
- [`dws attendance` — Attendance](#dws-attendance) · 4 commands
|
||||
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
|
||||
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
|
||||
- [`dws chat` — Group Chat / IM](#dws-chat) · 26 commands
|
||||
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
|
||||
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
|
||||
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
|
||||
@@ -134,12 +134,15 @@ _Calendar events, participants, meeting rooms, and busy-status queries._
|
||||
|
||||
_Group chats, conversations, messages, and robot/webhook integrations._
|
||||
|
||||
**23 commands**
|
||||
**26 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws chat bot search` | Search robots (bots) created by the current user by keyword. | When the agent needs to resolve one of its own bots by name to a robot code before sending bot messages. |
|
||||
| `dws chat conversation-info` | Retrieve basic metadata for a conversation (single chat or group chat) by conversation ID. | When the agent needs context about a conversation (name, type, member count) before operating on it. |
|
||||
| `dws chat emotion favorite` | Add a media ID to the current user's personal favorite emotions. | When the agent needs to save an available mediaId as a reusable personal emotion, optionally preserving source message context. |
|
||||
| `dws chat emotion list` | List the current user's personal favorite emotions. | When the agent needs to inspect available personal emotions or resolve an emotionId/mediaId before sending. |
|
||||
| `dws chat emotion send` | Send a personal favorite emotion to a group or direct chat as the authenticated user. | When the agent needs to send a known personal emotion mediaId to exactly one group, userId, or openDingTalkId target. |
|
||||
| `dws chat group create` | Create a new internal group chat with a set of initial members. | When the agent needs to spin up a dedicated group for a new project, incident, or discussion thread. |
|
||||
| `dws chat group members` | List members of a group chat; can also be used against the current user to enumerate their groups' members. | When the agent needs the roster of a group before mentioning, removing, or auditing members. |
|
||||
| `dws chat group members add` | Add one or more users to an existing group chat. | When the agent expands a group to include additional participants. |
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
# AI 表格数据源指令使用指南
|
||||
|
||||
## 概述
|
||||
|
||||
dws 新增了 7 个 AI 表格数据源同步管理指令,用于将外部数据源(一期支持审批数据)接入 AI 表格,实现数据的自动同步。
|
||||
|
||||
所有指令均通过 `dws aitable +datasource-*` 前缀调用,操作对象是 AI 表格中的"数据源表"——一种由数据源同步创建的特殊数据表。
|
||||
|
||||
## 指令速览
|
||||
|
||||
| 指令 | 用途 | 读写 | 风险 |
|
||||
|------|------|------|------|
|
||||
| `+datasource-list-sources` | 列出数据源类型可用的来源信息(OA 返回 result/processCode、sourceType、sourceUrl) | 读 | low |
|
||||
| `+datasource-get-fields` | 获取数据源来源的可同步字段结构 | 读 | low |
|
||||
| `+datasource-create` | 创建数据源表并触发首次同步 | 写 | medium |
|
||||
| `+datasource-update` | 更新已有数据源表的同步配置 | 写 | medium |
|
||||
| `+datasource-sync` | 手动触发一次同步 | 写 | medium |
|
||||
| `+datasource-sync-status` | 查询同步任务状态 | 读 | low |
|
||||
| `+datasource-get-config` | 查看数据源表配置 | 读 | low |
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. **登录认证**:执行 `dws auth login` 确保已登录
|
||||
2. **获取 Base ID**:通过 `dws aitable +base-list` 或 `dws aitable +base-search --query "关键词"` 获取目标 AI 表格的 Base ID
|
||||
|
||||
---
|
||||
|
||||
## 1. 列出数据源可用来源
|
||||
|
||||
```
|
||||
dws aitable +datasource-list-sources [flags]
|
||||
```
|
||||
|
||||
列出指定数据源类型可用的来源信息。OA 审批类型返回当前 Base 可用的审批数据源条目(`sources` 数组,当前通常为单条),用于构造 `+datasource-create` / `+datasource-update` / `+datasource-get-fields` 的 `--source-config`。OA 场景下每条 source 的 `result` 字段是 JSON 字符串,需解析后得到 `approvals` 数组,再从中提取目标模板的 `processCode`、`name`、`iconUrl`、`url`。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 列出审批数据源来源,获取 result(JSON,解析后得到 approvals[].processCode)
|
||||
dws aitable +datasource-list-sources \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
返回 `sources` 数组,每个条目包含:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `result` | OA 审批场景为 JSON 字符串,解析后得到 `approvals` 数组;每个 approval 含 `processCode`、`name`、`iconUrl`、`url` |
|
||||
| `sourceType` | 数据源类型编号(OA 对应内部枚举值 2) |
|
||||
| `sourceUrl` | 数据源访问链接,可选 |
|
||||
|
||||
`result` 本身不是 `processCode`,需要解析出 `approvals` 数组,再取目标模板的 `processCode`、`name`、`iconUrl`、`url` 原样填入 `--source-config`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 获取数据源可同步字段
|
||||
|
||||
```
|
||||
dws aitable +datasource-get-fields [flags]
|
||||
```
|
||||
|
||||
获取指定数据源来源(如某个审批模板)的可同步字段列表,包括字段 ID、字段名称、字段类型和是否主键等信息。用于创建数据源前选择需要同步的字段。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
|
||||
| `--source-config` | string | 是 | 源配置 JSON 字符串,结构同 `+datasource-create` 的 `--source-config` |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 获取某审批模板的可同步字段
|
||||
dws aitable +datasource-get-fields \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
返回可同步字段列表,每个字段包含字段 ID、名称、类型和是否主键。字段 ID 可用于 `+datasource-create` / `+datasource-update` 的 `--field-ids` 参数。
|
||||
|
||||
---
|
||||
|
||||
## 3. 创建数据源表
|
||||
|
||||
```
|
||||
dws aitable +datasource-create [flags]
|
||||
```
|
||||
|
||||
为指定 AI 表格创建数据源同步配置,自动创建一张数据源表并触发首次全量同步。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID(通过 `+base-list` / `+base-search` 获取) |
|
||||
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
|
||||
| `--source-config` | string | 是 | 源配置 JSON 字符串(格式见下方) |
|
||||
| `--auto` | bool | 否 | 是否开启自动同步,默认 false;无论是否传入,CLI 都会把该字段下发给下游 |
|
||||
| `--auto-sync-setting` | string | 否 | 自动同步频率配置 JSON 字符串,仅在 `--auto=true` 时生效,格式见下方 |
|
||||
| `--field-ids` | stringSlice | 否 | 需要同步的字段 ID 列表,不传时同步全部字段 |
|
||||
|
||||
### source-config 格式(审批类)
|
||||
|
||||
审批数据源的 `--source-config` 是一个 JSON 对象字符串,包含以下字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `processCode` | string | 是 | 审批模板编码,对应 `+datasource-list-sources` 返回的 `result` |
|
||||
| `name` | string | 是 | 数据源展示名称,须从 `+datasource-list-sources` 结果原样透传 |
|
||||
| `iconUrl` | string | 是 | OA 审批图标 URL,须从 `+datasource-list-sources` 结果原样透传 |
|
||||
| `url` | string | 是 | OA 审批跳转链接,须从 `+datasource-list-sources` 结果原样透传 |
|
||||
| `dataType` | string | 是 | 数据时间范围类型:`time_range` / `start_time` / `recent_time` |
|
||||
| `recentDays` | string | 当 dataType=recent_time 时必填 | 近 N 天:`7d` / `30d` / `1y` |
|
||||
| `startDate` | string | 当 dataType=time_range 或 start_time 时必填 | 起始日期,格式 `yyyy-MM-dd` |
|
||||
| `endDate` | string | 当 dataType=time_range 时必填 | 结束日期,格式 `yyyy-MM-dd` |
|
||||
| `keepRemovedFields` | bool | 否 | 是否保留已删除字段,默认 false |
|
||||
|
||||
> 注:`splitParentTableField`、`enableDataSyncOaDetailList` 等字段为下游内部字段,无需传入,下游自动处理。
|
||||
|
||||
按 `dataType` 选择对应的时间参数组合:
|
||||
|
||||
| dataType | 需要的时间字段 | 说明 |
|
||||
|----------|----------------|------|
|
||||
| `recent_time` | `recentDays` | 同步近 N 天数据(7d/30d/1y) |
|
||||
| `start_time` | `startDate` | 同步从某日期至今的数据 |
|
||||
| `time_range` | `startDate` + `endDate` | 同步指定日期范围内的数据 |
|
||||
|
||||
### auto-sync-setting 格式
|
||||
|
||||
`--auto-sync-setting` 仅在 `--auto=true` 时生效,用于指定自动同步频率。不传时使用下游默认策略。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `syncType` | string | 是 | `hourly`(按小时间隔)/ `scheduled`(定时触发) |
|
||||
| `hourlyInterval` | int | hourly 时必填 | 正整数,小时间隔 |
|
||||
| `scheduleType` | string | scheduled 时必填 | `daily` / `weekly` / `monthly` |
|
||||
| `timeValue` | string | scheduled 时必填 | 触发时间,格式 `HH:mm` |
|
||||
| `selectedMonthDays` | int[] | monthly 时必填 | 每月几号触发,1-31 |
|
||||
| `selectedWeekdays` | int[] | weekly 时必填 | 每周哪几天触发,1=周一…7=周日 |
|
||||
| `skipNonWorkingDay` | bool | 否 | 是否跳过非工作日,默认 false |
|
||||
|
||||
示例:`{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 基本创建——同步近 30 天审批数据
|
||||
dws aitable +datasource-create \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
|
||||
# 指定日期范围创建并开启自动同步
|
||||
dws aitable +datasource-create \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' \
|
||||
--auto
|
||||
|
||||
# 指定同步字段(仅同步部分字段,field-ids 可通过 +datasource-get-fields 获取)
|
||||
dws aitable +datasource-create \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' \
|
||||
--field-ids fldAAA,fldBBB,fldCCC
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
创建成功后返回新建数据源表 ID 和同步任务 ID,后续操作需要用到这两个 ID。
|
||||
|
||||
---
|
||||
|
||||
## 4. 更新数据源配置
|
||||
|
||||
```
|
||||
dws aitable +datasource-update [flags]
|
||||
```
|
||||
|
||||
更新已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后会自动触发一次同步。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--table-id` | string | 是 | 已存在的数据源表 ID(由 `+datasource-create` 返回) |
|
||||
| `--source-config` | string | 否 | 新的源配置 JSON 字符串,不传时保持原有配置。结构同 `+datasource-create` |
|
||||
| `--auto` | bool | 否 | 是否开启自动同步;仅显式设置时下发给下游,省略时保持原有自动同步开关不变 |
|
||||
| `--auto-sync-setting` | string | 否 | 自动同步频率配置 JSON 字符串,仅在显式设置 `--auto=true` 时生效;省略时保持原频率配置 |
|
||||
| `--field-ids` | stringSlice | 否 | 需要同步的字段 ID 列表,不传时保持现有字段配置 |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 更换审批模板并调整时间范围
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
|
||||
# 开启自动同步
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--auto
|
||||
|
||||
# 更新同步字段范围
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--field-ids fldAAA,fldDDD
|
||||
```
|
||||
|
||||
> 注意:`--table-id` 指向的是数据源表(由 `+datasource-create` 创建),不是普通数据表。
|
||||
|
||||
---
|
||||
|
||||
## 5. 触发手动同步
|
||||
|
||||
```
|
||||
dws aitable +datasource-sync [flags]
|
||||
```
|
||||
|
||||
对已有数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--table-ids` | stringSlice | 是 | 待触发同步的数据源表 ID 列表(1-5 个) |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 同步单张表
|
||||
dws aitable +datasource-sync \
|
||||
--base-id BASE123 \
|
||||
--table-ids TBL1
|
||||
|
||||
# 批量同步多张表(逗号分隔,最多 5 个)
|
||||
dws aitable +datasource-sync \
|
||||
--base-id BASE123 \
|
||||
--table-ids TBL1,TBL2,TBL3
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
返回每个表的同步任务 ID,可通过 `+datasource-sync-status` 查询最终结果。
|
||||
|
||||
---
|
||||
|
||||
## 6. 查询同步状态
|
||||
|
||||
```
|
||||
dws aitable +datasource-sync-status [flags]
|
||||
```
|
||||
|
||||
按任务 ID 查询数据源表的同步任务状态。与 `+datasource-sync` / `+datasource-create` / `+datasource-update` 配对使用——这些指令触发同步后返回任务 ID,本指令通过任务 ID 查询最终结果。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--table-id` | string | 是 | 数据源表 ID |
|
||||
| `--task-ids` | stringSlice | 是 | 待查询的同步任务 ID 列表(1-5 个) |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 按任务 ID 查询(批量,最多 5 个)
|
||||
dws aitable +datasource-sync-status \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--task-ids TASK1,TASK2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 获取数据源配置
|
||||
|
||||
```
|
||||
dws aitable +datasource-get-config [flags]
|
||||
```
|
||||
|
||||
获取指定数据源表的同步配置信息,包括源配置、同步模式、自动同步开关和同步状态。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--table-id` | string | 是 | 数据源表 ID |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
dws aitable +datasource-get-config \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 典型工作流
|
||||
|
||||
### 场景一:从零接入审批数据
|
||||
|
||||
```bash
|
||||
# 0. 获取 Base ID
|
||||
dws aitable +base-search --query "我的项目表"
|
||||
|
||||
# 1. 列出可用审批数据源来源,解析 result JSON 获取 approvals[].processCode
|
||||
dws aitable +datasource-list-sources \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA
|
||||
# → 返回 sources[0].result 为 JSON 字符串,解析后取 approvals[0].processCode=PROC-XXXX
|
||||
|
||||
# 2. 查看可同步字段(可选,用于指定 field-ids)
|
||||
dws aitable +datasource-get-fields \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
|
||||
# 3. 创建数据源表(创建后自动触发首次同步)
|
||||
dws aitable +datasource-create \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
# → 返回 tableId=TBL456, taskId=TASK001
|
||||
|
||||
# 4. 查询首次同步是否完成
|
||||
dws aitable +datasource-sync-status \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--task-ids TASK001
|
||||
|
||||
# 5. 确认配置
|
||||
dws aitable +datasource-get-config \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456
|
||||
```
|
||||
|
||||
### 场景二:更换审批模板后重新同步
|
||||
|
||||
```bash
|
||||
# 1. 更新源配置(更新后自动触发一次同步)
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--source-config '{"processCode":"PROC-NEW","name":"新审批模板","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
|
||||
# 2. 查询同步状态(更新后会返回新的 taskId)
|
||||
dws aitable +datasource-sync-status \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--task-ids TASK002
|
||||
```
|
||||
|
||||
### 场景三:手动触发日常同步
|
||||
|
||||
```bash
|
||||
# 仅触发同步,不修改配置
|
||||
dws aitable +datasource-sync \
|
||||
--base-id BASE123 \
|
||||
--table-ids TBL456
|
||||
|
||||
# 查询结果(sync 会返回 taskId)
|
||||
dws aitable +datasource-sync-status \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--task-ids TASK001
|
||||
```
|
||||
|
||||
### 场景四:开启自动同步后确认
|
||||
|
||||
```bash
|
||||
# 1. 更新配置,开启自动同步
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--auto
|
||||
|
||||
# 2. 确认配置已更新
|
||||
dws aitable +datasource-get-config \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456
|
||||
# → 返回中应显示 auto=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 通用选项
|
||||
|
||||
以下全局选项可在所有指令中使用:
|
||||
|
||||
| 选项 | 说明 |
|
||||
|------|------|
|
||||
| `-f, --format` | 输出格式:json(默认)/ table / raw / pretty / ndjson / csv |
|
||||
| `--jq` | jq 表达式过滤输出(如 `.tableId` 或 `.status`) |
|
||||
| `--fields` | 筛选输出字段(逗号分隔) |
|
||||
| `--dry-run` | 预览操作内容,不实际执行 |
|
||||
| `--profile` | 指定组织或账号 |
|
||||
| `--timeout` | HTTP 请求超时时间(秒,默认 30) |
|
||||
| `--debug` | 显示调试日志 |
|
||||
| `-v, --verbose` | 显示详细日志 |
|
||||
|
||||
### 输出过滤示例
|
||||
|
||||
```bash
|
||||
# 只取 tableId
|
||||
dws aitable +datasource-create ... --jq '.tableId'
|
||||
|
||||
# 只取同步状态
|
||||
dws aitable +datasource-sync-status ... --jq '.status'
|
||||
|
||||
# table 格式查看
|
||||
dws aitable +datasource-get-config ... -f table
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **推荐流程**:先 `+datasource-list-sources` 解析 `result` JSON 获取 `approvals[].processCode`,再 `+datasource-get-fields` 查看可同步字段,最后 `+datasource-create` 创建数据源表。
|
||||
|
||||
2. **数据源表 vs 普通数据表**:`+datasource-create` 创建的是"数据源表",它由数据源同步驱动数据写入。`+datasource-update` 和 `+datasource-sync` 仅适用于数据源表,不可对普通数据表使用。
|
||||
|
||||
3. **datasource-type 透传**:CLI 层不对 `--datasource-type` 做枚举校验,目前一期仅支持 `OA`(审批)。后续支持其他类型时由服务端控制,CLI 无需修改。
|
||||
|
||||
4. **source-config 格式**:`--source-config` 必须是合法 JSON 字符串。审批数据源需要原样透传 `processCode`(从 `+datasource-list-sources` 返回的 `result` JSON 中解析 `approvals[]` 提取)、`name`、`iconUrl`、`url`,设置 `dataType`(时间范围类型),并按 `dataType` 提供对应的时间参数(`recentDays` / `startDate` / `endDate`)。
|
||||
|
||||
5. **同步限制**:`+datasource-sync` 单次最多 5 张表;`+datasource-sync-status` 单次最多查询 5 个任务 ID。
|
||||
|
||||
6. **创建即同步**:`+datasource-create` 和 `+datasource-update` 在操作完成后会自动触发一次同步,无需额外调用 `+datasource-sync`。
|
||||
|
||||
7. **自动同步**:`--auto` 开启后,数据源表会按 `--auto-sync-setting` 指定的频率自动定期同步;未指定频率时使用服务端默认策略。关闭 `--auto` 后仅能通过 `+datasource-sync` 手动触发。
|
||||
+10
-5
@@ -23,7 +23,7 @@
|
||||
|
||||
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
||||
|
||||
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、命令兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
|
||||
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、CLI 与 Schema 兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
|
||||
|
||||
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
|
||||
|
||||
@@ -102,7 +102,7 @@ fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
|
||||
预检包含测试、策略检查、旧正式版 CLI 与 Schema 双基线兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
|
||||
|
||||
## 正式发布
|
||||
|
||||
@@ -140,14 +140,19 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
`.changes/<unique-name>.md` 中增加一个独立 fragment;格式和允许的分类见
|
||||
[`.changes/README.md`](../.changes/README.md)。预发封板时
|
||||
`scripts/release/prepare-changelog.sh prerelease <version>` 会稳定排序并汇总所有未归档
|
||||
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。因此并发 PR 不会争用
|
||||
`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。
|
||||
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。如果 beta 发布后又有
|
||||
带 fragment 的 PR 合入,而维护者决定直接发布 stable,
|
||||
`scripts/release/prepare-changelog.sh stable <version> --from-beta <tag>` 会保留 beta 晋级摘要
|
||||
模板,并把这些 post-beta fragments 写到明确的 `Changes since <beta>` 边界之后,再移动到
|
||||
`.changes/released/<stable-version>/`。没有 active fragment 时,stable 仍只生成原有晋级摘要
|
||||
模板。因此并发 PR 不会争用 `CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与
|
||||
归档移动,供审计复核。
|
||||
|
||||
## CI/CD 保证
|
||||
|
||||
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
|
||||
- tag 必须由云端 seal job 创建为 annotated tag;封板提交必须已通过 PR 合入并包含在远端 `main` 历史中。流水线允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
|
||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整 CLI 与 Schema 契约;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
|
||||
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
|
||||
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
# Attendance Shortcut 下游业务能力需求规格
|
||||
|
||||
> 日期:2026-08-18
|
||||
> Rebased executable 基线:`69bda96e49c7a478729b5f9232677fd9055e5d7d`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中
|
||||
> 对比基线:Lark CLI 1.0.87
|
||||
> 范围:Attendance Shortcut only;不改 DWS 产品 Skill 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。
|
||||
|
||||
## 1. 执行摘要
|
||||
|
||||
- Attendance 共审核 35 个源码 Shortcut;8 个具备 Agent 公开条件,27 个保持 unavailable。为守住已发布 CLI 的 argv/Help 兼容,其中 11 个历史可见入口继续以 compatibility-visible 形式可发现,但仍从 Agent public Catalog 排除、保持 legacy 输出且不发布 Result/Pagination;其余 16 个保持 hidden。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。
|
||||
- 这 11 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;它们的 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 MCP 字段名由 Execute 的显式 adapter 负责,不能在未经过版本化迁移时重定向已发布 Schema property。
|
||||
- `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`;DWS inventory 还包含打卡流水、审批、班次、规则、设置、假期和个人视图等更宽能力。排班查询入口虽然保留历史 CLI 兼容,但因 `DS-ATTENDANCE-008` 当前保持 Agent-unavailable。
|
||||
- 已确认 8 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture、6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture、班次详情不回显稳定 ID、个人设置缺少逐场景权限发现与安全 fixture,以及排班查询对合法非空/空请求均返回 `exit 0 + literal null`。
|
||||
- 审批模板的同类型多模板问题已在上游修复:以 `processCode` 作为资源身份,`approveType` 只做请求绑定,并要求 `submitUrl` 非空。班次详情与个人设置仍有下游合同/权限前置,不能以请求 echo 或部分场景成功伪造整体可用。
|
||||
|
||||
| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 建议 Owner | 解锁的 Shortcut |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `DS-ATTENDANCE-001` | P1 | business-service defect / contract insufficient | 搜索后读取补卡规则详情 | unavailable | Attendance Wukong 规则服务 | `+get-adjustment-rule` |
|
||||
| `DS-ATTENDANCE-002` | P1 | business-service defect / contract insufficient | 发现报表列并查询考勤/假期报表 | unavailable | Attendance 报表服务 / MCP adapter | `+list-report-columns`, `+query-report-data`, `+query-report-leave` |
|
||||
| `DS-ATTENDANCE-003` | P2 | contract insufficient | 可靠翻完打卡结果 | partial | Attendance 打卡查询服务 | `+check-result` 完整分页 |
|
||||
| `DS-ATTENDANCE-004` | P1 | tenant-or-fixture / permission | 验证考勤组、全局设置、余额和写操作 | blocked / unavailable | Attendance 产品测试基础设施 / 权限 Owner | 14 个读写 Shortcut |
|
||||
| `DS-ATTENDANCE-005` | P1 | response contract / tenant-or-fixture | 可验证地读取摘要、假期、签到和个人考勤 | blocked / unavailable | Attendance 查询服务 / 产品测试基础设施 | 6 个读 Shortcut |
|
||||
| `DS-ATTENDANCE-006` | P1 | response contract | 用搜索得到的班次 ID 精确读取同一班次详情 | unavailable | Attendance Wukong 班次服务 | `+get-class` |
|
||||
| `DS-ATTENDANCE-007` | P1 | capability / permission fixture | 可发现地读取全部个人设置场景 | blocked / unavailable | Attendance 设置服务 / 权限 Owner / 测试基础设施 | `+get-self-setting` |
|
||||
| `DS-ATTENDANCE-008` | P1 | response contract | 可验证地读取员工排班 | unavailable | Attendance Wukong 排班服务 / MCP adapter | `+get-schedule` |
|
||||
|
||||
## 2. 用户任务与能力缺口总览
|
||||
|
||||
| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 |
|
||||
|---|---|---|---|---|---|
|
||||
| 批量查询员工打卡结果 | `attendance +check-result` | `attendance user_tasks query` | covered;框架分页 token 由当前页保守派生 | contract insufficient | 声明 `Pagination(kind=cursor,cursor_parameter=offset)`;续页只放 `meta.pagination`,业务 `data` 仅含 `count/records` |
|
||||
| 搜索并读取班次 | `+search-class` → `+get-class` | 无同级入口 | partial | response contract | 只公开搜索;详情因不回显请求 classId 而 unavailable |
|
||||
| 搜索并读取补卡规则 | `+search-adjustment-rule` → `+get-adjustment-rule` | 无同级入口 | partial | business-service defect | 只公开搜索;详情 unavailable |
|
||||
| 发现字段并查询考勤报表 | `+list-report-columns` → `+query-report-data` | 无同级入口 | unavailable | contract insufficient | 两个入口均不进入 Agent Catalog;历史 `+query-report-data` 仅保留 CLI 兼容可见性 |
|
||||
| 查询假期报表 | `+query-report-leave` | 无同级入口 | unavailable | business-service defect | hidden/unavailable |
|
||||
| 搜索并读取考勤组 | `+search-group` → `+get-group` | 无同级入口 | blocked | tenant-or-fixture | 无已知非空安全 fixture;历史 `+search-group` 仅保留 CLI 兼容可见性,二者都不进入 Agent Catalog |
|
||||
| 查询企业全局设置和假期余额 | `+get-global-setting`, `+get-leave-balance` | 无同级入口 | blocked | permission / fixture | hidden/unavailable |
|
||||
| 查询个人设置 | `+get-self-setting` | 无同级入口 | partial | capability / permission fixture | 前五个场景已验证;全部场景发布前保持 Agent-unavailable,仅保留历史 CLI 兼容可见性 |
|
||||
| 查询员工排班 | `+get-schedule` | 无同级入口 | unavailable | response contract | 合法非空与保证零命中请求均收到 `exit 0 + literal null`;旧 CLI 兼容可见,但不进入 Agent Catalog |
|
||||
| 修改排班、班次、考勤组、假期和打卡结果 | 9 个写 Shortcut | 无同级入口 | unsafe to verify | tenant-or-fixture / contract insufficient | hidden/unavailable,不以 dry-run 记通过 |
|
||||
|
||||
## 3. 下游需求明细
|
||||
|
||||
### `DS-ATTENDANCE-001` — 让搜索得到的补卡规则可被稳定读取
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:先按名称浏览补卡规则,再用结果中的稳定主键读取完整规则。
|
||||
- canonical Shortcut:`attendance +search-adjustment-rule`、`attendance +get-adjustment-rule`。
|
||||
- atomic/raw route:`attendance adjustment search`、`attendance adjustment get`。
|
||||
- Exact Shortcut 与 atomic/raw 均使用搜索返回的同一候选主键;搜索明确成功且非空,详情调用明确 `success=true`,但 `result=null`。
|
||||
- 已排除上游空数组投影、整数解析和候选字段遗漏:多个可作为候选的数值字段均未得到非空详情;加班规则的相邻搜索→详情闭环正常。
|
||||
- 置信度:高。仍需下游确认“搜索 ID 与详情 ID 不同”还是详情服务未返回对象。
|
||||
- 安全证据句柄:`ATT-DETAIL-NULL-01`;仓库不保存 raw body、资源 ID 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 明确 `get_adjustment_rule` 列表项中哪个字段是 `get_adjustment_rule_detail.adjustmentId` 的稳定主键;名称和类型必须在 Schema 中一致。
|
||||
- 对存在且有权限的规则返回 `success=true` 和非空对象 `result`,对象必须回显同一稳定规则 ID。
|
||||
- 对不存在、已删除、无权限、租户未开通分别返回稳定的 typed error;不得以 `success=true + result=null` 表示任一失败。
|
||||
- 如详情接口不受支持,提供可发现的 capability/feature 状态,或在搜索结果中返回足以完成详情任务的完整对象并声明字段稳定性。
|
||||
- 改动应 additive/versioned;旧字段保留兼容期,禁止静默改变现有 ID 的语义。
|
||||
|
||||
#### C. 验收标准
|
||||
|
||||
1. 创建或选择隔离规则,atomic search 非空并取得稳定 ID。
|
||||
2. atomic detail 和 exact `+get-adjustment-rule` 均返回同一 ID 的非空对象。
|
||||
3. 不存在 ID、无权限和已删除 ID 分别返回非零 typed error。
|
||||
4. 上游恢复公开后,搜索→详情 E2E 通过且仓库/远端无测试残留。
|
||||
|
||||
#### D. 临时处置
|
||||
|
||||
`+get-adjustment-rule` 保持 Agent-unavailable 并从公开 Catalog 排除;旧 CLI 入口仅为 argv/Help 兼容继续可见,`+search-adjustment-rule` 不再承诺详情入口可用。
|
||||
|
||||
### `DS-ATTENDANCE-002` — 提供可发现、可验证的考勤报表合同
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- Golden Route:列出企业可查询报表列 → 选择稳定列 ID → 查询一批员工的列值;另一路径按假期类型查询时长报表。
|
||||
- canonical Shortcut:`+list-report-columns`、`+query-report-data`、`+query-report-leave`。
|
||||
- atomic/raw operations:`get_report_columns`、`get_report_columns_value`、`get_leave_time_by_leave_names`。
|
||||
- 观察:列发现与假期报表调用均退出码 0 且 payload 为 JSON `null`;使用未经验证的列 ID 查询列值仅得到显式空数组,不能证明列 ID 有效或查询正确。
|
||||
- 已排除上游投影丢失:原子调用本身即返回 `null`;Shortcut 现已拒绝把 `null` 当作合法空集合。
|
||||
- 置信度:高。权限/租户功能可能是触发条件,但接口没有返回可区分的状态。
|
||||
- 安全证据句柄:`ATT-REPORT-NULL-01`。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- `get_report_columns`:成功时必须返回显式列数组;每项含稳定 `columnId`、显示名、值类型、单位、支持的日期/人员范围和是否需要管理员权限。
|
||||
- 合法无列必须是 `success=true + result=[]`;未开通、无权限和服务异常必须是不同 typed error,不得返回裸 `null`。
|
||||
- `get_report_columns_value`:返回值必须绑定请求的用户集合、列 ID 和时间范围;未知列返回 `COLUMN_NOT_FOUND`,不能静默得到空数组。
|
||||
- `get_leave_time_by_leave_names`:返回显式数组并包含稳定用户身份、假期类型标识、单位和数值;合法零记录为显式空数组。
|
||||
- 列值和假期报表若分页,必须提供 page/cursor、hasMore 和终止证据;批量用户存在部分失败时返回逐项 ledger 与整体 partial status。
|
||||
- 提供安全 capability discovery:租户是否开通、调用身份所需权限、最大用户数、最大列数、最大时间跨度。
|
||||
|
||||
#### C. 验收标准
|
||||
|
||||
1. 管理员测试租户中列发现有已知非空和明确空租户两组 E2E。
|
||||
2. 使用发现的同一 `columnId` 执行 atomic 与 exact Shortcut,返回与请求用户/区间绑定的非空值。
|
||||
3. 未知列、无权限、未开通和超范围分别产生稳定非零错误。
|
||||
4. 假期报表至少覆盖已知非空、合法空和未知假期类型。
|
||||
5. 分页/partial 分支和远端零残留通过。
|
||||
|
||||
#### D. 临时处置
|
||||
|
||||
三个报表 Shortcut 均保持 Agent-unavailable;其中历史 `+query-report-data` 只保留 CLI 兼容可见性。不得用 `null`、请求 echo 或未验证列产生的空数组标记 PASS。
|
||||
|
||||
### `DS-ATTENDANCE-003` — 为打卡结果提供确定的分页终止证据
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+check-result` 已真实返回非空打卡结果并覆盖 Lark 任务;当前接口只接受 `offset/limit`,响应缺少稳定总量、hasMore 或 nextOffset。
|
||||
- DWS 只能在返回条数小于 limit 时证明结束;满页时保守输出 `meta.pagination.endpoint_exhausted=false` 和 `next_token=offset+count`,不能声明全量完成。`complete/nextOffset/limit` 仅保留在 legacy 兼容输出,unified 业务 `data` 不冒充分页协议。
|
||||
- 安全证据句柄:`ATT-CHECK-PAGE-01`。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 响应增加 `hasMore` 与 `nextOffset`,或 `totalCount`;这些字段必须与同一快照/排序一致。
|
||||
- 固定稳定排序键和同 offset 重放语义;说明并发新增/修改是否可能造成重复或漏项。
|
||||
- 空页且 `hasMore=true` 必须仍给出前进 token/offset;重复或倒退 offset 为协议错误。
|
||||
- 声明最大 limit、最大时间跨度和超过上限的 typed validation error。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
- 验收覆盖多页、最后一页、零记录、满页但仍有下一页、重复 token/offset 和并发变更。
|
||||
- 下游完成前,DWS 使用框架 `PaginationSpec` 和 `meta.pagination`表达保守续页;`cursor_parameter=offset` 表示调用者将 `next_token` 作为下一次 `--offset`,不表示下游已提供服务端 opaque cursor。满页始终不会被当作已完整。
|
||||
|
||||
### `DS-ATTENDANCE-004` — 建立可回收的 Attendance 管理员与写操作测试资源
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 受影响读取:`+search-group`、`+get-group`、`+get-group-filtered`、`+get-global-setting`、`+get-leave-balance`。
|
||||
- 受影响写入:`+import-schedule`、`+create-class`、`+update-class`、`+update-group-members`、`+create-group`、`+update-group`、`+update-leave-type`、`+save-leave-balance`、`+boss-check`。
|
||||
- 当前安全身份没有已知非空考勤组 fixture;全局设置被权限拒绝;余额读取没有可验证结果。写操作会影响真实员工规则,且部分资源缺删除/恢复能力,因此未执行生产数据写入。
|
||||
- 这不是对业务接口必然有 bug 的结论,而是可测试性和权限前置不足。
|
||||
- 安全证据句柄:`ATT-FIXTURE-GAP-01`。
|
||||
|
||||
#### B. 需要的测试基础设施与合同
|
||||
|
||||
- 提供隔离租户或专用测试组织,包含:管理员测试身份、两个无业务含义测试成员、一个可删除考勤组、一个可删除班次、一个可恢复假期类型、可控排班与打卡结果。
|
||||
- 只授予完成相应接口所需的最小 scopes;提供 capability discovery,区分权限不足、功能未开通和资源不存在。
|
||||
- 写接口返回稳定资源 ID、逐项结果、幂等/commit-unknown 语义;所有更新支持精确读回。
|
||||
- 为不可删除的企业设置提供 snapshot/restore 或专用 reset API;余额和 BOSS 改签必须能恢复原值。
|
||||
- Fixture 有 TTL、Owner 和自动清理告警;日志只保留受控 evidence handle,不输出业务内容或身份值。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 考勤组搜索有已知非空和保证零命中;详情绑定同一 ID。
|
||||
2. create→get→update→restore/delete 覆盖班次、考勤组与排班。
|
||||
3. 成员、余额和打卡结果写入均有 before/after 精确读回并恢复原值。
|
||||
4. 未确认时远程写调用为 0;任一 partial/commit-unknown 非零退出。
|
||||
5. 测试结束远端和本地均零残留。
|
||||
|
||||
在完整 fixture 到位前,相关 Shortcut 保持 hidden/unavailable。
|
||||
|
||||
### `DS-ATTENDANCE-005` — 为 6 个读场景提供请求绑定与双态 fixture
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+get-summary`:真实响应只含统计项,不回显请求 user、period 或 statsType,上游无法证明返回属于哪个请求。
|
||||
- `+list-leave-types`:当前安全租户只有已知非空列表,而命令无筛选参数;不能用越界分页或错误请求伪造合法空结果。
|
||||
- `+get-leave-records`、`+get-checkin-record`:当前只取得合法空结果,缺少已知非空流水 fixture,无法排除响应投影或请求绑定错误。
|
||||
- `+my-attendance`、`+this-month`:上游已严格验证当前用户 profile 与每条打卡 ID,但当前期间仅有合法空数组,缺少同一身份下的已知非空 fixture。
|
||||
- 安全证据句柄:`ATT-READ-FIXTURE-GAP-01`;不保存 raw body、用户 ID 或打卡时间。
|
||||
|
||||
#### B. 需要下游提供的合同与 fixture
|
||||
|
||||
- 摘要响应回显稳定 userId、统计周期起止和 statsType,或返回可校验的请求摘要;任一字段不一致必须 typed failure。
|
||||
- 提供隔离的「无假期类型」测试租户,以显式 `success=true + result=[]` 证明 `+list-leave-types` 的合法空语义。
|
||||
- 提供可创建、读取并清理的假期变更流水、签到流水和打卡流水;每项都必须包含稳定 ID、请求用户和时间范围回显。
|
||||
- 为 nonempty 与 guaranteed-zero 提供独立 fixture;未知用户、无权限、未开通和合法空集合必须可区分,不得都返回裸 `null` 或无标识空数组。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 每个集合叶子都用 exact Shortcut 和 owning atomic/raw 在同一参数下各证明一次已知非空和一次合法保证零命中。
|
||||
2. 非空项的稳定 ID、用户和时间绑定在两层结果中一致;空结果仍有显式业务 success 和正确集合容器。
|
||||
3. malformed/null/success=false/错身份/超范围均非零失败,且不会继续调用后续考勤接口。
|
||||
|
||||
在上述证据完整前,6 个 Shortcut 均保持 Agent-unavailable,并仅为历史 argv/Help 保留 CLI 兼容可见性;已实现的严格校验不等于已获得发布证据。
|
||||
|
||||
### `DS-ATTENDANCE-006` — 让班次详情回显可验证的稳定身份
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:先用 `+search-class` 浏览班次并取得稳定 `classId`,再用同一 ID 读取班次详情。
|
||||
- canonical Shortcut:`+search-class`、`+get-class`;atomic/raw route:`attendance class search`、`attendance class get`。
|
||||
- 在 clean discovery HEAD 上,搜索 exact/raw 均返回同一组非空正整数 `classId`;使用其中真实 ID 调用 raw detail,服务端返回 `success=true` 和非空 `shiftVO`,但对象没有 `id` 或 `classId`。
|
||||
- 上游不能把请求 ID 注入响应来伪造 readback,也不能仅凭“非空详情”证明详情属于请求资源。因此 `+get-class` 保持 unavailable。
|
||||
- 安全证据句柄:`ATT-CLASS-ID-ECHO-GAP-01`;不保存 raw body、资源 ID 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- `get_class_detail` 成功对象必须回显与请求精确一致的稳定 `id`/`classId`,类型与 `get_class_list` 列表身份字段一致。
|
||||
- 存在、已删除、不存在、无权限和租户未开通必须返回可区分的 typed terminal 状态;不得以非空但无身份对象表示可验证成功。
|
||||
- 明确班次 ID 的租户作用域、生命周期和搜索→详情一致性;如详情存在版本号,也应返回稳定版本字段以支持更新前读回。
|
||||
- 改动需 additive/versioned;现有详情业务字段保持兼容。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. exact/raw 搜索得到同一非空 `classId`,同 ID detail 均返回身份精确匹配的非空对象。
|
||||
2. 不存在、已删除和无权限分别非零 typed failure,不能成为 `success=true + result=null` 或无身份对象。
|
||||
3. 上游 `+get-class` 的 missing/false/null/malformed/wrong-ID 回归与真实 E2E 全部通过。
|
||||
|
||||
下游补齐稳定 ID 回显前,`+get-class` 保持 hidden/unavailable;`+search-class` 仍可独立公开。
|
||||
|
||||
### `DS-ATTENDANCE-007` — 提供个人设置逐场景 capability 与权限安全 fixture
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+get-self-setting` 公开参数包含 6 个场景。clean discovery HEAD 上,前 5 个场景的 exact/raw 均能精确绑定请求 userId、场景字段和已观测类型;`bossAttendStatNotify` 在两层均返回稳定业务错误 `NO_PERMISSION`。
|
||||
- 当前接口没有 capability discovery 告知调用身份可读哪些场景,也没有可安全授权的隔离 fixture。只验证 5/6 不能宣称整个公开枚举可用。
|
||||
- 这不是把权限错误误判为业务空结果;exact/raw 均非零退出。上游保留严格 user/scene/type 校验,但发布面整体降级。
|
||||
- 安全证据句柄:`ATT-SELF-SETTING-PERMISSION-GAP-01`。
|
||||
|
||||
#### B. 需要下游提供的合同与 fixture
|
||||
|
||||
- 提供 capability discovery,返回当前调用身份逐场景的 readable/forbidden/unsupported 状态、所需最小 scope/角色和租户功能开通状态。
|
||||
- 为 6 个场景提供字段名、类型、可空性和版本化语义;成功必须回显请求 userId,并明确返回对应场景字段。
|
||||
- 提供隔离测试身份或可撤销的临时最小权限授权 fixture,使 6 个场景均能完成 exact/raw 同场景验证;测试后权限必须回收。
|
||||
- 无权限、场景不支持、用户不存在和设置未配置必须返回不同 typed error;不得统一为 `null`、空对象或无标识空成功。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. capability discovery 与 6 个场景实际调用一致,不遗漏权限前置。
|
||||
2. 每个场景 exact/raw 的 userId、场景字段、类型和对象内容一致;`null`、错类型、错用户均非零。
|
||||
3. bogus user、invalid scene、无权限和未开通均返回可区分非零错误。
|
||||
4. 权限 fixture 全程最小化、可撤销,结束后无授权残留。
|
||||
|
||||
能力发现和安全 fixture 到位前,`+get-self-setting` 保持 Agent-unavailable;旧 CLI 入口仅保留兼容可见性。
|
||||
|
||||
### `DS-ATTENDANCE-008` — 让排班查询返回可判定的成功集合或业务错误
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:按员工和日期范围读取逐日排班,用稳定排班 ID 继续执行只读分析或受控的 BOSS 改签。
|
||||
- canonical Shortcut:`attendance +get-schedule`;owning raw route:`attendance-wukong/getScheduleByRange`。
|
||||
- 两次独立 clean HEAD 的真实验证中,已知历史非空区间与保证零命中的未来区间都得到同一结果:owning raw 进程退出 0,但响应为 literal `null`;Exact Shortcut 均以 `response_validation/empty_tool_response` 非零拒绝。
|
||||
- 这既不能证明排班非空,也不能证明合法为空。上游严格校验已避免把 `null` 投影成 `[]`,但在下游提供可判定合同前无法公开该能力。
|
||||
- 安全证据句柄:`ATT-SCHEDULE-NULL-01`;仓库不保存用户、日期、排班 ID、raw body 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 成功查询必须返回显式排班数组;每项包含稳定非空排班 ID、请求用户身份、业务日期、班次身份和是否休息等字段。
|
||||
- 合法零结果必须返回 `success=true + result=[]`(或等价的已审核显式集合),不得以裸 `null`、缺字段或空 body 表示。
|
||||
- 无权限、用户不存在、租户未开通、日期范围非法和服务异常必须返回可区分的 typed nonzero error;不得继续用进程退出 0 掩盖业务失败。
|
||||
- 如服务存在分页,必须提供页大小、前进 token/页号、hasMore/total 和明确终止证据;同一请求的 item identity 不得跨页重复。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 已知非空 fixture 的 raw 与 exact 均返回同一显式数组,稳定 ID 集合、用户和日期绑定一致。
|
||||
2. 保证零命中 fixture 的 raw 与 exact 均返回显式空数组,并有明确终止证据。
|
||||
3. `null`、缺集合、错型 item、重复/空 ID、错用户和越界日期全部非零;错误 reason 可稳定区分。
|
||||
4. 新 clean HEAD 完成 nonempty/zero 双层 E2E,仓库和远端均无测试残留。
|
||||
|
||||
下游修复前,`+get-schedule` 保持 `public=false/unavailable`、legacy 输出且不发布 Result/Pagination;旧 CLI/Help/full Schema 仅为历史兼容继续可发现,不代表 Agent 可用。
|
||||
|
||||
## 4. Lark 对齐与平台差异
|
||||
|
||||
| Lark 用户任务 | 所需下游能力 | 可精确对齐 | 平台差异 | DWS 推荐结论 |
|
||||
|---|---|---|---|---|
|
||||
| `attendance user_tasks query` 查询打卡结果 | 现有 `query_check_result`;最好补分页终止证据 | yes,分页完整性 partial | Lark 当前没有同级的排班、规则、报表和企业设置任务 | 保留 `+check-result` 为主对齐入口,报告分页边界 |
|
||||
|
||||
无法对齐的不是 DWS 缺入口,而是部分钉钉管理面缺少可验证下游合同或安全 fixture;不能为追求同名率伪造成功。
|
||||
|
||||
## 5. 超越 Lark 的产品机会
|
||||
|
||||
| 产品原生能力 | 所需下游支持 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 |
|
||||
|---|---|---|---|---|
|
||||
| 异常考勤处置队列 | 稳定异常记录 ID、原因、关联审批、处理状态、分页和可恢复更正 | `attendance +exceptions` / `+resolve-exception` | 读写分离;更正确认;写后同 ID 终态读回;可恢复 | P2 |
|
||||
| 跨员工考勤汇总 | 可按组织/成员批量聚合迟到、缺卡、加班、请假并给出统计口径版本 | `attendance +team-summary` | 最小权限、聚合脱敏、口径版本、分页完整性 | P2 |
|
||||
| 规则影响预览 | 更新班次/考勤组/假期前返回受影响成员与日期范围,不提交写入 | `attendance +rule-impact-preview` | 只读、稳定影响计数、无副作用、与最终写请求同参数语义 | P1 |
|
||||
|
||||
## 6. 无需下游变更的上游修复
|
||||
|
||||
| Shortcut | 上游根因 | 已完成修复 | 回归证据 |
|
||||
|---|---|---|---|
|
||||
| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵与最终 clean runtime tree 的 8 个公开入口真实 nonempty/zero、详情或模板 exact/raw 双层复核均完成 |
|
||||
| `+check-record` | 初版误用业务归属日 `workDate` 校验按 `checkDateFrom/checkDateTo` 发起的实际打卡查询,导致跨午夜下班卡被静默丢弃 | 改用 `userCheckTime` 严格绑定请求日期范围;`workDate` 只作为班次归属日原样保留。完整 raw 集合仍必须先通过显式 collection、全量正整数唯一 ID、请求用户和实际打卡时间校验;任何实际时间越界都整次 fail-closed,不再静默过滤 | 最终 live 复核 exact/raw 均为 157 条且完整对象一致;旧轮 `workDate=start-24h`、`userCheckTime` 在范围内的跨午夜 OffDuty 记录明确保留;fresh zero 双层显式空,不由过滤制造 |
|
||||
| `+check-result`, `+list-approve` | 初版把裸日期 `--end` 解析为当天 00:00,可能拒绝结束日白天的结果;旧 end-of-day 语义还会漏最后 999ms | 裸日期结束边界改为本地下一日 00:00 前 1ms;显式 datetime 保持精确值;结束日中午与最后 1ms 可接受,下一日 00:00 非零拒绝 | Execute 回归覆盖结束日中午/最后毫秒/下一日并锁定 reason;最终 live 的 `+check-result` 有真实 end-date item,`+list-approve` end-date 单日 probe exact/raw 一致 |
|
||||
| `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;clean HEAD 上 5 个类型 exact/raw 全通过,TRAVEL/OUT 双项集合一致 |
|
||||
| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 坏 item/空 ID/重复 ID/分页矛盾单元回归通过;clean HEAD 上 nonempty/guaranteed-zero 与 raw 对照通过,班次/加班规则另完成实际多页前进与终止 |
|
||||
| `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;clean HEAD 上 exact/raw 同真实搜索 ID 对象一致,raw 对不存在 ID 返回错对象时 exact 非零拒绝 |
|
||||
| `+get-class` | 上游已严格要求 `shiftVO.id`,但真实下游详情不回显任何 ID | 没有注入请求 ID 或放宽校验;按真实合同降级 unavailable | discovery HEAD 上真实搜索→raw detail 非空但 ID 缺失;等待 `DS-ATTENDANCE-006`,修复后再重跑 |
|
||||
| `+get-self-setting` | 仅检查场景 key 存在会让 `null` 伪成功;用户外围空白可造成下传/比较漂移 | 用户输入只归一化一次并以同值下传/比较;场景字段必须非空且符合已观测 object/boolean/integer 类型;因 1/6 场景权限不可验证而整体 unavailable | 5 个 scene exact/raw 对照通过;boss scene exact/raw 均 `NO_PERMISSION`,等待 `DS-ATTENDANCE-007`,不把部分场景成功当整体 PASS |
|
||||
| `+my-attendance`, `+this-month` | 旧的当前用户解析可跳过 malformed row,也可把 success=false 中的 stale result 当身份 | 改为严格 business success/result/唯一用户身份,坏 profile 后考勤 raw 调用为 0;每条打卡要求唯一正整数 ID | 静态/Execute 回归已通过;因当前只有合法空集合而保持 unavailable,不记 live PASS |
|
||||
|
||||
### 6.1 clean-HEAD live 发布门状态
|
||||
|
||||
| 叶子 | clean executable HEAD 双层证据 | 发布状态 |
|
||||
|---|---|---|
|
||||
| `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+check-record` | exact/raw 均 157 条且完整对象、稳定 ID 集合一致;跨午夜 `workDate=start-24h`、`userCheckTime` 在范围内的记录已保留;fresh zero 两层均为显式空 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+list-approve` | exact/raw known-nonempty 为 7 条,稳定 ID、用户、类型、日期范围及完整数组一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-schedule` | 两次独立 clean HEAD 的 known-nonempty 与 guaranteed-zero 均为 raw `exit 0 + literal null`,Exact Shortcut 均非零 `empty_tool_response`;没有把未知结果投影成空数组 | unavailable;等待 `DS-ATTENDANCE-008`,旧 CLI 仅兼容可见 |
|
||||
| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw known-nonempty 与随机唯一词 guaranteed-zero 通过;稳定 ID 集合与分页终止一致,班次为 5/5/3 三页,加班规则为 1/1/1 三页 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-overtime-rule` | 使用本轮真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-approve-template` | 5 个 approveType 全部 exact/raw 通过,数量 1/1/1/2/2;TRAVEL/OUT 多项 `processCode` 非空唯一且集合一致,类型绑定和提交入口有效 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-class` | raw 非空但不回显请求 ID | unavailable;等待下游合同,不以旧调用记 PASS |
|
||||
| `+get-self-setting` | 5 个场景通过,1 个场景 `NO_PERMISSION` | unavailable;等待 capability/权限 fixture,不以部分结果记 PASS |
|
||||
|
||||
pre-rebase discovery 轮次的多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合;后续最终轮次未再出现该 transient。
|
||||
|
||||
上述 8 个公开入口均在最终 clean runtime tree 从零重跑,未继承 discovery PASS;最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。`+get-schedule` 的四次 raw `null` 与 Exact 非零结果作为降级证据保留,不计入公开通过数。
|
||||
|
||||
## 7. 安全与脱敏声明
|
||||
|
||||
- 本文不含真实用户、组织、租户、profile、规则、排班、考勤组或打卡记录 ID。
|
||||
- 本文不含 trace/request ID、token、签名 URL、邮箱、电话、业务标题正文或真实日程内容。
|
||||
- Raw 响应仅在仓库外临时目录中处理并已删除;本文只保留不可反查的证据句柄和聚合事实。
|
||||
- 进入 Git 前必须扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。
|
||||
@@ -1,198 +0,0 @@
|
||||
# Mail Shortcut 下游业务能力需求规格
|
||||
|
||||
> 日期:2026-08-18
|
||||
> Rebased executable 基线:`3fc3be37c67d14f60273a702a7a6b38f6ba32d4c`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中
|
||||
> 对比基线:lark-cli 1.0.87
|
||||
> 范围:Shortcut only;不改 `skills/multi` 或 `skills/mono` 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。
|
||||
> 发布属性:仓库安全版本;不包含真实邮箱、人员、组织、邮件内容、资源 ID 或请求标识。
|
||||
|
||||
## 1. 执行摘要
|
||||
|
||||
本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。8 个公开只读入口已在相同 runtime tree 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 Agent-unavailable,但为守住既有 argv/Help 合同继续以 compatibility-visible 形式留在 CLI;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。
|
||||
|
||||
上述 6 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;其 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 `folderId`、`size` 等 MCP 字段继续由 Execute 显式适配,不能在未经过版本化迁移时改写已发布 Schema property。
|
||||
|
||||
仍不能诚实对齐的任务集中在草稿/模板清理终态、发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口或安全测试 fixture 补足可验证合同。
|
||||
|
||||
| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 下游 Owner | 解锁的 Shortcut |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `DS-Mail-001` | P0 | contract insufficient | 发信/发送草稿并确认最终投递 | partial | Mail service / adapter | `+send`、`+draft-send` |
|
||||
| `DS-Mail-002` | P0 | missing capability | 回复、回复全部、转发默认保存草稿 | partial | Mail service | `+reply`、`+reply-all`、`+forward` |
|
||||
| `DS-Mail-003` | P0 | contract insufficient | 批量修改、移动、软删除邮件 | partial | Mail service / adapter | `+message-modify`、`+message-trash` |
|
||||
| `DS-Mail-004` | P1 | missing capability | 处理已读回执与邮箱签名 | unavailable | Mail service | `+send-receipt`、`+decline-receipt`、`+signature` |
|
||||
| `DS-Mail-005` | P1 | missing capability | 持续监听新邮件 | unavailable | Event + Mail service | `+watch` |
|
||||
| `DS-Mail-006` | P1 | contract insufficient | 带附件/内联图片的模板创建更新 | partial | Mail + Drive adapters | 完整 `+template-create/update` |
|
||||
| `DS-Mail-007` | P1 | adapter defect | 创建联系人并取得稳定身份 | blocked | Mail adapter | `+contact-create/update/delete` |
|
||||
| `DS-Mail-008` | P1 | adapter defect | 一致的成功、空结果与分页合同 | partial | Mail adapter | 全部 list/search Shortcut |
|
||||
| `DS-Mail-009` | P1 | tenant-or-fixture | 安全验证发送、回执、分享和监听 | blocked | Product QA / tenant admin | 全部高影响 Mail Shortcut |
|
||||
| `DS-Mail-010` | P0 | contract insufficient | 草稿/模板可证明的清理终态 | blocked | Mail service / adapter | `+draft-create/edit`、`+template-create/update` |
|
||||
|
||||
## 2. 用户任务与能力缺口总览
|
||||
|
||||
| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 |
|
||||
|---|---|---|---|---|---|
|
||||
| 浏览/筛选摘要 | `+triage`、`+search-mail` | `+triage` | covered | 无 | 公开,严格分页 |
|
||||
| 固定未读/近期列表 | `+unread-mail`、`+recent-mail` | Lark 对应任务入口 | blocked | 固定查询/文件夹缺可控 guaranteed-zero fixture | 保持 unavailable |
|
||||
| 读取一封、多封、会话 | `+message`、`+messages`、`+thread` | 同名入口 | covered | 无 | 公开,精确 ID 读回 |
|
||||
| 新建/编辑草稿 | `+draft-create`、`+draft-edit` | 同名入口 | blocked | 两次 batch-delete 后同 ID 仍可读,无法证明零残留 | 保持 unavailable |
|
||||
| 创建/更新基础模板 | `+template-create`、`+template-update` | 同名入口 | blocked | delete 后 get 没有 typed nonfound;from/isDraft 也不可读回 | 保持 unavailable |
|
||||
| 发送新邮件/已有草稿 | 无公开 Shortcut;存在 raw send | `+send`、`+draft-send` | partial | 终态、逐项结果、幂等不足 | 保持 raw,不宣称对齐 |
|
||||
| 回复/回复全部/转发 | 无公开 Shortcut;raw 路径会立即发送 | `+reply`、`+reply-all`、`+forward` | partial | 缺少默认草稿与邮件头保真合同 | 保持 raw,不宣称对齐 |
|
||||
| 修改/删除邮件 | 无公开 Shortcut;存在 raw batch route | `+message-modify`、`+message-trash` | partial | 无逐项 ledger 和严格终态 | 保持 raw,不宣称对齐 |
|
||||
| 发送/拒绝已读回执 | 无 | `+send-receipt`、`+decline-receipt` | unavailable | 专用业务接口与标签合同缺失 | 明确不可用 |
|
||||
| 邮箱签名 | 无 | `+signature` | unavailable | 签名读取接口缺失 | 明确不可用 |
|
||||
| 分享邮件到聊天 | raw 高风险入口 | `+share-to-chat` | partial | 缺安全 fixture、逐目标结果与读回 | 不公开 Shortcut |
|
||||
| HTML lint | 无 | `+lint-html` | unavailable | 缺统一邮件 HTML 规则包 | 下游或本地规则能力需求 |
|
||||
| 监听新邮件 | 无公开 Mail Shortcut | `+watch` | unavailable | 订阅生命周期和安全事件合同不足 | 不公开 Shortcut |
|
||||
| 文件夹/标签/联系人/企业邮箱用户 | `+folder-list`、`+user-search`、`+find-mail-user` 公开;其余列表不公开 | 无同名任务入口 | partial DWS extra | 标签/模板/联系人/会话列表缺安全双态 fixture | 无双态证据的入口保持 unavailable |
|
||||
|
||||
## 3. 下游需求明细
|
||||
|
||||
### `DS-Mail-001` — 可验证的发送生命周期
|
||||
|
||||
- 用户任务:发送新邮件或一个/多个草稿,并知道每一封最终是成功、失败、部分成功还是状态未知。
|
||||
- 当前证据:raw 发送可返回业务 success 或发送标识,但不能统一证明最终投递;批量草稿发送没有逐项 ledger、请求顺序、未知提交和安全重试合同。
|
||||
- 所需接口合同:
|
||||
- 创建/发送必须返回稳定 `messageId` 与 `internetMessageId`,并明确 `accepted/pending/sent/partial_failure/failure/unknown`。
|
||||
- 提供按同一身份查询发送状态的接口;状态必须绑定请求邮件与收件人集合。
|
||||
- 批量发送返回逐项结果,任何一项失败时整体不得退出 0 冒充全成功。
|
||||
- 支持幂等键,或明确 unknown commit 不可自动重试。
|
||||
- 失败错误区分参数、权限、风控、限流、收件人拒收和提交未知。
|
||||
- 验收:安全自发自收 fixture 完成 draft-create → exact get → send → 状态终态 → sent-folder exact read;批量中注入一项失败,验证 ledger 与非零整体结果;清理无测试草稿残留。
|
||||
|
||||
### `DS-Mail-002` — 回复/转发的草稿优先与 MIME 保真
|
||||
|
||||
- 用户任务:回复、回复全部或转发一封邮件,默认保存草稿,只有再次确认才发送。
|
||||
- 当前证据:DWS raw route 会创建回复/转发草稿后立即发送,无法对齐 Lark 的默认草稿语义;上游也无法证明 `In-Reply-To`、`References`、原始引用块和收件人集合正确。
|
||||
- 所需接口合同:
|
||||
- 独立 `create_reply_draft`、`create_reply_all_draft`、`create_forward_draft`,返回稳定草稿 ID,不隐式发送。
|
||||
- 服务端生成并可读回线程关系头、回复全部去重后的 To/CC、转发引用块和附件继承结果。
|
||||
- 发送必须复用 `DS-Mail-001` 的确认、终态和幂等合同。
|
||||
- 验收:用隔离自发邮件分别创建三类草稿,精确 ID 读回核对父邮件、参与人集合和引用语义;未确认时远程发送调用为 0;确认发送后状态终态可验证。
|
||||
|
||||
### `DS-Mail-003` — 邮件修改、移动和删除的逐项终态
|
||||
|
||||
- 用户任务:批量标记已读/未读、增删标签、移动文件夹、软删除邮件。
|
||||
- 当前证据:raw batch route 多数只给聚合 success;删除后邮件仍可能可读,无法区分“移入已删除文件夹”“永久删除”“延迟可见”或“未生效”。
|
||||
- 所需接口合同:
|
||||
- 每个输入 messageId 返回 `applied/already_applied/failed/unknown` 与稳定原因码。
|
||||
- 修改/移动后详情或摘要必须可读回 `isRead/tags/folderId`;删除返回明确 tombstone 或 folder transition。
|
||||
- 软删除和永久删除使用不同操作,危险级别与确认要求可声明。
|
||||
- 任何部分失败整体 outcome 为 `partial_failure` 且进程非零。
|
||||
- 验收:创建隔离邮件,执行 mark-unread/read、标签增删、移动与软删除,每步同 ID 读回;错误 ID 与合法 ID 混合时逐项 ledger 完整且整体非零。
|
||||
|
||||
### `DS-Mail-004` — 已读回执与签名
|
||||
|
||||
- 用户任务:识别邮件是否请求回执;确认后发送标准回执,或拒绝并清除提示;列出和查看默认签名。
|
||||
- 当前证据:现有 Mail 接口没有稳定暴露回执请求标签、专用发送/拒绝操作或签名读取资源,上游无法安全组合普通回复替代。
|
||||
- 所需接口合同:
|
||||
- 消息详情公开稳定回执请求状态和请求者身份类型。
|
||||
- 专用 send/decline receipt 操作,幂等且返回状态;正文由服务端生成,不能让上游伪造。
|
||||
- 签名列表/详情返回稳定 ID、默认发送场景、HTML/文本内容和敏感字段标注。
|
||||
- 验收:预置请求回执邮件,未确认零写调用;发送/拒绝后状态读回且重复调用幂等;签名已知非空与合法空均可证明。
|
||||
|
||||
### `DS-Mail-005` — 新邮件监听的订阅生命周期
|
||||
|
||||
- 用户任务:在限定时间内监听新邮件,得到稳定、可恢复、可去重的事件流。
|
||||
- 当前证据:通用事件基础设施不能证明 Mail scope、订阅状态、ready marker、断线续传和消息读取权限形成完整任务链。
|
||||
- 所需接口合同:订阅/查询/退订;明确 user/bot 身份、scope 和租户开关;ready marker;事件 `eventId/messageId/mailbox/time`;断线 cursor、去重和界限参数;心跳不冒充业务事件。
|
||||
- 验收:隔离邮箱订阅后注入一封测试邮件,只收到一次并能以 messageId 精确读取;超时、权限缺失、断线重连和退订后零事件均有确定结果。
|
||||
|
||||
### `DS-Mail-006` — 模板附件与内联图片事务
|
||||
|
||||
- 用户任务:创建或更新含普通附件、内联图片和 HTML 的模板,同时保留未修改 MIME 结构。
|
||||
- 当前证据:本轮只对齐名称、主题、正文核心字段;现有多步上传缺少模板级事务、附件稳定 ID、失败回滚和更新时的结构保真证明。
|
||||
- 所需接口合同:创建/更新草稿会话、附件上传会话、content-id 映射、提交/取消;返回逐附件 ledger;更新提供版本或 etag,避免 last-write-wins 覆盖;失败可回滚且无孤儿文件。
|
||||
- 验收:普通附件和内联图片各一,创建后按模板 ID 读取附件 ID/名称/大小/content-id;更新正文不丢附件;中途失败自动取消并证明零孤儿资源。
|
||||
|
||||
### `DS-Mail-007` — 联系人写操作的稳定身份
|
||||
|
||||
- 用户任务:创建、更新、删除个人邮件联系人并验证精确对象。
|
||||
- 当前证据:真实 create 返回 `success=true` 但没有 contactId;上游只能用随机显示名再扫列表定位,无法用于一般用户输入,因为名称/邮箱可能重复。
|
||||
- 所需接口合同:create 返回稳定 contactId;get-by-id;update/delete 返回同 ID 与版本;列表支持 exact email 或 ID filter;重复联系人规则明确。
|
||||
- 验收:创建回执直接得到 ID,get-by-id 精确核对,更新同 ID,删除后 not-found/tombstone;重复邮箱和同名联系人有稳定结果而非猜测。
|
||||
|
||||
### `DS-Mail-008` — 统一成功、空结果与分页协议
|
||||
|
||||
- 用户任务:可靠地区分“确实没有结果”“还有下一页”“服务异常或响应漂移”。
|
||||
- 当前证据:同一产品的 success 同时出现布尔和字符串;hasMore 也出现两种编码;搜索终页用 `$`,部分列表用空串;零命中邮件会返回 `total=0` 加一个只有空收件人字段的占位对象。当前租户又没有空邮箱或空邮件文件夹,不能为无筛选列表证明 guaranteed-zero。
|
||||
- 所需接口合同:
|
||||
- success 与 hasMore 统一为布尔;所有列表显式数组,合法空只返回 `[]`。
|
||||
- 统一 `nextCursor` 与 `endpointExhausted`;终页不使用业务哨兵对象或魔法值。
|
||||
- 每项稳定 ID 必填;total 使用整数;服务错误必须 `success=false` 和稳定错误码。
|
||||
- 保留兼容期,但提供 capability/version 让上游安全切换。
|
||||
- 验收:每个列表/搜索执行已知非空、保证零命中、坏 item、缺集合、错型、hasMore 无游标、重复游标;只有显式合法空成功。
|
||||
|
||||
### `DS-Mail-009` — 安全租户与真实 E2E fixture
|
||||
|
||||
- 用户任务:在不触达真实业务收件人和内容的前提下验证所有高影响 Mail Shortcut。
|
||||
- 所需 fixture:隔离自发自收邮箱、可控第二收件人、回执请求邮件、可分享的测试聊天、安全事件订阅、测试签名、可回收附件;所有资源用随机无业务含义标记并有自动清理。
|
||||
- 权限:最小 Mail read/write/event、Drive attachment、IM share scopes 分离;可测试 user/bot 差异和缺权限错误。
|
||||
- 验收:stdout 只输出 PASS 标签与聚合计数;原始 JSON 只在临时目录;finally 清理;远端零测试草稿/模板/联系人/邮件/订阅残留;仓库和历史扫描无身份数据。
|
||||
|
||||
### `DS-Mail-010` — 草稿/模板可证明的清理终态
|
||||
|
||||
- 用户任务:用可回收 fixture 验证草稿与模板写 Shortcut,不留下无法确认的远端测试对象。
|
||||
- 当前证据:草稿创建/更新回执和 exact-ID 读回成功,但同一 ID 连续两次 batch-delete 后仍可读;模板 delete 返回成功后,get 仅为未分类失败,既非 typed nonfound 也不能证明 tombstone。
|
||||
- 所需接口合同:分离软删除与永久删除;返回稳定 ID、终态和幂等证据;get-by-id 对已永久删除对象返回稳定 `not_found/deleted` 错误或已审核 tombstone,不得空 body、通用失败或继续返回对象。
|
||||
- 验收:create/update → exact-ID readback → permanent delete → exact Shortcut + raw get 双层 typed absence;有界轮询后仍可读或终态未知时整体非零,且不得发布 Shortcut。
|
||||
- 临时处置:四个写 Shortcut 保持 `public=false` / `unavailable`,直到安全 fixture 与 typed absence 同时可证明。
|
||||
|
||||
## 4. Lark 对齐与平台差异
|
||||
|
||||
| Lark 用户任务 | 可精确对齐 | 平台差异 | DWS 推荐结论 |
|
||||
|---|---|---|---|
|
||||
| `+message` / `+messages` / `+thread` / `+triage` | yes | DWS 额外自动解析邮箱和收件箱,并严格发布完整性 | 已公开 |
|
||||
| `+draft-create` / `+draft-edit` | blocked | 核心写回可证,但删除后同 ID 仍可读,无安全清理终态 | 不公开,保持 unavailable |
|
||||
| `+template-create` / `+template-update` | blocked | 核心字段可读回,但 from/isDraft 不可验且删除后缺 typed nonfound | 不公开,保持 unavailable |
|
||||
| `+send` / `+draft-send` | no | DWS raw 偏立即发送且缺统一终态/逐项 ledger | 暂不公开 Shortcut |
|
||||
| `+reply` / `+reply-all` / `+forward` | no | DWS raw 会立即发送,Lark 默认保存草稿 | 暂不公开 Shortcut |
|
||||
| `+message-modify` / `+message-trash` | no | 聚合 success 不足以证明逐项终态 | 暂不公开 Shortcut |
|
||||
| `+send-receipt` / `+decline-receipt` | no | 缺专用接口和可验证标签 | platform unavailable |
|
||||
| `+signature` | no | 缺签名读取资源 | platform unavailable |
|
||||
| `+watch` | no | 缺完整订阅生命周期与安全 fixture | fixture + capability blocked |
|
||||
| `+share-to-chat` | partial | raw 可调用但缺逐目标验证和安全 fixture | 保持 raw |
|
||||
| `+lint-html` | no | DWS 未提供统一规则包 | downstream/local capability needed |
|
||||
|
||||
## 5. 超越 Lark 的产品机会
|
||||
|
||||
| 产品原生能力 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 |
|
||||
|---|---|---|---|
|
||||
| 文件夹、标签与联系人目录 | `+organize`:规则化移动、标记与标签组合 | 逐项 ledger、写后读回、补偿恢复 | P1 |
|
||||
| 收信规则、白名单、黑名单、自动回复 | `+inbox-policy-audit` | 只读汇总优先;写操作强确认和版本化 | P2 |
|
||||
| 邮箱日历 | `+mail-calendar-conflicts` | 与主 Calendar 的 ownership boundary 明确,禁止双写 | P2 |
|
||||
| 发送状态与召回 | `+delivery-audit` | 终态、收件人粒度、召回结果和不可逆提示 | P1 |
|
||||
| 附件导出与分享 | `+archive-message` | 精确 messageId、原子本地写入、敏感路径与清理 | P2 |
|
||||
|
||||
## 6. 无需下游变更的上游修复
|
||||
|
||||
| Shortcut | 上游根因 | 已完成修复 | 回归证据 |
|
||||
|---|---|---|---|
|
||||
| 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID;无双态 fixture 的 leaf 不发布 | deterministic 响应矩阵;live 证据逐 leaf 记录,不作泛化 |
|
||||
| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | 各完成 known-nonempty 20;3 个 fresh 零命中 raw 均为 `total=0` + 无稳定 ID/正文且收件字段全空的 reviewed sentinel + terminal cursor,exact 才归一化为显式 `[]`;不把该下游特例描述成 raw 空数组 |
|
||||
| `+search-mail` / `+triage` 自动邮箱解析 | 严格化时只接受顶层对象数组,会拒绝历史已观测的字符串数组和 `result/data.emailAccounts` 包装 | 仅接受三个审核路径 `emailAccounts` / `result.emailAccounts` / `data.emailAccounts`,每项可为非空邮箱字符串或含非空 `email` 的对象;缺集合、错型、坏项或多路径冲突全部 fail-closed;空发件人也不再投影为空字符串成功 | top/result/data × string/object、blank/wrong/multiple-path 与 sender missing/null/wrong-type 回归覆盖;最终 live 未传 `--email` 执行 `+search-mail`/`+triage`,owning 响应为顶层 object-item 形态并成功解析 |
|
||||
| `+unread-mail` / `+recent-mail` / `+thread-list` | 固定条件或文件夹不能保证零命中 | 严格响应代码已完成,但没有空邮箱/空文件夹证据时关闭发布 | BLOCKED fixture;不得修改真实邮件状态造空 |
|
||||
| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | 各完成 known-nonempty 20 + fresh raw 显式空;stable identity set 与 raw pagination/meta 精确一致;`+user-search` 同轮实跑历史 string `--limit` |
|
||||
| `+tag-list` / `+template-list` / `+contact-list` | 无 query 的列表容易把末页/删除后列表误作合法空 | 严格响应代码已完成;无专用空邮箱和 typed cleanup 时关闭发布 | BLOCKED fixture;不把临时资源从列表消失记为零态 PASS |
|
||||
| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | `+message`/`+thread` 与同稳定 ID raw 完整对象一致;`+messages` 用两个不同 ID 验证输入顺序与逐对象一致 |
|
||||
| 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对;清理无法证明时保持 unavailable | deterministic 回执/读回矩阵 PASS;live cleanup BLOCKED |
|
||||
|
||||
### 6.1 clean executable HEAD 双层证据
|
||||
|
||||
| 公开入口 | exact Shortcut + owning raw 证据 | 状态 |
|
||||
|---|---|---|
|
||||
| `+search-mail`, `+triage` | 各 20 条 known-nonempty;3 个独立 fresh 零命中由 raw `total=0`、无稳定 ID/正文的单 sentinel 与 terminal cursor 共同证明,exact 严格归一化为显式空;稳定 message ID 集合和分页状态一致 | `PASS_WITH_REVIEWED_ZERO_ENCODING`;最终 SHA 见 PR 证据 |
|
||||
| `+user-search`, `+find-mail-user` | 各 20 条 known-nonempty 与 raw 显式 fresh zero;条件身份集合和分页状态一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+folder-list` | 顶层 5 条 nonempty;本轮先由 raw 验证同一父文件夹确实为空,再由 Shortcut 返回显式空;ID 集合一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+message`, `+messages`, `+thread` | 单邮件/会话同稳定 ID 完整对象一致;批量用两个不同 ID 验证请求顺序和逐对象一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
|
||||
8 个公开入口均在最终 clean runtime tree 从零重跑;其中 6 个使用标准 raw 显式空或精确对象证据,2 个邮件搜索使用上述审核过的下游零命中 sentinel 编码。最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。
|
||||
|
||||
## 7. 安全与脱敏声明
|
||||
|
||||
- 本文不含用户、组织、租户、profile、邮箱、人员姓名、邮件/会话/模板/联系人/聊天真实 ID。
|
||||
- 本文不含邮件主题正文、收发件人、trace/request ID、token、签名 URL、电话或真实业务时间。
|
||||
- 真实 E2E 原始响应仅在仓库外临时目录解析;普通输出只保留能力标签、计数和布尔断言。
|
||||
- 临时草稿虽已执行两次 batch-delete 但仍可按同 ID 读取;临时模板删除后也未获得 typed nonfound。两者都不记为清理 PASS,四个写 Shortcut 因此保持 unavailable。
|
||||
- 当前邮箱没有已验证的空邮件文件夹或专用空邮箱;因此 `+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 不记 live 双态 PASS,并保持 unavailable。
|
||||
- 最终提交前仍需扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。
|
||||
+227
-110
@@ -1,7 +1,17 @@
|
||||
{
|
||||
"generated_at": "2026-08-19T10:35:58.304269",
|
||||
"count": 423,
|
||||
"generated_at": "2026-08-24T12:22:05.108140",
|
||||
"count": 426,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aisearch",
|
||||
"command": "+search-person",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "对 enterprise_person_search 增加显式 success/result 数组、坏元素、来源类型和稳定人员身份校验;exact live 已同时证明已知非空与 phone 维度不可存在号码的显式零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
@@ -272,6 +282,76 @@
|
||||
"semantic_delta": "更新仪表盘配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-get-config",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取已有数据源表的同步配置详情(源配置、字段选择、自动同步状态)的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-get-fields",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键),用于决定 field-ids。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-list-sources",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "列出指定 Base 下可用的数据源条目(OA 审批模板等),提取 processCode/name/iconUrl/url 用于 sourceConfig。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-sync",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-sync-status",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "更新已有数据源表的同步配置并触发一次同步;不改配置可只切换 auto 开关或 field-ids。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
@@ -2263,102 +2343,134 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+by-mobile",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "使用专用手机号精确查询接口解析稳定 userId;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,未命中返回 typed nonzero;命中后读取并精确核对同一用户详情,null、错型、坏身份或详情 ID 不一致均失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+dept-members",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按部门名唯一解析 deptId 后列直属成员;搜索候选和成员集合均逐项严格校验,绝不猜测多匹配。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+list-dept-members",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按 deptId 列直属成员,严格要求显式 deptUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+list-followings",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格要求 success、result.models 数组、对象元素、唯一稳定 openDingTalkId;可用 --open-id 做本地精确筛选,exact live 已证明已知非空与保证零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+list-role-members",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按角色列成员,严格要求 success、显式 labelUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "contact",
|
||||
"command": "+list-roles",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+list-sub-depts",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按父部门列直属子部门,严格要求显式 result 数组与有效 deptId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+lookup",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名唯一解析稳定 userId 后读取并核对唯一用户详情;零命中和多命中均错误关闭。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+me",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "读取当前用户唯一详情并严格要求 orgEmployeeModel 与稳定 userId,再投影最小自身份字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+org",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析用户、核对用户详情与主 deptId,再读取并核对部门详情的稳定身份。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+resolve-dept",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按名称返回唯一 deptId 或显式候选;严格要求 deptList 数组、有效且不重复的 deptId 与部门名。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+search-mobile",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "使用专用手机号精确查询接口取得稳定 userId,并直接投影该接口返回的受审身份字段,不额外依赖用户详情权限;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,null、空对象、数组或坏身份均失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+search-user",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按姓名搜索并严格要求 success、显式 result 数组、非空对象和稳定 userId/openDingTalkId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+team",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析用户和主部门后列直属成员;每一步校验 success、稳定身份及显式成员集合。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
@@ -2494,32 +2606,14 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "ding",
|
||||
"command": "+list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"service": "ding",
|
||||
"command": "+recall-personal",
|
||||
"risk": "high-risk-write",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "ding",
|
||||
"command": "+receiver-status",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"service": "ding",
|
||||
"command": "+send-personal",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3602,81 +3696,84 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+list-cc",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+list-executed",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+list-forms",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+list-pending",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+list-submitted",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+my-initiated",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "oa",
|
||||
"command": "+search-forms",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "pat",
|
||||
"command": "+browser-policy",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在隔离本地策略文件上提供显式确认、无写入请求预览、同目标磁盘读回和不暴露 agent identity 的统一结果;exact 写入与清理已通过。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "report",
|
||||
"command": "+inbox-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;current HEAD exact 与 owning atomic 同场景已知页均为 20 项、稳定身份集合和 next cursor 一致,独立未来范围均为 0 且明确终止。终止页回显 cursor 只作已验证收据且不发布 next_token。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "report",
|
||||
"command": "+outbox-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;current HEAD exact 与 owning atomic 同场景已知页均为 1 项且身份一致,独立未来范围均为 0 并明确终止。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "report",
|
||||
"command": "+report-latest",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;current HEAD exact 所选稳定 reportId 与 owning atomic 候选和精确详情身份一致,严格详情字段计数双层均为 3。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "report",
|
||||
"command": "+template-search",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;current HEAD exact 与 owning atomic 完整集合过滤的已知结果均为 1 且身份一致,随机 UUID 查询均为 0。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "sheet",
|
||||
"command": "+list-sheets",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格要求 success=true、显式 sheets 数组、非空且唯一的 sheetId 与标题;提供完整标题本地精确筛选,因此可分别证明已知非空和合法零命中,未知结构绝不降级为空数组。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "sheet",
|
||||
"command": "+read",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格校验 success、二维 cells、行列坐标与完成证据;服务返回 hasMore=true 时因没有可执行续页游标而失败关闭,保留 Sheet 读取与 AITable/Base 记录查询的产品边界。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3888,6 +3985,26 @@
|
||||
"semantic_delta": "更新指定字段后读取详情逐字段核验。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "whiteboard",
|
||||
"command": "+query",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格投影要求 success=true、OpenNodes V1、显式 pages 数组、每页稳定 id 与显式 nodes 数组,并校验跨页节点身份及服务端完整性摘要。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "whiteboard",
|
||||
"command": "+update",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "首次远端调用前完成 OpenNodes V1 校验与用户确认;写后要求 success=true、同一目标、非空终态回执、createdNodeIds/idMap 精确映射,再按真实节点身份独立 query 读回请求关键字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
|
||||
@@ -10,6 +10,7 @@ require (
|
||||
github.com/charmbracelet/bubbletea v1.3.6
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
|
||||
@@ -372,7 +372,7 @@ func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t
|
||||
{path: "chat +conversation-category-list", candidates: []string{"chat +category-list", "chat +category-list-conversations"}},
|
||||
{path: "chat +conversation-group-list", candidates: []string{"chat +category-list-conversations", "chat +conversation-list"}},
|
||||
{path: "chat +list-my-groups", candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"}},
|
||||
{path: "oa +list-processes", candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}},
|
||||
{path: "oa +list-processes", candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.path, func(t *testing.T) {
|
||||
|
||||
@@ -1295,6 +1295,17 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
|
||||
}
|
||||
|
||||
func stopPersonalConsumers(w io.Writer, ipcEndpoint string, subscribeIDs []string) error {
|
||||
hasTarget := false
|
||||
for _, id := range subscribeIDs {
|
||||
if strings.TrimSpace(id) != "" {
|
||||
hasTarget = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasTarget {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := personalStopConsumers(ipcEndpoint, subscribeIDs); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, busctl.ErrConsumerStopUnsupported) {
|
||||
|
||||
@@ -734,7 +734,7 @@ func TestCrossPlatformCoverageRunPersonalEventConsumeManySetupAndCleanupEdges(t
|
||||
})
|
||||
}
|
||||
|
||||
func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
|
||||
func TestCrossPlatformCoverageStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
|
||||
oldStop := personalStopConsumers
|
||||
oldQuery := personalQueryStatus
|
||||
oldFind := personalFindProcess
|
||||
@@ -746,6 +746,18 @@ func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
|
||||
personalSignalProcess = oldSignal
|
||||
}()
|
||||
|
||||
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
||||
t.Fatal("targeted stop called without a subscribe_id")
|
||||
return transport.ConsumerStopResp{}, nil
|
||||
}
|
||||
personalQueryStatus = func(string) (*transport.StatusResp, error) {
|
||||
t.Fatal("legacy status queried without a subscribe_id")
|
||||
return nil, nil
|
||||
}
|
||||
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"", " "}); err != nil {
|
||||
t.Fatalf("empty target stop = %v", err)
|
||||
}
|
||||
|
||||
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
||||
return transport.ConsumerStopResp{Stopped: []string{"sub-a"}}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSheetWhiteboardMarkdownRoutes(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, root)
|
||||
assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t, root, tools)
|
||||
assertMarkdownDriveRoutesStayCrossProduct(t, root, tools)
|
||||
assertWhiteboardPublicShortcutsStayAvailableInSchema(t, root, tools)
|
||||
}
|
||||
|
||||
func assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
|
||||
t.Helper()
|
||||
registered := 0
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "markdown" {
|
||||
registered++
|
||||
}
|
||||
}
|
||||
if registered != 0 {
|
||||
t.Fatalf("registered Markdown Shortcuts=%d, want 0: existing composite leaves own these workflows", registered)
|
||||
}
|
||||
|
||||
type route struct {
|
||||
canonical string
|
||||
confirmation string
|
||||
flags []string
|
||||
}
|
||||
routes := map[string]route{
|
||||
"create": {
|
||||
canonical: "markdown.create", confirmation: "not_required",
|
||||
flags: []string{"content", "file", "folder", "name", "space-id", "workspace"},
|
||||
},
|
||||
"fetch": {
|
||||
canonical: "markdown.fetch", confirmation: "not_required",
|
||||
flags: []string{"node", "output", "space-id", "workspace"},
|
||||
},
|
||||
"overwrite": {
|
||||
canonical: "markdown.overwrite", confirmation: "user_required",
|
||||
flags: []string{"content", "dry-run", "file", "name", "node", "space-id", "workspace"},
|
||||
},
|
||||
"patch": {
|
||||
canonical: "markdown.patch", confirmation: "user_required",
|
||||
flags: []string{"content", "dry-run", "node", "pattern", "regex", "space-id", "workspace"},
|
||||
},
|
||||
"diff": {
|
||||
canonical: "markdown.diff", confirmation: "not_required",
|
||||
flags: []string{"context", "file", "node", "version", "version2"},
|
||||
},
|
||||
}
|
||||
|
||||
group := mustFindCommand(t, root, "markdown")
|
||||
children := map[string]bool{}
|
||||
for _, child := range group.Commands() {
|
||||
children[child.Name()] = true
|
||||
}
|
||||
if len(children) != len(routes) {
|
||||
t.Fatalf("Markdown ordinary leaves=%v, want exactly five routed workflows", children)
|
||||
}
|
||||
|
||||
for name, want := range routes {
|
||||
leaf := mustFindCommand(t, root, "markdown", name)
|
||||
if leaf.Hidden || !leaf.Runnable() {
|
||||
t.Errorf("markdown %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable())
|
||||
}
|
||||
if !children[name] {
|
||||
t.Errorf("markdown %s is not mounted on the ordinary product group", name)
|
||||
}
|
||||
for _, flag := range want.flags {
|
||||
if leaf.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("markdown %s is missing routed flag --%s", name, flag)
|
||||
}
|
||||
}
|
||||
if shortcut.InPublicCatalog("markdown", "+"+name) {
|
||||
t.Errorf("markdown +%s unexpectedly entered the public Shortcut catalog", name)
|
||||
}
|
||||
|
||||
meta, ok := cli.ResolveMeta("markdown " + name)
|
||||
if !ok {
|
||||
t.Errorf("markdown %s missing from assembled Schema", name)
|
||||
continue
|
||||
}
|
||||
if meta.Identity.Canonical != want.canonical || meta.Identity.CLIPath != "markdown "+name {
|
||||
t.Errorf("markdown %s identity=%#v, want canonical=%q cli_path=%q", name, meta.Identity, want.canonical, "markdown "+name)
|
||||
}
|
||||
if meta.Safety.Confirmation != want.confirmation {
|
||||
t.Errorf("markdown %s confirmation=%q, want %q", name, meta.Safety.Confirmation, want.confirmation)
|
||||
}
|
||||
|
||||
tool := tools[want.canonical]
|
||||
if tool == nil {
|
||||
t.Errorf("markdown %s missing from full delivery Schema", name)
|
||||
continue
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("markdown %s availability=%q, want available", name, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
|
||||
t.Errorf("markdown %s interface_mode=%q, want composite", name, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got == "" {
|
||||
t.Errorf("markdown %s is missing the reviewed composite routing reason", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertMarkdownDriveRoutesStayCrossProduct(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
|
||||
t.Helper()
|
||||
driveShortcuts := map[string]string{
|
||||
"+copy": "drive.shortcut_copy",
|
||||
"+delete": "drive.shortcut_delete",
|
||||
"+find-file": "drive.shortcut_find_file",
|
||||
"+list": "drive.shortcut_list",
|
||||
"+move": "drive.shortcut_move",
|
||||
"+publish-get": "drive.shortcut_publish_get",
|
||||
"+recycle-restore": "drive.shortcut_recycle_restore",
|
||||
"+rename": "drive.shortcut_rename",
|
||||
"+version-download": "drive.shortcut_version_download",
|
||||
"+version-get": "drive.shortcut_version_get",
|
||||
"+version-history": "drive.shortcut_version_history",
|
||||
"+version-revert": "drive.shortcut_version_revert",
|
||||
}
|
||||
for name, canonical := range driveShortcuts {
|
||||
leaf := mustFindCommand(t, root, "drive", name)
|
||||
if leaf.Hidden || !leaf.Runnable() {
|
||||
t.Errorf("drive %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable())
|
||||
}
|
||||
if !shortcut.InPublicCatalog("drive", name) {
|
||||
t.Errorf("drive %s is not in the public Shortcut catalog", name)
|
||||
}
|
||||
assertMarkdownCrossProductRoute(t, tools, "drive "+name, canonical)
|
||||
}
|
||||
|
||||
ordinaryRoutes := map[string]string{
|
||||
"drive permission list": "drive.list_permission",
|
||||
"drive pull": "drive.folder_pull",
|
||||
"drive push": "drive.folder_push",
|
||||
"drive status": "drive.folder_status",
|
||||
"drive sync": "drive.folder_sync",
|
||||
"wiki node list": "wiki.list_nodes",
|
||||
}
|
||||
for cliPath, canonical := range ordinaryRoutes {
|
||||
assertMarkdownCrossProductRoute(t, tools, cliPath, canonical)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMarkdownCrossProductRoute(t *testing.T, tools map[string]map[string]any, cliPath, canonical string) {
|
||||
t.Helper()
|
||||
meta, ok := cli.ResolveMeta(cliPath)
|
||||
if !ok {
|
||||
t.Errorf("cross-product route %q is missing from assembled Schema", cliPath)
|
||||
return
|
||||
}
|
||||
if meta.Identity.Canonical != canonical || meta.Identity.CLIPath != cliPath {
|
||||
t.Errorf("cross-product route %q identity=%#v, want canonical=%q", cliPath, meta.Identity, canonical)
|
||||
}
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Errorf("cross-product route %q is missing from full delivery Schema", cliPath)
|
||||
return
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("cross-product route %q availability=%q, want available", cliPath, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func TestOAFinalSchemaAvailabilityMatchesReviewedExecution(t *testing.T) {
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
for _, canonical := range []string{
|
||||
"oa.shortcut_approve_by",
|
||||
"oa.shortcut_done_approvals",
|
||||
"oa.shortcut_list_cc",
|
||||
"oa.shortcut_list_executed",
|
||||
"oa.shortcut_list_forms",
|
||||
"oa.shortcut_list_pending",
|
||||
"oa.shortcut_list_submitted",
|
||||
"oa.shortcut_my_initiated",
|
||||
"oa.shortcut_pending",
|
||||
"oa.shortcut_search_forms",
|
||||
} {
|
||||
tool, ok := snapshot.Tools[canonical]
|
||||
if !ok {
|
||||
t.Errorf("final Schema lacks OA tool %s", canonical)
|
||||
continue
|
||||
}
|
||||
if got := tool["availability"]; got != contract.InterfaceAvailable {
|
||||
t.Errorf("%s final availability=%v, want %q", canonical, got, contract.InterfaceAvailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,34 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
return `{"success":true,"result":[]}`
|
||||
case "list_suggested_event_times":
|
||||
return `{"success":true,"result":{"recommendEventTimes":[]}}`
|
||||
case "list_by_keyword_and_time_range":
|
||||
return `{"success":true,"result":{"itemList":[{"taskUuid":"u1","startTime":1}]}}`
|
||||
case "get_minutes_basic_info":
|
||||
return `{"success":true,"result":{"taskUuid":"u1","title":"Fixture Minutes"}}`
|
||||
case "get_minutes_transcription":
|
||||
return `{"success":true,"result":{"paragraphList":[],"hasNext":false}}`
|
||||
case "create_personal_todo":
|
||||
return `{"success":true,"result":{"taskId":"task-1"}}`
|
||||
case "get_todo_detail":
|
||||
return `{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"Fixture Todo","isDone":false}}}`
|
||||
case "get_user_todos_in_current_org":
|
||||
return `{"success":true,"result":{"todoCards":[],"hasMore":false}}`
|
||||
case "add_todo_reminder":
|
||||
return `{"success":true}`
|
||||
case "copy_document":
|
||||
return `{"success":true,"nodeId":"copy-1"}`
|
||||
case "move_document", "add_member", "update_member", "remove_member":
|
||||
return `{"success":true}`
|
||||
case "get_document_info":
|
||||
if len(c.calls) > 1 {
|
||||
switch c.calls[len(c.calls)-2].tool {
|
||||
case "copy_document":
|
||||
return `{"success":true,"nodeId":"copy-1","workspaceId":"workspace-1","folderId":"folder-1"}`
|
||||
case "move_document":
|
||||
return `{"success":true,"nodeId":"node-1","workspaceId":"drive-1","folderId":"folder-1"}`
|
||||
}
|
||||
}
|
||||
return `{"success":true,"nodeId":"node-1","workspaceId":"source-1","folderId":"source-folder"}`
|
||||
case "create_calendar_event":
|
||||
return `{"success":true,"result":{"eventId":"event-1"}}`
|
||||
case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant":
|
||||
|
||||
@@ -153,7 +153,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"devdoc article search": {"devdoc", "article", "search", "--query", "fixture", "--page", "2", "--size", "7"},
|
||||
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
|
||||
"ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"},
|
||||
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"},
|
||||
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1"},
|
||||
"doc +comment-create": {"doc", "+comment-create", "--node", "node-1", "--content", "fixture comment", "--yes"},
|
||||
"doc +comment-list": {"doc", "+comment-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +comment-reply": {"doc", "+comment-reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--yes"},
|
||||
@@ -221,21 +221,142 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"drive search": {"drive", "search", "--query", "fixture", "--created-from", "1", "--created-to", "2", "--modified-from", "3", "--modified-to", "4", "--creator-uids", "user-1,user-2"},
|
||||
"drive upload": {"drive", "upload", "--file", "../../go.mod", "--space-id", "space-1"},
|
||||
"drive upload-info": {"drive", "upload-info", "--file-name", "fixture.txt", "--file-size", "7", "--space-id", "space-1"},
|
||||
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
|
||||
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", "--yes"},
|
||||
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder"},
|
||||
"mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
|
||||
"mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"},
|
||||
"mail user search": {"mail", "user", "search", "--keyword", "fixture"},
|
||||
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "1"},
|
||||
"oa +search-forms": {"oa", "+search-forms", "--query", "fixture"},
|
||||
"oa approval search-forms": {"oa", "approval", "search-forms", "--query", "fixture"},
|
||||
"report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"},
|
||||
}
|
||||
|
||||
// paramAliasCandidateCompleteCommands contains complete invocations for
|
||||
// reviewed parameter-concept product drafts. Keeping candidate-only commands
|
||||
// in a separate map lets a test change land before a draft replaces the formal
|
||||
// param_concepts.json: inactive candidate templates are ignored, while every
|
||||
// command becomes mandatory as soon as one of its reviewed aliases is active.
|
||||
var paramAliasCandidateCompleteCommands = map[string][]string{
|
||||
"attendance +check-record": {"attendance", "+check-record", "--users", "user-1,user-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
|
||||
"attendance +get-adjustment-rule": {"attendance", "+get-adjustment-rule", "--adjustment-id", "adjustment-1"},
|
||||
"attendance +get-approve-template": {"attendance", "+get-approve-template", "--type", "leave"},
|
||||
"attendance +get-checkin-record": {"attendance", "+get-checkin-record", "--operator-corp-id", "corp-1", "--operator-staff-id", "staff-operator", "--staff-ids", "staff-1,staff-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
|
||||
"attendance +get-leave-records": {"attendance", "+get-leave-records", "--user", "user-1", "--start", "2026-03-01", "--end", "2026-03-31", "--leave-code", "annual_leave"},
|
||||
"attendance +get-overtime-rule": {"attendance", "+get-overtime-rule", "--overtime-id", "overtime-1"},
|
||||
"attendance +get-schedule": {"attendance", "+get-schedule", "--users", "user-1,user-2", "--start", "2026-03-10", "--end", "2026-03-11"},
|
||||
"attendance +get-self-setting": {"attendance", "+get-self-setting", "--user", "user-1", "--setting-scene", "checkRemind"},
|
||||
"attendance +get-summary": {"attendance", "+get-summary", "--user", "user-1", "--date", "2026-03-10", "--stats-type", "week"},
|
||||
"attendance +list-approve": {"attendance", "+list-approve", "--users", "user-1,user-2", "--types", "leave", "--start", "2026-03-01", "--end", "2026-03-31"},
|
||||
"attendance +query-report-data": {"attendance", "+query-report-data", "--users", "user-1,user-2", "--columns", "attendance_days,late_count", "--start", "2026-03-01", "--end", "2026-03-31"},
|
||||
"attendance +search-adjustment-rule": {"attendance", "+search-adjustment-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
|
||||
"attendance +search-class": {"attendance", "+search-class", "--filter-type", "name", "--query", "fixture"},
|
||||
"attendance +search-group": {"attendance", "+search-group", "--type", "FIXED"},
|
||||
"attendance +search-overtime-rule": {"attendance", "+search-overtime-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
|
||||
"ding +list": {"ding", "+list", "--cursor", "0", "--type", "ALL"},
|
||||
"ding +recall-personal": {"ding", "+recall-personal", "--id", "ding-1", "--yes"},
|
||||
"ding +send-personal": {"ding", "+send-personal", "--users", appFixtureCurrentDOpenID, "--content", "fixture", "--yes"},
|
||||
"mail +contact-list": {"mail", "+contact-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +folder-list": {"mail", "+folder-list", "--email", "fixture@example.com", "--folder", "folder-1"},
|
||||
"mail +message": {"mail", "+message", "--email", "fixture@example.com", "--id", "message-1"},
|
||||
"mail +messages": {"mail", "+messages", "--email", "fixture@example.com", "--ids", "message-1,message-2"},
|
||||
"mail +recent-mail": {"mail", "+recent-mail", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +search-mail": {"mail", "+search-mail", "--query", "fixture", "--size", "7", "--cursor", "cursor-1"},
|
||||
"mail +template-list": {"mail", "+template-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +thread": {"mail", "+thread", "--email", "fixture@example.com", "--id", "thread-1"},
|
||||
"mail +thread-list": {"mail", "+thread-list", "--email", "fixture@example.com", "--folder", "folder-1", "--cursor", "cursor-1"},
|
||||
"mail +triage": {"mail", "+triage", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +unread-mail": {"mail", "+unread-mail", "--size", "7", "--cursor", "cursor-1"},
|
||||
"mail +user-search": {"mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"},
|
||||
"markdown create": {"markdown", "create", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1"},
|
||||
"markdown diff": {"markdown", "diff", "--node", "node-1", "--version", "1", "--version2", "2", "--context", "3"},
|
||||
"markdown fetch": {"markdown", "fetch", "--node", "node-1", "--space-id", "space-1", "--output", "/tmp/dws-markdown-fixture.md"},
|
||||
"markdown overwrite": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--yes"},
|
||||
"markdown patch": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--space-id", "space-1", "--yes"},
|
||||
"oa +list-cc": {"oa", "+list-cc", "--page", "2"},
|
||||
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "2"},
|
||||
"oa +list-forms": {"oa", "+list-forms", "--cursor", "2"},
|
||||
"oa +list-pending": {"oa", "+list-pending", "--start", "1773072000000", "--end", "1773158399000", "--page", "2"},
|
||||
"oa +list-submitted": {"oa", "+list-submitted", "--page", "2"},
|
||||
"oa +my-initiated": {"oa", "+my-initiated", "--page", "2"},
|
||||
"report +outbox-list": {"report", "+outbox-list", "--size", "7"},
|
||||
"report +report-latest": {"report", "+report-latest", "--keyword", "Fixture", "--start", "2026-03-01T00:00:00+08:00", "--end", "2026-03-10T00:00:00+08:00"},
|
||||
"report +template-search": {"report", "+template-search", "--query", "fixture"},
|
||||
"sheet +list-sheets": {"sheet", "+list-sheets", "--node", "node-1"},
|
||||
"sheet +read": {"sheet", "+read", "--node", "node-1", "--sheet-id", "Sheet1"},
|
||||
|
||||
"minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"},
|
||||
"minutes +latest": {"minutes", "+latest", "--keyword", "fixture"},
|
||||
"minutes +list-all": {"minutes", "+list-all", "--limit", "7"},
|
||||
"minutes +record-pause": {"minutes", "+record-pause", "--id", "u1", "--yes"},
|
||||
"minutes +replace-batch": {"minutes", "+replace-batch", "--id", "u1", "--pair", "old=>new", "--yes"},
|
||||
"minutes +search": {"minutes", "+search", "--query", "fixture", "--cursor", "cursor-1"},
|
||||
"minutes +share": {"minutes", "+share", "--ids", "u1,u2", "--member-uids", "user-1,user-2", "--permission", "view", "--yes"},
|
||||
"minutes +speaker-replace": {"minutes", "+speaker-replace", "--id", "u1", "--from", "old", "--to", "new", "--target-uid", "user-1", "--yes"},
|
||||
"minutes +summary": {"minutes", "+summary", "--id", "u1", "--content", "fixture", "--yes"},
|
||||
"minutes +transcript": {"minutes", "+transcript", "--keyword", "fixture"},
|
||||
"minutes +upload-and-analyze": {"minutes", "+upload-and-analyze", "--resume-id", "u1", "--yes"},
|
||||
"minutes audio-memo list": {"minutes", "audio-memo", "list", "--max", "7"},
|
||||
"minutes get batch": {"minutes", "get", "batch", "--ids", "u1,u2"},
|
||||
"minutes hot-word add": {"minutes", "hot-word", "add", "--words", "DWS,Minutes"},
|
||||
"minutes list all": {"minutes", "list", "all", "--end", "2026-03-10T23:59:59+08:00"},
|
||||
"minutes list mine": {"minutes", "list", "mine", "--start", "2026-03-10T00:00:00+08:00"},
|
||||
"minutes replace-text": {"minutes", "replace-text", "--id", "u1", "--search", "old", "--replace", "new"},
|
||||
"minutes tag query": {"minutes", "tag", "query", "--tag-id", "tag-1"},
|
||||
"minutes update title": {"minutes", "update", "title", "--id", "u1", "--title", "Fixture Minutes"},
|
||||
"minutes upload complete": {"minutes", "upload", "complete", "--session-id", "session-1"},
|
||||
"todo +assign": {"todo", "+assign", "--task", "Fixture Todo", "--to", "Fixture User", "--yes"},
|
||||
"todo +assign-multi": {"todo", "+assign-multi", "--task", "Fixture Todo", "--to", "Fixture User,User Two", "--yes"},
|
||||
"todo +comment": {"todo", "+comment", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
|
||||
"todo +complete": {"todo", "+complete", "--task-id", "task-1", "--yes"},
|
||||
"todo +create": {"todo", "+create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +due-today": {"todo", "+due-today", "--role-types", "executor"},
|
||||
"todo +get-my-tasks": {"todo", "+get-my-tasks", "--role-types", "executor", "--priority", "40", "--page", "2", "--size", "7"},
|
||||
"todo +get-related-tasks": {"todo", "+get-related-tasks", "--role-types", "creator,executor", "--status", "false"},
|
||||
"todo +list-comment": {"todo", "+list-comment", "--task-id", "task-1", "--page", "2"},
|
||||
"todo +remind": {"todo", "+remind", "--task", "Fixture Todo", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +reminder": {"todo", "+reminder", "--task-id", "task-1", "--base-time", "customTime", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +reopen": {"todo", "+reopen", "--task-id", "task-1", "--yes"},
|
||||
"todo +search": {"todo", "+search", "--query", "fixture", "--status", "false"},
|
||||
"todo +todo-done": {"todo", "+todo-done", "--task", "Fixture Todo", "--yes"},
|
||||
"todo +update": {"todo", "+update", "--task-id", "task-1", "--title", "Fixture Updated Todo", "--yes"},
|
||||
"todo comment add": {"todo", "comment", "add", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
|
||||
"todo comment list": {"todo", "comment", "list", "--task-id", "task-1", "--page", "2", "--size", "7"},
|
||||
"todo task add-executor": {"todo", "task", "add-executor", "--task-id", "task-1", "--executors", "user-1,user-2", "--yes"},
|
||||
"todo task add-participant": {"todo", "task", "add-participant", "--task-id", "task-1", "--participants", "user-1,user-2", "--yes"},
|
||||
"todo task add-reminder": {"todo", "task", "add-reminder", "--task-id", "task-1", "--base-time", "customTime", "--reminder-time-stamp", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo task create": {"todo", "task", "create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo task create-sub": {"todo", "task", "create-sub", "--parent-id", "task-parent", "--title", "Fixture Sub Todo", "--executors", "user-1", "--yes"},
|
||||
"todo task done": {"todo", "task", "done", "--task-id", "task-1", "--status", "true", "--yes"},
|
||||
"todo task get": {"todo", "task", "get", "--task-id", "task-1"},
|
||||
"todo task list": {"todo", "task", "list", "--role-types", "executor", "--page", "2", "--size", "7"},
|
||||
"todo task update": {"todo", "task", "update", "--task-id", "task-1", "--done", "true", "--yes"},
|
||||
"wiki +member-add": {"wiki", "+member-add", "--workspace", "workspace-1", "--user", "user-1", "--role", "READER", "--yes"},
|
||||
"wiki +member-remove": {"wiki", "+member-remove", "--workspace", "workspace-1", "--user", "user-1", "--yes"},
|
||||
"wiki +member-update": {"wiki", "+member-update", "--workspace", "workspace-1", "--user", "user-1", "--role", "EDITOR", "--yes"},
|
||||
"wiki +move": {"wiki", "+move", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +move-to-drive": {"wiki", "+move-to-drive", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +node-copy": {"wiki", "+node-copy", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +node-delete": {"wiki", "+node-delete", "--workspace", "workspace-1", "--node", "node-1", "--yes"},
|
||||
}
|
||||
|
||||
// A command can expose more than one mutually exclusive canonical route. In
|
||||
// that case the shared command template above cannot contain every canonical
|
||||
// flag at once, so select a fixture-specific complete invocation here.
|
||||
var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"markdown create": {
|
||||
"file": {"markdown", "create", "--file", "../../README.md", "--name", "fixture.md", "--space-id", "space-1"},
|
||||
},
|
||||
"markdown diff": {
|
||||
"file": {"markdown", "diff", "--node", "node-1", "--file", "../../README.md", "--context", "3"},
|
||||
},
|
||||
"markdown overwrite": {
|
||||
"file": {"markdown", "overwrite", "--node", "node-1", "--file", "../../README.md", "--name", "fixture.md", "--space-id", "space-1", "--yes"},
|
||||
"dry-run": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--dry-run"},
|
||||
},
|
||||
"markdown patch": {
|
||||
"dry-run": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--dry-run"},
|
||||
},
|
||||
|
||||
"doc +copy": {
|
||||
"folder": {"doc", "+copy", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
|
||||
@@ -531,6 +652,20 @@ var paramAliasNewConfirmationCases = []struct {
|
||||
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
|
||||
}
|
||||
|
||||
// Candidate confirmation cases become active with the joint draft. One write
|
||||
// workflow per product plus TODO's reminder workflow proves semantic aliasing
|
||||
// cannot move execution across the shared --yes barrier.
|
||||
var paramAliasCandidateConfirmationCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "minutes +record-pause", emitted: "uuid", canonical: "id"},
|
||||
{command: "todo +create", emitted: "deadline", canonical: "due"},
|
||||
{command: "todo +reminder", emitted: "reminder-time-stamp", canonical: "at"},
|
||||
{command: "wiki +node-copy", emitted: "node-id", canonical: "node"},
|
||||
}
|
||||
|
||||
// paramAliasRepresentativePayloadCases keeps final transport coverage across
|
||||
// old concept aliases, command overrides, native compatibility flags, read and
|
||||
// write commands, and different products. Every reviewed alias is still
|
||||
@@ -600,6 +735,30 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
|
||||
// Candidate representatives exercise the final transport boundary for each
|
||||
// Minutes/TODO/Wiki alias family. They are required only when the exact fixture
|
||||
// exists in the loaded reviewed table, so the tests are mergeable before the
|
||||
// joint draft is promoted to internal/cli/param_concepts.json.
|
||||
var paramAliasCandidateRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("minutes +latest", "query"): true,
|
||||
paramAliasPayloadCaseKey("minutes +transcript", "query"): true,
|
||||
paramAliasPayloadCaseKey("minutes get batch", "uuids"): true,
|
||||
paramAliasPayloadCaseKey("minutes update title", "task-uuid"): true,
|
||||
paramAliasPayloadCaseKey("minutes upload complete", "upload-id"): true,
|
||||
paramAliasPayloadCaseKey("todo +create", "deadline"): true,
|
||||
paramAliasPayloadCaseKey("todo +get-my-tasks", "current-page"): true,
|
||||
paramAliasPayloadCaseKey("todo +reminder", "reminder-time-stamp"): true,
|
||||
paramAliasPayloadCaseKey("todo comment add", "text"): true,
|
||||
paramAliasPayloadCaseKey("todo task add-executor", "executor-ids"): true,
|
||||
paramAliasPayloadCaseKey("todo task get", "todo-id"): true,
|
||||
paramAliasPayloadCaseKey("todo task update", "status"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-add", "user-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-remove", "uid"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-update", "user-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +move-to-drive", "node-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +node-copy", "node-id"): true,
|
||||
}
|
||||
|
||||
// paramAliasCalendarPayloadCases keeps the full reviewed Calendar expansion
|
||||
// separate from the long-lived app-c race process. Each case still executes
|
||||
// both canonical and alias argv through the real PreParse/Cobra path and
|
||||
@@ -709,6 +868,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
}
|
||||
|
||||
activeCommands := make(map[string]bool)
|
||||
activeFixtureCases := make(map[string]bool)
|
||||
activeCases := 0
|
||||
executedRepresentatives := make(map[string]bool)
|
||||
for _, fixture := range concepts.Fixture {
|
||||
@@ -717,6 +877,8 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
}
|
||||
activeCommands[fixture.Command] = true
|
||||
activeCases++
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
activeFixtureCases[caseKey] = true
|
||||
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
|
||||
if !ok {
|
||||
t.Errorf("reviewed active fixture %q/%q has no complete-command E2E template", fixture.Command, fixture.Emitted)
|
||||
@@ -729,8 +891,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
continue
|
||||
}
|
||||
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
if !paramAliasRepresentativePayloadCases[caseKey] {
|
||||
if !paramAliasRepresentativePayloadCases[caseKey] && !paramAliasCandidateRepresentativePayloadCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
executedRepresentatives[caseKey] = true
|
||||
@@ -742,26 +903,43 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
if activeCases == 0 {
|
||||
t.Fatal("reviewed fixture contains no active alias cases")
|
||||
}
|
||||
templateCommands := make(map[string]bool, len(paramAliasCompleteCommands)+len(paramAliasCandidateCompleteCommands))
|
||||
for command := range paramAliasCompleteCommands {
|
||||
if !activeCommands[command] {
|
||||
t.Errorf("complete-command E2E template %q has no active reviewed fixture", command)
|
||||
}
|
||||
templateCommands[command] = true
|
||||
}
|
||||
for command := range paramAliasCandidateCompleteCommands {
|
||||
if activeCommands[command] {
|
||||
templateCommands[command] = true
|
||||
}
|
||||
}
|
||||
for command := range activeCommands {
|
||||
if _, ok := paramAliasCompleteCommands[command]; !ok {
|
||||
if !templateCommands[command] {
|
||||
t.Errorf("active reviewed command %q has no complete-command E2E template", command)
|
||||
}
|
||||
}
|
||||
if len(activeCommands) != len(paramAliasCompleteCommands) {
|
||||
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(paramAliasCompleteCommands), len(activeCommands), activeCases)
|
||||
if len(activeCommands) != len(templateCommands) {
|
||||
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(templateCommands), len(activeCommands), activeCases)
|
||||
}
|
||||
for caseKey := range paramAliasRepresentativePayloadCases {
|
||||
if !executedRepresentatives[caseKey] {
|
||||
t.Errorf("representative final-payload case %q has no active reviewed fixture", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executedRepresentatives) != len(paramAliasRepresentativePayloadCases) {
|
||||
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), len(paramAliasRepresentativePayloadCases))
|
||||
activeRepresentatives := len(paramAliasRepresentativePayloadCases)
|
||||
for caseKey := range paramAliasCandidateRepresentativePayloadCases {
|
||||
if !activeFixtureCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
activeRepresentatives++
|
||||
if !executedRepresentatives[caseKey] {
|
||||
t.Errorf("candidate representative final-payload case %q was not executed", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executedRepresentatives) != activeRepresentatives {
|
||||
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), activeRepresentatives)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,6 +1030,121 @@ func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageEightProductTemplatedParamAliasesCannotBypassConfirmation
|
||||
// exercises every distinct reviewed mutating complete-command template in the
|
||||
// eight-product Shortcut expansion. The fixture gate already proves every
|
||||
// alias resolves through PreParse; this gate removes the confirmation flag
|
||||
// from one active alias invocation per distinct template and requires the
|
||||
// runtime boundary to stop it before the first transport call. An explicit
|
||||
// --dry-run is a reviewed preview path and must not carry a bypass flag.
|
||||
func TestCrossPlatformCoverageEightProductTemplatedParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
|
||||
requiredTemplates := make(map[string]bool)
|
||||
coveredTemplates := make(map[string]bool)
|
||||
for _, fixture := range concepts.Fixture {
|
||||
if strings.HasPrefix(fixture.Expect, "did-you-mean:") {
|
||||
continue
|
||||
}
|
||||
product, _, _ := strings.Cut(fixture.Command, " ")
|
||||
switch product {
|
||||
case "attendance", "mail", "oa", "ding", "report", "sheet", "whiteboard", "markdown":
|
||||
default:
|
||||
continue
|
||||
}
|
||||
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
_, yesCount := removeExactArg(complete, "--yes")
|
||||
_, userSayYesCount := removeExactArg(complete, "--user-say-yes")
|
||||
confirmationCount := yesCount + userSayYesCount
|
||||
confirmationArg := "--yes"
|
||||
if userSayYesCount == 1 {
|
||||
confirmationArg = "--user-say-yes"
|
||||
}
|
||||
_, dryRunCount := removeExactArg(complete, "--dry-run")
|
||||
if dryRunCount > 1 {
|
||||
t.Errorf("template must contain --dry-run at most once: command=%q args=%v", fixture.Command, complete)
|
||||
continue
|
||||
}
|
||||
if meta, exists := cli.ResolveMeta(fixture.Command); exists {
|
||||
switch meta.Safety.Confirmation {
|
||||
case "user_required":
|
||||
if dryRunCount == 1 {
|
||||
if confirmationCount != 0 {
|
||||
t.Errorf("Schema-confirmed dry-run template must not contain a confirmation bypass flag: command=%q args=%v", fixture.Command, complete)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if confirmationCount != 1 {
|
||||
t.Errorf("Schema-confirmed template must contain exactly one reviewed confirmation flag: command=%q confirmation=%q args=%v", fixture.Command, meta.Safety.Confirmation, complete)
|
||||
continue
|
||||
}
|
||||
case "not_required":
|
||||
if confirmationCount != 0 {
|
||||
t.Errorf("Schema-unconfirmed template must not contain a confirmation bypass flag: command=%q confirmation=%q args=%v", fixture.Command, meta.Safety.Confirmation, complete)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if confirmationCount == 0 {
|
||||
continue
|
||||
}
|
||||
if confirmationCount != 1 {
|
||||
t.Errorf("confirmation template must contain exactly one reviewed confirmation flag: command=%q args=%v", fixture.Command, complete)
|
||||
continue
|
||||
}
|
||||
|
||||
templateKey := fixture.Command + "\x00" + strings.Join(complete, "\x00")
|
||||
requiredTemplates[templateKey] = true
|
||||
if coveredTemplates[templateKey] {
|
||||
continue
|
||||
}
|
||||
aliasArgs, replacements := replaceLongFlag(complete, fixture.Expect, fixture.Emitted)
|
||||
if replacements != 1 {
|
||||
t.Errorf("confirmation template for %q/%q must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Command, fixture.Emitted, fixture.Expect, replacements, complete)
|
||||
continue
|
||||
}
|
||||
coveredTemplates[templateKey] = true
|
||||
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
|
||||
assertTemplatedParamAliasCannotBypassConfirmation(t, fixture.Command, confirmationArg, aliasArgs)
|
||||
})
|
||||
}
|
||||
|
||||
if len(requiredTemplates) == 0 {
|
||||
t.Fatal("reviewed complete-command templates contain no confirmation cases")
|
||||
}
|
||||
if len(coveredTemplates) != len(requiredTemplates) {
|
||||
t.Fatalf("templated confirmation coverage = %d, want %d", len(coveredTemplates), len(requiredTemplates))
|
||||
}
|
||||
}
|
||||
|
||||
func assertTemplatedParamAliasCannotBypassConfirmation(t *testing.T, command, confirmationArg string, aliasArgs []string) {
|
||||
t.Helper()
|
||||
unconfirmedArgs, removals := removeExactArg(aliasArgs, confirmationArg)
|
||||
if removals != 1 {
|
||||
t.Fatalf("confirmation template must contain %s exactly once; removals=%d args=%v", confirmationArg, removals, aliasArgs)
|
||||
}
|
||||
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...)
|
||||
if ctx == nil {
|
||||
t.Fatal("unconfirmed alias command skipped PreParse")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if errors.As(err, &appErr) && appErr.Reason == "confirmation_required" {
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed alias crossed the transport boundary before confirmation: args=%v calls=%#v", unconfirmedArgs, caller.calls)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("unconfirmed alias command error = %#v, want confirmation_required\ncommand=%q args=%v calls=%#v", err, command, unconfirmedArgs, caller.calls)
|
||||
}
|
||||
|
||||
func assertParamAliasFinalPayloadEquivalent(t *testing.T, command string, canonicalArgs, aliasArgs []string) {
|
||||
t.Helper()
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
@@ -1091,7 +1384,20 @@ func TestCrossPlatformCoverageNewAITableDeleteDisableAliasesPreserveConfirmation
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
for _, test := range paramAliasNewConfirmationCases {
|
||||
tests := append([]struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{}, paramAliasNewConfirmationCases...)
|
||||
for _, candidate := range paramAliasCandidateConfirmationCases {
|
||||
entry, exists := cli.LookupParamAlias(candidate.command)
|
||||
target, active := entry.ResolveAlias(candidate.emitted)
|
||||
if exists && active && target == candidate.canonical {
|
||||
tests = append(tests, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
|
||||
@@ -1173,6 +1479,10 @@ func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
|
||||
return variant, true
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return complete, true
|
||||
}
|
||||
complete, ok = paramAliasCandidateCompleteCommands[command]
|
||||
return complete, ok
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ func missingChatCatalogCoveragePaths() []string {
|
||||
"chat clear-messages",
|
||||
"chat clear-red-point",
|
||||
"chat data-auth cross-org",
|
||||
"chat emotion favorite",
|
||||
"chat emotion list",
|
||||
"chat emotion send",
|
||||
"chat group audit-join-validation",
|
||||
"chat group list-all",
|
||||
"chat group list-join-validations",
|
||||
|
||||
@@ -136,6 +136,53 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema 验证合并后的
|
||||
// upload 命令以 composite 接口模式交付:它内部串联 init/commit 两个 RPC 与本地 HTTP PUT,
|
||||
// 无法绑定单一 interface_ref,因此不进入上面按 mcp 模式断言的表驱动用例。
|
||||
func TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema(t *testing.T) {
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
tool := snapshot.Tools["oa.attachment_upload"]
|
||||
if tool == nil {
|
||||
t.Fatal("oa.attachment_upload is missing from final Schema")
|
||||
}
|
||||
if got := schemaContractString(tool["primary_cli_path"]); got != "oa approval attachment upload" {
|
||||
t.Fatalf("primary_cli_path = %q, want oa approval attachment upload", got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("interface_mode = %q, want composite", got)
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Fatalf("availability = %q, want available", got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got == "" {
|
||||
t.Fatal("composite upload command must document an interface reason")
|
||||
}
|
||||
if got := schemaContractString(tool["effect"]); got != "write" {
|
||||
t.Fatalf("effect = %q, want write", got)
|
||||
}
|
||||
if got := schemaContractString(tool["risk"]); got != "low" {
|
||||
t.Fatalf("risk = %q, want low", got)
|
||||
}
|
||||
if got := schemaContractString(tool["confirmation"]); got != "not_required" {
|
||||
t.Fatalf("confirmation = %q, want not_required", got)
|
||||
}
|
||||
parameters := schemaContractMap(tool["parameters"])
|
||||
for _, flag := range []string{"file", "file-name", "md5"} {
|
||||
if parameters[flag] == nil {
|
||||
t.Fatalf("upload --%s is missing from final Schema", flag)
|
||||
}
|
||||
}
|
||||
if required, _ := parameters["file"]["required"].(bool); !required {
|
||||
t.Fatalf("upload --file required = %#v, want true", parameters["file"]["required"])
|
||||
}
|
||||
result := schemaContractMap(tool["result"])
|
||||
dataSchema := schemaContractMap(result["data_schema"])
|
||||
properties := schemaContractMap(dataSchema["properties"])
|
||||
if properties["fileId"] == nil {
|
||||
t.Fatal("upload Result data_schema is missing fileId")
|
||||
}
|
||||
}
|
||||
|
||||
func oaAttachmentResultContract(t *testing.T, tool map[string]any, resultType string, fields map[string]string, sensitivePaths []string) map[string]any {
|
||||
t.Helper()
|
||||
result, ok := tool["result"].(map[string]any)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSheetFloatImageLocalFileFinalSchema(t *testing.T) {
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(),
|
||||
"sheet.create_float_image",
|
||||
"sheet.update_float_image",
|
||||
)
|
||||
|
||||
create := payload.Tools["sheet.create_float_image"]
|
||||
if create == nil {
|
||||
t.Fatal("missing sheet.create_float_image")
|
||||
}
|
||||
if create["interface_mode"] != "mcp" {
|
||||
t.Fatalf("create interface mode = %#v", create["interface_mode"])
|
||||
}
|
||||
createRef, _ := create["interface_ref"].(map[string]any)
|
||||
if createRef["product_id"] != "sheet" || createRef["rpc_name"] != "create_float_image" {
|
||||
t.Fatalf("create interface ref = %#v", createRef)
|
||||
}
|
||||
createDryRun, _ := create["dry_run"].(map[string]any)
|
||||
if createDryRun["preview_kind"] != "request" {
|
||||
t.Fatalf("create dry-run = %#v", createDryRun)
|
||||
}
|
||||
if remoteReads, exists := createDryRun["remote_reads"]; exists && remoteReads != false {
|
||||
t.Fatalf("create dry-run remote_reads = %#v", remoteReads)
|
||||
}
|
||||
createParameters, _ := create["parameters"].(map[string]any)
|
||||
file, _ := createParameters["file"].(map[string]any)
|
||||
src, _ := createParameters["src"].(map[string]any)
|
||||
if file["required"] != false || file["required_when"] != "exactly one of --file or --src must be provided" {
|
||||
t.Fatalf("create --file metadata = %#v", file)
|
||||
}
|
||||
if schemaContractString(file["property"]) != "" {
|
||||
t.Fatalf("create --file leaked an RPC property: %#v", file["property"])
|
||||
}
|
||||
if src["required"] != false || schemaContractString(src["required_when"]) != "" || src["property"] != "src" {
|
||||
t.Fatalf("create --src compatibility metadata = %#v", src)
|
||||
}
|
||||
assertSchemaContractConstraintGroup(t, create, "mutually_exclusive", []string{"file", "src"})
|
||||
assertSchemaContractConstraintGroup(t, create, "require_one_of", []string{"file", "src"})
|
||||
|
||||
update := payload.Tools["sheet.update_float_image"]
|
||||
if update == nil {
|
||||
t.Fatal("missing sheet.update_float_image")
|
||||
}
|
||||
updateDryRun, _ := update["dry_run"].(map[string]any)
|
||||
if update["interface_mode"] != "mcp" || updateDryRun["preview_kind"] != "request" {
|
||||
t.Fatalf("update interface/dry-run = %#v/%#v", update["interface_mode"], updateDryRun)
|
||||
}
|
||||
updateParameters, _ := update["parameters"].(map[string]any)
|
||||
updateFile, _ := updateParameters["file"].(map[string]any)
|
||||
if schemaContractString(updateFile["property"]) != "" {
|
||||
t.Fatalf("update --file leaked an RPC property: %#v", updateParameters["file"])
|
||||
}
|
||||
assertSchemaContractConstraintGroup(t, update, "mutually_exclusive", []string{"file", "src"})
|
||||
assertSchemaContractConstraintGroup(t, update, "require_one_of", []string{"file", "src", "range", "width", "height", "offset-x", "offset-y"})
|
||||
|
||||
root := NewRootCommand()
|
||||
for _, cliPath := range []string{"sheet create-float-image", "sheet update-float-image"} {
|
||||
command := exactCommandForTest(root, cliPath)
|
||||
if command == nil || command.Flags().Lookup("file") == nil {
|
||||
t.Fatalf("%s has no executable --file flag", cliPath)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 422
|
||||
publicShortcutCount = 425
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including the hidden historical minutes.shortcut_minutes_search contract.
|
||||
schemaPublishedShortcutCount = 447
|
||||
// including reviewed hidden compatibility and unavailable contracts.
|
||||
schemaPublishedShortcutCount = 482
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 422
|
||||
publiclyDeliveredShortcutCount = 425
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
|
||||
product := executeShortcutSchemaQuery(t, "chat")
|
||||
productPayload, _ := product["product"].(map[string]any)
|
||||
if got, want := int(product["count"].(float64)), 217; got != want {
|
||||
if got, want := int(product["count"].(float64)), 220; got != want {
|
||||
t.Fatalf("schema chat count = %d, want %d", got, want)
|
||||
}
|
||||
summaries := schemaContractObjectSlice(productPayload["tools"])
|
||||
@@ -140,6 +140,57 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
assertChatCatalogCompleteLeafContracts(t)
|
||||
}
|
||||
|
||||
func TestChatPersonalEmotionSchemaDeclaresUnpinnedIMAdapter(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
cliPath string
|
||||
params map[string]string
|
||||
}{
|
||||
{
|
||||
cliPath: "chat emotion list",
|
||||
},
|
||||
{
|
||||
cliPath: "chat emotion send",
|
||||
params: map[string]string{
|
||||
"media-id": "mediaId",
|
||||
"emotion-id": "emotionId",
|
||||
"group": "openConversationId",
|
||||
"open-dingtalk-id": "receiverOpenDingTalkId",
|
||||
"idempotency-key": "uuid",
|
||||
},
|
||||
},
|
||||
{
|
||||
cliPath: "chat emotion favorite",
|
||||
params: map[string]string{
|
||||
"media-id": "mediaId",
|
||||
"name": "name",
|
||||
"source-conversation-id": "sourceConversationId",
|
||||
"source-message-id": "sourceMessageId",
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.cliPath, func(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", tc.cliPath)
|
||||
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("%s interface_mode = %q, want composite", tc.cliPath, got)
|
||||
}
|
||||
reason := schemaContractString(leaf["interface_reason"])
|
||||
if !strings.Contains(reason, "Reviewed unpinned remote adapter") {
|
||||
t.Fatalf("%s interface_reason = %q", tc.cliPath, reason)
|
||||
}
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
for name, want := range tc.params {
|
||||
parameter := parameters[name]
|
||||
if parameter == nil {
|
||||
t.Fatalf("%s missing --%s parameter: %#v", tc.cliPath, name, parameters)
|
||||
}
|
||||
if got := schemaContractString(parameter["property"]); got != want {
|
||||
t.Fatalf("%s --%s property = %q, want %q", tc.cliPath, name, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableTableBootstrapPublishesResultContract(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "aitable +table-bootstrap")
|
||||
result, _ := leaf["result"].(map[string]any)
|
||||
@@ -220,6 +271,60 @@ func TestAllShortcutsWikiSchemaExamplesIncludeRequiredParameters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllShortcutsAITableDatasourceExamplesSourceConfigHasRequiredMembers(t *testing.T) {
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
requiredSourceConfigMembers := []string{"processCode", "name", "iconUrl", "url"}
|
||||
checked := 0
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service != "aitable" || declared.UserDefined || !shortcut.InPublicCatalog(declared.Service, declared.Command) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(declared.Command, "+datasource-") {
|
||||
continue
|
||||
}
|
||||
if declared.Command != "+datasource-create" && declared.Command != "+datasource-update" && declared.Command != "+datasource-get-fields" {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
canonical := shortcutSchemaCanonical(declared)
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Fatalf("delivery schema --all is missing %s", canonical)
|
||||
}
|
||||
examples := schemaContractStringSlice(tool["examples"])
|
||||
if len(examples) == 0 {
|
||||
t.Fatalf("%s has no delivered examples", canonical)
|
||||
}
|
||||
for _, example := range examples {
|
||||
if !strings.Contains(example, "--source-config") {
|
||||
continue
|
||||
}
|
||||
argv, err := cli.ParseAgentExampleArgv(example)
|
||||
if err != nil {
|
||||
t.Fatalf("%s example %q is not valid argv: %v", canonical, example, err)
|
||||
}
|
||||
sourceConfig := schemaExampleFlagValue(argv, "source-config")
|
||||
if sourceConfig == "" {
|
||||
t.Errorf("%s example %q contains --source-config but has no value", canonical, example)
|
||||
continue
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal([]byte(sourceConfig), &cfg); err != nil {
|
||||
t.Errorf("%s example %q has invalid source-config JSON: %v", canonical, example, err)
|
||||
continue
|
||||
}
|
||||
for _, member := range requiredSourceConfigMembers {
|
||||
if _, ok := cfg[member]; !ok {
|
||||
t.Errorf("%s example %q source-config is missing required member %q", canonical, example, member)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked != 3 {
|
||||
t.Fatalf("checked aitable datasource source-config examples = %d, want 3", checked)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaExampleHasLongFlag(argv []string, names ...string) bool {
|
||||
for _, argument := range argv {
|
||||
for _, name := range names {
|
||||
@@ -231,6 +336,25 @@ func schemaExampleHasLongFlag(argv []string, names ...string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func schemaExampleFlagValue(argv []string, name string) string {
|
||||
prefix := "--" + name + "="
|
||||
for _, argument := range argv {
|
||||
if argument == "--"+name {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(argument, prefix) {
|
||||
return strings.TrimPrefix(argument, prefix)
|
||||
}
|
||||
}
|
||||
// Value may be in the next argv entry: `--flag value` form.
|
||||
for i := 0; i < len(argv)-1; i++ {
|
||||
if argv[i] == "--"+name {
|
||||
return argv[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func assertSchemaSummarySafety(
|
||||
t testing.TB,
|
||||
summaries map[string]map[string]any,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func assertWhiteboardPublicShortcutsStayAvailableInSchema(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
|
||||
t.Helper()
|
||||
for canonical, command := range map[string]string{
|
||||
"whiteboard.shortcut_query": "+query",
|
||||
"whiteboard.shortcut_update": "+update",
|
||||
} {
|
||||
leaf, _, err := root.Find([]string{"whiteboard", command})
|
||||
if err != nil || leaf == nil || leaf.Name() != command {
|
||||
t.Errorf("find whiteboard %s: leaf=%v err=%v", command, leaf, err)
|
||||
} else if leaf.Hidden || !leaf.Runnable() {
|
||||
t.Errorf("whiteboard %s hidden/runnable=%v/%v, want false/true", command, leaf.Hidden, leaf.Runnable())
|
||||
}
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Errorf("public %s missing from delivery Schema surface", canonical)
|
||||
continue
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("%s availability=%q, want available", canonical, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
|
||||
t.Errorf("%s interface_mode=%q, want composite", canonical, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got == "" {
|
||||
t.Errorf("%s missing composite adapter reason", canonical)
|
||||
}
|
||||
if tool["interface_ref"] != nil {
|
||||
t.Errorf("%s composite interface_ref=%#v, want nil", canonical, tool["interface_ref"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
updated.UserName = data.UserName
|
||||
updated.RepairOrganizationMirror = data.RepairOrganizationMirror
|
||||
if updated.CorpName == "" {
|
||||
updated.CorpName = data.CorpName
|
||||
}
|
||||
@@ -239,6 +240,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
updated.UserName = data.UserName
|
||||
updated.RepairOrganizationMirror = data.RepairOrganizationMirror
|
||||
if updated.CorpName == "" {
|
||||
updated.CorpName = data.CorpName
|
||||
}
|
||||
|
||||
@@ -811,7 +811,207 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("refreshing token (dual-locked)")
|
||||
}
|
||||
return oauthRefreshToken(p, ctx, data)
|
||||
refreshed, rErr := oauthRefreshToken(p, ctx, data)
|
||||
if rErr == nil || !isRefreshTokenRejected(rErr) {
|
||||
return refreshed, rErr
|
||||
}
|
||||
// A stale identity slot can survive an older organization-only refresh.
|
||||
// Retry once with the same-corp organization mirror while holding the
|
||||
// existing dual lock; the fallback marks the publication so the rotated
|
||||
// credential is written back into the mirror slot it consumed.
|
||||
logging.AuthDebug(
|
||||
"auth.refresh.fallback.triggered",
|
||||
"corp_id", strings.TrimSpace(data.CorpID),
|
||||
"user_id", strings.TrimSpace(data.UserID),
|
||||
"error", rErr,
|
||||
)
|
||||
fallback, fErr := p.refreshFromOrgSlot(ctx, data)
|
||||
if fErr != nil {
|
||||
logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr)
|
||||
// The organization mirror may be absent for long-lived local logins
|
||||
// that predate mirror publication. Recover from the legacy global
|
||||
// slot before giving up.
|
||||
if recovered, recoverErr := p.recoverRefreshFromLegacyGlobalSlot(ctx, data, rErr); recoverErr == nil {
|
||||
return recovered, nil
|
||||
}
|
||||
return nil, rErr
|
||||
}
|
||||
if p.logger != nil {
|
||||
p.logger.Warn(i18n.T("当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态"))
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
// refreshFromOrgSlot retries a rejected refresh with the token mirrored in
|
||||
// the organization slot. The mirror must match the current corp, be valid,
|
||||
// and differ from the rejected token. When both slots carry user identities,
|
||||
// they must agree; legacy mirrors with an empty UserID are backfilled from the
|
||||
// current identity before refresh.
|
||||
func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenData) (*TokenData, error) {
|
||||
if current == nil {
|
||||
return nil, fmt.Errorf("no current token data")
|
||||
}
|
||||
corpID := strings.TrimSpace(current.CorpID)
|
||||
if corpID == "" {
|
||||
return nil, fmt.Errorf("current token has no corpId")
|
||||
}
|
||||
orgData, err := tokenLoadKeychainForCorpID(corpID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orgData == nil {
|
||||
return nil, ErrTokenDataNotFound
|
||||
}
|
||||
if strings.TrimSpace(orgData.CorpID) != corpID {
|
||||
return nil, fmt.Errorf("organization token mirror for corpId %q contains token for corpId %q; refusing refresh fallback", corpID, orgData.CorpID)
|
||||
}
|
||||
if !orgData.IsRefreshTokenValid() {
|
||||
return nil, fmt.Errorf("organization mirror refresh_token 已过期")
|
||||
}
|
||||
if orgData.RefreshToken == current.RefreshToken {
|
||||
return nil, fmt.Errorf("organization mirror holds the same rejected refresh_token")
|
||||
}
|
||||
currentUserID := strings.TrimSpace(current.UserID)
|
||||
orgUserID := strings.TrimSpace(orgData.UserID)
|
||||
if currentUserID != "" && orgUserID != "" && orgUserID != currentUserID {
|
||||
return nil, fmt.Errorf("organization token mirror for corpId %q belongs to userId %q; refusing refresh fallback for userId %q", corpID, orgData.UserID, current.UserID)
|
||||
}
|
||||
if orgUserID == "" {
|
||||
orgData.UserID = current.UserID
|
||||
orgData.UserName = current.UserName
|
||||
}
|
||||
// The refresh below consumes the mirror's refresh_token. Mark the
|
||||
// publication so persistence writes the rotated credential back into the
|
||||
// organization slot even under an explicit runtime selector whose plan
|
||||
// would otherwise skip it (for example a preserved unresolved sibling).
|
||||
orgData.RepairOrganizationMirror = true
|
||||
refreshed, err := oauthRefreshToken(p, ctx, orgData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logging.AuthDebug(
|
||||
"auth.refresh.fallback.success",
|
||||
"corp_id", corpID,
|
||||
"new_at_expires_at", refreshed.ExpiresAt.Format(time.RFC3339),
|
||||
)
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) recoverRefreshFromLegacyGlobalSlot(ctx context.Context, selected *TokenData, refreshErr error) (*TokenData, error) {
|
||||
var exchangeErr *MCPTokenExchangeError
|
||||
if !errors.As(refreshErr, &exchangeErr) || !exchangeErr.requiresReauthorization() {
|
||||
return nil, refreshErr
|
||||
}
|
||||
if selected == nil {
|
||||
return nil, refreshErr
|
||||
}
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.triggered",
|
||||
"corp_id", strings.TrimSpace(selected.CorpID),
|
||||
"user_id", strings.TrimSpace(selected.UserID),
|
||||
"refresh_error_code", exchangeErr.Code,
|
||||
)
|
||||
legacy, loadErr := tokenLoadKeychain()
|
||||
if loadErr != nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "error", loadErr)
|
||||
return nil, refreshErr
|
||||
}
|
||||
if legacy == nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "reason", "empty_legacy")
|
||||
return nil, refreshErr
|
||||
}
|
||||
if !legacyGlobalRefreshCandidateMatches(p.configDir, selected, legacy) {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed",
|
||||
"step", "candidate_mismatch",
|
||||
"legacy_corp_id", strings.TrimSpace(legacy.CorpID),
|
||||
"legacy_user_id", strings.TrimSpace(legacy.UserID),
|
||||
)
|
||||
return nil, refreshErr
|
||||
}
|
||||
recovered := *legacy
|
||||
if strings.TrimSpace(recovered.UserID) == "" {
|
||||
recovered.UserID = strings.TrimSpace(selected.UserID)
|
||||
}
|
||||
if strings.TrimSpace(recovered.UserName) == "" {
|
||||
recovered.UserName = strings.TrimSpace(selected.UserName)
|
||||
}
|
||||
if recovered.IsAccessTokenValid() {
|
||||
if err := oauthSaveTokenLocked(p.configDir, &recovered); err != nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "save", "error", err)
|
||||
return nil, refreshErr
|
||||
}
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "valid_access_token")
|
||||
return &recovered, nil
|
||||
}
|
||||
if !recovered.IsRefreshTokenValid() {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh_expired")
|
||||
return nil, refreshErr
|
||||
}
|
||||
if strings.TrimSpace(recovered.RefreshToken) == strings.TrimSpace(selected.RefreshToken) {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "same_refresh_token")
|
||||
return nil, refreshErr
|
||||
}
|
||||
if err := preflightTokenRefreshPersistence(p.configDir, &recovered); err != nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "preflight", "error", err)
|
||||
return nil, refreshErr
|
||||
}
|
||||
refreshed, recoverErr := oauthRefreshToken(p, ctx, &recovered)
|
||||
if recoverErr != nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh", "error", recoverErr)
|
||||
return nil, refreshErr
|
||||
}
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "refresh")
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
func legacyGlobalRefreshCandidateMatches(configDir string, selected, legacy *TokenData) bool {
|
||||
if selected == nil || legacy == nil {
|
||||
return false
|
||||
}
|
||||
selectedCorpID := strings.TrimSpace(selected.CorpID)
|
||||
legacyCorpID := strings.TrimSpace(legacy.CorpID)
|
||||
if selectedCorpID == "" || legacyCorpID != selectedCorpID {
|
||||
return false
|
||||
}
|
||||
selectedUserID := strings.TrimSpace(selected.UserID)
|
||||
legacyUserID := strings.TrimSpace(legacy.UserID)
|
||||
if legacyUserID != "" {
|
||||
return legacyUserID == selectedUserID
|
||||
}
|
||||
return legacyGlobalBlankUserIDMatchesSingleProfile(configDir, selectedCorpID, selectedUserID)
|
||||
}
|
||||
|
||||
func legacyGlobalBlankUserIDMatchesSingleProfile(configDir, corpID, userID string) bool {
|
||||
if strings.TrimSpace(corpID) == "" {
|
||||
return false
|
||||
}
|
||||
cfg, err := tokenLoadProfiles(configDir)
|
||||
if err != nil || cfg == nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected", "reason", "profiles_error", "error", err)
|
||||
return false
|
||||
}
|
||||
profiles := profilesForCorpID(cfg, corpID)
|
||||
if len(profiles) != 1 {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected",
|
||||
"reason", "multi_profile",
|
||||
"corp_id", strings.TrimSpace(corpID),
|
||||
"profile_count", len(profiles),
|
||||
)
|
||||
return false
|
||||
}
|
||||
profile := profiles[0]
|
||||
if profile != nil && sameProfileIdentity(profile.CorpID, profile.UserID, corpID, userID) {
|
||||
return true
|
||||
}
|
||||
profileUserID := ""
|
||||
if profile != nil {
|
||||
profileUserID = strings.TrimSpace(profile.UserID)
|
||||
}
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected",
|
||||
"reason", "identity_mismatch",
|
||||
"selected_user_id", strings.TrimSpace(userID),
|
||||
"profile_user_id", profileUserID,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
// 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 auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageIsRefreshTokenRejected(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"mcp authCode.notFound", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}, true},
|
||||
{"mcp other business code", &MCPTokenExchangeError{Code: "other.error", Message: "boom"}, false},
|
||||
{"wrapped mcp rejection", fmt.Errorf("refresh: %w", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode}), true},
|
||||
{"http 400 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusBadRequest}, false},
|
||||
{"http 401 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusUnauthorized}, false},
|
||||
{"http 403 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusForbidden}, false},
|
||||
{"http 500 is transient", &HTTPStatusError{StatusCode: http.StatusInternalServerError}, false},
|
||||
{"http 429 is transient", &HTTPStatusError{StatusCode: http.StatusTooManyRequests}, false},
|
||||
{"plain error is unknown", errors.New("boom"), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isRefreshTokenRejected(tt.err); got != tt.want {
|
||||
t.Fatalf("isRefreshTokenRejected(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// orgSlotFallbackFixture wires the injectable seams lockedRefresh depends on
|
||||
// and records refresh attempts plus organization slot lookups.
|
||||
type orgSlotFallbackFixture struct {
|
||||
provider *OAuthProvider
|
||||
stale *TokenData
|
||||
orgMirror *TokenData
|
||||
renewed *TokenData
|
||||
rejected *MCPTokenExchangeError
|
||||
refreshErr error
|
||||
orgRefreshErr error
|
||||
refreshCalls []string
|
||||
refreshUserIDs []string
|
||||
orgLoads int
|
||||
}
|
||||
|
||||
func newOrgSlotFallbackFixture(t *testing.T) *orgSlotFallbackFixture {
|
||||
t.Helper()
|
||||
isolateOAuthPersistence(t)
|
||||
|
||||
f := &orgSlotFallbackFixture{
|
||||
provider: &OAuthProvider{configDir: t.TempDir(), logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Output: io.Discard},
|
||||
stale: &TokenData{
|
||||
AccessToken: "old-access",
|
||||
RefreshToken: "stale-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
},
|
||||
orgMirror: &TokenData{
|
||||
AccessToken: "org-access",
|
||||
RefreshToken: "org-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Minute),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
},
|
||||
renewed: &TokenData{
|
||||
AccessToken: "new-access",
|
||||
RefreshToken: "new-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
},
|
||||
rejected: &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"},
|
||||
}
|
||||
f.refreshErr = f.rejected
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(configDir, _ string) (*TokenData, error) { return oauthLoadToken(configDir) })
|
||||
testseam.Swap(t, &oauthLoadToken, func(string) (*TokenData, error) { return f.stale, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(_ *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
|
||||
f.refreshCalls = append(f.refreshCalls, data.RefreshToken)
|
||||
f.refreshUserIDs = append(f.refreshUserIDs, data.UserID)
|
||||
switch data.RefreshToken {
|
||||
case "stale-refresh":
|
||||
return nil, f.refreshErr
|
||||
case "org-refresh":
|
||||
return f.renewed, f.orgRefreshErr
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected refresh token %q", data.RefreshToken)
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(corpID string) (*TokenData, error) {
|
||||
f.orgLoads++
|
||||
if corpID != "corp-1" {
|
||||
return nil, ErrTokenDataNotFound
|
||||
}
|
||||
return f.orgMirror, nil
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLockedRefreshFallsBackToOrgSlot(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
|
||||
got, err := f.provider.lockedRefresh(context.Background())
|
||||
if err != nil || got != f.renewed {
|
||||
t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err)
|
||||
}
|
||||
if len(f.refreshCalls) != 2 || f.refreshCalls[0] != "stale-refresh" || f.refreshCalls[1] != "org-refresh" {
|
||||
t.Fatalf("refresh attempts = %v, want [stale-refresh org-refresh]", f.refreshCalls)
|
||||
}
|
||||
if f.orgLoads != 1 {
|
||||
t.Fatalf("organization slot loads = %d, want 1", f.orgLoads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRepairMarkerForcesOrganizationSlotWrite(t *testing.T) {
|
||||
cfg := &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
Profiles: []Profile{
|
||||
{Name: "legacy", CorpID: "corp-1", UserID: ""},
|
||||
{Name: "user-1", CorpID: "corp-1", UserID: "user-1"},
|
||||
},
|
||||
}
|
||||
selector := profileSelector("corp-1", "user-1")
|
||||
|
||||
without := &TokenData{CorpID: "corp-1", UserID: "user-1"}
|
||||
if plan := planTokenPersistenceWrites(cfg, without, selector); plan.WriteOrganization {
|
||||
t.Fatalf("explicit selector preserved unresolved org slot: WriteOrganization = true, want false")
|
||||
}
|
||||
|
||||
with := &TokenData{CorpID: "corp-1", UserID: "user-1", RepairOrganizationMirror: true}
|
||||
if plan := planTokenPersistenceWrites(cfg, with, selector); !plan.WriteOrganization {
|
||||
t.Fatalf("repair marker did not force the organization slot write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLockedRefreshFallbackRepairsPersistedSlots(t *testing.T) {
|
||||
isolateOAuthPersistence(t)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
// Fake MCP refresh endpoint: the first call rejects the stale identity
|
||||
// refresh_token with the reviewed business code; the second call (the
|
||||
// organization mirror) succeeds and returns a rotated credential.
|
||||
var refreshCalls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if refreshCalls.Add(1) == 1 {
|
||||
fmt.Fprint(w, `{"errorCode":"invalidParameter.authCode.notFound","errorMsg":"authCode not found"}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp-1","userId":"user-1","userName":"User One"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
resetAppConfigCache()
|
||||
|
||||
// Seed the pre-fallback state: an identity slot whose refresh_token the
|
||||
// server rejects, plus a legacy organization mirror (no userId) with a
|
||||
// still-valid refresh_token and a preserved unresolved sibling profile so
|
||||
// an explicit --profile refresh would normally skip the org slot.
|
||||
cfg := &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
Profiles: []Profile{
|
||||
{Name: "corp-1", CorpID: "corp-1", UserID: "", ClientID: "mcp-client"},
|
||||
{Name: "user-1", CorpID: "corp-1", UserID: "user-1", UserName: "User One", ClientID: "mcp-client"},
|
||||
},
|
||||
}
|
||||
if err := SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
orgMirror := &TokenData{
|
||||
AccessToken: "org-access",
|
||||
RefreshToken: "org-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Minute),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
Source: "mcp",
|
||||
ClientID: "mcp-client",
|
||||
}
|
||||
if err := SaveTokenDataKeychainForCorpID("corp-1", orgMirror); err != nil {
|
||||
t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err)
|
||||
}
|
||||
staleIdentity := &TokenData{
|
||||
AccessToken: "stale-access",
|
||||
RefreshToken: "stale-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
UserName: "User One",
|
||||
Source: "mcp",
|
||||
ClientID: "mcp-client",
|
||||
}
|
||||
if err := SaveTokenDataKeychainForIdentity("corp-1", "user-1", staleIdentity); err != nil {
|
||||
t.Fatalf("SaveTokenDataKeychainForIdentity() error = %v", err)
|
||||
}
|
||||
|
||||
SetRuntimeProfile("corp-1:user-1")
|
||||
t.Cleanup(func() { SetRuntimeProfile("") })
|
||||
|
||||
p := &OAuthProvider{
|
||||
configDir: configDir,
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
got, err := p.lockedRefresh(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("lockedRefresh() error = %v", err)
|
||||
}
|
||||
if got == nil || got.AccessToken != "new-access" || got.RefreshToken != "new-refresh" {
|
||||
t.Fatalf("lockedRefresh() = %#v, want rotated credential", got)
|
||||
}
|
||||
if refreshCalls.Load() != 2 {
|
||||
t.Fatalf("MCP refresh calls = %d, want primary rejection plus fallback", refreshCalls.Load())
|
||||
}
|
||||
|
||||
// The fallback consumed the mirror's refresh_token: both persisted slots
|
||||
// must now carry the rotated credential instead of the consumed one.
|
||||
orgSlot, err := LoadTokenDataKeychainForCorpID("corp-1")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataKeychainForCorpID() error = %v", err)
|
||||
}
|
||||
if orgSlot.RefreshToken != "new-refresh" || orgSlot.UserID != "user-1" {
|
||||
t.Fatalf("organization slot = %#v, want new-refresh for user-1", orgSlot)
|
||||
}
|
||||
identitySlot, err := LoadTokenDataKeychainForIdentity("corp-1", "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataKeychainForIdentity() error = %v", err)
|
||||
}
|
||||
if identitySlot.RefreshToken != "new-refresh" {
|
||||
t.Fatalf("identity slot = %#v, want new-refresh", identitySlot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLockedRefreshOrgSlotFallbackGuardrails(t *testing.T) {
|
||||
t.Run("transient failure does not fall back", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.refreshErr = &HTTPStatusError{StatusCode: http.StatusInternalServerError}
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if err == nil || err.Error() != f.refreshErr.Error() {
|
||||
t.Fatalf("lockedRefresh() error = %v, want transient failure", err)
|
||||
}
|
||||
if f.orgLoads != 0 {
|
||||
t.Fatalf("organization slot loads = %d, want 0 for transient failure", f.orgLoads)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing org slot preserves rejection", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) {
|
||||
f.orgLoads++
|
||||
return nil, ErrTokenDataNotFound
|
||||
})
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil org data from keychain preserves rejection", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) {
|
||||
f.orgLoads++
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("org slot refresh failure preserves rejection", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgRefreshErr = fmt.Errorf("org mirror refresh failed")
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 2 {
|
||||
t.Fatalf("refresh attempts = %v, want primary and fallback attempts", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different user in org slot is rejected", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgMirror.UserID = "user-2"
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, mismatched user must not refresh", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty org slot user identity is backfilled", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgMirror.UserID = ""
|
||||
f.orgMirror.UserName = ""
|
||||
|
||||
got, err := f.provider.lockedRefresh(context.Background())
|
||||
if err != nil || got != f.renewed {
|
||||
t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err)
|
||||
}
|
||||
if len(f.refreshCalls) != 2 {
|
||||
t.Fatalf("refresh attempts = %v, want fallback attempt", f.refreshCalls)
|
||||
}
|
||||
if f.refreshUserIDs[1] != "user-1" {
|
||||
t.Fatalf("fallback refresh UserID = %q, want backfilled current identity user-1", f.refreshUserIDs[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same rejected refresh token is skipped", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgMirror.RefreshToken = "stale-refresh"
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, retrying the rejected token must not run", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expired org refresh token is skipped", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgMirror.RefreshExpAt = time.Now().Add(-time.Hour)
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing corp id skips fallback", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.stale.CorpID = ""
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if f.orgLoads != 0 {
|
||||
t.Fatalf("organization slot loads = %d, want 0 without corpId", f.orgLoads)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("direct mode terminal status does not fall back without business code", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.refreshErr = &HTTPStatusError{StatusCode: http.StatusBadRequest}
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if err == nil || err.Error() != f.refreshErr.Error() {
|
||||
t.Fatalf("lockedRefresh() error = %v, want direct terminal status", err)
|
||||
}
|
||||
if f.orgLoads != 0 {
|
||||
t.Fatalf("fallback slot loads = %d, want 0 without reviewed business code", f.orgLoads)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRefreshFromOrgSlotBoundaries(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
|
||||
if _, err := f.provider.refreshFromOrgSlot(context.Background(), nil); err == nil {
|
||||
t.Fatal("refreshFromOrgSlot(nil) succeeded")
|
||||
}
|
||||
f.orgMirror.CorpID = "corp-2"
|
||||
if _, err := f.provider.refreshFromOrgSlot(context.Background(), f.stale); err == nil {
|
||||
t.Fatal("refreshFromOrgSlot with mismatched corpId succeeded")
|
||||
}
|
||||
if len(f.refreshCalls) != 0 {
|
||||
t.Fatalf("refresh attempts = %v, corpId mismatch must not refresh", f.refreshCalls)
|
||||
}
|
||||
}
|
||||
@@ -87,3 +87,16 @@ func ClassifyRefreshFailure(err error) RefreshFailureClass {
|
||||
}
|
||||
return RefreshFailureUnknown
|
||||
}
|
||||
|
||||
// isRefreshTokenRejected reports whether the server returned a reviewed
|
||||
// business code that definitively rejects the presented refresh_token.
|
||||
// Only the reviewed MCP business code enables the organization-slot
|
||||
// fallback; direct-mode terminal HTTP rejections (400/401/403) carry no
|
||||
// reviewed business code and deliberately do not trigger the fallback.
|
||||
func isRefreshTokenRejected(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var exchangeErr *MCPTokenExchangeError
|
||||
return errors.As(err, &exchangeErr) && exchangeErr != nil && exchangeErr.requiresReauthorization()
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -189,6 +190,195 @@ func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientR
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRecoversRejectedIdentityRefresh(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039",
|
||||
UserID: "user-v1039",
|
||||
UserName: "V1039 User",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
return nil, &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{
|
||||
Name: "V1039 User",
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
UserName: selected.UserName,
|
||||
}}}, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
var saved *TokenData
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(_ string, data *TokenData) error {
|
||||
copy := *data
|
||||
saved = ©
|
||||
return nil
|
||||
})
|
||||
|
||||
recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("lockedRefresh() error = %v", err)
|
||||
}
|
||||
if recovered.AccessToken != legacy.AccessToken || recovered.RefreshToken != legacy.RefreshToken {
|
||||
t.Fatalf("recovered token = %#v, want legacy credential material %#v", recovered, legacy)
|
||||
}
|
||||
if recovered.UserID != selected.UserID || recovered.UserName != selected.UserName {
|
||||
t.Fatalf("recovered identity = %q/%q, want selected identity %q/%q", recovered.UserID, recovered.UserName, selected.UserID, selected.UserName)
|
||||
}
|
||||
if saved == nil || saved.AccessToken != recovered.AccessToken || saved.UserID != selected.UserID {
|
||||
t.Fatalf("saved recovery token = %#v, want recovered identity token %#v", saved, recovered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankUserIDForMultiAccountCorp(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-multi",
|
||||
UserID: "user-v1039-a",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{
|
||||
{Name: "User A", CorpID: selected.CorpID, UserID: selected.UserID},
|
||||
{Name: "User B", CorpID: selected.CorpID, UserID: "user-v1039-b"},
|
||||
}}, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a blank-user token for a multi-account organization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankSelectedUserIDForMultiAccountCorp(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-blank-selected",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{
|
||||
{Name: "Blank A", CorpID: selected.CorpID, UserID: ""},
|
||||
{Name: "Blank B", CorpID: selected.CorpID, UserID: ""},
|
||||
}}, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a blank-selected token for a multi-account organization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsDifferentUserID(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-user-mismatch",
|
||||
UserID: "user-v1039-selected",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: "user-v1039-other",
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a token owned by a different user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsolated(t *testing.T) {
|
||||
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
|
||||
expired := *fixture.blankToken
|
||||
@@ -253,3 +443,330 @@ func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsola
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSingleProfileIdentityMismatch(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-single-mismatch",
|
||||
UserID: "user-v1039-selected",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{
|
||||
Name: "Other User",
|
||||
CorpID: selected.CorpID,
|
||||
UserID: "user-v1039-other",
|
||||
}}}, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a blank-user token whose single profile identity does not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRefreshesExpiredLegacyCredential(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-legacy-refresh",
|
||||
UserID: "user-v1039",
|
||||
UserName: "V1039 User",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
refreshed := &TokenData{
|
||||
AccessToken: "refreshed-legacy-access",
|
||||
RefreshToken: "rotated-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
refreshCalls := 0
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
refreshCalls++
|
||||
if refreshCalls == 1 {
|
||||
return nil, rejection
|
||||
}
|
||||
return refreshed, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
|
||||
recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("lockedRefresh() error = %v", err)
|
||||
}
|
||||
if refreshCalls != 2 {
|
||||
t.Fatalf("oauthRefreshToken called %d times, want 2 (identity rejection + legacy refresh)", refreshCalls)
|
||||
}
|
||||
if recovered.AccessToken != refreshed.AccessToken {
|
||||
t.Fatalf("recovered access token = %q, want refreshed legacy credential %q", recovered.AccessToken, refreshed.AccessToken)
|
||||
}
|
||||
if recovered.RefreshToken != refreshed.RefreshToken {
|
||||
t.Fatalf("recovered refresh token = %q, want rotated credential %q", recovered.RefreshToken, refreshed.RefreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsProfilesLoadError(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-profiles-error",
|
||||
UserID: "user-v1039",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { return nil, errors.New("profiles read failed") })
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a blank-user token when profiles could not be loaded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSameRefreshToken(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "shared-rejected-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-same-refresh",
|
||||
UserID: "user-v1039",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-global-access",
|
||||
RefreshToken: selected.RefreshToken,
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a token holding the same rejected refresh_token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsNilSelectedAndEmptyLegacy(t *testing.T) {
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
provider := NewOAuthProvider(t.TempDir(), nil)
|
||||
|
||||
// nil selected must be rejected before any dereference.
|
||||
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), nil, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("nil selected error = %v, want original rejection", err)
|
||||
}
|
||||
|
||||
// A keychain load that returns (nil, nil) must be rejected before any dereference.
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return nil, nil })
|
||||
selected := &TokenData{
|
||||
CorpID: "corp-v1039-nil-legacy",
|
||||
UserID: "user-v1039",
|
||||
Source: "mcp",
|
||||
}
|
||||
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("nil legacy error = %v, want original rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsNonReauthorizationErrors(t *testing.T) {
|
||||
provider := NewOAuthProvider(t.TempDir(), nil)
|
||||
selected := &TokenData{CorpID: "corp-v1039-plain", UserID: "user-v1039", Source: "mcp"}
|
||||
|
||||
plainErr := errors.New("plain refresh failure")
|
||||
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, plainErr); !errors.Is(err, plainErr) {
|
||||
t.Fatalf("plain error = %v, want original plain failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsSaveAndRefreshFailures(t *testing.T) {
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
selected := &TokenData{
|
||||
CorpID: "corp-v1039-recovery-steps",
|
||||
UserID: "user-v1039",
|
||||
Source: "mcp",
|
||||
}
|
||||
|
||||
t.Run("save_failure", func(t *testing.T) {
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-access",
|
||||
RefreshToken: "valid-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { return errors.New("save failed") })
|
||||
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("save failure error = %v, want original rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refresh_expired", func(t *testing.T) {
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-access",
|
||||
RefreshToken: "expired-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(-time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("expired refresh error = %v, want original rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refresh_error", func(t *testing.T) {
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-access",
|
||||
RefreshToken: "valid-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
return nil, errors.New("legacy refresh failed")
|
||||
})
|
||||
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("legacy refresh error = %v, want original rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preflight_error", func(t *testing.T) {
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-access",
|
||||
RefreshToken: "valid-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &profilesReadFile, func(string) ([]byte, error) { return nil, errors.New("read failed") })
|
||||
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("preflight error = %v, want original rejection", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalCandidateMatchingBoundaries(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
selected := &TokenData{CorpID: "corp-v1039-candidate", UserID: "user-v1039"}
|
||||
|
||||
if legacyGlobalRefreshCandidateMatches(configDir, selected, nil) {
|
||||
t.Fatal("nil legacy accepted")
|
||||
}
|
||||
if legacyGlobalRefreshCandidateMatches(configDir, selected, &TokenData{CorpID: "corp-other", UserID: selected.UserID}) {
|
||||
t.Fatal("different corp accepted")
|
||||
}
|
||||
if legacyGlobalRefreshCandidateMatches(configDir, &TokenData{UserID: "user-v1039"}, &TokenData{UserID: "user-v1039"}) {
|
||||
t.Fatal("blank selected corp accepted")
|
||||
}
|
||||
|
||||
blankSelected := &TokenData{CorpID: selected.CorpID}
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{Name: "Blank User", CorpID: selected.CorpID}}}, nil
|
||||
})
|
||||
if !legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID}) {
|
||||
t.Fatal("both blank user IDs should match only through the single-profile guard")
|
||||
}
|
||||
if legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID, UserID: "user-other"}) {
|
||||
t.Fatal("blank selected with non-blank legacy accepted")
|
||||
}
|
||||
|
||||
if legacyGlobalBlankUserIDMatchesSingleProfile(configDir, "", selected.UserID) {
|
||||
t.Fatal("blank corp accepted by single-profile check")
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -107,6 +107,13 @@ type TokenData struct {
|
||||
// transient marker to reject ambiguous UID-less logins without breaking
|
||||
// legitimate refreshes of unresolved accounts.
|
||||
FreshAuthorization bool `json:"-"`
|
||||
// RepairOrganizationMirror marks a fallback refresh that consumed the
|
||||
// organization mirror's refresh_token. The regular write plan can skip the
|
||||
// organization slot under an explicit runtime selector (for example when an
|
||||
// unresolved sibling profile still owns it), which would strand a
|
||||
// refresh_token the server has already rotated; the marker forces the
|
||||
// rotated credential back into that slot.
|
||||
RepairOrganizationMirror bool `json:"-"`
|
||||
}
|
||||
|
||||
// tokenPersistenceWritePlan is the single source of truth for deciding which
|
||||
@@ -127,6 +134,7 @@ type tokenPersistenceWritePlan struct {
|
||||
ExistingIdentity bool
|
||||
UpgradesLegacyProfile bool
|
||||
PreserveUnresolvedOrganization bool
|
||||
RepairOrganizationMirror bool
|
||||
WriteIdentity bool
|
||||
WriteOrganization bool
|
||||
WriteGlobal bool
|
||||
@@ -167,6 +175,11 @@ func planTokenPersistenceWrites(
|
||||
plan.PreserveUnresolvedOrganization = plan.UserID != "" &&
|
||||
unresolvedProfileForCorp(cfg, plan.CorpID) != nil &&
|
||||
!plan.UpgradesLegacyProfile
|
||||
// A fallback refresh consumed the organization mirror's refresh_token;
|
||||
// the rotated credential must go back into that slot even when the
|
||||
// selector-driven plan would skip it (for example an explicit --profile
|
||||
// that preserves an unresolved sibling profile's slot).
|
||||
plan.RepairOrganizationMirror = data.RepairOrganizationMirror
|
||||
plan.WriteIdentity = plan.UserID != ""
|
||||
orgCurrentSelector := ""
|
||||
if cfg != nil {
|
||||
@@ -177,7 +190,8 @@ func planTokenPersistenceWrites(
|
||||
// reauthorization. Its organization slot must move with the newly exact
|
||||
// identity even when an explicit runtime selector keeps it from becoming
|
||||
// process-global current.
|
||||
plan.WriteOrganization = plan.UserID == "" ||
|
||||
plan.WriteOrganization = plan.RepairOrganizationMirror ||
|
||||
plan.UserID == "" ||
|
||||
plan.UpgradesLegacyProfile ||
|
||||
(!plan.PreserveUnresolvedOrganization &&
|
||||
(plan.MakeCurrent ||
|
||||
@@ -387,6 +401,7 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
|
||||
"persistence_profile", plan.PersistenceSelector,
|
||||
"write_identity_slot", plan.WriteIdentity,
|
||||
"write_org_mirror", plan.WriteOrganization,
|
||||
"repair_org_mirror", plan.RepairOrganizationMirror,
|
||||
"write_global_mirror", plan.WriteGlobal,
|
||||
"publish_incoming_global", plan.MakeCurrent,
|
||||
)
|
||||
|
||||
@@ -191,12 +191,12 @@
|
||||
"from": "oa +list-processes",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"oa +list-forms",
|
||||
"oa +my-initiated",
|
||||
"oa +search-forms",
|
||||
"oa approval list-submitted",
|
||||
"oa approval list-initiated"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf."
|
||||
"review_reason": "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes."
|
||||
},
|
||||
{
|
||||
"from": "chat +conversation-detail",
|
||||
|
||||
@@ -242,9 +242,9 @@ var generatedCommandPathFallbacks = []CommandPathFallback{
|
||||
{
|
||||
From: "oa +list-processes",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
|
||||
Candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf.",
|
||||
ReviewReason: "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) {
|
||||
"chat +send-file": {"chat +messages-send", "chat message send"},
|
||||
"chat +send-image": {"chat +messages-send", "chat message send"},
|
||||
"chat +send-media": {"chat +messages-send", "chat message send"},
|
||||
"oa +list-processes": {"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
|
||||
"oa +list-processes": {"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"},
|
||||
"doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"},
|
||||
"doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"},
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1081
-646
File diff suppressed because one or more lines are too long
@@ -301,7 +301,7 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
t.Fatalf("doc read --content-format required = %#v, want false", contentFormat["required"])
|
||||
}
|
||||
|
||||
for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id"} {
|
||||
for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id", "version", "password"} {
|
||||
if parameters[flagName]["required"] != false {
|
||||
t.Fatalf("doc read --%s required = %#v, want false", flagName, parameters[flagName]["required"])
|
||||
}
|
||||
@@ -315,6 +315,15 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
if parameters["max-depth"]["type"] != "integer" {
|
||||
t.Fatalf("doc read --max-depth type = %#v, want integer", parameters["max-depth"]["type"])
|
||||
}
|
||||
if got := parameters["version"]["property"]; got != "historyVersion" {
|
||||
t.Fatalf("doc read --version property = %#v, want historyVersion", got)
|
||||
}
|
||||
if parameters["version"]["type"] != "integer" {
|
||||
t.Fatalf("doc read --version type = %#v, want integer", parameters["version"]["type"])
|
||||
}
|
||||
if got := parameters["password"]["property"]; got != "password" {
|
||||
t.Fatalf("doc read --password property = %#v, want password", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryCatalogDocCommentParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
|
||||
@@ -76,6 +76,7 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||
"agoal scorecard detail",
|
||||
"agoal scorecard entity-detail",
|
||||
"agoal scorecard update",
|
||||
"agoal scorecard search-entities",
|
||||
"agoal strategy detail",
|
||||
"agoal strategy list",
|
||||
"agoal strategy update",
|
||||
|
||||
@@ -96,7 +96,14 @@ func init() {
|
||||
registerRequireOneOf("sheet.update_cond_format", "ranges", "condition", "cell-style", "data-bar-style")
|
||||
registerRequireOneOf("sheet.update_dimension", "hidden", "pixel-size")
|
||||
registerRequireOneOf("sheet.update_filter_view", "name", "range", "criteria")
|
||||
registerRequireOneOf("sheet.update_float_image", "src", "range", "width", "height", "offset-x", "offset-y")
|
||||
RegisterRuntimeSchemaConstraints("sheet.create_float_image", RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"file", "src"}},
|
||||
RequireOneOf: [][]string{{"file", "src"}},
|
||||
})
|
||||
RegisterRuntimeSchemaConstraints("sheet.update_float_image", RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"file", "src"}},
|
||||
RequireOneOf: [][]string{{"file", "src", "range", "width", "height", "offset-x", "offset-y"}},
|
||||
})
|
||||
registerRequireOneOf("sheet.update_sheet", "name", "index", "hidden", "frozen-row-count", "frozen-column-count", "tab-color")
|
||||
registerRequireOneOf("sheet.import", "folder-token", "workspace")
|
||||
registerRequireOneOf("wiki.search_wikiSpaces", "query", "type")
|
||||
|
||||
@@ -408,6 +408,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"doc.insert_document_block --level": "aggregate convenience input used to build element",
|
||||
"doc.insert_document_block --content": "aggregate convenience input used to build element",
|
||||
"doc.list_document_blocks --block-id": "runtime extension sends blockId, which is absent from the pinned list_document_blocks metadata",
|
||||
"doc.list_permission --limit": "The server rejects the legacy maxResults path for list_permission; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
|
||||
"doc.reply_comment --mentioned-open-conversation-id": "Runtime extension sends mentionedOpenConversationIds, which is absent from the immutable pinned reply_comment metadata at its declared source revision.",
|
||||
"doc.style_background_clear --node": "Reviewed unpinned adapter: doc.style_background_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.style_background_set --color": "Reviewed unpinned adapter: doc.style_background_set has no singular pinned interface_ref; --color is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -476,6 +477,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"drive.list_files --space-id": "drive-branch-only route input on a composite drive/doc command; no singular interface property is advertised",
|
||||
"drive.list_files --thumbnail": "drive-branch-only option on a composite drive/doc command; no singular interface property is advertised",
|
||||
"drive.list_files --workspace": "selects the doc.list_nodes branch of the composite drive/doc command",
|
||||
"drive.list_permission --limit": "The server rejects the legacy maxResults path for list_permission; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
|
||||
"drive.mark_star --node": "Reviewed unpinned adapter: drive.mark_star has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.publish_get --node": "Reviewed unpinned adapter: drive.publish_get has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.publish_set --node": "Reviewed unpinned adapter: drive.publish_set has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -552,6 +554,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"sheet.chart_update --properties": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.chart_update --sheet-id": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties",
|
||||
"sheet.create_float_image --file": "local Sheet upload input used to obtain a resourceUrl before create_float_image",
|
||||
"sheet.create_pivot_table --properties": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_pivot_table --source": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --source is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_pivot_table --target-position": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --target-position is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -644,6 +647,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"sheet.ungroup_dimension --range": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.ungroup_dimension --sheet-id": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties",
|
||||
"sheet.update_float_image --file": "local Sheet upload input used to obtain a resourceUrl before update_float_image",
|
||||
"sheet.update_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_pivot_table --properties": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -664,6 +668,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"todo.list_todo_attachment --task-id": "Reviewed unpinned adapter: --task-id is nested under todoAttachmentListRequest at runtime, while the immutable pinned MCP snapshot has no interface_ref for todo.list_todo_attachment.",
|
||||
"wiki.create_wikiSpace --icon": "runtime extension sends icon, which is absent from the pinned create_wikiSpace metadata",
|
||||
"wiki.delete_document --workspace": "local validation/authorization context; not sent to delete_document",
|
||||
"wiki.list_member --limit": "The server rejects the legacy maxResults path for list_member; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
|
||||
"wiki.list_wikiSpaces --cursor": "composite route maps to pageToken for wiki.list_wikiSpaces or nextToken for drive.list_spaces",
|
||||
"wiki.list_wikiSpaces --limit": "composite route maps to pageSize for wiki.list_wikiSpaces or maxResults for drive.list_spaces",
|
||||
"wiki.list_wikiSpaces --type": "route selector maps to wikiSpaceType or spaceType on different composite branches",
|
||||
|
||||
@@ -32,12 +32,18 @@ func ValidateInputSchema(params map[string]any, schema map[string]any) error {
|
||||
if params == nil {
|
||||
params = map[string]any{}
|
||||
}
|
||||
if err := validateSchemaValue("$", params, schema); err != nil {
|
||||
if err := ValidateJSONSchemaValue(params, schema); err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("input schema validation failed: %v", err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateJSONSchemaValue validates one decoded JSON value against the
|
||||
// required/type/enum/properties/items subset used by reviewed CLI contracts.
|
||||
func ValidateJSONSchemaValue(value any, schema map[string]any) error {
|
||||
return validateSchemaValue("$", value, schema)
|
||||
}
|
||||
|
||||
func validateSchemaValue(path string, value any, schema map[string]any) error {
|
||||
if len(schema) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package commentreaction validates the reviewed DingTalk names accepted by
|
||||
// comment emoji replies. The names mirror the bundled default emoji catalog;
|
||||
// arbitrary text and raw Unicode emoji must never be persisted as reactions.
|
||||
package commentreaction
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
// Validate rejects values outside DingTalk's reviewed default reaction-name
|
||||
// catalog. Names are case-sensitive because they are sent verbatim to the
|
||||
// doc-comment service.
|
||||
func Validate(value string) error {
|
||||
name := strings.TrimSpace(value)
|
||||
if value != name || !utf8.ValidString(name) || !supported(name) {
|
||||
return apperrors.NewValidation(
|
||||
"--reaction/--content 必须是受支持的钉钉表情名称(如 憨笑、鼓掌、比心、赞),不要传 Unicode Emoji 或任意文本",
|
||||
apperrors.WithReason("unsupported_comment_reaction"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func supported(name string) bool {
|
||||
switch name {
|
||||
case "微笑", "可爱", "憨笑", "色", "发呆", "老板", "傻笑", "流泪", "害羞", "闭嘴",
|
||||
"睡", "大哭", "尴尬", "感谢", "拒绝", "赞", "鼓掌", "打招呼", "666", "抱拳",
|
||||
"握手", "OK", "胜利", "向左", "向右", "向上", "向下", "来呀", "一点点", "捏住",
|
||||
"比心", "送花花", "加油干", "调皮", "大笑", "惊讶", "流汗", "奋斗", "口罩", "生病",
|
||||
"吐", "难过", "抓狂", "右哼哼", "太阳", "月亮", "强", "弱", "彩带", "蛋糕",
|
||||
"骷髅", "撇嘴", "鄙视", "嘘", "思考", "亲亲", "无奈", "感冒", "对不起", "再见",
|
||||
"投降", "哼", "欠扁", "拜托", "可怜", "舒服", "爱意", "财迷", "迷惑", "委屈",
|
||||
"灵感", "天使", "鬼脸", "凄凉", "郁闷", "坏笑", "算账", "PK", "忍者", "衰",
|
||||
"炸弹", "笑哭", "嘿嘿", "捂脸哭", "抠鼻", "流鼻血", "呲牙", "吃瓜", "彩虹", "耶",
|
||||
"发怒", "捂眼睛", "推眼镜", "暗中观察", "脑暴", "冷笑", "热", "开心", "惊喜", "回头",
|
||||
"白眼", "一团乱麻", "黑眼圈", "裂开", "恭喜", "费解", "收到", "快来", "敲打", "捧脸",
|
||||
"Get", "客服", "AR", "小蜜蜂", "虎虎生威", "兔飞猛进", "龙头老大", "蛇来运转", "马上来财", "专注",
|
||||
"忙疯了", "等一等", "一脸苦笑", "王之蔑视", "洪荒之力", "向左看", "向右看", "YYDS", "这边请", "弹射下班",
|
||||
"退退退", "在吗", "让人头大", "摊手", "抱抱", "举手", "开车", "抱大腿", "跪了", "鞠躬",
|
||||
"选我", "元气满满", "会议", "猫咪", "二哈", "狗子", "三多", "承让", "撒花", "礼物",
|
||||
"生日快乐", "爱心", "心碎", "嘴唇", "鲜花", "残花", "干杯", "咖啡", "奶茶", "茶",
|
||||
"OKR", "KPI", "100分", "对勾", "打叉", "气泡", "加一", "Done", "钉子", "出差",
|
||||
"高铁", "火箭", "邮件", "文档", "演示", "表格", "废纸篓", "手机", "时间", "静音",
|
||||
"公文包", "地球", "碳减排", "回收标志", "幼苗", "红包", "锦鲤", "福", "灯笼", "爆竹",
|
||||
"烟花", "恭喜发财", "月饼", "鸡腿", "休假", "火", "点赞", "平安健康", "定胜":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package commentreaction
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageValidate(t *testing.T) {
|
||||
for _, value := range []string{"憨笑", "鼓掌", "比心", "赞", "OK", "Done", "平安健康"} {
|
||||
if err := Validate(value); err != nil {
|
||||
t.Errorf("supported reaction %q rejected: %v", value, err)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"", "😄", "👏", "�", "garbled", "乱码", "憨笑\n"} {
|
||||
if err := Validate(value); err == nil {
|
||||
t.Errorf("unsupported reaction %q accepted", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,24 @@ func WithOperation(operation string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// IsConfirmationRequired reports whether err (or any wrapped cause) is a
|
||||
// typed framework confirmation-gate failure carrying reason
|
||||
// confirmation_required. Downstream classifiers must pass such errors through
|
||||
// verbatim: the "re-run with --yes" semantics can only be carried by the
|
||||
// machine-readable reason, while message-text classification actively
|
||||
// misroutes them (a command path containing "permission" would be reported as
|
||||
// an auth failure, and any other wording degrades to an unclassified error).
|
||||
func IsConfirmationRequired(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var typed *Error
|
||||
if stderrors.As(err, &typed) {
|
||||
return strings.TrimSpace(typed.Reason) == "confirmation_required"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WithServerKey records the server identifier associated with the failure.
|
||||
func WithServerKey(serverKey string) Option {
|
||||
return func(err *Error) {
|
||||
|
||||
@@ -15,6 +15,7 @@ package errors
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -464,3 +465,34 @@ func TestCrossPlatformCoveragePrintHumanHidesRPCCode_Normal(t *testing.T) {
|
||||
t.Fatalf("normal mode should not show RPC Code, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageIsConfirmationRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if IsConfirmationRequired(nil) {
|
||||
t.Fatal("nil error must not report confirmation_required")
|
||||
}
|
||||
if IsConfirmationRequired(NewValidation("missing required flag")) {
|
||||
t.Fatal("plain validation error must not report confirmation_required")
|
||||
}
|
||||
plain := stderrors.New("需要用户确认")
|
||||
if IsConfirmationRequired(plain) {
|
||||
t.Fatal("message text alone must not report confirmation_required")
|
||||
}
|
||||
confirmation := NewValidation(
|
||||
"blocked",
|
||||
WithReason("confirmation_required"),
|
||||
)
|
||||
if !IsConfirmationRequired(confirmation) {
|
||||
t.Fatal("typed confirmation error must report confirmation_required")
|
||||
}
|
||||
// 包装链(fmt.Errorf %w)必须能穿透到 typed 原因。
|
||||
wrapped := fmt.Errorf("call tool: %w", confirmation)
|
||||
if !IsConfirmationRequired(wrapped) {
|
||||
t.Fatal("wrapped confirmation error must report confirmation_required")
|
||||
}
|
||||
otherReason := NewValidation("rate limited", WithReason("rate_limit"))
|
||||
if IsConfirmationRequired(otherReason) {
|
||||
t.Fatal("other reasons must not report confirmation_required")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ func newAgoalCommand() *cobra.Command {
|
||||
dws agoal scorecard detail 获取计分卡详情
|
||||
dws agoal scorecard entity-detail 获取计分卡实体详情
|
||||
dws agoal scorecard update 更新计分卡
|
||||
dws agoal scorecard search-entities 搜索计分卡指标与关键事项
|
||||
dws agoal user rules 获取用户规则
|
||||
dws agoal user objectives 查询用户目标列表
|
||||
dws agoal report list-statistics 获取周月报数据跟催列表
|
||||
@@ -359,7 +360,37 @@ scopeType 支持:
|
||||
scorecardUpdateCmd.Flags().String("content", "", "内容 JSON 数组 (必填)")
|
||||
scorecardUpdateCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
|
||||
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd)
|
||||
scorecardSearchContentCmd := &cobra.Command{
|
||||
Use: "search-entities",
|
||||
Short: "搜索计分卡指标与关键事项",
|
||||
Long: `根据关键词模糊搜索计分卡中的指标和关键事项标题,返回匹配的计分卡实体信息(计分卡ID、实体ID、实体类型、标题、所属团队等)。`,
|
||||
Example: ` dws agoal scorecard search-entities --keyword "业绩"
|
||||
dws agoal scorecard search-entities --keyword "业绩" --page 1 --page-size 20`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "keyword"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"keyword": mustGetFlag(cmd, "keyword"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("page"); v != 0 {
|
||||
toolArgs["page"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("page-size"); v != 0 {
|
||||
toolArgs["pageSize"] = v
|
||||
}
|
||||
return callMCPTool("search_score_card_entities", toolArgs)
|
||||
},
|
||||
}
|
||||
scorecardSearchContentCmd.Flags().String("keyword", "", "搜索关键词,标题模糊匹配 (必填)")
|
||||
scorecardSearchContentCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
scorecardSearchContentCmd.Flags().Int("page", 0, "页码,默认 1 (可选)")
|
||||
scorecardSearchContentCmd.Flags().Int("page-size", 0, "每页数量,最大 100 (可选)")
|
||||
|
||||
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd, scorecardSearchContentCmd)
|
||||
|
||||
// ── user: 用户目标管理 ──────────────────────────────────────
|
||||
|
||||
|
||||
+777
-3
@@ -7,6 +7,8 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -126,6 +128,83 @@ func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) {
|
||||
return dsl, nil
|
||||
}
|
||||
|
||||
// executeAitableWorkflowPublish executes a publish exactly once, then requires
|
||||
// the reviewed valid/flowId envelope before rendering any success output.
|
||||
// create_workflow is non-idempotent, so transport uncertainty is never retried.
|
||||
func executeAitableWorkflowPublish(toolName string, args map[string]any) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer("aitable", toolName, args)
|
||||
}
|
||||
raw, err := callMCPToolReturnTextOnServer(context.Background(), "aitable", toolName, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &envelope); err != nil || envelope == nil {
|
||||
return fmt.Errorf("%s response is not a JSON object", toolName)
|
||||
}
|
||||
valid, validFound, flowID, err := strictAitableWorkflowPublishResult(envelope)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s response validation failed: %w", toolName, err)
|
||||
}
|
||||
if !validFound {
|
||||
return fmt.Errorf("%s response is missing valid", toolName)
|
||||
}
|
||||
if !valid {
|
||||
return fmt.Errorf("%s returned valid=false; inspect issues and correct the workflow DSL", toolName)
|
||||
}
|
||||
if flowID == "" {
|
||||
return fmt.Errorf("%s response is missing a non-empty flowId", toolName)
|
||||
}
|
||||
return RenderLegacyMCPText(toolName, raw)
|
||||
}
|
||||
|
||||
func strictAitableWorkflowPublishResult(envelope map[string]any) (valid bool, validFound bool, flowID string, err error) {
|
||||
var visit func(map[string]any) error
|
||||
visit = func(object map[string]any) error {
|
||||
if raw, exists := object["valid"]; exists {
|
||||
value, ok := raw.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("valid must be boolean, got %T", raw)
|
||||
}
|
||||
if validFound && valid != value {
|
||||
return fmt.Errorf("conflicting valid values")
|
||||
}
|
||||
valid, validFound = value, true
|
||||
}
|
||||
for _, key := range []string{"flowId", "workflowId"} {
|
||||
raw, exists := object[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
value, ok := raw.(string)
|
||||
value = strings.TrimSpace(value)
|
||||
if !ok || value == "" {
|
||||
return fmt.Errorf("%s must be a non-empty string", key)
|
||||
}
|
||||
if flowID != "" && flowID != value {
|
||||
return fmt.Errorf("conflicting workflow IDs")
|
||||
}
|
||||
flowID = value
|
||||
}
|
||||
for _, key := range []string{"data", "result", "response"} {
|
||||
if nested, ok := object[key].(map[string]any); ok {
|
||||
if err := visit(nested); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if envelope == nil {
|
||||
return false, false, "", fmt.Errorf("empty response")
|
||||
}
|
||||
if err := visit(envelope); err != nil {
|
||||
return false, false, "", err
|
||||
}
|
||||
return valid, validFound, flowID, nil
|
||||
}
|
||||
|
||||
func validateWorkflowRunFlags(cmd *cobra.Command, _ []string) error {
|
||||
tableID, _ := cmd.Flags().GetString("table-id")
|
||||
tableID = strings.TrimSpace(tableID)
|
||||
@@ -1184,6 +1263,290 @@ func runAitableViewUpdateArray(cmd *cobra.Command, blockKey string) error {
|
||||
return callUpdateViewWithBlock(baseID, tableID, viewID, blockKey, cfgMap[blockKey], nil)
|
||||
}
|
||||
|
||||
func runAitableViewUpdateFilter(cmd *cobra.Command) error {
|
||||
baseID, tableID, viewID, _, err := viewUpdateCommonPreflight(cmd, "filter", nil, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jsonStr, _ := cmd.Flags().GetString("json")
|
||||
if jsonStr == "" {
|
||||
return fmt.Errorf("必须指定 --json 传入 filter JSON 数组")
|
||||
}
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil {
|
||||
return fmt.Errorf("--json 解析失败: %v", err)
|
||||
}
|
||||
cfgMap := map[string]any{"filter": parsed}
|
||||
if err := normalizeViewConfigBlock(cfgMap); err != nil {
|
||||
return err
|
||||
}
|
||||
filter, _ := cfgMap["filter"].([]any)
|
||||
fieldTypes, err := loadAitableFieldTypes(context.Background(), baseID, tableID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAitableViewFilter(filter, fieldTypes); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID, "tableId": tableID, "viewId": viewID,
|
||||
"config": map[string]any{"filter": filter},
|
||||
}
|
||||
if deps.Caller.DryRun() {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true, "executed": false, "tool": "update_view", "arguments": toolArgs,
|
||||
})
|
||||
}
|
||||
if _, err := callMCPToolReturnTextOnServer(context.Background(), "aitable", "update_view", toolArgs); err != nil {
|
||||
return err
|
||||
}
|
||||
var actual any
|
||||
var readBackErr error
|
||||
for attempt := 0; attempt < aitableViewFilterReadbackAttempts; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(1<<(attempt-1)) * time.Second
|
||||
if backoff > 8*time.Second {
|
||||
backoff = 8 * time.Second
|
||||
}
|
||||
aitableViewFilterReadbackSleep(backoff)
|
||||
}
|
||||
view, _, err := getViewRaw(context.Background(), baseID, tableID, viewID)
|
||||
if err != nil {
|
||||
readBackErr = err
|
||||
continue
|
||||
}
|
||||
actualViewID, _ := view["viewId"].(string)
|
||||
if actualViewID != viewID {
|
||||
readBackErr = fmt.Errorf("update_view read-back returned viewId %q, want %q", actualViewID, viewID)
|
||||
continue
|
||||
}
|
||||
actual = walkViewPath(view, "filter")
|
||||
if persistedViewFilterMatches(actual, filter) {
|
||||
readBackErr = nil
|
||||
break
|
||||
}
|
||||
readBackErr = fmt.Errorf("update_view filter read-back mismatch: got %s, want %s", compactJSON(actual), compactJSON(filter))
|
||||
}
|
||||
if readBackErr != nil {
|
||||
return &CLIError{Code: CodeMCPToolError, Message: readBackErr.Error(), Suggestion: "重新读取 view get filter,确认服务端支持所用字段类型和操作符后再试"}
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"success": true,
|
||||
"data": map[string]any{"baseId": baseID, "tableId": tableID, "viewId": viewID, "filter": filter, "verified": true},
|
||||
})
|
||||
}
|
||||
|
||||
const aitableViewFilterReadbackAttempts = 6
|
||||
|
||||
var aitableViewFilterReadbackSleep = time.Sleep
|
||||
|
||||
func persistedViewFilterMatches(actual any, expected []any) bool {
|
||||
if reflect.DeepEqual(actual, expected) {
|
||||
return true
|
||||
}
|
||||
root, ok := actual.(map[string]any)
|
||||
if !ok || root["operator"] != "and" {
|
||||
return false
|
||||
}
|
||||
operands, ok := root["operands"].([]any)
|
||||
return ok && reflect.DeepEqual(operands, expected)
|
||||
}
|
||||
|
||||
func loadAitableFieldTypes(ctx context.Context, baseID, tableID string) (map[string]string, error) {
|
||||
raw, err := callMCPReadToolReturnTextOnServer(ctx, "aitable", "get_fields", map[string]any{"baseId": baseID, "tableId": tableID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload any
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return nil, fmt.Errorf("get_fields response is not valid JSON: %v", err)
|
||||
}
|
||||
fields, ok := findAitableObjectList(payload, "fields", "fieldList")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("get_fields response is missing the fields collection")
|
||||
}
|
||||
types := make(map[string]string, len(fields))
|
||||
for index, field := range fields {
|
||||
fieldID, _ := field["fieldId"].(string)
|
||||
if strings.TrimSpace(fieldID) == "" {
|
||||
fieldID, _ = field["id"].(string)
|
||||
}
|
||||
fieldType, _ := field["type"].(string)
|
||||
if strings.TrimSpace(fieldType) == "" {
|
||||
fieldType, _ = field["fieldType"].(string)
|
||||
}
|
||||
if strings.TrimSpace(fieldID) == "" || strings.TrimSpace(fieldType) == "" {
|
||||
return nil, fmt.Errorf("get_fields field %d is missing fieldId or type", index)
|
||||
}
|
||||
types[strings.TrimSpace(fieldID)] = strings.TrimSpace(fieldType)
|
||||
}
|
||||
return types, nil
|
||||
}
|
||||
|
||||
func findAitableObjectList(value any, names ...string) ([]map[string]any, bool) {
|
||||
wanted := make([]string, 0, len(names))
|
||||
seen := make(map[string]bool, len(names))
|
||||
for _, name := range names {
|
||||
name = strings.ToLower(name)
|
||||
if !seen[name] {
|
||||
wanted = append(wanted, name)
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
var walk func(any) ([]map[string]any, bool)
|
||||
walk = func(current any) ([]map[string]any, bool) {
|
||||
switch typed := current.(type) {
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(typed))
|
||||
for key := range typed {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, name := range wanted {
|
||||
for _, key := range keys {
|
||||
if !strings.EqualFold(key, name) {
|
||||
continue
|
||||
}
|
||||
items, ok := typed[key].([]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, object)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
}
|
||||
for _, key := range keys {
|
||||
if found, ok := walk(typed[key]); ok {
|
||||
return found, true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
if found, ok := walk(child); ok {
|
||||
return found, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return walk(value)
|
||||
}
|
||||
|
||||
func validateAitableViewFilter(filter []any, fieldTypes map[string]string) error {
|
||||
for index, raw := range filter {
|
||||
condition, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("filter[%d] must be an object", index)
|
||||
}
|
||||
if err := validateAitableViewFilterCondition(condition, fieldTypes); err != nil {
|
||||
return fmt.Errorf("filter[%d]: %w", index, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAitableViewFilterCondition(condition map[string]any, fieldTypes map[string]string) error {
|
||||
operator, _ := condition["operator"].(string)
|
||||
operator = strings.TrimSpace(operator)
|
||||
if operator == "" || !validFilterOperators[operator] {
|
||||
return fmt.Errorf("unsupported operator %q", operator)
|
||||
}
|
||||
operands, ok := condition["operands"].([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("operator %s requires an operands array", operator)
|
||||
}
|
||||
if operator == "and" || operator == "or" {
|
||||
return fmt.Errorf("logical operator %s is not supported by the persisted view protocol; pass a flat array of leaf conditions (combined as AND)", operator)
|
||||
}
|
||||
wantOperands := 2
|
||||
if operator == "exist" || operator == "un_exist" {
|
||||
wantOperands = 1
|
||||
}
|
||||
if len(operands) != wantOperands {
|
||||
return fmt.Errorf("operator %s requires %d operands", operator, wantOperands)
|
||||
}
|
||||
fieldID, ok := operands[0].(string)
|
||||
fieldID = strings.TrimSpace(fieldID)
|
||||
if !ok || fieldID == "" {
|
||||
return fmt.Errorf("operator %s requires a fieldId as its first operand", operator)
|
||||
}
|
||||
fieldType, exists := fieldTypes[fieldID]
|
||||
if !exists {
|
||||
return fmt.Errorf("filter references unknown fieldId %q", fieldID)
|
||||
}
|
||||
if isAitableDateFieldType(fieldType) && !isAitableDateFilterOperator(operator) {
|
||||
return fmt.Errorf("operator %s is invalid for %s field %s; use date_eq/before/after/not_before/not_after/exist/un_exist", operator, fieldType, fieldID)
|
||||
}
|
||||
if operator == "any_of" || operator == "all_of" || operator == "none_of" {
|
||||
if !strings.EqualFold(fieldType, "multipleSelect") && !strings.EqualFold(fieldType, "multiSelect") {
|
||||
return fmt.Errorf("operator %s requires a multipleSelect field, got %s", operator, fieldType)
|
||||
}
|
||||
if operator == "any_of" {
|
||||
if values, ok := operands[1].([]any); ok {
|
||||
if err := validateAitableMultiSelectOptionNames(values); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("multipleSelect any_of with multiple values is not supported by the persisted view protocol; use one scalar option or separate views")
|
||||
}
|
||||
}
|
||||
if err := validateAitableMultiSelectFilterValue(operands[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAitableMultiSelectOptionNames(values []any) error {
|
||||
if len(values) == 0 {
|
||||
return fmt.Errorf("multipleSelect any_of array must not be empty")
|
||||
}
|
||||
for index, value := range values {
|
||||
text, ok := value.(string)
|
||||
text = strings.TrimSpace(text)
|
||||
if !ok || text == "" {
|
||||
return fmt.Errorf("multipleSelect any_of value %d must be a non-empty option-name string", index)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isAitableDateFieldType(fieldType string) bool {
|
||||
return strings.EqualFold(fieldType, "date") ||
|
||||
strings.EqualFold(fieldType, "createdTime") ||
|
||||
strings.EqualFold(fieldType, "lastModifiedTime")
|
||||
}
|
||||
|
||||
func isAitableDateFilterOperator(operator string) bool {
|
||||
switch operator {
|
||||
case "date_eq", "before", "after", "not_before", "not_after", "exist", "un_exist":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateAitableMultiSelectFilterValue(value any) error {
|
||||
if typed, ok := value.(string); ok && strings.TrimSpace(typed) != "" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("multipleSelect filter value must be one option-name string; the persisted view protocol does not support a multi-value OR expression")
|
||||
}
|
||||
|
||||
func compactJSON(value any) string {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%#v", value)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func newAitableCommand() *cobra.Command {
|
||||
// Product-level Agent routing Decl (migrated from selection/aitable.json
|
||||
// products.aitable). Catalog assembly stamps provenance contract_final.
|
||||
@@ -4076,7 +4439,7 @@ colorConfigs (JSON 数组) / officialHoliday (bool)。`,
|
||||
若传对象会自动 wrap 为数组;其他非法格式拒绝。`,
|
||||
Example: ` dws aitable view update filter --view-id VIEW_ID --json '[{"operator":"and","operands":[{"operator":"eq","operands":["fldX","value"]}]}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runAitableViewUpdateArray(cmd, "filter")
|
||||
return runAitableViewUpdateFilter(cmd)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(viewUpdateFilterCmd, LeafSpec{
|
||||
@@ -5282,7 +5645,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
|
||||
}
|
||||
// create_workflow is non-idempotent. Bypass the retry wrapper to
|
||||
// prevent an uncertain first response from creating a duplicate.
|
||||
return callMCPToolOnServer("aitable", "create_workflow", toolArgs)
|
||||
return executeAitableWorkflowPublish("create_workflow", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(workflowCreateCmd, LeafSpec{
|
||||
@@ -5376,7 +5739,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
|
||||
if locale, _ := cmd.Flags().GetString("locale"); strings.TrimSpace(locale) != "" {
|
||||
toolArgs["locale"] = locale
|
||||
}
|
||||
return callAitableTool("update_workflow", toolArgs)
|
||||
return executeAitableWorkflowPublish("update_workflow", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(workflowUpdateCmd, LeafSpec{
|
||||
@@ -8059,6 +8422,416 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
sectionMoveNodeCmd,
|
||||
)
|
||||
|
||||
// ── datasource: 数据源同步管理 ──────────────────────────────
|
||||
|
||||
datasourceCmd := &cobra.Command{Use: "datasource", Short: "数据源同步管理", RunE: groupRunE}
|
||||
|
||||
datasourceGetConfigCmd := &cobra.Command{
|
||||
Use: "get-config",
|
||||
Short: "获取数据源表同步配置",
|
||||
Example: ` dws aitable datasource get-config --base-id BASE_ID --table-id TABLE_ID`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callAitableTool("get_datasource_config", map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceGetConfigCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_get_config",
|
||||
CanonicalPath: "aitable.datasource_get_config",
|
||||
CLIPath: "aitable datasource get-config",
|
||||
PrimaryCLIPath: "aitable datasource get-config",
|
||||
},
|
||||
Description: "获取数据源表的同步配置信息。",
|
||||
Interface: aitableMCPInterface("get_datasource_config"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取数据源表的同步配置信息。",
|
||||
UseWhen: []string{"查看已有数据源表的配置详情时"},
|
||||
AvoidWhen: []string{"更新配置用 datasource update;查询同步状态用 datasource sync-status"},
|
||||
Examples: []string{"dws aitable datasource get-config --base-id <BASE_ID> --table-id <TABLE_ID>"},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceGetConfigCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceGetConfigCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
|
||||
|
||||
datasourceListSourcesCmd := &cobra.Command{
|
||||
Use: "list-sources",
|
||||
Short: "列出可用数据源来源",
|
||||
Example: ` dws aitable datasource list-sources --base-id BASE_ID --datasource-type OA`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "datasource-type"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callAitableTool("list_datasource_sources", map[string]any{
|
||||
"baseId": baseID,
|
||||
"datasourceType": mustGetFlag(cmd, "datasource-type"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceListSourcesCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_list_sources",
|
||||
CanonicalPath: "aitable.datasource_list_sources",
|
||||
CLIPath: "aitable datasource list-sources",
|
||||
PrimaryCLIPath: "aitable datasource list-sources",
|
||||
},
|
||||
Description: "列出指定 Base 下可用的数据源条目。",
|
||||
Interface: aitableMCPInterface("list_datasource_sources"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出指定 Base 下可用的数据源条目(OA 审批模板等)。",
|
||||
UseWhen: []string{"创建或更新数据源前需要查看可用来源时"},
|
||||
AvoidWhen: []string{"获取字段结构用 datasource get-fields"},
|
||||
Examples: []string{"dws aitable datasource list-sources --base-id <BASE_ID> --datasource-type OA"},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceListSourcesCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceListSourcesCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
|
||||
|
||||
validateJSONObject := func(flag, raw string) error {
|
||||
var v any
|
||||
if err := json.Unmarshal([]byte(raw), &v); err != nil {
|
||||
return fmt.Errorf("--%s must be a valid JSON object: %w", flag, err)
|
||||
}
|
||||
if _, ok := v.(map[string]any); !ok {
|
||||
return fmt.Errorf("--%s must be a JSON object, got %T", flag, v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
validateAutoSyncSetting := func(raw string) error {
|
||||
return validateJSONObject("auto-sync-setting", raw)
|
||||
}
|
||||
|
||||
datasourceGetFieldsCmd := &cobra.Command{
|
||||
Use: "get-fields",
|
||||
Short: "获取数据源可同步字段列表",
|
||||
Example: ` dws aitable datasource get-fields --base-id BASE_ID --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "datasource-type", "source-config"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callAitableTool("get_datasource_fields", map[string]any{
|
||||
"baseId": baseID,
|
||||
"datasourceType": mustGetFlag(cmd, "datasource-type"),
|
||||
"sourceConfig": mustGetFlag(cmd, "source-config"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceGetFieldsCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_get_fields",
|
||||
CanonicalPath: "aitable.datasource_get_fields",
|
||||
CLIPath: "aitable datasource get-fields",
|
||||
PrimaryCLIPath: "aitable datasource get-fields",
|
||||
},
|
||||
Description: "获取指定数据源来源的可同步字段列表。",
|
||||
Interface: aitableMCPInterface("get_datasource_fields"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键)。",
|
||||
UseWhen: []string{"创建或更新数据源前需要查看可同步字段以决定 field-ids 时"},
|
||||
AvoidWhen: []string{"列出可用来源用 datasource list-sources"},
|
||||
Examples: []string{`dws aitable datasource get-fields --base-id <BASE_ID> --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceGetFieldsCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceGetFieldsCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
|
||||
datasourceGetFieldsCmd.Flags().String("source-config", "", "源配置 JSON 字符串,需含 processCode、name、iconUrl、url、dataType 及对应时间字段 (必填)")
|
||||
|
||||
datasourceCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建数据源表并触发首次同步",
|
||||
Example: ` dws aitable datasource create --base-id BASE_ID --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "datasource-type", "source-config"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auto, _ := cmd.Flags().GetBool("auto")
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"datasourceType": mustGetFlag(cmd, "datasource-type"),
|
||||
"sourceConfig": mustGetFlag(cmd, "source-config"),
|
||||
"auto": auto,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("field-ids"); v != "" {
|
||||
toolArgs["fieldIds"] = parseCSVValues(v)
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("auto-sync-setting"); v != "" {
|
||||
if err := validateAutoSyncSetting(v); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["autoSyncSetting"] = v
|
||||
}
|
||||
return callAitableTool("create_datasource", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceCreateCmd, LeafSpec{
|
||||
Safety: aitableSafetyWrite(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_create",
|
||||
CanonicalPath: "aitable.datasource_create",
|
||||
CLIPath: "aitable datasource create",
|
||||
PrimaryCLIPath: "aitable datasource create",
|
||||
},
|
||||
Description: "为指定 Base 创建数据源表并触发首次全量同步。",
|
||||
Interface: aitableMCPInterface("create_datasource"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。",
|
||||
UseWhen: []string{"需要将外部数据源接入 AI 表格、创建新的数据源表时"},
|
||||
AvoidWhen: []string{"已有数据源表改配置用 datasource update;仅触发同步用 datasource sync"},
|
||||
Examples: []string{`dws aitable datasource create --base-id <BASE_ID> --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "datasource-type", Property: "datasourceType", Required: boolPtr(true)},
|
||||
{Name: "source-config", Property: "sourceConfig", Required: boolPtr(true)},
|
||||
{Name: "auto", Property: "auto"},
|
||||
{Name: "field-ids", Property: "fieldIds", InterfaceType: "array"},
|
||||
{Name: "auto-sync-setting", Property: "autoSyncSetting"},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceCreateCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceCreateCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
|
||||
datasourceCreateCmd.Flags().String("source-config", "", "源配置 JSON 字符串,须从 list-sources 原样透传 processCode/name/iconUrl/url,并设置 dataType 及对应时间字段 (必填)")
|
||||
datasourceCreateCmd.Flags().Bool("auto", false, "是否开启自动同步,默认 false;创建新数据源表时始终下发给下游")
|
||||
datasourceCreateCmd.Flags().String("field-ids", "", "需要同步的字段 ID 列表,逗号分隔;不传时同步全部字段")
|
||||
datasourceCreateCmd.Flags().String("auto-sync-setting", "", "自动同步频率配置 JSON 字符串,仅在 --auto=true 时生效。字段:syncType(必填,hourly/scheduled)、hourlyInterval(syncType=hourly 时必填)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(HH:mm)、selectedMonthDays(scheduleType=monthly 时)、selectedWeekdays(scheduleType=weekly 时)、skipNonWorkingDay")
|
||||
|
||||
datasourceUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新数据源表同步配置并触发同步",
|
||||
Example: ` dws aitable datasource update --base-id BASE_ID --table-id TABLE_ID --auto`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
}
|
||||
if cmd.Flags().Changed("source-config") {
|
||||
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["sourceConfig"] = mustGetFlag(cmd, "source-config")
|
||||
}
|
||||
if cmd.Flags().Changed("auto") {
|
||||
auto, _ := cmd.Flags().GetBool("auto")
|
||||
toolArgs["auto"] = auto
|
||||
}
|
||||
if cmd.Flags().Changed("field-ids") {
|
||||
v := mustGetFlag(cmd, "field-ids")
|
||||
if v == "" {
|
||||
return fmt.Errorf("--field-ids 显式提供时不能为空,如需保持默认请勿传入")
|
||||
}
|
||||
toolArgs["fieldIds"] = parseCSVValues(v)
|
||||
}
|
||||
if cmd.Flags().Changed("auto-sync-setting") {
|
||||
v := mustGetFlag(cmd, "auto-sync-setting")
|
||||
if v == "" {
|
||||
return fmt.Errorf("--auto-sync-setting 显式提供时不能为空,如需保持默认请勿传入")
|
||||
}
|
||||
if err := validateAutoSyncSetting(v); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["autoSyncSetting"] = v
|
||||
}
|
||||
if !cmd.Flags().Changed("source-config") && !cmd.Flags().Changed("auto") && !cmd.Flags().Changed("field-ids") && !cmd.Flags().Changed("auto-sync-setting") {
|
||||
return fmt.Errorf("至少需要一个配置变更:--source-config、--auto、--field-ids 或 --auto-sync-setting;仅触发同步请使用 datasource sync")
|
||||
}
|
||||
return callAitableTool("update_datasource_config", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceUpdateCmd, LeafSpec{
|
||||
Safety: aitableSafetyWrite(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_update",
|
||||
CanonicalPath: "aitable.datasource_update",
|
||||
CLIPath: "aitable datasource update",
|
||||
PrimaryCLIPath: "aitable datasource update",
|
||||
},
|
||||
Description: "更新已有数据源表的同步配置并触发一次同步。",
|
||||
Interface: aitableMCPInterface("update_datasource_config"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "更新已有数据源表的同步配置并触发一次同步。",
|
||||
UseWhen: []string{"需要修改已有数据源表的配置(更换模板、调整字段、开关自动同步)时"},
|
||||
AvoidWhen: []string{"创建新数据源表用 datasource create;仅触发同步用 datasource sync"},
|
||||
Examples: []string{
|
||||
"dws aitable datasource update --base-id <BASE_ID> --table-id <TABLE_ID> --auto",
|
||||
`dws aitable datasource update --base-id <BASE_ID> --table-id <TABLE_ID> --source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
|
||||
{Name: "source-config", Property: "sourceConfig"},
|
||||
{Name: "auto", Property: "auto"},
|
||||
{Name: "field-ids", Property: "fieldIds", InterfaceType: "array"},
|
||||
{Name: "auto-sync-setting", Property: "autoSyncSetting"},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceUpdateCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceUpdateCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
|
||||
datasourceUpdateCmd.Flags().String("source-config", "", "可选。新的源配置 JSON 字符串,不传时保持原配置;传入时整体覆盖,须含 processCode、name、iconUrl、url、dataType 及对应时间字段")
|
||||
datasourceUpdateCmd.Flags().Bool("auto", false, "可选。是否开启自动同步;仅显式设置时下发给下游,省略时保持原设置")
|
||||
datasourceUpdateCmd.Flags().String("field-ids", "", "需要同步的字段 ID 列表,逗号分隔;不传时保持现有配置(创建时默认为全部字段)")
|
||||
datasourceUpdateCmd.Flags().String("auto-sync-setting", "", "可选。自动同步频率配置 JSON 字符串,仅在显式设置 --auto=true 时生效;省略时保持原有自动同步频率配置。字段:syncType(必填,hourly/scheduled)、hourlyInterval(syncType=hourly 时必填)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(HH:mm)、selectedMonthDays(scheduleType=monthly 时)、selectedWeekdays(scheduleType=weekly 时)、skipNonWorkingDay")
|
||||
|
||||
datasourceSyncCmd := &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "触发数据源表手动同步",
|
||||
Example: ` dws aitable datasource sync --base-id BASE_ID --table-ids TBL1,TBL2`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tableIDs := parseCSVValues(mustGetFlag(cmd, "table-ids"))
|
||||
if len(tableIDs) < 1 || len(tableIDs) > 5 {
|
||||
return fmt.Errorf("--table-ids requires 1-5 table IDs, got %d", len(tableIDs))
|
||||
}
|
||||
return callAitableTool("run_datasource_sync", map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableIds": tableIDs,
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceSyncCmd, LeafSpec{
|
||||
Safety: aitableSafetyWrite(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_sync",
|
||||
CanonicalPath: "aitable.datasource_sync",
|
||||
CLIPath: "aitable datasource sync",
|
||||
PrimaryCLIPath: "aitable datasource sync",
|
||||
},
|
||||
Description: "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回。",
|
||||
Interface: aitableMCPInterface("run_datasource_sync"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。",
|
||||
UseWhen: []string{"需要手动触发已有数据源表的数据同步时"},
|
||||
AvoidWhen: []string{"创建新数据源表用 datasource create;更新配置用 datasource update"},
|
||||
Examples: []string{"dws aitable datasource sync --base-id <BASE_ID> --table-ids TBL1,TBL2"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-ids", Property: "tableIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceSyncCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceSyncCmd.Flags().String("table-ids", "", "待触发同步的数据源表 ID 列表,逗号分隔,1-5 个 (必填)")
|
||||
|
||||
datasourceSyncStatusCmd := &cobra.Command{
|
||||
Use: "sync-status",
|
||||
Short: "按任务 ID 查询数据源同步任务状态",
|
||||
Example: ` dws aitable datasource sync-status --base-id BASE_ID --table-id TABLE_ID --task-ids TASK1,TASK2`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id", "task-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := parseCSVValues(mustGetFlag(cmd, "task-ids"))
|
||||
if len(ids) < 1 || len(ids) > 5 {
|
||||
return fmt.Errorf("--task-ids requires 1-5 task IDs, got %d", len(ids))
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
"taskIds": ids,
|
||||
}
|
||||
return callAitableTool("get_datasource_sync_status", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceSyncStatusCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_sync_status",
|
||||
CanonicalPath: "aitable.datasource_sync_status",
|
||||
CLIPath: "aitable datasource sync-status",
|
||||
PrimaryCLIPath: "aitable datasource sync-status",
|
||||
},
|
||||
Description: "按任务 ID 查询数据源同步任务状态(RUNNING/FINISHED/FAILED)。",
|
||||
Interface: aitableMCPInterface("get_datasource_sync_status"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按任务 ID 批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。",
|
||||
UseWhen: []string{"触发同步后需要按任务 ID 查询任务是否完成时"},
|
||||
AvoidWhen: []string{"触发同步用 datasource sync"},
|
||||
Examples: []string{"dws aitable datasource sync-status --base-id <BASE_ID> --table-id <TABLE_ID> --task-ids TASK1"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
|
||||
{Name: "task-ids", Property: "taskIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceSyncStatusCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceSyncStatusCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
|
||||
datasourceSyncStatusCmd.Flags().String("task-ids", "", "待查询的同步任务 ID 列表,逗号分隔,1-5 个 (必填)")
|
||||
|
||||
datasourceCmd.AddCommand(
|
||||
datasourceGetConfigCmd, datasourceListSourcesCmd, datasourceGetFieldsCmd,
|
||||
datasourceCreateCmd, datasourceUpdateCmd,
|
||||
datasourceSyncCmd, datasourceSyncStatusCmd,
|
||||
)
|
||||
|
||||
// 组装 aitable 命令树
|
||||
root.AddCommand(
|
||||
baseCmd, tableCmd, fieldCmd,
|
||||
@@ -8069,6 +8842,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
attachmentCmd, templateCmd,
|
||||
advpermCmd,
|
||||
sectionCmd,
|
||||
datasourceCmd,
|
||||
)
|
||||
|
||||
// 批量注册 --base 作为 --base-id 的隐藏别名
|
||||
|
||||
@@ -361,3 +361,228 @@ func TestCrossPlatformCoverageAitableDeleteCancellationEdges(t *testing.T) {
|
||||
_ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{}, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableViewFilterValidationAndReadBack(t *testing.T) {
|
||||
testseam.Swap(t, &aitableViewFilterReadbackSleep, func(time.Duration) {})
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
os.Args = []string{"dws", "aitable"}
|
||||
filter := `[{"operator":"eq","operands":["fldA","x"]},{"operator":"any_of","operands":["fldMulti","A"]}]`
|
||||
fields := `{"data":{"fields":[{"fieldId":"fldA","type":"text"},{"fieldId":"fldB","type":"text"},{"fieldId":"fldMulti","type":"multipleSelect"}]}}`
|
||||
readBack := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":[{"operator":"eq","operands":["fldA","x"]},{"operator":"any_of","operands":["fldMulti","A"]}]}]}}`
|
||||
|
||||
t.Run("flat leaf filters are written then exactly verified", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`, readBack}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter)
|
||||
if err != nil || len(caller.calls) != 3 || caller.calls[0].tool != "get_fields" || caller.calls[1].tool != "update_view" || caller.calls[2].tool != "get_views" {
|
||||
t.Fatalf("verified view filter = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
config, _ := caller.calls[1].args["config"].(map[string]any)
|
||||
if _, ok := config["filter"].([]any); !ok {
|
||||
t.Fatalf("update_view filter encoding = %#v", caller.calls[1].args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("logical groups fail closed before write", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"or","operands":[{"operator":"eq","operands":["fldA","x"]},{"operator":"eq","operands":["fldB","y"]}]}]`)
|
||||
if err == nil || !strings.Contains(err.Error(), "persisted view protocol") || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
|
||||
t.Fatalf("logical filter group fail-closed = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown field is rejected before write", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"eq","operands":["missing","x"]}]`)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown fieldId") || len(caller.calls) != 1 {
|
||||
t.Fatalf("unknown filter field = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("eventual persisted and wrapper is retried then verified", func(t *testing.T) {
|
||||
input := `[{"operator":"any_of","operands":["fldMulti","A"]}]`
|
||||
stale := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":{"operator":"and","operands":[]}}]}}`
|
||||
terminal := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":{"operator":"and","operands":[{"operator":"any_of","operands":["fldMulti","A"]}]}}]}}`
|
||||
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`, stale, terminal}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
|
||||
if err != nil || len(caller.calls) != 4 || caller.calls[2].tool != "get_views" || caller.calls[3].tool != "get_views" {
|
||||
t.Fatalf("eventual wrapped filter = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-select operator rejects text field", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"any_of","operands":["fldA",["A"]]}]`)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires a multipleSelect field") || len(caller.calls) != 1 {
|
||||
t.Fatalf("wrong filter field type = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-select array fails closed before write", func(t *testing.T) {
|
||||
input := `[{"operator":"any_of","operands":["fldMulti",["A","B"]]}]`
|
||||
caller := &aitableTestCaller{responses: []string{fields}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
|
||||
if err == nil || !strings.Contains(err.Error(), "persisted view protocol") || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
|
||||
t.Fatalf("multi-select array fail-closed = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-select invalid array value fails before write", func(t *testing.T) {
|
||||
input := `[{"operator":"any_of","operands":["fldMulti",["A",""]]}]`
|
||||
caller := &aitableTestCaller{responses: []string{fields}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
|
||||
if err == nil || !strings.Contains(err.Error(), "non-empty option-name") || len(caller.calls) != 1 {
|
||||
t.Fatalf("invalid multi-select array = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("date and system-time fields require date operators", func(t *testing.T) {
|
||||
fieldTypes := map[string]string{"date": "date", "created": "createdTime", "modified": "lastModifiedTime"}
|
||||
for fieldID := range fieldTypes {
|
||||
valid := []any{map[string]any{"operator": "date_eq", "operands": []any{fieldID, "2026-08-18"}}}
|
||||
if err := validateAitableViewFilter(valid, fieldTypes); err != nil {
|
||||
t.Fatalf("date_eq for %s: %v", fieldID, err)
|
||||
}
|
||||
invalid := []any{map[string]any{"operator": "eq", "operands": []any{fieldID, "2026-08-18"}}}
|
||||
if err := validateAitableViewFilter(invalid, fieldTypes); err == nil || !strings.Contains(err.Error(), "invalid for") {
|
||||
t.Fatalf("eq for %s = %v, want date-operator error", fieldID, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry-run validates but performs no write or read-back", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields}, dryRun: true}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter, "--dry-run")
|
||||
if err != nil || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
|
||||
t.Fatalf("view filter dry-run = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read-back mismatch is not success", func(t *testing.T) {
|
||||
responses := []string{fields, `{"success":true}`}
|
||||
for range aitableViewFilterReadbackAttempts {
|
||||
responses = append(responses, `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":[]}]}}`)
|
||||
}
|
||||
caller := &aitableTestCaller{responses: responses}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter)
|
||||
if err == nil || !strings.Contains(err.Error(), "read-back mismatch") || len(caller.calls) != 2+aitableViewFilterReadbackAttempts {
|
||||
t.Fatalf("mismatched filter readback = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableViewFilterFailureAndShapeEdges(t *testing.T) {
|
||||
testseam.Swap(t, &aitableViewFilterReadbackSleep, func(time.Duration) {})
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "aitable"}
|
||||
fields := `{"data":{"fields":[{"fieldId":"fldA","type":"text"}]}}`
|
||||
filter := `[{"operator":"eq","operands":["fldA","x"]}]`
|
||||
args := []string{"view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json=" + filter}
|
||||
|
||||
t.Run("update transport error", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields}, errors: []error{nil, context.Canceled}}
|
||||
if err := runAitableCoverageCommand(t, caller, args...); err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || len(caller.calls) != 2 {
|
||||
t.Fatalf("update error = %v, calls=%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("readback transport errors exhaust", func(t *testing.T) {
|
||||
errs := []error{nil, nil}
|
||||
for range aitableViewFilterReadbackAttempts {
|
||||
errs = append(errs, context.DeadlineExceeded)
|
||||
}
|
||||
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`}, errors: errs}
|
||||
if err := runAitableCoverageCommand(t, caller, args...); err == nil || len(caller.calls) != 2+aitableViewFilterReadbackAttempts {
|
||||
t.Fatalf("readback errors = %v, calls=%d", err, len(caller.calls))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("readback wrong identity exhausts", func(t *testing.T) {
|
||||
responses := []string{fields, `{"success":true}`}
|
||||
for range aitableViewFilterReadbackAttempts {
|
||||
responses = append(responses, `{"data":{"views":[{"viewId":"other","filter":[]}]}}`)
|
||||
}
|
||||
caller := &aitableTestCaller{responses: responses}
|
||||
if err := runAitableCoverageCommand(t, caller, args...); err == nil || !strings.Contains(err.Error(), "returned viewId") {
|
||||
t.Fatalf("wrong readback identity = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
loadCases := []struct {
|
||||
name string
|
||||
response string
|
||||
callErr error
|
||||
wantError string
|
||||
}{
|
||||
{name: "transport", callErr: context.Canceled, wantError: "context canceled"},
|
||||
{name: "invalid json", response: `{`, wantError: "not valid JSON"},
|
||||
{name: "missing collection", response: `{}`, wantError: "missing the fields collection"},
|
||||
{name: "missing identity", response: `{"fields":[{"type":"text"}]}`, wantError: "missing fieldId or type"},
|
||||
}
|
||||
for _, tc := range loadCases {
|
||||
t.Run("load fields "+tc.name, func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{tc.response}, errors: []error{tc.callErr}}
|
||||
installAitableDeps(t, caller)
|
||||
if _, err := loadAitableFieldTypes(context.Background(), "b", "t"); err == nil || !strings.Contains(err.Error(), tc.wantError) {
|
||||
t.Fatalf("load fields error = %v, want %q", err, tc.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("load legacy field keys", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{`{"fieldList":[{"id":"legacy","fieldType":"text"}]}`}}
|
||||
installAitableDeps(t, caller)
|
||||
got, err := loadAitableFieldTypes(context.Background(), "b", "t")
|
||||
if err != nil || got["legacy"] != "text" {
|
||||
t.Fatalf("legacy field keys = %#v, %v", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
if _, ok := findAitableObjectList(map[string]any{"fields": "bad"}, "fields"); ok {
|
||||
t.Fatal("scalar fields collection must fail")
|
||||
}
|
||||
if _, ok := findAitableObjectList(map[string]any{"fields": []any{"bad"}}, "fields"); ok {
|
||||
t.Fatal("scalar field item must fail")
|
||||
}
|
||||
if got, ok := findAitableObjectList([]any{"skip", map[string]any{"nested": map[string]any{"fields": []any{map[string]any{"fieldId": "f"}}}}}, "fields"); !ok || len(got) != 1 {
|
||||
t.Fatalf("recursive fields = %#v, %v", got, ok)
|
||||
}
|
||||
if got, ok := findAitableObjectList(map[string]any{
|
||||
"fieldList": []any{map[string]any{"fieldId": "legacy"}},
|
||||
"fields": []any{map[string]any{"fieldId": "canonical"}},
|
||||
}, "fields", "fieldList"); !ok || len(got) != 1 || got[0]["fieldId"] != "canonical" {
|
||||
t.Fatalf("declared collection priority = %#v, %v", got, ok)
|
||||
}
|
||||
|
||||
invalidFilters := []struct {
|
||||
filter []any
|
||||
want string
|
||||
}{
|
||||
{filter: []any{"bad"}, want: "must be an object"},
|
||||
{filter: []any{map[string]any{"operator": "bogus", "operands": []any{}}}, want: "unsupported operator"},
|
||||
{filter: []any{map[string]any{"operator": "eq", "operands": "bad"}}, want: "requires an operands array"},
|
||||
{filter: []any{map[string]any{"operator": "and", "operands": []any{}}}, want: "logical operator"},
|
||||
{filter: []any{map[string]any{"operator": "exist", "operands": []any{"f", "extra"}}}, want: "requires 1 operands"},
|
||||
{filter: []any{map[string]any{"operator": "eq", "operands": []any{1, "x"}}}, want: "requires a fieldId"},
|
||||
{filter: []any{map[string]any{"operator": "any_of", "operands": []any{"multi", 1}}}, want: "one option-name string"},
|
||||
}
|
||||
for _, tc := range invalidFilters {
|
||||
if err := validateAitableViewFilter(tc.filter, map[string]string{"f": "text", "multi": "multipleSelect"}); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("validate filter %#v = %v, want %q", tc.filter, err, tc.want)
|
||||
}
|
||||
}
|
||||
if err := validateAitableMultiSelectOptionNames(nil); err == nil {
|
||||
t.Fatal("empty any_of array must fail")
|
||||
}
|
||||
if err := validateAitableMultiSelectOptionNames([]any{"ok", 1}); err == nil {
|
||||
t.Fatal("non-string any_of option must fail")
|
||||
}
|
||||
if err := validateAitableMultiSelectOptionNames([]any{"first", " second "}); err != nil {
|
||||
t.Fatalf("valid any_of option names = %v", err)
|
||||
}
|
||||
if got := compactJSON(make(chan int)); !strings.HasPrefix(got, "(chan int)") {
|
||||
t.Fatalf("compactJSON fallback = %q", got)
|
||||
}
|
||||
if persistedViewFilterMatches("bad", nil) || persistedViewFilterMatches(map[string]any{"operator": "or"}, nil) {
|
||||
t.Fatal("invalid persisted wrapper must not match")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type aitableTestCaller struct {
|
||||
responses []string
|
||||
errors []error
|
||||
calls []aitableTestCall
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
@@ -38,8 +39,11 @@ func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, arg
|
||||
}
|
||||
return textToolResult(response), nil
|
||||
}
|
||||
func (c *aitableTestCaller) CallReadTool(ctx context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
return c.CallTool(ctx, server, tool, args)
|
||||
}
|
||||
func (*aitableTestCaller) Format() string { return "json" }
|
||||
func (*aitableTestCaller) DryRun() bool { return false }
|
||||
func (c *aitableTestCaller) DryRun() bool { return c.dryRun }
|
||||
func (*aitableTestCaller) Fields() string { return "" }
|
||||
func (*aitableTestCaller) JQ() string { return "" }
|
||||
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type aitableDatasourceCaller struct {
|
||||
calls []aitableTestCall
|
||||
}
|
||||
|
||||
func (c *aitableDatasourceCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, aitableTestCall{server: server, tool: tool, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||
Type: "text",
|
||||
Text: `{"status":"success","data":{"tableId":"tbl_test","taskId":"task_test"}}`,
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
func (*aitableDatasourceCaller) Format() string { return "json" }
|
||||
func (*aitableDatasourceCaller) DryRun() bool { return false }
|
||||
func (*aitableDatasourceCaller) Fields() string { return "" }
|
||||
func (*aitableDatasourceCaller) JQ() string { return "" }
|
||||
|
||||
func runAitableDatasourceCommand(t *testing.T, args ...string) (*aitableDatasourceCaller, error) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
|
||||
caller := &aitableDatasourceCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
os.Args = append([]string{"dws", "aitable", "datasource"}, args...)
|
||||
|
||||
root := newAitableCommand()
|
||||
root.SetArgs(append([]string{"datasource"}, args...))
|
||||
return caller, root.Execute()
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncRejectsMissingTableIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-ids") {
|
||||
t.Fatalf("error = %v, want table-ids required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncRejectsTooManyTableIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
|
||||
"--table-ids", "T1,T2,T3,T4,T5,T6")
|
||||
if err == nil || !strings.Contains(err.Error(), "1-5") {
|
||||
t.Fatalf("error = %v, want 1-5 limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncRejectsEmptyTableIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
|
||||
"--table-ids", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-ids") {
|
||||
t.Fatalf("error = %v, want table-ids error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncAcceptsBoundaryFive(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
|
||||
"--table-ids", "T1,T2,T3,T4,T5")
|
||||
if err != nil {
|
||||
t.Fatalf("5 table-ids should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "run_datasource_sync" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncAcceptsSingleTableID(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
|
||||
"--table-ids", "T1")
|
||||
if err != nil {
|
||||
t.Fatalf("single table-id should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncStatusRejectsTooManyTaskIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync-status",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--task-ids", "TK1,TK2,TK3,TK4,TK5,TK6")
|
||||
if err == nil || !strings.Contains(err.Error(), "requires 1-5") {
|
||||
t.Fatalf("error = %v, want 1-5 limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncStatusAcceptsFiveTaskIDs(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "sync-status",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--task-ids", "TK1,TK2,TK3,TK4,TK5")
|
||||
if err != nil {
|
||||
t.Fatalf("5 task-ids should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_sync_status" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncStatusRequiresTaskIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync-status",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456")
|
||||
if err == nil || !strings.Contains(err.Error(), "task-ids") {
|
||||
t.Fatalf("error = %v, want task-ids required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncStatusRejectsMissingTableID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync-status", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-id") {
|
||||
t.Fatalf("error = %v, want table-id required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetConfigRejectsMissingTableID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "get-config", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-id") {
|
||||
t.Fatalf("error = %v, want table-id required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetConfigSuccess(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "get-config",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456")
|
||||
if err != nil {
|
||||
t.Fatalf("get-config should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_config" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].args["tableId"] != "TBL456" {
|
||||
t.Fatalf("tableId = %v, want TBL456", caller.calls[0].args["tableId"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceListSourcesSuccess(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "list-sources",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA")
|
||||
if err != nil {
|
||||
t.Fatalf("list-sources should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "list_datasource_sources" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceListSourcesRejectsMissingType(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "list-sources", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "datasource-type") {
|
||||
t.Fatalf("error = %v, want datasource-type required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsRejectsMissingSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA")
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsRejectsInvalidSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsRejectsNonObjectSourceConfig(t *testing.T) {
|
||||
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
|
||||
for _, raw := range cases {
|
||||
_, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsSuccess(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("get-fields should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_fields" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].args["sourceConfig"] != `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}` {
|
||||
t.Fatalf("sourceConfig not passed as raw string: %v", caller.calls[0].args["sourceConfig"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsMissingSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA")
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsInvalidSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsNonObjectSourceConfig(t *testing.T) {
|
||||
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
|
||||
for _, raw := range cases {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateSuccess(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("create should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "create_datasource" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
if v, ok := caller.calls[0].args["auto"]; !ok || v != false {
|
||||
t.Fatalf("auto = %v, want false when not provided", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateWithAuto(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
|
||||
"--auto")
|
||||
if err != nil {
|
||||
t.Fatalf("create with --auto should succeed: %v", err)
|
||||
}
|
||||
if v, ok := caller.calls[0].args["auto"]; !ok || v != true {
|
||||
t.Fatalf("auto = %v, want true", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateWithFieldIDsAndAutoSyncSetting(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
|
||||
"--auto",
|
||||
"--field-ids", "fldAAA,fldBBB",
|
||||
"--auto-sync-setting", `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("create with field-ids and auto-sync-setting should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "create_datasource" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
fieldIDs, ok := caller.calls[0].args["fieldIds"].([]string)
|
||||
if !ok || len(fieldIDs) != 2 || fieldIDs[0] != "fldAAA" || fieldIDs[1] != "fldBBB" {
|
||||
t.Fatalf("fieldIds = %v, want [fldAAA fldBBB]", caller.calls[0].args["fieldIds"])
|
||||
}
|
||||
if caller.calls[0].args["autoSyncSetting"] != `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` {
|
||||
t.Fatalf("autoSyncSetting not passed as raw string: %v", caller.calls[0].args["autoSyncSetting"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsInvalidAutoSyncSetting(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
|
||||
"--auto-sync-setting", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsMissingTableID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "update", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-id") {
|
||||
t.Fatalf("error = %v, want table-id required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsInvalidSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--source-config", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsNonObjectSourceConfig(t *testing.T) {
|
||||
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
|
||||
for _, raw := range cases {
|
||||
_, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--source-config", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsNoChanges(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456")
|
||||
if err == nil || !strings.Contains(err.Error(), "至少需要一个配置变更") {
|
||||
t.Fatalf("error = %v, want at least one config change required", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("MCP should not be called when no changes provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateWithAutoOnly(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456", "--auto")
|
||||
if err != nil {
|
||||
t.Fatalf("update with --auto only should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "update_datasource_config" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
if v, ok := caller.calls[0].args["auto"]; !ok || v != true {
|
||||
t.Fatalf("auto = %v, want true", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateWithSourceConfig(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--source-config", `{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("update with source-config should succeed: %v", err)
|
||||
}
|
||||
if caller.calls[0].args["sourceConfig"] != `{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}` {
|
||||
t.Fatalf("sourceConfig not passed as raw string: %v", caller.calls[0].args["sourceConfig"])
|
||||
}
|
||||
if _, ok := caller.calls[0].args["auto"]; ok {
|
||||
t.Fatalf("auto should not be sent when --auto is omitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateWithAutoFalse(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456", "--auto=false")
|
||||
if err != nil {
|
||||
t.Fatalf("update with --auto=false should succeed: %v", err)
|
||||
}
|
||||
if v, ok := caller.calls[0].args["auto"]; !ok || v != false {
|
||||
t.Fatalf("auto = %v, want false", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateWithFieldIDsAndAutoSyncSetting(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--field-ids", "fldAAA,fldBBB",
|
||||
"--auto-sync-setting", `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("update with field-ids and auto-sync-setting should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "update_datasource_config" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
fieldIDs, ok := caller.calls[0].args["fieldIds"].([]string)
|
||||
if !ok || len(fieldIDs) != 2 || fieldIDs[0] != "fldAAA" || fieldIDs[1] != "fldBBB" {
|
||||
t.Fatalf("fieldIds = %v, want [fldAAA fldBBB]", caller.calls[0].args["fieldIds"])
|
||||
}
|
||||
if caller.calls[0].args["autoSyncSetting"] != `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` {
|
||||
t.Fatalf("autoSyncSetting not passed as raw string: %v", caller.calls[0].args["autoSyncSetting"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsInvalidAutoSyncSetting(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--auto-sync-setting", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsNonObjectAutoSyncSetting(t *testing.T) {
|
||||
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
|
||||
for _, raw := range cases {
|
||||
_, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--auto-sync-setting", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("auto-sync-setting %q: error = %v, want auto-sync-setting validation error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsEmptyExplicitFieldIDs(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--field-ids", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "field-ids") {
|
||||
t.Fatalf("error = %v, want field-ids empty error", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("MCP should not be called when empty field-ids is rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsEmptyExplicitAutoSyncSetting(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--auto-sync-setting", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting empty error", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("MCP should not be called when empty auto-sync-setting is rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsRejectsMissingBaseID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), "base-id") {
|
||||
t.Fatalf("error = %v, want base-id required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsMissingBaseID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), "base-id") {
|
||||
t.Fatalf("error = %v, want base-id required", err)
|
||||
}
|
||||
}
|
||||
@@ -23,27 +23,42 @@ type aitableWorkflowCall struct {
|
||||
}
|
||||
|
||||
type aitableWorkflowCaller struct {
|
||||
calls []aitableWorkflowCall
|
||||
calls []aitableWorkflowCall
|
||||
response string
|
||||
err error
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *aitableWorkflowCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, aitableWorkflowCall{productID: productID, toolName: toolName, args: args})
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
response := c.response
|
||||
if response == "" {
|
||||
response = `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}`
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||
Type: "text",
|
||||
Text: `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}`,
|
||||
Text: response,
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
func (*aitableWorkflowCaller) Format() string { return "json" }
|
||||
func (*aitableWorkflowCaller) DryRun() bool { return false }
|
||||
func (c *aitableWorkflowCaller) DryRun() bool { return c.dryRun }
|
||||
func (*aitableWorkflowCaller) Fields() string { return "" }
|
||||
func (*aitableWorkflowCaller) JQ() string { return "" }
|
||||
|
||||
func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*aitableWorkflowCaller, error) {
|
||||
t.Helper()
|
||||
caller := &aitableWorkflowCaller{}
|
||||
return caller, runAitableWorkflowCommandWithCaller(t, caller, stdin, args...)
|
||||
}
|
||||
|
||||
func runAitableWorkflowCommandWithCaller(t *testing.T, caller *aitableWorkflowCaller, stdin io.Reader, args ...string) error {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
|
||||
caller := &aitableWorkflowCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
os.Args = append([]string{"dws", "aitable", "workflow"}, args...)
|
||||
@@ -51,6 +66,7 @@ func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*
|
||||
cmd := newAitableCommand()
|
||||
cmd.PersistentFlags().String("format", "json", "output format")
|
||||
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
|
||||
cmd.PersistentFlags().Bool("dry-run", false, "preview only")
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(append([]string{"workflow"}, args...))
|
||||
@@ -58,10 +74,10 @@ func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*
|
||||
stdin = strings.NewReader("")
|
||||
}
|
||||
cmd.SetIn(stdin)
|
||||
return caller, cmd.Execute()
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
|
||||
func TestCrossPlatformCoverageAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
|
||||
wantDSL := map[string]any{
|
||||
"version": "workflow-dsl/v1",
|
||||
"name": "create test",
|
||||
@@ -92,6 +108,65 @@ func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableWorkflowPublishRejectsFalseSuccess(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want string
|
||||
}{
|
||||
{name: "valid false", response: `{"status":"success","data":{"valid":false,"issues":[{"message":"bad dsl"}]}}`, want: "valid=false"},
|
||||
{name: "missing valid", response: `{"status":"success","data":{"flowId":"flow-test"}}`, want: "missing valid"},
|
||||
{name: "missing flow id", response: `{"status":"success","data":{"valid":true}}`, want: "missing a non-empty flowId"},
|
||||
{name: "wrong valid type", response: `{"status":"success","data":{"valid":"true","flowId":"flow-test"}}`, want: "valid must be boolean"},
|
||||
{name: "malformed response", response: `{`, want: "not a JSON object"},
|
||||
{name: "null response", response: `null`, want: "not a JSON object"},
|
||||
{name: "conflicting valid", response: `{"valid":true,"data":{"valid":false,"flowId":"flow-test"}}`, want: "conflicting valid values"},
|
||||
{name: "invalid flow id", response: `{"valid":true,"flowId":1}`, want: "flowId must be a non-empty string"},
|
||||
{name: "conflicting workflow ids", response: `{"valid":true,"flowId":"one","data":{"workflowId":"two"}}`, want: "conflicting workflow IDs"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &aitableWorkflowCaller{response: tc.response}
|
||||
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
|
||||
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) || len(caller.calls) != 1 {
|
||||
t.Fatalf("workflow false success = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("update uses the same strict contract", func(t *testing.T) {
|
||||
caller := &aitableWorkflowCaller{response: `{"status":"success","data":{"valid":false}}`}
|
||||
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
|
||||
"update", "--base-id", "base", "--workflow-id", "flow", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), "valid=false") || len(caller.calls) != 1 || caller.calls[0].toolName != "update_workflow" {
|
||||
t.Fatalf("workflow update false success = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry-run does not call publish tool", func(t *testing.T) {
|
||||
caller := &aitableWorkflowCaller{dryRun: true}
|
||||
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
|
||||
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`, "--dry-run")
|
||||
if err != nil || len(caller.calls) != 0 {
|
||||
t.Fatalf("workflow create dry-run = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("transport error", func(t *testing.T) {
|
||||
caller := &aitableWorkflowCaller{err: context.Canceled}
|
||||
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
|
||||
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || len(caller.calls) != 1 {
|
||||
t.Fatalf("workflow transport error = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
if _, _, _, err := strictAitableWorkflowPublishResult(nil); err == nil || !strings.Contains(err.Error(), "empty response") {
|
||||
t.Fatalf("nil workflow envelope = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableWorkflowEditExampleMapsEmptyArguments(t *testing.T) {
|
||||
caller, err := runAitableWorkflowCommand(t, nil, "edit-example")
|
||||
if err != nil {
|
||||
|
||||
@@ -10569,7 +10569,7 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
|
||||
chatCategoryCmd.AddCommand(chatCategoryCreateSmartCmd)
|
||||
chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageAddFavoriteCmd, chatMessageRemoveFavoriteCmd, chatMessageListFavoritesCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd, chatMessageListEmotionRepliesCmd)
|
||||
|
||||
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd, newChatToolbarCommand())
|
||||
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd, newChatToolbarCommand(), newChatEmotionCommand())
|
||||
|
||||
// Keep the v1.0.56 command surface recognizable while directing callers to
|
||||
// the supported nested commands. The chat root's "im" alias makes these
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const personalEmotionUnpinnedReason = "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
|
||||
|
||||
func newChatEmotionCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "emotion",
|
||||
Short: "个人收藏表情",
|
||||
Long: "查询、发送和新增当前用户的个人收藏表情。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newChatEmotionListCommand(),
|
||||
newChatEmotionSendCommand(),
|
||||
newChatEmotionFavoriteCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newChatEmotionListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "列出个人收藏表情",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return callMCPToolOnServer("im", "list_personal_emotions", map[string]any{})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "list_personal_emotions",
|
||||
CanonicalPath: "chat.list_personal_emotions", CLIPath: "chat emotion list", PrimaryCLIPath: "chat emotion list",
|
||||
},
|
||||
Description: "列出当前用户的个人收藏表情",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出当前用户的个人收藏表情",
|
||||
UseWhen: []string{"需要查看当前用户已收藏的表情、获取 emotionId 或 mediaId 时"},
|
||||
AvoidWhen: []string{"查询消息 reaction 使用 chat message list-emotion-replies"},
|
||||
Examples: []string{"dws chat emotion list --format json"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newChatEmotionSendCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "send",
|
||||
Short: "发送个人收藏表情",
|
||||
Long: `发送当前用户的个人收藏表情。
|
||||
|
||||
⚠️ 重要:该接口会真实发送表情到目标会话,不可用于测试或试探性调用。调用前必须确认表情媒体 ID 和接收对象无误。`,
|
||||
Example: ` dws chat emotion send --media-id <mediaId> --group <openConversationId>
|
||||
dws chat emotion send --media-id <mediaId> --emotion-id <emotionId> --user <userId>
|
||||
dws chat emotion send --media-id <mediaId> --open-dingtalk-id <openDingTalkId> --uuid <idempotencyKey>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
mediaID, _ := cmd.Flags().GetString("media-id")
|
||||
if strings.TrimSpace(mediaID) == "" {
|
||||
return fmt.Errorf("--media-id is required")
|
||||
}
|
||||
target, err := personalEmotionSendTarget(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)}
|
||||
emotionID, _ := cmd.Flags().GetString("emotion-id")
|
||||
if strings.TrimSpace(emotionID) != "" {
|
||||
payload["emotionId"] = strings.TrimSpace(emotionID)
|
||||
}
|
||||
for key, value := range target {
|
||||
payload[key] = value
|
||||
}
|
||||
if uuid := strings.TrimSpace(flagOrFallback(cmd, "uuid", "idempotency-key")); uuid != "" {
|
||||
payload["uuid"] = uuid
|
||||
}
|
||||
return callMCPToolOnServer("im", "send_personal_emotion", payload)
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("media-id", "", "表情媒体 ID (必填)")
|
||||
cmd.Flags().String("emotion-id", "", "表情 ID")
|
||||
cmd.Flags().String("conversation-id", "", "群聊 openConversationId")
|
||||
cmd.Flags().String("group", "", "群聊 openConversationId(--conversation-id 别名)")
|
||||
cmd.Flags().String("user", "", "单聊接收人 userId;CLI 会解析为 openDingTalkId")
|
||||
cmd.Flags().String("open-dingtalk-id", "", "单聊接收人 openDingTalkId")
|
||||
cmd.Flags().String("uuid", "", "幂等键")
|
||||
cmd.Flags().String("idempotency-key", "", "幂等键(--uuid 别名)")
|
||||
cmd.MarkFlagsMutuallyExclusive("conversation-id", "group")
|
||||
cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}},
|
||||
RequireOneOf: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}},
|
||||
})
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: personalEmotionSendContract(),
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newChatEmotionFavoriteCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "favorite",
|
||||
Short: "新增个人收藏表情",
|
||||
Example: ` dws chat emotion favorite --media-id <mediaId> --name "赞"
|
||||
dws chat emotion favorite --media-id <mediaId> --source-conversation-id <cid> --source-message-id <mid>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
mediaID, _ := cmd.Flags().GetString("media-id")
|
||||
if strings.TrimSpace(mediaID) == "" {
|
||||
return fmt.Errorf("--media-id is required")
|
||||
}
|
||||
sourceConversationID, _ := cmd.Flags().GetString("source-conversation-id")
|
||||
sourceMessageID, _ := cmd.Flags().GetString("source-message-id")
|
||||
if err := validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID); err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)}
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
if strings.TrimSpace(name) != "" {
|
||||
payload["name"] = strings.TrimSpace(name)
|
||||
}
|
||||
if strings.TrimSpace(sourceConversationID) != "" {
|
||||
payload["sourceConversationId"] = strings.TrimSpace(sourceConversationID)
|
||||
payload["sourceMessageId"] = strings.TrimSpace(sourceMessageID)
|
||||
}
|
||||
return callMCPToolOnServer("im", "favorite_personal_emotion", payload)
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("media-id", "", "待收藏 mediaId (必填)")
|
||||
cmd.Flags().String("name", "", "表情名称")
|
||||
cmd.Flags().String("source-conversation-id", "", "来源会话 ID;需与 --source-message-id 成对指定")
|
||||
cmd.Flags().String("source-message-id", "", "来源消息 ID;需与 --source-conversation-id 成对指定")
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: personalEmotionFavoriteContract(),
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func personalEmotionSendTarget(cmd *cobra.Command) (map[string]any, error) {
|
||||
groupID := strings.TrimSpace(flagOrFallback(cmd, "conversation-id", "group"))
|
||||
userID, _ := cmd.Flags().GetString("user")
|
||||
openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id")
|
||||
userID = strings.TrimSpace(userID)
|
||||
openDingTalkID = strings.TrimSpace(openDingTalkID)
|
||||
specified := 0
|
||||
for _, value := range []string{groupID, userID, openDingTalkID} {
|
||||
if value != "" {
|
||||
specified++
|
||||
}
|
||||
}
|
||||
if specified != 1 {
|
||||
return nil, fmt.Errorf("--conversation-id, --user or --open-dingtalk-id is required; specify exactly one")
|
||||
}
|
||||
if groupID != "" {
|
||||
return map[string]any{"openConversationId": groupID}, nil
|
||||
}
|
||||
if openDingTalkID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"receiverOpenDingTalkId": openDingTalkID}, nil
|
||||
}
|
||||
if isOpenDingTalkID(userID) {
|
||||
return map[string]any{"receiverOpenDingTalkId": userID}, nil
|
||||
}
|
||||
resolved, err := resolveOpenDingTalkID(cmd.Context(), userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot resolve --user %q to openDingTalkId: %w; pass --open-dingtalk-id instead", userID, err)
|
||||
}
|
||||
return map[string]any{"receiverOpenDingTalkId": resolved}, nil
|
||||
}
|
||||
|
||||
func validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID string) error {
|
||||
hasConversation := strings.TrimSpace(sourceConversationID) != ""
|
||||
hasMessage := strings.TrimSpace(sourceMessageID) != ""
|
||||
if hasConversation != hasMessage {
|
||||
return fmt.Errorf("--source-conversation-id and --source-message-id must be specified together")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func personalEmotionSendContract() LeafContract {
|
||||
return LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "send_personal_emotion",
|
||||
CanonicalPath: "chat.send_personal_emotion", CLIPath: "chat emotion send", PrimaryCLIPath: "chat emotion send",
|
||||
},
|
||||
Description: "以当前用户身份向群聊或单聊发送个人收藏表情",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "以当前用户身份发送个人收藏表情",
|
||||
UseWhen: []string{"用户明确要求发送个人收藏表情,且已提供 mediaId 或 emotionId 时"},
|
||||
AvoidWhen: []string{"发送普通文本、Markdown 或文件时使用 chat message send"},
|
||||
Examples: []string{"dws chat emotion send --media-id <mediaId> --group <openConversationId> --uuid <idempotencyKey>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "media-id", Property: "mediaId", Required: boolPtr(true)},
|
||||
{Name: "emotion-id", Property: "emotionId", Required: boolPtr(false)},
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)},
|
||||
{Name: "group", Property: "openConversationId", Required: boolPtr(false)},
|
||||
{Name: "user", Property: "receiverOpenDingTalkId", Required: boolPtr(false)},
|
||||
{Name: "open-dingtalk-id", Property: "receiverOpenDingTalkId", Required: boolPtr(false)},
|
||||
{Name: "uuid", Property: "uuid", Required: boolPtr(false)},
|
||||
{Name: "idempotency-key", Property: "uuid", Required: boolPtr(false)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func personalEmotionFavoriteContract() LeafContract {
|
||||
return LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "favorite_personal_emotion",
|
||||
CanonicalPath: "chat.favorite_personal_emotion", CLIPath: "chat emotion favorite", PrimaryCLIPath: "chat emotion favorite",
|
||||
},
|
||||
Description: "将 mediaId 新增到当前用户的个人收藏表情",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "新增当前用户的个人收藏表情",
|
||||
UseWhen: []string{"用户要把一个 mediaId 收藏为个人表情时"},
|
||||
AvoidWhen: []string{"收藏消息使用 chat message add-favorite"},
|
||||
Examples: []string{"dws chat emotion favorite --media-id <mediaId> --name \"赞\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "media-id", Property: "mediaId", Required: boolPtr(true)},
|
||||
{Name: "name", Property: "name", Required: boolPtr(false)},
|
||||
{Name: "source-conversation-id", Property: "sourceConversationId", Required: boolPtr(false), RequiredWhen: "source-message-id is provided"},
|
||||
{Name: "source-message-id", Property: "sourceMessageId", Required: boolPtr(false), RequiredWhen: "source-conversation-id is provided"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type personalEmotionCall struct {
|
||||
server string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type personalEmotionCaller struct {
|
||||
calls []personalEmotionCall
|
||||
}
|
||||
|
||||
func (c *personalEmotionCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
copied[key] = value
|
||||
}
|
||||
c.calls = append(c.calls, personalEmotionCall{server: server, tool: tool, args: copied})
|
||||
if server == "contact" && tool == "get_user_info_by_user_ids" {
|
||||
return textToolResult(`{"result":[{"userId":"u1","openDingTalkId":"` + helperCurrentDOpenID2 + `"}]}`), nil
|
||||
}
|
||||
return textToolResult(`{"ok":true}`), nil
|
||||
}
|
||||
|
||||
func (*personalEmotionCaller) Format() string { return "json" }
|
||||
func (*personalEmotionCaller) DryRun() bool { return false }
|
||||
func (*personalEmotionCaller) Fields() string { return "" }
|
||||
func (*personalEmotionCaller) JQ() string { return "" }
|
||||
|
||||
func executePersonalEmotionCommand(t *testing.T, caller *personalEmotionCaller, args ...string) error {
|
||||
t.Helper()
|
||||
installHelpersCoreDeps(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
root := newChatCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func requirePersonalEmotionCall(t *testing.T, caller *personalEmotionCaller, tool string, want map[string]any) {
|
||||
t.Helper()
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.server != "im" || call.tool != tool {
|
||||
t.Fatalf("tool call = %s/%s, want im/%s", call.server, call.tool, tool)
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, want) {
|
||||
t.Fatalf("args = %#v, want %#v", call.args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatEmotionListCallsIMToolWithoutBusinessArgs(t *testing.T) {
|
||||
// TC-001: list 无业务参数,当前用户身份由 MCP server 注入。
|
||||
caller := &personalEmotionCaller{}
|
||||
if err := executePersonalEmotionCommand(t, caller, "emotion", "list"); err != nil {
|
||||
t.Fatalf("chat emotion list returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "list_personal_emotions", map[string]any{})
|
||||
}
|
||||
|
||||
func TestChatEmotionSendMapsGroupTargetAndIdempotency(t *testing.T) {
|
||||
// TC-002: 群聊目标映射为 openConversationId,uuid 与表情字段按 MCP 字段透传。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "send",
|
||||
"--media-id", "@media",
|
||||
"--emotion-id", "emotion123",
|
||||
"--group", "cid123",
|
||||
"--idempotency-key", "idem-001",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion send returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
|
||||
"mediaId": "@media",
|
||||
"emotionId": "emotion123",
|
||||
"openConversationId": "cid123",
|
||||
"uuid": "idem-001",
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatEmotionSendMapsOpenDingTalkTarget(t *testing.T) {
|
||||
// TC-003: 已知 openDingTalkId 时直传 receiverOpenDingTalkId,不做外部解析。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "send",
|
||||
"--media-id", "@media",
|
||||
"--open-dingtalk-id", helperCurrentDOpenID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion send returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
|
||||
"mediaId": "@media",
|
||||
"receiverOpenDingTalkId": helperCurrentDOpenID,
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatEmotionSendTreatsOpenDingTalkIDPassedAsUserAsResolvedTarget(t *testing.T) {
|
||||
// TC-004: --user 收到 openDingTalkId 形态时保持 chat message send 的兼容语义。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "send",
|
||||
"--media-id", "@media",
|
||||
"--user", helperCurrentDOpenID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion send returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
|
||||
"mediaId": "@media",
|
||||
"receiverOpenDingTalkId": helperCurrentDOpenID,
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatEmotionSendResolvesUserIDTarget(t *testing.T) {
|
||||
// TC-004b: --user 收到普通 userId 时先解析为 openDingTalkId,再发送个人收藏表情。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "send",
|
||||
"--media-id", "@media",
|
||||
"--user", "u1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion send returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 2 {
|
||||
t.Fatalf("calls = %d, want contact resolve then send: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
resolveCall := caller.calls[0]
|
||||
if resolveCall.server != "contact" || resolveCall.tool != "get_user_info_by_user_ids" {
|
||||
t.Fatalf("resolve call = %s/%s, want contact/get_user_info_by_user_ids", resolveCall.server, resolveCall.tool)
|
||||
}
|
||||
sendCall := caller.calls[1]
|
||||
if sendCall.server != "im" || sendCall.tool != "send_personal_emotion" {
|
||||
t.Fatalf("send call = %s/%s, want im/send_personal_emotion", sendCall.server, sendCall.tool)
|
||||
}
|
||||
want := map[string]any{
|
||||
"mediaId": "@media",
|
||||
"receiverOpenDingTalkId": helperCurrentDOpenID2,
|
||||
}
|
||||
if !reflect.DeepEqual(sendCall.args, want) {
|
||||
t.Fatalf("send args = %#v, want %#v", sendCall.args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatEmotionSendRejectsInvalidTargets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing target",
|
||||
args: []string{"emotion", "send", "--media-id", "@media"},
|
||||
wantErr: "specify exactly one",
|
||||
},
|
||||
{
|
||||
name: "multiple targets",
|
||||
args: []string{"emotion", "send", "--media-id", "@media", "--group", "cid", "--open-dingtalk-id", helperCurrentDOpenID},
|
||||
wantErr: "specify exactly one",
|
||||
},
|
||||
{
|
||||
name: "missing media",
|
||||
args: []string{"emotion", "send", "--group", "cid"},
|
||||
wantErr: "--media-id is required",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller, tc.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid command reached MCP: %+v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatEmotionFavoriteMapsOptionalSourcePair(t *testing.T) {
|
||||
// TC-005: 收藏来源字段成对出现时透传为 sourceConversationId/sourceMessageId。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "favorite",
|
||||
"--media-id", "@media",
|
||||
"--name", "赞",
|
||||
"--source-conversation-id", "cid123",
|
||||
"--source-message-id", "msg123",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion favorite returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "favorite_personal_emotion", map[string]any{
|
||||
"mediaId": "@media",
|
||||
"name": "赞",
|
||||
"sourceConversationId": "cid123",
|
||||
"sourceMessageId": "msg123",
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatEmotionFavoriteRejectsMissingRequiredOrUnpairedSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing media",
|
||||
args: []string{"emotion", "favorite", "--name", "赞"},
|
||||
wantErr: "--media-id is required",
|
||||
},
|
||||
{
|
||||
name: "source conversation only",
|
||||
args: []string{"emotion", "favorite", "--media-id", "@media", "--source-conversation-id", "cid123"},
|
||||
wantErr: "must be specified together",
|
||||
},
|
||||
{
|
||||
name: "source message only",
|
||||
args: []string{"emotion", "favorite", "--media-id", "@media", "--source-message-id", "msg123"},
|
||||
wantErr: "must be specified together",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller, tc.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid command reached MCP: %+v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,788 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageCollegeContactCommand_Structure(t *testing.T) {
|
||||
cmd := newCollegeContactCommand()
|
||||
|
||||
if cmd.Name() != "college-contact" {
|
||||
t.Errorf("expected name 'college-contact', got %q", cmd.Name())
|
||||
}
|
||||
if !cmd.Hidden {
|
||||
t.Error("extension root command should be Hidden")
|
||||
}
|
||||
|
||||
// 分组 → 叶子命令映射
|
||||
groups := map[string][]string{
|
||||
"dept": {
|
||||
"get-standard-structure", "get-detail", "get-chain", "search",
|
||||
"create", "update", "delete", "batch-update-type", "overview",
|
||||
},
|
||||
"employee": {
|
||||
"get-detail", "add", "remove", "change-type", "change-dept",
|
||||
"send-active-sms", "list-employees", "list-unaccepted",
|
||||
"list-unactive", "upgrade-status", "start-upgrade",
|
||||
},
|
||||
"alumni": {
|
||||
"get-dept-tree", "get-info", "list", "query", "search", "list-unaccepted", "get-group", "create-dept", "update-dept", "delete-dept", "update-managers", "add-alumnus", "update-alumnus", "remove-alumnus", "cancel-invite", "create-group", "disband-group", "get-alumni-org-from-graduate", "create-alumni-org", "add-alumni-org-main-admins",
|
||||
},
|
||||
"graduate": {
|
||||
"query-graduate-years", "query-graduate-depts", "query-graduate-sub-depts", "query-page-graduate-users", "get-task-result", "get-alumni-org", "query-restore-sub-depts", "query-dept-deleted-emps", "search-graduate", "commit-graduate", "all-graduate", "batch-graduate", "delete-and-graduate", "batch-delete-pending", "batch-update-pending", "commit-restore",
|
||||
},
|
||||
"group": {
|
||||
"query-group-rule", "get-group-rule-schedule", "query-preview-data", "create-group-rule", "delete-group-rule", "enable-group-rule", "disable-group-rule", "set-group-rule-schedule", "execute-group-rule",
|
||||
},
|
||||
}
|
||||
|
||||
for groupName, leaves := range groups {
|
||||
var groupCmd *cobra.Command
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Name() == groupName {
|
||||
groupCmd = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if groupCmd == nil {
|
||||
t.Fatalf("subcommand group %q not found", groupName)
|
||||
}
|
||||
for _, leaf := range leaves {
|
||||
found := false
|
||||
for _, c := range groupCmd.Commands() {
|
||||
if c.Name() == leaf {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("leaf command %q not found under %q", leaf, groupName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stats 分组已移除
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Name() == "stats" {
|
||||
t.Error("subcommand group 'stats' should be removed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCollegeContactCommand_FindPath(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.AddCommand(newCollegeContactCommand())
|
||||
|
||||
c, _, err := root.Find([]string{"college-contact", "dept", "get-standard-structure"})
|
||||
if err != nil {
|
||||
t.Fatalf("command path not found: %v", err)
|
||||
}
|
||||
if c.Name() != "get-standard-structure" {
|
||||
t.Errorf("expected leaf 'get-standard-structure', got %q", c.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// newCollegeContactTestRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上
|
||||
// 注册全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
|
||||
func newCollegeContactTestRoot() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
|
||||
root.AddCommand(newCollegeContactCommand())
|
||||
return root
|
||||
}
|
||||
|
||||
// runDestructiveLeaf 执行不可逆叶子命令并捕获 panic。
|
||||
// 单测环境未初始化 products 运行时依赖,若门禁放行后进入
|
||||
// CallMCPToolOnServer 会因 deps 为 nil 而 panic,据此区分
|
||||
// “被门禁拦截(返回错误)”与“已越过门禁到达 MCP 调用层(panic)”。
|
||||
func runDestructiveLeaf(t *testing.T, args ...string) (err error, panicked bool) {
|
||||
t.Helper()
|
||||
root := newCollegeContactTestRoot()
|
||||
root.SetArgs(args)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
panicked = true
|
||||
}
|
||||
}()
|
||||
err = root.Execute()
|
||||
return err, false
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCollegeContactDestructive_RejectedWithoutYes(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"college-contact", "dept", "delete", "--dept-id", "12345"},
|
||||
{"college-contact", "employee", "remove", "--staff-ids", "S12345,S12346"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
err, panicked := runDestructiveLeaf(t, args...)
|
||||
if panicked {
|
||||
t.Fatalf("%v: 未传 --yes 不应到达 MCP 调用层", args)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("%v: 未传 --yes 应拒绝执行", args)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--yes") {
|
||||
t.Errorf("%v: 错误信息应提示 --yes,got: %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCollegeContactDestructive_ProceedsWithYes(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"college-contact", "dept", "delete", "--dept-id", "12345", "--yes"},
|
||||
{"college-contact", "employee", "remove", "--staff-ids", "S12345", "--yes"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
err, panicked := runDestructiveLeaf(t, args...)
|
||||
if !panicked {
|
||||
// 未 panic 意味着未到达 MCP 调用层;若返回的仍是门禁错误则为拦截失败
|
||||
if err != nil && strings.Contains(err.Error(), "需要用户确认") {
|
||||
t.Fatalf("%v: 已传 --yes 仍被门禁拦截: %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withCollegeContactCaller installs a dry-run capture caller so happy-path
|
||||
// command execution exercises each RunE up to the callMCPToolOnServer dispatch
|
||||
// without requiring a live MCP transport. In dry-run mode destructive
|
||||
// commands' confirm gate short-circuits to nil, so no --yes flag is needed.
|
||||
func withCollegeContactCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{dryRun: true}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
// TestCollegeContactHappyPaths runs every leaf command with required flags only
|
||||
// and with all optional flags populated, expecting a nil error (dry-run preview).
|
||||
func TestCrossPlatformCoverageCollegeContactHappyPaths(t *testing.T) {
|
||||
withCollegeContactCaller(t)
|
||||
|
||||
cases := [][]string{
|
||||
// ── dept ─────────────────────────────────────────────
|
||||
{"dept", "get-standard-structure"},
|
||||
{"dept", "get-standard-structure", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
|
||||
{"dept", "get-detail", "--dept-id", "123"},
|
||||
{"dept", "get-detail", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
|
||||
{"dept", "get-chain", "--dept-id", "123"},
|
||||
{"dept", "get-chain", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
|
||||
{"dept", "search", "--dept-id", "123", "--keyword", "k"},
|
||||
{"dept", "search", "--dept-id", "123", "--keyword", "k", "--staff-id", "S1", "--offset", "0", "--size", "20"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "false", "--dept-id", "5", "--dept-code", "C", "--brief", "b", "--phone", "p"},
|
||||
{"dept", "update", "--dept-id", "123", "--dept-type", "college"},
|
||||
{"dept", "update", "--dept-id", "123", "--dept-type", "college", "--stru-dept-id", "200", "--super-id", "100", "--create-dept-group", "true", "--name", "X", "--dept-code", "C", "--brief", "b", "--phone", "p"},
|
||||
{"dept", "delete", "--dept-id", "123"},
|
||||
{"dept", "batch-update-type", "--dept-ids", "100,200", "--target-dept-type", "college"},
|
||||
{"dept", "overview"},
|
||||
{"dept", "overview", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
|
||||
|
||||
// ── employee ─────────────────────────────────────────
|
||||
{"employee", "get-detail", "--staff-id", "S1"},
|
||||
{"employee", "get-detail", "--staff-id", "S1", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "job_number", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "add", "--emp-type", "college_student", "--main-dept-id", "100", "--exclusive-account", "true"},
|
||||
{"employee", "add", "--emp-type", "college_student", "--main-dept-id", "100", "--exclusive-account", "true", "--dept-id", "1", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--send-active-sms", "false", "--staff-id", "S1", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "remove", "--staff-ids", "S1"},
|
||||
{"employee", "remove", "--staff-ids", "S1", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc"},
|
||||
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "college_teacher"},
|
||||
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "college_teacher", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200"},
|
||||
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--dept-id", "1", "--main-dept-id", "2", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "send-active-sms", "--dept-id", "100"},
|
||||
{"employee", "send-active-sms", "--dept-id", "100", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "list-employees", "--dept-id", "123"},
|
||||
{"employee", "list-employees", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "list-unaccepted", "--dept-id", "123"},
|
||||
{"employee", "list-unaccepted", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "list-unactive", "--dept-id", "123"},
|
||||
{"employee", "list-unactive", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "upgrade-status"},
|
||||
{"employee", "upgrade-status", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
|
||||
{"employee", "start-upgrade"},
|
||||
|
||||
// ── alumni ───────────────────────────────────────────
|
||||
{"alumni", "get-dept-tree", "--alumni-dept-id", "123"},
|
||||
{"alumni", "get-info", "--alumni-dept-id", "123"},
|
||||
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "dept_entry", "--ordering", "asc"},
|
||||
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "dept_entry", "--ordering", "asc", "--offset", "0", "--size", "20"},
|
||||
{"alumni", "query", "--staff-id", "S1"},
|
||||
{"alumni", "search", "--keyword", "x"},
|
||||
{"alumni", "search", "--keyword", "x", "--offset", "0", "--size", "20"},
|
||||
{"alumni", "list-unaccepted", "--alumni-dept-id", "1"},
|
||||
{"alumni", "list-unaccepted", "--alumni-dept-id", "1", "--offset", "0", "--size", "20"},
|
||||
{"alumni", "get-group", "--alumni-dept-id", "1"},
|
||||
{"alumni", "create-dept", "--alumni-dept-id", "1", "--dept-name", "D"},
|
||||
{"alumni", "update-dept", "--alumni-dept-id", "1", "--dept-name", "D"},
|
||||
{"alumni", "delete-dept", "--alumni-dept-id", "1"},
|
||||
{"alumni", "update-managers", "--alumni-dept-id", "1", "--admin-user-ids", "u1,u2"},
|
||||
{"alumni", "add-alumnus", "--name", "X", "--mobile", "138", "--dept-ids", "1,2"},
|
||||
{"alumni", "add-alumnus", "--name", "X", "--mobile", "138", "--dept-ids", "1,2", "--student-number", "2020", "--email", "e", "--intake", "2020", "--outtake", "2024"},
|
||||
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "1,2"},
|
||||
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "1,2", "--student-number", "2020", "--email", "e", "--intake", "2020", "--outtake", "2024"},
|
||||
{"alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "1"},
|
||||
{"alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
{"alumni", "create-group", "--alumni-dept-id", "1"},
|
||||
{"alumni", "disband-group", "--alumni-dept-id", "1"},
|
||||
{"alumni", "get-alumni-org-from-graduate"},
|
||||
{"alumni", "create-alumni-org", "--org-name", "O"},
|
||||
{"alumni", "add-alumni-org-main-admins", "--admin-user-ids", "u1,u2"},
|
||||
|
||||
// ── graduate ─────────────────────────────────────────
|
||||
{"graduate", "query-graduate-years"},
|
||||
{"graduate", "query-graduate-depts", "--dept-id", "1"},
|
||||
{"graduate", "query-graduate-depts", "--dept-id", "1", "--graduate-year", "2026"},
|
||||
{"graduate", "query-graduate-sub-depts", "--dept-id", "1"},
|
||||
{"graduate", "query-page-graduate-users", "--dept-id", "1"},
|
||||
{"graduate", "query-page-graduate-users", "--dept-id", "1", "--graduate-year", "2026", "--offset", "0", "--size", "20"},
|
||||
{"graduate", "get-task-result", "--request-no", "r1"},
|
||||
{"graduate", "get-task-result", "--request-no", "r1", "--type", "GRADUATE"},
|
||||
{"graduate", "get-alumni-org"},
|
||||
{"graduate", "query-restore-sub-depts", "--dept-id", "1"},
|
||||
{"graduate", "query-dept-deleted-emps", "--dept-id", "1"},
|
||||
{"graduate", "query-dept-deleted-emps", "--dept-id", "1", "--offset", "0", "--size", "20"},
|
||||
{"graduate", "search-graduate", "--keyword", "x"},
|
||||
{"graduate", "search-graduate", "--keyword", "x", "--offset", "0", "--size", "20"},
|
||||
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"},
|
||||
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026", "--request-no", "r1"},
|
||||
{"graduate", "all-graduate", "--graduate-year", "2026"},
|
||||
{"graduate", "all-graduate", "--graduate-year", "2026", "--request-no", "r1"},
|
||||
{"graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
{"graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
{"graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1,s2", "--graduate-year", "2026"},
|
||||
{"graduate", "commit-restore", "--graduate-dept-ids", "1,2"},
|
||||
{"graduate", "commit-restore", "--graduate-dept-ids", "1,2", "--request-no", "r1"},
|
||||
|
||||
// ── group ────────────────────────────────────────────
|
||||
{"group", "query-group-rule"},
|
||||
{"group", "query-group-rule", "--name", "N", "--offset", "0", "--size", "20"},
|
||||
{"group", "get-group-rule-schedule"},
|
||||
{"group", "query-preview-data"},
|
||||
{"group", "query-preview-data", "--offset", "0", "--size", "20"},
|
||||
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college"},
|
||||
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college", "--auto-admin", "true"},
|
||||
{"group", "delete-group-rule", "--rule-id", "1"},
|
||||
{"group", "enable-group-rule", "--rule-id", "1"},
|
||||
{"group", "disable-group-rule", "--rule-id", "1"},
|
||||
{"group", "set-group-rule-schedule"},
|
||||
{"group", "set-group-rule-schedule", "--cron", "0 0 2 * * ?"},
|
||||
{"group", "execute-group-rule"},
|
||||
}
|
||||
|
||||
for _, args := range cases {
|
||||
root := newCollegeContactCommand()
|
||||
if err := executeCommand(root, args...); err != nil {
|
||||
t.Errorf("%v: expected nil error, got: %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollegeContactValidationErrors exercises every validation-error branch:
|
||||
// missing required flags, non-integer int flags, invalid bool flags, and
|
||||
// empty-after-split CSV lists. Each case must return a non-nil error.
|
||||
func TestCrossPlatformCoverageCollegeContactValidationErrors(t *testing.T) {
|
||||
withCollegeContactCaller(t)
|
||||
|
||||
cases := [][]string{
|
||||
// ── dept ─────────────────────────────────────────────
|
||||
{"dept", "get-standard-structure", "--dept-id", "abc"},
|
||||
{"dept", "get-standard-structure", "--offset", "abc"},
|
||||
{"dept", "get-standard-structure", "--size", "abc"},
|
||||
{"dept", "get-detail"},
|
||||
{"dept", "get-detail", "--dept-id", "abc"},
|
||||
{"dept", "get-detail", "--dept-id", "1", "--offset", "abc"},
|
||||
{"dept", "get-detail", "--dept-id", "1", "--size", "abc"},
|
||||
{"dept", "get-chain"},
|
||||
{"dept", "get-chain", "--dept-id", "abc"},
|
||||
{"dept", "get-chain", "--dept-id", "1", "--offset", "abc"},
|
||||
{"dept", "get-chain", "--dept-id", "1", "--size", "abc"},
|
||||
{"dept", "search"},
|
||||
{"dept", "search", "--dept-id", "abc", "--keyword", "k"},
|
||||
{"dept", "search", "--dept-id", "1"},
|
||||
{"dept", "search", "--dept-id", "1", "--keyword", "k", "--offset", "abc"},
|
||||
{"dept", "search", "--dept-id", "1", "--keyword", "k", "--size", "abc"},
|
||||
{"dept", "create"},
|
||||
{"dept", "create", "--super-id", "abc"},
|
||||
{"dept", "create", "--super-id", "100"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "abc"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "maybe"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true", "--dept-id", "abc"},
|
||||
{"dept", "update"},
|
||||
{"dept", "update", "--dept-id", "abc"},
|
||||
{"dept", "update", "--dept-id", "1"},
|
||||
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--stru-dept-id", "abc"},
|
||||
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--super-id", "abc"},
|
||||
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--create-dept-group", "maybe"},
|
||||
{"dept", "delete"},
|
||||
{"dept", "delete", "--dept-id", "abc"},
|
||||
{"dept", "batch-update-type"},
|
||||
{"dept", "batch-update-type", "--dept-ids", "abc", "--target-dept-type", "college"},
|
||||
{"dept", "batch-update-type", "--dept-ids", ",,", "--target-dept-type", "college"},
|
||||
{"dept", "batch-update-type", "--dept-ids", "1,2"},
|
||||
{"dept", "overview", "--dept-id", "abc"},
|
||||
{"dept", "overview", "--offset", "abc"},
|
||||
{"dept", "overview", "--size", "abc"},
|
||||
|
||||
// ── employee ─────────────────────────────────────────
|
||||
{"employee", "get-detail"},
|
||||
{"employee", "get-detail", "--staff-id", "S1", "--dept-id", "abc"},
|
||||
{"employee", "get-detail", "--staff-id", "S1", "--exclusive-account", "maybe"},
|
||||
{"employee", "add"},
|
||||
{"employee", "add", "--emp-type", "x"},
|
||||
{"employee", "add", "--emp-type", "x", "--main-dept-id", "abc"},
|
||||
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100"},
|
||||
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "maybe"},
|
||||
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "true", "--dept-id", "abc"},
|
||||
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "true", "--send-active-sms", "maybe"},
|
||||
{"employee", "remove"},
|
||||
{"employee", "remove", "--staff-ids", ",,"},
|
||||
{"employee", "remove", "--staff-ids", "S1", "--dept-id", "abc"},
|
||||
{"employee", "remove", "--staff-ids", "S1", "--exclusive-account", "maybe"},
|
||||
{"employee", "change-type"},
|
||||
{"employee", "change-type", "--staff-id", "S1"},
|
||||
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "t", "--dept-id", "abc"},
|
||||
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "t", "--exclusive-account", "maybe"},
|
||||
{"employee", "change-dept"},
|
||||
{"employee", "change-dept", "--staff-id", "S1"},
|
||||
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "abc"},
|
||||
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--dept-id", "abc"},
|
||||
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--exclusive-account", "maybe"},
|
||||
{"employee", "send-active-sms"},
|
||||
{"employee", "send-active-sms", "--dept-id", "abc"},
|
||||
{"employee", "send-active-sms", "--dept-id", "1", "--main-dept-id", "abc"},
|
||||
{"employee", "send-active-sms", "--dept-id", "1", "--exclusive-account", "maybe"},
|
||||
{"employee", "list-employees"},
|
||||
{"employee", "list-employees", "--dept-id", "abc"},
|
||||
{"employee", "list-employees", "--dept-id", "1", "--main-dept-id", "abc"},
|
||||
{"employee", "list-employees", "--dept-id", "1", "--exclusive-account", "maybe"},
|
||||
{"employee", "list-unaccepted"},
|
||||
{"employee", "list-unaccepted", "--dept-id", "abc"},
|
||||
{"employee", "list-unaccepted", "--dept-id", "1", "--main-dept-id", "abc"},
|
||||
{"employee", "list-unaccepted", "--dept-id", "1", "--exclusive-account", "maybe"},
|
||||
{"employee", "list-unactive"},
|
||||
{"employee", "list-unactive", "--dept-id", "abc"},
|
||||
{"employee", "list-unactive", "--dept-id", "1", "--main-dept-id", "abc"},
|
||||
{"employee", "list-unactive", "--dept-id", "1", "--exclusive-account", "maybe"},
|
||||
{"employee", "upgrade-status", "--dept-id", "abc"},
|
||||
{"employee", "upgrade-status", "--offset", "abc"},
|
||||
{"employee", "upgrade-status", "--size", "abc"},
|
||||
|
||||
// ── alumni ───────────────────────────────────────────
|
||||
{"alumni", "get-dept-tree"},
|
||||
{"alumni", "get-dept-tree", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "get-info"},
|
||||
{"alumni", "get-info", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "list"},
|
||||
{"alumni", "list", "--alumni-dept-id", "abc", "--order-field", "f", "--ordering", "asc"},
|
||||
{"alumni", "list", "--alumni-dept-id", "1"},
|
||||
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "f"},
|
||||
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "f", "--ordering", "asc", "--offset", "abc"},
|
||||
{"alumni", "query"},
|
||||
{"alumni", "search"},
|
||||
{"alumni", "search", "--keyword", "x", "--offset", "abc"},
|
||||
{"alumni", "list-unaccepted"},
|
||||
{"alumni", "list-unaccepted", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "list-unaccepted", "--alumni-dept-id", "1", "--offset", "abc"},
|
||||
{"alumni", "get-group"},
|
||||
{"alumni", "get-group", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "create-dept"},
|
||||
{"alumni", "create-dept", "--alumni-dept-id", "abc", "--dept-name", "D"},
|
||||
{"alumni", "create-dept", "--alumni-dept-id", "1"},
|
||||
{"alumni", "update-dept"},
|
||||
{"alumni", "update-dept", "--alumni-dept-id", "abc", "--dept-name", "D"},
|
||||
{"alumni", "update-dept", "--alumni-dept-id", "1"},
|
||||
{"alumni", "delete-dept"},
|
||||
{"alumni", "delete-dept", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "update-managers"},
|
||||
{"alumni", "update-managers", "--alumni-dept-id", "abc", "--admin-user-ids", "u"},
|
||||
{"alumni", "update-managers", "--alumni-dept-id", "1"},
|
||||
{"alumni", "update-managers", "--alumni-dept-id", "1", "--admin-user-ids", ",,"},
|
||||
{"alumni", "add-alumnus"},
|
||||
{"alumni", "add-alumnus", "--name", "X"},
|
||||
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m"},
|
||||
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m", "--dept-ids", "abc"},
|
||||
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m", "--dept-ids", ",,"},
|
||||
{"alumni", "update-alumnus"},
|
||||
{"alumni", "update-alumnus", "--staff-id", "S1"},
|
||||
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X"},
|
||||
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "abc"},
|
||||
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", ",,"},
|
||||
{"alumni", "remove-alumnus"},
|
||||
{"alumni", "remove-alumnus", "--staff-id", "S1"},
|
||||
{"alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "cancel-invite"},
|
||||
{"alumni", "cancel-invite", "--alumni-dept-id", "abc", "--staff-ids", "s"},
|
||||
{"alumni", "cancel-invite", "--alumni-dept-id", "1"},
|
||||
{"alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", ",,"},
|
||||
{"alumni", "create-group"},
|
||||
{"alumni", "create-group", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "disband-group"},
|
||||
{"alumni", "disband-group", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "create-alumni-org"},
|
||||
{"alumni", "add-alumni-org-main-admins"},
|
||||
{"alumni", "add-alumni-org-main-admins", "--admin-user-ids", ",,"},
|
||||
|
||||
// ── graduate ─────────────────────────────────────────
|
||||
{"graduate", "query-graduate-depts"},
|
||||
{"graduate", "query-graduate-depts", "--dept-id", "abc"},
|
||||
{"graduate", "query-graduate-depts", "--dept-id", "1", "--graduate-year", "abc"},
|
||||
{"graduate", "query-graduate-sub-depts"},
|
||||
{"graduate", "query-graduate-sub-depts", "--dept-id", "abc"},
|
||||
{"graduate", "query-page-graduate-users"},
|
||||
{"graduate", "query-page-graduate-users", "--dept-id", "abc"},
|
||||
{"graduate", "query-page-graduate-users", "--dept-id", "1", "--offset", "abc"},
|
||||
{"graduate", "get-task-result"},
|
||||
{"graduate", "query-restore-sub-depts"},
|
||||
{"graduate", "query-restore-sub-depts", "--dept-id", "abc"},
|
||||
{"graduate", "query-dept-deleted-emps"},
|
||||
{"graduate", "query-dept-deleted-emps", "--dept-id", "abc"},
|
||||
{"graduate", "query-dept-deleted-emps", "--dept-id", "1", "--offset", "abc"},
|
||||
{"graduate", "search-graduate"},
|
||||
{"graduate", "search-graduate", "--keyword", "x", "--offset", "abc"},
|
||||
{"graduate", "commit-graduate"},
|
||||
{"graduate", "commit-graduate", "--graduate-dept-ids", "abc"},
|
||||
{"graduate", "commit-graduate", "--graduate-dept-ids", ",,"},
|
||||
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2"},
|
||||
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "abc"},
|
||||
{"graduate", "all-graduate"},
|
||||
{"graduate", "all-graduate", "--graduate-year", "abc"},
|
||||
{"graduate", "batch-graduate"},
|
||||
{"graduate", "batch-graduate", "--dept-id", "abc"},
|
||||
{"graduate", "batch-graduate", "--dept-id", "1"},
|
||||
{"graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", ",,"},
|
||||
{"graduate", "delete-and-graduate"},
|
||||
{"graduate", "delete-and-graduate", "--dept-id", "abc"},
|
||||
{"graduate", "delete-and-graduate", "--dept-id", "1"},
|
||||
{"graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", ",,"},
|
||||
{"graduate", "batch-delete-pending"},
|
||||
{"graduate", "batch-delete-pending", "--dept-id", "abc"},
|
||||
{"graduate", "batch-delete-pending", "--dept-id", "1"},
|
||||
{"graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", ",,"},
|
||||
{"graduate", "batch-update-pending"},
|
||||
{"graduate", "batch-update-pending", "--dept-id", "abc"},
|
||||
{"graduate", "batch-update-pending", "--dept-id", "1"},
|
||||
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", ",,"},
|
||||
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1"},
|
||||
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1", "--graduate-year", "abc"},
|
||||
{"graduate", "commit-restore"},
|
||||
{"graduate", "commit-restore", "--graduate-dept-ids", "abc"},
|
||||
{"graduate", "commit-restore", "--graduate-dept-ids", ",,"},
|
||||
|
||||
// ── group ────────────────────────────────────────────
|
||||
{"group", "query-group-rule", "--offset", "abc"},
|
||||
{"group", "query-group-rule", "--size", "abc"},
|
||||
{"group", "query-preview-data", "--offset", "abc"},
|
||||
{"group", "query-preview-data", "--size", "abc"},
|
||||
{"group", "create-group-rule"},
|
||||
{"group", "create-group-rule", "--name", "X"},
|
||||
{"group", "create-group-rule", "--name", "X", "--tag-code", "T"},
|
||||
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college", "--auto-admin", "maybe"},
|
||||
{"group", "delete-group-rule"},
|
||||
{"group", "delete-group-rule", "--rule-id", "abc"},
|
||||
{"group", "enable-group-rule"},
|
||||
{"group", "enable-group-rule", "--rule-id", "abc"},
|
||||
{"group", "disable-group-rule"},
|
||||
{"group", "disable-group-rule", "--rule-id", "abc"},
|
||||
}
|
||||
|
||||
for _, args := range cases {
|
||||
root := newCollegeContactCommand()
|
||||
if err := executeCommand(root, args...); err == nil {
|
||||
t.Errorf("%v: expected non-nil error, got nil", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageCollegeContactDestructiveConfirmGate verifies every
|
||||
// user_required destructive leaf in a paired manner:
|
||||
// - Without --yes: returns confirmation_required error AND caller is never invoked (zero calls).
|
||||
// - With --yes: proceeds to MCP dispatch with exactly one call AND the correct
|
||||
// productID, tool name, and complete argument payload.
|
||||
func TestCrossPlatformCoverageCollegeContactDestructiveConfirmGate(t *testing.T) {
|
||||
type destructiveCase struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantInput map[string]any
|
||||
}
|
||||
|
||||
cases := []destructiveCase{
|
||||
{
|
||||
"dept delete",
|
||||
[]string{"college-contact", "dept", "delete", "--dept-id", "123"},
|
||||
"delete_college_contact_dept",
|
||||
map[string]any{"deptId": int64(123)},
|
||||
},
|
||||
{
|
||||
"employee remove",
|
||||
[]string{"college-contact", "employee", "remove", "--staff-ids", "S1,S2"},
|
||||
"remove_employee",
|
||||
map[string]any{"staffIds": []string{"S1", "S2"}},
|
||||
},
|
||||
{
|
||||
"alumni delete-dept",
|
||||
[]string{"college-contact", "alumni", "delete-dept", "--alumni-dept-id", "1"},
|
||||
"delete_alumni_dept",
|
||||
map[string]any{"alumniDeptId": int64(1)},
|
||||
},
|
||||
{
|
||||
"alumni remove-alumnus",
|
||||
[]string{"college-contact", "alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "1"},
|
||||
"delete_alumnus",
|
||||
map[string]any{"staffId": "S1", "alumniDeptId": int64(1)},
|
||||
},
|
||||
{
|
||||
"alumni cancel-invite",
|
||||
[]string{"college-contact", "alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
"delete_alumni_invite_record",
|
||||
map[string]any{"alumniDeptId": int64(1), "staffIds": []string{"s1", "s2"}},
|
||||
},
|
||||
{
|
||||
"alumni disband-group",
|
||||
[]string{"college-contact", "alumni", "disband-group", "--alumni-dept-id", "1"},
|
||||
"disband_alumni_group",
|
||||
map[string]any{"alumniDeptId": int64(1)},
|
||||
},
|
||||
{
|
||||
"graduate commit-graduate",
|
||||
[]string{"college-contact", "graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"},
|
||||
"commit_graduate",
|
||||
map[string]any{"graduateDeptIds": []int64{1, 2}, "graduateYear": int64(2026)},
|
||||
},
|
||||
{
|
||||
"graduate all-graduate",
|
||||
[]string{"college-contact", "graduate", "all-graduate", "--graduate-year", "2026"},
|
||||
"all_graduate",
|
||||
map[string]any{"graduateYear": int64(2026)},
|
||||
},
|
||||
{
|
||||
"graduate batch-graduate",
|
||||
[]string{"college-contact", "graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
"batch_graduate",
|
||||
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
|
||||
},
|
||||
{
|
||||
"graduate delete-and-graduate",
|
||||
[]string{"college-contact", "graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
"delete_and_graduate",
|
||||
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
|
||||
},
|
||||
{
|
||||
"graduate batch-delete-pending",
|
||||
[]string{"college-contact", "graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
"batch_delete_pending",
|
||||
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
|
||||
},
|
||||
{
|
||||
"graduate batch-update-pending",
|
||||
[]string{"college-contact", "graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1,s2", "--graduate-year", "2026"},
|
||||
"batch_update_pending",
|
||||
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}, "graduateYear": int64(2026)},
|
||||
},
|
||||
{
|
||||
"graduate commit-restore",
|
||||
[]string{"college-contact", "graduate", "commit-restore", "--graduate-dept-ids", "1,2"},
|
||||
"commit_restore",
|
||||
map[string]any{"graduateDeptIds": []int64{1, 2}},
|
||||
},
|
||||
{
|
||||
"group delete-group-rule",
|
||||
[]string{"college-contact", "group", "delete-group-rule", "--rule-id", "1"},
|
||||
"delete_group_rule",
|
||||
map[string]any{"ruleId": int64(1)},
|
||||
},
|
||||
{
|
||||
"group execute-group-rule",
|
||||
[]string{"college-contact", "group", "execute-group-rule"},
|
||||
"execute_group_rule",
|
||||
map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
|
||||
caller := &recruitCaptureCaller{dryRun: false}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newCollegeContactTestRoot()
|
||||
root.SetArgs(tc.args)
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected confirm-gate error without --yes, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "需要用户确认") {
|
||||
t.Fatalf("expected confirmation gate error, got: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
|
||||
caller := &recruitCaptureCaller{dryRun: false}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newCollegeContactTestRoot()
|
||||
argsWithYes := append(append([]string{}, tc.args...), "--yes")
|
||||
root.SetArgs(argsWithYes)
|
||||
err := root.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() with --yes error = %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
|
||||
}
|
||||
if caller.calls[0].productID != "college-contact" {
|
||||
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "college-contact")
|
||||
}
|
||||
if caller.calls[0].tool != tc.wantTool {
|
||||
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
|
||||
}
|
||||
gotArgs := caller.calls[0].args
|
||||
if len(gotArgs) != 1 {
|
||||
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
|
||||
}
|
||||
gotInput, ok := gotArgs["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
|
||||
}
|
||||
if !reflect.DeepEqual(gotInput, tc.wantInput) {
|
||||
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// withCollegeContactDispatchCaller installs a non-dry-run capture caller so
|
||||
// commands go through the full dispatch path (deps.Caller.CallTool) and we can
|
||||
// verify the productID, tool name, and args passed to callMCPToolOnServer.
|
||||
func withCollegeContactDispatchCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
// TestCollegeContactDispatch verifies that representative commands from each
|
||||
// group dispatch to the correct MCP tool with the expected productID and args.
|
||||
func TestCrossPlatformCoverageCollegeContactDispatch(t *testing.T) {
|
||||
type dispatchCase struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantProd string
|
||||
checkArgs func(t *testing.T, args map[string]any)
|
||||
}
|
||||
|
||||
cases := []dispatchCase{
|
||||
{
|
||||
name: "dept get-standard-structure",
|
||||
args: []string{"college-contact", "dept", "get-standard-structure"},
|
||||
wantTool: "get_college_standard_structure",
|
||||
wantProd: "college-contact",
|
||||
},
|
||||
{
|
||||
name: "dept get-detail",
|
||||
args: []string{"college-contact", "dept", "get-detail", "--dept-id", "123"},
|
||||
wantTool: "get_college_dept_detail",
|
||||
wantProd: "college-contact",
|
||||
checkArgs: func(t *testing.T, args map[string]any) {
|
||||
input := args["input"].(map[string]any)
|
||||
if input["deptId"] != int64(123) {
|
||||
t.Errorf("deptId = %v (%T), want int64(123)", input["deptId"], input["deptId"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dept create",
|
||||
args: []string{"college-contact", "dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true"},
|
||||
wantTool: "create_college_contact_dept",
|
||||
wantProd: "college-contact",
|
||||
},
|
||||
{
|
||||
name: "employee get-detail",
|
||||
args: []string{"college-contact", "employee", "get-detail", "--staff-id", "S1"},
|
||||
wantTool: "get_employee_detail",
|
||||
wantProd: "college-contact",
|
||||
checkArgs: func(t *testing.T, args map[string]any) {
|
||||
input := args["input"].(map[string]any)
|
||||
if input["staffId"] != "S1" {
|
||||
t.Errorf("staffId = %v, want S1", input["staffId"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "alumni get-dept-tree",
|
||||
args: []string{"college-contact", "alumni", "get-dept-tree", "--alumni-dept-id", "123"},
|
||||
wantTool: "get_alumni_dept_tree",
|
||||
wantProd: "college-contact",
|
||||
checkArgs: func(t *testing.T, args map[string]any) {
|
||||
input := args["input"].(map[string]any)
|
||||
if input["alumniDeptId"] != int64(123) {
|
||||
t.Errorf("alumniDeptId = %v (%T), want int64(123)", input["alumniDeptId"], input["alumniDeptId"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "graduate query-graduate-years",
|
||||
args: []string{"college-contact", "graduate", "query-graduate-years"},
|
||||
wantTool: "query_graduate_years",
|
||||
wantProd: "college-contact",
|
||||
},
|
||||
{
|
||||
name: "group query-group-rule",
|
||||
args: []string{"college-contact", "group", "query-group-rule"},
|
||||
wantTool: "query_group_rule",
|
||||
wantProd: "college-contact",
|
||||
},
|
||||
{
|
||||
name: "dept delete with --yes",
|
||||
args: []string{"college-contact", "dept", "delete", "--dept-id", "123", "--yes"},
|
||||
wantTool: "delete_college_contact_dept",
|
||||
wantProd: "college-contact",
|
||||
checkArgs: func(t *testing.T, args map[string]any) {
|
||||
input := args["input"].(map[string]any)
|
||||
if input["deptId"] != int64(123) {
|
||||
t.Errorf("deptId = %v (%T), want int64(123)", input["deptId"], input["deptId"])
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := withCollegeContactDispatchCaller(t)
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
|
||||
root.AddCommand(newCollegeContactCommand())
|
||||
root.SetArgs(tc.args)
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if caller.productID != tc.wantProd {
|
||||
t.Errorf("productID = %q, want %q", caller.productID, tc.wantProd)
|
||||
}
|
||||
if caller.tool != tc.wantTool {
|
||||
t.Errorf("tool = %q, want %q", caller.tool, tc.wantTool)
|
||||
}
|
||||
if tc.checkArgs != nil {
|
||||
tc.checkArgs(t, caller.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -231,6 +232,9 @@ Unicode Emoji。例如用户要求 😄 时传“憨笑”,要求 👏 时传
|
||||
if err := validateRequiredFlags(cmd, "comment-key", "reaction"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := commentreaction.Validate(mustGetFlag(cmd, "reaction")); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer(commentServer, "reply_comment", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
|
||||
|
||||
@@ -135,6 +135,29 @@ func TestCrossPlatformCoverageCommentReactReplyForcesEmojiTrue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommentReactionsRejectUnsupportedValuesBeforeRPC(t *testing.T) {
|
||||
for _, surface := range []string{"doc", "sheet"} {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "react unicode", args: []string{"comment", "react-reply", "--node", "node-1", "--comment-key", "comment-1", "--reaction", "😄"}},
|
||||
{name: "react garbage", args: []string{"comment", "react-reply", "--node", "node-1", "--comment-key", "comment-1", "--reaction", "乱码"}},
|
||||
{name: "reply unicode", args: []string{"comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "👏", "--emoji"}},
|
||||
} {
|
||||
t.Run(surface+"/"+test.name, func(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
if err := executeCommentBaseCommand(t, caller, surface, test.args...); err == nil {
|
||||
t.Fatal("unsupported reaction accepted")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unsupported reaction reached RPC: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommentReactReplyGuidesDingTalkEmojiNames(t *testing.T) {
|
||||
for _, surface := range []struct {
|
||||
name string
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -39,7 +41,16 @@ func TestMain(m *testing.M) {
|
||||
if os.Getenv(helpersShellStubEnv) == "1" {
|
||||
os.Exit(runHelpersShellStub())
|
||||
}
|
||||
// Cobra's Windows pre-exec hook walks the process table on every Execute*
|
||||
// call to detect Explorer launches. Helpers tests execute command trees
|
||||
// thousands of times, and none of those in-process invocations can be an
|
||||
// Explorer launch, so keep that production-only check out of the test
|
||||
// process. This also prevents every new exhaustive harness from having to
|
||||
// remember a test-local override.
|
||||
originalMousetrapHelpText := cobra.MousetrapHelpText
|
||||
cobra.MousetrapHelpText = ""
|
||||
code := m.Run()
|
||||
cobra.MousetrapHelpText = originalMousetrapHelpText
|
||||
if helpersShellStubBaseDir != "" {
|
||||
_ = os.RemoveAll(helpersShellStubBaseDir)
|
||||
}
|
||||
|
||||
@@ -504,3 +504,147 @@ func TestSheetConfirmationGuardCoversEveryProtectedLeaf(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// executeGuardedPermissionRemoveCommand mirrors executeGuardedMutationCommand
|
||||
// but additionally rewrites os.Args so resolveProductID routes doc/wiki
|
||||
// auto-routed tools to their real MCP server (doc → doc, wiki → wiki).
|
||||
func executeGuardedPermissionRemoveCommand(t *testing.T, caller *guardedMutationCaller, build func() *cobra.Command, args ...string) error {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
root := build()
|
||||
os.Args = append([]string{"dws", root.Name()}, args...)
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
if root.PersistentFlags().Lookup("yes") == nil {
|
||||
root.PersistentFlags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
if root.PersistentFlags().Lookup("dry-run") == nil {
|
||||
root.PersistentFlags().Bool("dry-run", false, "preview without executing")
|
||||
}
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
if root.InOrStdin() == os.Stdin {
|
||||
root.SetIn(strings.NewReader(""))
|
||||
}
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
// TestPermissionMemberRemoveRequiresConfirmationBeforeToolCall pins the
|
||||
// destructive confirmation gate on the three batch remove entry points
|
||||
// (drive/doc permission remove, wiki member remove). Removing up to 30
|
||||
// USER/DEPT/CONVERSATION/TAG members in one call can revoke access for whole
|
||||
// departments, chats, or role groups, so every invocation — new --members
|
||||
// format and legacy --users alike — must confirm first: without --yes the
|
||||
// command fails with the typed confirmation_required error and performs zero
|
||||
// MCP calls; with --yes it dispatches exactly one call with the complete,
|
||||
// precise tool arguments; --dry-run previews without any call.
|
||||
func TestPermissionMemberRemoveRequiresConfirmationBeforeToolCall(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
build func() *cobra.Command
|
||||
args []string
|
||||
product string
|
||||
tool string
|
||||
wantArgs map[string]any
|
||||
}{
|
||||
{
|
||||
name: "drive permission remove --members",
|
||||
build: newDriveCommand,
|
||||
args: []string{"permission", "remove", "--node", "n1", "--members", `[{"type":"CONVERSATION","id":"cid1"},{"type":"TAG","id":"t1","corpId":"c1"}]`},
|
||||
product: "doc",
|
||||
tool: "remove_permission",
|
||||
wantArgs: map[string]any{
|
||||
"nodeId": "n1",
|
||||
"members": []map[string]any{
|
||||
{"type": "CONVERSATION", "id": "cid1"},
|
||||
{"type": "TAG", "id": "t1", "corpId": "c1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "drive permission remove --users",
|
||||
build: newDriveCommand,
|
||||
args: []string{"permission", "remove", "--node", "n1", "--users", "uid1,uid2"},
|
||||
product: "doc",
|
||||
tool: "remove_permission",
|
||||
wantArgs: map[string]any{
|
||||
"nodeId": "n1",
|
||||
"userIds": []string{"uid1", "uid2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "doc permission remove --members",
|
||||
build: newDocCommand,
|
||||
args: []string{"permission", "remove", "--node", "n1", "--members", `[{"type":"USER","id":"u1","corpId":"c1"}]`},
|
||||
product: "doc",
|
||||
tool: "remove_permission",
|
||||
wantArgs: map[string]any{
|
||||
"nodeId": "n1",
|
||||
"members": []map[string]any{{"type": "USER", "id": "u1", "corpId": "c1"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wiki member remove --members",
|
||||
build: newWikiCommand,
|
||||
args: []string{"member", "remove", "--workspace", "ws1", "--members", `[{"type":"DEPT","id":"d1","corpId":"c1"}]`},
|
||||
product: "wiki",
|
||||
tool: "remove_member",
|
||||
wantArgs: map[string]any{
|
||||
"workspaceId": "ws1",
|
||||
"members": []map[string]any{{"type": "DEPT", "id": "d1", "corpId": "c1"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wiki member remove --users",
|
||||
build: newWikiCommand,
|
||||
args: []string{"member", "remove", "--workspace", "ws1", "--users", "uid1"},
|
||||
product: "wiki",
|
||||
tool: "remove_member",
|
||||
wantArgs: map[string]any{
|
||||
"workspaceId": "ws1",
|
||||
"userIds": []string{"uid1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// 1) 未确认:typed confirmation_required 错误 + 零 MCP 调用。
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedPermissionRemoveCommand(t, caller, tc.build, tc.args...)
|
||||
requireTypedConfirmationError(t, err)
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("tool calls before confirmation = %#v, want none", caller.calls)
|
||||
}
|
||||
|
||||
// 2) 明确确认(--yes):恰好一次调用,参数完整且精确。
|
||||
confirmed := &guardedMutationCaller{}
|
||||
if err := executeGuardedPermissionRemoveCommand(t, confirmed, tc.build, append(append([]string(nil), tc.args...), "--yes")...); err != nil {
|
||||
t.Fatalf("confirmed remove returned error: %v", err)
|
||||
}
|
||||
if len(confirmed.calls) != 1 {
|
||||
t.Fatalf("tool calls = %d, want exactly 1: %+v", len(confirmed.calls), confirmed.calls)
|
||||
}
|
||||
call := confirmed.calls[0]
|
||||
if call.productID != tc.product || call.toolName != tc.tool {
|
||||
t.Fatalf("tool call = %s/%s, want %s/%s", call.productID, call.toolName, tc.product, tc.tool)
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, tc.wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, tc.wantArgs)
|
||||
}
|
||||
|
||||
// 3) --dry-run 预览:不触发任何 MCP 调用。
|
||||
dryRun := &guardedMutationCaller{dryRun: true}
|
||||
if err := executeGuardedPermissionRemoveCommand(t, dryRun, tc.build, append(append([]string(nil), tc.args...), "--dry-run")...); err != nil {
|
||||
t.Fatalf("dry-run remove returned error: %v", err)
|
||||
}
|
||||
if len(dryRun.calls) != 0 {
|
||||
t.Fatalf("dry-run tool calls = %#v, want none", dryRun.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,16 @@ import (
|
||||
// remindType: 服务端 API 1=应用内 2=短信 3=电话
|
||||
var dingRemindTypeMap = map[string]int{"app": 1, "sms": 2, "call": 3}
|
||||
|
||||
var dingPersonalRemindTypeMap = map[string]string{"app": "APP", "sms": "SMS", "call": "PHONE"}
|
||||
|
||||
func dingPersonalRemindType(value string) (string, error) {
|
||||
remindType, ok := dingPersonalRemindTypeMap[strings.ToLower(strings.TrimSpace(value))]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("--type must be one of app, sms, call")
|
||||
}
|
||||
return remindType, nil
|
||||
}
|
||||
|
||||
func newDingCommand() *cobra.Command {
|
||||
// Product-level Agent routing Decl (migrated from selection/ding.json
|
||||
// products.ding). Catalog assembly stamps provenance contract_final.
|
||||
@@ -232,11 +242,15 @@ func newDingCommand() *cobra.Command {
|
||||
if err := validateRequiredFlags(cmd, "users", "content"); err != nil {
|
||||
return err
|
||||
}
|
||||
remindType, err := dingPersonalRemindType(mustGetFlag(cmd, "type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users := parseCSVValues(mustGetFlag(cmd, "users"))
|
||||
toolArgs := map[string]any{
|
||||
"receiverOpenDingTalkIds": users,
|
||||
"content": mustGetFlag(cmd, "content"),
|
||||
"remindType": mustGetFlag(cmd, "type"),
|
||||
"remindType": remindType,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
|
||||
toolArgs["uuid"] = v
|
||||
@@ -262,12 +276,16 @@ func newDingCommand() *cobra.Command {
|
||||
if err := validateRequiredFlags(cmd, "group", "message-id", "users"); err != nil {
|
||||
return err
|
||||
}
|
||||
remindType, err := dingPersonalRemindType(mustGetFlag(cmd, "type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users := parseCSVValues(mustGetFlag(cmd, "users"))
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "group"),
|
||||
"openMessageId": mustGetFlag(cmd, "message-id"),
|
||||
"receiverOpenDingTalkIds": users,
|
||||
"remindType": mustGetFlag(cmd, "type"),
|
||||
"remindType": remindType,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
|
||||
toolArgs["uuid"] = v
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageDingPersonalRemindTypeMatchesMCPEnum(t *testing.T) {
|
||||
for input, want := range map[string]string{"app": "APP", "sms": "SMS", "call": "PHONE", " APP ": "APP"} {
|
||||
got, err := dingPersonalRemindType(input)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("dingPersonalRemindType(%q)=(%q,%v), want %q", input, got, err, want)
|
||||
}
|
||||
}
|
||||
if got, err := dingPersonalRemindType("push"); err == nil || got != "" {
|
||||
t.Fatalf("unsupported remind type=(%q,%v)", got, err)
|
||||
}
|
||||
}
|
||||
+277
-59
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
@@ -1428,9 +1429,14 @@ func newDocCommand() *cobra.Command {
|
||||
readCmd := &cobra.Command{
|
||||
Use: "read",
|
||||
Short: "读取文档内容 (Markdown)",
|
||||
Long: `获取文档内容,以 Markdown 格式返回。支持传入文档 URL 或 ID。`,
|
||||
Long: `获取文档内容,以 Markdown 格式返回。支持传入文档 URL 或 ID。
|
||||
|
||||
互联网公开文档(含开启密码保护的)可传入公开链接;设置了访问密码时通过 --password 提供。
|
||||
--version 读取指定历史版本内容(版本号从 dws doc version list 获取,0 表示文档初始版本,需要文档编辑权限);缺省读最新版。`,
|
||||
Example: ` dws doc read --node DOC_ID
|
||||
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"`,
|
||||
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
|
||||
dws doc read --node PUBLIC_URL --password <ACCESS_PASSWORD>
|
||||
dws doc read --node DOC_ID --version 3`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
@@ -1440,6 +1446,15 @@ func newDocCommand() *cobra.Command {
|
||||
"dws doc read --node DOC_ID --content-format jsonml"); err != nil {
|
||||
return err
|
||||
}
|
||||
password, _ := cmd.Flags().GetString("password")
|
||||
historyVersion := 0
|
||||
historyVersionSet := cmd.Flags().Changed("version")
|
||||
if historyVersionSet {
|
||||
historyVersion, _ = cmd.Flags().GetInt("version")
|
||||
if historyVersion < 0 {
|
||||
return fmt.Errorf("--version 必须为非负整数历史版本号(0 表示初始版本,版本号从 dws doc version list 获取),当前值: %d", historyVersion)
|
||||
}
|
||||
}
|
||||
format, _ := cmd.Flags().GetString("content-format")
|
||||
scope, _ := cmd.Flags().GetString("scope")
|
||||
tags, _ := cmd.Flags().GetString("tags")
|
||||
@@ -1480,15 +1495,18 @@ func newDocCommand() *cobra.Command {
|
||||
startBlockID,
|
||||
endBlockID,
|
||||
outputPath,
|
||||
password,
|
||||
historyVersion,
|
||||
historyVersionSet,
|
||||
)
|
||||
}
|
||||
if format == "jsonml" {
|
||||
outputPath, _ := cmd.Flags().GetString("output")
|
||||
return runDocReadJsonML(cmd, nodeID, outputPath)
|
||||
return runDocReadJsonML(nodeID, outputPath, password, historyVersion, historyVersionSet)
|
||||
}
|
||||
return callMCPTool("get_document_content", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
})
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
applyDocReadAccessParams(toolArgs, password, historyVersion, historyVersionSet)
|
||||
return callMCPTool("get_document_content", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(readCmd, LeafSpec{
|
||||
@@ -1515,6 +1533,8 @@ func newDocCommand() *cobra.Command {
|
||||
UseWhen: []string{
|
||||
"用户要读取钉钉在线文字文档(adoc)正文(Markdown)时",
|
||||
"用户直接粘贴文档 URL 且无其他指令时(默认读内容)",
|
||||
"互联网公开文档(含设置密码保护的公开链接)时配合 --password 提供访问密码",
|
||||
"要读取指定历史版本内容时用 --version(版本号来自 doc version list,0 表示初始版本,需要编辑权限)",
|
||||
"只需标题大纲、指定块区间/单块或特定 JSONML tags 时使用 --content-format jsonml 与 --scope",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
@@ -1532,9 +1552,11 @@ func newDocCommand() *cobra.Command {
|
||||
{Name: "content-format", Property: "format", Required: boolPtr(false)},
|
||||
{Name: "end-block-id", Required: boolPtr(false)},
|
||||
{Name: "max-depth", Required: boolPtr(false), InterfaceType: "integer"},
|
||||
{Name: "password", Property: "password", Required: boolPtr(false)},
|
||||
{Name: "scope", Required: boolPtr(false)},
|
||||
{Name: "start-block-id", Required: boolPtr(false), RequiredWhen: "--scope=range or --scope=section"},
|
||||
{Name: "tags", Required: boolPtr(false), RequiredWhen: "--scope=tags"},
|
||||
{Name: "version", Property: "historyVersion", Required: boolPtr(false), InterfaceType: "integer"},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -2679,6 +2701,8 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
readCmd.Flags().Int("max-depth", 0, "筛选遍历最大深度, 0 表示不限(仅 --scope 时生效)")
|
||||
readCmd.Flags().String("start-block-id", "", "range/section 起始块 ID(节点 uuid); scope=range/section 时必填")
|
||||
readCmd.Flags().String("end-block-id", "", "range 结束块 ID(节点 uuid); \"-1\"或空=到文档末尾(仅 scope=range 生效)")
|
||||
readCmd.Flags().String("password", "", "互联网公开文档开启密码保护时的访问密码;普通文档无需传入")
|
||||
readCmd.Flags().Int("version", 0, "读取指定历史版本内容(版本号从 doc version list 获取, 0 表示初始版本, 需要文档编辑权限);缺省读最新版")
|
||||
cli.AnnotateRuntimeFlagEnum(readCmd, "scope", "outline", "range", "section", "tags")
|
||||
cli.AnnotateRuntimeFlagRequiredWhen(readCmd, "tags", "--scope=tags")
|
||||
cli.AnnotateRuntimeFlagRequiredWhen(readCmd, "start-block-id", "--scope=range or --scope=section")
|
||||
@@ -3220,6 +3244,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("emoji"); v {
|
||||
if err := commentreaction.Validate(mustGetFlag(cmd, "content")); err != nil {
|
||||
return err
|
||||
}
|
||||
groupMentions, err := commentGroupMentionIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -3529,11 +3556,20 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
permissionAddCmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "添加文档协作者",
|
||||
Args: cobra.NoArgs,
|
||||
Long: `为指定文档(或文件夹/文件)添加一个或多个协作成员,并授予指定角色。
|
||||
|
||||
通过 --user 传入逗号分隔的 userId 列表,多个用户将被授予同一角色。
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
|
||||
|
||||
支持的角色 (--role)(必须大写):
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织)
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织)
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型
|
||||
|
||||
支持的角色(大小写不敏感):
|
||||
MANAGER 管理员,可读写、管理成员
|
||||
EDITOR 编辑者,可查看、编辑、上传内容
|
||||
DOWNLOADER 查看下载者,可查看并下载内容
|
||||
@@ -3541,29 +3577,46 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
|
||||
注意:
|
||||
- OWNER 角色不可通过此接口添加。
|
||||
- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
|
||||
- 单次请求最多 30 个成员,超出请分批调用。
|
||||
- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false;省略时 CLI 不向服务端发送该字段,服务端按不通知处理,需要通知请显式传 --notify。
|
||||
|
||||
用户 uid 可通过「钉钉通讯录」相关命令检索,如:
|
||||
dws contact user search --keyword "姓名"`,
|
||||
Example: ` dws doc permission add --node DOC_ID --users uid1 --role READER
|
||||
dws doc permission add --node DOC_ID --users uid1,uid2,uid3 --role EDITOR
|
||||
dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1 --role MANAGER --workspace WS_ID`,
|
||||
dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1 --role MANAGER --workspace WS_ID
|
||||
dws doc permission add --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"},{"type":"DEPT","id":"deptId1","roleId":"EDITOR","corpId":"xxx"}]' --notify
|
||||
dws doc permission add --node DOC_ID --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"userIds": userIds,
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
if cmd.Flags().Changed("notify") {
|
||||
notify, _ := cmd.Flags().GetBool("notify")
|
||||
toolArgs["notify"] = notify
|
||||
}
|
||||
} else {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -3600,7 +3653,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
Examples: []string{"dws doc permission add --node <DOC_ID> --users uid1 --role READER --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "notify", Property: "notify"},
|
||||
{Name: "role", Property: "roleId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -3609,18 +3664,31 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
})
|
||||
|
||||
permissionAddCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
|
||||
permissionAddCmd.Flags().String("users", "", "被授权的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
|
||||
permissionAddCmd.Flags().String("users", "", "被授权的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
|
||||
permissionAddCmd.Flags().String("user", "", "")
|
||||
_ = permissionAddCmd.Flags().MarkHidden("user")
|
||||
permissionAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
|
||||
permissionAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)")
|
||||
permissionAddCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
|
||||
permissionAddCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
|
||||
permissionAddCmd.Flags().Bool("notify", false, "是否通知被添加的成员(仅 --members 新格式时生效,需显式传入才通知)")
|
||||
|
||||
permissionUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新文档协作者权限",
|
||||
Long: `更新指定节点已有协作者的权限角色(仅支持 USER 类型成员)。
|
||||
Args: cobra.NoArgs,
|
||||
Long: `更新指定节点已有协作者的权限角色。
|
||||
|
||||
支持的角色 (--role)(必须大写):
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
|
||||
|
||||
支持的角色 (--role)(大小写不敏感):
|
||||
MANAGER 管理员
|
||||
EDITOR 编辑者
|
||||
DOWNLOADER 查看下载者
|
||||
@@ -3630,26 +3698,43 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
- OWNER 角色不可通过此接口变更。
|
||||
- 同一成员在同一节点只能拥有一个角色,变更后旧角色自动替换。
|
||||
- 若成员的角色来自父节点的权限继承(PASS_ON),且继承角色高于目标角色,接口会拒绝操作。
|
||||
- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
|
||||
- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知,默认 false。
|
||||
|
||||
仅可更新已存在协作关系的用户,新增协作者请使用 dws doc permission add。`,
|
||||
Example: ` dws doc permission update --node DOC_ID --users uid1 --role EDITOR
|
||||
dws doc permission update --node DOC_ID --users uid1,uid2 --role READER`,
|
||||
dws doc permission update --node DOC_ID --users uid1,uid2 --role READER
|
||||
dws doc permission update --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
|
||||
dws doc permission update --node DOC_ID --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"userIds": userIds,
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
if cmd.Flags().Changed("notify") {
|
||||
notify, _ := cmd.Flags().GetBool("notify")
|
||||
toolArgs["notify"] = notify
|
||||
}
|
||||
} else {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -3683,7 +3768,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
Examples: []string{"dws doc permission update --node <DOC_ID> --users uid1 --role EDITOR --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "notify", Property: "notify"},
|
||||
{Name: "role", Property: "roleId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -3692,11 +3779,13 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
})
|
||||
|
||||
permissionUpdateCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
|
||||
permissionUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
|
||||
permissionUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
|
||||
permissionUpdateCmd.Flags().String("user", "", "")
|
||||
_ = permissionUpdateCmd.Flags().MarkHidden("user")
|
||||
permissionUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
|
||||
permissionUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)")
|
||||
permissionUpdateCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
|
||||
permissionUpdateCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
|
||||
permissionUpdateCmd.Flags().Bool("notify", false, "是否通知被变更的成员(仅 --members 新格式时生效)")
|
||||
|
||||
permissionListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -3704,11 +3793,14 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
Short: "查询文档协作者列表",
|
||||
Long: `查询指定节点的协作者列表,返回每位成员的 userId、姓名、角色等信息。
|
||||
|
||||
注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 200)。
|
||||
若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围。`,
|
||||
底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。
|
||||
出参包含 totalCount(全量成员总数)、hasMore(是否还有下一页)和 nextToken(下一页游标)。
|
||||
当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。
|
||||
操作者需满足该节点配置的权限管理最低角色要求,权限不足返回 forbidden.accessDenied。`,
|
||||
Example: ` dws doc permission list --node DOC_ID
|
||||
dws doc permission list --node DOC_ID --limit 100
|
||||
dws doc permission list --node DOC_ID --filter-role MANAGER,EDITOR`,
|
||||
dws doc permission list --node DOC_ID --limit 50
|
||||
dws doc permission list --node DOC_ID --filter-role MANAGER,EDITOR
|
||||
dws doc permission list --node DOC_ID --next-token <上次返回的 nextToken>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
@@ -3717,14 +3809,13 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
}
|
||||
limit := 0
|
||||
if cmd.Flags().Changed("limit") {
|
||||
limit, _ = cmd.Flags().GetInt("limit")
|
||||
} else if cmd.Flags().Changed("max-results") {
|
||||
limit, _ = cmd.Flags().GetInt("max-results")
|
||||
if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
toolArgs["pageSize"] = size
|
||||
}
|
||||
if limit > 0 {
|
||||
toolArgs["maxResults"] = limit
|
||||
if v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" {
|
||||
toolArgs["nextToken"] = v
|
||||
}
|
||||
if v := mustGetFlag(cmd, "filter-role"); v != "" {
|
||||
toolArgs["filterRoleIds"] = parseRoleList(v)
|
||||
@@ -3762,50 +3853,75 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "filter-role", Property: "filterRoleIds"},
|
||||
{Name: "limit", Property: "maxResults"},
|
||||
// limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize,
|
||||
// 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。
|
||||
{Name: "limit"},
|
||||
{Name: "next-token", Property: "nextToken"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
},
|
||||
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "next-token"},
|
||||
},
|
||||
})
|
||||
|
||||
permissionListCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
|
||||
permissionListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
|
||||
permissionListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50")
|
||||
permissionListCmd.Flags().Int("max-results", 0, "")
|
||||
_ = permissionListCmd.Flags().MarkHidden("max-results")
|
||||
permissionListCmd.Flags().String("filter-role", "", "按角色过滤(逗号分隔):OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
|
||||
permissionListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken")
|
||||
permissionListCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
|
||||
|
||||
permissionRemoveCmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "移除文档协作者权限",
|
||||
Long: `从指定节点移除一个或多个协作成员的权限(仅支持 USER 类型)。
|
||||
Long: `从指定节点移除一个或多个协作成员的权限。
|
||||
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,只需 type 和 id(USER/DEPT/TAG 还需 corpId)
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
|
||||
|
||||
移除后相关用户将无法通过该节点的直接授权访问内容(若有父节点继承权限则仍可通过继承权限访问)。
|
||||
|
||||
注意:
|
||||
- OWNER 角色不可通过此接口移除。
|
||||
- 操作者需在该节点具备 EDITOR 及以上角色(OWNER / MANAGER / EDITOR)。
|
||||
- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
|
||||
- 单次请求最多 30 个成员,超出请分批调用。
|
||||
|
||||
用户 uid 可通过「钉钉通讯录」相关命令检索,如:
|
||||
dws contact user search --keyword "姓名"`,
|
||||
Example: ` dws doc permission remove --node DOC_ID --users uid1
|
||||
dws doc permission remove --node DOC_ID --users uid1,uid2,uid3
|
||||
dws doc permission remove --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1`,
|
||||
dws doc permission remove --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1
|
||||
dws doc permission remove --node DOC_ID --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"userIds": userIds,
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, true)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
} else {
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -3815,8 +3931,11 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
}
|
||||
DeclareLeafMetadata(permissionRemoveCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
// 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销多个
|
||||
// 成员的访问,部门/群聊/角色组还可能间接影响大量用户,与删除同级的
|
||||
// destructive 入口,必须经过用户确认(--yes 或交互 yes)。
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
@@ -3839,6 +3958,7 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
Examples: []string{"dws doc permission remove --node <DOC_ID> --users uid1 --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -3846,9 +3966,10 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
},
|
||||
})
|
||||
permissionRemoveCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
|
||||
permissionRemoveCmd.Flags().String("users", "", "被移除权限的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
|
||||
permissionRemoveCmd.Flags().String("users", "", "被移除权限的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
|
||||
permissionRemoveCmd.Flags().String("user", "", "")
|
||||
_ = permissionRemoveCmd.Flags().MarkHidden("user")
|
||||
permissionRemoveCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥")
|
||||
permissionRemoveCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
|
||||
|
||||
// permission 子命令的 --node 隐藏别名
|
||||
@@ -4748,16 +4869,30 @@ func wrapDocDeprecatedToTarget(cmd *cobra.Command, targetCmd string) {
|
||||
}
|
||||
}
|
||||
|
||||
// applyDocReadAccessParams 把 doc read 的访问参数(互联网公开文档密码、历史版本号)
|
||||
// 附加到 get_document_content 请求上;空密码或未显式设置版本时不发送对应字段,
|
||||
// 显式 --version 0 表示读取文档初始版本。
|
||||
func applyDocReadAccessParams(args map[string]any, password string, historyVersion int, historyVersionSet bool) {
|
||||
if password != "" {
|
||||
args["password"] = password
|
||||
}
|
||||
if historyVersionSet && historyVersion >= 0 {
|
||||
args["historyVersion"] = historyVersion
|
||||
}
|
||||
}
|
||||
|
||||
// resolveContentFromFlags 从 --content-file / --content-path / --content / --markdown 获取文档内容。
|
||||
// 优先级:--content-file/--content-path > --content > --markdown(已弃用别名,向后兼容)。
|
||||
func runDocReadJsonML(_ *cobra.Command, nodeID string, outputPath string) error {
|
||||
func runDocReadJsonML(nodeID, outputPath, password string, historyVersion int, historyVersionSet bool) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
resultText, err := callMCPToolReturnText(ctx, "get_document_content", map[string]any{
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"format": "jsonml",
|
||||
})
|
||||
}
|
||||
applyDocReadAccessParams(toolArgs, password, historyVersion, historyVersionSet)
|
||||
resultText, err := callMCPToolReturnText(ctx, "get_document_content", toolArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -4809,7 +4944,7 @@ func runDocReadJsonML(_ *cobra.Command, nodeID string, outputPath string) error
|
||||
|
||||
// runDocReadScope calls get_document_content with JSONML filtering parameters
|
||||
// and preserves the returned read-only fragment container.
|
||||
func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool, startBlockID, endBlockID, outputPath string) error {
|
||||
func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool, startBlockID, endBlockID, outputPath, password string, historyVersion int, historyVersionSet bool) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
@@ -4829,6 +4964,7 @@ func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool,
|
||||
if endBlockID != "" && scope == "range" {
|
||||
args["endBlockId"] = endBlockID
|
||||
}
|
||||
applyDocReadAccessParams(args, password, historyVersion, historyVersionSet)
|
||||
|
||||
resultText, err := callMCPToolReturnTextOnServer(ctx, "doc", "get_document_content", args)
|
||||
if err != nil {
|
||||
@@ -5157,3 +5293,85 @@ func collectUserIDs(cmd *cobra.Command) ([]string, error) {
|
||||
}
|
||||
return userIds, nil
|
||||
}
|
||||
|
||||
// collectMembers parses the --members JSON array flag (new format), returning
|
||||
// a members list ready to embed in MCP tool args. Supports USER/DEPT/CONVERSATION/TAG
|
||||
// member types, each carrying an independent roleId.
|
||||
//
|
||||
// When onlyTypeID is true (remove operations), roleId is not required —
|
||||
// only type and id are needed.
|
||||
func collectMembers(cmd *cobra.Command, onlyTypeID bool) ([]map[string]any, error) {
|
||||
raw := mustGetFlag(cmd, "members")
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var members []map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &members); err != nil {
|
||||
return nil, fmt.Errorf("--members JSON 解析失败: %w", err)
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return nil, fmt.Errorf("--members 不能为空数组")
|
||||
}
|
||||
if len(members) > 30 {
|
||||
return nil, fmt.Errorf("--members 单次最多 30 个成员,超出请分批调用")
|
||||
}
|
||||
for i, m := range members {
|
||||
mt, ok := m["type"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("--members[%d] 缺少必填字段 type", i)
|
||||
}
|
||||
if _, ok := m["id"].(string); !ok {
|
||||
return nil, fmt.Errorf("--members[%d] 缺少必填字段 id", i)
|
||||
}
|
||||
// USER/DEPT/TAG 类型需携带 corpId 用于确定成员所属组织,CONVERSATION 类型选填
|
||||
if (mt == "USER" || mt == "DEPT" || mt == "TAG") && m["corpId"] == nil {
|
||||
return nil, fmt.Errorf("--members[%d] 类型 %s 需携带 corpId 以确定所属组织", i, mt)
|
||||
}
|
||||
if !onlyTypeID {
|
||||
if _, ok := m["roleId"].(string); !ok {
|
||||
return nil, fmt.Errorf("--members[%d] 缺少必填字段 roleId", i)
|
||||
}
|
||||
if r, ok := m["roleId"].(string); ok {
|
||||
m["roleId"] = normalizePermissionRole(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
// validateMembersExclusivity ensures --members (new format) and --users (legacy
|
||||
// format) are not used simultaneously. Exactly one must be provided.
|
||||
func validateMembersExclusivity(cmd *cobra.Command) error {
|
||||
hasMembers := mustGetFlag(cmd, "members") != ""
|
||||
hasUsers := flagOrFallback(cmd, "users", "user") != ""
|
||||
if hasMembers && hasUsers {
|
||||
return fmt.Errorf("--members 与 --users 互斥,不可同时传递")
|
||||
}
|
||||
if !hasMembers && !hasUsers {
|
||||
return fmt.Errorf("必须指定 --members(新格式)或 --users(旧格式)之一")
|
||||
}
|
||||
if hasMembers && mustGetFlag(cmd, "role") != "" {
|
||||
return fmt.Errorf("--members 新格式下不需要 --role,每个 member 携带独立 roleId")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// permissionPageSizeFromFlags resolves and validates the page size for the
|
||||
// permission / member list commands. Both --limit and the hidden
|
||||
// --max-results alias map to the server pageSize, whose accepted range is
|
||||
// 1..50 (the backend rejects pageSize > 50 with
|
||||
// invalidRequest.inputArgs.invalid, and non-positive values are invalid).
|
||||
// It returns (size, true, nil) when a page size was explicitly provided.
|
||||
func permissionPageSizeFromFlags(cmd *cobra.Command) (int, bool, error) {
|
||||
for _, name := range []string{"limit", "max-results"} {
|
||||
if !cmd.Flags().Changed(name) {
|
||||
continue
|
||||
}
|
||||
size, _ := cmd.Flags().GetInt(name)
|
||||
if size < 1 || size > 50 {
|
||||
return 0, false, fmt.Errorf("--%s 取值范围为 1..50(服务端 pageSize 上限 50),当前值 %d", name, size)
|
||||
}
|
||||
return size, true, nil
|
||||
}
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ func TestCrossPlatformCoverageRunDocReadJSONMLCoverage(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{tc.step}}
|
||||
installScriptedCaller(t, caller)
|
||||
err := runDocReadJsonML(&cobra.Command{}, "node", tc.output)
|
||||
err := runDocReadJsonML("node", tc.output, "", 0, false)
|
||||
if tc.wantFail && err == nil {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
|
||||
err := executeDocGroupMentionCommand(
|
||||
t,
|
||||
caller,
|
||||
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
|
||||
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
|
||||
"--emoji", "--mentioned-open-conversation-id", "oc-1",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "emoji replies do not support group mentions") {
|
||||
@@ -235,7 +235,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
|
||||
err := executeDocGroupMentionCommand(
|
||||
t,
|
||||
caller,
|
||||
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
|
||||
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
|
||||
"--emoji", "--mentioned-open-conversation-id", " ",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
@@ -251,7 +251,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t
|
||||
err := executeDocGroupMentionCommand(
|
||||
t,
|
||||
caller,
|
||||
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like",
|
||||
"comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞",
|
||||
"--emoji",
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestCrossPlatformCoverageDocReadScopeFlagsAndValidation(t *testing.T) {
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("find read: remaining=%v err=%v", remaining, err)
|
||||
}
|
||||
for _, name := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id"} {
|
||||
for _, name := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id", "password", "version"} {
|
||||
if read.Flags().Lookup(name) == nil {
|
||||
t.Fatalf("doc read missing --%s", name)
|
||||
}
|
||||
@@ -249,7 +249,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := installDocReadScopeCaller(t, tt.caller)
|
||||
err := runDocReadScope("doc-1", "", "", 0, false, "", "ignored", tt.output)
|
||||
err := runDocReadScope("doc-1", "", "", 0, false, "", "ignored", tt.output, "", 0, false)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %v, want %q", err, tt.wantErr)
|
||||
@@ -277,7 +277,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{}]"}`}
|
||||
stdout := installDocReadScopeCaller(t, caller)
|
||||
outputPath := filepath.Join(t.TempDir(), "fragment.json")
|
||||
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath); err != nil {
|
||||
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath, "", 0, false); err != nil {
|
||||
t.Fatalf("runDocReadScope: %v", err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
@@ -292,7 +292,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
|
||||
t.Run("human output reports missing fragment", func(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{}`, format: "raw"}
|
||||
stdout := installDocReadScopeCaller(t, caller)
|
||||
if err := runDocReadScope("doc-1", "", "", 0, false, "", "", ""); err != nil {
|
||||
if err := runDocReadScope("doc-1", "", "", 0, false, "", "", "", "", 0, false); err != nil {
|
||||
t.Fatalf("runDocReadScope: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "未匹配到节点") {
|
||||
@@ -304,7 +304,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{}]"}`, format: "raw"}
|
||||
stdout := installDocReadScopeCaller(t, caller)
|
||||
outputPath := filepath.Join(t.TempDir(), "fragment.json")
|
||||
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath); err != nil {
|
||||
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath, "", 0, false); err != nil {
|
||||
t.Fatalf("runDocReadScope: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "JSONML fragment 已写入 "+outputPath) {
|
||||
@@ -312,3 +312,93 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDocReadAccessParamsForwarding 验证 doc read 三条读取路径
|
||||
// 都把 --password / --version 透传到 get_document_content,且非法版本号在
|
||||
// 本地校验阶段被拒绝、不产生远端调用。
|
||||
func TestCrossPlatformCoverageDocReadAccessParamsForwarding(t *testing.T) {
|
||||
t.Run("markdown path forwards password and history version", func(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"markdown":"body"}`}
|
||||
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--password", "pw", "--version", "7"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
want := map[string]any{"nodeId": "doc-1", "password": "pw", "historyVersion": 7}
|
||||
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("jsonml path forwards password and history version", func(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"jsonml":"[\"root\",{}]","revision":7}`}
|
||||
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--content-format", "jsonml", "--password", "pw", "--version", "7"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
want := map[string]any{"nodeId": "doc-1", "format": "jsonml", "password": "pw", "historyVersion": 7}
|
||||
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scope path forwards password and history version", func(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{},[\"h1\",{},\"t\"]]"}`}
|
||||
if err := executeDocReadScopeCommand(t, caller,
|
||||
"read", "--node", "doc-1", "--content-format", "jsonml", "--scope", "outline",
|
||||
"--password", "pw", "--version", "7"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
want := map[string]any{"nodeId": "doc-1", "format": "jsonml", "scope": "outline", "password": "pw", "historyVersion": 7}
|
||||
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("negative history version is rejected locally", func(t *testing.T) {
|
||||
for _, raw := range []string{"-1", "-3"} {
|
||||
caller := &docReadScopeCaller{}
|
||||
err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--version", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "--version 必须为非负整数") {
|
||||
t.Fatalf("--version %s error = %v, want invalid --version", raw, err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("--version %s produced %d remote calls, want 0", raw, len(caller.calls))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit zero history version forwards initial version", func(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"markdown":"body"}`}
|
||||
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--version", "0"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
want := map[string]any{"nodeId": "doc-1", "historyVersion": 0}
|
||||
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unset history version is not forwarded", func(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"markdown":"body"}`}
|
||||
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
want := map[string]any{"nodeId": "doc-1"}
|
||||
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+268
-50
@@ -2042,27 +2042,55 @@ func newDriveCommand() *cobra.Command {
|
||||
drivePermAddCmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "添加协作者",
|
||||
Args: cobra.NoArgs,
|
||||
Long: `为文档空间节点添加协作成员并授予指定角色。
|
||||
|
||||
支持的角色 (--role): MANAGER / EDITOR / DOWNLOADER / READER`,
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织)
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织)
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型
|
||||
|
||||
支持的角色: MANAGER / EDITOR / DOWNLOADER / READER
|
||||
--notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false。
|
||||
省略 --notify 时 CLI 不向服务端发送该字段,服务端按不通知处理;需要通知请显式传 --notify。`,
|
||||
Example: ` dws drive permission add --node DOC_ID --users uid1 --role READER
|
||||
dws drive permission add --node DOC_ID --users uid1,uid2 --role EDITOR`,
|
||||
dws drive permission add --node DOC_ID --users uid1,uid2 --role EDITOR
|
||||
dws drive permission add --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"}]' --notify
|
||||
dws drive permission add --node DOC_ID --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"userIds": userIds,
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
if cmd.Flags().Changed("notify") {
|
||||
notify, _ := cmd.Flags().GetBool("notify")
|
||||
toolArgs["notify"] = notify
|
||||
}
|
||||
} else {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -2103,7 +2131,9 @@ func newDriveCommand() *cobra.Command {
|
||||
Examples: []string{"dws drive permission add --node <ID> --users uid1,uid2 --role READER --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "notify", Property: "notify"},
|
||||
{Name: "role", Property: "roleId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -2111,35 +2141,64 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
drivePermAddCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
drivePermAddCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
|
||||
drivePermAddCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
|
||||
drivePermAddCmd.Flags().String("user", "", "")
|
||||
_ = drivePermAddCmd.Flags().MarkHidden("user")
|
||||
drivePermAddCmd.Flags().String("role", "", "角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)")
|
||||
drivePermAddCmd.Flags().String("role", "", "角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填)")
|
||||
drivePermAddCmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
drivePermAddCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
|
||||
drivePermAddCmd.Flags().Bool("notify", false, "是否通知被添加的成员(仅 --members 新格式时生效,需显式传入才通知)")
|
||||
|
||||
drivePermUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新协作者权限",
|
||||
Args: cobra.NoArgs,
|
||||
Long: `更新文档空间节点已有协作者的权限角色。
|
||||
|
||||
支持的角色 (--role): MANAGER / EDITOR / DOWNLOADER / READER`,
|
||||
Example: ` dws drive permission update --node DOC_ID --users uid1 --role EDITOR`,
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
|
||||
|
||||
支持的角色: MANAGER / EDITOR / DOWNLOADER / READER
|
||||
--notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知,默认 false。`,
|
||||
Example: ` dws drive permission update --node DOC_ID --users uid1 --role EDITOR
|
||||
dws drive permission update --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
|
||||
dws drive permission update --node DOC_ID --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"userIds": userIds,
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
if cmd.Flags().Changed("notify") {
|
||||
notify, _ := cmd.Flags().GetBool("notify")
|
||||
toolArgs["notify"] = notify
|
||||
}
|
||||
} else {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -2176,7 +2235,9 @@ func newDriveCommand() *cobra.Command {
|
||||
Examples: []string{"dws drive permission update --node <ID> --users uid1 --role EDITOR --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "notify", Property: "notify"},
|
||||
{Name: "role", Property: "roleId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -2184,33 +2245,39 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
drivePermUpdateCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
drivePermUpdateCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
|
||||
drivePermUpdateCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
|
||||
drivePermUpdateCmd.Flags().String("user", "", "")
|
||||
_ = drivePermUpdateCmd.Flags().MarkHidden("user")
|
||||
drivePermUpdateCmd.Flags().String("role", "", "新角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)")
|
||||
drivePermUpdateCmd.Flags().String("role", "", "新角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填)")
|
||||
drivePermUpdateCmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
drivePermUpdateCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
|
||||
drivePermUpdateCmd.Flags().Bool("notify", false, "是否通知被变更的成员(仅 --members 新格式时生效)")
|
||||
|
||||
drivePermListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "查询协作者列表",
|
||||
Long: `查询文档空间节点的协作者列表。`,
|
||||
Long: `查询文档空间节点的协作者列表,支持分页和角色过滤。
|
||||
|
||||
底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。
|
||||
出参包含 totalCount、hasMore 和 nextToken。
|
||||
当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。`,
|
||||
Example: ` dws drive permission list --node DOC_ID
|
||||
dws drive permission list --node DOC_ID --limit 100 --filter-role MANAGER,EDITOR`,
|
||||
dws drive permission list --node DOC_ID --limit 50 --filter-role MANAGER,EDITOR
|
||||
dws drive permission list --node DOC_ID --next-token <上次返回的 nextToken>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
limit := 0
|
||||
if cmd.Flags().Changed("limit") {
|
||||
limit, _ = cmd.Flags().GetInt("limit")
|
||||
} else if cmd.Flags().Changed("max-results") {
|
||||
limit, _ = cmd.Flags().GetInt("max-results")
|
||||
if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
toolArgs["pageSize"] = size
|
||||
}
|
||||
if limit > 0 {
|
||||
toolArgs["maxResults"] = limit
|
||||
if v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" {
|
||||
toolArgs["nextToken"] = v
|
||||
}
|
||||
if v := mustGetFlag(cmd, "filter-role"); v != "" {
|
||||
toolArgs["filterRoleIds"] = parseRoleList(v)
|
||||
@@ -2251,38 +2318,184 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "filter-role", Property: "filterRoleIds"},
|
||||
{Name: "limit", Property: "maxResults"},
|
||||
// limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize,
|
||||
// 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。
|
||||
{Name: "limit"},
|
||||
{Name: "next-token", Property: "nextToken"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
},
|
||||
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "next-token"},
|
||||
},
|
||||
})
|
||||
drivePermListCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
|
||||
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50")
|
||||
drivePermListCmd.Flags().Int("max-results", 0, "")
|
||||
_ = drivePermListCmd.Flags().MarkHidden("max-results")
|
||||
drivePermListCmd.Flags().String("filter-role", "", "按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
|
||||
drivePermListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken")
|
||||
drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
|
||||
drivePermRemoveCmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "移除协作者权限",
|
||||
Long: `从文档空间节点移除协作成员的权限。`,
|
||||
Example: ` dws drive permission remove --node DOC_ID --users uid1
|
||||
dws drive permission remove --node DOC_ID --users uid1,uid2`,
|
||||
drivePermGetSettingCmd := &cobra.Command{
|
||||
Use: "get-setting",
|
||||
Short: "查询节点权限设置",
|
||||
Long: `查询文档空间节点的权限设置,返回三部分配置:
|
||||
|
||||
- permissionMode: 权限模式(INHERITED 继承上级 / INDEPENDENT 独立管理)
|
||||
- shareScope: 分享范围(可见范围、链接分享设置)
|
||||
- policies: 权限策略列表(水印、组织外分享、成员邀请门槛等)
|
||||
|
||||
查询协作者列表请改用 permission list。`,
|
||||
Example: ` dws drive permission get-setting --node DOC_ID`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
return callMCPToolOnServer("drive", "get_permission_setting", map[string]any{"nodeId": nodeID})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(drivePermGetSettingCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "drive",
|
||||
Name: "get_permission_setting",
|
||||
CanonicalPath: "drive.get_permission_setting",
|
||||
CLIPath: "drive permission get-setting",
|
||||
PrimaryCLIPath: "drive permission get-setting",
|
||||
},
|
||||
Description: "查询文档空间节点的权限设置(权限模式/分享范围/权限策略)",
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"节点权限设置(权限模式/分享范围/权限策略)",
|
||||
"properties":{
|
||||
"docUrl":{"type":"string","description":"当前查询节点的文档访问链接,可直接在浏览器中打开"},
|
||||
"nodeId":{"type":"string","description":"当前查询节点的 nodeId(入参解析后的规范形式)"},
|
||||
"permissionMode":{"type":["string","null"],"enum":["INHERITED","INDEPENDENT",null],"description":"权限模式:INHERITED=继承上级权限配置,INDEPENDENT=独立管理权限;未知时为 null"},
|
||||
"shareScope":{
|
||||
"type":"object",
|
||||
"description":"分享范围设置",
|
||||
"properties":{
|
||||
"visibility":{"type":["string","null"],"enum":["PRIVATE","ORGANIZATION","PUBLIC",null],"description":"PRIVATE=仅指定成员可见,ORGANIZATION=组织内公开,PUBLIC=互联网公开;未知时为 null"},
|
||||
"partnerIncluded":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义,true 表示组织内公开范围包含合作伙伴(含生态组织外部协作成员)。其余场景为 false。"},
|
||||
"defaultRole":{"type":["string","null"],"enum":["READER","DOWNLOADER","EDITOR","MANAGER",null],"description":"仅 visibility=ORGANIZATION 时有意义,通过链接获得访问的默认角色;未下发或不在值域内时为 null"},
|
||||
"canSearch":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义。"},
|
||||
"canRecommend":{"type":"boolean","description":"仅 visibility=ORGANIZATION 时有意义。"},
|
||||
"linkShare":{
|
||||
"type":"object",
|
||||
"description":"链接分享设置;仅开启链接分享时返回,未开启时该字段不返回",
|
||||
"properties":{
|
||||
"requirePassword":{"type":"boolean","description":"true 表示通过链接访问需要提供密码。密码明文不会返回。"},
|
||||
"expireAt":{"type":["integer","null"],"description":"秒级 Unix 时间戳,未设置过期时为 null。"},
|
||||
"expireDays":{"type":["integer","null"],"description":"设置的有效天数,未设置时为 null。"},
|
||||
"forCurrentNode":{"type":"boolean","description":"true 表示该分享范围仅作用于当前节点;false 表示作用于当前节点及其子节点。"}
|
||||
},
|
||||
"additionalProperties":true
|
||||
}
|
||||
},
|
||||
"additionalProperties":true
|
||||
},
|
||||
"policies":{
|
||||
"type":"array",
|
||||
"description":"仅包含支持的策略项,未下发或不受支持的策略不会返回;node_spread_scope 仅文件夹类节点返回;allowedValues 为当前可设置的取值,disabledValues 为当前不可设置的取值及原因,两者互斥",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"description":"权限策略项",
|
||||
"properties":{
|
||||
"code":{"type":"string","enum":["external_share","external_share_manager_only","member_invite","member_invite_org_only","comment","permission_apply","external_permission_apply","watermark","node_spread","online_content_copy","node_move_forbidden","node_spread_scope"],"description":"external_share=添加企业外协作者;external_share_manager_only=企业外协作者仅限管理员;member_invite=谁可以添加协作者;member_invite_org_only=仅企业内用户可添加协作者;comment=谁可以评论;permission_apply=权限申请;external_permission_apply=组织外权限申请;watermark=显示水印;node_spread=谁可以下载、创建副本、打印;online_content_copy=谁可以复制文档内容;node_move_forbidden=禁止移动;node_spread_scope=下载与传播生效范围(仅文件夹类节点)。"},
|
||||
"name":{"type":"string","description":"策略的中文名称,文案与产品权限设置页一致;为确定性字段,只要该策略返回就必带"},
|
||||
"description":{"type":"string","description":"策略的含义说明,解释该策略管控的行为及各取值的语义;为确定性字段,只要该策略返回就必带"},
|
||||
"value":{"type":["string","null"],"description":"取值随策略类型不同:开关型(external_share、external_share_manager_only、member_invite_org_only、permission_apply、external_permission_apply、watermark、node_move_forbidden)为 ENABLED/DISABLED;阈值型(member_invite、comment)为 READER_AND_ABOVE/DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE,阈值型(node_spread、online_content_copy)为 DOWNLOADER_AND_ABOVE/EDITOR_AND_ABOVE/MANAGER_AND_ABOVE/NOBODY,均表示不低于该角色才允许对应操作,NOBODY 表示所有人禁止;二值型(node_spread_scope):ALL_NODES=下载与传播限制对所有文档生效,PREVIEWABLE_ONLY=仅对可预览的文档(在线文档、图片视频等)生效;未知值时为 null"},
|
||||
"disabledValues":{
|
||||
"type":"array",
|
||||
"description":"该策略当前不可设置的取值及禁用原因(与 allowedValues 互斥);为确定性字段,恒返回,无被禁取值时为空数组",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"value":{"type":"string","description":"被禁档位的取值(与 value 同一值域)"},
|
||||
"reason":{"type":["string","null"],"description":"服务端按请求语言返回的禁用原因文案,仅供展示理解,可为 null"}
|
||||
},
|
||||
"required":["value"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
},
|
||||
"allowedValues":{"type":["array","null"],"items":{"type":"string"},"description":"该策略当前可设置的取值(与 value 同一值域),未下发时为 null"}
|
||||
},
|
||||
"required":["code","name","description","disabledValues"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required":["docUrl","nodeId","shareScope","policies"],
|
||||
"additionalProperties":true
|
||||
}`),
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "drive", RPCName: "get_permission_setting"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询文档空间节点的权限设置(权限模式/分享范围/权限策略)",
|
||||
UseWhen: []string{"查看节点权限模式/分享范围/水印等权限策略配置时"},
|
||||
AvoidWhen: []string{
|
||||
"查协作者清单用 permission list",
|
||||
"查可申请角色与审批人用 permission apply-info",
|
||||
},
|
||||
Examples: []string{"dws drive permission get-setting --node <ID> --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId"},
|
||||
},
|
||||
},
|
||||
})
|
||||
drivePermGetSettingCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
|
||||
drivePermRemoveCmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "移除协作者权限",
|
||||
Long: `从文档空间节点移除协作成员的权限。
|
||||
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,只需 type 和 id(USER/DEPT/TAG 还需 corpId)
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId`,
|
||||
Example: ` dws drive permission remove --node DOC_ID --users uid1
|
||||
dws drive permission remove --node DOC_ID --users uid1,uid2
|
||||
dws drive permission remove --node DOC_ID --members '[{"type":"USER","id":"uid1","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"userIds": userIds,
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, true)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
} else {
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -2292,8 +2505,11 @@ func newDriveCommand() *cobra.Command {
|
||||
}
|
||||
DeclareLeafMetadata(drivePermRemoveCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
// 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销多个
|
||||
// 成员的访问,部门/群聊/角色组还可能间接影响大量用户,与删除同级的
|
||||
// destructive 入口,必须经过用户确认(--yes 或交互 yes)。
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
@@ -2319,6 +2535,7 @@ func newDriveCommand() *cobra.Command {
|
||||
Examples: []string{"dws drive permission remove --node <ID> --users uid1 --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -2326,13 +2543,14 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
drivePermRemoveCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
drivePermRemoveCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
|
||||
drivePermRemoveCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
|
||||
drivePermRemoveCmd.Flags().String("user", "", "")
|
||||
_ = drivePermRemoveCmd.Flags().MarkHidden("user")
|
||||
drivePermRemoveCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥")
|
||||
drivePermRemoveCmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
|
||||
// permission 子命令 --node 隐藏别名(保持与迁移前 doc 命令一致)
|
||||
for _, c := range []*cobra.Command{drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermRemoveCmd} {
|
||||
for _, c := range []*cobra.Command{drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermGetSettingCmd, drivePermRemoveCmd} {
|
||||
c.Flags().String("url", "", "")
|
||||
c.Flags().String("id", "", "")
|
||||
c.Flags().String("node-id", "", "")
|
||||
@@ -2564,7 +2782,7 @@ func newDriveCommand() *cobra.Command {
|
||||
drivePermApplyCmd.Flags().String("notify-mode", "", "通知方式: DEFAULT / MSG_ACCOUNT / SINGLE_CHAT")
|
||||
drivePermApplyCmd.Flags().String("reason", "", "申请理由,最长 200 字符")
|
||||
|
||||
drivePermissionCmd.AddCommand(drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermRemoveCmd, drivePermTransferOwnerCmd, drivePermApplyInfoCmd, drivePermApplyCmd)
|
||||
drivePermissionCmd.AddCommand(drivePermAddCmd, drivePermUpdateCmd, drivePermListCmd, drivePermGetSettingCmd, drivePermRemoveCmd, drivePermTransferOwnerCmd, drivePermApplyInfoCmd, drivePermApplyCmd)
|
||||
|
||||
// --node 隐藏别名(保持与迁移前 doc 命令一致)
|
||||
driveNodeAliasCmds := []*cobra.Command{
|
||||
|
||||
@@ -108,7 +108,7 @@ func driveFolderPushResultSpec() *contract.ResultSpec {
|
||||
"preview_kind":{"type":"string","description":"预览类型","enum":["plan"]},
|
||||
"operation":{"type":"string","description":"预览的命令"},
|
||||
"if_exists":{"type":"string","description":"远端同名文件处理策略"},
|
||||
"plan":{"type":"object","description":"预览的推送计划","properties":{"summary":{"type":"object","description":"计划动作计数","properties":{"uploaded":{"type":"integer","description":"计划上传或覆盖的文件数"},"skipped":{"type":"integer","description":"计划跳过的文件数"},"failed":{"type":"integer","description":"预检失败的条目数"},"aborted":{"type":"boolean","description":"计划是否会中止"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false}
|
||||
"plan":{"type":"object","description":"预览的推送计划","properties":{"summary":{"type":"object","description":"计划动作计数;兼容执行计数 uploaded/skipped 在预览中保持为零","properties":{"uploaded":{"type":"integer","description":"兼容字段;预览未执行上传,固定为零"},"skipped":{"type":"integer","description":"兼容字段;预览未执行跳过,固定为零"},"failed":{"type":"integer","description":"预检失败的条目数"},"aborted":{"type":"boolean","description":"计划是否会中止"},"planned_uploads":{"type":"integer","description":"计划上传或覆盖的文件数"},"planned_skips":{"type":"integer","description":"计划按策略跳过的文件数"},"planned_folders":{"type":"integer","description":"计划创建的远端目录数"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"未执行的 planned_* 计划动作或已确认的预检失败"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false}
|
||||
},
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
@@ -134,7 +134,7 @@ func driveFolderSyncResultSpec() *contract.ResultSpec {
|
||||
"executed":{"type":"boolean","description":"是否执行了写操作","const":false},
|
||||
"preview_kind":{"type":"string","description":"预览类型","enum":["plan"]},
|
||||
"operation":{"type":"string","description":"预览的命令","const":"drive sync"},
|
||||
"plan":{"type":"object","description":"预览的同步计划","properties":{"detection":{"type":"string","description":"计划使用的差异检测模式","enum":["exact","quick"]},"diff":{"type":"object","description":"计划执行前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},"summary":{"type":"object","description":"计划动作计数","properties":{"pulled":{"type":"integer","description":"计划拉取的条目数"},"pushed":{"type":"integer","description":"计划推送的条目数"},"skipped":{"type":"integer","description":"计划跳过的条目数"},"failed":{"type":"integer","description":"预检失败的条目数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["detection","diff","summary","items"],"additionalProperties":false}
|
||||
"plan":{"type":"object","description":"预览的同步计划","properties":{"detection":{"type":"string","description":"计划使用的差异检测模式","enum":["exact","quick"]},"diff":{"type":"object","description":"计划执行前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},"summary":{"type":"object","description":"计划动作计数;兼容执行计数 pulled/pushed/skipped 在预览中保持为零","properties":{"pulled":{"type":"integer","description":"兼容字段;预览未执行拉取,固定为零"},"pushed":{"type":"integer","description":"兼容字段;预览未执行推送,固定为零"},"skipped":{"type":"integer","description":"兼容字段;预览未执行跳过,固定为零"},"failed":{"type":"integer","description":"预检失败的条目数"},"planned_pulls":{"type":"integer","description":"计划拉取的文件数"},"planned_pushes":{"type":"integer","description":"计划上传或覆盖的文件数"},"planned_skips":{"type":"integer","description":"计划按策略跳过的文件数"},"planned_folders":{"type":"integer","description":"计划创建的远端目录数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"未执行的 planned_* 计划动作、待决策动作或预检失败"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["detection","diff","summary","items"],"additionalProperties":false}
|
||||
},
|
||||
"oneOf":[
|
||||
{"required":["detection","diff","summary","items"]},
|
||||
|
||||
@@ -93,10 +93,26 @@ func TestCrossPlatformCoverageDriveFolderContractsPublishResultAndDryRun(t *test
|
||||
if name == "sync" {
|
||||
assertDriveSyncResultSchema(t, final.Result.DataSchema)
|
||||
}
|
||||
if name == "push" {
|
||||
assertDrivePushResultSchema(t, final.Result.DataSchema)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertDrivePushResultSchema(t *testing.T, raw json.RawMessage) {
|
||||
t.Helper()
|
||||
properties := resultSchemaProperties(t, raw)
|
||||
plan := schemaObjectProperty(t, properties, "plan")
|
||||
summary := schemaObjectProperty(t, schemaProperties(t, plan), "summary")
|
||||
summaryProperties := schemaProperties(t, summary)
|
||||
for _, name := range []string{"planned_uploads", "planned_skips", "planned_folders"} {
|
||||
if _, ok := summaryProperties[name]; !ok {
|
||||
t.Fatalf("push plan summary schema is missing %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resultSchemaProperties(t *testing.T, raw json.RawMessage) map[string]any {
|
||||
t.Helper()
|
||||
var schema map[string]any
|
||||
@@ -133,6 +149,13 @@ func assertDriveSyncResultSchema(t *testing.T, raw json.RawMessage) {
|
||||
planProperties := schemaProperties(t, plan)
|
||||
planDiff := schemaObjectProperty(t, planProperties, "diff")
|
||||
assertSchemaRequired(t, planDiff, "new_local", "new_remote", "modified", "unchanged", "unknown")
|
||||
planSummary := schemaObjectProperty(t, planProperties, "summary")
|
||||
planSummaryProperties := schemaProperties(t, planSummary)
|
||||
for _, name := range []string{"planned_pulls", "planned_pushes", "planned_skips", "planned_folders"} {
|
||||
if _, ok := planSummaryProperties[name]; !ok {
|
||||
t.Fatalf("sync plan summary schema is missing %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// 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.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
)
|
||||
|
||||
// ── drive permission get-setting:跨产品路由与 --node 别名归一化 ──
|
||||
|
||||
func TestCrossPlatformCoverageDrivePermissionGetSettingRoutesToDrive(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
|
||||
"permission", "get-setting", "--node", "node-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want exactly one", caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "drive" || call.toolName != "get_permission_setting" {
|
||||
t.Fatalf("call = %#v", call)
|
||||
}
|
||||
if len(call.args) != 1 || call.args["nodeId"] != "node-1" {
|
||||
t.Fatalf("args = %#v, want only nodeId=node-1", call.args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePermissionGetSettingHiddenAliases(t *testing.T) {
|
||||
for _, alias := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
|
||||
"permission", "get-setting", "--"+alias, "node-alias")
|
||||
if err != nil {
|
||||
t.Fatalf("alias --%s: %v", alias, err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("alias --%s calls = %#v, want exactly one", alias, caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "drive" || call.toolName != "get_permission_setting" {
|
||||
t.Fatalf("alias --%s call = %#v", alias, call)
|
||||
}
|
||||
if call.args["nodeId"] != "node-alias" {
|
||||
t.Fatalf("alias --%s args = %#v, want nodeId=node-alias", alias, call.args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePermissionGetSettingRequiresNode(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
|
||||
"permission", "get-setting")
|
||||
if err == nil || !strings.Contains(err.Error(), "flag --node is required") {
|
||||
t.Fatalf("err = %v, want flag --node is required", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("calls = %#v, want none before required-flag validation", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ── drive permission get-setting:ResultSpec 返回值契约 ──
|
||||
|
||||
func TestCrossPlatformCoverageDrivePermissionGetSettingResultContract(t *testing.T) {
|
||||
drive := newDriveCommand()
|
||||
leaf, _, err := drive.Find([]string{"permission", "get-setting"})
|
||||
if err != nil || leaf == nil {
|
||||
t.Fatalf("find drive permission get-setting: command=%v err=%v", leaf, err)
|
||||
}
|
||||
final, ok := contractfinal.RuntimeContractFinal(leaf)
|
||||
if !ok || final.Identity == nil || final.Identity.CanonicalPath != "drive.get_permission_setting" {
|
||||
t.Fatalf("get-setting ContractFinal identity = %#v, found = %v", final.Identity, ok)
|
||||
}
|
||||
if final.Result == nil {
|
||||
t.Fatal("get-setting final Result is nil")
|
||||
}
|
||||
wantOutcomes := []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}
|
||||
if !reflect.DeepEqual(final.Result.Outcomes, wantOutcomes) {
|
||||
t.Fatalf("outcomes = %#v, want %#v", final.Result.Outcomes, wantOutcomes)
|
||||
}
|
||||
|
||||
var root map[string]any
|
||||
if err := json.Unmarshal(final.Result.DataSchema, &root); err != nil {
|
||||
t.Fatalf("result data_schema is not JSON: %v\n%s", err, final.Result.DataSchema)
|
||||
}
|
||||
assertSchemaRequired(t, root, "docUrl", "nodeId", "shareScope", "policies")
|
||||
properties := resultSchemaProperties(t, final.Result.DataSchema)
|
||||
if got := sortedContractSchemaKeys(properties); !reflect.DeepEqual(got, []string{"docUrl", "nodeId", "permissionMode", "policies", "shareScope"}) {
|
||||
t.Fatalf("result properties = %#v", got)
|
||||
}
|
||||
|
||||
permissionMode, ok := properties["permissionMode"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("permissionMode = %#v, want schema object", properties["permissionMode"])
|
||||
}
|
||||
if got := schemaEnumValues(t, permissionMode); !reflect.DeepEqual(got, []string{"INHERITED", "INDEPENDENT", "<null>"}) {
|
||||
t.Fatalf("permissionMode enum = %#v", got)
|
||||
}
|
||||
if types, ok := permissionMode["type"].([]any); !ok || len(types) != 2 || types[0] != "string" || types[1] != "null" {
|
||||
t.Fatalf("permissionMode type = %#v, want [string null]", permissionMode["type"])
|
||||
}
|
||||
|
||||
shareScope := schemaObjectProperty(t, properties, "shareScope")
|
||||
shareScopeProperties := schemaProperties(t, shareScope)
|
||||
if got := sortedContractSchemaKeys(shareScopeProperties); !reflect.DeepEqual(got, []string{"canRecommend", "canSearch", "defaultRole", "linkShare", "partnerIncluded", "visibility"}) {
|
||||
t.Fatalf("shareScope properties = %#v", got)
|
||||
}
|
||||
if _, ok := shareScope["required"]; ok {
|
||||
t.Fatalf("shareScope required = %#v, want none (linkShare is only returned when link sharing is configured)", shareScope["required"])
|
||||
}
|
||||
if got := schemaEnumValues(t, shareScopeProperties["visibility"].(map[string]any)); !reflect.DeepEqual(got, []string{"PRIVATE", "ORGANIZATION", "PUBLIC", "<null>"}) {
|
||||
t.Fatalf("visibility enum = %#v", got)
|
||||
}
|
||||
if got := schemaEnumValues(t, shareScopeProperties["defaultRole"].(map[string]any)); !reflect.DeepEqual(got, []string{"READER", "DOWNLOADER", "EDITOR", "MANAGER", "<null>"}) {
|
||||
t.Fatalf("defaultRole enum = %#v", got)
|
||||
}
|
||||
linkShareProperties := schemaProperties(t, schemaObjectProperty(t, shareScopeProperties, "linkShare"))
|
||||
if got := sortedContractSchemaKeys(linkShareProperties); !reflect.DeepEqual(got, []string{"expireAt", "expireDays", "forCurrentNode", "requirePassword"}) {
|
||||
t.Fatalf("linkShare properties = %#v", got)
|
||||
}
|
||||
|
||||
policies, ok := properties["policies"].(map[string]any)
|
||||
if !ok || policies["type"] != "array" {
|
||||
t.Fatalf("policies = %#v, want array schema", properties["policies"])
|
||||
}
|
||||
policiesDescription, _ := policies["description"].(string)
|
||||
for _, fragment := range []string{"未下发或不受支持的策略不会返回", "node_spread_scope 仅文件夹类节点返回", "两者互斥"} {
|
||||
if !strings.Contains(policiesDescription, fragment) {
|
||||
t.Fatalf("policies description missing %q: %s", fragment, policiesDescription)
|
||||
}
|
||||
}
|
||||
items, ok := policies["items"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("policies items = %#v", policies["items"])
|
||||
}
|
||||
assertSchemaRequired(t, items, "code", "name", "description", "disabledValues")
|
||||
itemProperties := schemaProperties(t, items)
|
||||
if got := sortedContractSchemaKeys(itemProperties); !reflect.DeepEqual(got, []string{"allowedValues", "code", "description", "disabledValues", "name", "value"}) {
|
||||
t.Fatalf("policy item properties = %#v", got)
|
||||
}
|
||||
code, ok := itemProperties["code"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("code = %#v", itemProperties["code"])
|
||||
}
|
||||
if got := schemaEnumValues(t, code); !reflect.DeepEqual(got, []string{
|
||||
"external_share", "external_share_manager_only", "member_invite", "member_invite_org_only",
|
||||
"comment", "permission_apply", "external_permission_apply", "watermark", "node_spread",
|
||||
"online_content_copy", "node_move_forbidden", "node_spread_scope",
|
||||
}) {
|
||||
t.Fatalf("code enum = %#v", got)
|
||||
}
|
||||
value, ok := itemProperties["value"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("value = %#v", itemProperties["value"])
|
||||
}
|
||||
valueDescription, _ := value["description"].(string)
|
||||
for _, fragment := range []string{"ENABLED/DISABLED", "READER_AND_ABOVE", "NOBODY", "ALL_NODES", "PREVIEWABLE_ONLY", "不低于该角色才允许", "所有人禁止", "限制对所有文档生效", "仅对可预览的文档"} {
|
||||
if !strings.Contains(valueDescription, fragment) {
|
||||
t.Fatalf("value description missing %q: %s", fragment, valueDescription)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"name", "description"} {
|
||||
entry, ok := itemProperties[field].(map[string]any)
|
||||
if !ok || entry["type"] != "string" {
|
||||
t.Fatalf("policy item %s = %#v, want string schema", field, itemProperties[field])
|
||||
}
|
||||
entryDescription, _ := entry["description"].(string)
|
||||
if !strings.Contains(entryDescription, "确定性字段") || !strings.Contains(entryDescription, "只要该策略返回就必带") {
|
||||
t.Fatalf("policy item %s description = %q", field, entryDescription)
|
||||
}
|
||||
}
|
||||
disabledValues, ok := itemProperties["disabledValues"].(map[string]any)
|
||||
if !ok || disabledValues["type"] != "array" {
|
||||
t.Fatalf("disabledValues = %#v, want array schema", itemProperties["disabledValues"])
|
||||
}
|
||||
disabledValuesDescription, _ := disabledValues["description"].(string)
|
||||
for _, fragment := range []string{"与 allowedValues 互斥", "恒返回", "空数组"} {
|
||||
if !strings.Contains(disabledValuesDescription, fragment) {
|
||||
t.Fatalf("disabledValues description missing %q: %s", fragment, disabledValuesDescription)
|
||||
}
|
||||
}
|
||||
disabledItems, ok := disabledValues["items"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("disabledValues items = %#v", disabledValues["items"])
|
||||
}
|
||||
assertSchemaRequired(t, disabledItems, "value")
|
||||
disabledItemProperties := schemaProperties(t, disabledItems)
|
||||
if got := sortedContractSchemaKeys(disabledItemProperties); !reflect.DeepEqual(got, []string{"reason", "value"}) {
|
||||
t.Fatalf("disabledValues item properties = %#v", got)
|
||||
}
|
||||
if disabledValue, ok := disabledItemProperties["value"].(map[string]any); !ok || disabledValue["type"] != "string" {
|
||||
t.Fatalf("disabledValues value = %#v, want string schema", disabledItemProperties["value"])
|
||||
}
|
||||
reason, ok := disabledItemProperties["reason"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("disabledValues reason = %#v", disabledItemProperties["reason"])
|
||||
}
|
||||
if types, ok := reason["type"].([]any); !ok || len(types) != 2 || types[0] != "string" || types[1] != "null" {
|
||||
t.Fatalf("disabledValues reason type = %#v, want [string null]", reason["type"])
|
||||
}
|
||||
allowedValues, ok := itemProperties["allowedValues"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("allowedValues = %#v", itemProperties["allowedValues"])
|
||||
}
|
||||
if allowedValuesDescription, _ := allowedValues["description"].(string); !strings.Contains(allowedValuesDescription, "与 value 同一值域") {
|
||||
t.Fatalf("allowedValues description = %q", allowedValues["description"])
|
||||
}
|
||||
}
|
||||
|
||||
func schemaEnumValues(t *testing.T, property map[string]any) []string {
|
||||
t.Helper()
|
||||
raw, ok := property["enum"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema enum = %#v, want array", property["enum"])
|
||||
}
|
||||
values := make([]string, 0, len(raw))
|
||||
for _, value := range raw {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
values = append(values, typed)
|
||||
case nil:
|
||||
values = append(values, "<null>")
|
||||
default:
|
||||
t.Fatalf("schema enum value = %#v, want string or null", value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -39,6 +39,11 @@ const (
|
||||
pushActionSkipped = "skipped" // 按 --if-exists 跳过
|
||||
pushActionFolderCreated = "folder_created" // 新建远端目录(不计入 uploaded)
|
||||
pushActionFailed = "failed"
|
||||
|
||||
pushActionPlannedUpload = "planned_upload"
|
||||
pushActionPlannedOverwrite = "planned_overwrite"
|
||||
pushActionPlannedSkip = "planned_skip"
|
||||
pushActionPlannedFolderCreate = "planned_folder_create"
|
||||
)
|
||||
|
||||
// localPushFile 描述一个待推送的本地常规文件。
|
||||
@@ -79,10 +84,13 @@ type drivePushItem struct {
|
||||
|
||||
// drivePushSummary 是各动作的计数汇总。uploaded 同时统计新建与覆盖。
|
||||
type drivePushSummary struct {
|
||||
Uploaded int `json:"uploaded"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Aborted bool `json:"aborted"`
|
||||
Uploaded int `json:"uploaded"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Aborted bool `json:"aborted"`
|
||||
PlannedUploads int `json:"planned_uploads,omitempty"`
|
||||
PlannedSkips int `json:"planned_skips,omitempty"`
|
||||
PlannedFolders int `json:"planned_folders,omitempty"`
|
||||
}
|
||||
|
||||
type drivePushResult struct {
|
||||
@@ -380,7 +388,8 @@ func printDrivePushDryRunWithPreflight(ifExists string, remoteFiles map[string]*
|
||||
continue
|
||||
}
|
||||
plannedFolders[dir] = "dry-run-planned-folder"
|
||||
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionFolderCreated})
|
||||
plan.Summary.PlannedFolders++
|
||||
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionPlannedFolderCreate})
|
||||
}
|
||||
for _, lf := range localFiles {
|
||||
size := lf.Size
|
||||
@@ -390,25 +399,25 @@ func printDrivePushDryRunWithPreflight(ifExists string, remoteFiles map[string]*
|
||||
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: "父目录未能创建"})
|
||||
continue
|
||||
}
|
||||
action := pushActionUploaded
|
||||
action := pushActionPlannedUpload
|
||||
if rf, exists := remoteFiles[lf.RelPath]; exists {
|
||||
switch ifExists {
|
||||
case ifExistsSkip:
|
||||
action = pushActionSkipped
|
||||
action = pushActionPlannedSkip
|
||||
case ifExistsSmart:
|
||||
if rf.ModifiedTimeValid && rf.ModifiedTime >= lf.ModTimeMillis {
|
||||
action = pushActionSkipped
|
||||
action = pushActionPlannedSkip
|
||||
} else {
|
||||
action = pushActionOverwritten
|
||||
action = pushActionPlannedOverwrite
|
||||
}
|
||||
case ifExistsOverwrite:
|
||||
action = pushActionOverwritten
|
||||
action = pushActionPlannedOverwrite
|
||||
}
|
||||
}
|
||||
if action == pushActionSkipped {
|
||||
plan.Summary.Skipped++
|
||||
if action == pushActionPlannedSkip {
|
||||
plan.Summary.PlannedSkips++
|
||||
} else {
|
||||
plan.Summary.Uploaded++
|
||||
plan.Summary.PlannedUploads++
|
||||
}
|
||||
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: action, SizeBytes: &size})
|
||||
}
|
||||
@@ -811,7 +820,10 @@ func defaultPushPutOpenedFile(ctx context.Context, url string, headers map[strin
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("failed to seek upload file: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, file)
|
||||
// net/http owns and closes Request.Body after RoundTrip. The *os.File belongs
|
||||
// to pushUploadFilePinned, which must stat it after PUT before commit_upload;
|
||||
// wrap it in a no-op closer so HTTP completion cannot close that shared handle.
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, io.NopCloser(file))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create upload request: %w", err)
|
||||
}
|
||||
|
||||
@@ -421,6 +421,29 @@ func TestCrossPlatformCoverageDrivePushFinalDefaultOpenedPUTAndCommit(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePushOpenedPUTKeepsCallerOwnedHandle(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, request.Body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "payload")
|
||||
mustWrite(t, path, "payload")
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := defaultPushPutOpenedFile(context.Background(), server.URL, nil, file, 7); err != nil {
|
||||
t.Fatalf("PUT failed: %v", err)
|
||||
}
|
||||
if _, err := file.Stat(); err != nil {
|
||||
t.Fatalf("HTTP PUT closed its caller-owned file handle: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncFinalPreflightAndKeepBothErrors(t *testing.T) {
|
||||
t.Run("local target preflight lstat error and directory", func(t *testing.T) {
|
||||
_, root, _ := openDriveFinalCoverageRoot(t)
|
||||
|
||||
@@ -65,6 +65,14 @@ const (
|
||||
syncActionRenamedLocal = "renamed_local"
|
||||
syncActionSkipped = "skipped"
|
||||
syncActionFailed = "failed"
|
||||
|
||||
syncActionPlannedDownload = "planned_download"
|
||||
syncActionPlannedUpload = "planned_upload"
|
||||
syncActionPlannedOverwrite = "planned_overwrite"
|
||||
syncActionPlannedFolderCreate = "planned_folder_create"
|
||||
syncActionPlannedRenameLocal = "planned_rename_local"
|
||||
syncActionPlannedSkip = "planned_skip"
|
||||
syncActionDecisionRequired = "decision_required"
|
||||
)
|
||||
|
||||
// driveSyncItem 是输出 items[] 中每条操作的明细。
|
||||
@@ -77,10 +85,14 @@ type driveSyncItem struct {
|
||||
|
||||
// driveSyncSummary 是各方向的计数汇总。
|
||||
type driveSyncSummary struct {
|
||||
Pulled int `json:"pulled"`
|
||||
Pushed int `json:"pushed"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Pulled int `json:"pulled"`
|
||||
Pushed int `json:"pushed"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
PlannedPulls int `json:"planned_pulls,omitempty"`
|
||||
PlannedPushes int `json:"planned_pushes,omitempty"`
|
||||
PlannedSkips int `json:"planned_skips,omitempty"`
|
||||
PlannedFolders int `json:"planned_folders,omitempty"`
|
||||
}
|
||||
|
||||
// driveSyncDiff 是本次同步前算出的五类差异(与 status 同源)。
|
||||
@@ -267,6 +279,12 @@ func runDriveSync(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
// --dry-run:只算差异、不执行任何同步动作。
|
||||
if deps.Caller.DryRun() {
|
||||
if len(preflight) == 0 && res.Summary.Failed == 0 {
|
||||
appendDriveSyncDryRunPlan(&res, driveSyncDryRunPlanInput{
|
||||
LocalDirs: localDirs, LocalByRel: localByRel, RemoteFiles: remoteFiles, RemoteFolders: remoteFolders,
|
||||
NewLocal: newLocal, NewRemote: newRemote, Modified: modified, Unknown: unknown, OnConflict: onConflict,
|
||||
})
|
||||
}
|
||||
return deps.Out.PrintJSON(driveSyncDryRunResult{
|
||||
DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive sync", Plan: res,
|
||||
})
|
||||
@@ -394,6 +412,104 @@ func runDriveSync(cmd *cobra.Command, _ []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type driveSyncDryRunPlanInput struct {
|
||||
LocalDirs []string
|
||||
LocalByRel map[string]localPushFile
|
||||
RemoteFiles map[string]*remoteFile
|
||||
RemoteFolders map[string]string
|
||||
NewLocal []string
|
||||
NewRemote []string
|
||||
Modified []string
|
||||
Unknown []string
|
||||
OnConflict string
|
||||
}
|
||||
|
||||
func appendDriveSyncDryRunPlan(res *driveSyncResult, input driveSyncDryRunPlanInput) {
|
||||
for _, rel := range input.Unknown {
|
||||
res.Summary.PlannedSkips++
|
||||
res.Items = append(res.Items, driveSyncItem{
|
||||
RelPath: rel, Action: syncActionPlannedSkip, Direction: syncDirectionConflict,
|
||||
Error: "远端无可靠 md5,内容无法核对,执行时会跳过(可改用 --quick 按 modified_time 比对)",
|
||||
})
|
||||
}
|
||||
|
||||
plannedFolders := make(map[string]string, len(input.RemoteFolders)+len(input.LocalDirs))
|
||||
for rel, fileID := range input.RemoteFolders {
|
||||
plannedFolders[rel] = fileID
|
||||
}
|
||||
for _, dir := range input.LocalDirs {
|
||||
if _, ok := plannedFolders[dir]; ok {
|
||||
continue
|
||||
}
|
||||
parentRel, _ := splitRel(dir)
|
||||
if plannedFolders[parentRel] == "" {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"})
|
||||
continue
|
||||
}
|
||||
plannedFolders[dir] = "dry-run-planned-folder"
|
||||
res.Summary.PlannedFolders++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionPlannedFolderCreate, Direction: syncDirectionPush})
|
||||
}
|
||||
|
||||
for _, rel := range input.NewRemote {
|
||||
res.Summary.PlannedPulls++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull})
|
||||
}
|
||||
for _, rel := range input.NewLocal {
|
||||
parentRel, _ := splitRel(rel)
|
||||
if plannedFolders[parentRel] == "" {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"})
|
||||
continue
|
||||
}
|
||||
res.Summary.PlannedPushes++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedUpload, Direction: syncDirectionPush})
|
||||
}
|
||||
|
||||
occupied := make(map[string]bool, len(input.LocalByRel)+len(input.LocalDirs)+len(input.RemoteFiles)+len(input.RemoteFolders))
|
||||
for rel := range input.LocalByRel {
|
||||
occupied[rel] = true
|
||||
}
|
||||
for _, rel := range input.LocalDirs {
|
||||
occupied[rel] = true
|
||||
}
|
||||
for rel := range input.RemoteFiles {
|
||||
occupied[rel] = true
|
||||
}
|
||||
for rel := range input.RemoteFolders {
|
||||
if rel != "" {
|
||||
occupied[rel] = true
|
||||
}
|
||||
}
|
||||
for _, rel := range input.Modified {
|
||||
switch input.OnConflict {
|
||||
case syncConflictRemoteWins:
|
||||
res.Summary.PlannedPulls++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull})
|
||||
case syncConflictLocalWins:
|
||||
res.Summary.PlannedPushes++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedOverwrite, Direction: syncDirectionConflict})
|
||||
case syncConflictKeepBoth:
|
||||
candidate := driveSyncSuffixedRel(rel, input.RemoteFiles[rel].FileID, occupied)
|
||||
occupied[candidate] = true
|
||||
res.Summary.PlannedPulls++
|
||||
res.Items = append(res.Items,
|
||||
driveSyncItem{RelPath: candidate, Action: syncActionPlannedRenameLocal, Direction: syncDirectionConflict},
|
||||
driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull},
|
||||
)
|
||||
case syncConflictAsk:
|
||||
res.Items = append(res.Items, driveSyncItem{
|
||||
RelPath: rel, Action: syncActionDecisionRequired, Direction: syncDirectionConflict,
|
||||
Error: "执行时需要交互选择 remote-wins、local-wins、keep-both 或 skip",
|
||||
})
|
||||
default:
|
||||
res.Summary.PlannedSkips++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedSkip, Direction: syncDirectionConflict})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncPathBlockedByTypeConflict 判断 rel 本身或任一祖先是否是文件/目录类型冲突根。
|
||||
// 祖先检查保证冲突目录下的本地、远端后代不会绕过根路径的 fail-closed 结论。
|
||||
func syncPathBlockedByTypeConflict(rel string, conflicts map[string]string) bool {
|
||||
|
||||
@@ -3,6 +3,7 @@ package helpers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -189,6 +190,116 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlansWithoutRemoteWrites(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePushDryRunPublishesPlannedNotExecutedActions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "local.txt"), "local")
|
||||
caller := syncCaller(nil)
|
||||
caller.dryRun = true
|
||||
|
||||
var out bytes.Buffer
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive", "push"})
|
||||
cmd := findDriveSubcommand(t, "push")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("dry-run push: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode dry-run push: %v\n%s", err, out.String())
|
||||
}
|
||||
plan := payload["plan"].(map[string]any)
|
||||
summary := plan["summary"].(map[string]any)
|
||||
if summary["uploaded"] != float64(0) || summary["planned_uploads"] != float64(1) {
|
||||
t.Fatalf("dry-run summary reports execution instead of a plan: %#v", summary)
|
||||
}
|
||||
items := plan["items"].([]any)
|
||||
if got := items[0].(map[string]any)["action"]; got != "planned_upload" {
|
||||
t.Fatalf("dry-run action = %v, want planned_upload", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncDryRunReturnsRealActionPlan(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "local.txt"), "local")
|
||||
mustWrite(t, filepath.Join(dir, "shared.txt"), "shared")
|
||||
if err := os.Mkdir(filepath.Join(dir, "empty-folder"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2},{"name":"shared.txt","type":"file","fileId":"S","modifyTime":2}],"nextToken":""}}`,
|
||||
})
|
||||
caller.dryRun = true
|
||||
|
||||
var out bytes.Buffer
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive", "sync"})
|
||||
cmd := findDriveSubcommand(t, "sync")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("dry-run sync: %v", err)
|
||||
}
|
||||
|
||||
var payload driveSyncDryRunResult
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode dry-run sync: %v\n%s", err, out.String())
|
||||
}
|
||||
if payload.Executed || payload.Plan.Summary.Pulled != 0 || payload.Plan.Summary.Pushed != 0 || payload.Plan.Summary.Skipped != 0 {
|
||||
t.Fatalf("dry-run plan summary = %+v, executed=%v", payload.Plan.Summary, payload.Executed)
|
||||
}
|
||||
if payload.Plan.Summary.PlannedPulls != 1 || payload.Plan.Summary.PlannedPushes != 1 ||
|
||||
payload.Plan.Summary.PlannedSkips != 1 || payload.Plan.Summary.PlannedFolders != 1 {
|
||||
t.Fatalf("dry-run planned summary = %+v", payload.Plan.Summary)
|
||||
}
|
||||
actions := map[string]int{}
|
||||
for _, item := range payload.Plan.Items {
|
||||
actions[item.Action]++
|
||||
}
|
||||
if len(payload.Plan.Items) != 4 || actions["planned_download"] != 1 || actions["planned_upload"] != 1 ||
|
||||
actions["planned_skip"] != 1 || actions["planned_folder_create"] != 1 {
|
||||
t.Fatalf("dry-run action plan = %#v", payload.Plan.Items)
|
||||
}
|
||||
for _, tool := range []string{"download_file", "get_upload_info", "commit_upload", "create_folder"} {
|
||||
if calls := caller.callsFor(tool); len(calls) != 0 {
|
||||
t.Fatalf("dry-run called %s: %#v", tool, calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncDryRunPlanBranchMatrix(t *testing.T) {
|
||||
remoteFiles := map[string]*remoteFile{
|
||||
"conflict.txt": {FileID: "remote-file"},
|
||||
}
|
||||
remoteFolders := map[string]string{"": "root", "existing": "existing-id"}
|
||||
localDirs := []string{"existing", "new", "missing/child"}
|
||||
localFiles := map[string]localPushFile{"conflict.txt": {}, "new/file.txt": {}}
|
||||
|
||||
for _, policy := range []string{syncConflictRemoteWins, syncConflictLocalWins, syncConflictKeepBoth, syncConflictAsk, syncConflictSkip} {
|
||||
t.Run(policy, func(t *testing.T) {
|
||||
res := &driveSyncResult{}
|
||||
appendDriveSyncDryRunPlan(res, driveSyncDryRunPlanInput{
|
||||
LocalDirs: localDirs, LocalByRel: localFiles, RemoteFiles: remoteFiles, RemoteFolders: remoteFolders,
|
||||
NewLocal: []string{"new/file.txt", "missing/file.txt"}, NewRemote: []string{"remote.txt"},
|
||||
Modified: []string{"conflict.txt"}, Unknown: []string{"unknown.txt"}, OnConflict: policy,
|
||||
})
|
||||
if res.Summary.PlannedSkips == 0 || res.Summary.PlannedFolders == 0 || res.Summary.PlannedPulls == 0 || res.Summary.Failed != 2 {
|
||||
t.Fatalf("dry-run plan summary for %s = %#v; items=%#v", policy, res.Summary, res.Items)
|
||||
}
|
||||
switch policy {
|
||||
case syncConflictRemoteWins, syncConflictKeepBoth:
|
||||
if res.Summary.PlannedPulls < 2 {
|
||||
t.Fatalf("%s planned pulls = %d", policy, res.Summary.PlannedPulls)
|
||||
}
|
||||
case syncConflictLocalWins:
|
||||
if res.Summary.PlannedPushes < 2 {
|
||||
t.Fatalf("local wins planned pushes = %d", res.Summary.PlannedPushes)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePull_dryRunPlanBranches(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "skip.txt"), "skip")
|
||||
@@ -242,7 +353,7 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) {
|
||||
[]string{"existing", "missing/child"}, files("orphan/a.txt", "skip.txt", "new.txt")); err != nil {
|
||||
t.Fatalf("skip plan: %v", err)
|
||||
}
|
||||
if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionUploaded) {
|
||||
if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedUpload) {
|
||||
t.Fatalf("skip plan did not cover failed/skipped/uploaded: %s", text)
|
||||
}
|
||||
out.Reset()
|
||||
@@ -250,14 +361,14 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) {
|
||||
files("smart-skip.txt", "smart-overwrite.txt")); err != nil {
|
||||
t.Fatalf("smart plan: %v", err)
|
||||
}
|
||||
if text := out.String(); !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionOverwritten) {
|
||||
if text := out.String(); !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedOverwrite) {
|
||||
t.Fatalf("smart plan did not cover skip/overwrite: %s", text)
|
||||
}
|
||||
out.Reset()
|
||||
if err := printDrivePushDryRun(ifExistsOverwrite, remoteFiles, remoteFolders, nil, files("overwrite.txt")); err != nil {
|
||||
t.Fatalf("overwrite plan: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), pushActionOverwritten) {
|
||||
if !strings.Contains(out.String(), pushActionPlannedOverwrite) {
|
||||
t.Fatalf("overwrite plan must overwrite existing file: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user