Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01476727e9 | ||
|
|
8d34acbb38 | ||
|
|
9401f9921a | ||
|
|
ed951ff0e1 | ||
|
|
b42596214d | ||
|
|
f684c412ea | ||
|
|
a4813f78bc | ||
|
|
415d962371 | ||
|
|
26253a4eeb | ||
|
|
46fdaed90b | ||
|
|
9ea19315e9 | ||
|
|
8549a90402 | ||
|
|
a5dbd2e049 | ||
|
|
0ebdcb455d | ||
|
|
b1a6f9e20c | ||
|
|
29a0dde9d4 | ||
|
|
0fae9dd8b3 | ||
|
|
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 | ||
|
|
aa4ae9a903 | ||
|
|
7a019c6fa3 | ||
|
|
103b05413e | ||
|
|
bb48aa0cc8 | ||
|
|
6ffb4bcb93 | ||
|
|
e2e4d6fc22 | ||
|
|
2c0d6e4118 | ||
|
|
169bbe88c0 | ||
|
|
08595594d7 | ||
|
|
379f625ca9 | ||
|
|
30782020ad | ||
|
|
540bbac35b | ||
|
|
9d27313f5e | ||
|
|
cc86d1e958 | ||
|
|
5619cb150e | ||
|
|
c4d5595ca9 | ||
|
|
5aaf2efb59 | ||
|
|
86d1eb8030 | ||
|
|
ab529e5ee5 | ||
|
|
15cb1f4311 | ||
|
|
97ca00868f | ||
|
|
df8885c350 | ||
|
|
d87cdef00b | ||
|
|
f79c066806 | ||
|
|
4e27a3a84a | ||
|
|
8685464c53 | ||
|
|
a0495c169b | ||
|
|
26b5939f9f | ||
|
|
95bcace6bd | ||
|
|
20c901d7ae | ||
|
|
e51ecc04f1 | ||
|
|
ce57cdf260 | ||
|
|
17741851f5 | ||
|
|
999e7a7b9d | ||
|
|
d10446bea7 | ||
|
|
94cee4388e | ||
|
|
3ec138ba99 | ||
|
|
dcc7e72ec1 | ||
|
|
0ed05a2c5d | ||
|
|
ae1edefee6 | ||
|
|
6dbc7ed82b | ||
|
|
586ad0a5df | ||
|
|
6d88c9968e | ||
|
|
36c61fd8ac | ||
|
|
272b6b8a70 | ||
|
|
c3328411c9 | ||
|
|
83cfd10416 | ||
|
|
9d43a12e08 | ||
|
|
7900e27946 | ||
|
|
42f54832b0 | ||
|
|
5f6ca90821 | ||
|
|
cad437aabd | ||
|
|
47d71375f6 | ||
|
|
69540c3372 | ||
|
|
3a2b738c06 | ||
|
|
725e60f07c | ||
|
|
8b4453adf9 | ||
|
|
f419c0f96d | ||
|
|
17101a8901 | ||
|
|
66aa00fb50 | ||
|
|
9d6e151a6f | ||
|
|
0df41d3eff | ||
|
|
ab0d1d2ad6 | ||
|
|
2a1ed8cc7a | ||
|
|
358e1ab065 | ||
|
|
c5951a10ff | ||
|
|
3dbd29ab50 | ||
|
|
1b50c7a5b4 | ||
|
|
fab84af434 | ||
|
|
2dd724f1e1 | ||
|
|
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,14 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Attendance and Mail Shortcuts** (#1045) — publishes only capabilities with
|
||||
strict response, identity, pagination, and real-data verification while
|
||||
retaining historical CLI discovery and argument compatibility for commands
|
||||
that remain unavailable to agents. Mailbox auto-resolution now accepts both
|
||||
reviewed string and object response shapes, and Attendance date ranges cover
|
||||
the complete requested end date without dropping cross-midnight punches whose
|
||||
actual check time is inside the requested range. The schedule query remains
|
||||
CLI-compatible but is withheld from the Agent catalog because its downstream
|
||||
service returns a successful process exit with a null body for both populated
|
||||
and empty ranges.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **OA admin approval query** — `oa approval list-by-admin` queries approval instances of a template with admin scope, with simple flags and an advanced `--request` mode; `startTime`/`endTime` use `yyyy-MM-dd HH:mm:ss` strings per the 2026-08 MCP contract update (ISO-8601 flag inputs auto-convert), and pageSize/time format are validated client-side with localized errors.
|
||||
@@ -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
|
||||
|
||||
+161
File diff suppressed because one or more lines are too long
@@ -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.2"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.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.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "e7776807f0664cbf0d0728cc236f2415c0981eb8d6557a897d2eeee708641b1d"
|
||||
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.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "3004474df3cfb529719348f02c9f2f39afa88f0fca469fe8303a9ebe0f3a0034"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.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.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "6386885d10f149c8c555031dda4cf07bf34e1e9daad61d4cd948b92d3c7b7bad"
|
||||
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.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "5c94c2af269d2fe5a79a400d4fa3af267a86d6ab21b01a24ede1d29514a6eaef"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-skills.zip"
|
||||
sha256 "e7028914a4a826af9b18ed4922d68fa8f279473817fed4f305465bc8a7aad363"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-skills.zip"
|
||||
sha256 "c3bd917f1b44a978ba2a9fbe95c5d0910ccf75f870f1c9b0dc356262ab1080c5"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -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`,不会覆盖稳定入口。
|
||||
|
||||
+522
-281
File diff suppressed because it is too large
Load Diff
@@ -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":
|
||||
|
||||
@@ -148,12 +148,12 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"},
|
||||
"contact +search-user": {"contact", "+search-user", "--query", "Fixture User"},
|
||||
"contact dept list-children": {"contact", "dept", "list-children", "--dept", "1"},
|
||||
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1"},
|
||||
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1", "--fields", "name,userId"},
|
||||
"dev app get": {"dev", "app", "get", "--unified-app-id", "app-1"},
|
||||
"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,196 @@ 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{
|
||||
"aisearch": {"aisearch", "--query", "Fixture User", "--dimension", "name"},
|
||||
"aisearch +search-person": {"aisearch", "+search-person", "--query", "Fixture User", "--dimensions", "name"},
|
||||
"aisearch behavior": {"aisearch", "behavior", "--queries", "fixture", "--types", "im", "--behavior-type", "send", "--chat-scope", "Fixture Group", "--direction", "我->Fixture User", "--time-range", "本周"},
|
||||
"aisearch enterprise": {"aisearch", "enterprise", "--queries", "fixture", "--types", "document", "--time-range", "本周"},
|
||||
"aisearch person": {"aisearch", "person", "--query", "Fixture User", "--dimension", "name"},
|
||||
"contact +by-mobile": {"contact", "+by-mobile", "--mobile", "13800138000"},
|
||||
"contact +list-dept-members": {"contact", "+list-dept-members", "--depts", "1,2"},
|
||||
"contact +list-followings": {"contact", "+list-followings", "--open-id", "open-fixture-1"},
|
||||
"contact +list-role-members": {"contact", "+list-role-members", "--id", "12345"},
|
||||
"contact +lookup": {"contact", "+lookup", "--name", "Fixture User"},
|
||||
"contact +org": {"contact", "+org", "--name", "Fixture User"},
|
||||
"contact +search-mobile": {"contact", "+search-mobile", "--mobile", "13800138000"},
|
||||
"contact +team": {"contact", "+team", "--name", "Fixture User"},
|
||||
"contact account create": {"contact", "account", "create", "--login-id", "fixture-login", "--org-user-name", "Fixture User", "--dept-ids", "1,2"},
|
||||
"contact account update": {"contact", "account", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--avatar-file-id", "file-1", "--yes"},
|
||||
"contact dept create": {"contact", "dept", "create", "--name", "Fixture Dept", "--parent", "1", "--create-dept-group", "--yes"},
|
||||
"contact dept get-info": {"contact", "dept", "get-info", "--dept", "1"},
|
||||
"contact dept list-members": {"contact", "dept", "list-members", "--depts", "1,2"},
|
||||
"contact dept search": {"contact", "dept", "search", "--query", "Fixture Dept"},
|
||||
"contact dept update": {"contact", "dept", "update", "--dept", "2", "--name", "Fixture Dept", "--parent", "1", "--yes"},
|
||||
"contact label get": {"contact", "label", "get", "--names", "Fixture Role"},
|
||||
"contact org create": {"contact", "org", "create", "--org-name", "Fixture Org", "--creator-username", "Fixture Creator"},
|
||||
"contact user dismission search": {"contact", "user", "dismission", "search", "--depts", "1,2", "--start", "2026-03-01", "--end", "2026-03-31", "--page", "2", "--limit", "7"},
|
||||
"contact user get": {"contact", "user", "get", "--ids", "user-1,user-2"},
|
||||
"contact user invite": {"contact", "user", "invite", "--org-user-mobile", "13800138000", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`},
|
||||
"contact user search": {"contact", "user", "search", "--query", "Fixture User"},
|
||||
"contact user search-mobile": {"contact", "user", "search-mobile", "--mobile", "13800138000"},
|
||||
"contact user update": {"contact", "user", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--yes"},
|
||||
"contact user update-ownness": {"contact", "user", "update-ownness", "--user-id", "user-1", "--ownness-text", "Fixture Status", "--yes"},
|
||||
"contact user update-self": {"contact", "user", "update-self", "--avatar-file-id", "file-1", "--nick", "Fixture Nick", "--yes"},
|
||||
"devdoc +search-docs": {"devdoc", "+search-docs", "--query", "fixture", "--page", "2", "--size", "7"},
|
||||
"hrbrain +get-pool": {"hrbrain", "+get-pool", "--pool-code", "pool-1"},
|
||||
"hrbrain +list-pool-employees": {"hrbrain", "+list-pool-employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain +list-pools": {"hrbrain", "+list-pools", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain +profile-career": {"hrbrain", "+profile-career", "--work-no", "work-1"},
|
||||
"hrbrain +profile-labels": {"hrbrain", "+profile-labels", "--staff-ids", "work-1,work-2", "--all-label"},
|
||||
"hrbrain +profile-metadata": {"hrbrain", "+profile-metadata", "--work-no", "work-1"},
|
||||
"hrbrain +profile-performance": {"hrbrain", "+profile-performance", "--work-no", "work-1"},
|
||||
"hrbrain +query-profile": {"hrbrain", "+query-profile", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`},
|
||||
"hrbrain +search-employees": {"hrbrain", "+search-employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain +search-employees-structured": {"hrbrain", "+search-employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain profile career": {"hrbrain", "profile", "career", "--work-no", "work-1"},
|
||||
"hrbrain profile labels": {"hrbrain", "profile", "labels", "--staff-ids", "work-1,work-2", "--all-label"},
|
||||
"hrbrain profile metadata": {"hrbrain", "profile", "metadata", "--work-no", "work-1"},
|
||||
"hrbrain profile performance": {"hrbrain", "profile", "performance", "--work-no", "work-1"},
|
||||
"hrbrain profile query": {"hrbrain", "profile", "query", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`},
|
||||
"hrbrain search employees": {"hrbrain", "search", "employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain search employees-structured": {"hrbrain", "search", "employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain talent-pool detail": {"hrbrain", "talent-pool", "detail", "--pool-code", "pool-1"},
|
||||
"hrbrain talent-pool employees": {"hrbrain", "talent-pool", "employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain talent-pool list": {"hrbrain", "talent-pool", "list", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
|
||||
"pat +browser-policy": {"pat", "+browser-policy", "--enabled=false", "--agent-code", "fixture-agent", "--dry-run"},
|
||||
"pat browser-policy": {"pat", "browser-policy", "--enabled=false", "--agentCode", "fixture-agent"},
|
||||
"pat chmod": {"pat", "chmod", "--product", "calendar", "--products", "aitable", "--domain", "chat", "--domains", "mail", "--grant-type", "session", "--session-id", "session-1", "--recommend", "--agentCode", "fixture-agent", "--dry-run"},
|
||||
"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 +706,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 +789,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 +922,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 +931,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 +945,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
continue
|
||||
}
|
||||
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
if !paramAliasRepresentativePayloadCases[caseKey] {
|
||||
if !paramAliasRepresentativePayloadCases[caseKey] && !paramAliasCandidateRepresentativePayloadCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
executedRepresentatives[caseKey] = true
|
||||
@@ -742,26 +957,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 +1084,122 @@ func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation
|
||||
// exercises every distinct reviewed mutating complete-command template in the
|
||||
// reviewed product expansions. 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 TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation(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",
|
||||
"aisearch", "contact", "live", "devdoc", "hrbrain", "pat":
|
||||
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 +1439,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 +1534,10 @@ func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
|
||||
return variant, true
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return complete, true
|
||||
}
|
||||
complete, ok = paramAliasCandidateCompleteCommands[command]
|
||||
return complete, ok
|
||||
}
|
||||
|
||||
|
||||
@@ -740,6 +740,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
"mcp_tool_error",
|
||||
"MCP tool returned a business error; check tool parameters and refer to skill documentation.",
|
||||
invocation.CanonicalProduct,
|
||||
invocation.Tool,
|
||||
diag,
|
||||
)
|
||||
logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag)
|
||||
@@ -765,6 +766,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
"business_error",
|
||||
"The API returned a business-level error. Check required parameters and values.",
|
||||
invocation.CanonicalProduct,
|
||||
invocation.Tool,
|
||||
diag,
|
||||
)
|
||||
logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag)
|
||||
|
||||
@@ -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 = 435
|
||||
publicShortcutCount = 436
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including the hidden historical minutes.shortcut_minutes_search contract.
|
||||
schemaPublishedShortcutCount = 438
|
||||
// including reviewed hidden compatibility and unavailable contracts.
|
||||
schemaPublishedShortcutCount = 493
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 435
|
||||
publiclyDeliveredShortcutCount = 436
|
||||
)
|
||||
|
||||
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,
|
||||
|
||||
@@ -20,18 +20,50 @@ import (
|
||||
)
|
||||
|
||||
type serverFailureClass struct {
|
||||
message string
|
||||
reason string
|
||||
origin string
|
||||
stage string
|
||||
hint string
|
||||
actions []string
|
||||
message string
|
||||
reason string
|
||||
origin string
|
||||
stage string
|
||||
hint string
|
||||
actions []string
|
||||
operation string
|
||||
retryable *bool
|
||||
}
|
||||
|
||||
func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
|
||||
func classifyServerFailure(message, serverKey, tool string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
|
||||
code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode))
|
||||
detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail))
|
||||
text := strings.ToLower(strings.TrimSpace(message))
|
||||
combined := text + " " + detail
|
||||
|
||||
if code == "999" &&
|
||||
(strings.Contains(combined, "nullpointerexception") || strings.Contains(combined, "system error")) {
|
||||
classified := serverFailureClass{
|
||||
message: message,
|
||||
reason: "upstream_internal_error",
|
||||
origin: "dingtalk_api",
|
||||
stage: "upstream_execution",
|
||||
hint: "上游服务发生内部异常;请保留 Trace ID 和 Server Code,确认操作结果后再决定是否重试。",
|
||||
actions: []string{
|
||||
"检查目标资源的当前状态,确认本次操作是否已经生效",
|
||||
"状态未确认前不要直接重试写操作",
|
||||
"持续失败时携带 Trace ID 和 Server Code 联系服务端排查",
|
||||
},
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(serverKey), "todo") &&
|
||||
strings.EqualFold(strings.TrimSpace(tool), "create_personal_todo") {
|
||||
retryable := false
|
||||
classified.operation = "todo/create_personal_todo"
|
||||
classified.retryable = &retryable
|
||||
classified.hint = "待办服务发生内部异常,创建结果未知;请先查询是否已创建相同待办,再决定是否重试。"
|
||||
classified.actions = []string{
|
||||
"查询近期由自己创建的待办,核对标题、执行人和截止时间",
|
||||
"确认没有创建成功后再重新提交",
|
||||
"持续失败时携带 Trace ID 和 Server Code 联系服务端排查",
|
||||
}
|
||||
}
|
||||
return classified, true
|
||||
}
|
||||
|
||||
if code == "NETWORK_ERROR" ||
|
||||
strings.Contains(detail, "statuscode.unavailable") ||
|
||||
@@ -74,6 +106,7 @@ func newServerFailureAPIError(
|
||||
fallbackReason string,
|
||||
fallbackHint string,
|
||||
serverKey string,
|
||||
tool string,
|
||||
diag apperrors.ServerDiagnostics,
|
||||
) error {
|
||||
opts := []apperrors.Option{
|
||||
@@ -84,7 +117,7 @@ func newServerFailureAPIError(
|
||||
apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"),
|
||||
apperrors.WithServerDiag(diag),
|
||||
}
|
||||
if classified, ok := classifyServerFailure(message, diag); ok {
|
||||
if classified, ok := classifyServerFailure(message, serverKey, tool, diag); ok {
|
||||
message = classified.message
|
||||
opts = append(opts,
|
||||
apperrors.WithReason(classified.reason),
|
||||
@@ -93,6 +126,12 @@ func newServerFailureAPIError(
|
||||
apperrors.WithHint(classified.hint),
|
||||
apperrors.WithActions(classified.actions...),
|
||||
)
|
||||
if classified.operation != "" {
|
||||
opts = append(opts, apperrors.WithOperation(classified.operation))
|
||||
}
|
||||
if classified.retryable != nil {
|
||||
opts = append(opts, apperrors.WithRetryable(*classified.retryable))
|
||||
}
|
||||
}
|
||||
return apperrors.NewAPI(message, opts...)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable(
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
"list_conversations",
|
||||
apperrors.ServerDiagnostics{
|
||||
TraceID: "trace-local",
|
||||
ServerErrorCode: "NETWORK_ERROR",
|
||||
@@ -66,6 +67,7 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"chat",
|
||||
"send_message",
|
||||
apperrors.ServerDiagnostics{ServerErrorCode: "1001"},
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
@@ -80,12 +82,74 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureClassifierTodoCreateUpstreamInternalError(t *testing.T) {
|
||||
serverSaysRetryable := true
|
||||
err := newServerFailureAPIError(
|
||||
"[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)",
|
||||
"business_error",
|
||||
"The API returned a business-level error. Check required parameters and values.",
|
||||
"todo",
|
||||
"create_personal_todo",
|
||||
apperrors.ServerDiagnostics{
|
||||
TraceID: "trace-todo-create",
|
||||
ServerErrorCode: "999",
|
||||
ServerRetryable: &serverSaysRetryable,
|
||||
},
|
||||
)
|
||||
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Reason != "upstream_internal_error" || typed.Origin != "dingtalk_api" || typed.FailureStage != "upstream_execution" {
|
||||
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
|
||||
}
|
||||
if typed.Operation != "todo/create_personal_todo" {
|
||||
t.Fatalf("operation = %q, want todo/create_personal_todo", typed.Operation)
|
||||
}
|
||||
if typed.ExecutionStarted != nil {
|
||||
t.Fatalf("execution_started = %v, want unknown", typed.ExecutionStarted)
|
||||
}
|
||||
if !typed.RetryableSet || typed.Retryable {
|
||||
t.Fatalf("retryability = (%v, %v), want explicit false", typed.RetryableSet, typed.Retryable)
|
||||
}
|
||||
if typed.ServerDiag.TraceID != "trace-todo-create" || typed.ServerDiag.ServerErrorCode != "999" {
|
||||
t.Fatalf("diagnostics = %#v", typed.ServerDiag)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(typed.Hint), "parameter") || !strings.Contains(typed.Hint, "创建结果未知") {
|
||||
t.Fatalf("hint = %q", typed.Hint)
|
||||
}
|
||||
for _, action := range typed.Actions {
|
||||
if strings.Contains(action, "dws doctor") || strings.Contains(action, "登录") || strings.Contains(action, "网络") {
|
||||
t.Fatalf("misleading action = %q", action)
|
||||
}
|
||||
}
|
||||
|
||||
payload := multiProfileErrorPayload(err)
|
||||
for key, want := range map[string]any{
|
||||
"reason": "upstream_internal_error",
|
||||
"origin": "dingtalk_api",
|
||||
"stage": "upstream_execution",
|
||||
"retryable": false,
|
||||
"trace_id": "trace-todo-create",
|
||||
"server_error_code": "999",
|
||||
} {
|
||||
if got := payload[key]; got != want {
|
||||
t.Errorf("payload[%q] = %#v, want %#v", key, got, want)
|
||||
}
|
||||
}
|
||||
if _, ok := payload["execution_started"]; ok {
|
||||
t.Fatalf("payload must keep execution_started unknown: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) {
|
||||
err := newServerFailureAPIError(
|
||||
"business error: success=false",
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
"list_conversations",
|
||||
apperrors.ServerDiagnostics{},
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
@@ -106,6 +170,7 @@ func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *test
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
"list_conversations",
|
||||
apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"},
|
||||
)
|
||||
if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" {
|
||||
@@ -123,6 +188,7 @@ func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics(
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
"list_conversations",
|
||||
apperrors.ServerDiagnostics{
|
||||
TraceID: "trace-multi",
|
||||
ServerErrorCode: "NETWORK_ERROR",
|
||||
@@ -224,3 +290,58 @@ func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFail
|
||||
t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExecuteInvocationClassifiesTodoCreateUpstreamInternalError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": request.ID,
|
||||
"result": map[string]any{
|
||||
"structuredContent": map[string]any{
|
||||
"success": false,
|
||||
"code": "999",
|
||||
"trace_id": "trace-todo-replay",
|
||||
"errorMsg": "[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := transport.NewClient(server.Client())
|
||||
client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")}
|
||||
runner := &runtimeRunner{
|
||||
transport: client,
|
||||
globalFlags: &GlobalFlags{Token: "local-test-token"},
|
||||
}
|
||||
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
|
||||
CanonicalProduct: "todo",
|
||||
Tool: "create_personal_todo",
|
||||
CanonicalPath: "todo.create_personal_todo",
|
||||
Params: map[string]any{
|
||||
"PersonalTodoCreateVO": map[string]any{
|
||||
"subject": "fixture",
|
||||
"executorIds": []string{"user-1"},
|
||||
},
|
||||
},
|
||||
})
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err)
|
||||
}
|
||||
if typed.Reason != "upstream_internal_error" || typed.Operation != "todo/create_personal_todo" {
|
||||
t.Fatalf("classification = reason %q operation %q", typed.Reason, typed.Operation)
|
||||
}
|
||||
if !typed.RetryableSet || typed.Retryable || typed.ExecutionStarted != nil {
|
||||
t.Fatalf("failure semantics = retryable(%v,%v) execution_started=%v", typed.RetryableSet, typed.Retryable, typed.ExecutionStarted)
|
||||
}
|
||||
if typed.ServerDiag.TraceID != "trace-todo-replay" || typed.ServerDiag.ServerErrorCode != "999" {
|
||||
t.Fatalf("diagnostics = %#v", typed.ServerDiag)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
+1347
-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{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user