Compare commits
344
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea18feb0a8 | ||
|
|
c5e3c2ec56 | ||
|
|
92c80f81f9 | ||
|
|
d245ea4c84 | ||
|
|
9e3a5d6fbd | ||
|
|
33623d09d9 | ||
|
|
b20055a0b5 | ||
|
|
fc05976d33 | ||
|
|
021da02474 | ||
|
|
a5b9e5a13f | ||
|
|
3dce49020e | ||
|
|
fac92c252e | ||
|
|
9f8c525008 | ||
|
|
207d4dd7e5 | ||
|
|
8db297fe4b | ||
|
|
dc2aec7696 | ||
|
|
9a6b7d4d41 | ||
|
|
404af112b7 | ||
|
|
5947016cc1 | ||
|
|
5425d1565f | ||
|
|
386426bb92 | ||
|
|
3cea671a54 | ||
|
|
f06ea4d9e2 | ||
|
|
a82d945f54 | ||
|
|
6846326445 | ||
|
|
54c2054a5c | ||
|
|
e5bf332b05 | ||
|
|
9ed55978d9 | ||
|
|
7ffbbc4a51 | ||
|
|
2db73a8185 | ||
|
|
a62332be93 | ||
|
|
1083093cbc | ||
|
|
c6ebe307cd | ||
|
|
3ee66d4373 | ||
|
|
a0be395ccc | ||
|
|
93dbd768f7 | ||
|
|
c1a549cd64 | ||
|
|
5a414999ef | ||
|
|
7aa8240629 | ||
|
|
37b9a1dc31 | ||
|
|
2ab8748c4d | ||
|
|
f041275811 | ||
|
|
f486105836 | ||
|
|
e14de2b4c2 | ||
|
|
fe2f3ca92f | ||
|
|
8e4519cacd | ||
|
|
16abb481e8 | ||
|
|
7ad82bbf0a | ||
|
|
104eb715c4 | ||
|
|
97ea887ea5 | ||
|
|
03838a3430 | ||
|
|
bfeb9f6af0 | ||
|
|
e26f278112 | ||
|
|
0d34150333 | ||
|
|
e6b5938bd8 | ||
|
|
4f95373420 | ||
|
|
afb90009f6 | ||
|
|
9e8b58cbb6 | ||
|
|
6411d26a95 | ||
|
|
31117d1b89 | ||
|
|
e3553fe7a5 | ||
|
|
fe724e96e8 | ||
|
|
067aff179f | ||
|
|
353454abb2 | ||
|
|
96b9cbce02 | ||
|
|
36877d00dc | ||
|
|
a9a97c2746 | ||
|
|
f72979f4a9 | ||
|
|
55d94d3b58 | ||
|
|
bfd0976b31 | ||
|
|
4a33e7e893 | ||
|
|
ee74765383 | ||
|
|
35239259fb | ||
|
|
85bf2dfc8a | ||
|
|
c4f2ab631b | ||
|
|
308e71c783 | ||
|
|
ecce09b355 | ||
|
|
1d02ff805d | ||
|
|
4d843cf7a4 | ||
|
|
8560830d3e | ||
|
|
9fbd8addbe | ||
|
|
92195a58a3 | ||
|
|
fb9ff7de73 | ||
|
|
5ee80cdb97 | ||
|
|
bf2c0653ed | ||
|
|
e4daddf9cf | ||
|
|
e92309f7c4 | ||
|
|
7a58b0d19a | ||
|
|
78e6f11d72 | ||
|
|
9a8a41a318 | ||
|
|
af8e6a9ccc | ||
|
|
547020f47e | ||
|
|
d5eee82816 | ||
|
|
0d8763b917 | ||
|
|
600404abd0 | ||
|
|
247926d0fa | ||
|
|
9ef2a4e652 | ||
|
|
d4daf9525c | ||
|
|
63a89e68fa | ||
|
|
29b73a7d5e | ||
|
|
3488e11129 | ||
|
|
596bdce3a1 | ||
|
|
0b012788c7 | ||
|
|
58eea98f6c | ||
|
|
e742a6c269 | ||
|
|
b53b84616e | ||
|
|
15a2fea0dc | ||
|
|
05868610f0 | ||
|
|
d8da9a2e9f | ||
|
|
1a6ae856ec | ||
|
|
22649e96ef | ||
|
|
9c6407ae74 | ||
|
|
f68a11f11d | ||
|
|
abe5129306 | ||
|
|
b9b8cc2c77 | ||
|
|
7b7bd556e9 | ||
|
|
d534ee242c | ||
|
|
e02fdbdc8f | ||
|
|
ce529c9337 | ||
|
|
6952b22f45 | ||
|
|
3aa06e32fa | ||
|
|
97fc783cc0 | ||
|
|
a18b1e5fe4 | ||
|
|
90473284b8 | ||
|
|
b17e030d1f | ||
|
|
03258ca045 | ||
|
|
afd8422580 | ||
|
|
07aa2c883a | ||
|
|
4b3e0e5046 | ||
|
|
a6f69a06ce | ||
|
|
2016e7f6dc | ||
|
|
95986bbfc5 | ||
|
|
322077be89 | ||
|
|
a7a0a97115 | ||
|
|
cbaa8c9bf5 | ||
|
|
0f5ecb609b | ||
|
|
5094c63755 | ||
|
|
4a78e7c1d9 | ||
|
|
0c2a9cb2b3 | ||
|
|
c9d4783968 | ||
|
|
2116122c95 | ||
|
|
4c3450792a | ||
|
|
f55f9bc3a6 | ||
|
|
be001949e4 | ||
|
|
bcd91aca1f | ||
|
|
12e6632692 | ||
|
|
a5111f486b | ||
|
|
d0e6aba319 | ||
|
|
b0b18986b1 | ||
|
|
7a9348f9aa | ||
|
|
6c78db7467 | ||
|
|
b539e15e6d | ||
|
|
abecb0dee1 | ||
|
|
d17f50b9de | ||
|
|
c9426622f0 | ||
|
|
5b01f29f2f | ||
|
|
5efb6210b0 | ||
|
|
113e084a8d | ||
|
|
2734e3e1ce | ||
|
|
a76492e16e | ||
|
|
10417396f1 | ||
|
|
41a3724e9e | ||
|
|
a0cc9b4b51 | ||
|
|
97b6022017 | ||
|
|
45df573d0e | ||
|
|
608edfa309 | ||
|
|
e8ef510d3a | ||
|
|
8c6266f158 | ||
|
|
91f0fb7b11 | ||
|
|
410a63ea9a | ||
|
|
570d2e6756 | ||
|
|
c3ffb9c831 | ||
|
|
58c382efb7 | ||
|
|
3598586bc0 | ||
|
|
e6821176a4 | ||
|
|
504db23823 | ||
|
|
44857449d6 | ||
|
|
29f2f1c813 | ||
|
|
d21f18af04 | ||
|
|
dd604455cc | ||
|
|
26049a158a | ||
|
|
b066a14f0c | ||
|
|
95f9d168f1 | ||
|
|
6d58520f57 | ||
|
|
8984a1c454 | ||
|
|
91090a13b9 | ||
|
|
395712490d | ||
|
|
29c00341fa | ||
|
|
2eef6fdaa2 | ||
|
|
14a2175434 | ||
|
|
94d4b5dcc9 | ||
|
|
5cbf18713a | ||
|
|
78d94380e7 | ||
|
|
973671bdf1 | ||
|
|
4b555515cd | ||
|
|
ec83d8ff53 | ||
|
|
8cd2b0259d | ||
|
|
2d24f74980 | ||
|
|
90278ab2fc | ||
|
|
671a41437d | ||
|
|
1b06d0105a | ||
|
|
18fad57bbe | ||
|
|
658f1e8e34 | ||
|
|
a32608f964 | ||
|
|
4b8d94c24e | ||
|
|
c3ef04988b | ||
|
|
9f1b3e8254 | ||
|
|
76e5a8c4d9 | ||
|
|
1f2fbca4de | ||
|
|
c718b051c2 | ||
|
|
76d54d6df6 | ||
|
|
58a8dddf31 | ||
|
|
6a93f14e0a | ||
|
|
0dc6735da2 | ||
|
|
a36189d31e | ||
|
|
913b7cf9a9 | ||
|
|
e46c4d0d71 | ||
|
|
35f399e2cf | ||
|
|
d52d16dba4 | ||
|
|
6abffce4e5 | ||
|
|
c3a3b59ad2 | ||
|
|
5b0cd561ff | ||
|
|
6b3f2e29bd | ||
|
|
86b78e45d7 | ||
|
|
c2c260b3a8 | ||
|
|
9ff74c852a | ||
|
|
9be59ddfec | ||
|
|
8c00068364 | ||
|
|
a354144412 | ||
|
|
d77fa91c69 | ||
|
|
9eeb0681ff | ||
|
|
9ea527a7c4 | ||
|
|
d525648b45 | ||
|
|
f78f1b83e7 | ||
|
|
75bd518447 | ||
|
|
3a6fa9a00c | ||
|
|
dc43d0d6d4 | ||
|
|
bb69ed76df | ||
|
|
427d0cc1fc | ||
|
|
a26b16b30e | ||
|
|
e0c9b4910d | ||
|
|
1f6010f998 | ||
|
|
d9ba74aac0 | ||
|
|
c118a6a795 | ||
|
|
90070840f1 | ||
|
|
14818775c5 | ||
|
|
3d6c93196a | ||
|
|
dc762dc6e3 | ||
|
|
45a80185f6 | ||
|
|
a1dc997004 | ||
|
|
b525497da8 | ||
|
|
273a3ab5dd | ||
|
|
647bdb251c | ||
|
|
9c59206d2f | ||
|
|
9edc587e96 | ||
|
|
5065e4bfb6 | ||
|
|
db2caf6544 | ||
|
|
e58b85ea45 | ||
|
|
f3f1174407 | ||
|
|
e3fef0b6d4 | ||
|
|
54535bec11 | ||
|
|
d32bbe009d | ||
|
|
0ea3d9810e | ||
|
|
ce6d5fb538 | ||
|
|
1e13413f79 | ||
|
|
43882bf959 | ||
|
|
891dde7d03 | ||
|
|
def6ed4d2f | ||
|
|
7bf8ce79bd | ||
|
|
55c6a09bbc | ||
|
|
2778bef5bd | ||
|
|
ed6e7e493c | ||
|
|
a55880ce82 | ||
|
|
ec4a730287 | ||
|
|
19a21b8f7e | ||
|
|
286376df93 | ||
|
|
fa00da3507 | ||
|
|
a7ac4a264e | ||
|
|
88cd453db6 | ||
|
|
6fdf6e0678 | ||
|
|
b8deec9087 | ||
|
|
dbee2de1d5 | ||
|
|
1a9945f299 | ||
|
|
b92ac4db0f | ||
|
|
3e27af8e21 | ||
|
|
4d13905cb8 | ||
|
|
9a3796c401 | ||
|
|
6bf78f1783 | ||
|
|
bb68baf0a9 | ||
|
|
18c8e8390c | ||
|
|
657f9ee368 | ||
|
|
ae6d9aa16d | ||
|
|
357b0955b1 | ||
|
|
221e42b103 | ||
|
|
8aee08268d | ||
|
|
6a4744073c | ||
|
|
bcc324cc8f | ||
|
|
a55bd9bff8 | ||
|
|
cf8dd167a4 | ||
|
|
1dabfa1dc6 | ||
|
|
d82e12d09e | ||
|
|
30f3273a17 | ||
|
|
3e362fb3d1 | ||
|
|
d40a22aeb0 | ||
|
|
516bd5d99c | ||
|
|
9818f7779a | ||
|
|
65a00b497b | ||
|
|
0e856f5a6e | ||
|
|
b29a12abbf | ||
|
|
e08fb484a8 | ||
|
|
65bedd5f8c | ||
|
|
2df3b99e26 | ||
|
|
21c6581975 | ||
|
|
3e4a3fb9d9 | ||
|
|
1f1c27d68f | ||
|
|
388ae0d37b | ||
|
|
f2a3025f41 | ||
|
|
5282a55a54 | ||
|
|
07c5d25d55 | ||
|
|
1b8ca149cb | ||
|
|
e9bbfdd20c | ||
|
|
59978d9c06 | ||
|
|
1f7d8c16bd | ||
|
|
9c14d9a6e1 | ||
|
|
8c25736f39 | ||
|
|
dacf166935 | ||
|
|
fd26152141 | ||
|
|
a53971b146 | ||
|
|
56bb50913b | ||
|
|
54aefaaf60 | ||
|
|
0a90c0350d | ||
|
|
654b740532 | ||
|
|
6910bda9c7 | ||
|
|
f256d7a43c | ||
|
|
488411615f | ||
|
|
01c1428b66 | ||
|
|
f6a699227e | ||
|
|
185fbb1544 | ||
|
|
b6c508acdf | ||
|
|
1d4c51a4d3 | ||
|
|
9472f4a1d9 | ||
|
|
90e27c4b86 | ||
|
|
132dea9aaa | ||
|
|
5034c332fe |
@@ -0,0 +1,11 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Aitable pagination and Minutes unshare verification** (#1006) — keeps
|
||||
record queries on the service's 20-record page boundary so multi-page reads
|
||||
and mutation readbacks no longer report false retryable failures, preserves
|
||||
`totalCount` when supplied, validates `--dry-run` plans before transport,
|
||||
follows active deletion readback continuations before proving absence, and
|
||||
rejects Minutes unshare success until the listening note exists and the
|
||||
service acknowledges the exact task and member targets.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Robot group reference replies** (#928) — `chat message send-by-bot` supports paired `--reply` and `--ref-sender` flags for Markdown replies that quote an existing group message.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Document write verification** (#960) — avoids false partial-success results when normalized Markdown, paginated blocks, inline images, or version reverts are confirmed by server readback. Document reverts and media inserts now require explicit readback evidence and report partial success when the server cannot prove the requested result.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Calendar event share-info** (#980) — adds `dws calendar event share-info` to fetch a calendar event's share info (title, organizer, location, join info) for sharing with others; supports `--calendar-id` and `--language`.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Calendar and To-do Shortcut workflows** — aligns 47 public task-oriented
|
||||
entries with lark-cli where the DingTalk backend supports equivalent
|
||||
semantics, rejects malformed or missing collections instead of returning
|
||||
false empty success, preserves truthful pagination, and requires stable
|
||||
identifiers plus read-back or explicit terminal receipts for writes. Adds
|
||||
deterministic contract coverage, a PII-safe live E2E runner, and a sanitized
|
||||
capability review with documented platform boundaries.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Chat sender identity guards** — preserves unverified mixed sender inputs after exact message `senderId` matches and aligns `--sender-query` Skill guidance with fail-closed Runtime behavior.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Doc/drive description scope** — restates the `dingtalk-doc` description as document-entity-and-content operations with an explicit exclusion list, and narrows `dingtalk-drive` to file-level management of DingTalk documents, so first-round Agent selection separates content work from file management without changing CLI behavior.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Drive `--latest` refuses incomplete Top-N** (#899) — `dws drive list --latest` used to
|
||||
exit 0 with a "Top-N" computed over a partially scanned tree whenever a directory read
|
||||
failed mid-recursion (permission denied, API error), letting an incomplete set pose as the
|
||||
globally newest files. Truncation at the 2000-item scan cap and mid-recursion directory
|
||||
failures now both fail closed (`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`), report
|
||||
the first failing folder with its depth and reason, and emit a recovery command that
|
||||
reproduces the original candidate set — query domain, `--folder`, `--pattern`, `--type`,
|
||||
`--start` and `--end` are all carried over. On POSIX shells each user-supplied value is
|
||||
quoted so a URL query string or a shell metacharacter cannot change how the copied command
|
||||
parses. On Windows no quoting form is safe for both `cmd.exe` and PowerShell, so values
|
||||
containing metacharacters are not inlined at all: the command carries a placeholder and the
|
||||
original value is shown on a separate line marked as data rather than an executable command.
|
||||
Unrecoverable errors under `--latest` return the root cause instead of a partial result.
|
||||
Remote-controlled folder names and server error text are stripped of ANSI escapes and
|
||||
control characters before they reach the plain-text stderr message. The internal `sortTime`
|
||||
sort key no longer leaks into `drive list --depth` output on any path.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Drive list type/time filtering** (#942) — `dws drive list` gains `--type
|
||||
file|folder`, `--start`, and `--end` for client-side filtering by node type
|
||||
and modification time on both the pan and workspace routes. Filtering runs
|
||||
a bounded full scan of the target directory (2000-entry cap, reported via
|
||||
`truncated=true`), composes with `--latest`/`--pattern`/`--depth`, and is
|
||||
mutually exclusive with `--versions`/`--cursor`/`--order-by`/`--order`/
|
||||
`--limit`. Time values accept relative forms (`24h`/`7d`/`2w`), RFC 3339,
|
||||
zone-less ISO 8601 (Asia/Shanghai), or a plain date.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Drive list pattern filtering** (#942) — `dws drive list --pattern` on the
|
||||
single-layer pan route now filters the returned page by name pattern; the
|
||||
flag was previously accepted but silently ignored.
|
||||
|
||||
- **Drive list `--type folder --latest` composition** (#942) — `--latest` now
|
||||
ranks the filtered entries (folders included when `--type folder` is set)
|
||||
instead of unconditionally dropping folders, so the documented combination
|
||||
returns the most recently modified folders rather than an empty list.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Chat message time defaults** (#973) — default omitted `chat message list-all` time bounds in `Asia/Shanghai` when emitting timezone-less `yyyy-MM-dd HH:mm:ss` values, matching parsing semantics and rejecting reversed windows.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Doc and Drive parameter aliases** — normalizes reviewed identifier, pagination, path, version, and role synonyms while blocking ambiguous values before dispatch.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Drive folder synchronization** — adds `dws drive status`, `dws drive pull`,
|
||||
`dws drive push`, and `dws drive sync` for file-level comparison and transfer
|
||||
between a local folder and a Drive folder. Differences come from exact MD5 by
|
||||
default or from modification time with `--quick`; `status` is read-only, `pull`
|
||||
and `push` are one-directional with `--if-exists skip|smart|overwrite`, and
|
||||
`sync` is bidirectional with `--on-conflict remote-wins|local-wins|keep-both|ask`.
|
||||
Only regular files are transferred — online documents and shortcuts are skipped,
|
||||
neither side deletes extra files, downloads are staged through a temporary file
|
||||
and committed with an atomic rename, and remote names that would escape
|
||||
`--local-folder` are reported as failures instead of being written. Every command
|
||||
prints a structured summary on stdout and exits non-zero when any item fails.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **International DingTalk region support** — adds `.io` login and MCP routing, pre-release endpoint overrides, and profile-aware gateway selection while preserving the existing `.com` flow.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Chat identity routing** — validates explicit `openDingTalkId` inputs and improves name, `userId`, and `openDingTalkId` routing for message shortcuts.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Privacy-safe CLI telemetry** (#1009) — reports reviewed command outcomes and profile identity dimensions while excluding command arguments, output, paths, device fingerprints, and automatic system dimensions; `DO_NOT_TRACK=1` disables reporting.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Feedback survey entry in root help** (#1019) — `dws --help` now closes with a Feedback section linking the user-experience survey form.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Chat IM ID flags** (#954) — standardizes chat command entry points on `--conversation-id` for conversation IDs and `--message-id` for message IDs, so help, Schema, and Agent recommendations use the same canonical flags.
|
||||
- **Legacy chat flag compatibility** (#954) — keeps older chat IM ID flags such as `--group`, `--id`, `--chat`, `--open-conversation-id`, `--msg-id`, and `--open-message-id` working as compatibility aliases where applicable, while hiding migrated aliases from recommended help and Schema surfaces.
|
||||
- **Chat group bots target flag** (#954) — keeps `dws chat group bots` on the visible `--group` flag; this command does not register `--group-name`, and `--group` accepts either an openConversationId or a uniquely resolved group name.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Chat card update evidence** — distinguishes an accepted update request from an independently verified visible update, preserving the real `bizId` and warning callers not to repeat an unverified write.
|
||||
- **Chat command guidance** — splits message and group references by task and explains that `--from` is ambiguous between sender and time-range intent.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Faster Schema Catalog assembly** — projects typed values into payload JSON
|
||||
without re-running a validation scan over documents `json.Marshal` has just
|
||||
produced, cutting roughly a third of the projection work across the full tool
|
||||
set. Untrusted JSON input keeps its existing validation.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Wiki Shortcut workflows** — publishes 20 reviewed space, member, node, and
|
||||
activity shortcuts with strict collection validation, cursor handling,
|
||||
write-terminal evidence, safe read-backs where the backend supports them,
|
||||
task-oriented routing, and documented backend
|
||||
boundaries.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Sheet SourceRange dropdowns** — supports range-backed dropdowns across direct, cell, and batch write paths, with structured readback for valid and invalid references. Batch `set-dropdown` now rejects unsupported top-level `colors` / `source-colors`; Inline colors belong in `options[].color`, while SourceRange color writes remain unsupported.
|
||||
- **Sheet read completion metadata** — documents and preserves returned ranges, truncation reasons, and partial-read status for large range and CSV reads.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Windows event bus lifecycle** — start event consumers without unsupported inherited file descriptors, stop buses through local IPC with a termination fallback, and preserve subscription cleanup when startup fails.
|
||||
+417
-69
@@ -24,6 +24,7 @@ jobs:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
changelog_only: ${{ steps.classify.outputs.changelog_only }}
|
||||
release_seal_only: ${{ steps.classify.outputs.release_seal_only }}
|
||||
changelog_changed: ${{ steps.classify.outputs.changelog_changed }}
|
||||
docs_only: ${{ steps.classify.outputs.docs_only }}
|
||||
full_suite: ${{ steps.classify.outputs.full_suite }}
|
||||
@@ -39,6 +40,7 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
let changelogOnly = false;
|
||||
let releaseSealOnly = false;
|
||||
let changelogChanged = false;
|
||||
let docsOnly = false;
|
||||
let fullSuite = context.eventName === 'push';
|
||||
@@ -149,8 +151,18 @@ jobs:
|
||||
filename.startsWith('scripts/') ||
|
||||
filename.startsWith('verify/') ||
|
||||
filename.startsWith('internal/helpers/') ||
|
||||
// Shortcut declarations feed the live command tree and Schema
|
||||
// assembly. Their reverse dependencies include the expensive
|
||||
// app and generator packages, which must run in separate shards.
|
||||
filename.startsWith('internal/shortcut/') ||
|
||||
filename.startsWith('internal/generator/') ||
|
||||
filename.startsWith('internal/cli/schema') ||
|
||||
// Parameter aliases are reduced against the live command tree.
|
||||
// Their reverse-dependency set is too large for one focused
|
||||
// race job, so use the existing full-suite shards.
|
||||
filename === 'internal/cli/param_concepts.json' ||
|
||||
filename === 'internal/cli/param_concepts.schema.json' ||
|
||||
filename === 'internal/cli/param_aliases_generated.go' ||
|
||||
filename.startsWith('internal/interfacesnapshot/') ||
|
||||
filename.startsWith('internal/app/upgrade') ||
|
||||
filename.startsWith('internal/transport/') ||
|
||||
@@ -162,6 +174,43 @@ jobs:
|
||||
filename === 'go.mod' ||
|
||||
filename === 'go.sum'
|
||||
);
|
||||
const isExactReleaseSeal = (candidates) => {
|
||||
const changelog = candidates.filter(
|
||||
({ filename, status, previous_filename }) =>
|
||||
filename === 'CHANGELOG.md' &&
|
||||
status === 'modified' &&
|
||||
!previous_filename
|
||||
);
|
||||
if (changelog.length !== 1 || candidates.length < 2) {
|
||||
return false;
|
||||
}
|
||||
let version = '';
|
||||
return candidates.every((file) => {
|
||||
if (file.filename === 'CHANGELOG.md') {
|
||||
return file.status === 'modified' && !file.previous_filename;
|
||||
}
|
||||
if (
|
||||
file.status !== 'renamed' ||
|
||||
typeof file.filename !== 'string' ||
|
||||
typeof file.previous_filename !== 'string' ||
|
||||
file.additions !== 0 ||
|
||||
file.deletions !== 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const target = file.filename.match(
|
||||
/^\.changes\/released\/([0-9]+\.[0-9]+\.[0-9]+(?:-beta\.[1-9][0-9]*)?)\/([a-z0-9][a-z0-9._-]*\.md)$/
|
||||
);
|
||||
if (!target || file.previous_filename !== `.changes/${target[2]}`) {
|
||||
return false;
|
||||
}
|
||||
if (version && version !== target[1]) {
|
||||
return false;
|
||||
}
|
||||
version = target[1];
|
||||
return true;
|
||||
});
|
||||
};
|
||||
const classifyFiles = (complete) => {
|
||||
const paths = files.flatMap(({ filename, previous_filename }) =>
|
||||
[filename, previous_filename].filter(
|
||||
@@ -248,19 +297,26 @@ jobs:
|
||||
);
|
||||
}
|
||||
|
||||
changelogOnly =
|
||||
const exactChangelogDiff =
|
||||
files.length === 1 &&
|
||||
files[0].filename === 'CHANGELOG.md' &&
|
||||
files[0].status === 'modified' &&
|
||||
!files[0].previous_filename;
|
||||
releaseSealOnly = isExactReleaseSeal(files);
|
||||
changelogOnly = exactChangelogDiff || releaseSealOnly;
|
||||
changelogChanged = files.some(
|
||||
({ filename, previous_filename }) =>
|
||||
filename === 'CHANGELOG.md' ||
|
||||
previous_filename === 'CHANGELOG.md'
|
||||
);
|
||||
classifyFiles(true);
|
||||
if (releaseSealOnly) {
|
||||
fullSuite = false;
|
||||
}
|
||||
fastPathTrust = changelogOnly
|
||||
? 'exact pull-request revision and synthetic merge policy'
|
||||
? releaseSealOnly
|
||||
? 'exact release-seal fragment archival and synthetic merge policy'
|
||||
: 'exact CHANGELOG-only revision and synthetic merge policy'
|
||||
: docsOnly
|
||||
? 'documentation-only focused admission'
|
||||
: fullSuite
|
||||
@@ -295,7 +351,8 @@ jobs:
|
||||
per_page: 100,
|
||||
});
|
||||
files = Array.isArray(comparison.files) ? comparison.files : [];
|
||||
classifyFiles(files.length < 300);
|
||||
const pushFilesComplete = files.length < 300;
|
||||
classifyFiles(pushFilesComplete);
|
||||
const linearFromValidatedTip =
|
||||
comparison.status === 'ahead' &&
|
||||
comparison.merge_base_commit?.sha === expectedBefore &&
|
||||
@@ -307,8 +364,10 @@ jobs:
|
||||
files[0].filename === 'CHANGELOG.md' &&
|
||||
files[0].status === 'modified' &&
|
||||
!files[0].previous_filename;
|
||||
const exactReleaseSealDiff =
|
||||
pushFilesComplete && isExactReleaseSeal(files);
|
||||
|
||||
if (linearFromValidatedTip && exactChangelogDiff) {
|
||||
if (linearFromValidatedTip && (exactChangelogDiff || exactReleaseSealDiff)) {
|
||||
const requiredContexts = [
|
||||
'Lint',
|
||||
'Test',
|
||||
@@ -359,9 +418,15 @@ jobs:
|
||||
|
||||
if (missing.length === 0 && nonSuccess.length === 0) {
|
||||
changelogOnly = true;
|
||||
releaseSealOnly = exactReleaseSealDiff;
|
||||
changelogChanged = true;
|
||||
if (releaseSealOnly) {
|
||||
fullSuite = false;
|
||||
}
|
||||
fastPathTrust =
|
||||
`exact CHANGELOG-only successor of validated ${expectedBefore}`;
|
||||
releaseSealOnly
|
||||
? `exact release-seal successor of validated ${expectedBefore}`
|
||||
: `exact CHANGELOG-only successor of validated ${expectedBefore}`;
|
||||
} else {
|
||||
fastPathTrust =
|
||||
'predecessor Code Admission is not fully successful; ' +
|
||||
@@ -376,6 +441,7 @@ jobs:
|
||||
}
|
||||
|
||||
core.setOutput('changelog_only', String(changelogOnly));
|
||||
core.setOutput('release_seal_only', String(releaseSealOnly));
|
||||
core.setOutput('changelog_changed', String(changelogChanged));
|
||||
core.setOutput('docs_only', String(docsOnly));
|
||||
core.setOutput('full_suite', String(fullSuite));
|
||||
@@ -387,7 +453,8 @@ jobs:
|
||||
await core.summary
|
||||
.addHeading('Code Admission scope')
|
||||
.addRaw(`- Event: \`${context.eventName}\`\n`)
|
||||
.addRaw(`- Exact modified CHANGELOG only: \`${changelogOnly}\`\n`)
|
||||
.addRaw(`- Metadata-only fast path: \`${changelogOnly}\`\n`)
|
||||
.addRaw(`- Release-seal fragments only: \`${releaseSealOnly}\`\n`)
|
||||
.addRaw(`- CHANGELOG touched: \`${changelogChanged}\`\n`)
|
||||
.addRaw(`- Documentation-only: \`${docsOnly}\`\n`)
|
||||
.addRaw(`- Full suite: \`${fullSuite}\`\n`)
|
||||
@@ -402,7 +469,14 @@ jobs:
|
||||
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: steps.classify.outputs.changelog_only == 'true'
|
||||
run: echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY"
|
||||
env:
|
||||
RELEASE_SEAL_ONLY: ${{ steps.classify.outputs.release_seal_only }}
|
||||
run: |
|
||||
if [ "$RELEASE_SEAL_ONLY" = true ]; then
|
||||
echo "Lint is satisfied by the trusted release-seal fragment Policy path." >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Record documentation-only fast path
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only == 'true'
|
||||
@@ -439,11 +513,38 @@ jobs:
|
||||
run: node .github/reviewer-routing.test.js
|
||||
|
||||
test-focused:
|
||||
name: Test (changed packages)
|
||||
name: "Test (focused: ${{ matrix.shard }})"
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
# Each shard owns one bounded slice of the impacted set, so no single job
|
||||
# carries internal/app together with every reverse dependency. The shard
|
||||
# list and per-shard execution below mirror test-race, which runs the same
|
||||
# shards at full-suite scope; release-scripts is included because its
|
||||
# dedicated job only runs at full-suite or release-sensitive scope, and
|
||||
# dropping it here would stop testing test/scripts changes entirely.
|
||||
# internal/app is carried by one shard per bounded partition rather than a
|
||||
# single app shard: the partitions used to run end to end inside one job,
|
||||
# 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.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c
|
||||
- app-d-r
|
||||
- app-s-z-example-fuzz
|
||||
- generators
|
||||
- helpers
|
||||
- cli
|
||||
- smoke
|
||||
- remaining
|
||||
- release-scripts
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -472,36 +573,110 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Test changed packages and reverse dependencies
|
||||
- name: Select impacted packages for shard
|
||||
id: select
|
||||
shell: bash
|
||||
env:
|
||||
TEST_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Every app partition shard tests the same single internal/app
|
||||
# package, so the impacted-package query uses the base shard name and
|
||||
# the partition only selects which tests run.
|
||||
package_shard="$TEST_SHARD"
|
||||
case "$TEST_SHARD" in
|
||||
app-*) package_shard=app ;;
|
||||
esac
|
||||
package_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list-shard "$package_shard" "$TEST_BASE_REF" "$TEST_HEAD_REF"
|
||||
)"
|
||||
if [ -z "$package_output" ]; then
|
||||
echo "No buildable Go package in shard $TEST_SHARD is affected by this revision." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
echo "affected=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# The package list travels through a file rather than a step output:
|
||||
# reading it with `mapfile < file` has unambiguous line semantics,
|
||||
# whereas a here-string over a multi-line output would append an extra
|
||||
# empty element if the value ever carried a trailing newline, and an
|
||||
# empty element would reach go test as an empty package argument.
|
||||
printf '%s\n' "$package_output" > "$RUNNER_TEMP/focused-shard-packages.txt"
|
||||
echo "affected=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build
|
||||
if: ${{ matrix.shard == 'remaining' && steps.select.outputs.affected == 'true' }}
|
||||
run: make build
|
||||
|
||||
- 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
|
||||
|
||||
- name: Test shard with Race Detection
|
||||
if: ${{ steps.select.outputs.affected == 'true' }}
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
TEST_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
package_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list "$TEST_BASE_REF" "$TEST_HEAD_REF"
|
||||
)"
|
||||
if [ -z "$package_output" ]; then
|
||||
echo "No buildable Go package is affected by this revision." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
mapfile -t packages < "$RUNNER_TEMP/focused-shard-packages.txt"
|
||||
test "${#packages[@]}" -gt 0
|
||||
for package in "${packages[@]}"; do
|
||||
test -n "$package" || {
|
||||
echo "shard package list contains an empty entry" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
case "$TEST_SHARD" in
|
||||
app-*)
|
||||
# A single long-lived app test process retains every constructed
|
||||
# command tree in framework registries. Each partition is its own
|
||||
# job, so that state is released when the process exits and the
|
||||
# partitions run concurrently instead of end to end. The helper
|
||||
# still verifies that the partition patterns cover every top-level
|
||||
# test exactly once before running the one it was asked for.
|
||||
test "${#packages[@]}" -eq 1
|
||||
./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
if [ "$TEST_SHARD" = "release-scripts" ]; then
|
||||
# Mirror the dedicated release-contract job: these suites shell out
|
||||
# to archive tooling and are not race-instrumented there.
|
||||
go test -v -count=1 -timeout=10m "${packages[@]}"
|
||||
exit 0
|
||||
fi
|
||||
mapfile -t packages <<< "$package_output"
|
||||
go test -v -race -count=1 -timeout=15m "${packages[@]}"
|
||||
# cli/smoke own heavy NewRootCommand / Schema assembly under -race;
|
||||
# give them a dedicated package timeout on slower hosted runners.
|
||||
timeout_budget=12m
|
||||
if [ "$TEST_SHARD" = "cli" ] ||
|
||||
[ "$TEST_SHARD" = "smoke" ]; then
|
||||
timeout_budget=15m
|
||||
fi
|
||||
go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}"
|
||||
|
||||
test-race:
|
||||
name: "Test (race: ${{ matrix.shard }})"
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
# cli/smoke shards need headroom beyond go test -timeout for setup + assembly.
|
||||
# internal/app is split across one shard per bounded partition so the
|
||||
# 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.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- app
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c
|
||||
- app-d-r
|
||||
- app-s-z-example-fuzz
|
||||
- generators
|
||||
- helpers
|
||||
- cli
|
||||
@@ -527,14 +702,35 @@ jobs:
|
||||
TEST_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
package_output="$(./scripts/ci/test-packages.sh list "$TEST_SHARD")"
|
||||
# Every app partition shard tests the same single internal/app
|
||||
# package, so the package query uses the base shard name and the
|
||||
# partition only selects which tests run.
|
||||
package_shard="$TEST_SHARD"
|
||||
case "$TEST_SHARD" in
|
||||
app-*) package_shard=app ;;
|
||||
esac
|
||||
package_output="$(./scripts/ci/test-packages.sh list "$package_shard")"
|
||||
test -n "$package_output"
|
||||
mapfile -t packages <<< "$package_output"
|
||||
test "${#packages[@]}" -gt 0
|
||||
# cli/smoke own heavy NewRootCommand / Schema assembly under -race; give
|
||||
# them a dedicated budget so remaining is not SIGTERM'd by OOM/timeout.
|
||||
case "$TEST_SHARD" in
|
||||
app-*)
|
||||
# A single long-lived app test process retains every constructed
|
||||
# command tree in framework registries. Each partition is its own
|
||||
# job, so that state is released when the process exits and the
|
||||
# partitions run concurrently instead of end to end. The helper
|
||||
# still verifies that the partition patterns cover every top-level
|
||||
# test exactly once before running the one it was asked for.
|
||||
test "${#packages[@]}" -eq 1
|
||||
./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
# cli/smoke own heavy NewRootCommand / Schema assembly under -race;
|
||||
# give them a dedicated package timeout on slower hosted runners.
|
||||
timeout_budget=12m
|
||||
if [ "$TEST_SHARD" = "cli" ] || [ "$TEST_SHARD" = "smoke" ]; then
|
||||
if [ "$TEST_SHARD" = "cli" ] ||
|
||||
[ "$TEST_SHARD" = "smoke" ]; then
|
||||
timeout_budget=15m
|
||||
fi
|
||||
go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}"
|
||||
@@ -631,7 +827,7 @@ jobs:
|
||||
failed=0
|
||||
if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then
|
||||
for shard in \
|
||||
"changed packages:$FOCUSED_RESULT" \
|
||||
"focused shards:$FOCUSED_RESULT" \
|
||||
"race shards:$RACE_RESULT" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT" \
|
||||
"cross-platform compile:$CROSS_PLATFORM_RESULT" \
|
||||
@@ -660,7 +856,7 @@ jobs:
|
||||
release_expected=success
|
||||
fi
|
||||
for shard in \
|
||||
"changed packages:$FOCUSED_RESULT:$focused_expected" \
|
||||
"focused shards:$FOCUSED_RESULT:$focused_expected" \
|
||||
"race shards:$RACE_RESULT:$race_expected" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT:$release_expected" \
|
||||
"cross-platform compile:$CROSS_PLATFORM_RESULT:success"
|
||||
@@ -831,7 +1027,7 @@ jobs:
|
||||
coverage-current:
|
||||
name: Coverage (current)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -846,10 +1042,6 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
if: needs.lint.outputs.full_suite == 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
@@ -871,41 +1063,33 @@ jobs:
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Run current unit tests with coverage
|
||||
- name: Run scoped unit tests with coverage
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$FULL_SUITE" = true ]; then
|
||||
changed_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
|
||||
)"
|
||||
impacted_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
|
||||
)"
|
||||
if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then
|
||||
printf 'mode: atomic\n' > coverage.txt
|
||||
echo "No buildable Go package needs scoped coverage." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
mapfile -t changed_packages <<< "$changed_output"
|
||||
mapfile -t impacted_packages <<< "$impacted_output"
|
||||
coverpkg="$(IFS=,; echo "${changed_packages[*]}")"
|
||||
go test -count=1 -p 1 \
|
||||
-coverpkg="$coverpkg" \
|
||||
-coverprofile=coverage.txt \
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
else
|
||||
changed_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
|
||||
)"
|
||||
impacted_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
|
||||
)"
|
||||
if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then
|
||||
printf 'mode: atomic\n' > coverage.txt
|
||||
echo "No buildable Go package needs scoped coverage." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
mapfile -t changed_packages <<< "$changed_output"
|
||||
mapfile -t impacted_packages <<< "$impacted_output"
|
||||
coverpkg="$(IFS=,; echo "${changed_packages[*]}")"
|
||||
go test -count=1 -p 1 \
|
||||
-coverpkg="$coverpkg" \
|
||||
-coverprofile=coverage.txt \
|
||||
-covermode=atomic \
|
||||
"${impacted_packages[@]}"
|
||||
fi
|
||||
"${impacted_packages[@]}"
|
||||
fi
|
||||
if [ "$(wc -l < coverage.txt)" -gt 1 ]; then
|
||||
go tool cover -func=coverage.txt
|
||||
@@ -918,6 +1102,66 @@ jobs:
|
||||
path: coverage.txt
|
||||
retention-days: 1
|
||||
|
||||
coverage-current-full:
|
||||
name: "Coverage (current: ${{ matrix.shard }})"
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- app
|
||||
- cli
|
||||
- generators
|
||||
- helpers
|
||||
- remaining
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
# Each shard keeps -p 1 so the authoritative measurement stays serial
|
||||
# inside one instrumented process group; shards run on isolated runners,
|
||||
# and scripts/ci/test-packages.sh verify proves the shard union equals
|
||||
# the previous single full-suite package set exactly once.
|
||||
- name: Run current shard tests with coverage
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
COVERAGE_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
package_output="$(./scripts/ci/test-packages.sh list-coverage "$COVERAGE_SHARD")"
|
||||
test -n "$package_output"
|
||||
mapfile -t packages <<< "$package_output"
|
||||
test "${#packages[@]}" -gt 0
|
||||
go test -count=1 -p 1 \
|
||||
-coverprofile="coverage-shard-$COVERAGE_SHARD.txt" \
|
||||
-covermode=atomic \
|
||||
"${packages[@]}"
|
||||
go tool cover -func="coverage-shard-$COVERAGE_SHARD.txt" | tail -n 1
|
||||
|
||||
- name: Upload current shard coverage profile
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-current-shard-${{ matrix.shard }}
|
||||
path: coverage-shard-${{ matrix.shard }}.txt
|
||||
retention-days: 1
|
||||
|
||||
coverage-supporting:
|
||||
name: Coverage (supporting)
|
||||
needs: lint
|
||||
@@ -971,14 +1215,11 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
id: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
if: needs.lint.outputs.full_suite == 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
@@ -996,7 +1237,34 @@ jobs:
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
# The merge-base full-suite profile is a pure function of the base
|
||||
# commit. Reuse the profile published by the last green push run of
|
||||
# exactly that commit instead of re-running the whole suite; any key
|
||||
# mismatch falls back to authoritative recomputation. Exact key only,
|
||||
# never prefix fallback: a near-miss profile would compare the
|
||||
# candidate against the wrong commit.
|
||||
- name: Restore cached merge-base coverage profile
|
||||
id: baseline-cache
|
||||
if: needs.lint.outputs.full_suite == 'true'
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Materialize cached merge-base coverage profile
|
||||
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
cp coverage-cache.txt coverage-base.txt
|
||||
|
||||
- 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
|
||||
|
||||
- name: Run baseline unit tests with coverage
|
||||
if: steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
@@ -1045,6 +1313,21 @@ jobs:
|
||||
fi
|
||||
)
|
||||
|
||||
- name: Prepare merge-base coverage profile cache
|
||||
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-base.txt
|
||||
test "$(head -n 1 coverage-base.txt)" = "mode: atomic"
|
||||
cp coverage-base.txt coverage-cache.txt
|
||||
|
||||
- name: Save merge-base coverage profile cache
|
||||
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Upload baseline coverage profile
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -1057,6 +1340,7 @@ jobs:
|
||||
needs:
|
||||
- lint
|
||||
- coverage-current
|
||||
- coverage-current-full
|
||||
- coverage-supporting
|
||||
- coverage-baseline
|
||||
- coverage-darwin
|
||||
@@ -1072,6 +1356,7 @@ jobs:
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
|
||||
CURRENT_RESULT: ${{ needs.coverage-current.result }}
|
||||
CURRENT_FULL_RESULT: ${{ needs.coverage-current-full.result }}
|
||||
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
|
||||
BASELINE_RESULT: ${{ needs.coverage-baseline.result }}
|
||||
DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
|
||||
@@ -1079,6 +1364,7 @@ jobs:
|
||||
run: |
|
||||
failed=0
|
||||
current_expected=success
|
||||
current_full_expected=skipped
|
||||
supporting_expected=skipped
|
||||
baseline_expected=success
|
||||
native_expected=skipped
|
||||
@@ -1086,6 +1372,8 @@ jobs:
|
||||
current_expected=skipped
|
||||
baseline_expected=skipped
|
||||
elif [ "$FULL_SUITE" = true ]; then
|
||||
current_expected=skipped
|
||||
current_full_expected=success
|
||||
supporting_expected=success
|
||||
fi
|
||||
if [ "$CHANGELOG_ONLY" != true ] &&
|
||||
@@ -1096,6 +1384,7 @@ jobs:
|
||||
|
||||
for profile in \
|
||||
"current:$CURRENT_RESULT:$current_expected" \
|
||||
"current shards:$CURRENT_FULL_RESULT:$current_full_expected" \
|
||||
"supporting:$SUPPORTING_RESULT:$supporting_expected" \
|
||||
"baseline:$BASELINE_RESULT:$baseline_expected"
|
||||
do
|
||||
@@ -1131,6 +1420,7 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
id: setup-go
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
@@ -1154,11 +1444,12 @@ jobs:
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download current coverage profile
|
||||
- name: Download current coverage profiles
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-current-profile
|
||||
pattern: coverage-current-*
|
||||
merge-multiple: true
|
||||
path: .
|
||||
|
||||
- name: Download supporting coverage profiles
|
||||
@@ -1175,6 +1466,26 @@ jobs:
|
||||
name: coverage-baseline-profile
|
||||
path: .
|
||||
|
||||
# Shard profiles cover disjoint package sets, so their block-level
|
||||
# concatenation is the same candidate profile one serial run produced.
|
||||
# Every expected shard must be present; a missing shard would silently
|
||||
# shrink the scope-matched overall comparison.
|
||||
- name: Assemble full-suite coverage profile
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test ! -f coverage.txt
|
||||
for shard in app cli generators helpers remaining; do
|
||||
profile="coverage-shard-$shard.txt"
|
||||
test -f "$profile"
|
||||
test "$(head -n 1 "$profile")" = "mode: atomic"
|
||||
done
|
||||
printf 'mode: atomic\n' > coverage.txt
|
||||
for shard in app cli generators helpers remaining; do
|
||||
tail -n +2 "coverage-shard-$shard.txt" >> coverage.txt
|
||||
done
|
||||
|
||||
- name: Enforce coverage gate
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
env:
|
||||
@@ -1195,6 +1506,26 @@ jobs:
|
||||
COVERAGE_ADDITIONAL_DIFF_PROFILE="$additional_profile" \
|
||||
make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
|
||||
|
||||
# Publish this push's full-suite profile as the merge-base cache for
|
||||
# future PRs whose merge-base is exactly this commit. Saved only after
|
||||
# the gate passed so a broken run never becomes a baseline. Both producer
|
||||
# and consumer use coverage-cache.txt because the cache version includes
|
||||
# the configured path as well as the compression tool.
|
||||
- name: Prepare push coverage profile as merge-base cache
|
||||
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage.txt
|
||||
test "$(head -n 1 coverage.txt)" = "mode: atomic"
|
||||
cp coverage.txt coverage-cache.txt
|
||||
|
||||
- name: Save push coverage profile as merge-base cache
|
||||
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Generate coverage report
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
run: |
|
||||
@@ -1256,6 +1587,7 @@ jobs:
|
||||
env:
|
||||
CLASSIFIED_CHANGELOG_CHANGED: ${{ needs.lint.outputs.changelog_changed }}
|
||||
CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }}
|
||||
RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -eu
|
||||
@@ -1282,7 +1614,7 @@ jobs:
|
||||
fi
|
||||
|
||||
mode=--content-only
|
||||
if [ "$CHANGELOG_ONLY" = true ]; then
|
||||
if [ "$CHANGELOG_ONLY" = true ] && [ "$RELEASE_SEAL_ONLY" != true ]; then
|
||||
mode=--fast-path
|
||||
fi
|
||||
./scripts/policy/check-changelog-pr.sh \
|
||||
@@ -1294,25 +1626,41 @@ jobs:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: ./scripts/policy/check-release-fragments.sh "$PR_BASE_SHA" HEAD
|
||||
|
||||
- name: Validate trusted main CHANGELOG-only push
|
||||
- name: Validate trusted main metadata-only push
|
||||
if: github.event_name == 'push' && needs.lint.outputs.changelog_only == 'true'
|
||||
env:
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
PUSH_AFTER_SHA: ${{ github.event.after }}
|
||||
RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }}
|
||||
run: |
|
||||
set -eu
|
||||
test "$(git rev-parse HEAD)" = "$PUSH_AFTER_SHA" || {
|
||||
echo "checked-out push revision does not match event after SHA" >&2
|
||||
exit 1
|
||||
}
|
||||
mode=--fast-path
|
||||
if [ "$RELEASE_SEAL_ONLY" = true ]; then
|
||||
mode=--content-only
|
||||
fi
|
||||
./scripts/policy/check-changelog-pr.sh \
|
||||
--fast-path "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"
|
||||
"$mode" "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"
|
||||
if [ "$RELEASE_SEAL_ONLY" = true ]; then
|
||||
./scripts/policy/check-release-fragments.sh \
|
||||
"$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"
|
||||
fi
|
||||
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: needs.lint.outputs.changelog_only == 'true'
|
||||
env:
|
||||
RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }}
|
||||
run: |
|
||||
echo "Only the trusted base-equivalent CHANGELOG validator ran; executable sources are unchanged." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
if [ "$RELEASE_SEAL_ONLY" = true ]; then
|
||||
echo "Only the trusted release-seal and fragment validators ran; executable sources are unchanged." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "Only the trusted base-equivalent CHANGELOG validator ran; executable sources are unchanged." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Validate scoped policy
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && (needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.interface_sensitive != 'true')) }}
|
||||
|
||||
@@ -2645,6 +2645,40 @@ jobs:
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
|
||||
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
|
||||
|
||||
# The sealed candidate tag is intentionally visible while its GitHub
|
||||
# authority is checked above. Compatibility must instead discover the
|
||||
# previous delivered stable tag, so hide only this verified candidate
|
||||
# from this isolated runner's local tag namespace.
|
||||
- name: Prepare delivered-stable compatibility ref view
|
||||
if: ${{ matrix.check == 'compatibility' }}
|
||||
env:
|
||||
RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }}
|
||||
RELEASE_COMMIT: ${{ needs.release-contract.outputs.release_commit }}
|
||||
RELEASE_TAG_OBJECT: ${{ needs.release-contract.outputs.release_tag_object }}
|
||||
PREVIOUS_STABLE: ${{ needs.release-contract.outputs.previous_stable }}
|
||||
PREVIOUS_STABLE_COMMIT: ${{ needs.release-contract.outputs.previous_stable_commit }}
|
||||
run: |
|
||||
set -eu
|
||||
test -n "$RELEASE_VERSION"
|
||||
test -n "$RELEASE_COMMIT"
|
||||
test -n "$RELEASE_TAG_OBJECT"
|
||||
test -n "$PREVIOUS_STABLE"
|
||||
test -n "$PREVIOUS_STABLE_COMMIT"
|
||||
test "$RELEASE_VERSION" != "$PREVIOUS_STABLE"
|
||||
test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"
|
||||
test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}")" = "$RELEASE_TAG_OBJECT"
|
||||
test "$(git rev-parse --verify "refs/tags/${RELEASE_VERSION}^{commit}")" = "$RELEASE_COMMIT"
|
||||
test "$(git rev-parse --verify "${PREVIOUS_STABLE}^{commit}")" = "$PREVIOUS_STABLE_COMMIT"
|
||||
|
||||
git update-ref -d "refs/tags/${RELEASE_VERSION}" "$RELEASE_TAG_OBJECT"
|
||||
|
||||
if git show-ref --verify --quiet "refs/tags/${RELEASE_VERSION}"; then
|
||||
echo "sealed candidate tag is still visible to compatibility baseline discovery" >&2
|
||||
exit 2
|
||||
fi
|
||||
test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT"
|
||||
test "$(git rev-parse --verify "${PREVIOUS_STABLE}^{commit}")" = "$PREVIOUS_STABLE_COMMIT"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
@@ -20,6 +20,10 @@ test/cli_compat/testdata/
|
||||
.gitignore
|
||||
.worktrees/
|
||||
.qoder/
|
||||
_logs/
|
||||
_docs/
|
||||
_output/
|
||||
vendor/
|
||||
|
||||
# Secrets & credentials
|
||||
.env
|
||||
|
||||
+133
@@ -6,6 +6,139 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.59-beta.2] - 2026-08-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Privacy-safe CLI telemetry** (#1009) — reports reviewed command outcomes and profile identity dimensions while excluding command arguments, output, paths, device fingerprints, and automatic system dimensions; `DO_NOT_TRACK=1` disables reporting.
|
||||
|
||||
- **Feedback survey entry in root help** (#1019) — `dws --help` now closes with a Feedback section linking the user-experience survey form.
|
||||
|
||||
- **Wiki Shortcut workflows** — publishes 20 reviewed space, member, node, and
|
||||
activity shortcuts with strict collection validation, cursor handling,
|
||||
write-terminal evidence, safe read-backs where the backend supports them,
|
||||
task-oriented routing, and documented backend
|
||||
boundaries.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat IM ID flags** (#954) — standardizes chat command entry points on `--conversation-id` for conversation IDs and `--message-id` for message IDs, so help, Schema, and Agent recommendations use the same canonical flags.
|
||||
- **Legacy chat flag compatibility** (#954) — keeps older chat IM ID flags such as `--group`, `--id`, `--chat`, `--open-conversation-id`, `--msg-id`, and `--open-message-id` working as compatibility aliases where applicable, while hiding migrated aliases from recommended help and Schema surfaces.
|
||||
- **Chat group bots target flag** (#954) — keeps `dws chat group bots` on the visible `--group` flag; this command does not register `--group-name`, and `--group` accepts either an openConversationId or a uniquely resolved group name.
|
||||
|
||||
- **Faster Schema Catalog assembly** — projects typed values into payload JSON
|
||||
without re-running a validation scan over documents `json.Marshal` has just
|
||||
produced, cutting roughly a third of the projection work across the full tool
|
||||
set. Untrusted JSON input keeps its existing validation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Chat card update evidence** — distinguishes an accepted update request from an independently verified visible update, preserving the real `bizId` and warning callers not to repeat an unverified write.
|
||||
- **Chat command guidance** — splits message and group references by task and explains that `--from` is ambiguous between sender and time-range intent.
|
||||
|
||||
|
||||
## [1.0.59-beta.1] - 2026-08-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Drive list type/time filtering** (#942) — `dws drive list` gains `--type
|
||||
file|folder`, `--start`, and `--end` for client-side filtering by node type
|
||||
and modification time on both the pan and workspace routes. Filtering runs
|
||||
a bounded full scan of the target directory (2000-entry cap, reported via
|
||||
`truncated=true`), composes with `--latest`/`--pattern`/`--depth`, and is
|
||||
mutually exclusive with `--versions`/`--cursor`/`--order-by`/`--order`/
|
||||
`--limit`. Time values accept relative forms (`24h`/`7d`/`2w`), RFC 3339,
|
||||
zone-less ISO 8601 (Asia/Shanghai), or a plain date.
|
||||
|
||||
- **Drive folder synchronization** — adds `dws drive status`, `dws drive pull`,
|
||||
`dws drive push`, and `dws drive sync` for file-level comparison and transfer
|
||||
between a local folder and a Drive folder. Differences come from exact MD5 by
|
||||
default or from modification time with `--quick`; `status` is read-only, `pull`
|
||||
and `push` are one-directional with `--if-exists skip|smart|overwrite`, and
|
||||
`sync` is bidirectional with `--on-conflict remote-wins|local-wins|keep-both|ask`.
|
||||
Only regular files are transferred — online documents and shortcuts are skipped,
|
||||
neither side deletes extra files, downloads are staged through a temporary file
|
||||
and committed with an atomic rename, and remote names that would escape
|
||||
`--local-folder` are reported as failures instead of being written. Every command
|
||||
prints a structured summary on stdout and exits non-zero when any item fails.
|
||||
|
||||
- **International DingTalk region support** — adds `.io` login and MCP routing, pre-release endpoint overrides, and profile-aware gateway selection while preserving the existing `.com` flow.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat identity routing** — validates explicit `openDingTalkId` inputs and improves name, `userId`, and `openDingTalkId` routing for message shortcuts.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Drive `--latest` refuses incomplete Top-N** (#899) — `dws drive list --latest` used to
|
||||
exit 0 with a "Top-N" computed over a partially scanned tree whenever a directory read
|
||||
failed mid-recursion (permission denied, API error), letting an incomplete set pose as the
|
||||
globally newest files. Truncation at the 2000-item scan cap and mid-recursion directory
|
||||
failures now both fail closed (`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`), report
|
||||
the first failing folder with its depth and reason, and emit a recovery command that
|
||||
reproduces the original candidate set — query domain, `--folder`, `--pattern`, `--type`,
|
||||
`--start` and `--end` are all carried over. On POSIX shells each user-supplied value is
|
||||
quoted so a URL query string or a shell metacharacter cannot change how the copied command
|
||||
parses. On Windows no quoting form is safe for both `cmd.exe` and PowerShell, so values
|
||||
containing metacharacters are not inlined at all: the command carries a placeholder and the
|
||||
original value is shown on a separate line marked as data rather than an executable command.
|
||||
Unrecoverable errors under `--latest` return the root cause instead of a partial result.
|
||||
Remote-controlled folder names and server error text are stripped of ANSI escapes and
|
||||
control characters before they reach the plain-text stderr message. The internal `sortTime`
|
||||
sort key no longer leaks into `drive list --depth` output on any path.
|
||||
|
||||
- **Drive list pattern filtering** (#942) — `dws drive list --pattern` on the
|
||||
single-layer pan route now filters the returned page by name pattern; the
|
||||
flag was previously accepted but silently ignored.
|
||||
|
||||
- **Drive list `--type folder --latest` composition** (#942) — `--latest` now
|
||||
ranks the filtered entries (folders included when `--type folder` is set)
|
||||
instead of unconditionally dropping folders, so the documented combination
|
||||
returns the most recently modified folders rather than an empty list.
|
||||
|
||||
- **Chat message time defaults** (#973) — default omitted `chat message list-all` time bounds in `Asia/Shanghai` when emitting timezone-less `yyyy-MM-dd HH:mm:ss` values, matching parsing semantics and rejecting reversed windows.
|
||||
|
||||
- **Doc and Drive parameter aliases** — normalizes reviewed identifier, pagination, path, version, and role synonyms while blocking ambiguous values before dispatch.
|
||||
|
||||
|
||||
## [1.0.58] - 2026-08-13
|
||||
|
||||
This release promotes the sealed `v1.0.58-beta.6` contents to stable.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Expanded collaborative workflows** — adds full AI Table, Sheet, Minutes,
|
||||
approval-event, Drive-comment, document export, and CSV workflow support,
|
||||
including safer validation, explicit confirmation for writes, and
|
||||
machine-readable completion receipts.
|
||||
- **More capable Chat operations** — adds robot image/file messages, toolbar
|
||||
management, streaming-card mentions, automatic pagination controls, and
|
||||
clearer post-send ID, Markdown-image, paging, and result-shape guidance.
|
||||
- **Reliable Agent and CLI contracts** — expands Agent-visible Chat and
|
||||
Minutes commands, aligns bundled skills, improves schema/result envelopes,
|
||||
and hardens parameter, pagination, runtime-token, and write-result
|
||||
verification so ambiguous or incomplete operations fail closed.
|
||||
- **Multi-skill install and upgrade** — makes the multi-skill layout the
|
||||
default for fresh installs and upgrades while preserving an explicit legacy
|
||||
mono option.
|
||||
- **Safer release delivery** — strengthens release-equivalent compatibility,
|
||||
sealing, package verification, and evaluation-dispatch checks for more
|
||||
reliable cross-platform releases.
|
||||
|
||||
## [1.0.58-beta.6] - 2026-08-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- **npm package verification for multi-skill installs** (#991) — aligns the
|
||||
release verifier with the installer’s concrete Agent skill-root selection,
|
||||
preventing valid multi-skill package layouts from failing release delivery.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Release-seal CI classification** (#987) — recognizes the reviewed
|
||||
CHANGELOG-and-fragment archival shape while retaining release-contract and
|
||||
lifecycle validation, reducing unrelated CI work for release-seal PRs.
|
||||
|
||||
## [1.0.58-beta.5] - 2026-08-13
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.58-beta.4"
|
||||
version "1.0.59-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.58-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "5c2ac92e35b1f1dba80234af8b0c9505b2883f4a37c1e73892b8a1c3087b7702"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "7f11218d3222f3e93c3b1e94b3a004c061eb0a297b206447ea95fe6b2b1ec674"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "93ef787770105fe1f0d27585adcac7b740aa6c37ff490275c4113814541ae095"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "72f06a334cf29d23123639fabe13bf2951765066136e47ccc6453667c382f8c2"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "011ce16a73d8fd24275e34c3122d3d0832c60cde2480f496018eb654059b5c05"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "3edbfabb7718b53914a2d9efe7b1152e9ebd70765ff0b6f19ad3125e4a2458ed"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "847b17ff8a8d80dce38f0013eb35c77c102be16c9f98b955a632b983cd5ec104"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "e1c610070a9c1b3763656cbd53c818290f199097adc07cb9fe629ed765c5e1e0"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-skills.zip"
|
||||
sha256 "f5e0c72cc92cb7e8886409319cf68bbbfc7740e969bd39a389b74af4befdbc66"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-skills.zip"
|
||||
sha256 "aa2854651eaa2c857b526aaec73fd1358d31b11999fe7fd3e99e5060b2522a1f"
|
||||
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.57"
|
||||
version "1.0.58"
|
||||
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.57/dws-darwin-arm64.tar.gz"
|
||||
sha256 "c01c28dc13948a70fca905207073dc8dbd22f7ba7fc90e68b3316eb9a9c98e88"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-arm64.tar.gz"
|
||||
sha256 "7d98599f90cae9d42b51ff2863efc87dbfb4a3176ff3c84fc2216110c0157a70"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-amd64.tar.gz"
|
||||
sha256 "d7baa218beefc851c6a933b456055195f8272984ce008d7e0122bdfc5dad94ea"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-amd64.tar.gz"
|
||||
sha256 "4c12e35e5bf7e0905812cd42dc94a5345068a2c16e306bb50b13c5c78b5cb95d"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-arm64.tar.gz"
|
||||
sha256 "0bbe9c233a3ff585077bae1ac5000937c32d967846d14cc44c46f98d49b95ae2"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-arm64.tar.gz"
|
||||
sha256 "5ef6bde24bc3db6a11a0f1d0b3343a048956b2cbcf6cd3409a037fb6ba425489"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-amd64.tar.gz"
|
||||
sha256 "f113ce3654f21d1f9ecc7c196f815aeafbca54d377a347b244a15116c5cba698"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-amd64.tar.gz"
|
||||
sha256 "3ccadcc6f070a39d2b2ba20429a4fcdc2f21639bf79f34361dc7d16f501bfda6"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-skills.zip"
|
||||
sha256 "0c9667209cf30761427a8f9348149cbbf1e397aa3c25587e99f205bc7525e101"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-skills.zip"
|
||||
sha256 "2626debc21c3daadfd155b4c167b2219b97e801398fe4441a8b48138960ab264"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -18,7 +18,7 @@ help:
|
||||
@printf "Available targets:\n"
|
||||
@printf " make build - Build the dws CLI binary\n"
|
||||
@printf " make test - Run the Go test suite\n"
|
||||
@printf " make test-plan - Verify every default Go package belongs to one CI test shard\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 lint - Run formatting checks, go vet, and staticcheck\n"
|
||||
@printf " make format-check - Check all repository Go source files with gofmt\n"
|
||||
|
||||
@@ -786,6 +786,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
|
||||
|
||||
## Reference & Docs
|
||||
|
||||
- [International DingTalk (`.io`) guide](./docs/international-region-guide.md) — international login, domestic/international profile switching, isolated testing, and troubleshooting
|
||||
- [Command Index](./docs/command-index.md) — every runtime command with description and when-to-use guidance
|
||||
- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
|
||||
- [Architecture](./docs/architecture.md) — static endpoint pipeline, command surface, transport layer
|
||||
|
||||
@@ -777,6 +777,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
|
||||
|
||||
## 参考与文档
|
||||
|
||||
- [国际版(`.io`)使用手册](./docs/international-region-guide.zh-CN.md) — 国际版登录、国内/国际 profile 切换、隔离验证与排障
|
||||
- [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景
|
||||
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
|
||||
- [架构设计](./docs/architecture.md) — 静态端点管道、命令面、Transport 层
|
||||
|
||||
+66
-2
@@ -15,12 +15,76 @@ package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/clitrack"
|
||||
)
|
||||
|
||||
var exit = os.Exit
|
||||
var (
|
||||
appExecute = app.ExecuteWithTelemetry
|
||||
resolveTelemetryIdentity = app.ResolveTelemetryIdentity
|
||||
trackRun = func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
clitrack.New(cfg).Run(execute, exitCode)
|
||||
}
|
||||
)
|
||||
|
||||
// trackedExitError tells clitrack that the command failed without asking it to
|
||||
// print the error a second time. The already-rendered message is published via
|
||||
// ExtraFields c5, while app.Execute remains the sole owner of presentation.
|
||||
type trackedExitError struct{}
|
||||
|
||||
func (trackedExitError) Error() string { return "" }
|
||||
|
||||
func trackerConfig(identity app.TelemetryIdentity, commandPath, errorMessage *string) clitrack.Config {
|
||||
return clitrack.Config{
|
||||
PID: "wcCRwZ",
|
||||
App: "dws",
|
||||
Version: app.RawVersion(),
|
||||
UID: identity.UserID,
|
||||
Username: identity.UserName,
|
||||
NoCommandLine: true,
|
||||
NoCwd: true,
|
||||
NoAutomaticDimensions: true,
|
||||
ExtraFields: func() map[string]string {
|
||||
fields := map[string]string{"c9": *commandPath}
|
||||
if identity.CorpID != "" {
|
||||
fields["c10"] = identity.CorpID
|
||||
}
|
||||
if *errorMessage != "" {
|
||||
fields["c5"] = *errorMessage
|
||||
}
|
||||
return fields
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func telemetryOptedOut() bool {
|
||||
return strings.TrimSpace(os.Getenv("DO_NOT_TRACK")) != ""
|
||||
}
|
||||
|
||||
func main() {
|
||||
exit(app.Execute())
|
||||
optedOut := telemetryOptedOut()
|
||||
identity := app.TelemetryIdentity{}
|
||||
if !optedOut {
|
||||
identity = resolveTelemetryIdentity(os.Args[1:])
|
||||
}
|
||||
exitCode := 0
|
||||
commandPath := "dws"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(identity, &commandPath, &errorMessage)
|
||||
if optedOut {
|
||||
cfg.PID = ""
|
||||
}
|
||||
trackRun(
|
||||
cfg,
|
||||
func() error {
|
||||
exitCode, commandPath, errorMessage = appExecute()
|
||||
if exitCode != 0 {
|
||||
return trackedExitError{}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
func(error) int { return exitCode },
|
||||
)
|
||||
}
|
||||
|
||||
+206
-13
@@ -1,27 +1,220 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/clitrack"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMainExitsWithSuccessfulVersionCommand(t *testing.T) {
|
||||
previousExit := exit
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
exit = previousExit
|
||||
os.Args = previousArgs
|
||||
})
|
||||
func TestCrossPlatformCoverageMainRunsThroughCLITracker(t *testing.T) {
|
||||
for _, wantCode := range []int{0, 1, 3, 5} {
|
||||
t.Run(fmt.Sprintf("exit_%d", wantCode), func(t *testing.T) {
|
||||
t.Setenv("DO_NOT_TRACK", "")
|
||||
wantError := ""
|
||||
if wantCode != 0 {
|
||||
wantError = "synthetic failure"
|
||||
}
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "sheet", "read", "--profile", "corp-a"})
|
||||
testseam.Swap(t, &resolveTelemetryIdentity, func(args []string) app.TelemetryIdentity {
|
||||
if strings.Join(args, " ") != "sheet read --profile corp-a" {
|
||||
t.Fatalf("telemetry identity args = %#v", args)
|
||||
}
|
||||
return app.TelemetryIdentity{UserID: "user-1", UserName: "Alice", CorpID: "corp-1"}
|
||||
})
|
||||
testseam.Swap(t, &appExecute, func() (int, string, string) { return wantCode, "sheet read", wantError })
|
||||
called := false
|
||||
testseam.Swap(t, &trackRun, func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
called = true
|
||||
if cfg.PID != "wcCRwZ" || cfg.App != "dws" {
|
||||
t.Fatalf("tracker identity = PID %q App %q", cfg.PID, cfg.App)
|
||||
}
|
||||
if cfg.Version != app.RawVersion() {
|
||||
t.Fatalf("tracker Version = %q, want %q", cfg.Version, app.RawVersion())
|
||||
}
|
||||
if !cfg.NoCommandLine || !cfg.NoCwd || !cfg.NoAutomaticDimensions || cfg.CaptureOutput {
|
||||
t.Fatalf("tracker privacy config = NoCommandLine %v NoCwd %v NoAutomaticDimensions %v CaptureOutput %v", cfg.NoCommandLine, cfg.NoCwd, cfg.NoAutomaticDimensions, cfg.CaptureOutput)
|
||||
}
|
||||
if cfg.Env != "" || cfg.EventID != "" || cfg.Endpoint != "" || cfg.FlushTimeout != 0 || cfg.OutputMaxLen != 0 {
|
||||
t.Fatalf("tracker SDK defaults were overridden: %#v", cfg)
|
||||
}
|
||||
if cfg.UID != "user-1" || cfg.Username != "Alice" || cfg.UserType != "" {
|
||||
t.Fatalf("tracker user identity = UID %q Username %q UserType %q", cfg.UID, cfg.Username, cfg.UserType)
|
||||
}
|
||||
|
||||
err := execute()
|
||||
if wantCode == 0 && err != nil {
|
||||
t.Fatalf("successful tracked execute error = %v", err)
|
||||
}
|
||||
if wantCode != 0 && (err == nil || err.Error() != "") {
|
||||
t.Fatalf("failed tracked execute error = %#v, want empty sentinel", err)
|
||||
}
|
||||
if gotCode := exitCode(err); gotCode != wantCode {
|
||||
t.Fatalf("tracked exit code = %d, want %d", gotCode, wantCode)
|
||||
}
|
||||
fields := cfg.ExtraFields()
|
||||
if fields["c9"] != "sheet read" || fields["c10"] != "corp-1" || fields["c5"] != wantError {
|
||||
t.Fatalf("tracker extra fields = %#v, want command path, corp ID, and error %q", fields, wantError)
|
||||
}
|
||||
if (wantError == "" && len(fields) != 2) || (wantError != "" && len(fields) != 3) {
|
||||
t.Fatalf("tracker extra field count = %d for error %q", len(fields), wantError)
|
||||
}
|
||||
})
|
||||
|
||||
main()
|
||||
if !called {
|
||||
t.Fatalf("trackRun was not called for exit code %d", wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTrackerConfigOmitsEmptyOrganization(t *testing.T) {
|
||||
commandPath := "version"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(app.TelemetryIdentity{}, &commandPath, &errorMessage)
|
||||
if cfg.UID != "" {
|
||||
t.Fatalf("empty identity UID = %q", cfg.UID)
|
||||
}
|
||||
if cfg.Username != "" {
|
||||
t.Fatalf("empty identity Username = %q", cfg.Username)
|
||||
}
|
||||
if fields := cfg.ExtraFields(); len(fields) != 1 || fields["c9"] != "version" {
|
||||
t.Fatalf("empty organization fields = %#v", fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDefaultTrackRunNoopTracker(t *testing.T) {
|
||||
called := false
|
||||
code := -1
|
||||
exit = func(value int) {
|
||||
trackRun(clitrack.Config{}, func() error {
|
||||
called = true
|
||||
code = value
|
||||
return nil
|
||||
}, nil)
|
||||
if !called {
|
||||
t.Fatal("default tracker did not execute callback")
|
||||
}
|
||||
os.Args = []string{"dws", "version"}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMainRespectsDoNotTrack(t *testing.T) {
|
||||
t.Setenv("DO_NOT_TRACK", "1")
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "version"})
|
||||
testseam.Swap(t, &resolveTelemetryIdentity, func([]string) app.TelemetryIdentity {
|
||||
t.Fatal("DO_NOT_TRACK must skip telemetry identity reads")
|
||||
return app.TelemetryIdentity{}
|
||||
})
|
||||
testseam.Swap(t, &appExecute, func() (int, string, string) { return 0, "version", "" })
|
||||
testseam.Swap(t, &trackRun, func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
if cfg.PID != "" || cfg.UID != "" || cfg.Username != "" {
|
||||
t.Fatalf("opted-out tracker config = %#v", cfg)
|
||||
}
|
||||
if err := execute(); err != nil {
|
||||
t.Fatalf("opted-out execution failed: %v", err)
|
||||
}
|
||||
if code := exitCode(nil); code != 0 {
|
||||
t.Fatalf("opted-out exit code = %d, want 0", code)
|
||||
}
|
||||
})
|
||||
|
||||
main()
|
||||
if !called || code != 0 {
|
||||
t.Fatalf("main exit = called %v, code %d", called, code)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTrackerPayloadUsesReviewedFieldWhitelist(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "sheet", "read", "--access-token", "must-not-leak"}
|
||||
t.Setenv("SHELL", "/bin/zsh")
|
||||
t.Setenv("TERM_SESSION_ID", "stable-session")
|
||||
t.Setenv("TMUX_PANE", "%42")
|
||||
t.Setenv("LANG", "zh_CN.UTF-8")
|
||||
t.Setenv("LC_ALL", "zh_CN.UTF-8")
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
requestBody := make(chan []byte, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
requestBody <- body
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
commandPath := "sheet read"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(app.TelemetryIdentity{UserID: "user-1", UserName: "Alice", CorpID: "corp-1"}, &commandPath, &errorMessage)
|
||||
cfg.Endpoint = server.URL
|
||||
cfg.FlushTimeout = time.Second
|
||||
clitrack.New(cfg).Run(func() error { return nil }, nil)
|
||||
|
||||
var body []byte
|
||||
select {
|
||||
case body = <-requestBody:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for telemetry request")
|
||||
}
|
||||
var envelope map[string]string
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
t.Fatalf("decode telemetry request %q: %v", body, err)
|
||||
}
|
||||
decoded, err := url.QueryUnescape(envelope["gokey"])
|
||||
if err != nil {
|
||||
t.Fatalf("decode gokey: %v", err)
|
||||
}
|
||||
globalFields, err := url.ParseQuery(decoded)
|
||||
if err != nil {
|
||||
t.Fatalf("parse global telemetry fields: %v", err)
|
||||
}
|
||||
eventFields, err := url.ParseQuery(globalFields.Get("msg"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse event telemetry fields: %v", err)
|
||||
}
|
||||
|
||||
assertTelemetryKeys(t, globalFields, []string{"app_name", "app_version", "env", "msg", "pid", "platform", "uid", "username", "version"})
|
||||
assertTelemetryKeys(t, eventFields, []string{"c1", "c10", "c3", "c4", "c9", "p1", "p4", "ts", "type"})
|
||||
for key, want := range map[string]string{
|
||||
"app_name": "dws", "app_version": app.RawVersion(), "env": "prod", "pid": "wcCRwZ",
|
||||
"platform": "cli", "uid": "user-1", "username": "Alice", "version": app.RawVersion(),
|
||||
} {
|
||||
if got := globalFields.Get(key); got != want {
|
||||
t.Fatalf("global telemetry field %s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"type": "event", "p1": "cli.exec", "p4": "SYS", "c1": "dws", "c3": "0", "c9": "sheet read", "c10": "corp-1",
|
||||
} {
|
||||
if got := eventFields.Get(key); got != want {
|
||||
t.Fatalf("event telemetry field %s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"device_id", "ext", "os", "os_version", "pv_id", "sdk_version", "sid", "timezone_offset"} {
|
||||
if globalFields.Has(key) {
|
||||
t.Fatalf("global telemetry leaked %s: %q", key, decoded)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"c2", "c5", "c6", "c7", "c8"} {
|
||||
if eventFields.Has(key) {
|
||||
t.Fatalf("event telemetry leaked %s: %q", key, globalFields.Get("msg"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertTelemetryKeys(t *testing.T, fields url.Values, want []string) {
|
||||
t.Helper()
|
||||
got := make([]string, 0, len(fields))
|
||||
for key := range fields {
|
||||
got = append(got, key)
|
||||
}
|
||||
sort.Strings(got)
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("telemetry keys = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+15
-2
@@ -201,8 +201,21 @@ base_ref=$(git merge-base HEAD origin/main)
|
||||
standard PR, CI derives changed packages and their reverse-dependency test
|
||||
closure, then generates candidate and merge-base profiles with the same test
|
||||
scope and `coverpkg`. High-risk and protected-main runs use the complete
|
||||
profiles. Supporting and (when platform-selected) native profiles are
|
||||
generated before the aggregate `Coverage` context evaluates them. The
|
||||
profiles. The complete candidate profile is produced by disjoint per-shard
|
||||
helper jobs (`scripts/ci/test-packages.sh list-coverage`, kept serial with
|
||||
`-p 1` inside each shard; `verify` proves the shard union equals the
|
||||
full-suite scope exactly once) and concatenated in the aggregate job before
|
||||
enforcement. The complete merge-base profile is restored from an exact-key
|
||||
cache written by the last green `main` push of that same commit (key:
|
||||
merge-base SHA plus resolved Go version); any miss falls back to recomputing
|
||||
it in a merge-base worktree. The trusted `main` producer and PR consumer use
|
||||
the same dedicated cache profile path because GitHub includes that path in the
|
||||
cache version; the runtime-facing candidate and baseline filenames remain
|
||||
separate. Near-miss reuse is forbidden — the caches carry no prefix restore
|
||||
keys, because a neighbouring commit's profile would compare the candidate
|
||||
against the wrong baseline. Supporting and (when
|
||||
platform-selected) native profiles are generated before the aggregate
|
||||
`Coverage` context evaluates them. The
|
||||
aggregate and native gates require 100% coverage for changed executable Go
|
||||
statements. Overall coverage remains an unrounded, zero-tolerance,
|
||||
scope-matched merge-base non-regression check. Candidate and baseline profiles
|
||||
|
||||
@@ -92,6 +92,7 @@ command/Leaf 不再写 `dws.schema.risk`;SafetySpec 走类型化 Final 载荷
|
||||
| `Required` / `MarkRequired` | 非空校验 / cobra 硬必填 | 是(`required`) |
|
||||
| `RequiredHint`, `Aliases`, `EnvVar` | 校验提示、隐藏别名、环境回退 | 否(执行细节;别名不上主 parameter 表) |
|
||||
| `ArgDefault`, `Bind`, `OmitEmpty`, `Trim`, `Transform` | toolArgs 装配语义 | 否(载荷细节;`Bind` 可进 property 映射,但不另造 flag) |
|
||||
| `Input` | 额外取值来源:`@path` 读文件 / `-` 读 stdin,在 required/enum/约束/`Validate` 之前原地解析 | 否(今日:能力由作者写进 `Usage` / `SchemaDescription` 文案,是已声明事实而非推断;不另造 flag。目标形态收敛为类型化投影字段,见 RFC §5.3) |
|
||||
|
||||
#### 1.2.2 编排 / 执行字段(不算声明)
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# International DingTalk (`.io`) Guide
|
||||
|
||||
This guide explains how to log in to the international DingTalk region and run DWS commands against `*.dingtalk.io` services.
|
||||
|
||||
## Region behavior
|
||||
|
||||
- `dws auth login --intl` creates or refreshes an international login using the `.io` login, OAuth, and MCP services.
|
||||
- Omitting `--intl` keeps the existing domestic `.com` behavior.
|
||||
- `--intl` is a login option, not a global option for business commands. After login, commands such as `contact`, `calendar`, and `doc` derive the region from the selected Token/profile.
|
||||
- Each new Token records its login region. Switching profiles therefore switches the official DingTalk gateway region automatically.
|
||||
- `--international` is a compatibility alias. Prefer `--intl` in new scripts.
|
||||
|
||||
For the complete Chinese guide, see [DWS 国际版(DingTalk `.io`)使用手册](./international-region-guide.zh-CN.md).
|
||||
|
||||
## Check availability
|
||||
|
||||
```bash
|
||||
dws auth login --help
|
||||
```
|
||||
|
||||
The help output must include `--intl` and `--international`.
|
||||
|
||||
When validating a source checkout, build it first and use `./dws` so an older binary on `PATH` is not invoked accidentally:
|
||||
|
||||
```bash
|
||||
make build
|
||||
./dws auth login --help
|
||||
```
|
||||
|
||||
## Log in
|
||||
|
||||
Browser login:
|
||||
|
||||
```bash
|
||||
dws auth login --intl
|
||||
```
|
||||
|
||||
Device flow for SSH, containers, and headless environments:
|
||||
|
||||
```bash
|
||||
dws auth login --intl --device
|
||||
```
|
||||
|
||||
User OAuth with custom application credentials:
|
||||
|
||||
```bash
|
||||
dws auth login --intl \
|
||||
--client-id <APP_KEY> \
|
||||
--client-secret <APP_SECRET>
|
||||
```
|
||||
|
||||
This mode still requires the user to complete OAuth authorization in a browser; it is not a userless `client_credentials` login. The application must be configured on the international developer platform with the required callback and permissions. Never commit an AppSecret to source control or include it in logs.
|
||||
|
||||
## Verify the login
|
||||
|
||||
```bash
|
||||
dws auth status --format json
|
||||
dws profile list --format json
|
||||
dws contact user get-self
|
||||
```
|
||||
|
||||
The last command is a read-only smoke check. If the organization has not enabled CLI access, an organization administrator must enable it or approve the access request on the international developer platform.
|
||||
|
||||
## Use domestic and international profiles together
|
||||
|
||||
```bash
|
||||
# Domestic (.com)
|
||||
dws auth login
|
||||
|
||||
# International (.io)
|
||||
dws auth login --intl
|
||||
|
||||
# Find the stable profile selectors
|
||||
dws profile list --format json
|
||||
```
|
||||
|
||||
Persistently switch profiles:
|
||||
|
||||
```bash
|
||||
dws profile switch <corpId>:<userId>
|
||||
```
|
||||
|
||||
Toggle back to the previous profile:
|
||||
|
||||
```bash
|
||||
dws profile switch -
|
||||
```
|
||||
|
||||
Select a profile for one command without changing the default:
|
||||
|
||||
```bash
|
||||
dws --profile <corpId>:<userId> contact user get-self
|
||||
```
|
||||
|
||||
Do not add `--intl` to business commands. DWS routes official endpoints from the selected profile's Token region.
|
||||
|
||||
## Isolated smoke testing
|
||||
|
||||
Use a separate configuration directory to avoid changing the normal `~/.dws` login state:
|
||||
|
||||
```bash
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth login --intl
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth status --format json
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws contact user get-self
|
||||
```
|
||||
|
||||
Use the same `DWS_CONFIG_DIR` for every command. Use `./dws` for a source build and `dws` for an installed release.
|
||||
|
||||
## Pre-release overrides (maintainers only)
|
||||
|
||||
Normal international users need only `--intl`; they should not set `--pre-url` or `--mcp-url`.
|
||||
|
||||
Maintainers can test the pre-release login/MCP pair with:
|
||||
|
||||
```bash
|
||||
dws auth login --intl --pre-url https://pre-login.dingtalk.io
|
||||
```
|
||||
|
||||
A corresponding `pre-mcp.*` URL is also accepted, and DWS derives the paired `pre-login.*` / `pre-mcp.*` bases. `--mcp-url` explicitly overrides the MCP base URL for that login.
|
||||
|
||||
Pre-release services may require internal network access or allowlisted accounts. `--pre-url` is intended primarily for the MCP-managed credential flow. Do not combine it with direct custom `--client-id/--client-secret` mode unless the pre-release API contract explicitly supports that combination.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### The browser still opens a `.com` page
|
||||
|
||||
1. Run `dws auth login --help` and confirm `--intl` is present.
|
||||
2. For a source checkout, use `./dws` instead of an older installed binary.
|
||||
3. Confirm the executed command is `dws auth login --intl`.
|
||||
|
||||
### A business command appears to use the wrong region
|
||||
|
||||
Run `dws profile list --format json`, then switch with the exact `<corpId>:<userId>` selector or use the global `--profile` option. For a legacy Token created before region metadata existed, reauthorize it with `dws auth login --intl` for an international account or `dws auth login` for a domestic account.
|
||||
|
||||
### Login succeeds but the command reports missing permission
|
||||
|
||||
This normally means the organization has not enabled CLI access or the application lacks a required permission. It does not by itself indicate a region-routing failure.
|
||||
|
||||
### Should I edit `~/.dws/mcp_url` manually?
|
||||
|
||||
No. Normal users should establish the login with `dws auth login` or `dws auth login --intl`. DWS then routes official endpoints from the selected Token/profile. Manual configuration is reserved for maintainers who explicitly control the target environment.
|
||||
|
||||
## Command reference
|
||||
|
||||
| Scenario | Command |
|
||||
|---|---|
|
||||
| Domestic browser login | `dws auth login` |
|
||||
| International browser login | `dws auth login --intl` |
|
||||
| International device login | `dws auth login --intl --device` |
|
||||
| Check auth state | `dws auth status --format json` |
|
||||
| List profiles | `dws profile list --format json` |
|
||||
| Persistently switch profile | `dws profile switch <corpId>:<userId>` |
|
||||
| Toggle to previous profile | `dws profile switch -` |
|
||||
| Select a profile once | `dws --profile <corpId>:<userId> <command>` |
|
||||
@@ -0,0 +1,185 @@
|
||||
# DWS 国际版(DingTalk `.io`)使用手册
|
||||
|
||||
本手册适用于使用钉钉国际版账号登录并调用国际站服务的用户。
|
||||
|
||||
## 核心规则
|
||||
|
||||
- `dws auth login --intl` 创建或刷新国际版登录,使用 `*.dingtalk.io` 登录、鉴权和 MCP 服务。
|
||||
- 不传 `--intl` 时仍使用国内钉钉 `*.dingtalk.com`,原有链路保持不变。
|
||||
- `--intl` 只用于登录命令。登录完成后,`contact`、`calendar`、`doc` 等业务命令不需要再传该参数。
|
||||
- 每个 Token 会记录登录区域。执行业务命令时,DWS 根据当前或 `--profile` 指定的账号自动选择 `.com` 或 `.io` 网关。
|
||||
- `--international` 是 `--intl` 的兼容别名;新脚本推荐使用较短的 `--intl`。
|
||||
|
||||
## 确认当前版本支持国际版
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
dws auth login --help
|
||||
```
|
||||
|
||||
帮助中应包含:
|
||||
|
||||
```text
|
||||
--intl
|
||||
--international
|
||||
```
|
||||
|
||||
从源码分支验证时,先在仓库根目录构建,并始终使用本次构建的 `./dws`,避免误用系统中已安装的旧版本:
|
||||
|
||||
```bash
|
||||
make build
|
||||
./dws auth login --help
|
||||
```
|
||||
|
||||
## 国际版登录
|
||||
|
||||
### 浏览器登录
|
||||
|
||||
```bash
|
||||
dws auth login --intl
|
||||
```
|
||||
|
||||
DWS 会打开国际版登录页面。完成扫码或账号授权后,登录结果会保存为本机 profile。
|
||||
|
||||
### 设备码登录
|
||||
|
||||
适用于 SSH、容器或没有可用浏览器的环境:
|
||||
|
||||
```bash
|
||||
dws auth login --intl --device
|
||||
```
|
||||
|
||||
按照终端提示,在另一台可打开浏览器的设备上完成授权。
|
||||
|
||||
### 使用自有应用凭证完成用户 OAuth
|
||||
|
||||
```bash
|
||||
dws auth login --intl \
|
||||
--client-id <APP_KEY> \
|
||||
--client-secret <APP_SECRET>
|
||||
```
|
||||
|
||||
该模式仍然需要用户在浏览器中完成 OAuth 授权,不是无用户授权的 `client_credentials` 登录。应用必须在国际版开放平台正确配置回调地址和所需权限。不要在命令历史、日志或 PR 中提交真实的 AppSecret。
|
||||
|
||||
## 验证登录和业务调用
|
||||
|
||||
查看当前登录状态:
|
||||
|
||||
```bash
|
||||
dws auth status --format json
|
||||
```
|
||||
|
||||
列出本机全部账号并找到当前 profile:
|
||||
|
||||
```bash
|
||||
dws profile list --format json
|
||||
```
|
||||
|
||||
执行一个只读命令验证国际链路,例如:
|
||||
|
||||
```bash
|
||||
dws contact user get-self
|
||||
```
|
||||
|
||||
登录状态正常但业务命令提示组织未开通 CLI 时,需要由国际版组织管理员在国际版开发者平台开启 CLI 访问或完成授权审批。
|
||||
|
||||
## 国内版和国际版账号并存
|
||||
|
||||
可以在同一台机器上分别登录国内版和国际版账号:
|
||||
|
||||
```bash
|
||||
# 国内版(.com)
|
||||
dws auth login
|
||||
|
||||
# 国际版(.io)
|
||||
dws auth login --intl
|
||||
|
||||
# 查看稳定的 profile 选择器
|
||||
dws profile list --format json
|
||||
```
|
||||
|
||||
持久切换账号:
|
||||
|
||||
```bash
|
||||
dws profile switch <corpId>:<userId>
|
||||
```
|
||||
|
||||
切回上一个账号:
|
||||
|
||||
```bash
|
||||
dws profile switch -
|
||||
```
|
||||
|
||||
只为单次命令指定账号,不修改默认账号:
|
||||
|
||||
```bash
|
||||
dws --profile <corpId>:<userId> contact user get-self
|
||||
```
|
||||
|
||||
DWS 会按照选中 profile 的 Token 区域自动选择 `.com` 或 `.io`,不需要在业务命令上追加 `--intl`。
|
||||
|
||||
## 使用独立配置目录进行验证
|
||||
|
||||
如果不希望测试登录影响日常使用的 `~/.dws`,可以指定独立配置目录:
|
||||
|
||||
```bash
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth login --intl
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth status --format json
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws contact user get-self
|
||||
```
|
||||
|
||||
请在三条命令中使用同一个 `DWS_CONFIG_DIR`。验证源码分支时使用 `./dws`;验证已安装版本时可改为 `dws`。
|
||||
|
||||
## 预发参数(仅维护者)
|
||||
|
||||
普通国际版用户只需要 `--intl`,不要配置 `--pre-url` 或 `--mcp-url`。
|
||||
|
||||
维护者验证预发登录/MCP 链路时可以使用:
|
||||
|
||||
```bash
|
||||
dws auth login --intl --pre-url https://pre-login.dingtalk.io
|
||||
```
|
||||
|
||||
也可以传入对应的 `pre-mcp.*` 地址;DWS 会推导配套的 `pre-login.*` / `pre-mcp.*` 地址。`--mcp-url` 用于显式覆盖本次登录的 MCP base URL。
|
||||
|
||||
预发环境可能只对内网或特定测试账号开放。`--pre-url` 主要服务于 MCP 托管凭证登录流程;除非预发 API 契约已经明确支持,否则不要把它与自有 `--client-id/--client-secret` 直连模式组合使用。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 仍然打开 `.com` 登录页面
|
||||
|
||||
1. 运行 `dws auth login --help`,确认当前二进制包含 `--intl`。
|
||||
2. 从源码验证时使用 `./dws`,不要误用 PATH 中的旧版本。
|
||||
3. 确认实际执行的是 `dws auth login --intl`,而不是普通 `dws auth login`。
|
||||
|
||||
### 业务命令似乎使用了错误区域
|
||||
|
||||
先检查当前账号:
|
||||
|
||||
```bash
|
||||
dws profile list --format json
|
||||
```
|
||||
|
||||
然后使用精确的 `<corpId>:<userId>` 切换或通过全局 `--profile` 单次指定。对于在区域字段引入前生成的历史 Token,建议使用正确的登录方式重新授权:国际账号执行 `dws auth login --intl`,国内账号执行 `dws auth login`。
|
||||
|
||||
### 登录成功但提示没有权限
|
||||
|
||||
这通常是组织 CLI 准入或应用授权问题,不代表区域路由失败。请确认目标组织已开启 CLI 访问,并且当前应用拥有命令所需权限。
|
||||
|
||||
### 是否需要手工修改 `~/.dws/mcp_url`
|
||||
|
||||
不需要。正常使用应通过 `dws auth login` 或 `dws auth login --intl` 建立登录态;业务命令会根据选中的 Token/profile 自动路由。手工修改配置只适用于明确了解目标环境的维护者调试场景。
|
||||
|
||||
## 命令速查
|
||||
|
||||
| 场景 | 命令 |
|
||||
|---|---|
|
||||
| 国内版浏览器登录 | `dws auth login` |
|
||||
| 国际版浏览器登录 | `dws auth login --intl` |
|
||||
| 国际版设备码登录 | `dws auth login --intl --device` |
|
||||
| 查看登录状态 | `dws auth status --format json` |
|
||||
| 查看所有账号 | `dws profile list --format json` |
|
||||
| 持久切换账号 | `dws profile switch <corpId>:<userId>` |
|
||||
| 切回上一个账号 | `dws profile switch -` |
|
||||
| 单次指定账号 | `dws --profile <corpId>:<userId> <command>` |
|
||||
@@ -292,7 +292,7 @@ Definition(仅声明;不可编译)
|
||||
|
||||
下列字段**是**框架声明面(经 `corecmd.New` 生效并嵌入 `dws.schema.*`):
|
||||
|
||||
- `Flags`(含 Name/Kind/Default/Required/MarkRequired/Usage 等注册面)
|
||||
- `Flags`(含 Name/Kind/Default/Required/MarkRequired/Usage 等注册面;`Input` 是取值来源声明,经 `corecmd.New` 生效但**不**嵌入 `dws.schema.*`,能力靠 `Usage` 文案声明,见 §5.3)
|
||||
- `Constraints`
|
||||
- **非空** `Risk`(空值 = 运行时当只读确认,且**不**嵌入 `dws.schema.risk`)
|
||||
- `ConstParams`(载荷声明;不上用户 flag 表)
|
||||
@@ -673,6 +673,52 @@ func (k Key[T]) Declare(opts ...FlagOption[T]) FlagSpec
|
||||
- 构造时拒绝 `InputSourceInvalid`。
|
||||
- 当前没有任何 Shortcut 或 Leaf 声明 `Input`,因此 M1 增加能力且零上线表面变化。让现有命令采用它属于 §9 下的用户可见变更。
|
||||
|
||||
`Input` 的框架能力今日已在 `corecmd` 落地(声明即执行的过渡形态,语义与上文目标一致),使用指南:
|
||||
|
||||
**今日声明形态**:`FlagSpec.Input []string`,源常量 `corecmd.InputFile`(`"file"`)/ `corecmd.InputStdin`(`"stdin"`)。`helpers.LeafFlag` 是 `corecmd.FlagSpec` 别名,直接可用;`shortcut.Flag.Input` 同形声明,经 `FromShortcut` 映射到 `FlagSpec`。
|
||||
|
||||
```go
|
||||
// LeafSpec / helpers
|
||||
Flags: []helpers.LeafFlag{
|
||||
{
|
||||
Name: "content",
|
||||
Usage: "文档内容(支持 @文件路径 或 - 读 stdin)",
|
||||
Bind: "content",
|
||||
Input: []string{corecmd.InputFile, corecmd.InputStdin},
|
||||
},
|
||||
}
|
||||
|
||||
// shortcut
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "markdown", Desc: "Markdown 内容(支持 @文件路径 或 -)",
|
||||
Input: []string{"file", "stdin"}},
|
||||
}
|
||||
```
|
||||
|
||||
**运行时语义**(`resolveInputFlags`,在 `runDeclaredPreflight` 内、required/enum/约束/Validate 之前执行,原地改写 cobra flag 值):
|
||||
|
||||
- `--flag @path`:文件内容替换取值;`--flag -`:stdin 内容替换取值。
|
||||
- `--flag @@value`:转义为字面 `@value`,不做来源解析。
|
||||
- 只解析显式 CLI token(主名或别名);EnvVar 回落与注册默认值透传不解析。
|
||||
- 内容前置剥离 UTF-8 BOM;`Trim` 等既有语义照常作用于解析后的值。
|
||||
- 读取失败、源不支持、`@` 后空路径都是类型化校验错误(退出码 3);同时声明两种源而文件读取失败时附 stdin 引导 hint。
|
||||
|
||||
**作者守则**:
|
||||
|
||||
- 声明即全部能力:required/enum/约束/Validate 校验的已是解析后的真实内容,`Execute`/`Invoke` 无需任何额外代码。
|
||||
- `Usage`/`Desc` 必须写明支持 `@路径`/`-`;框架不自动改写 help 文案,今日也不向 Schema 投影(新增投影字段须先过 homology 评审,避免 catalog drift)。
|
||||
- `user_required` 确认的写命令若声明 `InputStdin`:stdin 在校验阶段被消费,交互确认将 fail-closed 为 `confirmation_required`,此类调用必须显式 `--yes`(或 `--dry-run`)。
|
||||
- **声明前先确认取值空间不会被前缀吃掉**:声明 `InputFile` 后,任何以 `@` 开头的合法值都会被当成文件路径(本产品尤其常见的是 at 提及类取值,如 `--at-user @zhangsan` 会报读取文件失败),用户只能改用 `@@` 转义;声明 `InputStdin` 后字面值 `-` 不可达(与 curl 等约定一致)。若该 flag 的正常取值可能命中这两种形态,就不要声明对应来源。
|
||||
- 声明在构造期校验(fail-closed panic):仅限 `KindString`;源值必须是 `file`/`stdin` 且不重复。
|
||||
|
||||
**今日实现与目标形态的差异**(迁移到本节目标 `FlagSpec` 时收敛):
|
||||
|
||||
| 维度 | 今日 | 目标 |
|
||||
|---|---|---|
|
||||
| 源类型 | `[]string` 常量 | 类型化 `InputSource` |
|
||||
| 路径边界 | 直接本地文件 IO | 复用 §5.5.2 本地文件 effect 边界 |
|
||||
| Schema 投影 | 无(靠作者在 Usage 声明) | 声明即最终源,随 Catalog 透传 |
|
||||
|
||||
核心 FlagSpec 故意没有:
|
||||
|
||||
- `Bind`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_at": "2026-08-12T00:10:44.511794",
|
||||
"count": 399,
|
||||
"generated_at": "2026-08-17T15:52:13.675461",
|
||||
"count": 435,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -1056,144 +1056,274 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+agenda",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 result.events 与分页证据,稳定投影 id 为 eventId;显式空数组才是空日程。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+attendee-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证参会人数组并投影显示名、响应状态和 self;真实后端不返回稳定参会人 userId,因此不伪造身份字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+book",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按参会人姓名解析后创建日程,并在多步骤路径提供失败回滚与详情读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+book-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证日历本数组并稳定投影 calendarId,缺失数组不再误报为空。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+book-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按日历本名称搜索并严格区分零命中与协议错误。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+cancel-event",
|
||||
"risk": "high-risk-write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "删除前读取并展示目标,经高风险确认后取消指定日程。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+conflicts",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在指定范围分析重叠日程并输出冲突对,避免人工逐项比对。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "创建响应必须给出 eventId/id,且标题和起止时间通过详情读回后才成功。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+free",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析参与者后查询闲忙,省去手工 userId 解析。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+free-slots",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "从已有日程计算工作时段内空档,并显式输出满足时长的候选区间。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+freebusy",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一按用户或会议室 ID 查询指定范围闲忙,并声明至少一类目标约束。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "严格读取日程对象并把稳定 id 规范化为 eventId,拒绝空响应和对象漂移。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+invite",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名唯一解析用户后邀请到已有日程。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+my-free",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "以当前用户身份查询一个时间段是否空闲。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+next-event",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在未来时间窗内定位最近的下一场日程。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+reschedule",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "修改前读取目标日程,再更新完整起止时间。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+room-find",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按单个未来时间段严格查询可用会议室,公开真实 page/pageSize/hasMore 证据和后端筛选边界。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+room-groups",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格读取 result.groupList,避免会议室分组被响应 wrapper 漂移静默清空。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+room-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按名称查询会议室但不声称检查时间可用性;缺失会议室数组直接失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+rsvp",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "把直白 RSVP 动作映射为 responseStatus,拒绝空 ack,并在写后读取日程终态。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+search-event",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "在服务端日程当前页按标题、描述和地点过滤,同时保留 hasMore/nextCursor,绝不把单页零命中当全局未找到。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+suggest-time",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名逐个唯一解析参与者,再查询大家共同可用的建议时间。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+suggestion",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "直接按 userId 查询建议时段,严格区分显式空 recommendEventTimes 与缺失或畸形响应。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+today",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "查询并整理今天的日程视图。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+tomorrow",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "查询并整理明天的日程视图。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "组合字段与参会人分阶段更新,明确非事务步骤并以详情和参会人列表读回验证。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+week",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "查询并按日期整理本周日程。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3582,88 +3712,414 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+assign",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "保留历史按姓名解析后创建并指派的新任务语义,避免同名破坏性变更。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+assign-multi",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "全部姓名唯一解析后才创建一次,并以单一统一结果输出稳定 taskId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+comment",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "新增评论必须取得稳定 commentId,并分页读回验证内容。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+complete",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先读当前状态避免重复写,必要时完成后再读回核验。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "创建后要求稳定 taskId,并读取 todoDetailModel 验证标题。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+created-todos",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "遍历创建者角色的全部分页后投影。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+due-today",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按本地日历日构造服务端截止窗口并遍历全部分页。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格绑定 todoDetailModel.taskId,缺失或错绑均失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+get-my-tasks",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格识别 todoCards/hasMore/page/size;显式空数组才是空结果,并支持有界全量分页。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+get-related-tasks",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "遍历与我相关三种角色的全部分页并按 taskId 去重。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+list-attachment",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格区分显式空附件与缺失容器,并要求每项稳定附件 ID。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+list-comment",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格区分显式空评论与缺失容器,并要求每项稳定 commentId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+list-sub",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格校验子待办容器、对象元素和稳定 taskId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+overdue",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "遍历执行者角色的全部分页并按当前时间筛选逾期项。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+related-tasks",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留旧命令拼写,新的 Agent 路由统一使用 +get-related-tasks。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+remind",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "解析当前用户后创建给自己的待办;该命令不是独立提醒规则。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+reminder",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证提醒参数和 success=true 终端回执;上游不能读回规则,固定 verified=false。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+reopen",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先读当前状态避免重复写,必要时重开后再读回核验。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+search",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "遍历全部 hasMore 分页并按标题匹配;达到页上限仍未耗尽时失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+todo-done",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "跨全部分页按标题唯一定位后完成任务。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "更新指定字段后读取详情逐字段核验。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+delete-space",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "删除前读取影响目标,经高风险确认后只接受 success=true 终态。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+feed-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "补充知识库协作动态查询,严格验证 feeds 并保留游标。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+member-add",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "支持 1-30 个 userId 与四类角色;严格要求写接口终态,并明确后端无法提供精确成员读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+member-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证成员数组并公开真实单次上限 50;后端无游标时不伪造 page-all。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+member-remove",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "支持批量 userId 移除;严格要求写接口终态,并明确后端无法提供精确成员读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+member-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "补充成员角色更新;严格要求写接口终态,并明确后端无法提供精确成员读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+move",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "同一入口覆盖 Wiki 内移动和我的文档在线节点入 Wiki,并校验目标 workspace/folder。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+move-to-drive",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "同步移动 Wiki 节点到我的文档并读回验证 workspace 变化,免去异步任务轮询。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-copy",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "高风险确认后复制,必须取得新 nodeId 并读回副本,避免空响应被当作成功。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "支持七种 Wiki 节点类型,创建后要求 nodeId 并读取元数据验证。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "删除前读取并核对 workspace,经高风险确认后要求 success=true 终态证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "统一节点 ID 或在线文档 URL 的元数据读取,补充文档域属性视角。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格区分显式空目录与假空成功,稳定投影节点并保留 nextCursor/hasMore。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-search",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "补充库内关键词和扩展名搜索,并拒绝假空结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+resolve-space",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "把关键词搜索收敛为唯一 workspaceId;零命中与多命中显式分流,绝不猜测。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+space-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "创建后要求 workspaceId 并通过空间详情读回验证真实落库。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+space-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "补充知识库详情入口,并要求 workspaceId 业务证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+space-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格区分显式空知识库列表与缺失、畸形或内部错误响应,并保留真实分页证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+space-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按关键词搜索知识库并拒绝把缺失业务数组误报为零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+wiki-new-doc",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按空间名精确唯一解析、创建在线文档并读回验证;零命中和歧义均显式失败。",
|
||||
"availability": "available"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>DWS Wiki Shortcut 全景评审</title>
|
||||
<style>
|
||||
:root{--ink:#14213d;--muted:#5c677d;--line:#dbe4f0;--paper:#fff;--bg:#f3f7fb;--blue:#1769e0;--cyan:#00a6a6;--green:#178746;--amber:#a45b00;--red:#b42318;--shadow:0 14px 34px rgba(20,33,61,.08)}
|
||||
*{box-sizing:border-box}body{margin:0;overflow-x:hidden;background:linear-gradient(150deg,#edf5ff 0,#f8fbff 45%,#eef8f5 100%);color:var(--ink);font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}
|
||||
main,.card,.two>*{min-width:0}main{width:min(1180px,calc(100% - 32px));margin:28px auto 72px}.hero,.card{background:rgba(255,255,255,.96);border:1px solid var(--line);border-radius:22px;box-shadow:var(--shadow)}
|
||||
.hero{padding:38px;background:radial-gradient(circle at 95% 0,#dff8f3,transparent 36%),linear-gradient(135deg,#fff,#f5f9ff)}h1{font-size:34px;line-height:1.2;margin:0 0 10px}.lead{font-size:17px;color:var(--muted);max-width:900px}.meta{display:flex;gap:10px;flex-wrap:wrap;margin-top:20px}.pill{border:1px solid #cbd9ea;border-radius:999px;padding:5px 11px;background:#fff;font-size:13px}
|
||||
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:18px 0}.metric{padding:20px}.metric b{display:block;font-size:31px;color:var(--blue)}.metric span{color:var(--muted)}
|
||||
section{margin-top:22px}.card{padding:26px}h2{font-size:23px;margin:0 0 14px}h3{font-size:17px;margin:22px 0 8px}.callout{border-left:4px solid var(--blue);background:#f2f7ff;padding:14px 16px;border-radius:8px}.warn{border-color:var(--amber);background:#fff8eb}.ok{border-color:var(--green);background:#effbf4}
|
||||
table{width:100%;border-collapse:collapse;font-size:14px}th,td{text-align:left;vertical-align:top;border-bottom:1px solid var(--line);padding:11px 9px}th{color:#41516b;background:#f7f9fc;position:sticky;top:0}code{background:#edf2f8;border-radius:5px;padding:2px 5px;color:#24466e}.tag{display:inline-block;border-radius:999px;padding:2px 8px;font-size:12px;font-weight:650;white-space:nowrap}.full{background:#e6f6ec;color:#116436}.partial{background:#fff0d5;color:#875000}.extra{background:#e8f1ff;color:#1854a5}.fixed{background:#f1eaff;color:#6338a5}
|
||||
.toolbar{display:flex;flex-wrap:wrap;gap:10px;margin:12px 0}.toolbar input,.toolbar select{border:1px solid #bdcada;border-radius:10px;padding:9px 11px;background:#fff;min-width:min(220px,100%);max-width:100%;flex:1 1 220px}.matrix{max-height:620px;overflow:auto;border:1px solid var(--line);border-radius:12px}.two{display:grid;grid-template-columns:1fr 1fr;gap:18px}.small{color:var(--muted);font-size:13px}ul{padding-left:20px}.footer{color:var(--muted);text-align:center;margin-top:22px}@media(max-width:850px){.grid,.two{grid-template-columns:1fr 1fr}.hero{padding:25px}}@media(max-width:560px){.grid,.two{grid-template-columns:1fr}main{width:min(100% - 18px,1180px)}.card{padding:18px}h1{font-size:28px}}
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<header class="hero">
|
||||
<h1>DWS Wiki Shortcut 全景评审</h1>
|
||||
<p class="lead">以 13 项成熟 Wiki 用户任务为基线,重新审视 DWS 的空间、成员、节点与动态能力。本次不是按命令名凑数:每个入口都要求真实业务证据,缺失数组、畸形响应、空确认或读回不一致一律失败。</p>
|
||||
<div class="meta"><span class="pill">评审日期 2026-08-14</span><span class="pill">独立 worktree / 独立分支</span><span class="pill">真实组织数据 E2E 28/28</span><span class="pill">报告已去标识化</span></div>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card metric"><b>20</b><span>公开 Wiki Shortcuts</span></div>
|
||||
<div class="card metric"><b>13/13</b><span>基线用户任务有对应路径</span></div>
|
||||
<div class="card metric"><b>7</b><span>DWS 额外场景</span></div>
|
||||
<div class="card metric"><b>20/20</b><span>真实数据能力已触达</span></div>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>结论先行</h2>
|
||||
<div class="callout ok"><strong>DWS 已形成比“API 快捷别名”更完整的 Wiki 任务层。</strong> 基线中的 13 个用户任务均有对应入口;DWS 还提供空间搜索/详情/唯一解析、成员角色更新、库内节点搜索、协作动态和按空间名新建文档。创建、复制、移动等关键写能力从“请求发出”升级为“终态 + ID + 读回”成功标准。</div>
|
||||
<div class="callout warn" style="margin-top:12px"><strong>能力边界必须诚实表达。</strong> DingTalk 成员接口不提供游标,单次真实上限是 50,因此不能实现成员 <code>--page-all</code>;成员身份只接受同组织可用的 userId,无法提供 email/open_id 等多种身份模式;节点创建也没有等价的 origin/shortcut 模式。这些差异保留为明确边界,而不是用本地循环或空结果伪装。</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>13 项基线任务逐条映射</h2>
|
||||
<div class="matrix"><table><thead><tr><th>基线任务</th><th>DWS 主入口</th><th>结论</th><th>DWS 视角与边界</th></tr></thead><tbody>
|
||||
<tr><td><code>+space-list</code></td><td><code>wiki +space-list</code></td><td><span class="tag full">完整对齐</span></td><td>严格空集合、游标续传、自动翻页、停滞检测;支持组织/我的知识库。</td></tr>
|
||||
<tr><td><code>+space-create</code></td><td><code>wiki +space-create</code></td><td><span class="tag full">超过</span></td><td>公开真实 32 字符名称上限;创建后按 workspaceId 读回。</td></tr>
|
||||
<tr><td><code>+delete-space</code></td><td><code>wiki +delete-space</code></td><td><span class="tag full">超过</span></td><td>预读目标、高风险确认、只接受 <code>success=true</code>;兼容 <code>+space-delete</code>。</td></tr>
|
||||
<tr><td><code>+member-add</code></td><td><code>wiki +member-add</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 1–30 个 userId 与四种角色;以写接口终态作为成功证据,不把最多 50 条的名单误作精确读回。</td></tr>
|
||||
<tr><td><code>+member-list</code></td><td><code>wiki +member-list</code></td><td><span class="tag partial">任务对齐</span></td><td>严格成员数组、角色过滤、真实上限 50;后端无游标,不能提供诚实的 page-all。</td></tr>
|
||||
<tr><td><code>+member-remove</code></td><td><code>wiki +member-remove</code></td><td><span class="tag partial">任务对齐</span></td><td>支持批量 userId;只接受写接口明确终态,并公开无法进行精确成员读回的边界。</td></tr>
|
||||
<tr><td><code>+node-list</code></td><td><code>wiki +node-list</code></td><td><span class="tag full">完整对齐</span></td><td>正确跨域路由 doc/list_nodes,严格空目录、分页与自动翻页。</td></tr>
|
||||
<tr><td><code>+node-get</code></td><td><code>wiki +node-get</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 DingTalk 节点 ID/在线文档 URL 并返回文档域元数据;不接受跨平台专用的 token/type 组合。</td></tr>
|
||||
<tr><td><code>+node-create</code></td><td><code>wiki +node-create</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 adoc/axls/able/appt/adraw/amind/folder 并读回;无 origin/shortcut 等价接口。</td></tr>
|
||||
<tr><td><code>+node-copy</code></td><td><code>wiki +node-copy</code></td><td><span class="tag full">超过</span></td><td>确认后要求新 nodeId 并读取副本;底层面向在线节点,不把 .dlink 当独立副本。</td></tr>
|
||||
<tr><td><code>+move</code></td><td><code>wiki +move</code></td><td><span class="tag partial">任务对齐</span></td><td>同一入口支持 Wiki 内移动和“我的文档”在线节点入 Wiki,读回 workspace/folder;底层接口没有 apply 权限迁移开关。</td></tr>
|
||||
<tr><td><code>+move-to-drive</code></td><td><code>wiki +move-to-drive</code></td><td><span class="tag full">超过</span></td><td>DWS 当前接口同步完成并读回 workspace 变化,无需暴露异步 task 轮询。</td></tr>
|
||||
<tr><td><code>+node-delete</code></td><td><code>wiki +node-delete</code></td><td><span class="tag full">超过</span></td><td>预读并核对 workspace,高风险确认,要求删除终态。</td></tr>
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>DWS 可挖掘的 7 个额外场景</h2>
|
||||
<div class="two">
|
||||
<div><h3>定位与创建链</h3><ul><li><code>+space-search</code>:严格关键词搜索。</li><li><code>+space-get</code>:空间详情与 workspaceId 证据。</li><li><code>+resolve-space</code>:唯一命中直出 ID,多命中拒绝猜测。</li><li><code>+wiki-new-doc</code>:空间名解析 → 创建 → 文档读回。</li></ul></div>
|
||||
<div><h3>治理与巡检链</h3><ul><li><code>+member-update</code>:角色变更终态与不可精确读回声明。</li><li><code>+node-search</code>:库内关键词/扩展名搜索,严格零命中。</li><li><code>+feed-list</code>:知识库动态时间线与服务端 exclude-file 过滤。</li></ul></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>隐藏问题与修复</h2>
|
||||
<table><thead><tr><th>原问题</th><th>错误风险</th><th>本次修复</th></tr></thead><tbody>
|
||||
<tr><td>5 个旧 Wiki Shortcut 可直接执行,但只有 1 个进入公开目录。</td><td>Help、Schema、Skill 发现链与运行面漂移。</td><td><span class="tag fixed">20 项统一评审</span> 全部具备 Contract/Safety/Result 与语义目录记录。</td></tr>
|
||||
<tr><td>列表投影找不到数组或遇到坏元素时返回空 slice。</td><td>把内部错误、字段漂移误报为“没有数据”。</td><td><span class="tag fixed">失败关闭</span> 只有响应中真实存在的 <code>[]</code> 才是合法空集合。</td></tr>
|
||||
<tr><td>节点列表 Shortcut 调错 Wiki MCP 服务。</td><td>真实后端 <code>success=false</code>,Mock/静态检查看不出。</td><td><span class="tag fixed">跨域路由</span> 明确调用 doc/list_nodes,并纳入真实 E2E。</td></tr>
|
||||
<tr><td>成员帮助宣称最大 200。</td><td>真实接口超过 50 直接参数错误。</td><td><span class="tag fixed">真实上限</span> Shortcut 与原子 Help 均改为 50,并在本地提前拒绝。</td></tr>
|
||||
<tr><td>成员写操作从最多 50 条、不可分页的名单推断成员存在或缺失。</td><td>目标在截断部分时会误报写失败,或把未验证的移除报告为已读回。</td><td><span class="tag fixed">终态证据</span> 只接受写接口 <code>success=true</code>,并在结果中明确 <code>readbackAvailable=false</code>。</td></tr>
|
||||
<tr><td>空间搜索的稳定工作流属性名与实际请求属性名不同。</td><td>直接改写已发布的 <code>query/limit</code> 会造成无版本 Schema 破坏;继续隐式转换又会让审计者误以为请求同名透传。</td><td><span class="tag fixed">显式复合适配</span> 最终 Schema 保留兼容属性并明确声明转换为 <code>keyword/pageSize</code>;回归测试同时锁定最终交付和精确请求参数。</td></tr>
|
||||
<tr><td>知识库名称帮助宣称最大 100。</td><td>真实接口超过 32 失败。</td><td><span class="tag fixed">真实上限</span> Help 与 Shortcut 校验统一为 32。</td></tr>
|
||||
<tr><td>复制/移动/创建只把无异常视为成功。</td><td>空确认、未知远端效果或移动未到目标仍可能被接受。</td><td><span class="tag fixed">读回证明</span> 在后端具备精确查询能力时检查 success、业务 ID、workspace/folder 等最终状态。</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>真实数据 E2E 证据矩阵</h2>
|
||||
<p class="small">28 项业务断言全部通过。测试使用一次性空知识库、临时在线文档与一名同组织内部测试成员;所有对象在 finally 清理。报告不保存对象 ID、成员身份、组织信息、URL、trace 或原始响应。</p>
|
||||
<div class="toolbar"><input id="q" placeholder="筛选命令或证据"><select id="g"><option value="">全部分组</option><option>空间</option><option>成员</option><option>节点</option><option>动态</option></select></div>
|
||||
<div class="matrix"><table id="catalog"><thead><tr><th>分组</th><th>Shortcut</th><th>实际业务断言</th><th>状态</th></tr></thead><tbody>
|
||||
<tr><td>空间</td><td><code>+space-list</code></td><td>真实 count、hasMore、nextCursor;自动翻页返回两页结果。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>空间</td><td><code>+space-search</code></td><td>等待搜索索引后命中一次性 workspaceId。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>空间</td><td><code>+space-get</code></td><td>读回 workspaceId 与创建结果一致。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>空间</td><td><code>+resolve-space</code></td><td>唯一名称解析为同一 workspaceId。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>空间</td><td><code>+space-create</code></td><td>success=true、workspaceId 非空、详情读回一致。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>空间</td><td><code>+delete-space</code></td><td>目标预读、确认、success=true;兼容别名执行 finally 清理。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>成员</td><td><code>+member-list</code></td><td>真实 owner 条目与显式 members 数组,limit=50。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>成员</td><td><code>+member-add</code></td><td>命令只报告写终态;一次性小规模空间另行确认名单完整且角色为 READER。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>成员</td><td><code>+member-update</code></td><td>命令只报告写终态;一次性小规模空间另行确认角色变为 EDITOR。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>成员</td><td><code>+member-remove</code></td><td>命令只报告写终态;一次性小规模空间另行确认完整名单中不存在该 userId。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-list</code></td><td>空库返回真实 nodes:[];有数据时验证游标与自动翻页。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-get</code></td><td>读回 nodeId 与请求一致。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-search</code></td><td>等待索引后按标题命中真实 nodeId。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-create</code></td><td>分别创建 folder/adoc,均取得 nodeId 和元数据读回。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-copy</code></td><td>取得不同的新 nodeId,副本元数据可读。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+move</code></td><td>读回 workspaceId 与 folderId 均等于目标;兼容 +node-move。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+move-to-drive</code></td><td>移动后读回 workspace 发生变化,再通过 +move 移回。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-delete</code></td><td>目标预读与 workspace 核对后收到 success=true。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+wiki-new-doc</code></td><td>按唯一空间名创建,nodeId 与文档详情读回一致。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>动态</td><td><code>+feed-list</code></td><td>创建/移动操作后返回真实 feeds 数组,缺字段不会被接受。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
</tbody></table></div>
|
||||
<p class="small">可复跑入口:<code>make build</code> 后设置临时 <code>DWS_WIKI_E2E_MEMBER_ID</code>,在交互终端运行 <code>./scripts/dev/wiki-shortcut-e2e.py</code>。脚本只输出能力标签,不输出业务对象;受保护操作及最终清理均由命令逐项获取终端确认,非交互环境会在创建测试数据前拒绝运行。</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>成功判定与发布门</h2>
|
||||
<div class="two"><div><h3>运行时证据层</h3><ol><li>传输/MCP 调用成功。</li><li>响应契约存在且类型正确。</li><li>写操作必须有 <code>success=true</code>;创建类操作还必须有业务 ID。</li><li>后端具备精确查询时必须读回;不具备时明确发布不可读回,而非从截断集合推断。</li><li>集合只有显式数组才允许为空。</li></ol></div><div><h3>交付门</h3><ol><li>20/20 语义目录与注册面精确覆盖。</li><li>Contract、Safety、Result、统一输出完整。</li><li>生成漂移、Schema、确认真值、全量 Go 测试。</li><li>独立真实数据 E2E 与 finally 清理。</li><li>diff PII/密钥/本地绝对路径扫描。</li></ol></div></div>
|
||||
</section>
|
||||
<p class="footer">DWS Wiki Shortcut business review · sanitized engineering artifact</p>
|
||||
</main>
|
||||
<script>
|
||||
const q=document.querySelector('#q'),g=document.querySelector('#g'),rows=[...document.querySelectorAll('#catalog tbody tr')];
|
||||
function filter(){const text=q.value.trim().toLowerCase(),group=g.value;rows.forEach(r=>{const okText=!text||r.textContent.toLowerCase().includes(text),okGroup=!group||r.children[0].textContent===group;r.style.display=okText&&okGroup?'':'none'})}q.addEventListener('input',filter);g.addEventListener('change',filter);
|
||||
</script></body></html>
|
||||
@@ -2,6 +2,8 @@ module github.com/DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
|
||||
go 1.25.9
|
||||
|
||||
replace gitlab.alibaba-inc.com/aes/aem-go-sdk => ./third_party/aem-go-sdk
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15
|
||||
@@ -16,7 +18,9 @@ require (
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/yuin/goldmark v1.8.5
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
gitlab.alibaba-inc.com/aes/aem-go-sdk v0.3.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/text v0.35.0
|
||||
|
||||
@@ -105,6 +105,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
|
||||
@@ -65,12 +65,16 @@ func TestCrossPlatformCoverageTokenManagerCachesUntilMarkerRevisionChanges(t *te
|
||||
token := "token-a"
|
||||
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
|
||||
calls.Add(1)
|
||||
return &authpkg.TokenData{AccessToken: token, ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
return &authpkg.TokenData{
|
||||
AccessToken: token,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
LoginRegion: string(authpkg.LoginRegionInternational),
|
||||
}, nil
|
||||
})
|
||||
|
||||
manager := NewTokenManager()
|
||||
first, err := manager.Get(context.Background(), configDir, "")
|
||||
if err != nil || first.AccessToken != "token-a" {
|
||||
if err != nil || first.AccessToken != "token-a" || first.LoginRegion != authpkg.LoginRegionInternational || !first.LoginRegionKnown {
|
||||
t.Fatalf("first token = %#v, %v", first, err)
|
||||
}
|
||||
second, err := manager.Get(context.Background(), configDir, "")
|
||||
|
||||
@@ -41,9 +41,11 @@ type accessTokenSnapshotGetter interface {
|
||||
// AccessTokenSnapshot is the minimal bearer view needed by the process cache.
|
||||
// Refresh-token material never leaves the auth package.
|
||||
type AccessTokenSnapshot struct {
|
||||
AccessToken string
|
||||
ExpiresAt time.Time
|
||||
Source string
|
||||
AccessToken string
|
||||
ExpiresAt time.Time
|
||||
Source string
|
||||
LoginRegion authpkg.LoginRegion
|
||||
LoginRegionKnown bool
|
||||
}
|
||||
|
||||
type tokenManagerKey struct {
|
||||
@@ -223,9 +225,11 @@ func resolveAccessTokenSnapshotFromDir(ctx context.Context, configDir, profile s
|
||||
data, err := snapshotProvider.GetTokenSnapshot(ctx)
|
||||
if err == nil && data != nil && strings.TrimSpace(data.AccessToken) != "" {
|
||||
return AccessTokenSnapshot{
|
||||
AccessToken: strings.TrimSpace(data.AccessToken),
|
||||
ExpiresAt: data.ExpiresAt,
|
||||
Source: "oauth",
|
||||
AccessToken: strings.TrimSpace(data.AccessToken),
|
||||
ExpiresAt: data.ExpiresAt,
|
||||
Source: "oauth",
|
||||
LoginRegion: authpkg.LoginRegion(strings.TrimSpace(data.LoginRegion)),
|
||||
LoginRegionKnown: true,
|
||||
}, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||
|
||||
@@ -712,9 +712,9 @@ func TestCrossPlatformCoverageAgentMetadataMCPAndPluginScoping(t *testing.T) {
|
||||
// calls must fail before preflight or transport while anonymous plugins remain
|
||||
// valid above.
|
||||
resolveCalled := false
|
||||
testseam.Swap(t, &runnerResolveAuthToken, func(*runtimeRunner, context.Context) (string, error) {
|
||||
testseam.Swap(t, &runnerResolveAuthSnapshot, func(*runtimeRunner, context.Context) (AccessTokenSnapshot, error) {
|
||||
resolveCalled = true
|
||||
return "", nil
|
||||
return AccessTokenSnapshot{}, nil
|
||||
})
|
||||
callsBefore := len(captured)
|
||||
unauthenticated := &runtimeRunner{
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -48,8 +50,24 @@ type authLoginConfig struct {
|
||||
TargetCorpID string
|
||||
HistoryProfileSelector string
|
||||
HistoryProfileSelectorExplicit bool
|
||||
International bool
|
||||
PreURL string
|
||||
MCPURL string
|
||||
}
|
||||
|
||||
type authLoginEndpointOverrides struct {
|
||||
LoginURL string
|
||||
MCPURL string
|
||||
}
|
||||
|
||||
type authLoginMCPPersistence uint8
|
||||
|
||||
const (
|
||||
authLoginMCPUseDefault authLoginMCPPersistence = iota
|
||||
authLoginMCPUseManagedRegion
|
||||
authLoginMCPUseExplicitOverride
|
||||
)
|
||||
|
||||
type authLoginGuideAction string
|
||||
|
||||
const (
|
||||
@@ -103,12 +121,17 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
支持的登录方式:
|
||||
- OAuth Loopback 流 (默认): 本机自动起 127.0.0.1 监听接收回调,浏览器授权后自动完成
|
||||
- OAuth 设备流 (--device): 显示 user_code + 短 URL,适合 SSH 远程 / 容器 / 无头环境
|
||||
- 自有应用 OAuth (--client-id/--client-secret): 使用指定应用完成用户授权
|
||||
- 直接提供 Token (--token): 跳过授权,使用已有 token
|
||||
|
||||
不支持的登录方式:
|
||||
- 邮箱/密码登录
|
||||
- 手机号/验证码登录
|
||||
- 应用凭证 (AppKey/AppSecret) 直接登录
|
||||
- 无用户授权的纯应用凭证 (client_credentials) 登录
|
||||
|
||||
区域:
|
||||
- 默认使用国内钉钉 .com 登录与服务端点
|
||||
- --intl(或 --international)使用国际版 .io 登录;后续业务命令按所选 profile 自动路由
|
||||
|
||||
注意: SSH 远程或无头环境(无本地浏览器可访问远端的 127.0.0.1)请使用 --device,
|
||||
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
|
||||
@@ -116,6 +139,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
示例:
|
||||
dws auth login # 本机登录并新增/刷新一个组织 profile
|
||||
dws auth login --profile <corpId> # 指定本次授权目标组织,不持久切换当前组织
|
||||
dws auth login --intl # 使用钉钉国际版 .io 登录入口
|
||||
dws auth login --intl --pre-url https://pre-login.dingtalk.io
|
||||
dws auth login --intl --pre-url https://pre-mcp.dingtalk.io
|
||||
dws auth login --recommend # 无交互批量授权服务端推荐权限
|
||||
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
|
||||
dws auth login --force # 兼容保留;login 默认已忽略缓存并进入授权流程
|
||||
@@ -126,6 +152,22 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var preOverrides authLoginEndpointOverrides
|
||||
if cfg.PreURL != "" {
|
||||
var err error
|
||||
preOverrides, err = authLoginEndpointOverridesForPreURL(cfg.PreURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
restoreLoginBaseURL := authpkg.PushLoginBaseURLOverride(preOverrides.LoginURL)
|
||||
defer restoreLoginBaseURL()
|
||||
}
|
||||
mcpBaseURL, mcpPersistence, err := authLoginMCPBaseURLForConfig(cfg, preOverrides)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
restoreMCPBaseURL := authpkg.PushMCPBaseURLOverride(mcpBaseURL)
|
||||
defer restoreMCPBaseURL()
|
||||
configDir := defaultConfigDir()
|
||||
var tokenData *authpkg.TokenData
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
@@ -139,6 +181,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
AccessToken: cfg.Token,
|
||||
ExpiresAt: time.Now().Add(config.ManualTokenExpiry),
|
||||
}
|
||||
if cfg.International {
|
||||
tokenData.LoginRegion = string(authpkg.LoginRegionInternational)
|
||||
}
|
||||
if err := authSaveTokenData(configDir, tokenData); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to persist auth token: %v", err))
|
||||
}
|
||||
@@ -149,6 +194,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
provider := authpkg.NewDeviceFlowProvider(configDir, nil)
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
if cfg.International {
|
||||
provider.SetLoginRegion(authpkg.LoginRegionInternational)
|
||||
}
|
||||
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
|
||||
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{
|
||||
Selector: cfg.HistoryProfileSelector,
|
||||
@@ -167,6 +215,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
provider.TargetCorpID = cfg.TargetCorpID
|
||||
if cfg.International {
|
||||
provider.LoginRegion = authpkg.LoginRegionInternational
|
||||
}
|
||||
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
|
||||
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{
|
||||
Selector: cfg.HistoryProfileSelector,
|
||||
@@ -180,6 +231,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
if tokenData != nil {
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, mcpBaseURL, mcpPersistence); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to persist MCP URL: %v", err))
|
||||
}
|
||||
}
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
@@ -278,6 +334,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
}
|
||||
cmd.Flags().String("token", "", "Access token")
|
||||
cmd.Flags().Bool("device", false, "Use device authorization flow")
|
||||
cmd.Flags().Bool("intl", false, "Use DingTalk international (.io) login and service endpoints")
|
||||
cmd.Flags().Bool("international", false, "Use DingTalk international (.io) login and service endpoints")
|
||||
cmd.Flags().String("pre-url", "", "Override pre-release login/MCP base URL for this login")
|
||||
cmd.Flags().String("mcp-url", "", "Override MCP base URL for this login")
|
||||
cmd.Flags().Bool("force", false, "兼容保留;login 默认已忽略缓存并进入授权流程")
|
||||
cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限")
|
||||
// Hidden compatibility flags
|
||||
@@ -967,6 +1027,7 @@ func newAuthResetCommand() *cobra.Command {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err))
|
||||
}
|
||||
_ = authRemove(filepath.Join(configDir, "mcp_url"))
|
||||
_ = authRemove(filepath.Join(configDir, config.ManagedMCPURLRegionFileName))
|
||||
_ = authRemove(filepath.Join(configDir, "token"))
|
||||
_ = authDeleteAppConfig(configDir)
|
||||
ResetRuntimeTokenCache()
|
||||
@@ -1225,6 +1286,14 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --device")
|
||||
}
|
||||
intl, err := cmd.Flags().GetBool("intl")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --intl")
|
||||
}
|
||||
international, err := cmd.Flags().GetBool("international")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --international")
|
||||
}
|
||||
force, err := cmd.Flags().GetBool("force")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --force")
|
||||
@@ -1233,6 +1302,14 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend")
|
||||
}
|
||||
preURL, err := cmd.Flags().GetString("pre-url")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --pre-url")
|
||||
}
|
||||
mcpURL, err := cmd.Flags().GetString("mcp-url")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --mcp-url")
|
||||
}
|
||||
yes := false
|
||||
profileSelector := ""
|
||||
if cmd.Root() != nil {
|
||||
@@ -1266,9 +1343,172 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
TargetCorpID: targetCorpID,
|
||||
HistoryProfileSelector: historyProfileSelector,
|
||||
HistoryProfileSelectorExplicit: historyProfileSelectorExplicit,
|
||||
International: intl || international,
|
||||
PreURL: strings.TrimSpace(preURL),
|
||||
MCPURL: strings.TrimSpace(mcpURL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func authLoginEndpointOverridesForPreURL(raw string) (authLoginEndpointOverrides, error) {
|
||||
parsed, normalized, err := normalizeAuthLoginBaseURL(raw, "--pre-url")
|
||||
if err != nil {
|
||||
return authLoginEndpointOverrides{}, err
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
switch {
|
||||
case strings.HasPrefix(host, "pre-login."):
|
||||
return authLoginEndpointOverrides{
|
||||
LoginURL: normalized,
|
||||
MCPURL: authLoginURLWithHost(parsed, "pre-mcp."+strings.TrimPrefix(host, "pre-login.")),
|
||||
}, nil
|
||||
case strings.HasPrefix(host, "pre-mcp."):
|
||||
return authLoginEndpointOverrides{
|
||||
LoginURL: authLoginURLWithHost(parsed, "pre-login."+strings.TrimPrefix(host, "pre-mcp.")),
|
||||
MCPURL: normalized,
|
||||
}, nil
|
||||
default:
|
||||
return authLoginEndpointOverrides{}, apperrors.NewValidation("--pre-url must be a pre-login.* or pre-mcp.* URL")
|
||||
}
|
||||
}
|
||||
|
||||
func authLoginMCPBaseURLForConfig(cfg authLoginConfig, preOverrides authLoginEndpointOverrides) (string, authLoginMCPPersistence, error) {
|
||||
if cfg.MCPURL != "" {
|
||||
_, normalized, err := normalizeAuthLoginBaseURL(cfg.MCPURL, "--mcp-url")
|
||||
if err != nil {
|
||||
return "", authLoginMCPUseDefault, err
|
||||
}
|
||||
return normalized, authLoginMCPUseExplicitOverride, nil
|
||||
}
|
||||
if cfg.PreURL != "" {
|
||||
if preOverrides.MCPURL == "" {
|
||||
var err error
|
||||
preOverrides, err = authLoginEndpointOverridesForPreURL(cfg.PreURL)
|
||||
if err != nil {
|
||||
return "", authLoginMCPUseDefault, err
|
||||
}
|
||||
}
|
||||
return preOverrides.MCPURL, authLoginMCPUseExplicitOverride, nil
|
||||
}
|
||||
if cfg.International {
|
||||
return authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion, nil
|
||||
}
|
||||
return authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault, nil
|
||||
}
|
||||
|
||||
func persistAuthLoginMCPBaseURL(configDir, mcpBaseURL string, persistence authLoginMCPPersistence) error {
|
||||
mcpURLPath := filepath.Join(configDir, "mcp_url")
|
||||
managedRegionPath := filepath.Join(configDir, config.ManagedMCPURLRegionFileName)
|
||||
|
||||
switch persistence {
|
||||
case authLoginMCPUseExplicitOverride:
|
||||
if err := removeAuthLoginManagedMCPRegion(managedRegionPath); err != nil {
|
||||
return fmt.Errorf("clear managed MCP region: %w", err)
|
||||
}
|
||||
if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
|
||||
return fmt.Errorf("save explicit MCP URL: %w", err)
|
||||
}
|
||||
return nil
|
||||
case authLoginMCPUseManagedRegion:
|
||||
managedURL, managedErr := authReadFile(managedRegionPath)
|
||||
if managedErr != nil && !os.IsNotExist(managedErr) {
|
||||
return fmt.Errorf("read managed MCP region: %w", managedErr)
|
||||
}
|
||||
currentURL, currentErr := authReadFile(mcpURLPath)
|
||||
switch {
|
||||
case currentErr == nil && os.IsNotExist(managedErr):
|
||||
return nil
|
||||
case currentErr == nil && strings.TrimSpace(string(currentURL)) != strings.TrimSpace(string(managedURL)):
|
||||
return removeAuthLoginManagedMCPRegion(managedRegionPath)
|
||||
case currentErr != nil && !os.IsNotExist(currentErr):
|
||||
return fmt.Errorf("read MCP URL: %w", currentErr)
|
||||
}
|
||||
if err := authAtomicWrite(managedRegionPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
|
||||
return fmt.Errorf("save managed MCP region: %w", err)
|
||||
}
|
||||
if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
|
||||
_ = authRemove(managedRegionPath)
|
||||
return fmt.Errorf("save managed MCP URL: %w", err)
|
||||
}
|
||||
return nil
|
||||
case authLoginMCPUseDefault:
|
||||
managedURL, err := authReadFile(managedRegionPath)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read managed MCP region: %w", err)
|
||||
}
|
||||
currentURL, err := authReadFile(mcpURLPath)
|
||||
if os.IsNotExist(err) {
|
||||
return removeAuthLoginManagedMCPRegion(managedRegionPath)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read MCP URL: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(string(currentURL)) != strings.TrimSpace(string(managedURL)) {
|
||||
return removeAuthLoginManagedMCPRegion(managedRegionPath)
|
||||
}
|
||||
if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
|
||||
return fmt.Errorf("restore default MCP URL: %w", err)
|
||||
}
|
||||
return removeAuthLoginManagedMCPRegion(managedRegionPath)
|
||||
default:
|
||||
return fmt.Errorf("unsupported MCP persistence mode %d", persistence)
|
||||
}
|
||||
}
|
||||
|
||||
func removeAuthLoginManagedMCPRegion(path string) error {
|
||||
if err := authRemove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeAuthLoginBaseURL(raw, flagName string) (*url.URL, string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return nil, "", apperrors.NewValidation(flagName + " cannot be empty")
|
||||
}
|
||||
if !strings.Contains(value, "://") {
|
||||
value = "https://" + value
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return nil, "", apperrors.NewValidation(fmt.Sprintf("invalid %s: %v", flagName, err))
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, "", apperrors.NewValidation(flagName + " must use http or https")
|
||||
}
|
||||
if parsed.Hostname() == "" {
|
||||
return nil, "", apperrors.NewValidation(flagName + " must include a host")
|
||||
}
|
||||
if parsed.Scheme == "http" && !isAuthLoginLoopbackHost(parsed.Hostname()) {
|
||||
return nil, "", apperrors.NewValidation(flagName + " must use HTTPS, except for a loopback HTTP test endpoint")
|
||||
}
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
return parsed, strings.TrimRight(parsed.String(), "/"), nil
|
||||
}
|
||||
|
||||
func isAuthLoginLoopbackHost(host string) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(host), "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(strings.TrimSpace(host))
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func authLoginURLWithHost(parsed *url.URL, host string) string {
|
||||
copyURL := *parsed
|
||||
if port := parsed.Port(); port != "" {
|
||||
copyURL.Host = net.JoinHostPort(host, port)
|
||||
} else {
|
||||
copyURL.Host = host
|
||||
}
|
||||
return strings.TrimRight(copyURL.String(), "/")
|
||||
}
|
||||
|
||||
func authLoginForcesAuthorization(_ authLoginConfig) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -214,7 +215,8 @@ func TestCrossPlatformCoverageAuthCoverageFormsParentAndTargets(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
oldSave := authSaveTokenData
|
||||
oldDevice := authDeviceLogin
|
||||
oldOAuth := authOAuthLogin
|
||||
@@ -255,6 +257,15 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
if out, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "token"}); err != nil || !strings.Contains(out, `"token_valid": true`) {
|
||||
t.Fatalf("json token login = %q, %v", out, err)
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "pre-url": "https://example.com"}); err == nil {
|
||||
t.Fatal("invalid pre-release host should fail")
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "mcp-url": "http://remote.example.com"}); err == nil {
|
||||
t.Fatal("remote plaintext MCP URL should fail")
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "pre-url": "https://pre-login.dingtalk.io"}); err != nil {
|
||||
t.Fatalf("pre-release token login = %v", err)
|
||||
}
|
||||
|
||||
authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) {
|
||||
return nil, errors.New("device")
|
||||
@@ -271,6 +282,15 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "no-browser": "true"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, _ context.Context) (*authpkg.TokenData, error) {
|
||||
if provider.LoginRegion != authpkg.LoginRegionInternational {
|
||||
t.Errorf("device login region = %q, want international", provider.LoginRegion)
|
||||
}
|
||||
return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "intl": "true"}); err != nil {
|
||||
t.Fatalf("international device login = %v", err)
|
||||
}
|
||||
|
||||
authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) {
|
||||
return nil, errors.New("oauth")
|
||||
@@ -291,6 +311,25 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
if out, _, err := authCoverageRunLogin(t, caller, "table", true, map[string]string{"no-browser": "true"}); err != nil || !strings.Contains(out, "Corp") {
|
||||
t.Fatalf("oauth success = %q, %v", out, err)
|
||||
}
|
||||
authOAuthLogin = func(provider *authpkg.OAuthProvider, _ context.Context, _ bool) (*authpkg.TokenData, error) {
|
||||
if provider.LoginRegion != authpkg.LoginRegionInternational {
|
||||
t.Errorf("OAuth login region = %q, want international", provider.LoginRegion)
|
||||
}
|
||||
return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"intl": "true"}); err != nil {
|
||||
t.Fatalf("international OAuth login = %v", err)
|
||||
}
|
||||
|
||||
blockedConfigDir := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(blockedConfigDir, "mcp_url"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", blockedConfigDir)
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "intl": "true"}); err == nil || !strings.Contains(err.Error(), "failed to persist MCP URL") {
|
||||
t.Fatalf("MCP URL persist failure = %v", err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error {
|
||||
return errors.New("recommend")
|
||||
@@ -1418,7 +1457,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
|
||||
authRemove = func(string) error { removed++; return errors.New("ignored") }
|
||||
authDeleteAppConfig = func(string) error { removed++; return errors.New("ignored") }
|
||||
edition.Override(&edition.Hooks{})
|
||||
if err := reset.RunE(reset, nil); err != nil || removed != 3 || !strings.Contains(out.String(), "重新登录") {
|
||||
if err := reset.RunE(reset, nil); err != nil || removed != 4 || !strings.Contains(out.String(), "重新登录") {
|
||||
t.Fatalf("reset = %q, %v, removed=%d", out.String(), err, removed)
|
||||
}
|
||||
edition.Override(&edition.Hooks{IsEmbedded: true})
|
||||
|
||||
@@ -33,6 +33,8 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -1033,8 +1035,12 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
|
||||
login := &cobra.Command{Use: "login"}
|
||||
login.Flags().String("token", "", "")
|
||||
login.Flags().Bool("device", false, "")
|
||||
login.Flags().Bool("intl", false, "")
|
||||
login.Flags().Bool("international", false, "")
|
||||
login.Flags().Bool("force", false, "")
|
||||
login.Flags().Bool("recommend", false, "")
|
||||
login.Flags().String("pre-url", "", "")
|
||||
login.Flags().String("mcp-url", "", "")
|
||||
root.AddCommand(login)
|
||||
|
||||
if err := root.PersistentFlags().Set("yes", "true"); err != nil {
|
||||
@@ -1061,6 +1067,560 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveAuthLoginConfigReadsInternationalAliases(t *testing.T) {
|
||||
for _, flag := range []string{"intl", "international"} {
|
||||
t.Run(flag, func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
login := &cobra.Command{Use: "login"}
|
||||
login.Flags().String("token", "", "")
|
||||
login.Flags().Bool("device", false, "")
|
||||
login.Flags().Bool("intl", false, "")
|
||||
login.Flags().Bool("international", false, "")
|
||||
login.Flags().Bool("force", false, "")
|
||||
login.Flags().Bool("recommend", false, "")
|
||||
login.Flags().String("pre-url", "", "")
|
||||
login.Flags().String("mcp-url", "", "")
|
||||
root.AddCommand(login)
|
||||
|
||||
if err := login.Flags().Set(flag, "true"); err != nil {
|
||||
t.Fatalf("set %s: %v", flag, err)
|
||||
}
|
||||
|
||||
cfg, err := resolveAuthLoginConfig(login)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAuthLoginConfig error = %v", err)
|
||||
}
|
||||
if !cfg.International {
|
||||
t.Fatalf("International = false for --%s, want true", flag)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
}{
|
||||
{
|
||||
name: "missing intl",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().Bool("device", false, "")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing international",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().Bool("device", false, "")
|
||||
cmd.Flags().Bool("intl", false, "")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing pre url",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().Bool("device", false, "")
|
||||
cmd.Flags().Bool("intl", false, "")
|
||||
cmd.Flags().Bool("international", false, "")
|
||||
cmd.Flags().Bool("force", false, "")
|
||||
cmd.Flags().Bool("recommend", false, "")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing mcp url",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().Bool("device", false, "")
|
||||
cmd.Flags().Bool("intl", false, "")
|
||||
cmd.Flags().Bool("international", false, "")
|
||||
cmd.Flags().Bool("force", false, "")
|
||||
cmd.Flags().Bool("recommend", false, "")
|
||||
cmd.Flags().String("pre-url", "", "")
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "login"}
|
||||
tc.setup(cmd)
|
||||
if _, err := resolveAuthLoginConfig(cmd); err == nil {
|
||||
t.Fatal("resolveAuthLoginConfig succeeded with an incomplete flag set")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveAuthLoginConfigReadsMCPURL(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
login := &cobra.Command{Use: "login"}
|
||||
login.Flags().String("token", "", "")
|
||||
login.Flags().Bool("device", false, "")
|
||||
login.Flags().Bool("intl", false, "")
|
||||
login.Flags().Bool("international", false, "")
|
||||
login.Flags().Bool("force", false, "")
|
||||
login.Flags().Bool("recommend", false, "")
|
||||
login.Flags().String("pre-url", "", "")
|
||||
login.Flags().String("mcp-url", "", "")
|
||||
root.AddCommand(login)
|
||||
|
||||
if err := login.Flags().Set("mcp-url", " https://pre-mcp.dingtalk.io/ "); err != nil {
|
||||
t.Fatalf("set mcp-url: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := resolveAuthLoginConfig(login)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAuthLoginConfig error = %v", err)
|
||||
}
|
||||
if cfg.MCPURL != "https://pre-mcp.dingtalk.io/" {
|
||||
t.Fatalf("MCPURL = %q, want trimmed flag value", cfg.MCPURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveAuthLoginConfigReadsPreURL(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
login := &cobra.Command{Use: "login"}
|
||||
login.Flags().String("token", "", "")
|
||||
login.Flags().Bool("device", false, "")
|
||||
login.Flags().Bool("intl", false, "")
|
||||
login.Flags().Bool("international", false, "")
|
||||
login.Flags().Bool("force", false, "")
|
||||
login.Flags().Bool("recommend", false, "")
|
||||
login.Flags().String("pre-url", "", "")
|
||||
login.Flags().String("mcp-url", "", "")
|
||||
root.AddCommand(login)
|
||||
|
||||
if err := login.Flags().Set("pre-url", " pre-login.dingtalk.io "); err != nil {
|
||||
t.Fatalf("set pre-url: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := resolveAuthLoginConfig(login)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAuthLoginConfig error = %v", err)
|
||||
}
|
||||
if cfg.PreURL != "pre-login.dingtalk.io" {
|
||||
t.Fatalf("PreURL = %q, want trimmed flag value", cfg.PreURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthLoginEndpointOverridesForPreURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantLogin string
|
||||
wantMCP string
|
||||
}{
|
||||
{
|
||||
name: "pre login",
|
||||
raw: "https://pre-login.dingtalk.io/",
|
||||
wantLogin: "https://pre-login.dingtalk.io",
|
||||
wantMCP: "https://pre-mcp.dingtalk.io",
|
||||
},
|
||||
{
|
||||
name: "pre mcp",
|
||||
raw: "pre-mcp.dingtalk.io",
|
||||
wantLogin: "https://pre-login.dingtalk.io",
|
||||
wantMCP: "https://pre-mcp.dingtalk.io",
|
||||
},
|
||||
{
|
||||
name: "pre login with port",
|
||||
raw: "https://pre-login.dingtalk.io:8443/path/",
|
||||
wantLogin: "https://pre-login.dingtalk.io:8443/path",
|
||||
wantMCP: "https://pre-mcp.dingtalk.io:8443/path",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := authLoginEndpointOverridesForPreURL(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("authLoginEndpointOverridesForPreURL error = %v", err)
|
||||
}
|
||||
if got.LoginURL != tc.wantLogin || got.MCPURL != tc.wantMCP {
|
||||
t.Fatalf("overrides = %#v, want login %q mcp %q", got, tc.wantLogin, tc.wantMCP)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, raw := range []string{"https://example.com", "http://pre-login.example.com"} {
|
||||
if _, err := authLoginEndpointOverridesForPreURL(raw); err == nil {
|
||||
t.Fatalf("authLoginEndpointOverridesForPreURL(%q) succeeded", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthLoginMCPBaseURLForConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg authLoginConfig
|
||||
preOverride authLoginEndpointOverrides
|
||||
wantURL string
|
||||
wantPersistence authLoginMCPPersistence
|
||||
}{
|
||||
{
|
||||
name: "default center login uses com and resets only managed region",
|
||||
cfg: authLoginConfig{},
|
||||
wantURL: authpkg.DefaultMCPBaseURL,
|
||||
wantPersistence: authLoginMCPUseDefault,
|
||||
},
|
||||
{
|
||||
name: "international login persists managed io",
|
||||
cfg: authLoginConfig{International: true},
|
||||
wantURL: authpkg.InternationalMCPBaseURL,
|
||||
wantPersistence: authLoginMCPUseManagedRegion,
|
||||
},
|
||||
{
|
||||
name: "pre login persists mapped pre mcp",
|
||||
cfg: authLoginConfig{PreURL: "pre-login.dingtalk.io"},
|
||||
preOverride: authLoginEndpointOverrides{
|
||||
LoginURL: "https://pre-login.dingtalk.io",
|
||||
MCPURL: "https://pre-mcp.dingtalk.io",
|
||||
},
|
||||
wantURL: "https://pre-mcp.dingtalk.io",
|
||||
wantPersistence: authLoginMCPUseExplicitOverride,
|
||||
},
|
||||
{
|
||||
name: "explicit mcp url wins over pre url",
|
||||
cfg: authLoginConfig{
|
||||
PreURL: "pre-login.dingtalk.io",
|
||||
MCPURL: " https://custom-mcp.example.com/ ",
|
||||
},
|
||||
preOverride: authLoginEndpointOverrides{
|
||||
LoginURL: "https://pre-login.dingtalk.io",
|
||||
MCPURL: "https://pre-mcp.dingtalk.io",
|
||||
},
|
||||
wantURL: "https://custom-mcp.example.com",
|
||||
wantPersistence: authLoginMCPUseExplicitOverride,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotURL, gotPersistence, err := authLoginMCPBaseURLForConfig(tc.cfg, tc.preOverride)
|
||||
if err != nil {
|
||||
t.Fatalf("authLoginMCPBaseURLForConfig error = %v", err)
|
||||
}
|
||||
if gotURL != tc.wantURL || gotPersistence != tc.wantPersistence {
|
||||
t.Fatalf("got url=%q persistence=%v, want url=%q persistence=%v", gotURL, gotPersistence, tc.wantURL, tc.wantPersistence)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, cfg := range []authLoginConfig{
|
||||
{MCPURL: "http://remote.example.com"},
|
||||
{PreURL: "https://example.com"},
|
||||
} {
|
||||
if _, _, err := authLoginMCPBaseURLForConfig(cfg, authLoginEndpointOverrides{}); err == nil {
|
||||
t.Fatalf("authLoginMCPBaseURLForConfig(%#v) succeeded", cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersistAuthLoginMCPBaseURL(t *testing.T) {
|
||||
t.Run("persists selected io mcp url", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
|
||||
t.Fatalf("persistAuthLoginMCPBaseURL error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(mcp_url) error = %v", err)
|
||||
}
|
||||
if string(data) != authpkg.InternationalMCPBaseURL {
|
||||
t.Fatalf("mcp_url = %q, want %q", string(data), authpkg.InternationalMCPBaseURL)
|
||||
}
|
||||
managed, err := os.ReadFile(filepath.Join(configDir, config.ManagedMCPURLRegionFileName))
|
||||
if err != nil || string(managed) != authpkg.InternationalMCPBaseURL {
|
||||
t.Fatalf("managed MCP region = %q, %v", string(managed), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("center login preserves previous persisted override", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
mcpURLPath := filepath.Join(configDir, "mcp_url")
|
||||
const customURL = "https://custom-mcp.example.com"
|
||||
if err := os.WriteFile(mcpURLPath, []byte(customURL), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
|
||||
t.Fatalf("persistAuthLoginMCPBaseURL error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(mcpURLPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(mcp_url) error = %v", err)
|
||||
}
|
||||
if string(data) != customURL {
|
||||
t.Fatalf("mcp_url = %q, want preserved override %q", string(data), customURL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("international login preserves an unmanaged explicit override", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
mcpURLPath := filepath.Join(configDir, "mcp_url")
|
||||
const customURL = "https://private-mcp.example.com"
|
||||
if err := os.WriteFile(mcpURLPath, []byte(customURL), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(mcpURLPath)
|
||||
if err != nil || string(data) != customURL {
|
||||
t.Fatalf("explicit mcp_url = %q, %v; want preserved custom URL", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("unmanaged override acquired a managed marker: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("center login resets a managed international URL", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
|
||||
if err != nil || string(data) != authpkg.DefaultMCPBaseURL {
|
||||
t.Fatalf("mcp_url = %q, %v; want domestic default", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("managed region marker remains after domestic login: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit override clears region ownership", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const customURL = "https://custom-mcp.example.com"
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, customURL, authLoginMCPUseExplicitOverride); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
|
||||
if err != nil || string(data) != customURL {
|
||||
t.Fatalf("explicit mcp_url = %q, %v; want preserved custom URL", string(data), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stale marker never deletes a different custom URL", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://custom.example.com"), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, config.ManagedMCPURLRegionFileName), []byte(authpkg.InternationalMCPBaseURL), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
|
||||
if err != nil || string(data) != "https://custom.example.com" {
|
||||
t.Fatalf("custom mcp_url = %q, %v", string(data), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("international login clears a stale marker without changing a custom URL", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
mcpURLPath := filepath.Join(configDir, "mcp_url")
|
||||
managedRegionPath := filepath.Join(configDir, config.ManagedMCPURLRegionFileName)
|
||||
const customURL = "https://private-mcp.example.com"
|
||||
if err := os.WriteFile(mcpURLPath, []byte(customURL), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(managedRegionPath, []byte(authpkg.DefaultMCPBaseURL), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(mcpURLPath)
|
||||
if err != nil || string(data) != customURL {
|
||||
t.Fatalf("custom mcp_url = %q, %v", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(managedRegionPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("stale managed marker remains: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersistAuthLoginMCPBaseURLErrors(t *testing.T) {
|
||||
fail := errors.New("persist failure")
|
||||
|
||||
t.Run("explicit marker cleanup", func(t *testing.T) {
|
||||
testseam.Swap(t, &authRemove, func(string) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), "https://custom.example.com", authLoginMCPUseExplicitOverride); !errors.Is(err, fail) {
|
||||
t.Fatalf("explicit marker cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit URL write", func(t *testing.T) {
|
||||
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), "https://custom.example.com", authLoginMCPUseExplicitOverride); !errors.Is(err, fail) {
|
||||
t.Fatalf("explicit URL write error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed marker write", func(t *testing.T) {
|
||||
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
|
||||
t.Fatalf("managed marker write error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed marker read", func(t *testing.T) {
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return nil, fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
|
||||
t.Fatalf("managed marker read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed MCP URL read", func(t *testing.T) {
|
||||
reads := 0
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
|
||||
reads++
|
||||
if reads == 1 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return nil, fail
|
||||
})
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
|
||||
t.Fatalf("managed MCP URL read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed stale marker cleanup", func(t *testing.T) {
|
||||
reads := 0
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
|
||||
reads++
|
||||
if reads == 1 {
|
||||
return []byte(authpkg.DefaultMCPBaseURL), nil
|
||||
}
|
||||
return []byte("https://private-mcp.example.com"), nil
|
||||
})
|
||||
testseam.Swap(t, &authRemove, func(string) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
|
||||
t.Fatalf("managed stale marker cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed URL write cleans marker", func(t *testing.T) {
|
||||
writes := 0
|
||||
removed := false
|
||||
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error {
|
||||
writes++
|
||||
if writes == 2 {
|
||||
return fail
|
||||
}
|
||||
return nil
|
||||
})
|
||||
testseam.Swap(t, &authRemove, func(string) error { removed = true; return nil })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) || !removed {
|
||||
t.Fatalf("managed URL write error = %v, marker removed=%v", err, removed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default managed marker read", func(t *testing.T) {
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return nil, fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
|
||||
t.Fatalf("managed marker read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing MCP URL cleans marker", func(t *testing.T) {
|
||||
reads := 0
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
|
||||
reads++
|
||||
if reads == 1 {
|
||||
return []byte(authpkg.InternationalMCPBaseURL), nil
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
})
|
||||
testseam.Swap(t, &authRemove, func(string) error { return nil })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
|
||||
t.Fatalf("missing MCP URL cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MCP URL read", func(t *testing.T) {
|
||||
reads := 0
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
|
||||
reads++
|
||||
if reads == 1 {
|
||||
return []byte(authpkg.InternationalMCPBaseURL), nil
|
||||
}
|
||||
return nil, fail
|
||||
})
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
|
||||
t.Fatalf("MCP URL read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default URL write", func(t *testing.T) {
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return []byte(authpkg.InternationalMCPBaseURL), nil })
|
||||
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
|
||||
t.Fatalf("default URL write error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("final marker cleanup", func(t *testing.T) {
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return []byte(authpkg.InternationalMCPBaseURL), nil })
|
||||
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return nil })
|
||||
testseam.Swap(t, &authRemove, func(string) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
|
||||
t.Fatalf("final marker cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPPersistence(255)); err == nil {
|
||||
t.Fatal("unsupported MCP persistence mode succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNormalizeAuthLoginBaseURLTransportSecurity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantURL string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "https remote", raw: "https://pre-mcp.example.com/path/?secret=drop#fragment", wantURL: "https://pre-mcp.example.com/path"},
|
||||
{name: "http localhost", raw: "http://localhost:8080/", wantURL: "http://localhost:8080"},
|
||||
{name: "http IPv4 loopback", raw: "http://127.0.0.1:8080", wantURL: "http://127.0.0.1:8080"},
|
||||
{name: "http IPv6 loopback", raw: "http://[::1]:8080", wantURL: "http://[::1]:8080"},
|
||||
{name: "http remote", raw: "http://pre-mcp.example.com", wantErr: "must use HTTPS"},
|
||||
{name: "empty", raw: " ", wantErr: "cannot be empty"},
|
||||
{name: "invalid URL", raw: "https://%", wantErr: "invalid --mcp-url"},
|
||||
{name: "invalid scheme", raw: "ftp://pre-mcp.example.com", wantErr: "must use http or https"},
|
||||
{name: "missing host", raw: "https:///path", wantErr: "must include a host"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, got, err := normalizeAuthLoginBaseURL(tc.raw, "--mcp-url")
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("normalizeAuthLoginBaseURL error = %v, want containing %q", err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeAuthLoginBaseURL error = %v", err)
|
||||
}
|
||||
if got != tc.wantURL {
|
||||
t.Fatalf("normalized URL = %q, want %q", got, tc.wantURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) {
|
||||
if !authLoginForcesAuthorization(authLoginConfig{}) {
|
||||
t.Fatal("auth login should force authorization by default so each login can add an organization profile")
|
||||
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -326,6 +327,29 @@ func TestCrossPlatformCoverageSmallAppRegistryAndRootCoverage(t *testing.T) {
|
||||
func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition); SetDynamicServers(nil) })
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
region authpkg.LoginRegion
|
||||
want string
|
||||
}{
|
||||
{raw: "%", want: "%"},
|
||||
{raw: "https://dingtalk.io/path", want: "https://dingtalk.com/path"},
|
||||
{raw: "https://mcp.dingtalk.com:8443/path", region: authpkg.LoginRegionInternational, want: "https://mcp.dingtalk.io:8443/path"},
|
||||
} {
|
||||
if got := mcpBaseURLForLoginRegion(tc.raw, tc.region); got != tc.want {
|
||||
t.Fatalf("mcpBaseURLForLoginRegion(%q, %q) = %q, want %q", tc.raw, tc.region, got, tc.want)
|
||||
}
|
||||
}
|
||||
if hasDirectRuntimeEndpointOverride("") {
|
||||
t.Fatal("blank product unexpectedly has an endpoint override")
|
||||
}
|
||||
t.Setenv("DINGTALK_COVERAGE_PRODUCT_MCP_URL", "https://override.test")
|
||||
if !hasDirectRuntimeEndpointOverride("coverage-product") {
|
||||
t.Fatal("configured product endpoint override was not detected")
|
||||
}
|
||||
if got := activeDingTalkGatewayEndpointWithBase("https://mcp-gw.dingtalk.com/server/contact", "%"); got != "https://mcp-gw.dingtalk.com/server/contact" {
|
||||
t.Fatalf("invalid gateway base rewrote endpoint to %q", got)
|
||||
}
|
||||
server := mcptypes.ServerDescriptor{
|
||||
Endpoint: "https://one.test",
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
@@ -913,12 +937,21 @@ func TestCrossPlatformCoverageAuthCommandPureCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthLoginTokenCommandCoverage(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
oldInteractive := authLoginInteractiveTerminal
|
||||
authLoginInteractiveTerminal = func() bool { return false }
|
||||
t.Cleanup(func() { authLoginInteractiveTerminal = oldInteractive; authpkg.SetRuntimeProfile("") })
|
||||
for _, format := range []string{"table", "json"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
for _, tc := range []struct {
|
||||
format string
|
||||
international bool
|
||||
}{
|
||||
{format: "table"},
|
||||
{format: "json", international: true},
|
||||
} {
|
||||
t.Run(tc.format, func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
@@ -929,16 +962,90 @@ func TestCrossPlatformCoverageAuthLoginTokenCommandCoverage(t *testing.T) {
|
||||
root.SetOut(&output)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{"login", "--token", "manual-token", "--yes"}
|
||||
if format == "json" {
|
||||
if tc.international {
|
||||
args = append(args, "--intl")
|
||||
}
|
||||
if tc.format == "json" {
|
||||
args = append(args, "--format", "json")
|
||||
}
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err != nil || output.Len() == 0 {
|
||||
t.Fatalf("token login = %q %v", output.String(), err)
|
||||
}
|
||||
data, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData error = %v", err)
|
||||
}
|
||||
wantRegion := ""
|
||||
if tc.international {
|
||||
wantRegion = string(authpkg.LoginRegionInternational)
|
||||
}
|
||||
if data.LoginRegion != wantRegion {
|
||||
t.Fatalf("LoginRegion = %q, want %q", data.LoginRegion, wantRegion)
|
||||
}
|
||||
if tc.international {
|
||||
snapshot, err := resolveAccessTokenSnapshotFromDir(context.Background(), configDir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAccessTokenSnapshotFromDir error = %v", err)
|
||||
}
|
||||
gotEndpoint := activeDingTalkGatewayEndpointForLoginRegion(
|
||||
"https://mcp-gw.dingtalk.com/server/contact",
|
||||
snapshot.LoginRegion,
|
||||
)
|
||||
if wantEndpoint := "https://mcp-gw.dingtalk.io/server/contact"; gotEndpoint != wantEndpoint {
|
||||
t.Fatalf("international manual-token endpoint = %q, want %q", gotEndpoint, wantEndpoint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("international then domestic login restores domestic MCP URL", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
runLogin := func(international bool) {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().String("profile", "", "")
|
||||
root.AddCommand(newAuthLoginCommand(nil))
|
||||
args := []string{"login", "--token", "manual-token", "--yes"}
|
||||
if international {
|
||||
args = append(args, "--intl")
|
||||
}
|
||||
root.SetArgs(args)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("international=%v login error = %v", international, err)
|
||||
}
|
||||
}
|
||||
|
||||
runLogin(true)
|
||||
if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != authpkg.InternationalMCPBaseURL {
|
||||
t.Fatalf("international mcp_url = %q, %v", string(data), err)
|
||||
}
|
||||
runLogin(false)
|
||||
if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != authpkg.DefaultMCPBaseURL {
|
||||
t.Fatalf("domestic mcp_url = %q, %v", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("managed MCP region marker remains: %v", err)
|
||||
}
|
||||
|
||||
const customURL = "https://private-mcp.example.com"
|
||||
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte(customURL), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runLogin(true)
|
||||
if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != customURL {
|
||||
t.Fatalf("explicit mcp_url after international login = %q, %v; want preserved custom URL", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("explicit mcp_url acquired a managed marker: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, hidden := range []bool{false, true} {
|
||||
old := edition.Get()
|
||||
edition.Override(&edition.Hooks{HideAuthLogin: hidden})
|
||||
|
||||
@@ -74,6 +74,20 @@ func defaultPATMCPEndpoint() string {
|
||||
|
||||
func defaultPATGatewayBaseURL() string {
|
||||
raw := strings.TrimSpace(authpkg.GetMCPBaseURL())
|
||||
return mcpGatewayBaseURL(raw)
|
||||
}
|
||||
|
||||
func defaultPATGatewayBaseURLForLoginRegion(region authpkg.LoginRegion) string {
|
||||
raw := strings.TrimSpace(authpkg.GetMCPBaseURL())
|
||||
if override := authpkg.MCPBaseURLOverride(); override != "" {
|
||||
raw = override
|
||||
} else {
|
||||
raw = mcpBaseURLForLoginRegion(raw, region)
|
||||
}
|
||||
return mcpGatewayBaseURL(raw)
|
||||
}
|
||||
|
||||
func mcpGatewayBaseURL(raw string) string {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return strings.TrimRight(raw, "/")
|
||||
@@ -100,6 +114,33 @@ func defaultPATGatewayBaseURL() string {
|
||||
return strings.TrimRight(parsed.String(), "/")
|
||||
}
|
||||
|
||||
func mcpBaseURLForLoginRegion(raw string, region authpkg.LoginRegion) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return raw
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
fromSuffix, toSuffix := ".dingtalk.io", ".dingtalk.com"
|
||||
if region.IsInternational() {
|
||||
fromSuffix, toSuffix = toSuffix, fromSuffix
|
||||
}
|
||||
bareFrom := strings.TrimPrefix(fromSuffix, ".")
|
||||
if host != bareFrom && !strings.HasSuffix(host, fromSuffix) {
|
||||
return raw
|
||||
}
|
||||
if host == bareFrom {
|
||||
host = strings.TrimPrefix(toSuffix, ".")
|
||||
} else {
|
||||
host = strings.TrimSuffix(host, fromSuffix) + toSuffix
|
||||
}
|
||||
if port := parsed.Port(); port != "" {
|
||||
parsed.Host = net.JoinHostPort(host, port)
|
||||
} else {
|
||||
parsed.Host = host
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
// SetDynamicServers injects server data discovered from servers.json.
|
||||
// All product endpoints are resolved dynamically from this data.
|
||||
func SetDynamicServers(servers []mcptypes.ServerDescriptor) {
|
||||
@@ -131,7 +172,7 @@ func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[strin
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(server.CLI.ID)
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
endpoint := activeDingTalkGatewayEndpoint(server.Endpoint)
|
||||
if id != "" && endpoint != "" {
|
||||
endpoints[id] = endpoint
|
||||
products[id] = true
|
||||
@@ -262,6 +303,19 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func hasDirectRuntimeEndpointOverride(productID string) bool {
|
||||
normalized := normalizeDirectRuntimeProductID(productID)
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := productEndpointOverride(candidate); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func editionServerEndpoint(productID string) (string, bool) {
|
||||
productID = strings.TrimSpace(productID)
|
||||
if productID == "" {
|
||||
@@ -282,7 +336,7 @@ func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo
|
||||
return "", false
|
||||
}
|
||||
for _, server := range fn() {
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
endpoint := activeDingTalkGatewayEndpoint(server.Endpoint)
|
||||
if endpoint == "" {
|
||||
continue
|
||||
}
|
||||
@@ -298,6 +352,46 @@ func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo
|
||||
return "", false
|
||||
}
|
||||
|
||||
func activeDingTalkGatewayEndpoint(endpoint string) string {
|
||||
return activeDingTalkGatewayEndpointWithBase(endpoint, defaultPATGatewayBaseURL())
|
||||
}
|
||||
|
||||
func activeDingTalkGatewayEndpointForLoginRegion(endpoint string, region authpkg.LoginRegion) string {
|
||||
return activeDingTalkGatewayEndpointWithBase(endpoint, defaultPATGatewayBaseURLForLoginRegion(region))
|
||||
}
|
||||
|
||||
func activeDingTalkGatewayEndpointWithBase(endpoint, gatewayBaseURL string) string {
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return endpoint
|
||||
}
|
||||
if !isDingTalkMCPGatewayHost(parsed.Hostname()) {
|
||||
return endpoint
|
||||
}
|
||||
base, err := url.Parse(gatewayBaseURL)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
return endpoint
|
||||
}
|
||||
parsed.Scheme = base.Scheme
|
||||
parsed.Host = base.Host
|
||||
return strings.TrimRight(parsed.String(), "/")
|
||||
}
|
||||
|
||||
func isDingTalkMCPGatewayHost(host string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(host)) {
|
||||
case "mcp-gw.dingtalk.com", "pre-mcp-gw.dingtalk.com", "mcp-gw.dingtalk.io", "pre-mcp-gw.dingtalk.io":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isDingTalkMCPGatewayEndpoint(endpoint string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(endpoint))
|
||||
return err == nil && isDingTalkMCPGatewayHost(parsed.Hostname())
|
||||
}
|
||||
|
||||
// DirectRuntimeProductIDs returns product IDs that should stay visible for
|
||||
// direct runtime execution. Dynamic products come from MCP discovery/plugin
|
||||
// registration; built-in helper products such as devapp resolve their endpoint
|
||||
|
||||
@@ -1220,7 +1220,8 @@ func newEventStopCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command {
|
||||
editionName := editionNameOrDefault()
|
||||
clientIDHash := dwsevent.ClientIDHash(clientID)
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
|
||||
if err := eventStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
|
||||
if err := eventStopBus(busctl.StopConfig{WorkDir: workDir, IPCEndpoint: ipcEndpoint}); err != nil {
|
||||
if errors.Is(err, busctl.ErrNotRunning) {
|
||||
fmt.Fprintln(c.OutOrStdout(), "bus is not running")
|
||||
return nil
|
||||
|
||||
@@ -1220,7 +1220,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
||||
}
|
||||
|
||||
busState := "personal bus stopped"
|
||||
if err := personalStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
||||
if err := personalStopBus(busctl.StopConfig{WorkDir: workDir, IPCEndpoint: ipcEndpoint}); err != nil {
|
||||
if errors.Is(err, busctl.ErrNotRunning) {
|
||||
busState = "personal bus is not running"
|
||||
} else {
|
||||
|
||||
@@ -82,6 +82,46 @@ func TestFlagErrorWithSuggestions_unknownFlagHintAndFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagErrorWithSuggestionsChatFromExplainsBothMeanings(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
search := &cobra.Command{Use: "+search-msg", Run: func(*cobra.Command, []string) {}}
|
||||
search.Flags().String("sender", "", "sender target")
|
||||
search.Flags().String("start", "", "start time")
|
||||
root.AddCommand(chat)
|
||||
chat.AddCommand(search)
|
||||
|
||||
orig := fmt.Errorf("unknown flag: --from")
|
||||
err := flagErrorWithSuggestions(search, orig)
|
||||
var ae *apperrors.Error
|
||||
if !stderrors.As(err, &ae) {
|
||||
t.Fatalf("want *apperrors.Error, got %T", err)
|
||||
}
|
||||
if ae.Reason != "ambiguous_flag" || !strings.Contains(ae.Hint, "--sender") || !strings.Contains(ae.Hint, "--start") {
|
||||
t.Fatalf("structured error = reason %q hint %q", ae.Reason, ae.Hint)
|
||||
}
|
||||
if !strings.HasSuffix(ae.Message, "See 'dws chat +search-msg --help' for usage.") {
|
||||
t.Fatalf("Message = %q", ae.Message)
|
||||
}
|
||||
|
||||
for _, flag := range []string{"from-file", "from-user"} {
|
||||
t.Run(flag, func(t *testing.T) {
|
||||
err := flagErrorWithSuggestions(search, fmt.Errorf("unknown flag: --%s", flag))
|
||||
var structured *apperrors.Error
|
||||
if stderrors.As(err, &structured) && structured.Reason == "ambiguous_flag" {
|
||||
t.Fatalf("--%s incorrectly used --from ambiguity handling: %#v", flag, structured)
|
||||
}
|
||||
if strings.Contains(err.Error(), "--from 在消息查询中含义不明确") {
|
||||
t.Fatalf("--%s incorrectly received --from ambiguity hint: %v", flag, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown flag: --"+flag) {
|
||||
t.Fatalf("error = %q, want original flag --%s", err, flag)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlagErrorWithSuggestions_fallbackTailHint 验证 fallback 路径(非 unknown flag 类错误,
|
||||
// 如 missing required flag / ambiguous shorthand)也带尾部 See '<cmd> --help' for usage.
|
||||
// 这是 wukong / docker / kubectl 的通用 UX——任何 flag 解析错误都给用户一条 help 入口。
|
||||
|
||||
@@ -268,7 +268,7 @@ type frameworkFailWriter struct{}
|
||||
|
||||
func (frameworkFailWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|
||||
|
||||
func TestFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
|
||||
for _, failWriter := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "emits", true: "fallback"}[failWriter], func(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
@@ -470,6 +470,118 @@ func TestCrossPlatformCoverageFrameworkExecuteRareOutcomeBranches(t *testing.T)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExecuteDeterministicInterruptionBranches(t *testing.T) {
|
||||
install := func(t *testing.T, state *processSignalState, stdout, stderr io.Writer) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws"}
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
testseam.Swap(t, &rootInstallProcessSignalContext, func(ctx context.Context, _ *output.ResultStore) (context.Context, *processSignalState, func()) {
|
||||
return ctx, state, func() {}
|
||||
})
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
cmd.SetContext(ctx)
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(stderr)
|
||||
return cmd
|
||||
})
|
||||
}
|
||||
interrupted := func(primaryCompleted bool) *processSignalState {
|
||||
return &processSignalState{
|
||||
interruption: &processInterruption{signal: os.Interrupt},
|
||||
primaryCompletedAtSignal: primaryCompleted,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("preparse interruption emits unified failure", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
install(t, interrupted(false), &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return errors.New("preparse failed") })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(*cobra.Command) (*cobra.Command, error) {
|
||||
t.Fatal("preparse failure reached command execution")
|
||||
return nil, nil
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 130 || summary == "" || !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("preparse interruption = code %d summary %q stdout %q", code, summary, stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("interruption before emission becomes primary error", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
install(t, interrupted(false), &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil })
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 130 || summary == "" || !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("pre-emission interruption = code %d summary %q stdout %q", code, summary, stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("late hook error preserves emitted result", func(t *testing.T) {
|
||||
install(t, interrupted(true), io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cmd, errors.New("late hook failed")
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 0 || summary != "late hook failed" {
|
||||
t.Fatalf("late hook result = code %d summary %q", code, summary)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("interruption after emission preserves emitted result", func(t *testing.T) {
|
||||
install(t, interrupted(false), io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cmd, nil
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 0 || summary == "" {
|
||||
t.Fatalf("post-emission interruption = code %d summary %q", code, summary)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publication failure replaces unobservable result", func(t *testing.T) {
|
||||
var original bytes.Buffer
|
||||
install(t, interrupted(false), io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, err := os.CreateTemp(t.TempDir(), "finished-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{file: file, original: &original, finished: true}))
|
||||
publicationErr := newOutputPublicationError("publish", errors.New("rename failed"))
|
||||
if _, handled, emitErr := emitOutputPublicationFailure(cmd, publicationErr); !handled || emitErr != nil {
|
||||
t.Fatalf("precondition publication failure = handled %v error %v unified %v state %v", handled, emitErr, output.UsesUnifiedResult(cmd), outputSinkForCommand(cmd) != nil)
|
||||
}
|
||||
return cmd, publicationErr
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 5 || summary == "" {
|
||||
t.Fatalf("publication failure = code %d summary %q output %q", code, summary, original.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type frameworkPanicWriter struct{}
|
||||
|
||||
func (frameworkPanicWriter) Write([]byte) (int, error) { panic("writer panic") }
|
||||
|
||||
@@ -52,6 +52,8 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
switch tool {
|
||||
case "list_calendar_events":
|
||||
return `{"result":{"events":[]}}`
|
||||
case "query_records":
|
||||
return `{"success":true,"status":"success","error":{},"data":{}}`
|
||||
case "search_mail_users":
|
||||
return `{"users":[{"name":"Fixture User","email":"fixture@example.com","id":"fixture-user"}]}`
|
||||
case "search_dept_by_keyword":
|
||||
@@ -63,11 +65,50 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
case "list_doc_versions":
|
||||
return `{"result":{"items":[{"version":3}]}}`
|
||||
case "revert_doc_version":
|
||||
return `{"version":3}`
|
||||
return `{"revertedToVersion":3}`
|
||||
case "search_doc_templates":
|
||||
return `{"result":[{"templateId":"fixture-template-id"}]}`
|
||||
case "create_document":
|
||||
return `{"nodeId":"fixture-node"}`
|
||||
case "list_files":
|
||||
return `{"success":true,"result":{"files":[],"hasMore":false}}`
|
||||
case "list_recycle_items":
|
||||
return `{"success":true,"result":{"recycleItems":[{"recycleItemId":"recycle-1","originalName":"Fixture Node"}],"hasMore":false}}`
|
||||
case "get_star_list":
|
||||
return `{"success":true,"result":{"starList":[],"hasMore":false}}`
|
||||
case "list_file_versions":
|
||||
return `{"success":true,"result":{"versions":[{"version":3,"name":"Fixture Version"}],"hasMore":false}}`
|
||||
case "get_file_info":
|
||||
name := "Fixture Node"
|
||||
for index := len(c.calls) - 2; index >= 0; index-- {
|
||||
call := c.calls[index]
|
||||
switch call.tool {
|
||||
case "create_folder":
|
||||
if value, ok := call.args["name"].(string); ok {
|
||||
name = value
|
||||
}
|
||||
index = -1
|
||||
case "rename_document":
|
||||
if value, ok := call.args["newName"].(string); ok {
|
||||
name = value
|
||||
}
|
||||
index = -1
|
||||
}
|
||||
}
|
||||
encoded, _ := json.Marshal(map[string]any{"success": true, "result": map[string]any{"fileId": "node-1", "name": name}})
|
||||
return string(encoded)
|
||||
case "get_cover", "get_node_stats":
|
||||
return `{"success":true,"result":{"nodeId":"node-1"}}`
|
||||
case "get_file_publish_status":
|
||||
return `{"success":true,"result":{"fileId":"node-1","published":false}}`
|
||||
case "create_folder", "create_shortcut":
|
||||
return `{"success":true,"fileId":"node-1"}`
|
||||
case "delete_document", "mark_star", "unmark_star", "restore_recycle_item", "rename_document", "revert_file_version":
|
||||
return `{"success":true,"fileId":"node-1"}`
|
||||
case "set_file_publish":
|
||||
return `{"success":true}`
|
||||
case "download_file", "download_file_version":
|
||||
return `{"success":true,"result":{"downloadUrl":"http://invalid.test/fixture.bin","fileName":"fixture.bin"}}`
|
||||
case "get_document_content":
|
||||
for index := len(c.calls) - 2; index >= 0; index-- {
|
||||
call := c.calls[index]
|
||||
@@ -322,9 +363,9 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasE2E(t, caller,
|
||||
"chat", "message", "send",
|
||||
"--to-user", "D-recipient",
|
||||
"--to-user", appFixtureCurrentDOpenID,
|
||||
"--text", "hello alias",
|
||||
"--uuid", "alias-e2e",
|
||||
"--idempotency-key", "alias-e2e",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat write alias E2E error = %v", err)
|
||||
@@ -336,7 +377,7 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
t.Fatalf("chat calls = %#v", caller.calls)
|
||||
}
|
||||
payload := caller.calls[0].args
|
||||
if payload["receiverOpenDingTalkId"] != "D-recipient" || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" {
|
||||
if payload["receiverOpenDingTalkId"] != appFixtureCurrentDOpenID || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" {
|
||||
t.Fatalf("chat payload identity fields = %#v", payload)
|
||||
}
|
||||
content, _ := payload["content"].(string)
|
||||
@@ -350,6 +391,29 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageSendLegacyUUIDAliasFinalPayload(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
_, err := executeParamAliasE2E(t, caller,
|
||||
"chat", "message", "send",
|
||||
"--group", "fixture-conversation",
|
||||
"--text", "hello legacy uuid",
|
||||
"--uuid", "legacy-alias-e2e",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat message send legacy uuid error = %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "send_personal_message" {
|
||||
t.Fatalf("chat calls = %#v", caller.calls)
|
||||
}
|
||||
payload := caller.calls[0].args
|
||||
if payload["uuid"] != "legacy-alias-e2e" || payload["openConversationId"] != "fixture-conversation" {
|
||||
t.Fatalf("chat legacy uuid payload = %#v", payload)
|
||||
}
|
||||
if _, exists := payload["idempotency-key"]; exists {
|
||||
t.Fatalf("chat payload leaked CLI-only idempotency-key: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -417,7 +481,11 @@ func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPaylo
|
||||
if err != nil {
|
||||
t.Fatalf("alias execution failed: %v", err)
|
||||
}
|
||||
if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" {
|
||||
if alias == "open-conversation-id" {
|
||||
if ctx == nil || len(ctx.Corrections) != 0 {
|
||||
t.Fatalf("alias corrections = %#v", ctx)
|
||||
}
|
||||
} else if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" {
|
||||
t.Fatalf("alias corrections = %#v", ctx)
|
||||
}
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
@@ -627,11 +695,11 @@ func TestCrossPlatformCoverageSelectedParamAliasesProduceCanonicalEquivalentDryR
|
||||
tool: "send_personal_message",
|
||||
canonicalArgs: []string{
|
||||
"--dry-run", "chat", "message", "send",
|
||||
"--user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
|
||||
"--user", appFixtureCurrentDOpenID, "--text", "hello dry-run", "--uuid", "alias-dry-run",
|
||||
},
|
||||
aliasArgs: []string{
|
||||
"--dry-run", "chat", "message", "send",
|
||||
"--to-user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
|
||||
"--to-user", appFixtureCurrentDOpenID, "--text", "hello dry-run", "--uuid", "alias-dry-run",
|
||||
},
|
||||
wantCorrections: 1,
|
||||
wantArgKeys: []string{"clawType", "content", "msgType", "receiverOpenDingTalkId", "uuid"},
|
||||
|
||||
@@ -4,14 +4,21 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
const (
|
||||
appFixtureCurrentDOpenID = "DAAAAAAAAAAAiE"
|
||||
appFixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE"
|
||||
)
|
||||
|
||||
// paramAliasCompleteCommands is deliberately keyed by the exact reviewed
|
||||
// fixture command path. Every argv is a complete, business-valid invocation:
|
||||
// required companion flags are present, time and enum values are valid, and
|
||||
@@ -32,12 +39,12 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
|
||||
"chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
|
||||
"chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"},
|
||||
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
|
||||
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2},
|
||||
"chat +chat-members-list": {"chat", "+chat-members-list", "--conversation-id", "fixture-conversation", "--member-types", "user,bot"},
|
||||
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", "D-user-1,D-user-2", "--mute-time", "3600000", "--yes"},
|
||||
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2, "--mute-time", "3600000", "--yes"},
|
||||
"chat +chat-remove-bot": {"chat", "+chat-remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
|
||||
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", "D-user-1", "--role-ids", "role-1", "--yes"},
|
||||
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", "D-user-1", "--yes"},
|
||||
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", appFixtureCurrentDOpenID, "--role-ids", "role-1", "--yes"},
|
||||
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", appFixtureCurrentDOpenID, "--yes"},
|
||||
"chat +chat-update": {"chat", "+chat-update", "--group", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
|
||||
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
|
||||
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
|
||||
@@ -55,7 +62,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
|
||||
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
|
||||
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
|
||||
"chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"},
|
||||
"chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
|
||||
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
@@ -68,10 +75,10 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat category create-smart": {"chat", "category", "create-smart", "--name", "Fixture Smart Category", "--keywords", "fixture,priority", "--yes"},
|
||||
"chat category rename": {"chat", "category", "rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
|
||||
"chat group members": {"chat", "group", "members", "--id", "fixture-conversation"},
|
||||
"chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", "D-user-1"},
|
||||
"chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID},
|
||||
"chat group members add-bot": {"chat", "group", "members", "add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
|
||||
"chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
|
||||
"chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", "D-user-1", "--yes"},
|
||||
"chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID + "," + appFixtureCurrentDOpenID2},
|
||||
"chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", appFixtureCurrentDOpenID, "--yes"},
|
||||
"chat group members remove-bot": {"chat", "group", "members", "remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
|
||||
"chat group rename": {"chat", "group", "rename", "--id", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
|
||||
"chat group set-admin": {"chat", "group", "set-admin", "--group", "fixture-conversation", "--user", "user-1", "--yes"},
|
||||
@@ -86,9 +93,9 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat message list-by-ids": {"chat", "message", "list-by-ids", "--msg-ids", "message-1,message-2"},
|
||||
"chat message list-unread-conversations": {"chat", "message", "list-unread-conversations", "--count", "7", "--exclude-muted"},
|
||||
"chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
|
||||
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
|
||||
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
|
||||
"chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"},
|
||||
"chat message send": {"chat", "message", "send", "--user", "D-recipient", "--text", "hello fixture", "--uuid", "param-alias-equivalence", "--yes"},
|
||||
"chat message send": {"chat", "message", "send", "--user", appFixtureCurrentDOpenID, "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence", "--yes"},
|
||||
"chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"},
|
||||
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"},
|
||||
@@ -124,6 +131,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
|
||||
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
|
||||
"doc +export": {"doc", "+export", "--node", "node-1", "--export-format", "docx", "--output", "exports/fixture.docx"},
|
||||
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
|
||||
@@ -131,9 +139,43 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"doc comment delete": {"doc", "comment", "delete", "--node", "node-1", "--comment-key", "comment-1", "--yes"},
|
||||
"doc comment reply": {"doc", "comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--mentioned-open-conversation-id", "cid-1,cid-2", "--yes"},
|
||||
"doc comment update": {"doc", "comment", "update", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture update", "--yes"},
|
||||
"doc create": {"doc", "create", "--name", "Fixture Document", "--workspace", "workspace-1"},
|
||||
"doc version revert": {"doc", "version", "revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"drive +cover": {"drive", "+cover", "--node", "node-1"},
|
||||
"drive +create-folder": {"drive", "+create-folder", "--name", "Fixture Folder", "--space-id", "space-1", "--folder", "folder-1"},
|
||||
"drive +create-shortcut": {"drive", "+create-shortcut", "--node", "node-1", "--folder", "folder-1", "--workspace", "workspace-1"},
|
||||
"drive +delete": {"drive", "+delete", "--node", "node-1", "--yes"},
|
||||
"drive +download": {"drive", "+download", "--node", "node-1", "--space-id", "space-1", "--output", "downloads/fixture.bin"},
|
||||
"drive +info": {"drive", "+info", "--node", "node-1", "--space-id", "space-1"},
|
||||
"drive +inspect": {"drive", "+inspect", "--node", "node-1", "--space-id", "space-1", "--include-stats"},
|
||||
"drive +list": {"drive", "+list", "--space-id", "space-1", "--folder", "folder-1", "--limit", "7", "--cursor", "cursor-1", "--order-by", "name", "--order", "asc"},
|
||||
"drive +publish-get": {"drive", "+publish-get", "--node", "node-1"},
|
||||
"drive +publish-unset": {"drive", "+publish-unset", "--node", "node-1", "--yes"},
|
||||
"drive +recycle-list": {"drive", "+recycle-list", "--space-id", "space-1", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"drive +recycle-restore": {"drive", "+recycle-restore", "--id", "recycle-1", "--yes"},
|
||||
"drive +rename": {"drive", "+rename", "--node", "node-1", "--name", "Fixture Renamed", "--yes"},
|
||||
"drive +search": {"drive", "+search", "--query", "fixture"},
|
||||
"drive +star-add": {"drive", "+star-add", "--node", "node-1"},
|
||||
"drive +star-list": {"drive", "+star-list", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"drive +star-remove": {"drive", "+star-remove", "--node", "node-1"},
|
||||
"drive +stats": {"drive", "+stats", "--node", "node-1"},
|
||||
"drive +upload": {"drive", "+upload", "--file", "param_alias_payload_equivalence_test.go", "--file-name", "fixture.txt", "--mime-type", "text/plain", "--space-id", "space-1", "--node", "node-1", "--yes"},
|
||||
"drive +version-download": {"drive", "+version-download", "--node", "node-1", "--version", "3", "--output", "downloads/fixture-v3.bin"},
|
||||
"drive +version-get": {"drive", "+version-get", "--node", "node-1", "--version", "3"},
|
||||
"drive +version-history": {"drive", "+version-history", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"drive +version-revert": {"drive", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"drive commit": {"drive", "commit", "--file-name", "fixture.txt", "--file-size", "7", "--upload-id", "upload-1", "--space-id", "space-1"},
|
||||
"drive copy": {"drive", "copy", "--node", "node-1", "--folder", "folder-1"},
|
||||
"drive download": {"drive", "download", "--node", "node-1", "--output", "downloads/fixture.bin", "--version", "3", "--space-id", "space-1"},
|
||||
"drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"},
|
||||
"drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"},
|
||||
"drive mkdir": {"drive", "mkdir", "--name", "Fixture Folder", "--space-id", "space-1"},
|
||||
"drive permission add": {"drive", "permission", "add", "--node", "node-1", "--users", "user-1,user-2", "--role", "READER"},
|
||||
"drive recycle list": {"drive", "recycle", "list", "--space-id", "space-1", "--limit", "7"},
|
||||
"drive recycle restore": {"drive", "recycle", "restore", "--id", "recycle-1"},
|
||||
"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 message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
|
||||
@@ -156,15 +198,29 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"doc block insert": {
|
||||
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--text", "fixture paragraph", "--yes"},
|
||||
},
|
||||
"doc +inspect": {
|
||||
"include-permissions": {"doc", "+inspect", "--node", "node-1", "--include-permissions"},
|
||||
},
|
||||
"doc +search": {
|
||||
"created-from": {"doc", "+search", "--query", "fixture", "--created-from", "1"},
|
||||
"created-to": {"doc", "+search", "--query", "fixture", "--created-to", "2"},
|
||||
"creator-uids": {"doc", "+search", "--query", "fixture", "--creator-uids", "user-1,user-2"},
|
||||
},
|
||||
"drive list": {
|
||||
"workspace": {"drive", "list", "--workspace", "workspace-1", "--limit", "7"},
|
||||
"order-by": {"drive", "list", "--folder", "folder-1", "--order-by", "name", "--limit", "7"},
|
||||
"space-id": {"drive", "list", "--space-id", "space-1", "--limit", "7"},
|
||||
"order": {"drive", "list", "--folder", "folder-1", "--order", "asc", "--limit", "7"},
|
||||
},
|
||||
"chat message list": {
|
||||
"user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
},
|
||||
"chat message list-by-sender": {
|
||||
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", "D-sender", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
|
||||
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", appFixtureCurrentDOpenID, "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
|
||||
},
|
||||
"chat message send": {
|
||||
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"},
|
||||
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--uuid", "param-alias-equivalence-file", "--yes"},
|
||||
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence-group", "--yes"},
|
||||
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--idempotency-key", "param-alias-equivalence-file", "--yes"},
|
||||
},
|
||||
"chat +conversation-set-top": {
|
||||
"conversation-ids": {"chat", "+conversation-set-top", "--conversation-ids", "fixture-conversation-1,fixture-conversation-2", "--yes"},
|
||||
@@ -241,12 +297,105 @@ var paramAliasNewIMCases = []struct {
|
||||
{command: "chat +messages-set-pin", emitted: "conversation-id", canonical: "open-conversation-id"},
|
||||
}
|
||||
|
||||
// paramAliasNewDriveCases is the exact executable-alias set introduced by the
|
||||
// reviewed Drive expansion. Guard fixtures are covered separately by the
|
||||
// exhaustive runtime-contract tests; every entry here must preserve the final
|
||||
// transport payload of its canonical spelling.
|
||||
var paramAliasNewDriveCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "drive +cover", emitted: "dentry-uuid", canonical: "node"},
|
||||
{command: "drive +create-folder", emitted: "folder-name", canonical: "name"},
|
||||
{command: "drive +create-folder", emitted: "storage-space-id", canonical: "space-id"},
|
||||
{command: "drive +create-shortcut", emitted: "source-file-id", canonical: "node"},
|
||||
{command: "drive +create-shortcut", emitted: "target-folder-id", canonical: "folder"},
|
||||
{command: "drive +create-shortcut", emitted: "target-workspace-id", canonical: "workspace"},
|
||||
{command: "drive +delete", emitted: "file-id", canonical: "node"},
|
||||
{command: "drive +download", emitted: "dentry-uuid", canonical: "node"},
|
||||
{command: "drive +download", emitted: "destination-path", canonical: "output"},
|
||||
{command: "drive +inspect", emitted: "include-statistics", canonical: "include-stats"},
|
||||
{command: "drive +list", emitted: "folder-id", canonical: "folder"},
|
||||
{command: "drive +list", emitted: "page-size", canonical: "limit"},
|
||||
{command: "drive +list", emitted: "next-token", canonical: "cursor"},
|
||||
{command: "drive +list", emitted: "sort-direction", canonical: "order"},
|
||||
{command: "drive +list", emitted: "sort-by", canonical: "order-by"},
|
||||
{command: "drive +publish-get", emitted: "dentry-uuid", canonical: "node"},
|
||||
{command: "drive +publish-unset", emitted: "file-id", canonical: "node"},
|
||||
{command: "drive +recycle-list", emitted: "storage-space-id", canonical: "space-id"},
|
||||
{command: "drive +recycle-list", emitted: "page-token", canonical: "cursor"},
|
||||
{command: "drive +recycle-restore", emitted: "recycle-item-id", canonical: "id"},
|
||||
{command: "drive +rename", emitted: "file-id", canonical: "node"},
|
||||
{command: "drive +rename", emitted: "new-name", canonical: "name"},
|
||||
{command: "drive +star-add", emitted: "dentry-uuid", canonical: "node"},
|
||||
{command: "drive +star-list", emitted: "max-results", canonical: "limit"},
|
||||
{command: "drive +star-remove", emitted: "file-id", canonical: "node"},
|
||||
{command: "drive +stats", emitted: "dentry-uuid", canonical: "node"},
|
||||
{command: "drive +upload", emitted: "source-file", canonical: "file"},
|
||||
{command: "drive +upload", emitted: "name", canonical: "file-name"},
|
||||
{command: "drive +upload", emitted: "overwrite-node-id", canonical: "node"},
|
||||
{command: "drive +version-download", emitted: "version-number", canonical: "version"},
|
||||
{command: "drive +version-download", emitted: "save-path", canonical: "output"},
|
||||
{command: "drive +version-get", emitted: "version-no", canonical: "version"},
|
||||
{command: "drive +version-history", emitted: "next-cursor", canonical: "cursor"},
|
||||
{command: "drive +version-history", emitted: "page-size", canonical: "limit"},
|
||||
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
|
||||
{command: "drive +cover", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +create-shortcut", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +delete", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +download", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +inspect", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +publish-get", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +publish-unset", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +rename", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +star-add", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +star-remove", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +stats", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +upload", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +version-download", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +version-get", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +version-history", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +version-revert", emitted: "node-id", canonical: "node"},
|
||||
{command: "drive +cover", emitted: "url", canonical: "node"},
|
||||
{command: "drive +create-shortcut", emitted: "document-id", canonical: "node"},
|
||||
{command: "drive +delete", emitted: "folder-id", canonical: "node"},
|
||||
{command: "drive +inspect", emitted: "document-id", canonical: "node"},
|
||||
{command: "drive +inspect", emitted: "folder-id", canonical: "node"},
|
||||
{command: "drive +publish-get", emitted: "url", canonical: "node"},
|
||||
{command: "drive +publish-unset", emitted: "document-url", canonical: "node"},
|
||||
{command: "drive +rename", emitted: "document-id", canonical: "node"},
|
||||
{command: "drive +rename", emitted: "folder-id", canonical: "node"},
|
||||
{command: "drive +star-add", emitted: "url", canonical: "node"},
|
||||
{command: "drive +star-remove", emitted: "doc-id", canonical: "node"},
|
||||
{command: "drive +stats", emitted: "document-url", canonical: "node"},
|
||||
{command: "drive +upload", emitted: "file-id", canonical: "node"},
|
||||
}
|
||||
|
||||
// paramAliasNewDriveConfirmationCases selects one newly reviewed alias for
|
||||
// every Drive command in the expansion whose declared runtime safety requires
|
||||
// confirmation. The full matrix below proves all spellings preserve the
|
||||
// confirmed payload; this smaller matrix proves aliases cannot cross the
|
||||
// confirmation boundary before any transport call is made.
|
||||
var paramAliasNewDriveConfirmationCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "drive +delete", emitted: "file-id", canonical: "node"},
|
||||
{command: "drive +publish-unset", emitted: "document-url", canonical: "node"},
|
||||
{command: "drive +recycle-restore", emitted: "recycle-item-id", canonical: "id"},
|
||||
{command: "drive +rename", emitted: "new-name", canonical: "name"},
|
||||
{command: "drive +upload", emitted: "source-file", canonical: "file"},
|
||||
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
|
||||
}
|
||||
|
||||
// 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
|
||||
// checked through the embedded PreParse delivery path and against a complete
|
||||
// business-valid command template. The separate IM gate below continues to
|
||||
// execute every alias introduced by the current IM optimization.
|
||||
// business-valid command template. The dedicated product gates below continue
|
||||
// to execute every alias introduced by the reviewed IM and Drive expansions.
|
||||
//
|
||||
// Keeping the older 100+ aliases at the contract layer avoids rebuilding and
|
||||
// executing the complete 800+ command Root twice per spelling under -race.
|
||||
@@ -261,6 +410,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
|
||||
paramAliasPayloadCaseKey("doc +comment-create", "body"): true, // write shortcut content alias
|
||||
paramAliasPayloadCaseKey("doc +copy", "parent-folder-id"): true, // Doc folder role on a write shortcut
|
||||
paramAliasPayloadCaseKey("doc create", "space-id"): true, // published Doc workspace compatibility remains payload-equivalent
|
||||
paramAliasPayloadCaseKey("doc +create", "content-format"): true, // shortcut format alias preserves markdown/jsonml enum
|
||||
paramAliasPayloadCaseKey("doc +create-from-template", "keyword"): true, // template search alias composes with a write workflow
|
||||
paramAliasPayloadCaseKey("doc +create-from-template", "workspace-id"): true, // template target workspace identifier
|
||||
@@ -269,6 +419,8 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("doc +fetch", "start-block"): true, // section boundary role remains exact
|
||||
paramAliasPayloadCaseKey("doc +history-revert", "version-number"): true, // destructive history version alias keeps confirmation
|
||||
paramAliasPayloadCaseKey("doc +inspect", "include-versions"): true, // boolean section alias preserves value
|
||||
paramAliasPayloadCaseKey("doc +search", "create-time-start"): true, // observed lower-bound spelling preserves milliseconds
|
||||
paramAliasPayloadCaseKey("doc +search", "create-time-end"): true, // observed upper-bound spelling preserves milliseconds
|
||||
paramAliasPayloadCaseKey("doc +template-list", "next-token"): true, // Doc cursor alias on a read shortcut
|
||||
paramAliasPayloadCaseKey("doc +update", "mode"): true, // write operation selector alias
|
||||
paramAliasPayloadCaseKey("doc +update", "revision"): true, // optimistic edit revision alias
|
||||
@@ -278,6 +430,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("doc block insert", "parent-block-id"): true, // scoped block-role alias
|
||||
paramAliasPayloadCaseKey("doc comment delete", "comment-id"): true, // destructive comment-key alias
|
||||
paramAliasPayloadCaseKey("doc comment reply", "mentioned-open-conversation-ids"): true, // list-valued group mention role
|
||||
paramAliasPayloadCaseKey("drive info", "workspace"): true, // published numeric storage-space compatibility remains payload-equivalent
|
||||
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
@@ -421,14 +574,118 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
|
||||
}
|
||||
}
|
||||
|
||||
// Resource download deliberately continues from the transport call into a
|
||||
// local HTTPS download. The generic capture caller returns an empty object, so
|
||||
// this command's stable post-transport validation error is the expected test
|
||||
// boundary; canonical and alias calls must still produce the same request and
|
||||
// the same error.
|
||||
func TestCrossPlatformCoverageNewDriveParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
activeAliases := 0
|
||||
for _, test := range paramAliasNewDriveCases {
|
||||
test := test
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
|
||||
if !ok {
|
||||
t.Fatal("reviewed Drive alias has no complete-command E2E template")
|
||||
}
|
||||
canonicalArgs := append([]string(nil), complete...)
|
||||
aliasArgs, replacements := replaceLongFlag(canonicalArgs, test.canonical, test.emitted)
|
||||
if replacements != 1 {
|
||||
t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, canonicalArgs)
|
||||
}
|
||||
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
|
||||
if canonicalErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, canonicalErr) {
|
||||
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
|
||||
}
|
||||
if len(canonicalCaller.calls) == 0 {
|
||||
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
|
||||
}
|
||||
|
||||
entry, exists := cli.LookupParamAlias(test.command)
|
||||
target, active := entry.ResolveAlias(test.emitted)
|
||||
if !exists || !active {
|
||||
return
|
||||
}
|
||||
if target != test.canonical {
|
||||
t.Fatalf("active reviewed Drive alias --%s resolves to --%s, want --%s", test.emitted, target, test.canonical)
|
||||
}
|
||||
activeAliases++
|
||||
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if aliasErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, aliasErr) {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("complete alias command skipped PreParse")
|
||||
}
|
||||
if (canonicalErr == nil) != (aliasErr == nil) || (canonicalErr != nil && canonicalErr.Error() != aliasErr.Error()) {
|
||||
t.Fatalf("canonical and alias completion errors differ: canonical=%v alias=%v", canonicalErr, aliasErr)
|
||||
}
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
if activeAliases != len(paramAliasNewDriveCases) {
|
||||
t.Fatalf("new Drive aliases active in embedded table = %d, want %d", activeAliases, len(paramAliasNewDriveCases))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewDriveParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
for _, test := range paramAliasNewDriveConfirmationCases {
|
||||
test := test
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
|
||||
if !ok {
|
||||
t.Fatal("reviewed Drive confirmation alias has no complete-command E2E template")
|
||||
}
|
||||
aliasArgs, replacements := replaceLongFlag(complete, test.canonical, test.emitted)
|
||||
if replacements != 1 {
|
||||
t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, complete)
|
||||
}
|
||||
unconfirmedArgs, removals := removeExactArg(aliasArgs, "--yes")
|
||||
if removals != 1 {
|
||||
t.Fatalf("confirmation template must contain --yes exactly once; removals=%d args=%v", removals, aliasArgs)
|
||||
}
|
||||
|
||||
entry, exists := cli.LookupParamAlias(test.command)
|
||||
target, active := entry.ResolveAlias(test.emitted)
|
||||
if !exists || !active || target != test.canonical {
|
||||
t.Fatalf("reviewed Drive alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical)
|
||||
}
|
||||
|
||||
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" {
|
||||
t.Fatalf("unconfirmed alias command error = %#v, want confirmation_required\nargs=%v", err, unconfirmedArgs)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed alias command crossed the transport boundary: args=%v calls=%#v", unconfirmedArgs, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Some artifact commands deliberately continue past the final captured
|
||||
// transport call into local download/upload handling. Deterministic invalid
|
||||
// resource responses form their expected post-transport test boundary;
|
||||
// canonical and alias calls must still produce the same request and error.
|
||||
func paramAliasExpectedCaptureBoundaryError(command string, err error) bool {
|
||||
return command == "chat +messages-resource-download" && err != nil &&
|
||||
strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址")
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
switch command {
|
||||
case "chat +messages-resource-download":
|
||||
return strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址")
|
||||
case "drive +download", "drive +version-download":
|
||||
return strings.Contains(err.Error(), "下载地址必须是受信任域名上的 HTTPS URL")
|
||||
case "drive +upload":
|
||||
return strings.Contains(err.Error(), "incomplete drive upload credentials")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// +chat-messages supplies the current wall-clock time when callers omit
|
||||
@@ -483,3 +740,16 @@ func replaceLongFlag(args []string, canonical, emitted string) ([]string, int) {
|
||||
}
|
||||
return out, replacements
|
||||
}
|
||||
|
||||
func removeExactArg(args []string, target string) ([]string, int) {
|
||||
out := make([]string, 0, len(args))
|
||||
removals := 0
|
||||
for _, arg := range args {
|
||||
if arg == target {
|
||||
removals++
|
||||
continue
|
||||
}
|
||||
out = append(out, arg)
|
||||
}
|
||||
return out, removals
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
)
|
||||
|
||||
@@ -218,8 +219,16 @@ func TestPatScopeError_Error(t *testing.T) {
|
||||
// /cli/oauth/device/poll?flowId=<fid> with the given status sequence.
|
||||
// It also writes the server URL into a temp DWS_CONFIG_DIR/mcp_url so that
|
||||
// GetMCPBaseURL() returns the test server address.
|
||||
func stubPATPollAccessToken(t *testing.T) {
|
||||
t.Helper()
|
||||
testseam.Swap(t, &patResolveAccessToken, func(context.Context, string, string) (string, error) {
|
||||
return "", authpkg.ErrTokenDataNotFound
|
||||
})
|
||||
}
|
||||
|
||||
func setupPollServer(t *testing.T, statuses []authpkg.DevicePollResponse) (*httptest.Server, string) {
|
||||
t.Helper()
|
||||
stubPATPollAccessToken(t)
|
||||
var callCount atomic.Int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -381,6 +390,7 @@ func TestPollPatDeviceFlow_ServerErrorFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPollPatDeviceFlow_RedirectSkipped(t *testing.T) {
|
||||
stubPATPollAccessToken(t)
|
||||
// When server returns 302 (SSO redirect), poll should continue until real response.
|
||||
var callCount int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -540,6 +550,7 @@ func (m *mockRunner) Run(ctx context.Context, inv executor.Invocation) (executor
|
||||
// It responds to device poll requests with the given status after the first poll.
|
||||
func setupHandlePATServer(t *testing.T, terminalStatus string, authCode string) (*httptest.Server, string) {
|
||||
t.Helper()
|
||||
stubPATPollAccessToken(t)
|
||||
var pollCount atomic.Int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -66,6 +66,32 @@ func TestPreparseProfileFlagUsesNormalizedProfileArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePreparseProfileFlagUsesLastOccurrence(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
valid bool
|
||||
}{
|
||||
{name: "space then equals", args: []string{"--profile", "corp-a", "version", "--profile=corp-b"}, want: "corp-b", valid: true},
|
||||
{name: "equals then space", args: []string{"--profile=corp-a", "version", "--profile", "corp-b"}, want: "corp-b", valid: true},
|
||||
{name: "last multi", args: []string{"--profile=corp-a", "--profile", "corp-b,", "corp-c", "version"}, want: "corp-b,corp-c", valid: true},
|
||||
{name: "empty equals clears earlier", args: []string{"--profile=corp-a", "version", "--profile="}},
|
||||
{name: "missing value clears earlier", args: []string{"--profile=corp-a", "version", "--profile"}},
|
||||
{name: "next flag is not profile value", args: []string{"--profile=corp-a", "--profile", "--debug", "version"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := preparseProfileFlag(tc.args); got != tc.want {
|
||||
t.Fatalf("preparseProfileFlag(%#v) = %q, want %q", tc.args, got, tc.want)
|
||||
}
|
||||
_, specified, valid := preparseProfileSelection(tc.args)
|
||||
if !specified || valid != tc.valid {
|
||||
t.Fatalf("preparseProfileSelection(%#v) = specified %v valid %v, want true/%v", tc.args, specified, valid, tc.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProcessProfileArgsRestoresOriginalArgv(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
+132
-24
@@ -80,10 +80,19 @@ var (
|
||||
rootAuthLoadTokenData = authpkg.LoadTokenData
|
||||
rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags
|
||||
rootEmitResult = output.EmitResult
|
||||
rootInstallProcessSignalContext = installProcessSignalContext
|
||||
)
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
func Execute() (exitCode int) {
|
||||
func Execute() int {
|
||||
exitCode, _, _ := ExecuteWithTelemetry()
|
||||
return exitCode
|
||||
}
|
||||
|
||||
// ExecuteWithTelemetry runs the root command and additionally returns a
|
||||
// privacy-safe command path and error summary for the official CLI entrypoint.
|
||||
func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage string) {
|
||||
commandPath = "dws"
|
||||
var (
|
||||
root *cobra.Command
|
||||
executed *cobra.Command
|
||||
@@ -91,12 +100,16 @@ func Execute() (exitCode int) {
|
||||
)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errorMessage = "internal panic"
|
||||
target := executed
|
||||
if target == nil && root != nil {
|
||||
if found, _, err := root.Find(os.Args[1:]); err == nil {
|
||||
target = found
|
||||
}
|
||||
}
|
||||
if target != nil {
|
||||
commandPath = telemetryCommandPath(target)
|
||||
}
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
exitCode = code
|
||||
if target != nil {
|
||||
@@ -121,6 +134,7 @@ func Execute() (exitCode int) {
|
||||
CloseFileLogger()
|
||||
if executed != nil {
|
||||
if err := closeOutputSink(executed); err != nil {
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
if code, handled, emitErr := emitOutputPublicationFailure(executed, err); handled && emitErr == nil {
|
||||
exitCode = code
|
||||
} else {
|
||||
@@ -144,7 +158,9 @@ func Execute() (exitCode int) {
|
||||
agentMetadata := readAgentMetadataSnapshot()
|
||||
if err := agentMetadata.validationError(); err != nil {
|
||||
emitEarlyAgentMetadataValidationError(err, os.Args[1:])
|
||||
return apperrors.ExitCode(err)
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
return
|
||||
}
|
||||
|
||||
timing := NewTimingCollector()
|
||||
@@ -162,12 +178,13 @@ func Execute() (exitCode int) {
|
||||
ctx, resultStore = output.WithResultStore(ctx)
|
||||
var signalState *processSignalState
|
||||
var stopSignals func()
|
||||
ctx, signalState, stopSignals = installProcessSignalContext(ctx, resultStore)
|
||||
ctx, signalState, stopSignals = rootInstallProcessSignalContext(ctx, resultStore)
|
||||
defer stopSignals()
|
||||
|
||||
initStart := time.Now()
|
||||
engine := newPipelineEngine()
|
||||
root = rootNewRootCommandWithEngine(ctx, engine)
|
||||
commandPath = telemetryCommandPath(root)
|
||||
timing.Record("cmd_init", time.Since(initStart))
|
||||
|
||||
// Run PreParse handlers on raw argv before Cobra parses flags.
|
||||
@@ -182,15 +199,23 @@ func Execute() (exitCode int) {
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||
code, emitErr := output.EmitResult(target, result)
|
||||
if emitErr == nil {
|
||||
return code
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
|
||||
return apperrors.ExitCode(err)
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
return
|
||||
}
|
||||
commandPath = telemetryCommandPathForArgs(root, os.Args[1:])
|
||||
|
||||
var err error
|
||||
executed, err = rootExecuteCommand(root)
|
||||
if executed != nil {
|
||||
commandPath = telemetryCommandPath(executed)
|
||||
}
|
||||
// PersistentPostRunE normally commits or aborts the transactional output
|
||||
// sink. Finalize once more at the process boundary so custom execution
|
||||
// seams, embedding callers, or future hook changes cannot leave publication
|
||||
@@ -222,7 +247,9 @@ func Execute() (exitCode int) {
|
||||
// successfully emitted result into a contradictory 130/143 process
|
||||
// status; likewise, a failed publication must retain its internal
|
||||
// error code instead of being relabelled as cancellation.
|
||||
return code
|
||||
errorMessage = telemetryErrorSummary(interrupted)
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
}
|
||||
var publicationErr *outputPublicationError
|
||||
@@ -233,20 +260,26 @@ func Execute() (exitCode int) {
|
||||
if err != nil {
|
||||
if executed == nil {
|
||||
executed = root
|
||||
commandPath = telemetryCommandPath(root)
|
||||
}
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
var publicationErr *outputPublicationError
|
||||
if stderrors.As(err, &publicationErr) {
|
||||
errorMessage = telemetryErrorSummary(publicationErr)
|
||||
if failureCode, handled, emitErr := emitOutputPublicationFailure(executed, publicationErr); handled {
|
||||
if emitErr == nil {
|
||||
return failureCode
|
||||
exitCode = failureCode
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: emit output publication failure: %v\n", emitErr)
|
||||
}
|
||||
return apperrors.ExitCode(publicationErr)
|
||||
exitCode = apperrors.ExitCode(publicationErr)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: command hook failed after result emission: %v\n", err)
|
||||
return code
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
err = rewordRequiredFlagError(err)
|
||||
var raw apperrors.RawStderrError
|
||||
@@ -254,7 +287,9 @@ func Execute() (exitCode int) {
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||
code, emitErr := output.EmitResult(executed, result)
|
||||
if emitErr == nil {
|
||||
return code
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
err = apperrors.NewInternal("emit failure result: "+emitErr.Error(), apperrors.WithCause(emitErr))
|
||||
}
|
||||
@@ -264,12 +299,42 @@ func Execute() (exitCode int) {
|
||||
_, _ = fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
|
||||
return apperrors.ExitCode(err)
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
return
|
||||
}
|
||||
if code, emitted := output.StoredExitCode(resultStore); emitted {
|
||||
return code
|
||||
exitCode = code
|
||||
return
|
||||
}
|
||||
return 0
|
||||
return
|
||||
}
|
||||
|
||||
func telemetryCommandPath(command *cobra.Command) string {
|
||||
if command == nil {
|
||||
return "dws"
|
||||
}
|
||||
path := strings.TrimSpace(command.CommandPath())
|
||||
root := command.Root()
|
||||
rootName := strings.TrimSpace(root.Name())
|
||||
if path == rootName {
|
||||
return rootName
|
||||
}
|
||||
if rootName != "" {
|
||||
path = strings.TrimSpace(strings.TrimPrefix(path, rootName+" "))
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func telemetryCommandPathForArgs(root *cobra.Command, args []string) string {
|
||||
if root == nil {
|
||||
return "dws"
|
||||
}
|
||||
command, _, err := root.Find(args)
|
||||
if err != nil || command == nil {
|
||||
return telemetryCommandPath(root)
|
||||
}
|
||||
return telemetryCommandPath(command)
|
||||
}
|
||||
|
||||
// emitEarlyAgentMetadataValidationError preserves each built-in command's
|
||||
@@ -511,6 +576,22 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
|
||||
// 无论哪种格式,子串 "--help' for usage." 都可被检索到。
|
||||
tail := fmt.Sprintf("\nSee '%s --help' for usage.", cmd.CommandPath())
|
||||
msgWithTail := errMsg + tail
|
||||
if flag, ok := unknownFlagName(errMsg); ok && flag == "from" {
|
||||
switch cmd.CommandPath() {
|
||||
case "dws chat +search-msg", "dws chat +chat-messages":
|
||||
return apperrors.NewValidation(
|
||||
msgWithTail,
|
||||
apperrors.WithHint("--from 在消息查询中含义不明确:按发送者过滤请使用 --sender <姓名|userId|openDingTalkId>;指定时间起点请使用 --start <RFC3339>"),
|
||||
apperrors.WithReason("ambiguous_flag"),
|
||||
apperrors.WithCause(err),
|
||||
apperrors.WithActions(
|
||||
"Use --sender <姓名|userId|openDingTalkId> to filter by sender",
|
||||
"Use --start <RFC3339> together with --end <RFC3339> to set a time range",
|
||||
),
|
||||
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
|
||||
)
|
||||
}
|
||||
}
|
||||
if flag, protection, ok := reviewedFlagProtection(cmd, errMsg); ok {
|
||||
hint := fmt.Sprintf("Parameter --%s is blocked from automatic normalization on %q; choose an explicit flag from --help.", flag, cmd.CommandPath())
|
||||
reason := "blocked_flag"
|
||||
@@ -579,15 +660,10 @@ func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline
|
||||
if cmd == nil {
|
||||
return "", "", false
|
||||
}
|
||||
const prefix = "unknown flag: --"
|
||||
idx := strings.Index(errMsg, prefix)
|
||||
if idx < 0 {
|
||||
flag, ok := unknownFlagName(errMsg)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
flag := strings.TrimSpace(errMsg[idx+len(prefix):])
|
||||
if i := strings.IndexAny(flag, " =\n\t"); i >= 0 {
|
||||
flag = flag[:i]
|
||||
}
|
||||
entry, ok := cli.LookupParamAlias(cmd.CommandPath())
|
||||
if !ok {
|
||||
return "", "", false
|
||||
@@ -602,6 +678,19 @@ func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func unknownFlagName(errMsg string) (string, bool) {
|
||||
const prefix = "unknown flag: --"
|
||||
idx := strings.Index(errMsg, prefix)
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
flag := strings.TrimSpace(errMsg[idx+len(prefix):])
|
||||
if i := strings.IndexAny(flag, " =\n\t"); i >= 0 {
|
||||
flag = flag[:i]
|
||||
}
|
||||
return flag, flag != ""
|
||||
}
|
||||
|
||||
func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err error) error {
|
||||
var raw apperrors.RawStderrError
|
||||
if stderrors.As(err, &raw) {
|
||||
@@ -935,17 +1024,36 @@ func installReviewedFlagProtectionHandlers(root *cobra.Command) {
|
||||
}
|
||||
|
||||
func preparseProfileFlag(args []string) string {
|
||||
profile, _, valid := preparseProfileSelection(args)
|
||||
if !valid {
|
||||
return ""
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
func preparseProfileSelection(args []string) (profile string, specified, valid bool) {
|
||||
args, _ = normalizeProfileFlagArgs(args)
|
||||
valid = true
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := strings.TrimSpace(args[i])
|
||||
switch {
|
||||
case arg == "--profile" && i+1 < len(args):
|
||||
return strings.TrimSpace(args[i+1])
|
||||
case arg == "--profile":
|
||||
specified = true
|
||||
if i+1 >= len(args) || strings.HasPrefix(strings.TrimSpace(args[i+1]), "-") {
|
||||
profile = ""
|
||||
valid = false
|
||||
continue
|
||||
}
|
||||
profile = strings.TrimSpace(args[i+1])
|
||||
valid = profile != ""
|
||||
i++
|
||||
case strings.HasPrefix(arg, "--profile="):
|
||||
return strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
|
||||
specified = true
|
||||
profile = strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
|
||||
valid = profile != ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return profile, specified, valid
|
||||
}
|
||||
|
||||
func normalizeProcessProfileArgs() func() {
|
||||
|
||||
@@ -37,29 +37,64 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
rootStopAllStdioClients = func() {}
|
||||
var executedLeaf *cobra.Command
|
||||
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
sheet := &cobra.Command{Use: "sheet"}
|
||||
executedLeaf = &cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}}
|
||||
sheet.AddCommand(executedLeaf)
|
||||
root.AddCommand(sheet)
|
||||
return root
|
||||
}
|
||||
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("successful Execute code = %d", code)
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return executedLeaf, nil }
|
||||
if code, commandPath, errorMessage := ExecuteWithTelemetry(); code != 0 || commandPath != "sheet read" || errorMessage != "" {
|
||||
t.Fatalf("successful ExecuteWithTelemetry = code %d path %q error %q", code, commandPath, errorMessage)
|
||||
}
|
||||
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return errors.New("alias/canonical conflict") }
|
||||
if code := Execute(); code == 0 {
|
||||
t.Fatal("pre-parse conflict returned zero")
|
||||
if code, _, errorMessage := ExecuteWithTelemetry(); code == 0 || errorMessage != "alias/canonical conflict" {
|
||||
t.Fatalf("pre-parse conflict = code %d error %q", code, errorMessage)
|
||||
}
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
|
||||
wantErr := errors.New("unknown command missing")
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
|
||||
if code := Execute(); code == 0 {
|
||||
t.Fatal("failed Execute returned zero")
|
||||
if code, _, errorMessage := ExecuteWithTelemetry(); code == 0 || errorMessage != "unknown command" {
|
||||
t.Fatalf("failed ExecuteWithTelemetry = code %d error %q", code, errorMessage)
|
||||
}
|
||||
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { panic("boom") }
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("panic Execute code = %d", code)
|
||||
os.Args = []string{"dws", "sheet", "read"}
|
||||
if code, commandPath, errorMessage := ExecuteWithTelemetry(); code != 5 || commandPath != "sheet read" || errorMessage != "internal panic" {
|
||||
t.Fatalf("panic ExecuteWithTelemetry = code %d path %q error %q", code, commandPath, errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryCommandPath(t *testing.T) {
|
||||
if got := telemetryCommandPath(nil); got != "dws" {
|
||||
t.Fatalf("nil command path = %q, want dws", got)
|
||||
}
|
||||
if got := telemetryCommandPathForArgs(nil, nil); got != "dws" {
|
||||
t.Fatalf("nil root command path = %q, want dws", got)
|
||||
}
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
sheet := &cobra.Command{Use: "sheet"}
|
||||
read := &cobra.Command{Use: "read <range>"}
|
||||
sheet.AddCommand(read)
|
||||
root.AddCommand(sheet)
|
||||
if got := telemetryCommandPath(root); got != "dws" {
|
||||
t.Fatalf("root command path = %q, want dws", got)
|
||||
}
|
||||
if got := telemetryCommandPath(read); got != "sheet read" {
|
||||
t.Fatalf("leaf command path = %q, want sheet read", got)
|
||||
}
|
||||
root.PersistentFlags().String("profile", "", "")
|
||||
read.Aliases = []string{"get"}
|
||||
if got := telemetryCommandPathForArgs(root, []string{"--profile", "corp-a", "sheet", "get", "A1:B2"}); got != "sheet read" {
|
||||
t.Fatalf("pre-execution command path = %q, want sheet read", got)
|
||||
}
|
||||
if got := telemetryCommandPathForArgs(root, []string{"missing"}); got != "dws" {
|
||||
t.Fatalf("unknown pre-execution command path = %q, want dws", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
@@ -13,6 +14,12 @@ import (
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// feedbackFormURL points at the DingTalk Notable form collecting dws CLI
|
||||
// user-experience feedback. The source parameter tags submissions that
|
||||
// originated from the CLI help output so they can be told apart from
|
||||
// responses arriving through other channels.
|
||||
const feedbackFormURL = "https://alidocs.dingtalk.com/notable/share/form/v01eLbnj1bw1ELb0laN_dv19yqvsgs3oebp3pcjys_1qX0QQ0?source=dws-cli"
|
||||
|
||||
func configureRootHelp(root *cobra.Command) {
|
||||
if root == nil {
|
||||
return
|
||||
@@ -101,6 +108,29 @@ func renderRootHelp(root *cobra.Command) {
|
||||
_, _ = fmt.Fprintln(w)
|
||||
_, _ = fmt.Fprintln(w, tui.Dim(long))
|
||||
}
|
||||
|
||||
// Keep the feedback entry last: everything above it is operational guidance
|
||||
// an agent acts on, while the survey is addressed to human readers who
|
||||
// scroll to the end.
|
||||
_, _ = fmt.Fprintln(w)
|
||||
renderRootFeedback(w)
|
||||
}
|
||||
|
||||
// renderRootFeedback prints the user-experience survey entry. The URL occupies
|
||||
// its own line and is never wrapped or padded through a tabwriter: it is longer
|
||||
// than the help rule width, and breaking it would stop terminals from
|
||||
// recognizing it as a clickable hyperlink. Soft wrapping performed by the
|
||||
// terminal itself keeps the link intact.
|
||||
//
|
||||
// The label is intentionally not routed through i18n. Everything surrounding it
|
||||
// in this listing — service descriptions, utility descriptions, global flag
|
||||
// usage — is hardcoded Chinese, so translating this one line would render it in
|
||||
// English on any host whose LANG is not zh_*, leaving a single English line
|
||||
// inside an otherwise Chinese screen.
|
||||
func renderRootFeedback(w io.Writer) {
|
||||
_, _ = fmt.Fprintln(w, tui.Section("Feedback:"))
|
||||
_, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.Dim("使用体验反馈问卷(1 分钟)"))
|
||||
_, _ = fmt.Fprintf(w, " %s\n", tui.Cyan(feedbackFormURL))
|
||||
}
|
||||
|
||||
func renderRootGlobalFlags(root *cobra.Command) {
|
||||
|
||||
@@ -22,6 +22,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -51,6 +53,49 @@ func TestRootHelpHidesCompatibilityOnlyCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelpShowsFeedbackEntry(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("root help: %v\n%s", err, out.String())
|
||||
}
|
||||
help := out.String()
|
||||
// The label stays Chinese regardless of the host locale: the rest of this
|
||||
// listing is hardcoded Chinese, so a translated label would show up as a
|
||||
// lone English line on any host whose LANG is not zh_*.
|
||||
for _, want := range []string{"Feedback:", "使用体验反馈问卷", feedbackFormURL} {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("root help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
// The form URL is longer than the help rule width; it must stay on a
|
||||
// single unbroken line so terminals keep recognizing it as a hyperlink.
|
||||
if !strings.Contains(help, "\n "+feedbackFormURL+"\n") {
|
||||
t.Fatalf("feedback URL must occupy one unwrapped line:\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
// The feedback entry is deliberately root-only: this CLI is driven mostly by
|
||||
// AI agents, and repeating a survey link in every subcommand help would be
|
||||
// pure context noise. Guard the boundary so a future refactor cannot move the
|
||||
// rendering into the shared subcommand help path unnoticed.
|
||||
func TestSubcommandHelpOmitsFeedbackEntry(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"chat", "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat help: %v\n%s", err, out.String())
|
||||
}
|
||||
if help := out.String(); strings.Contains(help, feedbackFormURL) {
|
||||
t.Fatalf("subcommand help must not carry the feedback URL:\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
@@ -92,8 +137,8 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
|
||||
}{
|
||||
{args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
} {
|
||||
command := NewRootCommand()
|
||||
command.SilenceErrors = true
|
||||
@@ -369,6 +414,20 @@ func TestChatFileUploadDownlinedButMessageFileSendStays(t *testing.T) {
|
||||
t.Fatalf("chat message send missing --%s", flag)
|
||||
}
|
||||
}
|
||||
idempotencyKey := send.Flags().Lookup("idempotency-key")
|
||||
if idempotencyKey == nil {
|
||||
t.Fatal("chat message send missing --idempotency-key")
|
||||
}
|
||||
legacyUUID := send.Flags().Lookup("uuid")
|
||||
if legacyUUID == nil || !legacyUUID.Hidden {
|
||||
t.Fatalf("chat message send --uuid hidden = %#v, want hidden compatibility flag", legacyUUID)
|
||||
}
|
||||
if got := legacyUUID.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "idempotency-key" {
|
||||
t.Fatalf("chat message send --uuid alias_of = %#v, want idempotency-key", got)
|
||||
}
|
||||
if got := legacyUUID.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
|
||||
t.Fatalf("chat message send --uuid alias_origin = %#v, want %s", got, runtimeannotate.FlagAliasOriginCorecmdV1)
|
||||
}
|
||||
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"chat", "file", "upload",
|
||||
@@ -403,6 +462,53 @@ func TestCalendarEventListDryRunPreviewsOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventShareInfoDryRunPreviewsOnly(t *testing.T) {
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"--dry-run", "calendar", "event", "share-info",
|
||||
"--id", "EVT_001",
|
||||
"--language", "zh-CN",
|
||||
"--calendar-id", "primary",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("calendar event share-info --dry-run error = %v\n%s", err, got)
|
||||
}
|
||||
for _, want := range []string{"get_event_share_info", "eventId", "EVT_001", "zh-CN", "primary"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("calendar event share-info dry-run output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventShareInfoRequiresEventID(t *testing.T) {
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"--dry-run", "calendar", "event", "share-info",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("calendar event share-info without --id: expected error, got nil\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "\"executed\": true") {
|
||||
t.Fatalf("share-info without --id must not execute:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventShareInfoOmitsOptionalArgs(t *testing.T) {
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"--dry-run", "calendar", "event", "share-info",
|
||||
"--id", "EVT_001",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("calendar event share-info --dry-run with only --id error = %v\n%s", err, got)
|
||||
}
|
||||
if !strings.Contains(got, "\"eventId\"") {
|
||||
t.Fatalf("calendar event share-info dry-run output missing eventId:\n%s", got)
|
||||
}
|
||||
for _, unwanted := range []string{"\"calendarId\"", "\"language\""} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("calendar event share-info dry-run with only --id should not contain %q:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
|
||||
@@ -473,6 +579,80 @@ func TestInjectStaticServersMergesStaticAndSupplementServers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStaticDingTalkEndpointsFollowConfiguredMCPBaseURL(t *testing.T) {
|
||||
previous := edition.Get()
|
||||
defer edition.Override(previous)
|
||||
defer SetDynamicServers(nil)
|
||||
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://pre-mcp.dingtalk.io\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "test",
|
||||
StaticServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{{
|
||||
ID: "contact",
|
||||
Name: "Contact",
|
||||
Endpoint: "https://mcp-gw.dingtalk.com/server/contact?key=abc",
|
||||
Prefixes: []string{"user"},
|
||||
}}
|
||||
},
|
||||
})
|
||||
|
||||
injectStaticServers()
|
||||
|
||||
for _, productID := range []string{"contact", "user"} {
|
||||
got, ok := directRuntimeEndpoint(productID, "")
|
||||
want := "https://pre-mcp-gw.dingtalk.io/server/contact?key=abc"
|
||||
if !ok || got != want {
|
||||
t.Fatalf("directRuntimeEndpoint(%q) = %q, %v; want %q, true", productID, got, ok, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDingTalkEndpointsFollowSelectedTokenRegion(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
mcpURLPath := filepath.Join(configDir, "mcp_url")
|
||||
|
||||
if err := os.WriteFile(mcpURLPath, []byte("https://pre-mcp.dingtalk.io\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
endpoint := "https://pre-mcp-gw.dingtalk.io/server/contact?key=abc"
|
||||
if got, want := activeDingTalkGatewayEndpointForLoginRegion(endpoint, authpkg.LoginRegionDefault), "https://pre-mcp-gw.dingtalk.com/server/contact?key=abc"; got != want {
|
||||
t.Fatalf("domestic profile endpoint = %q, want %q", got, want)
|
||||
}
|
||||
if got := activeDingTalkGatewayEndpointForLoginRegion(endpoint, authpkg.LoginRegionInternational); got != endpoint {
|
||||
t.Fatalf("international profile endpoint = %q, want %q", got, endpoint)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(mcpURLPath, []byte("https://mcp.dingtalk.com\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
if got, want := activeDingTalkGatewayEndpointForLoginRegion("https://mcp-gw.dingtalk.com/server/contact", authpkg.LoginRegionInternational), "https://mcp-gw.dingtalk.io/server/contact"; got != want {
|
||||
t.Fatalf("international profile endpoint from domestic config = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDingTalkEndpointUsesLoginScopedMCPOverride(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
restore := authpkg.PushMCPBaseURLOverride("https://pre-mcp.dingtalk.io")
|
||||
defer restore()
|
||||
|
||||
got := activeDingTalkGatewayEndpointForLoginRegion(
|
||||
"https://mcp-gw.dingtalk.com/server/contact",
|
||||
authpkg.LoginRegionDefault,
|
||||
)
|
||||
want := "https://pre-mcp-gw.dingtalk.io/server/contact"
|
||||
if got != want {
|
||||
t.Fatalf("login-scoped endpoint = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func mustFindCommand(t *testing.T, root *cobra.Command, path ...string) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := root
|
||||
|
||||
@@ -181,7 +181,7 @@ func TestPublicRootDirectExecuteClosesSinkOnHandlerError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
|
||||
@@ -230,13 +230,20 @@ func TestOutputSinkUnifiedPublicationFailureFailsAndLeavesNoFinalFile(t *testing
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "result.json")
|
||||
t.Chdir(dir)
|
||||
// Keep argv portable: an absolute Windows path contains a volume colon,
|
||||
// which the CLI intentionally rejects as unsafe user-supplied output.
|
||||
target := "result.json"
|
||||
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originalInfo, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Args = []string{"dws", "atomic-output-unified-publication", "--output", target, "--format", "json"}
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
@@ -277,7 +284,7 @@ func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing
|
||||
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 0 {
|
||||
t.Fatalf("rolled-back success leaked to stdout: %s", stdout.String())
|
||||
}
|
||||
assertOutputFile(t, target, "original", 0o640)
|
||||
assertOutputFile(t, target, "original", originalInfo.Mode().Perm())
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
|
||||
+29
-12
@@ -162,7 +162,7 @@ var (
|
||||
runnerResolveMultiProfileSelections = resolveMultiProfileSelections
|
||||
runnerResolveProfile = authpkg.ResolveProfile
|
||||
runnerGetCachedRuntimeToken = getCachedRuntimeToken
|
||||
runnerResolveAuthToken = (*runtimeRunner).resolveAuthToken
|
||||
runnerResolveAuthSnapshot = (*runtimeRunner).resolveAuthSnapshot
|
||||
runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload
|
||||
runnerCallTool = (*transport.Client).CallTool
|
||||
runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized
|
||||
@@ -552,11 +552,16 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
if hasPluginAuth {
|
||||
authToken = pluginAuth.Token
|
||||
} else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) {
|
||||
var tokenErr error
|
||||
authToken, tokenErr = runnerResolveAuthToken(r, ctx)
|
||||
snapshot, tokenErr := runnerResolveAuthSnapshot(r, ctx)
|
||||
if tokenErr != nil {
|
||||
return executor.Result{}, tokenResolutionError(tokenErr)
|
||||
}
|
||||
authToken = snapshot.AccessToken
|
||||
if !hasDirectRuntimeEndpointOverride(invocation.CanonicalProduct) &&
|
||||
isDingTalkMCPGatewayEndpoint(endpoint) &&
|
||||
(snapshot.LoginRegionKnown || authpkg.MCPBaseURLOverride() != "") {
|
||||
endpoint = activeDingTalkGatewayEndpointForLoginRegion(endpoint, snapshot.LoginRegion)
|
||||
}
|
||||
}
|
||||
|
||||
var timeoutSec int
|
||||
@@ -877,21 +882,33 @@ func (r *runtimeRunner) executeStdioInvocationAtEndpoint(
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) resolveAuthToken(ctx context.Context) (string, error) {
|
||||
explicitToken := ""
|
||||
if r != nil && r.globalFlags != nil {
|
||||
explicitToken = r.globalFlags.Token
|
||||
}
|
||||
return resolveRuntimeAuthToken(ctx, explicitToken)
|
||||
}
|
||||
|
||||
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) {
|
||||
snapshot, err := runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken)
|
||||
snapshot, err := r.resolveAuthSnapshot(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return snapshot.AccessToken, nil
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) resolveAuthSnapshot(ctx context.Context) (AccessTokenSnapshot, error) {
|
||||
explicitToken := ""
|
||||
if r != nil && r.globalFlags != nil {
|
||||
explicitToken = r.globalFlags.Token
|
||||
}
|
||||
return resolveRuntimeAuthSnapshot(ctx, explicitToken)
|
||||
}
|
||||
|
||||
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) {
|
||||
snapshot, err := resolveRuntimeAuthSnapshot(ctx, explicitToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return snapshot.AccessToken, nil
|
||||
}
|
||||
|
||||
func resolveRuntimeAuthSnapshot(ctx context.Context, explicitToken string) (AccessTokenSnapshot, error) {
|
||||
return runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken)
|
||||
}
|
||||
|
||||
// getCachedRuntimeToken is kept as the prefetch seam used by runner tests. The
|
||||
// cache itself lives exclusively in TokenManager.
|
||||
func getCachedRuntimeToken(ctx context.Context) (string, error) {
|
||||
|
||||
@@ -127,12 +127,14 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
oldEdition := edition.Get()
|
||||
oldPreflight := runnerPreflightDocDownload
|
||||
oldCall := runnerCallTool
|
||||
oldHandle := runnerHandlePatAuthCheck
|
||||
oldRetry := runnerRetryWithPatAuthRetry
|
||||
oldCapture := runnerCaptureRuntimeFailure
|
||||
oldResolveSnapshot := runnerResolveAuthSnapshot
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
runnerPreflightDocDownload = oldPreflight
|
||||
@@ -140,6 +142,7 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
|
||||
runnerHandlePatAuthCheck = oldHandle
|
||||
runnerRetryWithPatAuthRetry = oldRetry
|
||||
runnerCaptureRuntimeFailure = oldCapture
|
||||
runnerResolveAuthSnapshot = oldResolveSnapshot
|
||||
})
|
||||
|
||||
pluginAuthMu.Lock()
|
||||
@@ -168,6 +171,27 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
|
||||
if got, err := r.executeInvocation(context.Background(), "https://example.test", inv); err != nil || !got.Invocation.Implemented {
|
||||
t.Fatalf("default auth execution = %#v, %v", got, err)
|
||||
}
|
||||
runnerResolveAuthSnapshot = func(*runtimeRunner, context.Context) (AccessTokenSnapshot, error) {
|
||||
return AccessTokenSnapshot{
|
||||
AccessToken: "international-token",
|
||||
LoginRegion: authpkg.LoginRegionInternational,
|
||||
LoginRegionKnown: true,
|
||||
}, nil
|
||||
}
|
||||
successfulCall := runnerCallTool
|
||||
routedEndpoint := ""
|
||||
runnerCallTool = func(_ *transport.Client, _ context.Context, endpoint, _ string, _ map[string]any) (transport.ToolCallResult, error) {
|
||||
routedEndpoint = endpoint
|
||||
return transport.ToolCallResult{Content: map[string]any{"value": 1}}, nil
|
||||
}
|
||||
if _, err := r.executeInvocation(context.Background(), "https://mcp-gw.dingtalk.com/server/contact", inv); err != nil {
|
||||
t.Fatalf("international auth execution = %v", err)
|
||||
}
|
||||
if routedEndpoint != "https://mcp-gw.dingtalk.io/server/contact" {
|
||||
t.Fatalf("international routed endpoint = %q", routedEndpoint)
|
||||
}
|
||||
runnerResolveAuthSnapshot = oldResolveSnapshot
|
||||
runnerCallTool = successfulCall
|
||||
|
||||
wantErr := errors.New("preflight")
|
||||
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
|
||||
@@ -362,6 +386,12 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
|
||||
if got, err := resolveRuntimeAuthToken(context.Background(), " runtime "); err != nil || got != "runtime" {
|
||||
t.Fatalf("runtime explicit token = %q, %v", got, err)
|
||||
}
|
||||
edition.Override(&edition.Hooks{TokenProvider: func(context.Context, func() (string, error)) (string, error) {
|
||||
return "", errors.New("snapshot failed")
|
||||
}})
|
||||
if _, err := r.resolveAuthToken(context.Background()); err == nil || !strings.Contains(err.Error(), "snapshot failed") {
|
||||
t.Fatalf("resolveAuthToken error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(envDWSChannel, "channel")
|
||||
edition.Override(&edition.Hooks{
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageCalendarAgendaFinalSchemaPreservesCompositeProperties(t *testing.T) {
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
tool := snapshot.Tools["calendar.shortcut_agenda"]
|
||||
if tool == nil {
|
||||
t.Fatal("calendar.shortcut_agenda is missing from final Schema")
|
||||
}
|
||||
parameters := schemaContractMap(tool["parameters"])
|
||||
for flag, want := range map[string]string{
|
||||
"start": "start",
|
||||
"end": "end",
|
||||
} {
|
||||
parameter := parameters[flag]
|
||||
if parameter == nil {
|
||||
t.Fatalf("calendar.shortcut_agenda --%s is missing from final Schema", flag)
|
||||
}
|
||||
if got := schemaContractString(parameter["property"]); got != want {
|
||||
t.Errorf("calendar.shortcut_agenda --%s property=%q, want %q", flag, got, want)
|
||||
}
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("calendar.shortcut_agenda interface_mode=%q, want composite", got)
|
||||
}
|
||||
result := schemaContractMap(tool["result"])
|
||||
dataSchema := schemaContractMap(result["data_schema"])
|
||||
properties := schemaContractMap(dataSchema["properties"])
|
||||
for _, field := range []string{"hasMore", "nextCursor"} {
|
||||
if _, exists := properties[field]; exists {
|
||||
t.Fatalf("calendar.shortcut_agenda Result data_schema leaked pagination field %q", field)
|
||||
}
|
||||
}
|
||||
if properties["complete"] == nil {
|
||||
t.Fatal("calendar.shortcut_agenda Result data_schema is missing complete")
|
||||
}
|
||||
pagination, ok := tool["pagination"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("calendar.shortcut_agenda pagination=%T, want object", tool["pagination"])
|
||||
}
|
||||
if got := schemaContractString(pagination["meta_path"]); got != "meta.pagination" {
|
||||
t.Fatalf("calendar.shortcut_agenda pagination meta_path=%q, want meta.pagination", got)
|
||||
}
|
||||
}
|
||||
@@ -400,6 +400,7 @@ func schemaContractPayloadForBoundCanonicals(t *testing.T, root *cobra.Command,
|
||||
func TestChatSchemaSeparatesSendAndReply(t *testing.T) {
|
||||
snapshot := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(),
|
||||
"chat.send_personal_message",
|
||||
"chat.send_robot_message",
|
||||
"chat.reply_personal_message",
|
||||
)
|
||||
|
||||
@@ -418,6 +419,19 @@ func TestChatSchemaSeparatesSendAndReply(t *testing.T) {
|
||||
if _, exists := snapshot.Tools["chat.upload_conversation_file"]; exists {
|
||||
t.Fatal("downlined chat file upload must not be advertised in Schema")
|
||||
}
|
||||
|
||||
botReply := snapshot.Tools["chat.send_robot_message"]
|
||||
botParams := schemaContractMap(botReply["parameters"])
|
||||
if got := schemaContractString(botParams["reply"]["property"]); got != "referenceOpenMessageId" {
|
||||
t.Fatalf("bot --reply property = %q", got)
|
||||
}
|
||||
if got := schemaContractString(botParams["ref-sender"]["property"]); got != "srcMsgSendOpenDingTalkId" {
|
||||
t.Fatalf("bot --ref-sender property = %q", got)
|
||||
}
|
||||
if got, ok := botParams["title"]["required"].(bool); !ok || got {
|
||||
t.Fatalf("bot --title required = %#v, want false for conditional Markdown input", botParams["title"]["required"])
|
||||
}
|
||||
assertSchemaContractConstraintGroup(t, botReply, "require_together", []string{"reply", "ref-sender"})
|
||||
}
|
||||
|
||||
func TestCalendarAttendeeDeleteSchemaMatchesRuntimeGate(t *testing.T) {
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 399
|
||||
publicShortcutCount = 434
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including the hidden historical minutes.shortcut_minutes_search contract.
|
||||
schemaPublishedShortcutCount = 401
|
||||
schemaPublishedShortcutCount = 437
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 399
|
||||
publiclyDeliveredShortcutCount = 434
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -140,6 +140,78 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
assertChatCatalogCompleteLeafContracts(t)
|
||||
}
|
||||
|
||||
func TestDeliveryWikiSpaceSearchDeclaresCompatibilityAdapter(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "wiki +space-search")
|
||||
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("wiki +space-search interface_mode = %q, want composite", got)
|
||||
}
|
||||
reason := schemaContractString(leaf["interface_reason"])
|
||||
for _, fragment := range []string{"query/limit", "search_wikiSpaces.keyword/pageSize", "versioned Schema migration"} {
|
||||
if !strings.Contains(reason, fragment) {
|
||||
t.Fatalf("wiki +space-search interface_reason = %q, want fragment %q", reason, fragment)
|
||||
}
|
||||
}
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
for name, want := range map[string]string{"query": "query", "limit": "limit"} {
|
||||
parameter := parameters[name]
|
||||
if parameter == nil {
|
||||
t.Fatalf("wiki +space-search missing --%s parameter: %#v", name, parameters)
|
||||
}
|
||||
if got := schemaContractString(parameter["property"]); got != want {
|
||||
t.Fatalf("wiki +space-search --%s property = %q, want compatibility value %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllShortcutsWikiSchemaExamplesIncludeRequiredParameters(t *testing.T) {
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
checked := 0
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service != "wiki" || declared.UserDefined || !shortcut.InPublicCatalog(declared.Service, declared.Command) {
|
||||
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 {
|
||||
argv, err := cli.ParseAgentExampleArgv(example)
|
||||
if err != nil {
|
||||
t.Fatalf("%s example %q is not valid argv: %v", canonical, example, err)
|
||||
}
|
||||
for _, flag := range declared.Flags {
|
||||
if !flag.Required {
|
||||
continue
|
||||
}
|
||||
names := append([]string{flag.Name}, flag.Aliases...)
|
||||
if !schemaExampleHasLongFlag(argv, names...) {
|
||||
t.Errorf("%s example %q is missing required --%s", canonical, example, flag.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked != 20 {
|
||||
t.Fatalf("checked Wiki shortcut examples = %d, want 20", checked)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaExampleHasLongFlag(argv []string, names ...string) bool {
|
||||
for _, argument := range argv {
|
||||
for _, name := range names {
|
||||
if argument == "--"+name || strings.HasPrefix(argument, "--"+name+"=") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertSchemaSummarySafety(
|
||||
t testing.TB,
|
||||
summaries map[string]map[string]any,
|
||||
@@ -207,7 +279,11 @@ func assertChatCatalogCompleteLeafContracts(t testing.TB) {
|
||||
})
|
||||
|
||||
auditJoin := executeShortcutSchemaQuery(t, "--cli-path", "chat group audit-join-validation")
|
||||
assertSchemaLeafParameterRequired(t, auditJoin, "chat group audit-join-validation", "conversation-id", true)
|
||||
assertSchemaLeafParameterEnum(t, auditJoin, "chat group audit-join-validation", "status", []string{"AuditApprove", "AuditDelete"})
|
||||
if parameters := schemaContractMap(auditJoin["parameters"]); parameters["group"] != nil {
|
||||
t.Fatalf("chat group audit-join-validation publishes hidden --group alias: %#v", parameters["group"])
|
||||
}
|
||||
}
|
||||
|
||||
func assertSchemaLeafParameterRequired(t testing.TB, leaf map[string]any, cliPath, name string, want bool) {
|
||||
|
||||
@@ -103,7 +103,7 @@ func installSignalExecuteSeams(t *testing.T, unified bool, stdout, stderr io.Wri
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
signal syscall.Signal
|
||||
@@ -197,7 +197,7 @@ func TestSignalAfterFailedEmissionAttemptPreservesPublicationExitCode(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
@@ -229,7 +229,7 @@ func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
const maxTelemetryErrorRunes = 200
|
||||
|
||||
var (
|
||||
telemetryUnknownFlagPattern = regexp.MustCompile(`(?i)unknown flag:\s*(--[a-z0-9][a-z0-9-]*)`)
|
||||
telemetryAuthPattern = regexp.MustCompile(`(?i)\b(?:bearer|basic)\s+[a-z0-9._~+/=-]+`)
|
||||
telemetrySensitiveFlag = regexp.MustCompile(`(?i)(--(?:access-token|refresh-token|token|client-secret|client-id|password|api-key|authorization|cookie|credential|secret))(?:=|\s+)\S+`)
|
||||
telemetrySensitiveValue = regexp.MustCompile(`(?i)\b(authorization|client[-_]?secret|client[-_]?id|access[-_]?token|refresh[-_]?token|api[-_]?key|password|cookie|credential|secret|token)\b\s*[:=]\s*[^\s,;]+`)
|
||||
telemetryURLPattern = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s]+`)
|
||||
telemetryJSONPattern = regexp.MustCompile(`(?s)[\[{].*[\]}]`)
|
||||
telemetryUnixPathPattern = regexp.MustCompile(`(^|[\s=:])(?:~/|/)[^\s]+`)
|
||||
telemetryWindowsPathPattern = regexp.MustCompile(`(?i)(^|[\s=])[a-z]:[\\/][^\s]+`)
|
||||
telemetryRelativePathPattern = regexp.MustCompile(`(^|[\s=:])\.\.?/[^\s]+`)
|
||||
telemetryEmailPattern = regexp.MustCompile(`(?i)\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b`)
|
||||
telemetryPhonePattern = regexp.MustCompile(`\b\+?\d[\d -]{7,}\d\b`)
|
||||
telemetryOpaqueTokenPattern = regexp.MustCompile(`\b[a-zA-Z0-9_-]{16,}\b`)
|
||||
)
|
||||
|
||||
func telemetryErrorSummary(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var patError *apperrors.PATError
|
||||
if stderrors.As(err, &patError) {
|
||||
return "permission error"
|
||||
}
|
||||
var rawError apperrors.RawStderrError
|
||||
if stderrors.As(err, &rawError) {
|
||||
return "raw stderr error"
|
||||
}
|
||||
if isUnknownCommandError(err) {
|
||||
return "unknown command"
|
||||
}
|
||||
if match := telemetryUnknownFlagPattern.FindStringSubmatch(err.Error()); len(match) == 2 {
|
||||
return "unknown flag: " + match[1]
|
||||
}
|
||||
return sanitizeTelemetryErrorText(err.Error())
|
||||
}
|
||||
|
||||
func telemetryPanicMessages(value any) (display, summary string) {
|
||||
return fmt.Sprintf("internal panic: %v", value), "internal panic"
|
||||
}
|
||||
|
||||
func sanitizeTelemetryErrorText(message string) string {
|
||||
message = output.SanitizeForTerminal(message)
|
||||
message = telemetryAuthPattern.ReplaceAllString(message, "<credential>")
|
||||
message = telemetrySensitiveFlag.ReplaceAllString(message, "$1=<redacted>")
|
||||
message = telemetrySensitiveValue.ReplaceAllString(message, "$1=<redacted>")
|
||||
message = telemetryURLPattern.ReplaceAllString(message, "<url>")
|
||||
message = telemetryJSONPattern.ReplaceAllString(message, "<payload>")
|
||||
message = redactTelemetryQuotedText(message)
|
||||
message = telemetryUnixPathPattern.ReplaceAllString(message, "$1<path>")
|
||||
message = telemetryWindowsPathPattern.ReplaceAllString(message, "$1<path>")
|
||||
message = telemetryRelativePathPattern.ReplaceAllString(message, "$1<path>")
|
||||
message = telemetryEmailPattern.ReplaceAllString(message, "<email>")
|
||||
message = telemetryPhonePattern.ReplaceAllString(message, "<phone>")
|
||||
message = telemetryOpaqueTokenPattern.ReplaceAllStringFunc(message, func(value string) string {
|
||||
var hasLetter, hasDigit bool
|
||||
for _, r := range value {
|
||||
hasLetter = hasLetter || unicode.IsLetter(r)
|
||||
hasDigit = hasDigit || unicode.IsDigit(r)
|
||||
}
|
||||
if hasLetter && hasDigit {
|
||||
return "<id>"
|
||||
}
|
||||
return value
|
||||
})
|
||||
message = strings.Join(strings.Fields(message), " ")
|
||||
return truncateTelemetryText(message, maxTelemetryErrorRunes)
|
||||
}
|
||||
|
||||
func redactTelemetryQuotedText(message string) string {
|
||||
var result strings.Builder
|
||||
runes := []rune(message)
|
||||
for index := 0; index < len(runes); {
|
||||
quote := runes[index]
|
||||
if quote != '\'' && quote != '"' && quote != '`' {
|
||||
result.WriteRune(quote)
|
||||
index++
|
||||
continue
|
||||
}
|
||||
result.WriteRune(quote)
|
||||
result.WriteString("<redacted>")
|
||||
index++
|
||||
escaped := false
|
||||
for index < len(runes) {
|
||||
current := runes[index]
|
||||
index++
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if current == '\\' && quote != '`' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if current == quote {
|
||||
result.WriteRune(quote)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func truncateTelemetryText(message string, maxRunes int) string {
|
||||
if maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(message)
|
||||
if len(runes) <= maxRunes {
|
||||
return message
|
||||
}
|
||||
if maxRunes <= 3 {
|
||||
return string(runes[:maxRunes])
|
||||
}
|
||||
return string(runes[:maxRunes-3]) + "..."
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
type telemetryRawError string
|
||||
|
||||
func (e telemetryRawError) Error() string { return string(e) }
|
||||
func (e telemetryRawError) RawStderr() string { return string(e) }
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryErrorSummaryFixedFamilies(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{name: "nil", want: ""},
|
||||
{name: "PAT", err: &apperrors.PATError{RawJSON: `{"token":"secret"}`}, want: "permission error"},
|
||||
{name: "raw stderr", err: telemetryRawError("raw secret"), want: "raw stderr error"},
|
||||
{name: "unknown command", err: errors.New(`unknown command "secret-value" for "dws"`), want: "unknown command"},
|
||||
{name: "unknown flag", err: errors.New("unknown flag: --token=secret-value"), want: "unknown flag: --token"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := telemetryErrorSummary(tc.err); got != tc.want {
|
||||
t.Fatalf("telemetryErrorSummary() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSanitizeTelemetryErrorText(t *testing.T) {
|
||||
message := "\x1b[31mfailed\x1b[0m " +
|
||||
"--client-secret very-secret " +
|
||||
"--access-token access-secret " +
|
||||
"Authorization: Bearer abcdefghijklmnop1234 " +
|
||||
"url=https://example.test/path?token=secret " +
|
||||
`body={"access_token":"secret"} ` +
|
||||
`user="Alice" email=alice@example.test phone=13800138000 ` +
|
||||
"path=/Users/alice/private.txt relative=./private/secrets.txt id=abcDEF1234567890XYZ"
|
||||
got := sanitizeTelemetryErrorText(message)
|
||||
for _, secret := range []string{
|
||||
"very-secret", "access-secret", "abcdefghijklmnop1234", "example.test", "access_token",
|
||||
"Alice", "alice@example.test", "13800138000", "/Users/alice", "abcDEF1234567890XYZ", "\x1b",
|
||||
"./private/secrets.txt",
|
||||
} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Fatalf("sanitized telemetry error leaked %q: %q", secret, got)
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{"failed", "<redacted>", "<url>", "<payload>", "<path>", "<id>"} {
|
||||
if !strings.Contains(got, marker) {
|
||||
t.Fatalf("sanitized telemetry error missing %q: %q", marker, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryErrorTruncationAndPanic(t *testing.T) {
|
||||
message := strings.Repeat("错", maxTelemetryErrorRunes+1)
|
||||
got := sanitizeTelemetryErrorText(message)
|
||||
if len([]rune(got)) != maxTelemetryErrorRunes || !strings.HasSuffix(got, "...") {
|
||||
t.Fatalf("truncated telemetry error rune length = %d suffix = %q", len([]rune(got)), got[len(got)-3:])
|
||||
}
|
||||
display, summary := telemetryPanicMessages("token-secret")
|
||||
if display != "internal panic: token-secret" || summary != "internal panic" || strings.Contains(summary, "token-secret") {
|
||||
t.Fatalf("panic messages = display %q summary %q", display, summary)
|
||||
}
|
||||
if got := truncateTelemetryText("value", 0); got != "" {
|
||||
t.Fatalf("zero-limit truncation = %q", got)
|
||||
}
|
||||
if got := truncateTelemetryText("value", 3); got != "val" {
|
||||
t.Fatalf("short-limit truncation = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryErrorEscapesAndOpaqueWords(t *testing.T) {
|
||||
const opaqueWord = "abcdefghijklmnop"
|
||||
got := sanitizeTelemetryErrorText(`failed "quoted \"value" ` + opaqueWord)
|
||||
if strings.Contains(got, "value") || !strings.Contains(got, opaqueWord) {
|
||||
t.Fatalf("escaped quote sanitization = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
)
|
||||
|
||||
// TelemetryIdentity is the privacy-reviewed subset of the local authentication
|
||||
// record that may be attached to a CLI execution event.
|
||||
type TelemetryIdentity struct {
|
||||
UserID string
|
||||
UserName string
|
||||
CorpID string
|
||||
}
|
||||
|
||||
var telemetryResolveProfileMetadata = authpkg.ResolveProfileMetadataReadOnly
|
||||
|
||||
// ResolveTelemetryIdentity returns a pre-execution snapshot of the identity
|
||||
// selected by args. Multi-profile executions are attributed to the current
|
||||
// default profile. Resolution is deliberately best-effort: telemetry must not
|
||||
// refresh credentials or change command behavior when local auth data is
|
||||
// missing, invalid, or unreadable.
|
||||
func ResolveTelemetryIdentity(args []string) (identity TelemetryIdentity) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
identity = TelemetryIdentity{}
|
||||
}
|
||||
}()
|
||||
|
||||
selector, specified, valid := preparseProfileSelection(args)
|
||||
if specified && !valid {
|
||||
return TelemetryIdentity{}
|
||||
}
|
||||
profile, err := resolveTelemetryProfileMetadata(defaultConfigDir(), selector)
|
||||
if err != nil || profile == nil {
|
||||
return TelemetryIdentity{}
|
||||
}
|
||||
return TelemetryIdentity{
|
||||
UserID: strings.TrimSpace(profile.UserID),
|
||||
UserName: strings.TrimSpace(profile.UserName),
|
||||
CorpID: strings.TrimSpace(profile.CorpID),
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTelemetryProfileMetadata(configDir, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" || !strings.Contains(selector, ",") {
|
||||
return telemetryResolveProfileMetadata(configDir, selector)
|
||||
}
|
||||
|
||||
// A local profile name may itself contain a comma. Match the runtime
|
||||
// resolver by trying the full selector before interpreting it as CSV.
|
||||
if profile, err := telemetryResolveProfileMetadata(configDir, selector); err == nil && profile != nil {
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
for _, part := range strings.Split(selector, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
return nil, fmt.Errorf("--profile contains an empty profile selector: %q", selector)
|
||||
}
|
||||
profile, err := telemetryResolveProfileMetadata(configDir, part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if profile == nil {
|
||||
return nil, fmt.Errorf("profile %q not found", part)
|
||||
}
|
||||
}
|
||||
return telemetryResolveProfileMetadata(configDir, "")
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityProfileSelection(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
|
||||
profiles := map[string]*authpkg.ProfileMetadata{
|
||||
"": {UserID: " default-user ", UserName: " Default User ", CorpID: " default-corp "},
|
||||
"corp-a": {UserID: "user-a", UserName: "Alice", CorpID: "corp-a"},
|
||||
"corp-b": {UserID: "user-b", UserName: "Bob", CorpID: "corp-b"},
|
||||
"alpha,beta": {UserID: "comma-user", UserName: "Comma User", CorpID: "comma-corp"},
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
profiles map[string]*authpkg.ProfileMetadata
|
||||
wantCalls []string
|
||||
want TelemetryIdentity
|
||||
}{
|
||||
{name: "default profile", args: []string{"version"}, profiles: profiles, wantCalls: []string{""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
|
||||
{name: "single profile", args: []string{"--profile", "corp-a", "version"}, profiles: profiles, wantCalls: []string{"corp-a"}, want: TelemetryIdentity{UserID: "user-a", UserName: "Alice", CorpID: "corp-a"}},
|
||||
{name: "equals form after command", args: []string{"version", "--profile=corp-b"}, profiles: profiles, wantCalls: []string{"corp-b"}, want: TelemetryIdentity{UserID: "user-b", UserName: "Bob", CorpID: "corp-b"}},
|
||||
{name: "last repeated profile", args: []string{"--profile", "corp-a", "version", "--profile=corp-b"}, profiles: profiles, wantCalls: []string{"corp-b"}, want: TelemetryIdentity{UserID: "user-b", UserName: "Bob", CorpID: "corp-b"}},
|
||||
{name: "comma profile name", args: []string{"--profile", "alpha,beta", "version"}, profiles: profiles, wantCalls: []string{"alpha,beta"}, want: TelemetryIdentity{UserID: "comma-user", UserName: "Comma User", CorpID: "comma-corp"}},
|
||||
{name: "multi profile uses default", args: []string{"--profile", "corp-a,corp-b", "version"}, profiles: profiles, wantCalls: []string{"corp-a,corp-b", "corp-a", "corp-b", ""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
|
||||
{name: "unquoted multi profile", args: []string{"--profile", "corp-a,", "corp-b", "version"}, profiles: profiles, wantCalls: []string{"corp-a,corp-b", "corp-a", "corp-b", ""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var calls []string
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(configDir, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
if configDir != "/telemetry-config" {
|
||||
t.Fatalf("resolver config dir = %q", configDir)
|
||||
}
|
||||
calls = append(calls, selector)
|
||||
profile := tc.profiles[selector]
|
||||
if profile == nil {
|
||||
return nil, errors.New("profile not found")
|
||||
}
|
||||
clone := *profile
|
||||
return &clone, nil
|
||||
})
|
||||
|
||||
if got := ResolveTelemetryIdentity(tc.args); got != tc.want {
|
||||
t.Fatalf("ResolveTelemetryIdentity() = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
if !reflect.DeepEqual(calls, tc.wantCalls) {
|
||||
t.Fatalf("metadata selectors = %#v, want %#v", calls, tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityRejectsMissingProfileValue(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"version", "--profile"},
|
||||
{"--profile=corp-a", "version", "--profile="},
|
||||
{"--profile", "--debug", "version"},
|
||||
} {
|
||||
t.Run(strings.Join(args, "_"), func(t *testing.T) {
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(string, string) (*authpkg.ProfileMetadata, error) {
|
||||
t.Fatal("invalid profile syntax attempted metadata resolution")
|
||||
return nil, nil
|
||||
})
|
||||
if got := ResolveTelemetryIdentity(args); got != (TelemetryIdentity{}) {
|
||||
t.Fatalf("invalid profile identity = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityFailsClosed(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
|
||||
fail := errors.New("metadata unavailable")
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
resolve func(string, string) (*authpkg.ProfileMetadata, error)
|
||||
}{
|
||||
{name: "read error", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return nil, fail }},
|
||||
{name: "missing profile", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return nil, nil }},
|
||||
{name: "empty fields", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return &authpkg.ProfileMetadata{}, nil }},
|
||||
{name: "resolver panic", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { panic("metadata failure") }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, tc.resolve)
|
||||
if got := ResolveTelemetryIdentity(nil); got != (TelemetryIdentity{}) {
|
||||
t.Fatalf("failed-closed identity = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityRejectsInvalidMultiProfile(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(_ string, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
if selector == "corp-a" {
|
||||
return &authpkg.ProfileMetadata{UserID: "user-a", CorpID: "corp-a"}, nil
|
||||
}
|
||||
return nil, errors.New("profile not found")
|
||||
})
|
||||
if got := ResolveTelemetryIdentity([]string{"--profile", "corp-a,missing", "version"}); got != (TelemetryIdentity{}) {
|
||||
t.Fatalf("invalid multi-profile identity = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryProfileMetadataRejectsMalformedMulti(t *testing.T) {
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(_ string, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
switch selector {
|
||||
case "corp-a,,corp-b":
|
||||
return nil, errors.New("not a literal profile")
|
||||
case "corp-a":
|
||||
return &authpkg.ProfileMetadata{UserID: "user-a"}, nil
|
||||
case "missing":
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, errors.New("unexpected selector")
|
||||
}
|
||||
})
|
||||
if _, err := resolveTelemetryProfileMetadata("/config", "corp-a,,corp-b"); err == nil || !strings.Contains(err.Error(), "empty profile selector") {
|
||||
t.Fatalf("empty multi-profile selector error = %v", err)
|
||||
}
|
||||
if _, err := resolveTelemetryProfileMetadata("/config", "corp-a,missing"); err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("missing multi-profile selector error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,10 @@ func TestMain(m *testing.M) {
|
||||
// remove a TempDir while the audit lock is still open.
|
||||
setupAuditSink()
|
||||
openBrowserFunc = func(string) error { return nil }
|
||||
// App tests may run in filtered CI processes. Install the same lazy Schema
|
||||
// source factory as NewRootCommand without constructing a root so ResolveMeta
|
||||
// tests never depend on an earlier test having initialized process state.
|
||||
registerSchemaRuntimeDelivery()
|
||||
code := m.Run()
|
||||
StopAllStdioClients()
|
||||
CloseAuditSink()
|
||||
|
||||
@@ -424,6 +424,87 @@ func TestBuildAuthURLIncludesTargetCorpID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBuildAuthURLForInternationalRegion(t *testing.T) {
|
||||
authURL := buildAuthURLForRegion("client-id", "http://127.0.0.1:1234/callback", "", LoginRegionInternational)
|
||||
if !strings.HasPrefix(authURL, InternationalAuthorizeURL+"?") {
|
||||
t.Fatalf("auth URL = %s, want international authorize host", authURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNotEnabledHTMLUsesRegionAwareAuthorizeURL(t *testing.T) {
|
||||
if !strings.Contains(notEnabledHTML, "status.authorizeUrl") {
|
||||
t.Fatal("not-enabled page must read the authorize URL from the regional login status")
|
||||
}
|
||||
if strings.Contains(notEnabledHTML, `"https://login.dingtalk.com/oauth2/auth?client_id="`) {
|
||||
t.Fatal("not-enabled page must not hard-code the domestic authorize URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLoginRegionEndpointDefaults(t *testing.T) {
|
||||
if got := AuthorizeURLForLoginRegion(LoginRegionDefault); got != AuthorizeURL {
|
||||
t.Fatalf("default authorize URL = %q, want %q", got, AuthorizeURL)
|
||||
}
|
||||
if got := DeviceBaseURLForLoginRegion(LoginRegionDefault); got != DefaultDeviceBaseURL {
|
||||
t.Fatalf("default device base URL = %q, want %q", got, DefaultDeviceBaseURL)
|
||||
}
|
||||
if got := UserAccessTokenURLForLoginRegion(LoginRegionInternational); got != InternationalUserAccessTokenURL {
|
||||
t.Fatalf("international user access token URL = %q, want %q", got, InternationalUserAccessTokenURL)
|
||||
}
|
||||
if got := MCPBaseURLForLoginRegion(LoginRegionInternational); got != InternationalMCPBaseURL {
|
||||
t.Fatalf("international MCP base URL = %q, want %q", got, InternationalMCPBaseURL)
|
||||
}
|
||||
if got := DeviceBaseURLForLoginRegion(LoginRegionInternational); got != InternationalDeviceBaseURL {
|
||||
t.Fatalf("international device base URL = %q, want %q", got, InternationalDeviceBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAuthProviderLoginRegionHelpers(t *testing.T) {
|
||||
var nilProvider *OAuthProvider
|
||||
if got := nilProvider.loginRegion(); got != LoginRegionDefault {
|
||||
t.Fatalf("nil provider login region = %q", got)
|
||||
}
|
||||
nilProvider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionInternational)})
|
||||
nilProvider.applyLoginRegionToToken(&TokenData{})
|
||||
|
||||
provider := &OAuthProvider{}
|
||||
provider.useTokenLoginRegion(nil)
|
||||
provider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionInternational)})
|
||||
if provider.LoginRegion != LoginRegionInternational {
|
||||
t.Fatalf("provider login region = %q, want international", provider.LoginRegion)
|
||||
}
|
||||
provider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionDefault)})
|
||||
provider.applyLoginRegionToToken(nil)
|
||||
token := &TokenData{}
|
||||
provider.applyLoginRegionToToken(token)
|
||||
if token.LoginRegion != string(LoginRegionInternational) {
|
||||
t.Fatalf("token login region = %q, want international", token.LoginRegion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMCPBaseURLOverrideAffectsInternationalRegion(t *testing.T) {
|
||||
restore := PushMCPBaseURLOverride("https://pre-mcp.dingtalk.io/")
|
||||
defer restore()
|
||||
|
||||
if got := MCPBaseURLForLoginRegion(LoginRegionInternational); got != "https://pre-mcp.dingtalk.io" {
|
||||
t.Fatalf("international MCP base URL = %q, want override", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLoginBaseURLOverrideAffectsInternationalRegion(t *testing.T) {
|
||||
restore := PushLoginBaseURLOverride("https://pre-login.dingtalk.io/")
|
||||
defer restore()
|
||||
|
||||
if got := DeviceBaseURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io" {
|
||||
t.Fatalf("international device base URL = %q, want override", got)
|
||||
}
|
||||
if got := AuthorizeURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io/oauth2/auth" {
|
||||
t.Fatalf("international authorize URL = %q, want override", got)
|
||||
}
|
||||
if got := UserAccessTokenURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io/v1.0/oauth2/userAccessToken" {
|
||||
t.Fatalf("international user access token URL = %q, want override", got)
|
||||
}
|
||||
}
|
||||
|
||||
func buildTokenDataFromResponse(resp tokenResponse) *TokenData {
|
||||
if resp.AccessToken == "" {
|
||||
return nil
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -206,6 +208,105 @@ func TestCheckCLIAuthEnabled_Enabled(t *testing.T) {
|
||||
t.Logf("✅ Normal enabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCheckCLIAuthEnabledTraceHeadersAndDebugLog(t *testing.T) {
|
||||
t.Setenv("DINGTALK_TRACE_ID", "trace-for-cli-auth-test")
|
||||
applyCLIAuthTraceHeaders(nil, "trace-for-cli-auth-test")
|
||||
applyCLIAuthTraceHeaders(httptest.NewRequest(http.MethodGet, "https://example.com", nil), "")
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
previousLogger := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
defer slog.SetDefault(previousLogger)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("EagleEye-TraceId"); got != "trace-for-cli-auth-test" {
|
||||
t.Errorf("EagleEye-TraceId = %q, want trace-for-cli-auth-test", got)
|
||||
}
|
||||
if got := r.Header.Get("X-Dingtalk-Trace-Id"); got != "trace-for-cli-auth-test" {
|
||||
t.Errorf("X-Dingtalk-Trace-Id = %q, want trace-for-cli-auth-test", got)
|
||||
}
|
||||
w.Header().Set("EagleEye-TraceId", "server-trace-001")
|
||||
w.Header().Set("EagleEye-RpcId", "rpc-001")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: true},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
|
||||
|
||||
status, err := p.CheckCLIAuthEnabled(context.Background(), "sensitive-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status.Result == nil || !status.Result.CLIAuthEnabled {
|
||||
t.Fatal("expected CLIAuthEnabled=true")
|
||||
}
|
||||
|
||||
logs := logBuf.String()
|
||||
for _, want := range []string{
|
||||
"auth.cli_auth_enabled.request",
|
||||
"auth.cli_auth_enabled.response",
|
||||
"trace-for-cli-auth-test",
|
||||
"server-trace-001",
|
||||
"rpc-001",
|
||||
} {
|
||||
if !strings.Contains(logs, want) {
|
||||
t.Fatalf("debug logs missing %q, got: %s", want, logs)
|
||||
}
|
||||
}
|
||||
if strings.Contains(logs, "sensitive-token") {
|
||||
t.Fatalf("debug logs leaked access token: %s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageInternationalLoginRegionRequestWrappers(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case ClientIDPath:
|
||||
_ = json.NewEncoder(w).Encode(ClientIDResponse{Success: true, Result: "international-client"})
|
||||
case SuperAdminPath:
|
||||
_ = json.NewEncoder(w).Encode(SuperAdminResponse{Success: true, Result: []SuperAdmin{{StaffID: "admin"}}})
|
||||
case SendCliAuthApplyPath:
|
||||
_ = json.NewEncoder(w).Encode(SendApplyResponse{Success: true, Result: true})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldClient := oauthHTTPClient
|
||||
oauthHTTPClient = server.Client()
|
||||
restoreBaseURL := PushMCPBaseURLOverride(server.URL)
|
||||
t.Cleanup(func() {
|
||||
restoreBaseURL()
|
||||
oauthHTTPClient = oldClient
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
for name, fetch := range map[string]func() (string, error){
|
||||
"device": func() (string, error) { return deviceFetchClientIDForLoginRegion(ctx, LoginRegionInternational) },
|
||||
"oauth": func() (string, error) { return oauthFetchClientIDForLoginRegion(ctx, LoginRegionInternational) },
|
||||
} {
|
||||
if got, err := fetch(); err != nil || got != "international-client" {
|
||||
t.Fatalf("%s client ID = %q, %v", name, got, err)
|
||||
}
|
||||
}
|
||||
if got, err := deviceGetAdminsForLoginRegion(ctx, "token", LoginRegionInternational); err != nil || !got.Success {
|
||||
t.Fatalf("device admins = %#v, %v", got, err)
|
||||
}
|
||||
if got, err := oauthGetAdminsForLoginRegion(ctx, "token", LoginRegionInternational); err != nil || !got.Success {
|
||||
t.Fatalf("OAuth admins = %#v, %v", got, err)
|
||||
}
|
||||
if got, err := oauthSendApplyForLoginRegion(ctx, "token", "admin", LoginRegionInternational); err != nil || !got.Success {
|
||||
t.Fatalf("OAuth apply = %#v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCLIAuthEnabled_Disabled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -1211,7 +1212,13 @@ func TestCrossPlatformCoveragePortableAuthBundleCoverageEdges(t *testing.T) {
|
||||
if err := os.Symlink(filepath.Join(keyDir, "dek"), filepath.Join(keyDir, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, value := range map[string]string{"app.json": "{}", profilesJSONFile: "{}", "mcp_url": "https://mcp.test", "terminal_url": "https://terminal.test"} {
|
||||
for name, value := range map[string]string{
|
||||
"app.json": "{}",
|
||||
profilesJSONFile: "{}",
|
||||
"mcp_url": "https://mcp.test",
|
||||
config.ManagedMCPURLRegionFileName: "https://mcp.test",
|
||||
"terminal_url": "https://terminal.test",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(configDir, name), []byte(value), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1234,6 +1241,9 @@ func TestCrossPlatformCoveragePortableAuthBundleCoverageEdges(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(importDir, "app.json")); err != nil {
|
||||
t.Fatal("config file was not imported")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(importDir, config.ManagedMCPURLRegionFileName)); err != nil {
|
||||
t.Fatal("managed MCP region marker was not imported")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc")); err != nil {
|
||||
t.Fatal("encrypted token was not imported")
|
||||
}
|
||||
|
||||
@@ -74,6 +74,20 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
func deviceFetchClientIDForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
|
||||
if region.IsInternational() {
|
||||
return FetchClientIDFromMCPForLoginRegion(ctx, region)
|
||||
}
|
||||
return deviceFetchClientID(ctx)
|
||||
}
|
||||
|
||||
func deviceGetAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
|
||||
if region.IsInternational() {
|
||||
return GetSuperAdminsForLoginRegion(ctx, accessToken, region)
|
||||
}
|
||||
return deviceGetAdmins(ctx, accessToken)
|
||||
}
|
||||
|
||||
type DeviceFlowProvider struct {
|
||||
configDir string
|
||||
clientID string
|
||||
@@ -85,6 +99,7 @@ type DeviceFlowProvider struct {
|
||||
httpClient *http.Client
|
||||
NoBrowser bool
|
||||
IdentityEnricher func(context.Context, *TokenData) error
|
||||
LoginRegion LoginRegion
|
||||
}
|
||||
|
||||
func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider {
|
||||
@@ -104,6 +119,12 @@ func (p *DeviceFlowProvider) SetBaseURL(baseURL string) {
|
||||
p.baseURL = strings.TrimRight(baseURL, "/")
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) SetLoginRegion(region LoginRegion) {
|
||||
p.LoginRegion = region
|
||||
p.baseURL = DeviceBaseURLForLoginRegion(region)
|
||||
p.terminalBaseURL = MCPBaseURLForLoginRegion(region)
|
||||
}
|
||||
|
||||
// SetTerminalBaseURL sets the terminal API base URL for device flow polling.
|
||||
func (p *DeviceFlowProvider) SetTerminalBaseURL(baseURL string) {
|
||||
p.terminalBaseURL = strings.TrimRight(baseURL, "/")
|
||||
@@ -213,7 +234,7 @@ func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := deviceFetchClientID(ctx)
|
||||
mcpClientID, mcpErr := deviceFetchClientIDForLoginRegion(ctx, p.LoginRegion)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
@@ -272,6 +293,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
clientID: p.clientID,
|
||||
logger: p.logger,
|
||||
IdentityEnricher: p.IdentityEnricher,
|
||||
LoginRegion: p.LoginRegion,
|
||||
}
|
||||
tokenData, err := deviceExchangeCode(oauthProvider, ctx, tokenResult.AuthCode)
|
||||
if err != nil {
|
||||
@@ -331,7 +353,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
|
||||
admins, adminErr := deviceGetAdmins(ctx, tokenData.AccessToken)
|
||||
admins, adminErr := deviceGetAdminsForLoginRegion(ctx, tokenData.AccessToken, p.LoginRegion)
|
||||
if adminErr == nil && admins.Success && len(admins.Result) > 0 {
|
||||
maxAdmins := 3
|
||||
if len(admins.Result) < maxAdmins {
|
||||
|
||||
@@ -90,6 +90,17 @@ func TestRequestDeviceCodeSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDeviceFlowSetLoginRegionUsesInternationalBaseURL(t *testing.T) {
|
||||
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
|
||||
provider.SetLoginRegion(LoginRegionInternational)
|
||||
if provider.baseURL != InternationalDeviceBaseURL {
|
||||
t.Fatalf("baseURL = %q, want %q", provider.baseURL, InternationalDeviceBaseURL)
|
||||
}
|
||||
if provider.terminalBaseURL != InternationalMCPBaseURL {
|
||||
t.Fatalf("terminalBaseURL = %q, want %q", provider.terminalBaseURL, InternationalMCPBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAuthorizationSucceedsAfterPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+112
-5
@@ -50,9 +50,15 @@ const (
|
||||
// AuthorizeURL is the DingTalk OAuth authorization page.
|
||||
AuthorizeURL = "https://login.dingtalk.com/oauth2/auth"
|
||||
|
||||
// InternationalAuthorizeURL is the DingTalk international OAuth authorization page.
|
||||
InternationalAuthorizeURL = "https://login.dingtalk.io/oauth2/auth"
|
||||
|
||||
// UserAccessTokenURL exchanges an authorization code for user tokens.
|
||||
UserAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/userAccessToken"
|
||||
|
||||
// InternationalUserAccessTokenURL exchanges authorization codes for international user tokens.
|
||||
InternationalUserAccessTokenURL = "https://api.dingtalk.io/v1.0/oauth2/userAccessToken"
|
||||
|
||||
// UserInfoURL fetches the authenticated user's profile.
|
||||
UserInfoURL = "https://api.dingtalk.com/v1.0/contact/users/me"
|
||||
|
||||
@@ -75,6 +81,9 @@ const (
|
||||
// DefaultDeviceBaseURL is the login server base URL for device flow.
|
||||
DefaultDeviceBaseURL = "https://login.dingtalk.com"
|
||||
|
||||
// InternationalDeviceBaseURL is the international login server base URL for device flow.
|
||||
InternationalDeviceBaseURL = "https://login.dingtalk.io"
|
||||
|
||||
// DeviceCodePath requests a device_code and user_code.
|
||||
DeviceCodePath = "/oauth2/device/code.json"
|
||||
|
||||
@@ -96,11 +105,12 @@ const (
|
||||
LogoutContinueURL = "https://login.dingtalk.com"
|
||||
|
||||
// MCP API endpoints for CLI authorization management.
|
||||
DefaultMCPBaseURL = config.DefaultMCPBaseURL
|
||||
CLIAuthEnabledPath = "/cli/cliAuthEnabled"
|
||||
SuperAdminPath = "/cli/superAdmin"
|
||||
SendCliAuthApplyPath = "/cli/sendCliAuthApply"
|
||||
ClientIDPath = "/cli/clientId"
|
||||
DefaultMCPBaseURL = config.DefaultMCPBaseURL
|
||||
InternationalMCPBaseURL = "https://mcp.dingtalk.io"
|
||||
CLIAuthEnabledPath = "/cli/cliAuthEnabled"
|
||||
SuperAdminPath = "/cli/superAdmin"
|
||||
SendCliAuthApplyPath = "/cli/sendCliAuthApply"
|
||||
ClientIDPath = "/cli/clientId"
|
||||
|
||||
// MCP OAuth endpoints (used when clientId is fetched from MCP).
|
||||
MCPOAuthTokenPath = "/oauth2/getToken"
|
||||
@@ -114,6 +124,57 @@ const (
|
||||
AppAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
|
||||
)
|
||||
|
||||
type LoginRegion string
|
||||
|
||||
const (
|
||||
LoginRegionDefault LoginRegion = ""
|
||||
LoginRegionInternational LoginRegion = "international"
|
||||
)
|
||||
|
||||
func (r LoginRegion) IsInternational() bool {
|
||||
return r == LoginRegionInternational
|
||||
}
|
||||
|
||||
func AuthorizeURLForLoginRegion(region LoginRegion) string {
|
||||
if override := LoginBaseURLOverride(); override != "" {
|
||||
return override + "/oauth2/auth"
|
||||
}
|
||||
if region.IsInternational() {
|
||||
return InternationalAuthorizeURL
|
||||
}
|
||||
return AuthorizeURL
|
||||
}
|
||||
|
||||
func DeviceBaseURLForLoginRegion(region LoginRegion) string {
|
||||
if override := LoginBaseURLOverride(); override != "" {
|
||||
return override
|
||||
}
|
||||
if region.IsInternational() {
|
||||
return InternationalDeviceBaseURL
|
||||
}
|
||||
return DefaultDeviceBaseURL
|
||||
}
|
||||
|
||||
func MCPBaseURLForLoginRegion(region LoginRegion) string {
|
||||
if override := MCPBaseURLOverride(); override != "" {
|
||||
return override
|
||||
}
|
||||
if region.IsInternational() {
|
||||
return InternationalMCPBaseURL
|
||||
}
|
||||
return GetMCPBaseURL()
|
||||
}
|
||||
|
||||
func UserAccessTokenURLForLoginRegion(region LoginRegion) string {
|
||||
if override := LoginBaseURLOverride(); override != "" {
|
||||
return override + "/v1.0/oauth2/userAccessToken"
|
||||
}
|
||||
if region.IsInternational() {
|
||||
return InternationalUserAccessTokenURL
|
||||
}
|
||||
return UserAccessTokenURL
|
||||
}
|
||||
|
||||
// GetTerminalBaseURL returns the terminal base URL with priority:
|
||||
// 1. ~/.dws/terminal_url file content (for pre-release environment)
|
||||
// 2. Default value (https://open-dev.dingtalk.com)
|
||||
@@ -146,8 +207,54 @@ var (
|
||||
// clientIDFromMCP indicates whether the clientID was fetched from MCP server.
|
||||
// When true, MCP OAuth endpoints should be used instead of direct DingTalk API.
|
||||
clientIDFromMCP bool
|
||||
|
||||
loginBaseURLMu sync.RWMutex
|
||||
loginBaseURLOverride string
|
||||
mcpBaseURLMu sync.RWMutex
|
||||
mcpBaseURLOverride string
|
||||
)
|
||||
|
||||
// PushLoginBaseURLOverride sets a process-local DingTalk login base URL
|
||||
// override and returns a restore function.
|
||||
func PushLoginBaseURLOverride(baseURL string) func() {
|
||||
loginBaseURLMu.Lock()
|
||||
previous := loginBaseURLOverride
|
||||
loginBaseURLOverride = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
loginBaseURLMu.Unlock()
|
||||
return func() {
|
||||
loginBaseURLMu.Lock()
|
||||
loginBaseURLOverride = previous
|
||||
loginBaseURLMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func LoginBaseURLOverride() string {
|
||||
loginBaseURLMu.RLock()
|
||||
defer loginBaseURLMu.RUnlock()
|
||||
return loginBaseURLOverride
|
||||
}
|
||||
|
||||
// PushMCPBaseURLOverride sets a process-local MCP base URL override and returns
|
||||
// a restore function. It is intended for one command invocation, such as
|
||||
// pre-release smoke testing.
|
||||
func PushMCPBaseURLOverride(baseURL string) func() {
|
||||
mcpBaseURLMu.Lock()
|
||||
previous := mcpBaseURLOverride
|
||||
mcpBaseURLOverride = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
mcpBaseURLMu.Unlock()
|
||||
return func() {
|
||||
mcpBaseURLMu.Lock()
|
||||
mcpBaseURLOverride = previous
|
||||
mcpBaseURLMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func MCPBaseURLOverride() string {
|
||||
mcpBaseURLMu.RLock()
|
||||
defer mcpBaseURLMu.RUnlock()
|
||||
return mcpBaseURLOverride
|
||||
}
|
||||
|
||||
// SetClientIDFromMCP sets the clientID fetched from MCP server and marks it as MCP-sourced.
|
||||
func SetClientIDFromMCP(id string) {
|
||||
clientMu.Lock()
|
||||
|
||||
+132
-15
@@ -20,6 +20,7 @@ import (
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -28,6 +29,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -51,7 +53,7 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
|
||||
"code": code,
|
||||
"grantType": "authorization_code",
|
||||
}
|
||||
resp, err := p.postJSON(ctx, UserAccessTokenURL, body)
|
||||
resp, err := p.postJSON(ctx, UserAccessTokenURLForLoginRegion(p.loginRegion()), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -62,6 +64,7 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
|
||||
// Snapshot credentials used for this token (for refresh)
|
||||
data.ClientID = clientID
|
||||
data.Source = resolveCredentialSource()
|
||||
p.applyLoginRegionToToken(data)
|
||||
// Save clientSecret for future refresh (even if env changes)
|
||||
if err := oauthSaveClientSecret(clientID, clientSecret); err != nil {
|
||||
// Log warning but don't fail login
|
||||
@@ -91,11 +94,36 @@ func ExchangeCodeForToken(ctx context.Context, configDir, code string) (*TokenDa
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) loginRegion() LoginRegion {
|
||||
if p == nil {
|
||||
return LoginRegionDefault
|
||||
}
|
||||
return p.LoginRegion
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) useTokenLoginRegion(data *TokenData) {
|
||||
if p == nil || p.LoginRegion != LoginRegionDefault || data == nil {
|
||||
return
|
||||
}
|
||||
if region := LoginRegion(strings.TrimSpace(data.LoginRegion)); region != LoginRegionDefault {
|
||||
p.LoginRegion = region
|
||||
}
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) applyLoginRegionToToken(data *TokenData) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
if region := p.loginRegion(); region != LoginRegionDefault {
|
||||
data.LoginRegion = string(region)
|
||||
}
|
||||
}
|
||||
|
||||
// exchangeCodeViaMCP exchanges auth code for token via MCP proxy.
|
||||
// This is used when client secret is not available (server-side secret management).
|
||||
func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*TokenData, error) {
|
||||
clientID := ClientID()
|
||||
url := GetMCPBaseURL() + MCPOAuthTokenPath
|
||||
url := MCPBaseURLForLoginRegion(p.loginRegion()) + MCPOAuthTokenPath
|
||||
body := map[string]string{
|
||||
"clientId": clientID,
|
||||
"authCode": code,
|
||||
@@ -112,6 +140,7 @@ func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*T
|
||||
// Snapshot credentials used for this token (for refresh)
|
||||
data.ClientID = clientID
|
||||
data.Source = "mcp"
|
||||
p.applyLoginRegionToToken(data)
|
||||
// MCP mode doesn't need to save clientSecret (server-side managed)
|
||||
return data, nil
|
||||
}
|
||||
@@ -120,8 +149,10 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
|
||||
// Use stored Source to determine refresh path (not current runtime state)
|
||||
// This ensures refresh works even if environment variables changed since login
|
||||
if data.Source == "mcp" {
|
||||
p.useTokenLoginRegion(data)
|
||||
return p.refreshViaMCP(ctx, data)
|
||||
}
|
||||
p.useTokenLoginRegion(data)
|
||||
|
||||
// Direct mode: use stored clientId and load saved clientSecret
|
||||
clientID := data.ClientID
|
||||
@@ -145,7 +176,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
|
||||
"refreshToken": data.RefreshToken,
|
||||
"grantType": "refresh_token",
|
||||
}
|
||||
resp, err := p.postJSON(ctx, UserAccessTokenURL, body)
|
||||
resp, err := p.postJSON(ctx, UserAccessTokenURLForLoginRegion(p.loginRegion()), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -156,6 +187,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
|
||||
// Preserve original credentials info
|
||||
updated.ClientID = data.ClientID
|
||||
updated.Source = data.Source
|
||||
updated.LoginRegion = data.LoginRegion
|
||||
updated.PersistentCode = data.PersistentCode
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
@@ -185,7 +217,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
|
||||
return nil, fmt.Errorf("无法刷新 token: 缺少 clientId,请重新登录")
|
||||
}
|
||||
|
||||
url := GetMCPBaseURL() + MCPRefreshTokenPath
|
||||
url := MCPBaseURLForLoginRegion(p.loginRegion()) + MCPRefreshTokenPath
|
||||
body := map[string]string{
|
||||
"clientId": clientID,
|
||||
"refreshToken": data.RefreshToken,
|
||||
@@ -202,6 +234,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
|
||||
// Preserve original credentials info
|
||||
updated.ClientID = data.ClientID
|
||||
updated.Source = data.Source
|
||||
updated.LoginRegion = data.LoginRegion
|
||||
updated.PersistentCode = data.PersistentCode
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
@@ -371,6 +404,10 @@ func firstNonEmpty(values ...string) string {
|
||||
}
|
||||
|
||||
func buildAuthURL(clientID, redirectURI, targetCorpID string) string {
|
||||
return buildAuthURLForRegion(clientID, redirectURI, targetCorpID, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func buildAuthURLForRegion(clientID, redirectURI, targetCorpID string, region LoginRegion) string {
|
||||
params := url.Values{
|
||||
"client_id": {clientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
@@ -381,7 +418,7 @@ func buildAuthURL(clientID, redirectURI, targetCorpID string) string {
|
||||
if targetCorpID = strings.TrimSpace(targetCorpID); targetCorpID != "" {
|
||||
params.Set("corpId", targetCorpID)
|
||||
}
|
||||
return AuthorizeURL + "?" + params.Encode()
|
||||
return AuthorizeURLForLoginRegion(region) + "?" + params.Encode()
|
||||
}
|
||||
|
||||
const successHTML = `<!doctype html>
|
||||
@@ -937,14 +974,15 @@ const notEnabledHTML = `<!doctype html>
|
||||
clientId = status.clientId || "";
|
||||
applySent = status.applySent || false;
|
||||
selectedAdminId = status.selectedAdminId || "";
|
||||
const authorizeUrl = status.authorizeUrl || "";
|
||||
|
||||
if (clientId) {
|
||||
if (clientId && authorizeUrl) {
|
||||
const port = location.port;
|
||||
const redirectUri = encodeURIComponent(
|
||||
"http://127.0.0.1:" + port + "/callback"
|
||||
);
|
||||
backLink.href =
|
||||
"https://login.dingtalk.com/oauth2/auth?client_id=" +
|
||||
authorizeUrl + "?client_id=" +
|
||||
clientId +
|
||||
"&prompt=consent&redirect_uri=" +
|
||||
redirectUri +
|
||||
@@ -1398,6 +1436,7 @@ const mcpRequestMaxRetries = 3
|
||||
// false negatives caused by momentary network issues.
|
||||
func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) {
|
||||
var lastErr error
|
||||
traceID := cliAuthTraceID()
|
||||
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
@@ -1406,7 +1445,7 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str
|
||||
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
status, err := p.doCheckCLIAuthEnabled(ctx, accessToken)
|
||||
status, err := p.doCheckCLIAuthEnabledAttempt(ctx, accessToken, attempt+1, traceID)
|
||||
if err == nil {
|
||||
return status, nil
|
||||
}
|
||||
@@ -1416,16 +1455,27 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) {
|
||||
url := GetMCPBaseURL() + CLIAuthEnabledPath
|
||||
return p.doCheckCLIAuthEnabledAttempt(ctx, accessToken, 1, cliAuthTraceID())
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) doCheckCLIAuthEnabledAttempt(ctx context.Context, accessToken string, attempt int, traceID string) (*CLIAuthStatus, error) {
|
||||
url := MCPBaseURLForLoginRegion(p.loginRegion()) + CLIAuthEnabledPath
|
||||
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
applyCLIAuthTraceHeaders(req, traceID)
|
||||
if ch := os.Getenv("DWS_CHANNEL"); ch != "" {
|
||||
req.Header.Set("x-dws-channel", ch)
|
||||
}
|
||||
applyEditionEnterpriseCredentialHeaders(req)
|
||||
slog.Debug("auth.cli_auth_enabled.request",
|
||||
"attempt", attempt,
|
||||
"url", url,
|
||||
"trace_id", traceID,
|
||||
"channel", os.Getenv("DWS_CHANNEL"),
|
||||
)
|
||||
|
||||
client := p.httpClient
|
||||
if client == nil {
|
||||
@@ -1433,9 +1483,23 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
slog.Debug("auth.cli_auth_enabled.error",
|
||||
"attempt", attempt,
|
||||
"url", url,
|
||||
"trace_id", traceID,
|
||||
"error", err,
|
||||
)
|
||||
return nil, fmt.Errorf("sending request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
slog.Debug("auth.cli_auth_enabled.response",
|
||||
"attempt", attempt,
|
||||
"url", url,
|
||||
"status", resp.StatusCode,
|
||||
"trace_id", traceID,
|
||||
"response_trace_id", cliAuthResponseTraceID(resp.Header),
|
||||
"eagleeye_rpc_id", resp.Header.Get("EagleEye-RpcId"),
|
||||
)
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
||||
if err != nil {
|
||||
@@ -1449,9 +1513,42 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
func cliAuthTraceID() string {
|
||||
if traceID := strings.TrimSpace(os.Getenv("DINGTALK_TRACE_ID")); traceID != "" {
|
||||
return traceID
|
||||
}
|
||||
return strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
|
||||
func applyCLIAuthTraceHeaders(req *http.Request, traceID string) {
|
||||
if req == nil || traceID == "" {
|
||||
return
|
||||
}
|
||||
req.Header.Set("EagleEye-TraceId", traceID)
|
||||
req.Header.Set("X-Dingtalk-Trace-Id", traceID)
|
||||
}
|
||||
|
||||
func cliAuthResponseTraceID(headers http.Header) string {
|
||||
for _, key := range []string{
|
||||
"EagleEye-TraceId",
|
||||
"X-Trace-Id",
|
||||
"X-Request-Id",
|
||||
"X-Dingtalk-Trace-Id",
|
||||
} {
|
||||
if value := strings.TrimSpace(headers.Get(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetSuperAdmins fetches the list of corp super admins.
|
||||
// It retries up to mcpRequestMaxRetries times on transient errors.
|
||||
func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) {
|
||||
return GetSuperAdminsForLoginRegion(ctx, accessToken, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func GetSuperAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
@@ -1461,7 +1558,7 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons
|
||||
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
result, err := doGetSuperAdmins(ctx, accessToken)
|
||||
result, err := doGetSuperAdminsForLoginRegion(ctx, accessToken, region)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
@@ -1471,7 +1568,11 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons
|
||||
}
|
||||
|
||||
func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) {
|
||||
url := GetMCPBaseURL() + SuperAdminPath
|
||||
return doGetSuperAdminsForLoginRegion(ctx, accessToken, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func doGetSuperAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
|
||||
url := MCPBaseURLForLoginRegion(region) + SuperAdminPath
|
||||
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
@@ -1500,6 +1601,10 @@ func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespo
|
||||
// SendCliAuthApply sends a CLI auth apply request to the specified admin.
|
||||
// It retries up to mcpRequestMaxRetries times on transient errors.
|
||||
func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) {
|
||||
return SendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func SendCliAuthApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
@@ -1509,7 +1614,7 @@ func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*S
|
||||
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
result, err := doSendCliAuthApply(ctx, accessToken, adminStaffID)
|
||||
result, err := doSendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, region)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
@@ -1519,7 +1624,11 @@ func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*S
|
||||
}
|
||||
|
||||
func doSendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) {
|
||||
url := GetMCPBaseURL() + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID
|
||||
return doSendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func doSendCliAuthApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) {
|
||||
url := MCPBaseURLForLoginRegion(region) + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID
|
||||
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
@@ -1557,6 +1666,10 @@ type ClientIDResponse struct {
|
||||
// This is used when no client ID is provided via flags, config, or env vars.
|
||||
// It retries up to mcpRequestMaxRetries times on transient errors.
|
||||
func FetchClientIDFromMCP(ctx context.Context) (string, error) {
|
||||
return FetchClientIDFromMCPForLoginRegion(ctx, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func FetchClientIDFromMCPForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
@@ -1566,7 +1679,7 @@ func FetchClientIDFromMCP(ctx context.Context) (string, error) {
|
||||
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
id, err := doFetchClientIDFromMCP(ctx)
|
||||
id, err := doFetchClientIDFromMCPForLoginRegion(ctx, region)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
}
|
||||
@@ -1576,7 +1689,11 @@ func FetchClientIDFromMCP(ctx context.Context) (string, error) {
|
||||
}
|
||||
|
||||
func doFetchClientIDFromMCP(ctx context.Context) (string, error) {
|
||||
url := GetMCPBaseURL() + ClientIDPath
|
||||
return doFetchClientIDFromMCPForLoginRegion(ctx, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func doFetchClientIDFromMCPForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
|
||||
url := MCPBaseURLForLoginRegion(region) + ClientIDPath
|
||||
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating request: %w", err)
|
||||
|
||||
@@ -68,6 +68,27 @@ var (
|
||||
oauthSleep = time.Sleep
|
||||
)
|
||||
|
||||
func oauthFetchClientIDForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
|
||||
if region.IsInternational() {
|
||||
return FetchClientIDFromMCPForLoginRegion(ctx, region)
|
||||
}
|
||||
return oauthFetchClientID(ctx)
|
||||
}
|
||||
|
||||
func oauthGetAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
|
||||
if region.IsInternational() {
|
||||
return GetSuperAdminsForLoginRegion(ctx, accessToken, region)
|
||||
}
|
||||
return oauthGetAdmins(ctx, accessToken)
|
||||
}
|
||||
|
||||
func oauthSendApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) {
|
||||
if region.IsInternational() {
|
||||
return SendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, region)
|
||||
}
|
||||
return oauthSendApply(ctx, accessToken, adminStaffID)
|
||||
}
|
||||
|
||||
// OAuthProvider handles the DingTalk OAuth 2.0 authorization code flow.
|
||||
type OAuthProvider struct {
|
||||
configDir string
|
||||
@@ -80,6 +101,7 @@ type OAuthProvider struct {
|
||||
// IdentityEnricher resolves userId/userName/corpName while the freshly
|
||||
// exchanged access token is still only in memory.
|
||||
IdentityEnricher func(context.Context, *TokenData) error
|
||||
LoginRegion LoginRegion
|
||||
}
|
||||
|
||||
// NewOAuthProvider creates a new OAuth provider.
|
||||
@@ -173,7 +195,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := oauthFetchClientID(ctx)
|
||||
mcpClientID, mcpErr := oauthFetchClientIDForLoginRegion(ctx, p.LoginRegion)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
@@ -401,7 +423,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
|
||||
return
|
||||
}
|
||||
result, err := oauthGetAdmins(ctx, token.AccessToken)
|
||||
result, err := oauthGetAdminsForLoginRegion(ctx, token.AccessToken, p.LoginRegion)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
|
||||
return
|
||||
@@ -425,7 +447,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
|
||||
return
|
||||
}
|
||||
result, err := oauthSendApply(ctx, token.AccessToken, adminStaffID)
|
||||
result, err := oauthSendApplyForLoginRegion(ctx, token.AccessToken, adminStaffID, p.LoginRegion)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
|
||||
return
|
||||
@@ -448,7 +470,13 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
applySent := callbackApplySent
|
||||
selectedAdminId := callbackSelectedAdminId
|
||||
callbackTokenMu.Unlock()
|
||||
_, _ = fmt.Fprintf(w, `{"clientId":"%s","applySent":%t,"selectedAdminId":"%s"}`, p.clientID, applySent, selectedAdminId)
|
||||
data, _ := json.Marshal(map[string]any{
|
||||
"clientId": p.clientID,
|
||||
"authorizeUrl": AuthorizeURLForLoginRegion(p.LoginRegion),
|
||||
"applySent": applySent,
|
||||
"selectedAdminId": selectedAdminId,
|
||||
})
|
||||
_, _ = w.Write(data)
|
||||
})
|
||||
|
||||
// API endpoint: check CLI auth enabled status
|
||||
@@ -491,7 +519,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
_ = server.Shutdown(shutCtx)
|
||||
}()
|
||||
|
||||
authURL := buildAuthURL(p.clientID, redirectURI, p.TargetCorpID)
|
||||
authURL := buildAuthURLForRegion(p.clientID, redirectURI, p.TargetCorpID, p.LoginRegion)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("authorization URL", "url", authURL)
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ func ImportPortableAuthBundle(configDir string, r io.Reader) (PortableImportRepo
|
||||
|
||||
func portableConfigFiles(configDir string) ([]string, error) {
|
||||
var files []string
|
||||
patterns := []string{"app*.json", profilesJSONFile, "mcp_url", "terminal_url"}
|
||||
patterns := []string{"app*.json", profilesJSONFile, "mcp_url", config.ManagedMCPURLRegionFileName, "terminal_url"}
|
||||
for _, pattern := range patterns {
|
||||
matches, err := portableGlob(filepath.Join(configDir, pattern))
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProfileMetadata is the minimal, non-sensitive identity projection exposed to
|
||||
// telemetry callers. It intentionally excludes profile names, client IDs,
|
||||
// organization names, token material, and credential status.
|
||||
type ProfileMetadata struct {
|
||||
UserID string
|
||||
UserName string
|
||||
CorpID string
|
||||
}
|
||||
|
||||
// ResolveProfileMetadataReadOnly resolves one identity exclusively from the
|
||||
// non-sensitive profiles.json metadata. It deliberately avoids auth locks,
|
||||
// token stores, Keychain access, migrations, quarantine renames, and writes.
|
||||
// A missing metadata file or an empty current profile returns (nil, nil).
|
||||
func ResolveProfileMetadataReadOnly(configDir, selector string) (*ProfileMetadata, error) {
|
||||
data, err := profilesReadFile(ProfilesPath(configDir))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read profile metadata: %w", err)
|
||||
}
|
||||
|
||||
var cfg ProfilesConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse profile metadata: %w", err)
|
||||
}
|
||||
if cfg.Version > profilesMaxVersion {
|
||||
return nil, fmt.Errorf("profile metadata version %d is newer than supported version %d", cfg.Version, profilesMaxVersion)
|
||||
}
|
||||
normalizeProfilesConfig(&cfg)
|
||||
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
selector = strings.TrimSpace(cfg.CurrentProfile)
|
||||
if selector == "" {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
profile, _, err := resolveProfileSelection("", &cfg, selector)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ProfileMetadata{
|
||||
UserID: profile.UserID,
|
||||
UserName: profile.UserName,
|
||||
CorpID: profile.CorpID,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageResolveProfileMetadataReadOnly(t *testing.T) {
|
||||
const metadata = `{
|
||||
"version": 3,
|
||||
"currentProfile": "corp-a:user-a",
|
||||
"profiles": [
|
||||
{"name":"alpha","corpId":"corp-a","userId":"user-a","userName":"Alice"},
|
||||
{"name":"beta","corpId":"corp-b","userId":"user-b","userName":"Bob"}
|
||||
]
|
||||
}`
|
||||
reads := 0
|
||||
testseam.Swap(t, &profilesReadFile, func(path string) ([]byte, error) {
|
||||
reads++
|
||||
if !strings.HasSuffix(path, profilesJSONFile) {
|
||||
t.Fatalf("metadata path = %q", path)
|
||||
}
|
||||
return []byte(metadata), nil
|
||||
})
|
||||
|
||||
current, err := ResolveProfileMetadataReadOnly("/config", "")
|
||||
if err != nil || current == nil || current.UserID != "user-a" || current.UserName != "Alice" || current.CorpID != "corp-a" {
|
||||
t.Fatalf("current metadata profile = %#v, %v", current, err)
|
||||
}
|
||||
explicit, err := ResolveProfileMetadataReadOnly("/config", "beta")
|
||||
if err != nil || explicit == nil || explicit.UserID != "user-b" || explicit.CorpID != "corp-b" {
|
||||
t.Fatalf("explicit metadata profile = %#v, %v", explicit, err)
|
||||
}
|
||||
if reads != 2 {
|
||||
t.Fatalf("profile metadata reads = %d, want 2", reads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveProfileMetadataReadOnlyFailsClosed(t *testing.T) {
|
||||
fail := errors.New("read failed")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
read func(string) ([]byte, error)
|
||||
wantErr string
|
||||
}{
|
||||
{name: "missing", read: func(string) ([]byte, error) { return nil, os.ErrNotExist }},
|
||||
{name: "read error", read: func(string) ([]byte, error) { return nil, fail }, wantErr: "read profile metadata"},
|
||||
{name: "corrupt", read: func(string) ([]byte, error) { return []byte("{"), nil }, wantErr: "parse profile metadata"},
|
||||
{name: "forward version", read: func(string) ([]byte, error) { return []byte(`{"version":999}`), nil }, wantErr: "newer than supported"},
|
||||
{name: "no current", read: func(string) ([]byte, error) { return []byte(`{"version":3,"profiles":[]}`), nil }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &profilesReadFile, tc.read)
|
||||
testseam.Swap(t, &profilesRename, func(string, string) error {
|
||||
t.Fatal("read-only metadata resolution attempted a quarantine rename")
|
||||
return nil
|
||||
})
|
||||
got, err := ResolveProfileMetadataReadOnly("/config", "")
|
||||
if tc.wantErr == "" {
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("read-only metadata = %#v, %v", got, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) || got != nil {
|
||||
t.Fatalf("read-only metadata = %#v, %v; want %q", got, err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveProfileMetadataReadOnlyRejectsUnknownSelector(t *testing.T) {
|
||||
testseam.Swap(t, &profilesReadFile, func(string) ([]byte, error) {
|
||||
return []byte(`{"version":3,"currentProfile":"corp-a:user-a","profiles":[{"name":"alpha","corpId":"corp-a","userId":"user-a"}]}`), nil
|
||||
})
|
||||
profile, err := ResolveProfileMetadataReadOnly("/config", "missing")
|
||||
if err == nil || profile != nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("unknown read-only profile = %#v, %v", profile, err)
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,7 @@ type TokenData struct {
|
||||
ClientID string `json:"client_id,omitempty"` // Associated app client ID for refresh
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
LoginRegion string `json:"login_region,omitempty"`
|
||||
// LegacyOrgScopedProfile is an in-memory destination for an explicitly
|
||||
// matched historical profile whose userId was never resolved. It is never
|
||||
// persisted as token material.
|
||||
|
||||
@@ -177,6 +177,7 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
|
||||
aliasMap := make(map[string]string)
|
||||
blockedSet := make(map[string]bool)
|
||||
excludedSet := make(map[string]bool)
|
||||
claimedRealSet := make(map[string]bool)
|
||||
pendingReview := ov.Confirm || ov.Investigate
|
||||
|
||||
for boundFlag, conceptID := range ov.Bind {
|
||||
@@ -218,6 +219,14 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
|
||||
if len(candidates) == 0 {
|
||||
continue
|
||||
}
|
||||
for m := range eff {
|
||||
if _, isReal := realByMorph[m]; isReal {
|
||||
claimedRealSet[m] = true
|
||||
}
|
||||
}
|
||||
for _, exclude := range concept.Excludes {
|
||||
excludedSet[cmdutil.Morph(exclude)] = true
|
||||
}
|
||||
visible := distinctRealNames(candidates, true)
|
||||
var canon string
|
||||
switch len(visible) {
|
||||
@@ -267,21 +276,18 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
|
||||
}
|
||||
aliasMap[m] = canon
|
||||
}
|
||||
// Excludes are not passive prose: once this concept is active on a
|
||||
// reviewed command, a non-real excluded spelling is protected from
|
||||
// downstream fuzzy correction. A real flag is left alone because it
|
||||
// already has an independently valid command-local meaning.
|
||||
for _, exclude := range concept.Excludes {
|
||||
morphed := cmdutil.Morph(exclude)
|
||||
if _, isReal := realByMorph[morphed]; !isReal {
|
||||
excludedSet[morphed] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for excluded := range excludedSet {
|
||||
if _, isAlias := aliasMap[excluded]; !isAlias {
|
||||
blockedSet[excluded] = true
|
||||
if _, isAlias := aliasMap[excluded]; isAlias {
|
||||
continue
|
||||
}
|
||||
if claimedRealSet[excluded] {
|
||||
continue
|
||||
}
|
||||
if _, isReal := realByMorph[excluded]; isReal {
|
||||
continue
|
||||
}
|
||||
blockedSet[excluded] = true
|
||||
}
|
||||
|
||||
// (b) Command scoped aliases override concept reductions.
|
||||
|
||||
+1468
-485
File diff suppressed because it is too large
Load Diff
@@ -237,6 +237,42 @@ func TestReduceLeafParamAliasesRemainingEdges(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReduceLeafParamAliasesBindExcludesRealFlags(t *testing.T) {
|
||||
entry, problems := reduceLeafParamAliases(
|
||||
"demo cmd",
|
||||
realMap(realFlag{name: "id"}, realFlag{name: "name"}, realFlag{name: "query"}),
|
||||
[]Concept{
|
||||
{ID: "base_id", Members: []string{"base-id", "base-token"}, Excludes: []string{"keyword", "name", "query", "unsafe"}},
|
||||
{ID: "query", Members: []string{"query", "keyword"}},
|
||||
},
|
||||
CommandOverride{Bind: map[string]string{"id": "base_id"}},
|
||||
)
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("reduceLeafParamAliases() problems = %v", problems)
|
||||
}
|
||||
if entry == nil {
|
||||
t.Fatal("reduceLeafParamAliases() entry = nil")
|
||||
}
|
||||
if entry.Aliases["base-id"] != "id" || entry.Aliases["base-token"] != "id" {
|
||||
t.Fatalf("bound aliases = %#v, want base-id/base-token -> id", entry.Aliases)
|
||||
}
|
||||
if entry.Aliases["keyword"] != "query" {
|
||||
t.Fatalf("query alias = %#v, want keyword -> query", entry.Aliases)
|
||||
}
|
||||
if containsParamAlias(entry.Blocked, "keyword") {
|
||||
t.Fatalf("excluded alias entered blocked list: %#v", entry.Blocked)
|
||||
}
|
||||
if containsParamAlias(entry.Blocked, "name") {
|
||||
t.Fatalf("real excluded flag entered blocked list: %#v", entry.Blocked)
|
||||
}
|
||||
if containsParamAlias(entry.Blocked, "query") {
|
||||
t.Fatalf("claimed real excluded flag entered blocked list: %#v", entry.Blocked)
|
||||
}
|
||||
if !containsParamAlias(entry.Blocked, "unsafe") {
|
||||
t.Fatalf("non-real excluded flag was not blocked: %#v", entry.Blocked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamAliasEntryLookupMethods(t *testing.T) {
|
||||
entry := ParamAliasEntry{
|
||||
Aliases: map[string]string{"uid": "user"},
|
||||
@@ -457,6 +493,22 @@ func TestReduceLeafParamAliasesExcludesProtectFuzzyButDoNotOverrideAnotherConcep
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesExcludesDoNotBlockRealFlag(t *testing.T) {
|
||||
concepts := []Concept{
|
||||
{ID: "single_id", Members: []string{"id", "item-id"}, Excludes: []string{"item-ids"}},
|
||||
}
|
||||
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}, realFlag{name: "item-ids"}), concepts, CommandOverride{})
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("unexpected problems: %v", problems)
|
||||
}
|
||||
if entry == nil {
|
||||
t.Fatal("expected a reduced entry")
|
||||
}
|
||||
if containsParamAlias(entry.Blocked, "item-ids") {
|
||||
t.Fatalf("real exclude was blocked: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testing.T) {
|
||||
real := realMap(realFlag{name: "user-id"}, realFlag{name: "user"})
|
||||
for name, override := range map[string]CommandOverride{
|
||||
@@ -472,6 +524,58 @@ func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedParamAliasesBlockPluralListSpellingsOnSingleIDCommands(t *testing.T) {
|
||||
entries := make(map[string]ParamAliasEntry, len(generatedParamAliases))
|
||||
for _, entry := range generatedParamAliases {
|
||||
entries[entry.CLIPath] = entry
|
||||
}
|
||||
assertBlocked := func(path string, names ...string) {
|
||||
t.Helper()
|
||||
entry, ok := entries[path]
|
||||
if !ok {
|
||||
t.Fatalf("missing generated alias entry for %q", path)
|
||||
}
|
||||
for _, name := range names {
|
||||
if !entry.IsBlocked(cmdutil.Morph(name)) {
|
||||
t.Fatalf("%s: %q not blocked; entry = %#v", path, name, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"chat message add-emoji",
|
||||
"chat message remove-emoji",
|
||||
"chat message add-text-emotion",
|
||||
"chat message remove-text-emotion",
|
||||
} {
|
||||
assertBlocked(path, "msg-ids", "message-ids")
|
||||
}
|
||||
for _, path := range []string{
|
||||
"chat message send",
|
||||
"chat conversation-info",
|
||||
"chat category add-conv",
|
||||
"chat category remove-conv",
|
||||
"chat message list",
|
||||
"chat message list-mentions",
|
||||
"chat message recall-by-bot",
|
||||
"chat message search",
|
||||
} {
|
||||
assertBlocked(path, "conversation-ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedParamAliasesKeepAuditJoinUserRoleAmbiguous(t *testing.T) {
|
||||
entry, ok := LookupParamAlias("chat group audit-join-validation")
|
||||
if !ok {
|
||||
t.Fatal("missing generated alias entry for chat group audit-join-validation")
|
||||
}
|
||||
for _, name := range []string{"user", "user-id", "userid", "uid", "staff-id"} {
|
||||
if !entry.IsAmbiguous(cmdutil.Morph(name)) {
|
||||
t.Fatalf("%q not ambiguous; entry = %#v", name, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeneratedParamAliasesAreWellFormed guards the committed generated table
|
||||
// at the Go level, complementing the byte-identity drift gate.
|
||||
func TestGeneratedParamAliasesAreWellFormed(t *testing.T) {
|
||||
@@ -515,3 +619,68 @@ func TestGeneratedParamAliasesAreWellFormed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePublishedSpaceWorkspaceAliasesRemainExecutable(t *testing.T) {
|
||||
docCommands := []string{
|
||||
"doc +access-change",
|
||||
"doc +access-grant",
|
||||
"doc +access-revoke",
|
||||
"doc +copy",
|
||||
"doc +create",
|
||||
"doc +create-from-template",
|
||||
"doc +grant-and-share",
|
||||
"doc +import",
|
||||
"doc +list",
|
||||
"doc +move",
|
||||
"doc create",
|
||||
"doc file create",
|
||||
"doc import",
|
||||
"doc template apply",
|
||||
}
|
||||
for _, command := range docCommands {
|
||||
entry, ok := LookupParamAlias(command)
|
||||
if !ok {
|
||||
t.Errorf("published Doc command %q has no generated parameter-alias entry", command)
|
||||
continue
|
||||
}
|
||||
for _, emitted := range []string{"space", "space-id"} {
|
||||
target, active := entry.ResolveAlias(emitted)
|
||||
if !active || target != "workspace" {
|
||||
t.Errorf("%s --%s resolution = active:%v target:%q, want --workspace", command, emitted, active, target)
|
||||
}
|
||||
if entry.IsBlocked(emitted) || entry.IsAmbiguous(emitted) {
|
||||
t.Errorf("%s --%s remains protected after restoring its published alias", command, emitted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
driveInfo, ok := LookupParamAlias("drive info")
|
||||
if !ok {
|
||||
t.Fatal("published drive info command has no generated parameter-alias entry")
|
||||
}
|
||||
for _, emitted := range []string{"space", "workspace", "workspace-id"} {
|
||||
target, active := driveInfo.ResolveAlias(emitted)
|
||||
if !active || target != "space-id" {
|
||||
t.Errorf("drive info --%s resolution = active:%v target:%q, want --space-id", emitted, active, target)
|
||||
}
|
||||
if driveInfo.IsBlocked(emitted) || driveInfo.IsAmbiguous(emitted) {
|
||||
t.Errorf("drive info --%s remains protected after restoring its published alias", emitted)
|
||||
}
|
||||
}
|
||||
|
||||
// Compatibility remains exact-path scoped. Strong type spellings introduced
|
||||
// after the split continue to guard the two value domains elsewhere.
|
||||
for _, test := range []struct {
|
||||
command string
|
||||
emitted string
|
||||
}{
|
||||
{command: "doc create", emitted: "storage-space-id"},
|
||||
{command: "drive +upload", emitted: "workspace-id"},
|
||||
{command: "drive info", emitted: "knowledge-base-id"},
|
||||
} {
|
||||
entry, ok := LookupParamAlias(test.command)
|
||||
if !ok || !entry.IsBlocked(test.emitted) {
|
||||
t.Errorf("%s --%s must remain blocked outside the published compatibility set", test.command, test.emitted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
},
|
||||
|
||||
"concepts": {
|
||||
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +create-from-template", "doc +find-doc", "doc +search", "doc +template-search", "doc template search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
|
||||
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "doc +comment-list", "doc +find-doc", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "mail thread list", "oa +list-executed"], "risk": "green"},
|
||||
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +create-from-template", "doc +find-doc", "doc +search", "doc +template-search", "doc template search", "drive +find-file", "drive +search", "drive +search-docs", "drive search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
|
||||
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "doc +comment-list", "doc +find-doc", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "drive +list", "drive +recent", "drive +recycle-list", "drive +search", "drive +search-docs", "drive +star-list", "drive +version-history", "drive list", "drive list-spaces", "drive permission list", "drive recent", "drive recycle list", "drive search", "drive star list", "mail thread list", "oa +list-executed"], "risk": "green"},
|
||||
"page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"},
|
||||
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list", "doc +comment-list", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list"], "risk": "green"},
|
||||
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list", "doc +comment-list", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "drive +list", "drive +recent", "drive +recycle-list", "drive +search", "drive +star-list", "drive +version-history", "drive list", "drive list-spaces", "drive recent", "drive recycle list", "drive search", "drive star list"], "risk": "green"},
|
||||
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc +checkpoint-update", "doc +comment-create", "doc +comment-reply", "doc +comment-update", "doc +create", "doc +doc-append", "doc block insert", "doc block update", "doc comment create", "doc comment create-inline", "doc comment reply", "doc comment update", "doc create"], "risk": "green"},
|
||||
"time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list"], "risk": "yellow"},
|
||||
"time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list"], "risk": "yellow"},
|
||||
@@ -31,31 +31,48 @@
|
||||
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat +messages-batch-send-by-bot", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat +chat-members-get", "chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"},
|
||||
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive list", "mail folder update"], "risk": "green"},
|
||||
"space_id": {"denotes": "drive/wiki/Doc workspace id with unchanged value", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive info"], "risk": "yellow"},
|
||||
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive +copy", "drive +create-folder", "drive +create-shortcut", "drive +list", "drive +move", "drive +upload", "drive commit", "drive copy", "drive list", "drive mkdir", "drive move", "drive shortcut", "drive upload", "drive upload-info", "mail folder update"], "risk": "green"},
|
||||
"drive_storage_space_id": {"denotes": "single numeric DingDrive storage space ID with unchanged value", "canonical_hint": "space-id", "members": ["space-id", "drive-space-id", "storage-space-id", "dingdrive-space-id"], "excludes": ["space", "workspace", "workspace-id", "knowledge-base-id", "wiki-workspace-id"], "commands": ["drive +create-folder", "drive +download", "drive +info", "drive +inspect", "drive +list", "drive +recycle-list", "drive +upload", "drive commit", "drive download", "drive info", "drive list", "drive mkdir", "drive recycle list", "drive upload", "drive upload-info"], "risk": "yellow"},
|
||||
"app_id": {"denotes": "application id", "canonical_hint": "unified-app-id", "members": ["app-id", "unified-app-id", "application-id"], "excludes": ["app-key", "app-secret", "agent-id"], "commands": ["dev app get"], "risk": "yellow"},
|
||||
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id", "bot-id", "open-bot-id", "bot-code"], "commands": ["chat +chat-add-bot", "chat +messages-batch-recall-by-bot", "chat +messages-batch-send-by-bot", "chat +messages-recall-by-bot", "chat +messages-send-by-bot", "chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
|
||||
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat +chat-remove-bot", "chat group members remove-bot"], "risk": "yellow"},
|
||||
"doc_node_id": {"denotes": "single DingTalk document nodeId or accepted document URL/token with unchanged value", "canonical_hint": "node", "members": ["node", "node-id", "doc", "doc-id", "file-id", "document-id", "url"], "excludes": ["id", "folder", "folder-id", "parent-id", "workspace", "workspace-id", "block-id", "comment-id", "comment-key", "job-id", "task-id", "template-id", "version", "revision"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +background-delete", "doc +background-update", "doc +checkpoint-update", "doc +comment-create", "doc +comment-delete", "doc +comment-list", "doc +comment-reply", "doc +comment-update", "doc +copy", "doc +doc-append", "doc +export", "doc +export-submit", "doc +fetch", "doc +history-list", "doc +history-revert", "doc +history-save", "doc +inspect", "doc +move", "doc +review", "doc +version-list", "doc +version-revert", "doc +version-save", "doc block delete", "doc block insert", "doc block list", "doc block update", "doc comment create", "doc comment create-inline", "doc comment delete", "doc comment list", "doc comment reply", "doc comment update", "doc export", "doc info", "doc media download", "doc media insert", "doc media upload", "doc read", "doc style background clear", "doc style background set", "doc style cover clear", "doc style cover set", "doc style get", "doc update", "doc version list", "doc version revert", "doc version save", "doc whiteboard insert"], "risk": "yellow"},
|
||||
"doc_node_id": {"denotes": "single DingTalk document nodeId or accepted document URL/token with unchanged value", "canonical_hint": "node", "members": ["node", "node-id", "doc", "doc-id", "file-id", "document-id", "url", "dentry-uuid"], "excludes": ["id", "folder", "folder-id", "parent-id", "workspace", "workspace-id", "block-id", "comment-id", "comment-key", "job-id", "task-id", "template-id", "version", "revision", "dentry-id", "space-id", "name", "role"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +background-delete", "doc +background-update", "doc +checkpoint-update", "doc +comment-create", "doc +comment-delete", "doc +comment-list", "doc +comment-reply", "doc +comment-update", "doc +copy", "doc +doc-append", "doc +export", "doc +export-submit", "doc +fetch", "doc +history-list", "doc +history-revert", "doc +history-save", "doc +inspect", "doc +move", "doc +review", "doc +version-list", "doc +version-revert", "doc +version-save", "doc block delete", "doc block insert", "doc block list", "doc block update", "doc comment create", "doc comment create-inline", "doc comment delete", "doc comment list", "doc comment reply", "doc comment update", "doc export", "doc info", "doc media download", "doc media insert", "doc media upload", "doc read", "doc style background clear", "doc style background set", "doc style cover clear", "doc style cover set", "doc style get", "doc update", "doc version list", "doc version revert", "doc version save", "doc whiteboard insert"], "risk": "yellow"},
|
||||
"doc_comment_key": {"denotes": "single DingTalk document commentKey with unchanged value", "canonical_hint": "comment-key", "members": ["comment-key", "comment-id"], "excludes": ["id", "node", "node-id", "doc-id", "block-id"], "commands": ["doc +comment-delete", "doc +comment-reply", "doc +comment-update", "doc comment delete", "doc comment reply", "doc comment update"], "risk": "yellow"},
|
||||
"doc_version_number": {"denotes": "single DingTalk document historical version number with unchanged integer value", "canonical_hint": "version", "members": ["version", "version-number", "version-no"], "excludes": ["revision", "id", "node", "node-id", "doc-id"], "commands": ["doc +history-revert", "doc +version-revert", "doc version revert"], "risk": "yellow"},
|
||||
"doc_version_number": {"denotes": "single document or Drive ordinary-file historical version number with unchanged positive integer value", "canonical_hint": "version", "members": ["version", "version-number", "version-no"], "excludes": ["revision", "id", "node", "node-id", "doc-id"], "commands": ["doc +history-revert", "doc +version-revert", "doc version revert", "drive +version-download", "drive +version-get", "drive +version-revert", "drive download", "drive download-version", "drive revert"], "risk": "yellow"},
|
||||
"doc_content_format": {"denotes": "DingTalk document body format with unchanged markdown/jsonml value", "canonical_hint": "content-format", "members": ["content-format", "doc-format"], "excludes": ["format", "export-format", "mime-type"], "commands": ["doc +create", "doc +update", "doc create", "doc update"], "risk": "green"},
|
||||
"doc_edit_revision": {"denotes": "single optimistic-concurrency revision for a document edit", "canonical_hint": "revision", "members": ["revision", "expected-revision"], "excludes": ["version", "version-number", "version-no"], "commands": ["doc +update", "doc update"], "risk": "yellow"}
|
||||
"doc_edit_revision": {"denotes": "single optimistic-concurrency revision for a document edit", "canonical_hint": "revision", "members": ["revision", "expected-revision"], "excludes": ["version", "version-number", "version-no"], "commands": ["doc +update", "doc update"], "risk": "yellow"},
|
||||
"drive_recycle_item_id": {"denotes": "single Drive recycle-bin item ID returned by recycle list", "canonical_hint": "id", "members": ["id", "recycle-item-id", "trash-item-id", "deleted-item-id"], "excludes": ["node", "node-id", "file-id", "folder-id", "space-id", "workspace-id"], "commands": ["drive +recycle-restore", "drive recycle restore"], "risk": "yellow"},
|
||||
"drive_modified_time_start": {"denotes": "Drive search modified-time lower bound in unchanged millisecond timestamp unit", "canonical_hint": "modified-from", "members": ["modified-from", "modified-after", "modify-time-from", "modified-time-start"], "excludes": ["modified-to", "created-from", "created-to", "start", "from"], "commands": ["drive +search", "drive search"], "risk": "yellow"},
|
||||
"drive_modified_time_end": {"denotes": "Drive search modified-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "modified-to", "members": ["modified-to", "modified-before", "modify-time-to", "modified-time-end"], "excludes": ["modified-from", "created-from", "created-to", "end", "to"], "commands": ["drive +search", "drive search"], "risk": "yellow"},
|
||||
"drive_file_size_bytes": {"denotes": "file size in bytes passed unchanged", "canonical_hint": "file-size", "members": ["file-size", "file-size-bytes", "size-bytes", "content-length"], "excludes": ["part-size", "page-size", "limit", "size"], "commands": ["drive commit", "drive upload-info"], "risk": "yellow"},
|
||||
"drive_sort_direction": {"denotes": "Drive result sort direction with unchanged asc/desc enum", "canonical_hint": "order", "members": ["order", "sort", "sort-direction", "order-direction"], "excludes": ["order-by", "sort-by", "order-field"], "commands": ["drive +list", "drive list", "drive star list"], "risk": "green"},
|
||||
"workspace_id": {"denotes": "single knowledge-base or document-space workspace ID/URL passed unchanged; never a numeric DingDrive storage space ID", "canonical_hint": "workspace", "members": ["workspace", "workspace-id", "knowledge-base-id", "wiki-workspace-id"], "excludes": ["space", "space-id", "drive-space-id", "storage-space-id", "dingdrive-space-id", "folder", "folder-id", "node", "node-id", "dentry-id"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive +copy", "drive +create-shortcut", "drive +move", "drive copy", "drive list", "drive move", "drive permission add", "drive permission list", "drive permission remove", "drive permission transfer-owner", "drive permission update", "drive shortcut", "drive upload"], "risk": "yellow"},
|
||||
"created_time_start": {"denotes": "Doc/Drive search created-time lower bound in unchanged millisecond timestamp unit", "canonical_hint": "created-from", "members": ["created-from", "created-after", "create-time-from", "created-time-start", "create-time-start"], "excludes": ["created-to", "modified-from", "modified-to", "start", "from"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"},
|
||||
"created_time_end": {"denotes": "Doc/Drive search created-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "created-to", "members": ["created-to", "created-before", "create-time-to", "created-time-end", "create-time-end"], "excludes": ["created-from", "modified-from", "modified-to", "end", "to"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"},
|
||||
"document_permission_role": {"denotes": "direct document-space permission role on grant/apply/update, passed unchanged", "canonical_hint": "role", "members": ["role", "permission-role", "access-role", "member-role"], "excludes": ["filter-role", "reserve-role", "permission", "public-permission"], "commands": ["doc +access-change", "doc +access-grant", "doc +grant-and-share", "drive permission add", "drive permission apply", "drive permission update"], "risk": "yellow"},
|
||||
"creator_user_ids": {"denotes": "creator userId list used to filter Doc/Drive search results, passed unchanged", "canonical_hint": "creator-uids", "members": ["creator-uids", "creator-user-ids", "creator-ids", "created-by-user-ids"], "excludes": ["user", "user-id", "user-ids", "users", "owner-id", "modifier-uids"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"},
|
||||
"local_output_path": {"denotes": "local destination file or directory path for a download/export result, passed unchanged", "canonical_hint": "output", "members": ["output", "output-path", "destination-path", "save-path"], "excludes": ["file", "file-path", "folder", "folder-id", "content-file"], "commands": ["doc +export", "doc +export-get", "doc +media-download", "doc +resource-download", "doc read", "drive +download", "drive +version-download", "drive download", "drive download-version"], "risk": "green"}
|
||||
},
|
||||
|
||||
"command_overrides": {
|
||||
"doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."},
|
||||
"doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."},
|
||||
"chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."},
|
||||
"chat group members": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."},
|
||||
"chat group members remove": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat message add-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat message add-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat mute": {"scoped_aliases": {"group": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id/--id/--chat remain unchanged; other reviewed openConversationId spellings reduce to --conversation-id."},
|
||||
"drive list": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"},
|
||||
"drive upload": {"ambiguous": ["space"], "note": "both --space-id and native compatibility --workspace-id/--workspace exist; bare --space cannot choose one"},
|
||||
"chat conversation-info": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat group-mute": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat group-mute-member": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat message add-emoji": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
|
||||
"chat message add-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
|
||||
"chat message list-mentions": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat message recall-by-bot": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
|
||||
"chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
|
||||
"chat message search": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat mute": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id and hidden compatibility --group/--id/--chat/--open-conversation-id remain unchanged; reviewed chat-id reduces to --conversation-id."},
|
||||
"drive list": {"ambiguous": ["root-id", "space"], "note": "Numeric --space-id and knowledge-base --workspace are distinct routes; bare --space/--root-id cannot select a domain or folder.", "scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}, "scope_strict": true, "block": ["dentry-id"]},
|
||||
"drive upload": {"ambiguous": ["destination-id", "space", "target-id"], "note": "Local file, display name, MIME, overwrite node, folder, storage space, and knowledge-base workspace remain distinct roles.", "scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "overwrite-node-id": "node", "target-folder-id": "folder", "target-workspace-id": "workspace", "source-file": "file", "content-type": "mime-type", "filename": "file-name", "name": "file-name", "display-name": "file-name", "upload-name": "file-name"}, "block": ["dentry-id", "document-url", "output-path"], "scope_strict": true},
|
||||
"ding +receiver-status": {"scoped_aliases": {"id": "ding-id"}, "note": "generic id reduces to ding-id"},
|
||||
"ding message receiver-status": {"scoped_aliases": {"id": "ding-id"}},
|
||||
"contact user profile get": {"scoped_aliases": {"id": "staff-id", "ids": "staff-id"}, "note": "user-id is reduced by the user_id concept; generic id/ids bound explicitly"},
|
||||
@@ -73,12 +90,12 @@
|
||||
"chat +unread-chats": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."},
|
||||
"chat message list-unread-conversations": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."},
|
||||
"chat +messages-list-direct": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."},
|
||||
"chat message list": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."},
|
||||
"chat message list": {"scoped_aliases": {"start": "time"}, "block": ["end"], "ambiguous": ["chat-id", "open-conversation-id"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat message list-by-sender": {"scoped_aliases": {"user-id": "sender-user-id", "open-dingtalk-id": "sender-open-dingtalk-id"}, "block": ["time"], "scope_strict": true, "note": "Only same-role sender identifiers are mapped; --time cannot supply the required RFC3339 start/end range."},
|
||||
"contact +resolve-dept": {"bind": {"name": "search_query"}, "note": "The real --name is a department-name search keyword and carries the search_query concept on this shortcut."},
|
||||
"contact +list-sub-depts": {"block": ["name", "query"], "note": "--dept is an integer department id; names and search queries require a separate resolution command"},
|
||||
"contact +dept-members": {"bind": {"dept": "search_query"}, "scoped_aliases": {"name": "dept"}, "note": "The real --dept is a department-name search keyword; search spellings come from search_query, while --name remains command-scoped."},
|
||||
"chat message send": {"scoped_aliases": {"to-user": "user", "file": "file-path"}, "note": "Recipient and local-file-path aliases are exact to this command; obsolete file metadata flags remain unsupported."},
|
||||
"chat message send": {"scoped_aliases": {"to-user": "user", "file": "file-path"}, "ambiguous": ["chat-id", "open-conversation-id"], "note": "Recipient and local-file-path aliases are exact to this command; obsolete file metadata flags remain unsupported. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat +group-members": {"bind": {"group": "group_name"}, "note": "The real --group is a group-name search keyword on this shortcut, not an identifier."},
|
||||
"chat +category-create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact shortcut."},
|
||||
"chat category create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact command."},
|
||||
@@ -87,8 +104,8 @@
|
||||
"chat +category-delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
|
||||
"chat category delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
|
||||
"chat category list-conversations": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
|
||||
"chat category add-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."},
|
||||
"chat category remove-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."},
|
||||
"chat category add-conv": {"block": ["category-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat category remove-conv": {"block": ["category-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat +chat-role-update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
|
||||
"chat group-role remove": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
|
||||
"chat group-role update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
|
||||
@@ -98,12 +115,12 @@
|
||||
"chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."},
|
||||
"chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."},
|
||||
"doc block insert": {"block": ["before-block-id"], "note": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true},
|
||||
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."},
|
||||
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids", "chat-id", "open-conversation-id"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"doc +export-get": {"block": ["doc-id", "document-id", "file-id", "node", "node-id", "task-id", "url"], "note": "This command queries one export jobId. Document node identifiers and import taskId spellings are different entities and are rejected.", "scoped_aliases": {"export-job-id": "job-id"}, "scope_strict": true},
|
||||
"doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"},
|
||||
"doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +list": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +move": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."},
|
||||
"doc +list": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."},
|
||||
"doc +move": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."},
|
||||
"doc comment create": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
"doc comment reply": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
"doc comment update": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
@@ -114,7 +131,7 @@
|
||||
"doc export get": {"scoped_aliases": {"export-job-id": "job-id"}, "block": ["doc-id", "document-id", "file-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one export jobId. Document node identifiers are rejected; native hidden --task-id remains the command's reviewed add-only compatibility alias for --job-id."},
|
||||
"doc import get": {"scoped_aliases": {"import-task-id": "task-id"}, "block": ["doc-id", "document-id", "file-id", "job-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one import taskId. Document node identifiers and export jobId spellings are different entities and are rejected."},
|
||||
"doc +share-doc": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a document nodeId into a URL, so identifier spellings are rejected with guidance to provide --url."},
|
||||
"doc update": {"block": ["version", "version-no", "version-number"], "note": "--revision is an optimistic-concurrency revision, not a historical document version number. Version spellings must not reduce to --revision."},
|
||||
"doc update": {"block": ["stdin", "version", "version-no", "version-number"], "note": "--revision is an optimistic-concurrency revision, not a historical document version number. Version spellings must not reduce to --revision. --stdin is not a real switch: stdin input is expressed as --content -, which requires a value-form transformation outside central name aliases."},
|
||||
"report outbox list": {"block": ["template-type"], "note": "type vs name are different fields"},
|
||||
"chat group members add-bot": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat group members list-by-ids": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["user-id", "user-ids"], "note": "This command's --id carries openConversationId, while --users carries an openDingTalkId list."},
|
||||
@@ -131,7 +148,7 @@
|
||||
"chat category create-smart": {"bind": {"members": "open_dingtalk_ids"}, "scoped_aliases": {"title": "name"}, "note": "The real --members is an openDingTalkId list; the reviewed title/name alias is exact to the category display name."},
|
||||
"chat group audit-join-validation": {"ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "note": "A role-free user identifier cannot choose between the required --applicant and --inviter roles."},
|
||||
"chat message reply": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The required --ref-sender is a role-specific openDingTalkId and must not accept generic userId spellings."},
|
||||
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."},
|
||||
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat +category-add-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
|
||||
"chat +category-list-conversations": {"block": ["category-ids"], "note": "The real --category-id is singular; list cardinality is not reduced automatically."},
|
||||
"chat +category-remove-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
|
||||
@@ -158,21 +175,86 @@
|
||||
"chat +messages-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values, not openMessageId values."},
|
||||
"chat +messages-reply": {"scoped_aliases": {"msg-id": "ref-msg-id", "open-message-id": "ref-msg-id"}, "block": ["group", "msg-ids", "message-ids", "open-message-ids"], "scope_strict": true, "note": "The observed --group spelling carried a natural group name and is blocked. The only message role is the referenced message; plural IDs are not accepted, while --chat remains a CID alias and native --message-id stays native."},
|
||||
"chat +messages-resource-download": {"block": ["download-dir"], "note": "--output may be a file or directory under workspace safety rules; a download directory cannot be assumed equivalent."},
|
||||
"doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["content-file", "parent-id"], "scope_strict": true, "note": "--content-format is value-preservingly normalized to --doc-format. A raw --content-file path cannot become --content without adding the required @file transform, so it is blocked with guidance to use @relative-path or stable doc create."},
|
||||
"doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "scope_strict": true, "note": "--mode append/overwrite is the same operation selector subset as --command and preserves its value. --content-file is blocked because +update requires @relative-path or stdin and central aliases cannot read/transform a file value."},
|
||||
"doc +inspect": {"scoped_aliases": {"include-versions": "include-history"}, "block": ["include", "include-info"], "scope_strict": true, "note": "Historical versions and history are the same optional section on this exact shortcut. Generic --include needs value-dependent flag expansion, while base document info is always returned, so those spellings are rejected with precise guidance."},
|
||||
"doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary and must stop before execution."},
|
||||
"doc +access-change": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
|
||||
"doc +access-grant": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "block": ["target-user-id", "target-user-ids", "user-id", "user-ids"], "ambiguous": ["target-user", "user", "users"], "scope_strict": true, "note": "The real --to accepts collaborator names and resolves them before granting access. Explicit ID spellings cannot be passed through unchanged, while role-free user spellings do not prove whether their values are names or IDs. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
|
||||
"doc +access-revoke": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
|
||||
"doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["content-file", "parent-id"], "scope_strict": true, "note": "--content-format is value-preservingly normalized to --doc-format. A raw --content-file path cannot become --content without adding the required @file transform, so it is blocked with guidance to use @relative-path or stable doc create. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
|
||||
"doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
|
||||
"doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
|
||||
"doc create": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
|
||||
"doc file create": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
|
||||
"doc import": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
|
||||
"doc template apply": {"scoped_aliases": {"space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "Command-scoped compatibility published before the workspace/storage-space concept split: these spellings keep passing their value unchanged to this Doc command's --workspace flag and do not establish global equivalence with a numeric DingDrive storage space."},
|
||||
"doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "ambiguous": ["element"], "scope_strict": true, "note": "--mode append/overwrite is the same operation selector subset as --command and preserves its value. --content-file is blocked because +update requires @relative-path or stdin and central aliases cannot read/transform a file value. --element cannot choose between document content, a block target, or an insertion reference."},
|
||||
"doc +inspect": {"scoped_aliases": {"include-access": "include-permissions", "include-member": "include-permissions", "include-members": "include-permissions", "include-versions": "include-history"}, "block": ["include", "include-blocks", "include-content", "include-info", "include-meta", "include-metadata"], "scope_strict": true, "note": "Access/member spellings denote the same optional permission list, and versions/history denote the same history section. Generic --include needs value-dependent expansion; base metadata is already returned; block/content reads belong to +fetch, so those spellings stop before fuzzy correction or dispatch."},
|
||||
"doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "block": ["content-format", "doc-format", "range"], "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary. --range is an invented composite argument observed alongside --scope range and cannot be split into block IDs by name-only normalization; content-format spellings do not map to the independent --detail contract."},
|
||||
"doc +export": {"block": ["wait"], "scope_strict": true, "note": "The shortcut already submits, polls, and downloads in one workflow. A generic --wait value cannot be converted into the distinct integer --max-polls contract by parameter-name normalization."},
|
||||
"doc +media-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose between document and media identities."},
|
||||
"doc +media-insert": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and local-media roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
|
||||
"doc +media-insert": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "block": ["after-block-id", "before-block-id"], "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and local-media roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role. Before/after block spellings each require expansion into both --ref-block and --where, which name-only normalization cannot perform."},
|
||||
"doc +media-list": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "The command lists media inside one document, so only strong document spellings map to --node. File and URL spellings remain role-ambiguous."},
|
||||
"doc +media-preview": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
|
||||
"doc +resource-delete": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command removes a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
|
||||
"doc +resource-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command downloads a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
|
||||
"doc +resource-update": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has document-node, local-file, and HTTPS image URL roles. Only strong document spellings map to --node; --file-id and --url must stop as ambiguous."},
|
||||
"doc +share": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a node identifier into a URL, so identifier spellings are rejected with guidance to provide --url."},
|
||||
"doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native."}
|
||||
"doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "space": "workspace", "space-id": "workspace"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native. The command-scoped --space/--space-id aliases preserve previously published Doc workspace compatibility without making workspace and numeric DingDrive storage-space values globally equivalent."},
|
||||
"doc +version-save": {"block": ["message", "title"], "scope_strict": true, "note": "The current snapshot API accepts only the document target. Version title/message metadata are unsupported and cannot be represented by another existing flag."},
|
||||
"drive copy": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]},
|
||||
"drive cover": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive download-version": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "chunk-size": "part-size", "concurrency": "parallel", "parallelism": "parallel"}, "scope_strict": true, "note": "Output path, chunk size, and concurrency names preserve values; local input and remote folder roles remain blocked.", "block": ["dentry-id", "file", "file-path", "folder", "folder-id"]},
|
||||
"drive move": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]},
|
||||
"drive permission add": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]},
|
||||
"drive permission apply": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "approver-user-ids": "users", "approver-ids": "users", "target-node-id": "node", "apply-reason": "reason", "notification-mode": "notify-mode"}, "scope_strict": true, "note": "The user list denotes approvers, not target collaborators; values and notification enum are passed unchanged.", "block": ["dentry-id"]},
|
||||
"drive permission apply-info": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive permission list": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "role-filter": "filter-role", "permission-role-filter": "filter-role", "target-node-id": "node"}, "scope_strict": true, "note": "Filtering by a role is not the same as granting/updating a role.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "permission", "role", "space-id", "storage-space-id"]},
|
||||
"drive permission remove": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]},
|
||||
"drive permission transfer-owner": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "target-node-id": "node", "target-workspace-id": "workspace", "old-owner-role": "reserve-role", "keep-role": "reserve-role", "new-owner-id": "new-owner", "new-owner-user-id": "new-owner"}, "scope_strict": true, "note": "Node/workspace target and new/old owner roles are distinct on this irreversible command; role-free names stop before dispatch.", "block": ["current-owner", "dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id", "user-ids", "users"], "ambiguous": ["owner", "owner-user-id", "target-id", "user-id"]},
|
||||
"drive permission update": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "member-user-ids": "users", "collaborator-ids": "users", "target-user-ids": "users", "target-node-id": "node"}, "scope_strict": true, "note": "The user list denotes target collaborators on this exact node permission command; the native singular compatibility spellings remain native.", "block": ["dentry-id", "dingdrive-space-id", "drive-space-id", "space-id", "storage-space-id"]},
|
||||
"drive publish get": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive publish set": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "public-permission": "permission", "public-role": "permission"}, "scope_strict": true, "note": "Internet-public permission is not the same as a direct collaborator role.", "block": ["access-role", "dentry-id", "permission-role", "role"]},
|
||||
"drive publish unset": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive rename": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "file-name": "name", "display-name": "name", "new-name": "name"}, "scope_strict": true, "note": "The name is this exact folder/node display name; search query and local path are different roles.", "block": ["dentry-id"]},
|
||||
"drive revert": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive shortcut": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "ambiguous": ["destination-id", "target-id"]},
|
||||
"drive star add": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive star remove": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive stats": {"scoped_aliases": {"document-id": "node", "dentry-uuid": "node"}, "scope_strict": true, "note": "The command accepts a Drive node ID or URL; native file-id/node-id/doc-id/url aliases stay native and are not duplicated centrally.", "block": ["dentry-id"]},
|
||||
"drive +cover": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "This shortcut calls the same get_cover nodeId interface as drive cover, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId, folder-role spellings and --name remain protected."},
|
||||
"drive +copy": {"scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "document-url", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "ambiguous": ["destination-id", "target-id"]},
|
||||
"drive +create-folder": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "parent-folder-id": "folder", "folder-name": "name", "display-name": "name", "new-name": "name"}, "block": ["dentry-id", "parent-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "The new shortcut creates a numeric DingDrive-space folder. Folder display name, parent dentryUuid and numeric storage space are separate roles; knowledge-base workspace spellings are not accepted."},
|
||||
"drive +create-shortcut": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "doc", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles. The source ID/URL aliases match drive shortcut and pass through unchanged; generic target/id spellings remain ambiguous, and storage-space IDs are not knowledge-base workspace IDs.", "ambiguous": ["destination-id", "id", "target-id"]},
|
||||
"drive +delete": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "folder": "node", "folder-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "id", "name", "url"], "scope_strict": true, "note": "Delete targets one already confirmed Drive file or folder dentryUuid. Folder spellings are the same single target role; numeric dentryId, unreviewed Doc URL spellings and --name stop before confirmation or write dispatch."},
|
||||
"drive +download": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "file", "file-path", "folder", "folder-id", "id", "knowledge-base-id", "name", "url", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "The remote ordinary-file node and local output path are different roles. Node-ID spelling is accepted, while Doc URLs, local input paths, folders, knowledge-base workspaces and numeric dentryId values are not interchangeable."},
|
||||
"drive +info": {"scoped_aliases": {"dentry-uuid": "node"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target; knowledge-base workspace spellings are a different value domain."},
|
||||
"drive +inspect": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "folder": "node", "folder-id": "node", "include-statistics": "include-stats", "include-publish-status": "include-publish", "include-public-status": "include-publish", "include-thumbnail": "include-cover"}, "block": ["dentry-id", "doc", "document-url", "id", "include", "include-content", "include-history", "include-permissions", "name", "url"], "scope_strict": true, "note": "Drive inspect accepts one file, folder or document node ID and can aggregate only stats, public-publish status and cover. URL spellings, Doc inspect section names and a generic --include remain protected because this shortcut does not declare URL input or value splitting."},
|
||||
"drive +list": {"ambiguous": ["root-id", "space"], "scoped_aliases": {"directory-id": "folder", "parent-folder-id": "folder", "order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}, "block": ["dentry-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This shortcut accepts one numeric DingDrive space and an optional parent dentryUuid; it does not accept a knowledge-base workspace. Sort field and direction remain separate roles."},
|
||||
"drive +move": {"scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "source-node-id": "node", "source-file-id": "node", "target-folder-id": "folder", "destination-folder-id": "folder", "target-workspace-id": "workspace", "destination-workspace-id": "workspace"}, "block": ["dentry-id", "destination-node-id", "dingdrive-space-id", "document-url", "drive-space-id", "name", "source-folder-id", "space-id", "storage-space-id"], "scope_strict": true, "note": "Source node and destination folder/workspace are different roles; role-free target IDs stop before write dispatch. --name is not a rename field on this command and must not be fuzzy-corrected to --node.", "ambiguous": ["destination-id", "target-id"]},
|
||||
"drive +publish-get": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same publication-status fileId interface as drive publish get, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."},
|
||||
"drive +publish-unset": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same set_file_publish fileId interface as drive publish unset, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected before confirmation."},
|
||||
"drive +recycle-restore": {"bind": {"id": "drive_recycle_item_id"}, "block": ["file-id", "folder-id", "node", "node-id", "space-id", "workspace-id"], "scope_strict": true, "note": "The real --id is a recycleItemId returned by drive +recycle-list, not a normal Drive node ID."},
|
||||
"drive +rename": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node", "folder": "node", "folder-id": "node", "file-name": "name", "display-name": "name", "new-name": "name"}, "block": ["dentry-id"], "scope_strict": true, "note": "The existing document, file or folder node and the requested new display name are separate required roles. Node ID/URL aliases match drive rename and pass through unchanged; numeric dentryId remains protected."},
|
||||
"drive delete": {"scoped_aliases": {"dentry-uuid": "node"}, "block": ["dentry-id", "document-url"], "scope_strict": true, "note": "This command publicly accepts an ID-only Drive node; dentry spellings preserve the value and URL-specific spellings remain protected."},
|
||||
"drive download": {"scoped_aliases": {"dentry-uuid": "node", "chunk-size": "part-size", "concurrency": "parallel", "parallelism": "parallel"}, "block": ["dentry-id", "document-url", "file", "file-path", "folder", "folder-id", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "Output path, chunk size, and concurrency names preserve values; local input and remote folder roles remain blocked."},
|
||||
"drive info": {"scoped_aliases": {"dentry-uuid": "node", "space": "space-id", "workspace": "space-id", "workspace-id": "space-id"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target. Its command-scoped --space/--workspace/--workspace-id aliases preserve compatibility published before the workspace/storage-space concept split and pass the value unchanged to --space-id; new commands must not infer that knowledge-base workspace IDs and numeric storage-space IDs are globally interchangeable."},
|
||||
"drive commit": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "upload-session-id": "upload-id", "size-bytes": "file-size", "name": "file-name", "display-name": "file-name", "filename": "file-name", "upload-name": "file-name"}, "scope_strict": true, "note": "Upload session, file name, file size in bytes, parent folder, and storage space remain separate roles.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]},
|
||||
"drive mkdir": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "folder-name": "name", "display-name": "name", "new-name": "name"}, "scope_strict": true, "note": "The name is this exact folder/node display name; search query and local path are different roles.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]},
|
||||
"drive upload-info": {"scoped_aliases": {"directory-id": "folder", "parent-directory-id": "folder", "content-type": "mime-type", "size-bytes": "file-size", "name": "file-name", "display-name": "file-name", "filename": "file-name", "upload-name": "file-name"}, "scope_strict": true, "note": "File metadata aliases preserve MIME and byte units; file path and upload session are different stages.", "block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"]},
|
||||
"drive recycle list": {"block": ["knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id"], "scope_strict": true, "note": "This command has only a numeric DingDrive space target; knowledge-base workspace spellings are a different value domain."},
|
||||
"drive search": {"ambiguous": ["end", "from", "start", "time-from", "time-to", "to", "types"], "block": ["offset", "page"], "scope_strict": true, "note": "Created/modified ranges and file/content type arrays are separate roles; generic time/type names are not guessed."},
|
||||
"drive +search": {"ambiguous": ["end", "from", "start", "time-from", "time-to", "to", "types"], "block": ["offset", "page"], "scope_strict": true, "note": "Created/modified ranges and file/content type arrays are separate roles; generic time/type names are not guessed."},
|
||||
"drive +star-add": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same mark_star nodeId interface as drive star add, so its reviewed document/node ID and URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."},
|
||||
"drive +star-remove": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same unmark_star nodeId interface as drive star remove, so its reviewed document/node ID and URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."},
|
||||
"drive +stats": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "doc-id": "node", "document-id": "node", "url": "node", "document-url": "node", "id": "node"}, "block": ["dentry-id", "doc", "folder", "folder-id", "name"], "scope_strict": true, "note": "The shortcut calls the same get_node_stats nodeId interface as drive stats, so its reviewed ID/URL aliases are preserved unchanged. Numeric dentryId and folder/name roles remain protected."},
|
||||
"drive +version-download": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "file", "file-path", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "Ordinary-file node, positive historical version number and local output path are three distinct roles; revision and Doc URL spellings must not be guessed."},
|
||||
"drive +version-get": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "This command reads one ordinary-file historical version by node ID and positive version number; document revision and URL roles are different."},
|
||||
"drive +version-history": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "This command pages versions for one ordinary-file Drive node; document URLs and numeric dentryId are not accepted."},
|
||||
"drive +version-revert": {"scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node"}, "block": ["dentry-id", "doc", "doc-id", "document-id", "document-url", "folder", "folder-id", "id", "name", "url"], "scope_strict": true, "note": "The high-risk revert targets one ordinary-file Drive node and a positive historical version number. Doc revisions and URLs must stop before confirmation or write dispatch."},
|
||||
"drive recycle restore": {"bind": {"id": "drive_recycle_item_id"}, "block": ["file-id", "folder-id", "node", "node-id", "space-id", "workspace-id"], "scope_strict": true, "note": "The real --id is a recycle-item ID from recycle list, not a normal Drive node ID."},
|
||||
"drive star list": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Content types and resource types are separate arrays; a generic type list cannot choose one.", "scoped_aliases": {"order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}},
|
||||
"drive recent": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Creator type, operation type, and file type filters are distinct and keep their enum/list forms."},
|
||||
"drive +recent": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "Creator type, operation type, and file type filters are distinct and keep their enum/list forms."},
|
||||
"drive +star-list": {"ambiguous": ["type", "types"], "scope_strict": true, "note": "The shortcut exposes only the API contentTypes filter. Generic type spellings may denote file extensions or node/resource types, so the current name-only layer must not guess the value domain."},
|
||||
"drive +upload": {"ambiguous": ["destination-id", "space", "target-id"], "scoped_aliases": {"dentry-uuid": "node", "file-id": "node", "node-id": "node", "overwrite-node-id": "node", "directory-id": "folder", "parent-directory-id": "folder", "parent-folder-id": "folder", "target-folder-id": "folder", "source-file": "file", "local-file": "file", "file-path": "file", "content-type": "mime-type", "filename": "file-name", "name": "file-name", "display-name": "file-name", "upload-name": "file-name"}, "block": ["dentry-id", "document-url", "knowledge-base-id", "wiki-workspace-id", "workspace", "workspace-id", "output-path"], "scope_strict": true, "note": "Local source path, remote display name, MIME type, parent folder, numeric storage space and optional overwrite node are distinct roles. ID-suffixed file/node spellings denote the overwrite target; the shortcut does not expose a knowledge-base workspace route."}
|
||||
},
|
||||
|
||||
"validation_fixture": {
|
||||
@@ -243,7 +325,7 @@
|
||||
{"command": "doc +export-get", "emitted": "node", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
|
||||
{"command": "doc block delete", "emitted": "index", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
|
||||
{"command": "doc block insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-multi-parameter-transform", "occ": 2},
|
||||
{"command": "drive info", "emitted": "workspace", "expect": "space-id", "via": "concept:space_id", "occ": 2},
|
||||
{"command": "drive info", "emitted": "workspace", "expect": "space-id", "via": "override:published-storage-space-compatibility", "occ": 2},
|
||||
{"command": "mail folder update", "emitted": "folder-id", "expect": "id", "via": "override:bind(folder_id)", "occ": 2},
|
||||
{"command": "report outbox list", "emitted": "template-type", "expect": "did-you-mean:blocked", "via": "override:block", "occ": 2},
|
||||
{"command": "chat +group-members", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"},
|
||||
@@ -252,7 +334,7 @@
|
||||
{"command": "chat group rename", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat group rename", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"},
|
||||
{"command": "chat message send", "emitted": "conversation-id", "expect": "group", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat message add-emoji", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "override:scoped"},
|
||||
{"command": "chat message add-emoji", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat message add-emoji", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"},
|
||||
{"command": "chat +group-members", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
|
||||
{"command": "chat +send-to-group", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"},
|
||||
@@ -325,7 +407,7 @@
|
||||
{"command": "doc +search", "emitted": "q", "expect": "query", "via": "concept:search_query"},
|
||||
{"command": "doc +comment-list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size"},
|
||||
{"command": "doc +list", "emitted": "page-token", "expect": "cursor", "via": "concept:page_cursor"},
|
||||
{"command": "doc +copy", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
|
||||
{"command": "doc +copy", "emitted": "workspace-id", "expect": "workspace", "via": "concept:workspace_id"},
|
||||
{"command": "doc +copy", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
|
||||
{"command": "doc +copy", "emitted": "parent-id", "expect": "did-you-mean:blocked", "via": "guard:doc-folder-value-domain"},
|
||||
{"command": "doc +comment-reply", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
|
||||
@@ -390,6 +472,7 @@
|
||||
{"command": "chat +flag-create", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +chat-add-bot", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat +chat-add-bot", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code"},
|
||||
{"command": "chat group audit-join-validation", "emitted": "user", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"},
|
||||
{"command": "chat +chat-audit-join", "emitted": "applicant-user-id", "expect": "applicant", "via": "override:scoped-user-role"},
|
||||
{"command": "chat +chat-audit-join", "emitted": "user-id", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"},
|
||||
{"command": "chat +chat-create", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
@@ -415,9 +498,9 @@
|
||||
{"command": "doc +create", "emitted": "content-format", "expect": "doc-format", "via": "concept:doc_content_format"},
|
||||
{"command": "doc +create", "emitted": "content-file", "expect": "did-you-mean:blocked", "via": "guard:requires-file-read-transform"},
|
||||
{"command": "doc +inspect", "emitted": "include-versions", "expect": "include-history", "via": "override:scoped-section"},
|
||||
{"command": "doc +inspect", "emitted": "include", "expect": "did-you-mean:blocked", "via": "guard:requires-value-dependent-flag-expansion"},
|
||||
{"command": "doc +inspect", "emitted": "include-info", "expect": "did-you-mean:blocked", "via": "guard:base-info-always-returned"},
|
||||
{"command": "doc +update", "emitted": "mode", "expect": "command", "via": "override:scoped-operation"},
|
||||
{"command": "doc +inspect", "emitted": "include", "expect": "did-you-mean:blocked", "via": "guard:requires-value-dependent-flag-expansion", "occ": 1},
|
||||
{"command": "doc +inspect", "emitted": "include-info", "expect": "did-you-mean:blocked", "via": "guard:base-info-always-returned", "occ": 1},
|
||||
{"command": "doc +update", "emitted": "mode", "expect": "command", "via": "override:scoped-operation", "occ": 5},
|
||||
{"command": "doc +update", "emitted": "revision", "expect": "expected-revision", "via": "concept:doc_edit_revision"},
|
||||
{"command": "doc +update", "emitted": "version", "expect": "did-you-mean:blocked", "via": "guard:historical-version-vs-edit-revision"},
|
||||
{"command": "doc +fetch", "emitted": "start-block", "expect": "start-block-id", "via": "override:scoped-boundary-role"},
|
||||
@@ -425,11 +508,154 @@
|
||||
{"command": "doc +access-grant", "emitted": "doc-id", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc +history-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "doc +create-from-template", "emitted": "keyword", "expect": "query", "via": "concept:search_query"},
|
||||
{"command": "doc +create-from-template", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
|
||||
{"command": "doc +create-from-template", "emitted": "workspace-id", "expect": "workspace", "via": "concept:workspace_id"},
|
||||
{"command": "doc +create-from-template", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
|
||||
{"command": "doc +media-download", "emitted": "file-id", "expect": "did-you-mean:ambiguous", "via": "guard:document-node-vs-attachment-resource-role"},
|
||||
{"command": "doc +resource-update", "emitted": "url", "expect": "did-you-mean:ambiguous", "via": "guard:document-url-vs-image-url-role"},
|
||||
{"command": "doc +share", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"}
|
||||
{"command": "doc +share", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"},
|
||||
{"command": "doc +copy", "emitted": "dentry-uuid", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc info", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-dentry-uuid"},
|
||||
{"command": "doc create", "emitted": "knowledge-base-id", "expect": "workspace", "via": "concept:workspace_id"},
|
||||
{"command": "doc create", "emitted": "space-id", "expect": "workspace", "via": "override:published-workspace-compatibility"},
|
||||
{"command": "drive list", "emitted": "knowledge-base-id", "expect": "workspace", "via": "concept:workspace_id"},
|
||||
{"command": "drive list", "emitted": "order-field", "expect": "order-by", "via": "override:scoped-sort-field"},
|
||||
{"command": "drive info", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-node-id"},
|
||||
{"command": "drive info", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-dentry-uuid"},
|
||||
{"command": "drive copy", "emitted": "target-folder-id", "expect": "folder", "via": "override:scoped-destination-folder"},
|
||||
{"command": "drive copy", "emitted": "target-id", "expect": "did-you-mean:ambiguous", "via": "guard:destination-role-required"},
|
||||
{"command": "drive search", "emitted": "created-after", "expect": "created-from", "via": "concept:created_time_start"},
|
||||
{"command": "drive search", "emitted": "created-before", "expect": "created-to", "via": "concept:created_time_end"},
|
||||
{"command": "drive search", "emitted": "modified-after", "expect": "modified-from", "via": "concept:drive_modified_time_start"},
|
||||
{"command": "drive search", "emitted": "modified-before", "expect": "modified-to", "via": "concept:drive_modified_time_end"},
|
||||
{"command": "drive search", "emitted": "creator-user-ids", "expect": "creator-uids", "via": "concept:creator_user_ids"},
|
||||
{"command": "drive permission add", "emitted": "permission-role", "expect": "role", "via": "concept:document_permission_role"},
|
||||
{"command": "drive permission list", "emitted": "role", "expect": "did-you-mean:blocked", "via": "guard:permission-role-vs-filter-role"},
|
||||
{"command": "drive upload", "emitted": "source-file", "expect": "file", "via": "override:scoped-local-file"},
|
||||
{"command": "doc +search", "emitted": "created-after", "expect": "created-from", "via": "concept:created_time_start"},
|
||||
{"command": "doc +search", "emitted": "create-time-start", "expect": "created-from", "via": "concept:created_time_start", "occ": 1},
|
||||
{"command": "doc +search", "emitted": "create-time-end", "expect": "created-to", "via": "concept:created_time_end", "occ": 1},
|
||||
{"command": "doc +search", "emitted": "creator-user-ids", "expect": "creator-uids", "via": "concept:creator_user_ids"},
|
||||
{"command": "doc +access-grant", "emitted": "permission-role", "expect": "role", "via": "concept:document_permission_role"},
|
||||
{"command": "doc +export", "emitted": "output-path", "expect": "output", "via": "concept:local_output_path"},
|
||||
{"command": "drive download", "emitted": "destination-path", "expect": "output", "via": "concept:local_output_path"},
|
||||
{"command": "drive download", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "drive recycle restore", "emitted": "recycle-item-id", "expect": "id", "via": "concept:drive_recycle_item_id"},
|
||||
{"command": "drive upload-info", "emitted": "size-bytes", "expect": "file-size", "via": "concept:drive_file_size_bytes"},
|
||||
{"command": "drive +info", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive commit", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive download", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive info", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive list", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive list", "emitted": "sort-direction", "expect": "order", "via": "concept:drive_sort_direction"},
|
||||
{"command": "drive mkdir", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive recycle list", "emitted": "drive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive upload", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive upload-info", "emitted": "dingdrive-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "doc +access-grant", "emitted": "user", "expect": "did-you-mean:ambiguous", "via": "guard:collaborator-name-vs-id-value-domain", "occ": 6},
|
||||
{"command": "doc +access-grant", "emitted": "target-user", "expect": "did-you-mean:ambiguous", "via": "guard:collaborator-name-vs-id-value-domain", "occ": 1},
|
||||
{"command": "doc +access-grant", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver", "occ": 1},
|
||||
{"command": "doc +access-grant", "emitted": "user-ids", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver", "occ": 1},
|
||||
{"command": "doc +access-grant", "emitted": "target-user-id", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver"},
|
||||
{"command": "doc +access-grant", "emitted": "target-user-ids", "expect": "did-you-mean:blocked", "via": "guard:user-id-cannot-feed-name-resolver"},
|
||||
{"command": "doc +fetch", "emitted": "content-format", "expect": "did-you-mean:blocked", "via": "guard:content-format-vs-detail-contract", "occ": 3},
|
||||
{"command": "doc +fetch", "emitted": "doc-format", "expect": "did-you-mean:blocked", "via": "guard:content-format-vs-detail-contract", "occ": 1},
|
||||
{"command": "doc +fetch", "emitted": "range", "expect": "did-you-mean:blocked", "via": "guard:composite-range-needs-block-ids", "occ": 1},
|
||||
{"command": "doc +inspect", "emitted": "include-access", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 3},
|
||||
{"command": "doc +inspect", "emitted": "include-member", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 1},
|
||||
{"command": "doc +inspect", "emitted": "include-members", "expect": "include-permissions", "via": "override:scoped-permission-section", "occ": 2},
|
||||
{"command": "doc +inspect", "emitted": "include-meta", "expect": "did-you-mean:blocked", "via": "guard:base-metadata-already-returned", "occ": 2},
|
||||
{"command": "doc +inspect", "emitted": "include-metadata", "expect": "did-you-mean:blocked", "via": "guard:base-metadata-already-returned", "occ": 1},
|
||||
{"command": "doc +inspect", "emitted": "include-blocks", "expect": "did-you-mean:blocked", "via": "guard:content-read-belongs-to-fetch", "occ": 1},
|
||||
{"command": "doc +inspect", "emitted": "include-content", "expect": "did-you-mean:blocked", "via": "guard:content-read-belongs-to-fetch", "occ": 1},
|
||||
{"command": "doc +inspect", "emitted": "role", "expect": "did-you-mean:blocked", "via": "guard:permission-role-vs-document-node", "occ": 1},
|
||||
{"command": "doc +copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role", "occ": 2},
|
||||
{"command": "doc +export", "emitted": "wait", "expect": "did-you-mean:blocked", "via": "guard:workflow-wait-vs-max-polls", "occ": 1},
|
||||
{"command": "doc +media-insert", "emitted": "after-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-and-where-expansion", "occ": 1},
|
||||
{"command": "doc +media-insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-and-where-expansion"},
|
||||
{"command": "doc +update", "emitted": "content-file", "expect": "did-you-mean:blocked", "via": "guard:requires-file-read-transform", "occ": 1},
|
||||
{"command": "doc +update", "emitted": "element", "expect": "did-you-mean:ambiguous", "via": "guard:content-vs-block-vs-reference-role", "occ": 1},
|
||||
{"command": "doc update", "emitted": "stdin", "expect": "did-you-mean:blocked", "via": "guard:stdin-requires-content-dash-transform", "occ": 1},
|
||||
{"command": "doc +version-save", "emitted": "title", "expect": "did-you-mean:blocked", "via": "guard:unsupported-version-metadata", "occ": 1},
|
||||
{"command": "doc +version-save", "emitted": "message", "expect": "did-you-mean:blocked", "via": "guard:unsupported-version-metadata", "occ": 1},
|
||||
{"command": "drive +search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1},
|
||||
{"command": "contact +search-user", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:person-name-vs-search-query", "occ": 1},
|
||||
{"command": "drive copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
|
||||
{"command": "drive +copy", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
|
||||
{"command": "drive move", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
|
||||
{"command": "drive +move", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
|
||||
{"command": "drive shortcut", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-node-role"},
|
||||
{"command": "drive +cover", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +cover", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-drive-node"},
|
||||
{"command": "drive +create-folder", "emitted": "folder-name", "expect": "name", "via": "override:scoped-folder-name"},
|
||||
{"command": "drive +create-folder", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive +create-shortcut", "emitted": "source-file-id", "expect": "node", "via": "override:scoped-source-node"},
|
||||
{"command": "drive +create-shortcut", "emitted": "target-folder-id", "expect": "folder", "via": "override:scoped-target-folder"},
|
||||
{"command": "drive +create-shortcut", "emitted": "target-workspace-id", "expect": "workspace", "via": "override:scoped-target-workspace"},
|
||||
{"command": "drive +create-shortcut", "emitted": "name", "expect": "did-you-mean:blocked", "via": "guard:fuzzy-name-vs-drive-node"},
|
||||
{"command": "drive +delete", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +delete", "emitted": "dentry-id", "expect": "did-you-mean:blocked", "via": "guard:dentry-id-vs-node-id"},
|
||||
{"command": "drive +download", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +download", "emitted": "destination-path", "expect": "output", "via": "concept:local_output_path"},
|
||||
{"command": "drive +inspect", "emitted": "include-statistics", "expect": "include-stats", "via": "override:scoped-inspect-section"},
|
||||
{"command": "drive +inspect", "emitted": "include-history", "expect": "did-you-mean:blocked", "via": "guard:doc-inspect-section"},
|
||||
{"command": "drive +list", "emitted": "folder-id", "expect": "folder", "via": "concept:folder_id"},
|
||||
{"command": "drive +list", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"},
|
||||
{"command": "drive +list", "emitted": "next-token", "expect": "cursor", "via": "concept:page_cursor"},
|
||||
{"command": "drive +list", "emitted": "sort-direction", "expect": "order", "via": "concept:drive_sort_direction"},
|
||||
{"command": "drive +list", "emitted": "sort-by", "expect": "order-by", "via": "override:scoped-sort-field"},
|
||||
{"command": "drive +publish-get", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +publish-unset", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +recycle-list", "emitted": "storage-space-id", "expect": "space-id", "via": "concept:drive_storage_space_id"},
|
||||
{"command": "drive +recycle-list", "emitted": "page-token", "expect": "cursor", "via": "concept:page_cursor"},
|
||||
{"command": "drive +recycle-restore", "emitted": "recycle-item-id", "expect": "id", "via": "override:bind(drive_recycle_item_id)"},
|
||||
{"command": "drive +recycle-restore", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:recycle-item-vs-node-id"},
|
||||
{"command": "drive +rename", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +rename", "emitted": "new-name", "expect": "name", "via": "override:scoped-display-name"},
|
||||
{"command": "drive +star-add", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +star-list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size"},
|
||||
{"command": "drive +star-list", "emitted": "types", "expect": "did-you-mean:ambiguous", "via": "guard:content-type-value-domain"},
|
||||
{"command": "drive +star-remove", "emitted": "file-id", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +stats", "emitted": "dentry-uuid", "expect": "node", "via": "override:scoped-drive-node"},
|
||||
{"command": "drive +upload", "emitted": "source-file", "expect": "file", "via": "override:scoped-local-file"},
|
||||
{"command": "drive +upload", "emitted": "name", "expect": "file-name", "via": "override:scoped-remote-name"},
|
||||
{"command": "drive +upload", "emitted": "overwrite-node-id", "expect": "node", "via": "override:scoped-overwrite-node"},
|
||||
{"command": "drive +upload", "emitted": "workspace-id", "expect": "did-you-mean:blocked", "via": "guard:unsupported-workspace-route"},
|
||||
{"command": "drive +version-download", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "drive +version-download", "emitted": "save-path", "expect": "output", "via": "concept:local_output_path"},
|
||||
{"command": "drive +version-get", "emitted": "version-no", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "drive +version-history", "emitted": "next-cursor", "expect": "cursor", "via": "concept:page_cursor"},
|
||||
{"command": "drive +version-history", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"},
|
||||
{"command": "drive +version-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "drive +version-revert", "emitted": "revision", "expect": "did-you-mean:blocked", "via": "guard:document-revision-vs-file-version"},
|
||||
{"command": "drive +cover", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +create-shortcut", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +delete", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +download", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +inspect", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +publish-get", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +publish-unset", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +rename", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +star-add", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +star-remove", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +stats", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +upload", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +version-download", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +version-get", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +version-history", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +version-revert", "emitted": "node-id", "expect": "node", "via": "override:node-id-parity"},
|
||||
{"command": "drive +cover", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +create-shortcut", "emitted": "document-id", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +delete", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"},
|
||||
{"command": "drive +inspect", "emitted": "document-id", "expect": "node", "via": "override:document-node-id"},
|
||||
{"command": "drive +inspect", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"},
|
||||
{"command": "drive +publish-get", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +publish-unset", "emitted": "document-url", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +rename", "emitted": "document-id", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +rename", "emitted": "folder-id", "expect": "node", "via": "override:single-folder-node-role"},
|
||||
{"command": "drive +star-add", "emitted": "url", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +star-remove", "emitted": "doc-id", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +stats", "emitted": "document-url", "expect": "node", "via": "override:existing-command-parity"},
|
||||
{"command": "drive +upload", "emitted": "file-id", "expect": "node", "via": "override:overwrite-node-id-role"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1354,15 +1354,16 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
interfaceType string
|
||||
}{
|
||||
{"chat message edit", "conversation-id", "openConversationId", true, ""},
|
||||
{"chat message edit", "msg-id", "openMessageId", true, ""},
|
||||
{"chat message edit", "message-id", "openMessageId", true, ""},
|
||||
{"chat message edit", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
|
||||
{"chat message update-text-emotion", "message-id", "openMsgId", true, ""},
|
||||
{"chat message send", "idempotency-key", "uuid", false, ""},
|
||||
{"chat message send-card", "at-all", "atAll", false, ""},
|
||||
{"chat message send-card", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
|
||||
{"chat message update-text-emotion", "msg-id", "openMsgId", true, ""},
|
||||
{"chat message update-text-emotion", "old-emotion-id", "oldEmotionId", true, ""},
|
||||
{"chat category batch-info", "category-ids", "categoryIds", true, "array"},
|
||||
{"chat category list-by-conv", "group", "openConversationId", true, ""},
|
||||
{"chat group update-nick", "group", "openConversationId", true, ""},
|
||||
{"chat category list-by-conv", "conversation-id", "openConversationId", true, ""},
|
||||
{"chat group update-nick", "conversation-id", "", true, ""},
|
||||
{"chat group upgrade-to-external", "extension", "extension", false, "object"},
|
||||
{"chat +messages-send-card", "receiver-open-dingtalk-id", "receiverOpenDingTalkId", false, ""},
|
||||
{"chat message list-favorites", "size", "", false, "string"},
|
||||
@@ -1396,7 +1397,8 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden conversation aliases must stay unpublished (merge-base parity).
|
||||
// Manifest-covered migrations hide legacy aliases; manifest-external
|
||||
// commands keep their existing visible flags for compatibility.
|
||||
editLeaf, err := queryDeliverySchemaPayload([]string{"chat message edit"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1407,14 +1409,65 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
t.Fatalf("chat message edit unexpectedly publishes hidden alias --%s", hidden)
|
||||
}
|
||||
}
|
||||
sendLeaf, err = queryDeliverySchemaPayload([]string{"chat message send"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sendParams = schemaMap(sendLeaf["parameters"])
|
||||
if _, ok := sendParams["uuid"]; ok {
|
||||
t.Fatal("chat message send unexpectedly publishes hidden alias --uuid")
|
||||
}
|
||||
listByConv, err := queryDeliverySchemaPayload([]string{"chat category list-by-conv"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listParams := schemaMap(listByConv["parameters"])
|
||||
for _, hidden := range []string{"conversation-id", "id"} {
|
||||
if _, ok := listParams[hidden]; ok {
|
||||
t.Fatalf("chat category list-by-conv unexpectedly publishes hidden alias --%s", hidden)
|
||||
if _, ok := listParams["conversation-id"]; !ok {
|
||||
t.Fatalf("chat category list-by-conv missing public canonical --conversation-id")
|
||||
}
|
||||
if _, ok := listParams["group"]; !ok {
|
||||
t.Fatalf("chat category list-by-conv unexpectedly hides manifest-external --group")
|
||||
}
|
||||
if _, ok := listParams["id"]; ok {
|
||||
t.Fatalf("chat category list-by-conv unexpectedly publishes hidden alias --id")
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"chat message add-emoji",
|
||||
"chat message remove-emoji",
|
||||
"chat message add-text-emotion",
|
||||
"chat message remove-text-emotion",
|
||||
} {
|
||||
leaf, err := queryDeliverySchemaPayload([]string{path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
params := schemaMap(leaf["parameters"])
|
||||
if _, ok := params["conversation-id"]; !ok {
|
||||
t.Fatalf("%s missing public canonical --conversation-id", path)
|
||||
}
|
||||
for _, visible := range []string{"group", "id", "chat"} {
|
||||
if _, ok := params[visible]; !ok {
|
||||
t.Fatalf("%s unexpectedly hides manifest-external --%s", path, visible)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groupBots, err := queryDeliverySchemaPayload([]string{"chat group bots"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
groupBotsParams := schemaMap(groupBots["parameters"])
|
||||
group := groupBotsParams["group"]
|
||||
if group == nil {
|
||||
t.Fatal("chat group bots missing public legacy --group")
|
||||
}
|
||||
if group["property"] != "openConversationId" {
|
||||
t.Fatalf("chat group bots --group property = %#v, want openConversationId", group["property"])
|
||||
}
|
||||
for _, migrated := range []string{"conversation-id", "group-name"} {
|
||||
if _, ok := groupBotsParams[migrated]; ok {
|
||||
t.Fatalf("chat group bots unexpectedly publishes migrated --%s", migrated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,19 +43,14 @@ func init() {
|
||||
RequireOneOf: [][]string{{"conversation-id", "open-dingtalk-id", "user", "permParam"}},
|
||||
})
|
||||
registerExclusiveOneOf("chat.chat_permission_grant_cross_org_data", "target-org-id", "all")
|
||||
registerRequireOneOf("chat.add_emoji_reaction", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.add_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerExclusiveOneOf("chat.clear_conversation_messages", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.clear_conversation_red_point", "conversation-id", "id", "chat")
|
||||
registerRequireOneOf("chat.update_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerExclusiveOneOf("chat.get_conversation_info", "group", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.hide_conversation", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.list_conversation_message_v2", "group", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.list_individual_chat_message", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.mark_conversation_unread", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.mark_message_read", "conversation-id", "id", "chat")
|
||||
registerRequireOneOf("chat.remove_emoji_reaction", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.remove_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.send_personal_message", "text", "content", "msg-type")
|
||||
registerExclusiveOneOf("chat.send_robot_message", "group", "users")
|
||||
registerRequireOneOf("chat.set_group_member_mute_list", "users", "user")
|
||||
|
||||
@@ -1127,21 +1127,37 @@ func putRawJSON(payload map[string]any, key string, raw json.RawMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// rawJSONValue decodes a JSON value that may come from an untrusted source, so
|
||||
// it validates before decoding. Callers holding output that json.Marshal just
|
||||
// produced should use typedJSONValue instead of paying the validation scan.
|
||||
func rawJSONValue(raw json.RawMessage) (any, error) {
|
||||
if !json.Valid(raw) {
|
||||
return nil, fmt.Errorf("invalid JSON value")
|
||||
}
|
||||
return decodeValidJSONValue(raw), nil
|
||||
}
|
||||
|
||||
// decodeValidJSONValue decodes JSON whose validity the caller has already
|
||||
// established, either by json.Valid or by having just marshaled it. Decode
|
||||
// errors are unreachable under that precondition and are therefore discarded,
|
||||
// exactly as this path behaved when the decode was inlined into rawJSONValue.
|
||||
func decodeValidJSONValue(raw json.RawMessage) any {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
_ = decoder.Decode(&value)
|
||||
return value, nil
|
||||
return value
|
||||
}
|
||||
|
||||
// typedJSONValue projects a typed value into the generic JSON shape the payload
|
||||
// renderers consume. json.Marshal output is valid by construction, so this path
|
||||
// decodes it directly: routing through rawJSONValue re-scanned every marshaled
|
||||
// document with json.Valid, which measured ~34% of Schema Catalog assembly time
|
||||
// across the 1121-tool set (26.0s -> 17.2s for the internal/app schema suite).
|
||||
func typedJSONValue(value any) (any, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rawJSONValue(data)
|
||||
return decodeValidJSONValue(data), nil
|
||||
}
|
||||
|
||||
@@ -252,7 +252,8 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"chat.batch_query_group_chat_settings --groups": "Reviewed unpinned adapter: chat.batch_query_group_chat_settings has no singular pinned interface_ref; --groups is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.batch_update_group_chat_settings --items": "Reviewed unpinned adapter: chat.batch_update_group_chat_settings has no singular pinned interface_ref; --items is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.create_text_emotion --background-id": "Runtime extension: the executable helper forwards backgroundId to im/create_text_emotion, but the pinned source-revision metadata does not declare that optional property; preserve the compatibility flag without advertising it as a pinned RPC field.",
|
||||
"chat.get_group_mute_config --group": "Reviewed unpinned adapter: chat.get_group_mute_config has no singular pinned interface_ref; --group is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.get_group_mute_config --conversation-id": "Reviewed unpinned adapter: chat.get_group_mute_config has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.get_group_mute_config --group": "Reviewed legacy Schema compatibility: the historical visible --group wrapper input was required but did not publish a direct interface property.",
|
||||
"chat.list_conversation_message_v2 --open-dingtalk-id": "selects the alternate list_individual_chat_message branch",
|
||||
"chat.list_conversation_message_v2 --user": "selects the alternate list_individual_chat_message branch",
|
||||
"chat.list_message_favorites --cursor": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -265,6 +266,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"chat.query_msg_read_status --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag",
|
||||
"chat.remove_message_favorite --open-conversation-id": "Reviewed unpinned adapter: chat.remove_message_favorite has no singular pinned interface_ref; --open-conversation-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.remove_message_favorite --open-message-id": "Reviewed unpinned adapter: chat.remove_message_favorite has no singular pinned interface_ref; --open-message-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.update_text_emotion --conversation-id": "Reviewed unpinned adapter: chat.update_text_emotion has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.reply_personal_message --ref-msg-id": "serialized into the aggregate content JSON string",
|
||||
"chat.reply_personal_message --ref-sender": "resolved then serialized into the aggregate content JSON string",
|
||||
"chat.reply_personal_message --text": "serialized into the aggregate content JSON string",
|
||||
|
||||
+40
-12
@@ -145,6 +145,15 @@ type FlagSpec struct {
|
||||
// alike) and makes a whitespace-only value count as empty in required checks.
|
||||
Trim bool
|
||||
|
||||
// Input declares extra input sources for a KindString flag beyond the
|
||||
// literal command-line value: InputFile enables @path (value replaced by
|
||||
// the file content), InputStdin enables - (value replaced by stdin).
|
||||
// "@@value" always escapes to the literal "@value". Only explicit CLI
|
||||
// tokens are resolved; EnvVar fallback and registration defaults pass
|
||||
// through unchanged. Resolution runs before required/enum/constraint/
|
||||
// Validate checks, so they see the payload content. Empty = flag value only.
|
||||
Input []string
|
||||
|
||||
// Schema parameter final facts (embedded to dws.schema.*; assembly pass-through).
|
||||
Enum []string // accepted values
|
||||
Format string // machine-readable format (e.g. uri)
|
||||
@@ -365,6 +374,7 @@ func New(spec Spec) *cobra.Command {
|
||||
validateDispatchDecl(spec)
|
||||
validateSafetySpec(spec)
|
||||
validateContractDecl(spec)
|
||||
validateInputSpecs(spec.Use, spec.Flags)
|
||||
// Help prose inherits the declaration when not authored separately:
|
||||
// Selection.Examples (already contract-validated against the real flags)
|
||||
// double as the --help Example block, keeping one authored source.
|
||||
@@ -476,6 +486,11 @@ func runDeclaredPreflight(cmd *cobra.Command, args []string, spec Spec) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Input resolution rewrites explicit @file / stdin values in place so the
|
||||
// required/enum/constraint/Validate stages below check the payload content.
|
||||
if err := resolveInputFlags(cmd, spec.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateRequired(cmd, spec.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -578,18 +593,7 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
|
||||
for _, alias := range flag.Aliases {
|
||||
RegisterFlag(cmd, flag.Kind, alias, "", flag.Usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
if registered := cmd.Flags().Lookup(alias); registered != nil {
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOf,
|
||||
flag.Name,
|
||||
)
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOrigin,
|
||||
runtimeannotate.FlagAliasOriginCorecmdV1,
|
||||
)
|
||||
}
|
||||
AnnotateFlagAlias(cmd, alias, flag.Name)
|
||||
}
|
||||
if flag.MarkRequired {
|
||||
_ = cmd.MarkFlagRequired(flag.Name)
|
||||
@@ -600,6 +604,30 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
|
||||
}
|
||||
}
|
||||
|
||||
// AnnotateFlagAlias records framework-owned evidence that aliasName is a hidden
|
||||
// compatibility alias for canonicalName. It is for commands that already own
|
||||
// their Cobra flag registration outside FlagSpec but still need the same
|
||||
// interface-snapshot alias contract as FlagSpec.Aliases.
|
||||
func AnnotateFlagAlias(cmd *cobra.Command, aliasName, canonicalName string) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
registered := cmd.Flags().Lookup(aliasName)
|
||||
if registered == nil {
|
||||
return
|
||||
}
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOf,
|
||||
canonicalName,
|
||||
)
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOrigin,
|
||||
runtimeannotate.FlagAliasOriginCorecmdV1,
|
||||
)
|
||||
}
|
||||
|
||||
// RegisterFlag registers one flag by Kind. Default is applied at registration
|
||||
// for every kind so --help DefValue matches the declared fallback.
|
||||
// Malformed KindInt / KindBool Default values panic at registration (fail-closed)
|
||||
|
||||
@@ -116,6 +116,16 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAnnotateFlagAliasIgnoresMissingInputs(t *testing.T) {
|
||||
AnnotateFlagAlias(nil, "alias", "canonical")
|
||||
|
||||
cmd := newTestCommand()
|
||||
AnnotateFlagAlias(cmd, "missing", "canonical")
|
||||
if flag := cmd.Flags().Lookup("missing"); flag != nil {
|
||||
t.Fatalf("unexpected missing flag registered: %#v", flag)
|
||||
}
|
||||
}
|
||||
|
||||
// ── effective value fallback chain ─────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageEffectiveValueFallbackChain(t *testing.T) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user