Compare commits

...
Author SHA1 Message Date
chichuan d583247935 test(ci): cover command governance branches 2026-08-20 01:50:13 +08:00
chichuan dfc3b028d7 fix(ci): prove extracted command constants end to end 2026-08-20 00:53:23 +08:00
chichuan 95da8214a1 test(ci): reject command parameter target collisions 2026-08-19 23:24:29 +08:00
chichuan d8a3d5d6fd fix(ci): close command migration governance gaps 2026-08-19 22:57:11 +08:00
chichuan 5ed7c3adce Merge remote-tracking branch 'origin/main' into codex/govern-command-path-migrations 2026-08-19 22:37:21 +08:00
github-actions[bot] d1f1ab724b Merge pull request #1058 from Anonymity-0/feat/chat-group-role-single-flag
feat(chat): expose single group role flag
2026-08-19 22:16:58 +08:00
前津 67505c6c83 Merge remote-tracking branch 'upstream/main' into feat/chat-group-role-single-flag 2026-08-19 21:55:58 +08:00
github-actions[bot] 61c39efb85 Merge pull request #996 from typefield/fix/canonical-agent-skills
fix(skills): adopt canonical global installation
2026-08-19 21:48:33 +08:00
前津 5b412ac196 test(chat): cover role flag resolver branches 2026-08-19 21:48:28 +08:00
玉澜 c0e579fe6b fix(skills): prove backup ownership before adopting or pruning stamp roots
A stamp-shaped directory name is not ownership proof: pruneSkillBackups
counted and RemoveAll'd any 20260819-120000-shaped entry under
~/.dws/skill-backups, so a user or tool that created such a directory
lost its contents once DWS held five backups, and the Go backup path
(MkdirAll) adopted a same-named foreign root outright. The PowerShell
installers already implemented the correct contract; every other
surface now matches it.

Go stamps a freshly created root with the exact marker bytes the
install scripts write (.dws-skill-backup = "dws skill backup v1")
before any payload moves in, claims the root with mkdir so an existing
unproven root bumps to a collision suffix instead of being adopted,
and prunes only roots whose marker verifies — unmarked or wrongly
worded stamp-shaped directories are foreign data, preserved and never
counted against the keep limit. The shell installers (install.sh,
install-skills.sh, install-event.sh, install-devapp.sh) and the npm
installer apply the same rule in their backup collision loops, with
roots recorded as created by the running process exempt from marker
re-verification so a mid-run marker permission failure still reuses
this run's own root and keeps the sibling payload intact.

Regression tests cover every surface: pruning an unmarked/wrongly
marked stamp-shaped directory alongside marked ones, refusing to adopt
a foreign root (payload moves to a suffixed root, foreign data and its
nonexistent marker untouched), same-stamp reuse of a proven root, and
marker-write failure cleaning the empty fresh root.
2026-08-19 21:29:47 +08:00
前津 c2ff4ab242 test(chat): cover missing group role flag 2026-08-19 21:26:52 +08:00
前津 3fa85d19c9 docs: add group role flag release fragment 2026-08-19 21:22:55 +08:00
前津 33b76400bf chore(policy): consume group role flag migration 2026-08-19 21:01:34 +08:00
Anonymity-0 2b417e2f2a Merge branch 'main' into feat/chat-group-role-single-flag 2026-08-19 20:51:06 +08:00
chichuan c0e1ec576a ci: govern command path migrations 2026-08-19 20:48:43 +08:00
玉澜 2b3f482fdc Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills-p1 2026-08-19 19:58:56 +08:00
玉澜 95645e4f2c fix(skills): no-clobber child moves in POSIX standalone publishers
copy_tree published staged children with mv, which replaces a
concurrently created same-name directory (POSIX rename succeeds over an
empty target) and whose rollback moved every dest child back — including
a concurrent writer's different-named entries — before deleting the
staging tree. Children now publish through kernel-level no-clobber
primitives (mkdir claim + recursion for directories with the recorded
mode restored, ln for regular files, ln -s for symlinks), a manifest
records exactly what this transaction published, the rollback retracts
only those entries in reverse order, and each level re-counts the
destination so a foreign different-named entry aborts the publish with
the destination retained. Read-only staged directories (0555 skill
trees) are made owner-writable for the move; the backup restore uses the
same discipline so a concurrent writer is refused without partially
draining the backup.

Regression tests cover both scripts: a concurrently created same-name
empty child directory and a different-named foreign entry mid-publish
are retained with the original backup kept; both fail against the
previous mv-based implementation.
2026-08-19 19:46:47 +08:00
前津 575303a5b0 docs(skill): remove stale group role flag guidance 2026-08-19 19:40:41 +08:00
github-actions[bot] d9b728f8e5 Merge pull request #1059 from Anonymity-0/feat/chat-group-role-flag-migration-approval
chore(policy): approve group role flag migration
2026-08-19 19:33:13 +08:00
前津 6240584ae2 chore(policy): approve group role flag migration 2026-08-19 19:13:40 +08:00
玉澜 cb46823280 Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills-p1 2026-08-19 19:08:24 +08:00
玉澜 fdcaa61587 test(skills): cover child-move edges for the 100% changed-code gates
The platform coverage gates execute only TestCrossPlatformCoverage-named
tests, so the child-move error and dispatch branches that the full local
suite covered incidentally were reported as uncovered changed code on
Windows (96.78% vs the 100% target). Adds a seam-driven edge suite for
the child-move fallback — source/claim/child stat and read failures,
per-child link and symlink collisions and publish failures, rollback
rename failure, foreign-entry abort, mode-restore failure, source shell
removal failure, nested-directory and simulated-symlink children, and
post-rename content drift — plus the retained-destination notice for a
dependent uncertain target in skill setup. The POSIX file identity impl
now consults the lstat seam so its degradation branches are coverable
the same way. Verified against the gate's own changed-line computation:
zero uncovered changed statements in internal/upgrade.
2026-08-19 18:54:04 +08:00
Anonymity-0 3e481d296c Merge branch 'main' into feat/chat-group-role-single-flag 2026-08-19 18:49:59 +08:00
前津 22f87296cd fix(chat): close role flag resolver 2026-08-19 18:46:38 +08:00
github-actions[bot] c198d8577d Merge pull request #976 from H3java/feat/recruit-job
feat: 新增招聘职位管理 to#85340676
2026-08-19 10:30:55 +00:00
玉澜 33828b7858 Merge remote-tracking branch 'fork/fix/canonical-agent-skills' into fix/canonical-agent-skills-p1 2026-08-19 18:28:04 +08:00
玉澜 0c80aa79e5 test(skills): make replacement-identity tests deterministic on Windows
The publish-confirmation and tunneled-replacement tests physically
removed and reseeded the destination to simulate a concurrent swap. On
NTFS the recreation can immediately reuse the freed MFT record, making
the file ID (volume serial + file index) compare equal and the proof
pass against a replaced object — the Windows coverage gate observed the
confirmation falling through to the fingerprint branch instead of the
identity branch. Both tests now force the replacement through the two
primitives the platform proof consults (os.SameFile on Unix, the file-ID
seam on Windows), matching the technique the tunneled-rollback case
already used for Unix inode recycling.
2026-08-19 18:27:39 +08:00
john da62d11b35 Merge branch 'main' into fix/canonical-agent-skills 2026-08-19 18:13:21 +08:00
赤川 a5d7fd05f1 Merge branch 'main' into feat/recruit-job 2026-08-19 18:12:36 +08:00
玉澜 cf13026f48 fix(skills): drop stale Statx identity test from merged remote line
skill_publication_identity_linux_test.go pinned the remote line's
Statx/birth-time identity design (skillPathStatx seam); the merged head
proves ownership with dev:ino plus the fingerprint backstop instead, so
the test no longer compiles on Linux. Caught by CI's Linux lint job,
which builds what macOS-local vet skips behind the linux build tag.
2026-08-19 18:10:23 +08:00
玉澜 7a858b9732 Merge remote branch (main evolution + npm/Shell no-clobber) into p1
Reconciles the two parallel evolutions of PR #996 with this session's
publication design as authoritative:

- internal/upgrade, internal/app: ours — mkdir-claim identity witness
  (dev:ino on POSIX, volume file ID on Windows), three-state ownership,
  ErrSkillPathPublicationUncertain, copy-fallback short-circuits. Drops
  the remote line's xattr publication-mark design and its six follow-up
  fixes (retract contracts, Statx token); skill_publication_mark_*.go
  removed accordingly.
- scripts/, build/npm/, test/scripts/, docs/rfc: theirs — same replayed
  install hardening plus main's evolution and the npm no-clobber child
  moves; no xattr dependency, consistent with the claim model.
- .changes: their npm/Shell/PowerShell narrative with the Go-design
  sentences rewritten for the uncertain-publication contract.

Verified: go build, go vet (tests compiled), gofmt, and package tests
for internal/upgrade, internal/app, test/scripts all green on this tree.
2026-08-19 18:06:06 +08:00
github-actions[bot] 00c337c438 Merge pull request #1052 from DingTalk-Real-AI/codex/fix-chat-user-mentions
fix(chat): preserve mentions and route direct media uploads
2026-08-19 18:05:50 +08:00
玉澜 27aca3ccc3 fix(skills): close no-replace fallback TOCTOU and surface uncertain publications
The mkdir->rename->remove->rename directory fallback had a TOCTOU window
between the second remove and the second rename: a concurrent writer
creating an entry at the destination was silently clobbered. The fallback
now claims the destination once with mkdir and never unlinks it: the
fast-path rename publishes over the claim (Linux), and platforms that
refuse directory renames (macOS, Windows) move the staged children into
the claim through atomic no-clobber primitives (mkdir/os.Link/os.Symlink),
consuming the emptied source shell on success.

renameSkillPathNoReplace now returns the mkdir-claim identity captured by
the child-move path. PublishSkillPathNoReplace uses it as a three-state
ownership witness: the atomic/fast paths keep the staged-inode proof, the
child-move path proves dest is still the mkdir claim, and a mismatch
reports the new ErrSkillPathPublicationUncertain sentinel with the
destination retained. The witness is real on POSIX now: darwin and linux
report the dev:ino file identity instead of the empty no-op.

Upstream consumers honor the sentinel: the mono/multi upgrade copy
fallbacks no longer retry over an uncertain destination (the retry would
displace the concurrent writer's object), and skill setup reports the
retained destination instead of claiming a rollback.

Rewrites the fallback tests that pinned the removed remove-and-retry flow
and adds regression coverage: concurrent claim entries abort with the
destination retained, wholesale replacement after child-move reports the
uncertain sentinel, staged-set transactions pass the sentinel through,
and both copy fallbacks short-circuit (ablation-verified).
2026-08-19 17:42:20 +08:00
xlb1130 84036678dd Merge branch 'main' into fix/85564002-chat-group-role-single-flag 2026-08-19 17:26:10 +08:00
长真 d5031a89f0 fix(chat): reject multiple public group role ids 2026-08-19 17:13:48 +08:00
恋川 ed1ebe5d03 chore: retrigger CI 2026-08-19 16:11:24 +08:00
恋川 0d22a4a1bd Merge remote-tracking branch 'upstream/main' into feat/recruit-job 2026-08-19 14:43:50 +08:00
克谨 83f3b4f385 Merge remote-tracking branch 'origin/main' into codex/fix-chat-user-mentions 2026-08-19 14:39:42 +08:00
恋川 e217901a6b fix(recruit): validate education list filter 2026-08-19 14:37:22 +08:00
github-actions[bot] 13d0ae66a6 Merge pull request #1044 from DingTalk-Real-AI/fix/param-hallucination
feat(calendar): expand reviewed parameter alias coverage
2026-08-19 14:27:17 +08:00
克谨 63854705fd fix(chat): route direct media upload targets 2026-08-19 14:22:02 +08:00
克谨 f3b0fcdc4c test(calendar): verify aliases preserve confirmation 2026-08-19 13:53:59 +08:00
恋川 109a2891de Merge remote-tracking branch 'upstream/main' into feat/recruit-job 2026-08-19 13:48:45 +08:00
玉澜 4e106cd5ad Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills 2026-08-19 13:44:46 +08:00
玉澜 9858844158 fix(skills): no-clobber child moves in npm publish 2026-08-19 13:37:11 +08:00
克谨 00ca448aa2 docs(release): add chat mention fix fragment 2026-08-19 13:34:45 +08:00
克谨 afe01d4b70 Merge remote-tracking branch 'origin/main' into codex/fix-chat-user-mentions 2026-08-19 13:30:47 +08:00
克谨 4c6db326f5 fix(chat): preserve and validate user mention tokens 2026-08-19 13:30:40 +08:00
克谨 f10d552fd7 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-19 12:28:10 +08:00
github-actions[bot] 8b8756b00e Merge pull request #999 from wxianfeng/feat/oa-approval-instance-cc
feat(event): support OA approval CC events
2026-08-19 04:23:05 +00:00
克谨 ec59cf8065 test(calendar): run alias payloads in platform gate 2026-08-19 12:14:05 +08:00
炳昱 2ffddbd5a0 feat(event): support OA approval CC events 2026-08-19 12:08:35 +08:00
john 843edd700d Merge branch 'main' into fix/canonical-agent-skills 2026-08-19 11:36:13 +08:00
恋川 58ff0248b6 fix(recruit): handle minimal terminal pages 2026-08-19 11:05:29 +08:00
克谨 1d3c56f9fa Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-19 10:51:30 +08:00
克谨 502317db68 test(calendar): cover suggestion time aliases 2026-08-19 10:47:50 +08:00
github-actions[bot] 66516755e6 chore: update beta formula for v1.0.59-beta.3 [skip ci] 2026-08-19 02:39:35 +00:00
恋川 6c895c23ed fix(recruit): validate pagination cursor responses 2026-08-19 10:17:18 +08:00
克谨 6e3f528f48 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-19 10:09:47 +08:00
克谨 d70e6b85b6 test(calendar): isolate exhaustive alias payload coverage 2026-08-19 10:09:37 +08:00
恋川 510b120630 fix(recruit): require job creator identity 2026-08-19 09:31:26 +08:00
恋川 f253f865c7 Merge remote-tracking branch 'upstream/main' into feat/recruit-job 2026-08-19 09:29:22 +08:00
克谨 c8f83533fb Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-19 09:21:44 +08:00
玉澜 8e34134dbc Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills 2026-08-19 08:03:42 +08:00
玉澜 65885dd262 fix(skills): abort degraded publish on foreign claim entries 2026-08-19 05:54:20 +08:00
玉澜 b354b371c9 fix(skills): prune backups without following reparse points 2026-08-19 00:15:31 +08:00
玉澜 15d289d3f3 fix(skills): keep sibling backups when marker write fails 2026-08-19 00:15:27 +08:00
玉澜 57b5845eb3 fix(skills): verify content fingerprint in shell rollback 2026-08-18 22:30:51 +08:00
克谨 75f08da197 feat(calendar): expand parameter alias normalization 2026-08-18 22:10:23 +08:00
玉澜 3ac9b83565 Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills 2026-08-18 21:00:14 +08:00
玉澜 234253e75f test(skills): cover linux statx identity without btime 2026-08-18 20:42:04 +08:00
玉澜 6a4803d85a fix(skills): verify backup ownership marker before pruning 2026-08-18 20:42:01 +08:00
玉澜 d11e69fbdb test(skills): cover copy fallback dest scan branches 2026-08-18 19:41:33 +08:00
玉澜 a3566f39c4 test(skills): cover unix publication identity fallbacks 2026-08-18 19:33:56 +08:00
玉澜 a192e988c4 fix(skills): refuse unplanned dests in copy fallback 2026-08-18 19:33:51 +08:00
玉澜 91d2e29925 test(skills): cover publication mark ownership branches
Windows coverage gate only runs TestCrossPlatformCoverage*, and the
xattr mark helpers are Unix-only. Inject seams so marked dest is
retracted on owned drift, left in place when the mark is gone, and
the helper error paths are exercised on every platform.
2026-08-18 17:50:39 +08:00
长真 26638cbd98 fix(chat): complete group role set-user flag compatibility 2026-08-18 17:44:51 +08:00
玉澜 2f05649277 fix(skills): keep concurrent dest across inode reuse
Linux overlayfs recycles device+inode, so SameFile and a lone inode
token treated a replacement as owned and retracted it. Stamp staged
inodes with an xattr mark, prove Linux/Darwin identity with birth
time, and make shell copied-set rollback check dest first with inode
plus child names.
2026-08-18 17:34:49 +08:00
长真 6c8e7e082b fix(chat): expose single group role set flag 2026-08-18 17:13:50 +08:00
玉澜 63a6de7b49 fix(skills): prove npm rollback ownership before quarantine
Match the Go dest-first identity check so a concurrent replacement is
never moved into .rollback-*; only a post-quarantine mismatch is
restored with no-replace. Cover both races in the npm smoke suite.
2026-08-18 16:40:21 +08:00
玉澜 46b641f227 fix(skills): retract leftover dest and qualify Windows junctions
Record dest on occupy and retract it when confirmation, verify, or
staging cleanup fails. Restore unmatched quarantine with a no-replace
publish. Event/devapp copy uses mkdir-claim; shell rollback claims dest
before delete. Release copy now says npm/PowerShell create junctions and
Go uses os.Symlink, with copy fallback when linking is unavailable.
2026-08-18 15:53:56 +08:00
玉澜 71da2dfded Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills 2026-08-18 13:48:35 +08:00
玉澜 21395ed12d fix(skills): retract unrecorded dest after cross-device publish
Cross-filesystem Skill moves now record publication identity as soon
as the staging path is renamed onto dest. A later mode-restore, copy
verification, or staging-cleanup failure retracts that proven dest so
retries are not blocked by an untracked leftover. A failed retract
reports an uncertain state naming both retained locations.
2026-08-18 13:48:23 +08:00
玉澜 5f2344d16d fix(skills): claim shell copy publications atomically and verify rollback identity
The shell mono/multi set publishers staged each Skill directory and
published it with a plain mv after the backup; anything another process
created at the destination between the backup and the move was silently
replaced, and restore_multi_skill_set then blind-deleted manifest paths,
so a concurrently replaced object could also be destroyed during
rollback. Publish through an atomic mkdir claim instead — EEXIST refuses
any occupant, staged children move into the claim one by one, and a
failed child move relocates them and removes only the claim. The
published manifest now records <dest>:<inode>, and rollback deletes a
destination only when its inode still matches the publication, skipping
concurrently replaced paths with a warning. Also fixes a latent
unbound-variable expansion where a shell variable was followed directly
by a full-width parenthesis in a message. Regression tests publish a
first Skill, replace it with a foreign directory, fail the second
publication, and assert rollback retains the foreign object untouched
while restoring the rest from backups.
2026-08-18 13:00:03 +08:00
玉澜 e79efc70af Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills 2026-08-18 12:00:32 +08:00
玉澜 7fa4ee7bac docs(skills): describe the actual degraded no-replace publication
The RFC section on filesystems that reject the atomic no-replace rename
still described the retired existence-check-plus-plain-rename fallback
and its accepted race window. The implementation (and the npm and shell
surfaces) claim the destination with mkdir or a hard link — or create
the link directly at the destination — and never release the claim mid
transaction, so a concurrently created object is refused rather than
overwritten. Record that contract and its only relaxed property (child
moves are not all-or-nothing visible) so future maintainers do not
port the racy description back into code.
2026-08-18 11:26:37 +08:00
恋川 5b9234d8fe fix(recruit): align job creation contract 2026-08-18 11:16:04 +08:00
恋川 619319517a Merge remote-tracking branch 'upstream/main' into feat/recruit-job 2026-08-18 11:08:45 +08:00
玉澜 74513bae2f Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills 2026-08-18 10:57:19 +08:00
玉澜 fcbddb0904 fix(skills): create shell-published links at the destination atomically
The POSIX shell installers staged shared Skill links and published them
with mv after an existence check; a file or symlink another process
created at the destination between the check and the move was silently
replaced, and the inode confirmation could not detect the loss. Publish
by creating each link directly at its destination instead — symlink(2)
refuses an occupied path with EEXIST, so the creation itself is the
atomic no-replace check. A directory that appears at the destination
turns ln -s into a container; the nested link is removed after an
identity check and the transaction rolls back, leaving the foreign
directory untouched. Applied to install.sh, install-skills.sh,
install-event.sh, and install-devapp.sh. Also covers the remaining
retraction branches of the Go shell-removal fallback so changed-code
coverage is complete. Regression tests inject a concurrent occupant at
the publish instant for regular-file and directory cases and assert the
foreign object and its contents stay completely unchanged.
2026-08-18 10:20:15 +08:00
玉澜 e84743615f fix(skills): retract a child move when the source shell cannot be removed
On filesystems without atomic no-replace rename, the degraded
publication moves the source children into a fresh claim and leaves an
emptied source shell for the caller to remove once the move is
confirmed. If that removal failed, moveSkillPathRecoverably reported a
plain failure claiming both locations were preserved while the data
existed only at the destination, so backupAndRemoveSkillDir never
recorded the backup and the original path was left empty. Move the
children back into the shell and withdraw the destination instead; a
failed retraction reports the data location explicitly. Restores the
contract that a failed move keeps the source intact.
2026-08-18 09:30:19 +08:00
玉澜 a102447eb5 Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills 2026-08-18 08:20:15 +08:00
玉澜 2b131a1031 fix(skills): create canonical links at the destination atomically
publishCanonicalLinkNoReplace checked the destination with lstat and
then published, leaving a window the comment claimed did not exist: on
Windows renameSync replaces a concurrent object outright (libuv passes
MOVEFILE_REPLACE_EXISTING), and on POSIX ln -P source target links INTO
a directory that appeared at the target, leaving a stray link inside
foreign data that the rollback list never recorded. Create the symlink
or junction directly at the destination instead — link creation fails
with EEXIST when anything occupies the path and never treats the target
as a container, so the publication itself is the atomic no-replace
check. Identity confirmation re-reads the live link before the
publication enters the rollback list. Covered by injected concurrent
creators at the publish instant on POSIX and simulated Windows,
asserting the foreign object and its contents stay completely
unchanged.
2026-08-18 08:17:46 +08:00
玉澜 5fdaea5f36 Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills 2026-08-18 00:43:13 +08:00
玉澜 e8a320a06b fix(skills): claim npm copy publish destinations atomically
The mono and multi set copy publishers checked destination existence
with lstat and then called Node's rename, which replaces the target on
every platform (libuv passes MOVEFILE_REPLACE_EXISTING on Windows). A
file, symlink, or empty directory created between the check and the
rename was silently overwritten, and the identity confirmation could not
recover it because the publication record only proved the staged object
arrived. Claim the destination with mkdir — which fails with EEXIST if
anything occupies the path, so the claim itself is the existence check —
and move the staged children into the claim, restoring the source mode
on it. A failed child move relocates the children back and removes only
the claim. Covered for mono, multi, and simulated Windows, including an
injected concurrent creator at the claim instant.
2026-08-18 00:24:18 +08:00
玉澜 59268a42a6 fix(skills): retract the published link when source removal fails
The no-replace file fallback links the destination and then removes the
source. If the removal fails, the caller treats the publish as failed,
but no publication record exists to roll the new destination back, and
a backup restore would refuse the occupied path. Remove the destination
behind an identity check — only the proven linked object may be deleted
— and report when the retraction itself fails or the destination was
concurrently replaced.
2026-08-17 23:51:17 +08:00
玉澜 06661af43f fix test: published file ID must differ from staged for Windows proof
The previous wrapper returned the first observed ID for both paths, so
expected == actual still held on Windows and the proof accepted the
swap. Return a distinct ID for the second probe.
2026-08-17 22:11:24 +08:00
玉澜 951dd27f0c test(skills): fake same file IDs across staged and published paths
The constant file-ID stub made both IDs equal, so the Windows proof
(expected == actual) accepted the publication and the subtest failed
there; Unix stayed green because its proof ignores the ID strings and
the swapped os.SameFile seam already forced the failure. Return the
first observed ID for both paths so staged and published identities
differ on every platform while real IDs still flow through the wrapper.
2026-08-17 22:10:37 +08:00
玉澜 bc5e5db7ef test(skills): pin publish identity rejection through the identity seam
The physical same-content swap relied on the recreated destination
getting a fresh inode, but CI runners' ext4/overlayfs recycle inodes
eagerly, so the swap was undetectable on Linux and the subtest failed
there (while passing on macOS). Swap the same-file identity seam instead
so the confirmation's fast-path rejection contract is pinned on every
platform.
2026-08-17 21:46:42 +08:00
玉澜 a0accff258 test(skills): assert claim mode matches source across platforms 2026-08-17 21:33:35 +08:00
玉澜 f7a3e606f7 fix(skills): never prune shell installers' current-run backups
The four standalone installers pruned the oldest excess stamp
directories regardless of origin, so a migration retiring more than
five batches destroyed its own rollback material mid-run — the same
data loss already fixed for Go via the run-root registry and present
in install.js/install.ps1 as currentRunBackupRoots. Every installer now
records the stamp directories it creates and pruning only removes
earlier-run batches, which is what the changelog already promises.
2026-08-17 21:24:46 +08:00
玉澜 1d1aca5fd1 test(skills): cover no-replace fallback error and rollback branches 2026-08-17 21:24:43 +08:00
恋川 b199fd29cb test(recruit): cover missing request job id 2026-08-17 20:53:32 +08:00
玉澜 93703113cb fix(skills): hold the no-replace claim instead of unlinking and retrying
The degraded directory publication claimed the destination with mkdir, then
— on platforms whose rename refuses to replace a directory (macOS refuses
even an empty target, verified empirically) — removed the claim and retried
a plain rename. Between the unlink and the retry a foreign directory could
appear at the destination and be silently overwritten, breaking the
no-replace contract the fallback exists to provide.

Hold the claim for the whole transaction instead: rename over the claim
where the platform permits it (Linux), otherwise move the source children
into the claim one by one. The destination is never unlinked, so a
concurrent creator can only ever lose the mkdir race; every child rename
targets a nonexistent path inside the empty claim, and a failed move
restores the children and removes only the claim.

The child move legitimately changes the publication's identity, which the
confirmation now handles: a rename that consumed the staged path is still
proven by identity, while a child move is proven by the pre-rename content
fingerprint. The emptied source shell doubles as the signal distinguishing
the two shapes; moveSkillPathRecoverably removes it to keep move semantics.
2026-08-17 20:21:10 +08:00
玉澜 e5ed9e6e39 fix(skills): never prune backups taken by the running migration
The backup stamp has second precision and pruning kept only the newest 5
stamps, so a canonical migration that retires copies across many Agent
roots deleted its own earlier backups mid-run. That silently voided the
reversibility guarantee the transaction depends on for rollback: a probe
retiring 8 paths lost 3 of them permanently.

Record every stamp directory this process creates, keyed by normalized
absolute path, and prune only the oldest foreign stamps.
2026-08-17 20:21:06 +08:00
玉澜 7924e84fb6 fix(skills): fall back to copy when link publication fails
Creating the staged symlink usually succeeds, so the link strategy really
fails at publish time: renameSkillPathNoReplace has no atomic no-clobber
primitive for a symlink source and refuses it whenever the kernel flag is
unavailable (NFS, FUSE, overlayfs). Gating the copy fallback on staging
alone therefore left every non-universal Agent unconfigured on exactly the
filesystems the fallback exists to support.

Retry the whole target transaction as a direct copy after a failure in any
phase, but only when the failed attempt fully restored the originals. The
converter also re-adds the replacement backups the link plan deliberately
skips for destinations already pointing at canonical, which a copy must
replace and no-replace publication would otherwise reject with EEXIST.
2026-08-17 20:21:03 +08:00
玉澜 b4129c467d test(skills): cover Windows same-file identity seam with synthetic info
skillPathSameFileIdentityImpl on Windows always returns false and is
never reached through skillPathIdentityProven (which uses file IDs
exclusively). Add a direct seam call with synthetic os.FileInfo to
exercise the Windows return-false path and the Unix os.SameFile path
with nil Sys().
2026-08-17 20:21:00 +08:00
玉澜 a12abdfb54 refactor(skills): collapse Windows identity error paths for coverage
Restructure skillPathFileIdentityImpl to use nested if-err-nil with a
named return and skillPathIdentityProven to use a single expression.
Error conditions now fall through to the bare return instead of
occupying separate coverage blocks, eliminating 5 uncovered statements
that the Windows coverage gate flagged at 99.4193%.
2026-08-17 20:20:58 +08:00
玉澜 d9283b9a82 style: gofmt alignment after adding skillPathSameFileIdentity seam 2026-08-17 20:20:55 +08:00
玉澜 f1d40e26e1 fix(skills): open reparse points in Windows file ID query and stabilize tunneled test
Add FILE_FLAG_OPEN_REPARSE_POINT to the Windows CreateFile call in
skillPathFileIdentityImpl so symlinks are opened as reparse points
rather than followed to their target. Staged symlinks carry relative
targets computed for the final destination, which may not resolve from
the staging directory; following them caused CreateFile to fail,
yielding an empty file ID that rejected publication and broke canonical
skill layout migration on Windows.

Make skillPathSameFileIdentity a seam variable so the tunneled
replacement test can deterministically simulate the identity change on
Unix. On tmpfs (used by Linux CI runners), os.SameFile can return true
for a recreated file due to inode reuse, making the test flaky. On
Windows the swap is a no-op because skillPathIdentityProven compares
file IDs from GetFileInformationByHandle and ignores
skillPathSameFileIdentity.
2026-08-17 20:20:52 +08:00
玉澜 0db91cfc44 fix(skills): prove Windows rollback identity via stable file ID
NTFS file tunneling can restore the original creation time for a
recreated same-named object, which defeated the creation-time
incarnation check and allowed rollback to delete a concurrent
replacement. Replace the platform-specific identity pair with a single
skillPathIdentityProven function:

- Unix: delegates to os.SameFile (inode/dev), ignoring file ID strings
- Windows: compares VolumeSerialNumber:FileIndexHigh:FileIndexLow from
  GetFileInformationByHandle, which uniquely identifies the file on the
  volume for its lifetime and is unaffected by tunneling

When the file ID cannot be obtained at publish time, identity is not
proven and the auto-delete is refused. Add a regression test that
simulates tunneled creation time and verifies rollback still refuses
the concurrent replacement.
2026-08-17 20:20:49 +08:00
玉澜 f88be7ae21 test(skills): cover non-EEXIST link error on Windows
On Windows isNoReplaceRenameUnsupported always returns false, so the
fallback is never entered from the invalid-path test. Force the fallback
and swap skillPathLink to a non-EEXIST error to cover line 94 on all
platforms.
2026-08-17 20:20:46 +08:00
玉澜 e3313095ba test(skills): cover all no-replace fallback branches for 100% coverage
Add tests for mkdir non-EEXIST error, remove failure after rename
failure, first-rename-succeeds path (Linux behavior), retry-rename
path, and non-regular source safe-fail. All 24 changed executable
statements now covered on both macOS and Windows.
2026-08-17 20:20:44 +08:00
玉澜 c7882d7f72 fix(skills): eliminate TOCTOU in no-replace rename fallback
The fallback path for filesystems without RENAME_NOREPLACE/EXCL (NFS,
FUSE, overlayfs) used Lstat-then-Rename, which could overwrite a
concurrently created destination between the check and the rename.

Replace the TOCTOU-prone check with truly atomic no-clobber primitives:
- Directories: os.Mkdir atomically claims the destination (fails with
  EEXIST if occupied). On Linux rename(2) replaces the empty dir
  directly; on Darwin/Windows rename refuses existing dirs so the empty
  dir is removed and the rename retried — any concurrent creation
  between remove and rename is detected by the second rename failing.
- Files: os.Link atomically fails if the destination exists, then
  os.Remove completes the move.

Add concurrent-creation test covering the mkdir→rename race window.
2026-08-17 20:20:41 +08:00
玉澜 92196738d3 test(skills): cover Windows stat-error branch in no-replace fallback
The !os.IsNotExist(statErr) branch in renameSkillPathNoReplace was
uncovered on Windows. Inject errNoReplaceRenameUnsupported for the
atomic rename and os.ErrPermission for skillPathLstat so the stat-error
path is exercised on every platform.
2026-08-17 20:20:38 +08:00
玉澜 0a4da58d8f fix(ci): unset XDG_CONFIG_HOME for npm installer smoke test
The smoke test creates temp home directories with .config/kimchi markers
for agent detection. On Linux CI runners XDG_CONFIG_HOME may point to the
runner's real config path, causing resolvedAgentTargets to look outside
the temp home. Unset it so detection resolves against the test's temp dir.
2026-08-17 20:20:35 +08:00
玉澜 f14332f143 fix(skills): guard pruneSkillBackups against non-DWS directories
Restrict backup pruning to directories whose names match the DWS stamp
format (YYYYmmdd-HHMMSS with optional -N suffix) across all 8 installer
surfaces (Go, npm, 4 shell, 2 PowerShell). Unknown directories in
~/.dws/skill-backups are now preserved. Also fixes Windows coverage test
portability and covers the remaining macOS changed-code gap (retire
warning loop in runUpgrade).
2026-08-17 20:20:32 +08:00
玉澜 62883b7940 fix(skills): make obsolete-copy retirement non-fatal and harden install
A universal Agent whose obsolete private copy cannot be retired installs
nothing there, yet every entry point counted that retirement failure as an
install failure — aborting `npm install`, `dws skill setup`, and the shell
installers even when the canonical store and all links published correctly,
and skipping the skills-state write. Route retirement failures to a separate
warning path across all surfaces (Go upgrade + skill setup, npm, PowerShell,
install.sh, install-skills.sh, install-event.sh, install-devapp.sh).

Also:
- Add a checked-rename fallback for filesystems that reject the atomic
  no-replace flag (NFS, FUSE, overlayfs); the no-clobber contract is kept and
  the previously unsupported platforms build and work.
- PowerShell multi-mode links only bundle skills, never the shared canonical
  store, so third-party/user skills are no longer fanned into every Agent root.
- Prune ~/.dws/skill-backups to the newest 5 on every surface; encode
  HOME-relative backup names on PowerShell to preserve origin.
- Add simulated-win32 junction coverage and rewrite the tautological
  no-replace test; remove dead code whose tests gave false coverage.
- Soften the overstated Windows ownership-proof comment (NTFS tunneling).
2026-08-17 20:20:29 +08:00
玉澜 6e20bc765f test(skills): cover Windows no-replace path errors 2026-08-17 20:20:27 +08:00
玉澜 ecaefb416f fix(skills): retain Windows reparse link publication 2026-08-17 20:20:24 +08:00
玉澜 f16feed896 fix(skills): compare Windows publication identity stably 2026-08-17 20:20:21 +08:00
玉澜 d0a9dad079 test(skills): cover post-publish identity reuse 2026-08-17 20:20:19 +08:00
玉澜 e6c54cf777 fix(skills): distinguish reused publication inodes 2026-08-17 20:20:15 +08:00
玉澜 7a97354d93 fix(skills): make publication rollback race-safe 2026-08-17 20:20:13 +08:00
玉澜 a46958c788 fix(skills): make PowerShell rollback race-safe 2026-08-17 20:20:10 +08:00
玉澜 b664ace2f2 test(skills): junction-safe rollback and per-agent degrade regressions
- extend the silent-rollback contract to install-event.sh
- static contract: Restore-MultiSkillSet removes published paths lexically
  (section-scoped so identity-anchor refactors keep the guarantee) and link
  staging dirs are cleaned via Remove-LinkStageRoot / Remove-DevLinkStageRoot
- install-event.sh integration test: an uninstallable agent target is
  skipped loudly while later agents still receive links
- pwsh probe: Test-SamePhysicalSkillRoot must dereference junctions and
  symlinks (junction idempotency asserted where junctions are creatable)
2026-08-17 20:20:08 +08:00
玉澜 a789a2eea7 fix(skills): junction-safe PowerShell rollback and per-agent degrade
- install.ps1: remove published junctions lexically in Restore-MultiSkillSet
  (Windows PowerShell 5.1 follows reparse points during Remove-Item -Recurse
  and could delete canonical store contents); clean link staging dirs
  lexically in Publish-CanonicalSkillLinks and Move-SkillPathRecoverably
- install.ps1: Test-SamePhysicalSkillRoot now dereferences junctions via
  Get-PhysicalSkillPath (mirrors EvalSymlinks/realpathSync/cd -P), so reruns
  recognize already-published junctions instead of backup churn
- install-event.sh: replace silent 'mv ... 2>/dev/null || true' rollback with
  the loud backup-retained failure contract already enforced for devapp
- event/devapp sh+ps1: link→copy fallback and per-agent failures now degrade
  per agent like install.sh (skip loudly, continue, report at the end)
  instead of aborting mid-loop or swallowing errors
- tests: junction-lexical removal contract, event per-agent degrade
  integration test, pwsh junction physical-root recognition + rerun
  idempotency (no backup churn)
2026-08-17 20:20:06 +08:00
玉澜 e4a1feccf0 test(skills): retain rollback identity anchor 2026-08-17 20:20:04 +08:00
玉澜 3f39a9ddb1 Revert "test(skills): retain rollback identity anchor"
This reverts commit ce73a5b452.
2026-08-17 20:20:02 +08:00
玉澜 b080b6e7c5 test(skills): retain rollback identity anchor 2026-08-17 20:19:59 +08:00
玉澜 dc180f6d07 fix(skills): retain link identity anchors through rollback 2026-08-17 20:19:57 +08:00
玉澜 7b64576ea1 fix(skills): protect shell link rollback from races 2026-08-17 20:19:55 +08:00
玉澜 437dd234b2 fix(skills): fail upgrade unconditionally when canonical publish fails
A failed canonical publish only failed the upgrade when
hasDependentSkillRoot reported a non-universal link target; that helper
explicitly skipped universal agents, which are exactly the direct consumers
of ~/.agents/skills. On a universal-only machine (e.g. only Codex
installed), UpgradeSkillLocations* returned a nil error with nothing
installed, contradicting the documented "canonical publication is
mandatory and fails the upgrade loudly" contract.

Canonical publish failures now return an error unconditionally in both the
mono and multi branches, and hasDependentSkillRoot is removed. The test
that pinned the old standalone-does-not-fail-fast behavior now asserts
error propagation in both modes.
2026-08-17 20:19:53 +08:00
玉澜 04f78bcb15 fix(skills): remove ineffective app detection gate 2026-08-17 20:19:50 +08:00
玉澜 9abcdb4deb Revert "fix(skills): make app-bundle detection gate HOME-independent"
This reverts commit 37cd629335.
2026-08-17 20:19:48 +08:00
玉澜 c0da89e674 fix(skills): make app-bundle detection gate HOME-independent
The allowSystemApps gate (homeDir == systemHome) was effectively a no-op in
production: systemHome came from os.UserHomeDir, which honors the $HOME env
override just like homeDir, so the two were always equal and the gate never
fired when $HOME was overridden.

ResolveSystemHomeDir now prefers the OS user database (getpwuid on Unix),
which is independent of $HOME, falling back to $HOME only when the user record
cannot be resolved. Production behavior is unchanged (a real $HOME still
matches); an isolated/overridden HOME now correctly skips machine-wide
/Applications discovery for zcode/minimax. The app surface references the same
shared resolver.

This is the correct fix for the hermeticity concern (machine-wide state leaking
into an isolated HOME): there is no cross-surface production inconsistency to
port — script installers always operate on the real user HOME in practice, so
they need no gate.
2026-08-17 20:19:46 +08:00
玉澜 09fc5d993d test(skills): cover Windows chmod failure branch 2026-08-17 20:19:44 +08:00
玉澜 8f3a9e9d4a test(skills): cover Windows permission preparation seams 2026-08-17 20:19:41 +08:00
玉澜 567ea5d77c test(skills): make mode checks portable on Windows 2026-08-17 20:19:39 +08:00
玉澜 fc18f8fd04 fix(skills): preserve read-only backup trees 2026-08-17 20:19:37 +08:00
玉澜 a39ad4e6af fix(skills): make backups cross-filesystem safe 2026-08-17 20:19:34 +08:00
玉澜 301429e3aa test(ci): cover canonical skill platform branches 2026-08-17 20:19:32 +08:00
玉澜 0af5751d75 fix(skills): harden canonical agent installation 2026-08-17 20:19:30 +08:00
玉澜 79f7ee80e0 fix(skills): complete canonical agent compatibility 2026-08-17 20:19:28 +08:00
玉澜 44d640bbae fix(skills): use canonical global installation 2026-08-17 20:19:25 +08:00
恋川 06b4f3ba31 merge main into feat/recruit-job to #85340676 2026-08-17 20:00:13 +08:00
恋川 9f983be1ac fix(recruit): validate job response identity to #85340676 2026-08-17 19:55:12 +08:00
恋川 80bca147e0 Merge remote-tracking branch 'upstream/main' into feat/recruit-job 2026-08-17 17:21:31 +08:00
恋川 2cea069f55 fix(recruit): scope lossless number decoding to #85340676 2026-08-17 16:09:43 +08:00
恋川 208a6c0273 Merge remote-tracking branch 'upstream/main' into feat/recruit-job 2026-08-17 15:28:04 +08:00
玉澜 4e8469a175 test(skills): cover Windows same-file identity seam with synthetic info
skillPathSameFileIdentityImpl on Windows always returns false and is
never reached through skillPathIdentityProven (which uses file IDs
exclusively). Add a direct seam call with synthetic os.FileInfo to
exercise the Windows return-false path and the Unix os.SameFile path
with nil Sys().
2026-08-17 15:25:32 +08:00
恋川 b7a07abcb1 test(recruit): cover cursor through response pipeline to #85340676 2026-08-17 15:14:22 +08:00
玉澜 2292a49c6a refactor(skills): collapse Windows identity error paths for coverage
Restructure skillPathFileIdentityImpl to use nested if-err-nil with a
named return and skillPathIdentityProven to use a single expression.
Error conditions now fall through to the bare return instead of
occupying separate coverage blocks, eliminating 5 uncovered statements
that the Windows coverage gate flagged at 99.4193%.
2026-08-17 15:00:25 +08:00
玉澜 cf43cf1b47 style: gofmt alignment after adding skillPathSameFileIdentity seam 2026-08-17 14:41:55 +08:00
玉澜 344104268a fix(skills): open reparse points in Windows file ID query and stabilize tunneled test
Add FILE_FLAG_OPEN_REPARSE_POINT to the Windows CreateFile call in
skillPathFileIdentityImpl so symlinks are opened as reparse points
rather than followed to their target. Staged symlinks carry relative
targets computed for the final destination, which may not resolve from
the staging directory; following them caused CreateFile to fail,
yielding an empty file ID that rejected publication and broke canonical
skill layout migration on Windows.

Make skillPathSameFileIdentity a seam variable so the tunneled
replacement test can deterministically simulate the identity change on
Unix. On tmpfs (used by Linux CI runners), os.SameFile can return true
for a recreated file due to inode reuse, making the test flaky. On
Windows the swap is a no-op because skillPathIdentityProven compares
file IDs from GetFileInformationByHandle and ignores
skillPathSameFileIdentity.
2026-08-17 14:39:59 +08:00
恋川 89027aa2e2 fix(recruit): distinguish business failures and unwrap once to #85340676 2026-08-17 14:05:14 +08:00
玉澜 e45608bb13 fix(skills): prove Windows rollback identity via stable file ID
NTFS file tunneling can restore the original creation time for a
recreated same-named object, which defeated the creation-time
incarnation check and allowed rollback to delete a concurrent
replacement. Replace the platform-specific identity pair with a single
skillPathIdentityProven function:

- Unix: delegates to os.SameFile (inode/dev), ignoring file ID strings
- Windows: compares VolumeSerialNumber:FileIndexHigh:FileIndexLow from
  GetFileInformationByHandle, which uniquely identifies the file on the
  volume for its lifetime and is unaffected by tunneling

When the file ID cannot be obtained at publish time, identity is not
proven and the auto-delete is refused. Add a regression test that
simulates tunneled creation time and verifies rollback still refuses
the concurrent replacement.
2026-08-17 14:03:28 +08:00
玉澜 ff6e2347f6 test(skills): cover non-EEXIST link error on Windows
On Windows isNoReplaceRenameUnsupported always returns false, so the
fallback is never entered from the invalid-path test. Force the fallback
and swap skillPathLink to a non-EEXIST error to cover line 94 on all
platforms.
2026-08-17 13:39:12 +08:00
玉澜 2d29f6601b test(skills): cover all no-replace fallback branches for 100% coverage
Add tests for mkdir non-EEXIST error, remove failure after rename
failure, first-rename-succeeds path (Linux behavior), retry-rename
path, and non-regular source safe-fail. All 24 changed executable
statements now covered on both macOS and Windows.
2026-08-17 13:22:58 +08:00
玉澜 fa887ccd26 fix(skills): eliminate TOCTOU in no-replace rename fallback
The fallback path for filesystems without RENAME_NOREPLACE/EXCL (NFS,
FUSE, overlayfs) used Lstat-then-Rename, which could overwrite a
concurrently created destination between the check and the rename.

Replace the TOCTOU-prone check with truly atomic no-clobber primitives:
- Directories: os.Mkdir atomically claims the destination (fails with
  EEXIST if occupied). On Linux rename(2) replaces the empty dir
  directly; on Darwin/Windows rename refuses existing dirs so the empty
  dir is removed and the rename retried — any concurrent creation
  between remove and rename is detected by the second rename failing.
- Files: os.Link atomically fails if the destination exists, then
  os.Remove completes the move.

Add concurrent-creation test covering the mkdir→rename race window.
2026-08-17 13:11:15 +08:00
玉澜 30202e2b81 Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills 2026-08-17 12:30:34 +08:00
玉澜 1203409185 test(skills): cover Windows stat-error branch in no-replace fallback
The !os.IsNotExist(statErr) branch in renameSkillPathNoReplace was
uncovered on Windows. Inject errNoReplaceRenameUnsupported for the
atomic rename and os.ErrPermission for skillPathLstat so the stat-error
path is exercised on every platform.
2026-08-17 12:23:40 +08:00
玉澜 0ba55350d8 fix(ci): unset XDG_CONFIG_HOME for npm installer smoke test
The smoke test creates temp home directories with .config/kimchi markers
for agent detection. On Linux CI runners XDG_CONFIG_HOME may point to the
runner's real config path, causing resolvedAgentTargets to look outside
the temp home. Unset it so detection resolves against the test's temp dir.
2026-08-17 11:48:11 +08:00
玉澜 14c2569cfb fix(skills): guard pruneSkillBackups against non-DWS directories
Restrict backup pruning to directories whose names match the DWS stamp
format (YYYYmmdd-HHMMSS with optional -N suffix) across all 8 installer
surfaces (Go, npm, 4 shell, 2 PowerShell). Unknown directories in
~/.dws/skill-backups are now preserved. Also fixes Windows coverage test
portability and covers the remaining macOS changed-code gap (retire
warning loop in runUpgrade).
2026-08-17 11:42:43 +08:00
john ae1565c0ff Merge branch 'main' into fix/canonical-agent-skills 2026-08-17 10:23:34 +08:00
玉澜 7581955892 fix(skills): make obsolete-copy retirement non-fatal and harden install
A universal Agent whose obsolete private copy cannot be retired installs
nothing there, yet every entry point counted that retirement failure as an
install failure — aborting `npm install`, `dws skill setup`, and the shell
installers even when the canonical store and all links published correctly,
and skipping the skills-state write. Route retirement failures to a separate
warning path across all surfaces (Go upgrade + skill setup, npm, PowerShell,
install.sh, install-skills.sh, install-event.sh, install-devapp.sh).

Also:
- Add a checked-rename fallback for filesystems that reject the atomic
  no-replace flag (NFS, FUSE, overlayfs); the no-clobber contract is kept and
  the previously unsupported platforms build and work.
- PowerShell multi-mode links only bundle skills, never the shared canonical
  store, so third-party/user skills are no longer fanned into every Agent root.
- Prune ~/.dws/skill-backups to the newest 5 on every surface; encode
  HOME-relative backup names on PowerShell to preserve origin.
- Add simulated-win32 junction coverage and rewrite the tautological
  no-replace test; remove dead code whose tests gave false coverage.
- Soften the overstated Windows ownership-proof comment (NTFS tunneling).
2026-08-15 14:26:11 +08:00
玉澜 24bbdda423 test(skills): cover Windows no-replace path errors 2026-08-14 20:22:32 +08:00
玉澜 276658590b fix(skills): retain Windows reparse link publication 2026-08-14 20:14:04 +08:00
玉澜 16d20b8178 fix(skills): compare Windows publication identity stably 2026-08-14 20:07:33 +08:00
玉澜 dd89c67f4d Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-14 20:00:47 +08:00
玉澜 75bf44b7be test(skills): cover post-publish identity reuse 2026-08-14 19:58:14 +08:00
玉澜 1bae872341 fix(skills): distinguish reused publication inodes 2026-08-14 19:47:04 +08:00
玉澜 d1ecdcd551 Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-14 19:24:09 +08:00
玉澜 a47740ca1c fix(skills): make publication rollback race-safe 2026-08-14 19:23:59 +08:00
玉澜 4ad321557f Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-14 18:04:34 +08:00
玉澜 34dee96833 fix(skills): make PowerShell rollback race-safe 2026-08-14 17:51:12 +08:00
玉澜 fc455f800c test(skills): junction-safe rollback and per-agent degrade regressions
- extend the silent-rollback contract to install-event.sh
- static contract: Restore-MultiSkillSet removes published paths lexically
  (section-scoped so identity-anchor refactors keep the guarantee) and link
  staging dirs are cleaned via Remove-LinkStageRoot / Remove-DevLinkStageRoot
- install-event.sh integration test: an uninstallable agent target is
  skipped loudly while later agents still receive links
- pwsh probe: Test-SamePhysicalSkillRoot must dereference junctions and
  symlinks (junction idempotency asserted where junctions are creatable)
2026-08-14 17:14:13 +08:00
玉澜 3556d28fdd fix(skills): junction-safe PowerShell rollback and per-agent degrade
- install.ps1: remove published junctions lexically in Restore-MultiSkillSet
  (Windows PowerShell 5.1 follows reparse points during Remove-Item -Recurse
  and could delete canonical store contents); clean link staging dirs
  lexically in Publish-CanonicalSkillLinks and Move-SkillPathRecoverably
- install.ps1: Test-SamePhysicalSkillRoot now dereferences junctions via
  Get-PhysicalSkillPath (mirrors EvalSymlinks/realpathSync/cd -P), so reruns
  recognize already-published junctions instead of backup churn
- install-event.sh: replace silent 'mv ... 2>/dev/null || true' rollback with
  the loud backup-retained failure contract already enforced for devapp
- event/devapp sh+ps1: link→copy fallback and per-agent failures now degrade
  per agent like install.sh (skip loudly, continue, report at the end)
  instead of aborting mid-loop or swallowing errors
- tests: junction-lexical removal contract, event per-agent degrade
  integration test, pwsh junction physical-root recognition + rerun
  idempotency (no backup churn)
2026-08-14 16:48:34 +08:00
玉澜 618eb842a2 test(skills): retain rollback identity anchor 2026-08-14 16:11:48 +08:00
玉澜 465acf1406 Revert "test(skills): retain rollback identity anchor"
This reverts commit ce73a5b452.
2026-08-14 16:11:13 +08:00
玉澜 ce73a5b452 test(skills): retain rollback identity anchor 2026-08-14 16:09:24 +08:00
玉澜 175b51cec0 fix(skills): retain link identity anchors through rollback 2026-08-14 15:45:51 +08:00
玉澜 53a71b08c7 fix(skills): protect shell link rollback from races 2026-08-14 15:35:25 +08:00
玉澜 3ef735bb3c Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-14 14:44:08 +08:00
恋川 44e18a4062 Merge remote-tracking branch 'upstream/main' into feat/recruit-job 2026-08-14 14:37:05 +08:00
恋川 ccbacf84b3 test(helpers): cover trailing MCP JSON responses 2026-08-14 14:36:49 +08:00
玉澜 7cfaa1ca74 fix(skills): fail upgrade unconditionally when canonical publish fails
A failed canonical publish only failed the upgrade when
hasDependentSkillRoot reported a non-universal link target; that helper
explicitly skipped universal agents, which are exactly the direct consumers
of ~/.agents/skills. On a universal-only machine (e.g. only Codex
installed), UpgradeSkillLocations* returned a nil error with nothing
installed, contradicting the documented "canonical publication is
mandatory and fails the upgrade loudly" contract.

Canonical publish failures now return an error unconditionally in both the
mono and multi branches, and hasDependentSkillRoot is removed. The test
that pinned the old standalone-does-not-fail-fast behavior now asserts
error propagation in both modes.
2026-08-14 14:27:57 +08:00
恋川 d8f8f29062 fix(recruit): unwrap connector result envelopes 2026-08-14 14:06:08 +08:00
玉澜 f8af8dc1dc Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-14 12:04:50 +08:00
玉澜 bb6fd2f256 fix(skills): remove ineffective app detection gate 2026-08-14 11:58:27 +08:00
玉澜 580c4d201b Revert "fix(skills): make app-bundle detection gate HOME-independent"
This reverts commit 37cd629335.
2026-08-14 11:43:57 +08:00
恋川 4f754133a5 fix(recruit): align pagination and size contracts 2026-08-14 11:43:39 +08:00
玉澜 37cd629335 fix(skills): make app-bundle detection gate HOME-independent
The allowSystemApps gate (homeDir == systemHome) was effectively a no-op in
production: systemHome came from os.UserHomeDir, which honors the $HOME env
override just like homeDir, so the two were always equal and the gate never
fired when $HOME was overridden.

ResolveSystemHomeDir now prefers the OS user database (getpwuid on Unix),
which is independent of $HOME, falling back to $HOME only when the user record
cannot be resolved. Production behavior is unchanged (a real $HOME still
matches); an isolated/overridden HOME now correctly skips machine-wide
/Applications discovery for zcode/minimax. The app surface references the same
shared resolver.

This is the correct fix for the hermeticity concern (machine-wide state leaking
into an isolated HOME): there is no cross-surface production inconsistency to
port — script installers always operate on the real user HOME in practice, so
they need no gate.
2026-08-14 11:33:55 +08:00
恋川 b447aac84b Merge remote-tracking branch 'upstream/main' into feat/recruit-job 2026-08-14 11:31:33 +08:00
恋川 9703a21a2d fix(recruit): normalize connector list response 2026-08-14 10:43:50 +08:00
玉澜 0c0e2b3ce1 test(skills): cover Windows chmod failure branch 2026-08-14 10:32:20 +08:00
玉澜 7d16c9693f test(skills): cover Windows permission preparation seams 2026-08-14 10:22:03 +08:00
玉澜 1dee02d900 test(skills): make mode checks portable on Windows 2026-08-14 10:08:40 +08:00
玉澜 7664f04fda fix(skills): preserve read-only backup trees 2026-08-14 08:50:14 +08:00
玉澜 8177a06296 Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-14 08:43:04 +08:00
玉澜 c674366aea fix(skills): make backups cross-filesystem safe 2026-08-14 08:42:54 +08:00
chichuan ce5815a606 Merge branch 'main' into fix/canonical-agent-skills 2026-08-13 22:01:48 +08:00
玉澜 d211b79a80 test(ci): cover canonical skill platform branches 2026-08-13 21:49:35 +08:00
玉澜 de8df0fa6f fix(skills): harden canonical agent installation 2026-08-13 21:22:57 +08:00
玉澜 9ff31cdd55 Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-13 20:23:05 +08:00
玉澜 fde37f7896 fix(skills): complete canonical agent compatibility 2026-08-13 20:21:56 +08:00
玉澜 31902a987e fix(skills): use canonical global installation 2026-08-13 18:50:27 +08:00
恋川 5ef52503ae fix(recruit): align result and cursor contracts 2026-08-13 15:37:49 +08:00
恋川 8ee9fc3f48 fix: 补充招聘结果与分页契约 to#85340676 2026-08-13 13:50:18 +08:00
恋川 19e19bcd2b feat: 新增招聘职位管理 to#85340676 2026-08-13 10:26:03 +08:00
130 changed files with 24268 additions and 1657 deletions
@@ -0,0 +1,5 @@
---
category: Changed
---
- **Chat group roles** (#1058) — exposes the single-value `--role-id` flag for assigning one custom group role while preserving hidden `--role-ids` compatibility.
+5
View File
@@ -0,0 +1,5 @@
---
category: Added
---
- **招聘职位管理** (#976) — 新增招聘职位列表、详情查询和职位创建命令。
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
---
category: Fixed
---
- **Chat user mentions** — preserves literal `<@openDingTalkId>` tokens in current-user Markdown messages and rejects mismatches between message-body mentions and mention flags before sending.
- **Chat direct media** — uses the IM upload target field for current-user direct file, audio, and video uploads, then uses the Chat receiver field for final message delivery.
@@ -0,0 +1,5 @@
---
category: Changed
---
- **CLI compatibility governance** — adds a reviewed two-stage path for hiding retained legacy commands or optional `NoOpt=true` boolean flags from Help and Schema when their activated capability moves to a dedicated command, with legacy-leaf, complete parameter/constant mapping, durable runtime constant evidence, protected framework bridges, dry-run preservation, parameter-collision, and fail-closed required-parameter checks.
+6
View File
@@ -512,6 +512,12 @@ jobs:
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
run: node .github/reviewer-routing.test.js
- name: Test npm installer smoke (prune, backup, publish)
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
env:
XDG_CONFIG_HOME: ""
run: node test/scripts/install_js_smoke.mjs
test-focused:
name: "Test (focused: ${{ matrix.shard }})"
needs: lint
+2 -2
View File
@@ -74,9 +74,9 @@ coverage is additionally selected for platform-sensitive code.
`make authoritative-interface-integrity BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`.
The Make target delegates to the authoritative wrapper; CI does not invoke a
second comparator or the legacy fixture checker. See
[CLI flag compatibility migration governance](docs/cli-interface-flag-migrations.md)
[CLI Help / Schema compatibility migration governance](docs/cli-interface-flag-migrations.md)
for the reviewed two-stage `pending` → `consumed` lifecycle.
Agent-visible flag migrations must also run
Agent-visible flag or command-path migrations must also run
`make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`;
it consumes the same base-owned ledger rather than a second exception list.
5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.59-beta.2"
version "1.0.59-beta.3"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-darwin-arm64.tar.gz"
sha256 "7f11218d3222f3e93c3b1e94b3a004c061eb0a297b206447ea95fe6b2b1ec674"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-arm64.tar.gz"
sha256 "9c99adcefd9104368eb443f0a1b4af8e7aceaa1ffdd4462e486854c1692bb6ce"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-darwin-amd64.tar.gz"
sha256 "72f06a334cf29d23123639fabe13bf2951765066136e47ccc6453667c382f8c2"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-amd64.tar.gz"
sha256 "f5cc8efb1f982d68ae549190fd683292359c2ab542b532fa52bb35e6b5c049af"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-linux-arm64.tar.gz"
sha256 "3edbfabb7718b53914a2d9efe7b1152e9ebd70765ff0b6f19ad3125e4a2458ed"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-arm64.tar.gz"
sha256 "7a4efd04b417ce8013b1e431274b396179958da244164f59974358ba327ff093"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-linux-amd64.tar.gz"
sha256 "e1c610070a9c1b3763656cbd53c818290f199097adc07cb9fe629ed765c5e1e0"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-amd64.tar.gz"
sha256 "90181e8f2e9010c1943a5773c3d45d7d3ac85d6bc93e18a9aaa7c69909e553d7"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-skills.zip"
sha256 "aa2854651eaa2c857b526aaec73fd1358d31b11999fe7fd3e99e5060b2522a1f"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-skills.zip"
sha256 "e7028914a4a826af9b18ed4922d68fa8f279473817fed4f305465bc8a7aad363"
end
def install
+5 -4
View File
@@ -210,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur
The upgrade process follows a two-phase atomic flow to ensure consistency:
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into detected agent-specific roots (for example `~/.codex/skills/dingtalk-chat`). `~/.agents/skills` is used only when no specific Agent is detected; once a specific root is active, older DWS-managed generic copies are backed up and retired so the same Skill is not discovered twice.
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into the canonical `~/.agents/skills` root. Agents classified by the pinned compatibility registry as supporting the universal root read it directly; other detected Agents receive links to the canonical copy, with a direct-copy fallback when links are unavailable. Older DWS-managed agent-specific copies are backed up and retired so the same Skill is not discovered twice.
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
@@ -405,7 +405,7 @@ After installing, AI tools like Claude Code / Cursor can operate DingTalk direct
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> Installers prefer detected agent-specific roots such as `$HOME/.codex/skills/`. They use `.agents/skills/` only as the generic fallback when no specific Agent is detected; multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
> Installers use `$HOME/.agents/skills/` as the canonical global store, following the universal `.agents/skills` convention. Agents classified by the pinned compatibility registry as universal read that root directly; detected non-universal Agents receive links to it (or copies when links are unavailable). Multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
>
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
@@ -482,7 +482,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
<details>
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and six OA approval task/instance events.
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and seven OA approval task/instance events.
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
@@ -530,12 +530,13 @@ dws event consume user_im_group_disbanded --group <openConversationId> --flatten
dws event +listen-im --kind sender --user <userId> \
--events message,read,recall -f ndjson
# Listen for all six public OA approval events in one process
# Listen for all seven public OA approval events in one process
dws event consume \
user_oa_approval_task_created \
user_oa_approval_task_finished \
user_oa_approval_task_redirected \
user_oa_approval_instance_started \
user_oa_approval_instance_cc \
user_oa_approval_instance_terminated \
user_oa_approval_instance_finished \
--flatten -f ndjson
+3 -2
View File
@@ -476,7 +476,7 @@ multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权
<details>
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及六个 OA 审批任务/实例事件。
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及七个 OA 审批任务/实例事件。
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
@@ -524,12 +524,13 @@ dws event consume user_im_group_disbanded --group <openConversationId> --flatten
dws event +listen-im --kind sender --user <userId> \
--events message,read,recall -f ndjson
# 一个进程监听全部六个公开 OA 审批事件
# 一个进程监听全部七个公开 OA 审批事件
dws event consume \
user_oa_approval_task_created \
user_oa_approval_task_finished \
user_oa_approval_task_redirected \
user_oa_approval_instance_started \
user_oa_approval_instance_cc \
user_oa_approval_instance_terminated \
user_oa_approval_instance_finished \
--flatten -f ndjson
+1214 -126
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -32,6 +32,6 @@
"README.md"
],
"engines": {
"node": ">=16"
"node": ">=16.7.0"
}
}
+60 -4
View File
@@ -157,6 +157,16 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
"",
"candidate flag migration manifest",
)
approvedCommandMigrationsPath := flags.String(
"approved-command-migrations",
"",
"merge-base-owned approved command migration manifest",
)
candidateCommandMigrationsPath := flags.String(
"candidate-command-migrations",
"",
"candidate command migration manifest",
)
if err := flags.Parse(args); err != nil {
return false, err
}
@@ -174,8 +184,13 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
"--approved-flag-migrations and --candidate-flag-migrations must be provided together",
)
}
if *approvedMigrationsPath != "" && (*basePath == "" || *stablePath == "") {
return false, fmt.Errorf("flag migration compare requires both --base and --stable")
if (*approvedCommandMigrationsPath == "") != (*candidateCommandMigrationsPath == "") {
return false, fmt.Errorf(
"--approved-command-migrations and --candidate-command-migrations must be provided together",
)
}
if (*approvedMigrationsPath != "" || *approvedCommandMigrationsPath != "") && (*basePath == "" || *stablePath == "") {
return false, fmt.Errorf("migration compare requires both --base and --stable")
}
current, err := readSnapshot(*currentPath)
@@ -197,7 +212,39 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
}
report := interfacesnapshot.CompareAll(current, references)
if *approvedMigrationsPath != "" {
if *approvedCommandMigrationsPath != "" {
flagApproved := interfacesnapshot.FlagMigrationManifest{Version: interfacesnapshot.FlagMigrationManifestVersion, Migrations: []interfacesnapshot.FlagMigration{}}
flagCandidate := flagApproved
if *approvedMigrationsPath != "" {
flagApproved, err = readFlagMigrationManifest(*approvedMigrationsPath)
if err != nil {
return false, fmt.Errorf("read approved flag migrations: %w", err)
}
flagCandidate, err = readFlagMigrationManifest(*candidateMigrationsPath)
if err != nil {
return false, fmt.Errorf("read candidate flag migrations: %w", err)
}
}
commandApproved, readErr := readCommandMigrationManifest(*approvedCommandMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read approved command migrations: %w", readErr)
}
commandCandidate, readErr := readCommandMigrationManifest(*candidateCommandMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read candidate command migrations: %w", readErr)
}
report, err = interfacesnapshot.CompareAllWithInterfaceMigrations(
current,
references,
flagApproved,
flagCandidate,
commandApproved,
commandCandidate,
)
if err != nil {
return false, fmt.Errorf("validate interface migration lifecycle: %w", err)
}
} else if *approvedMigrationsPath != "" {
approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read approved flag migrations: %w", readErr)
@@ -234,6 +281,15 @@ func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationMani
return interfacesnapshot.ReadFlagMigrationManifest(file)
}
func readCommandMigrationManifest(path string) (interfacesnapshot.CommandMigrationManifest, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
return interfacesnapshot.CommandMigrationManifest{}, err
}
defer file.Close()
return interfacesnapshot.ReadCommandMigrationManifest(file)
}
func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error {
for _, command := range snapshot.Commands {
path := strings.TrimPrefix(command.Path, "dws")
@@ -280,5 +336,5 @@ func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage:")
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE]")
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE] [--approved-command-migrations FILE --candidate-command-migrations FILE]")
}
+123
View File
@@ -166,6 +166,102 @@ func TestCrossPlatformCoverageRunCompareRequiresBothFlagMigrationInputs(t *testi
}
}
func TestCrossPlatformCoverageRunCompareCommandMigrationInputs(t *testing.T) {
dir := t.TempDir()
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
emptyFlag := writeManifest(t, dir, "empty-flags.json", `{"version":1,"migrations":[]}`)
emptyCommand := writeManifest(t, dir, "empty-commands.json", `{"version":1,"migrations":[]}`)
invalid := writeManifest(t, dir, "invalid-commands.json", `{`)
var stdout, stderr bytes.Buffer
args := []string{
"compare",
"--current", snapshotPath,
"--base", snapshotPath,
"--stable", snapshotPath,
"--approved-flag-migrations", emptyFlag,
"--candidate-flag-migrations", emptyFlag,
"--approved-command-migrations", emptyCommand,
"--candidate-command-migrations", emptyCommand,
}
if exitCode := run(args, &stdout, &stderr); exitCode != 0 {
t.Fatalf("combined migration compare exit=%d stderr=%s", exitCode, stderr.String())
}
for _, test := range []struct {
name string
approved string
candidate string
want string
}{
{"approved flag", invalid, emptyFlag, "read approved flag migrations"},
{"candidate flag", emptyFlag, invalid, "read candidate flag migrations"},
} {
t.Run(test.name, func(t *testing.T) {
stdout.Reset()
stderr.Reset()
testArgs := []string{
"compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath,
"--approved-flag-migrations", test.approved,
"--candidate-flag-migrations", test.candidate,
"--approved-command-migrations", emptyCommand,
"--candidate-command-migrations", emptyCommand,
}
if exitCode := run(testArgs, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
t.Fatalf("combined flag error exit=%d stderr=%s", exitCode, stderr.String())
}
})
}
for _, test := range []struct {
name string
approved string
candidate string
want string
}{
{"approved", invalid, emptyCommand, "read approved command migrations"},
{"candidate", emptyCommand, invalid, "read candidate command migrations"},
} {
t.Run(test.name, func(t *testing.T) {
stdout.Reset()
stderr.Reset()
testArgs := []string{
"compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath,
"--approved-command-migrations", test.approved,
"--candidate-command-migrations", test.candidate,
}
if exitCode := run(testArgs, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
t.Fatalf("command manifest error exit=%d stderr=%s", exitCode, stderr.String())
}
})
}
stderr.Reset()
if exitCode := run([]string{
"compare", "--current", snapshotPath, "--base", snapshotPath,
"--approved-command-migrations", emptyCommand,
}, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), "provided together") {
t.Fatalf("one-sided command manifest exit=%d stderr=%s", exitCode, stderr.String())
}
if _, err := readCommandMigrationManifest(filepath.Join(dir, "missing.json")); err == nil {
t.Fatal("missing command migration manifest unexpectedly read")
}
if _, err := readCommandMigrationManifest(invalid); err == nil {
t.Fatal("invalid command migration manifest unexpectedly read")
}
pending := writeManifest(t, dir, "pending-command.json", commandMigrationManifestJSON("pending"))
consumed := writeManifest(t, dir, "consumed-command.json", commandMigrationManifestJSON("consumed"))
stderr.Reset()
if exitCode := run([]string{
"compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath,
"--approved-command-migrations", pending,
"--candidate-command-migrations", consumed,
}, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), "validate interface migration lifecycle") {
t.Fatalf("command lifecycle error exit=%d stderr=%s", exitCode, stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareRequiresBothReferencesForFlagMigrations(t *testing.T) {
dir := t.TempDir()
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
@@ -567,6 +663,33 @@ func flagMigrationManifestJSON(state string) string {
}`, "STATE", state, 1)
}
func commandMigrationManifestJSON(state string) string {
return strings.Replace(`{
"version": 1,
"migrations": [{
"kind": "command_move",
"legacy": {
"command": "dws chat message old",
"before": {"present": true, "runnable": true},
"after": {"present": true, "runnable": true, "hidden": true}
},
"replacement": {
"command": "dws chat topic new",
"before": {"present": false},
"after": {"present": true, "runnable": true}
},
"schema": {
"product_id": "chat",
"source_tool_id": "chat.move",
"replacement_tool_id": "chat.move",
"parameters": []
},
"state": "STATE",
"reason": "reviewed command migration"
}]
}`, "STATE", state, 1)
}
func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
for _, flag := range flags {
if flag.Name == name && flag.Type == flagType {
+5 -5
View File
@@ -184,11 +184,11 @@ candidate SHA。
`check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准
flag 迁移。
Schema compatibility 使用同一组 base、stable、candidate refs 和同一份 base-owned flag
migration ledger。merge-base-owned checker 分别规范化 merge-base 与 stable 的完整
Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 Interface
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
放宽其他 Schema 历史字段。
Schema compatibility 使用同一组 base、stable、candidate refs,以及 base-owned flag
与 command migration ledgers。merge-base-owned checker 分别规范化 merge-base 与
stable 的完整 Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过
Interface lifecycle 的 exact rename、command move 或 flag extraction 规范化到当前历史
副本,不会维护第二份 allowlist,也不会放宽其他 Schema 历史字段。
For a release-seal branch that archives rendered fragments:
+49 -2
View File
@@ -1,7 +1,9 @@
# CLI flag 兼容迁移治理
# CLI Help / Schema 兼容迁移治理
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。
同一套 base-owned lifecycle 也治理两类跨命令迁移:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;或把旧命令中的一个可选 flag 拆成新的专用命令。跨命令迁移只允许清单精确声明的 `command_became_hidden` / `flag_became_hidden` 及其 Schema 投影,不是通用 command-path breaking-change 豁免。
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
只能进入本文的 JSON lifecycle ledger。一项迁移不得跨两种机制组合授权。
@@ -31,7 +33,7 @@ Smoke fixture,不参与迁移审批。
同时提供 `--base` 与 `--stable`;核心 lifecycle 也拒绝缺失 stable 的非空清单,避免
调用方因漏传历史参考而提前清理 consumed receipt。
PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入本机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空清单,不会让 candidate 新增的 comparator 决定本 PR 是否兼容。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。
PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入 flag 机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空 flag 清单。后续引入 command migration 扩展时,base 已拥有 flag comparator;bootstrap 仍只执行 base-owned 普通比较,不向旧 helper 传入新的 command ledger,因此允许随治理 PR 提交仍处于 before 的 pending 计划,也不会授予任何迁移豁免。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。
这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。
@@ -39,10 +41,55 @@ PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁
```text
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
scripts/policy/interface-migrations/approved-command-migrations-v1.json
```
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
## 跨命令迁移原语
`approved-command-migrations-v1.json` 只接受两种 `kind`:
| kind | CLI after 状态 | Schema 允许的精确投影 |
|---|---|---|
| `command_move` | legacy 命令仍 runnable、由 visible 变 hidden;replacement 由 absent 变 visible runnable | 同一 stable tool identity 的 `primary_cli_path` 改到 replacement;只允许清单列出的参数改名,参数类型、property、requiredness、default 等必须等价 |
| `flag_extraction` | legacy 命令保持 visible runnable;指定 legacy flag 仍可执行但由 visible 变 hidden;replacement 由 absent 变 visible runnable | source tool 只删除指定参数;replacement tool 必须位于精确的新路径,并保持 source 的 interface 与 safety identity;清单必须完整列出每个 source 参数到 replacement 参数或常量 property 的承接关系 |
`command_move` 只能隐藏没有子命令的 legacy leaf,且 legacy 与 replacement
不得互为祖先路径;整棵命令树的迁移需要单独设计逐叶治理,不能复用这一原语。
稳定 Schema tool 可以继续接受普通的 optional 参数新增,但不得借路径迁移引入清单未登记的
`required`、`cli_required` 或 `required_when` 参数;参数改名的目标也不得与历史
Schema 中已有的其他参数重名,避免把两个历史参数静默合并。`flag_extraction` 只接受
optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。它必须对 source tool
的全部历史参数逐项声明:普通参数使用精确 `from` → `to`(同名也必须显式写出),且恰好
一个与 legacy flag 同名的 `from` 使用 `replacement_constant`,不得同时声明 `to`;所有
`from` 与 replacement 参数/property 目标必须唯一。legacy bool flag 的 `no_opt` 必须等于
常量布尔值的字符串形式。v1 只治理 optional bool flag 的 `NoOpt=true` 激活分支,因此
`replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即
`true` 或固定 `false` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。
`replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot
必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、
值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create`
必须通过 `NewLeafCommand` 的 `ConstParams` 声明并实际注入
`convThreadEnabled=true`;手写 `RunE` 固定值、Cobra annotation 或只改清单都不能提供这份
同源证据,Snapshot 只读取 `corecmd` 包内私有注册表公开的只读副本。第一次向旧快照增加
bool 常量证据属于 bootstrap;一旦任一历史快照已记录该
证据,普通 Interface Compare 会持续要求 property/value 集合完全一致,因此 ledger 清理后
删除、翻转或增加常量仍会阻塞。若 candidate 改动 command ledger,则
`internal/corecmd/corecmd.go`、`internal/corecmd/interface_const_params.go` 与
`internal/helpers/leaf.go` 三份执行/证据桥必须保持 base Git blob 不变;框架演进必须先用
独立 PR 合入,不能和产品消费混在一起。
replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run` 非空时不得删除或改值;
历史未声明时允许 replacement 新增 dry-run。这与普通 Schema 兼容规则保持同一单调边界。
两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。
跨命令清单复用下文同一套 `pending → consumed → cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
当前首批 pending 记录覆盖 `chat topic` 收口:`chat group create --thread` 拆到 `chat topic create`,以及 `chat message list-topic-replies` / `forward-topic` 迁到对应的 `chat topic` 命令。前一条完整登记 `name` / `type` / `users` 的同名承接,以及 `thread` → `convThreadEnabled=true` 的常量承接。产品 PR 消费这些记录时只能把三条 `state` 改为 `consumed`,不得改写其 before、after、Schema mapping、constant 或 reason。
## 两阶段迁移与回执清理
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
+57 -6
View File
@@ -54,8 +54,8 @@ DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约
| 模式 | Agent 目录布局 | 选择方式 |
|---|---|---|
| multi(默认) | `<agent-home>/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` |
| mono(兼容) | `<agent-home>/dws/` | `dws skill setup --mode mono` 或安装器的 mono opt-in |
| multi(默认) | canonical `~/.agents/skills/dingtalk-*/`;非 universal Agent 使用链接或复制兼容层 | 默认;`dws skill setup --mode multi` |
| mono(兼容) | canonical `~/.agents/skills/dws/`;非 universal Agent 使用链接或复制兼容层 | `dws skill setup --mode mono` 或安装器的 mono opt-in |
模式切换通过重新执行 setup 完成。安装 multi 前备份并移除 mono 的 `dws/`;安装
mono 前只备份并移除能够证明由 DWS 管理的 multi 目录。两个方向都不提供隐式、
@@ -140,10 +140,26 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen
## 8. Upgrade 与恢复语义
升级器对每个 Agent 目标执行:
升级器始终先发布 `~/.agents/skills` canonical 集合。固定兼容注册表中被分类为
universal 的 Agent 不再保留 Agent 私有副本;检测到的
非 universal Agent(如 Claude、OpenClaw、Hermes、Windsurf)使用指向 canonical
的目录链接:npm 与 PowerShell 安装器在 Windows 上创建 junction,`dws upgrade` /
`dws skill setup` 创建符号链接(`os.Symlink`)。链接不可用时回退为内容完整的
直接复制,包括未开启开发者模式、因而无法创建符号链接的 Windows。
自定义 `CODEX_HOME`、`CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`AUTOHAND_HOME`、
`GROK_HOME`、`VIBE_HOME`、`XDG_CONFIG_HOME` 与 OpenClaw 历史目录 `.clawdbot`、
`.moltbot` 必须按 Agent 实际优先级解析。
- 先探测具体 Agent home;只在没有任何具体 Agent 时使用 `~/.agents/skills` 通用 fallback;
- 具体 Agent 安装成功后,将 `~/.agents/skills` 中旧的 DWS 受管副本可恢复地迁入备份,避免 Codex 等同时扫描两个根目录时重复发现同名 Skill;
Agent 兼容矩阵以 `vercel-labs/skills` 的 `agents.ts` 与 `installer.ts`(基准提交
`c6f69c6`)为契约:76 个 ID 必须完整登记,其中 19 个 universal、57 个
non-universal。`eve`、`promptscript` 没有全局目录,因此全局安装时跳过;多个 Agent
解析到同一个 XDG 目录时按最终绝对路径去重(Windows 大小写不敏感)。DWS 额外支持
Qoderwork(按 non-universal Agent 建立兼容链接);旧版使用的 `.github/skills`、
`.amp/skills`、`.cline/skills` 与
`.windsurf/skills` 仅作为可恢复迁移清理目标,不计入上游 Agent 枚举。
对 universal Agent,上游 installer 的 global 模式明确选择 canonical 并跳过
Agent 私有 global 目录;注册表中的 `globalSkillsDir` 仍用于识别和退役历史 native
路径,不作为 universal symlink 模式的发布目标。
1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill;
2. 在目标文件系统的 staging 中复制完整新集合;
@@ -151,6 +167,14 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen
4. 逐项发布 staging;任一发布失败时删除已发布的新目录,并逆序恢复该目标的全部旧目录;
5. 仅在没有目标失败且至少一个目标成功时更新状态快照。
旧集合可能位于外部卷或自定义 Agent 根,而备份固定写入
`~/.dws/skill-backups`。因此备份与反向恢复统一采用 rename-first:同卷直接原子
rename;遇到跨文件系统错误时,在目标所在文件系统创建临时 staging,词法复制并
保留目录/文件权限、普通文件、符号链接及 dangling symlink,校验路径类型、目录项、
文件大小与 SHA256、链接目标后,再将 staging 原子 rename 为正式目标。正式目标
再次校验成功后才删除源路径。复制、校验或发布失败时保留源并清理 staging;源删除
失败时允许源与正式目标同时存在,但必须返回明确错误,不能报告成功。
Go upgrade 当前提供 **单 Agent 目标级事务恢复**:复制失败发生在旧目录移动前;
备份中途失败会恢复此前已移动的目录;发布中途失败会恢复该目标的完整旧集合。不同
Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功升级的其他目标,这与
@@ -159,11 +183,31 @@ Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功
## 9. 备份合同
- 路径:`~/.dws/skill-backups/<UTC 时间戳>/...`;
- 主要操作:同一文件系统内使用 rename 移动;
- 主要操作:同一文件系统内使用 rename 移动;跨文件系统使用目标卷 staging 的
copy → verify → publish → remove 回退;
- 失败语义:备份失败时原目录保持不变,目标安装失败;
- 恢复语义:反向恢复使用相同回退;若删除备份源失败,原路径和备份可同时存在,
但恢复必须失败并明确提示两份均被保留;
- 可见性:计划和执行日志显示原路径与备份路径;
- 保留策略:自动修剪,仅保留最近 5 批。
跨卷回退只有 staging → 正式目标的发布 rename 是原子的,整次迁移不是跨文件系统
原子事务;该边界由“发布前不删源、发布后再次校验、删除失败保留两份”补偿。Shell
入口继续使用系统 `mv` 的跨文件系统复制/删除能力;Go、npm 与 PowerShell 显式实现
上述验证和失败合同。
原子 no-replace 发布(Linux `RENAME_NOREPLACE`、Darwin `RENAME_EXCL`)依赖底层文件
系统支持:`rename(2)` 只列出 ext4、btrfs、tmpfs 与 cifs,因此 NFS、FUSE 与
overlayfs 家目录会以 `EINVAL` 拒绝该 flag。这些文件系统不得让安装整体失败,而是降级
为原子占位发布:目录目标用 `mkdir` 认领(已占用即 `EEXIST`,认领期间目标始终被本事务
持有,源子项逐个移入认领目录,最终以 rename 覆盖仅属于本事务的空认领或直接移入);
普通文件目标用硬链接占位(同样以 `EEXIST` 拒绝已占用路径)后删除源。任何一步失败都会
回迁已移动的子项并只撤销本事务的占位,被并发创建的对象(文件、符号链接或目录)既不会
被覆盖,也不会被链接进内部。逐子项移动路径不是全量原子可见(降级文件系统上的可接受
边界),但不覆盖契约在所有平台保持不变。Windows `MoveFile` 本身即拒绝已存在的目标,
无需降级。npm 与 Shell 安装面遵循同一占位模型:目录用 `mkdir`/子项移动,链接直接在
目标路径创建(symlink(2) 原子拒绝已占用路径)。
备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行
`dws skill setup --mode mono`。
@@ -192,6 +236,7 @@ setup 在未显式指定 `--source` 时的本地回退缓存。
| `scripts/install.ps1` | multi | 任一检测到的目标失败则脚本非零 |
| `scripts/install-skills.sh` | multi | 任一检测到的目标失败则脚本非零 |
| npm `install.js` | multi | 任一检测到的目标失败则 postinstall 失败 |
| `scripts/install-event.sh` / `install-devapp.*` | 产品 multi 子集 | 同样使用 canonical 与 Agent 兼容层 |
Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行相同流程。
@@ -209,6 +254,12 @@ Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行
- 复制失败不留下 Agent 可见的残缺官方目录;
- 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill;
- Windows、macOS、Linux 的路径和覆盖率门禁;
- symlinked parent、npm/PowerShell 的 Windows junction、`dws upgrade` /
`dws skill setup` 的符号链接、链接失败复制回退与 broken link 修复;
- Claude/Codex/Hermes 自定义根目录及 OpenClaw 历史目录优先级;
- `CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`XDG_CONFIG_HOME` 等自定义根跨文件系统时的
正向备份、反向恢复、普通链接及 dangling symlink 词法保留;
- copy、verify、publish、remove 各阶段故障,以及非跨设备权限错误不得进入复制回退;
- npm、Shell、PowerShell 与包管理器安装冒烟。
## 13. 后续演进
+1 -1
View File
@@ -403,7 +403,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
Selection: contract.SelectionSpec{
AgentSummary: "消费 OA、群生命周期或需要底层控制的个人事件流;Agent 通常使用 --flatten 输出 NDJSON",
UseWhen: []string{
"需要监听六个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
"需要监听七个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
"需要监听指定群的标题变更、成员进退群或群解散事件",
"用户显式给出原始 EventKey、Filter DSL、subscribe_id,要求原始 transport envelope,或需要普通 IM facade 不提供的高级多事件控制",
},
+2 -2
View File
@@ -152,8 +152,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionProtectionCoversAllPublicEvent
}
}
if publicCount != 22 {
t.Fatalf("public personal events = %d, want 22 (16 IM + 6 OA)", publicCount)
if publicCount != 23 {
t.Fatalf("public personal events = %d, want 23 (16 IM + 7 OA)", publicCount)
}
for _, ruleType := range []string{"at", "all", "singleChat", "sender", "group"} {
if !ruleTypes[ruleType] {
+9
View File
@@ -61,6 +61,13 @@ func TestPersonalOAEventListAndSchemaCommands(t *testing.T) {
"process_code", "title", "status", "create_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalInstanceCC,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "create_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalInstanceTerminated,
properties: []string{
@@ -161,6 +168,7 @@ func TestPersonalOAEventConsumeDryRunAndValidation(t *testing.T) {
personal.EventOAApprovalTaskFinished,
personal.EventOAApprovalTaskRedirected,
personal.EventOAApprovalInstanceStarted,
personal.EventOAApprovalInstanceCC,
personal.EventOAApprovalInstanceTerminated,
personal.EventOAApprovalInstanceFinished,
}
@@ -414,6 +422,7 @@ func TestPersonalOAMultiConsumeCreatesIndependentAllSubscriptionsOnSharedBus(t *
personal.EventOAApprovalTaskFinished,
personal.EventOAApprovalTaskRedirected,
personal.EventOAApprovalInstanceStarted,
personal.EventOAApprovalInstanceCC,
personal.EventOAApprovalInstanceTerminated,
personal.EventOAApprovalInstanceFinished,
}
+67 -1
View File
@@ -51,7 +51,38 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string {
switch tool {
case "list_calendar_events":
return `{"result":{"events":[]}}`
return `{"success":true,"result":{"events":[],"hasMore":false,"nextCursor":""}}`
case "get_calendar_detail":
return c.paramAliasCalendarDetailResponse()
case "get_calendar_participants":
return `{"success":true,"result":{"participants":[{"userId":"fixture-user","displayName":"Fixture User"},{"userId":"user-2","displayName":"User Two"}]}}`
case "search_calendar":
return `{"success":true,"result":{"calendars":[]}}`
case "search_rooms":
return `{"success":true,"result":{"rooms":[]}}`
case "query_available_meeting_room":
return `{"success":true,"result":{"rooms":[],"hasMore":false}}`
case "list_meeting_room_groups":
return `{"success":true,"result":{"groups":[]}}`
case "query_busy_status":
return `{"success":true,"result":[]}`
case "list_suggested_event_times":
return `{"success":true,"result":{"recommendEventTimes":[]}}`
case "create_calendar_event":
return `{"success":true,"result":{"eventId":"event-1"}}`
case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant":
return `{"success":true}`
case "respond":
status := "accepted"
if call := c.lastParamAliasCall(); call != nil {
if value, ok := call.args["responseStatus"].(string); ok && value != "" {
status = value
}
}
encoded, _ := json.Marshal(map[string]any{"success": true, "result": map[string]any{"responseStatus": status}})
return string(encoded)
case "get_current_user_profile":
return `{"success":true,"result":{"userId":"user-1","name":"Fixture Current User"}}`
case "query_records":
return `{"success":true,"status":"success","error":{},"data":{}}`
case "search_mail_users":
@@ -127,6 +158,41 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
}
}
func (c *paramAliasCaptureCaller) lastParamAliasCall() *paramAliasToolCall {
if len(c.calls) == 0 {
return nil
}
return &c.calls[len(c.calls)-1]
}
func (c *paramAliasCaptureCaller) paramAliasCalendarDetailResponse() string {
event := map[string]any{
"eventId": "event-1",
"summary": "Fixture Meeting",
"description": "fixture description",
"startDateTime": "2026-03-10T09:00:00+08:00",
"endDateTime": "2026-03-10T10:00:00+08:00",
}
for _, call := range c.calls {
switch call.tool {
case "create_calendar_event", "update_calendar_event":
for _, key := range []string{"eventId", "summary", "description", "startDateTime", "endDateTime", "timeZone", "location", "freeBusy"} {
if value, ok := call.args[key]; ok {
event[key] = value
}
}
case "respond":
if value, ok := call.args["responseStatus"]; ok {
event["responseStatus"] = value
}
case "delete_calendar_event":
event["status"] = "cancelled"
}
}
encoded, _ := json.Marshal(map[string]any{"success": true, "result": event})
return string(encoded)
}
func (*paramAliasCaptureCaller) Format() string { return "json" }
func (*paramAliasCaptureCaller) DryRun() bool { return false }
func (*paramAliasCaptureCaller) Fields() string { return "" }
@@ -5,6 +5,8 @@ package app
import (
"errors"
"os"
"os/exec"
"reflect"
"strings"
"testing"
@@ -17,6 +19,8 @@ import (
const (
appFixtureCurrentDOpenID = "DAAAAAAAAAAAiE"
appFixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE"
paramAliasCalendarPayloadChildEnv = "DWS_TEST_CALENDAR_PARAM_ALIAS_PAYLOAD_CHILD"
)
// paramAliasCompleteCommands is deliberately keyed by the exact reviewed
@@ -46,7 +50,37 @@ var paramAliasCompleteCommands = map[string][]string{
"aitable workflow run": {"aitable", "workflow", "run", "--base-id", "base-1", "--workflow-id", "workflow-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"},
"attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"calendar +agenda": {"calendar", "+agenda", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
"calendar +attendee-list": {"calendar", "+attendee-list", "--event", "event-1", "--calendar-id", "primary"},
"calendar +book": {"calendar", "+book", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--with", "Fixture User", "--yes"},
"calendar +book-search": {"calendar", "+book-search", "--query", "fixture"},
"calendar +cancel-event": {"calendar", "+cancel-event", "--event", "event-1", "--yes"},
"calendar +conflicts": {"calendar", "+conflicts", "--in-days", "1"},
"calendar +create": {"calendar", "+create", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--desc", "fixture description", "--attendees", "user-1,user-2", "--rooms", "room-1,room-2", "--calendar-id", "primary", "--yes"},
"calendar +free": {"calendar", "+free", "--who", "Fixture User", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
"calendar +free-slots": {"calendar", "+free-slots", "--from", "9", "--to", "18", "--in-days", "1"},
"calendar +freebusy": {"calendar", "+freebusy", "--users", "user-1,user-2", "--rooms", "room-1,room-2", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
"calendar +get": {"calendar", "+get", "--event", "event-1", "--calendar-id", "primary"},
"calendar +invite": {"calendar", "+invite", "--event", "event-1", "--with", "Fixture User", "--yes"},
"calendar +my-free": {"calendar", "+my-free", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
"calendar +reschedule": {"calendar", "+reschedule", "--event", "event-1", "--start", "2026-03-10T10:00:00+08:00", "--end", "2026-03-10T11:00:00+08:00", "--yes"},
"calendar +room-find": {"calendar", "+room-find", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--room-name", "Fixture Room", "--group-id", "group-1", "--page", "1", "--limit", "7"},
"calendar +room-groups": {"calendar", "+room-groups", "--page", "1", "--limit", "7"},
"calendar +room-search": {"calendar", "+room-search", "--room-name", "Fixture Room"},
"calendar +rsvp": {"calendar", "+rsvp", "--event", "event-1", "--status", "accept", "--calendar-id", "primary", "--yes"},
"calendar +search-event": {"calendar", "+search-event", "--query", "fixture", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
"calendar +suggest-time": {"calendar", "+suggest-time", "--with", "Fixture User", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
"calendar +suggestion": {"calendar", "+suggestion", "--users", "user-1,user-2", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--timezone", "Asia/Shanghai"},
"calendar +update": {"calendar", "+update", "--event", "event-1", "--title", "Fixture Updated Meeting", "--desc", "fixture updated description", "--start", "2026-03-10T10:00:00+08:00", "--end", "2026-03-10T11:00:00+08:00", "--add-attendees", "user-2", "--remove-attendees", "user-1", "--yes"},
"calendar busy search": {"calendar", "busy", "search", "--users", "user-1,user-2", "--rooms", "room-1,room-2", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
"calendar event create": {"calendar", "event", "create", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--remind-minutes", "15", "--timezone", "Asia/Shanghai", "--rooms", "room-1,room-2"},
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
"calendar event respond": {"calendar", "event", "respond", "--id", "event-1", "--status", "accepted"},
"calendar event suggest": {"calendar", "event", "suggest", "--users", "user-1,user-2", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--timezone", "Asia/Shanghai"},
"calendar event update": {"calendar", "event", "update", "--id", "event-1", "--timezone", "Asia/Shanghai"},
"calendar room add": {"calendar", "room", "add", "--event", "event-1", "--rooms", "room-1,room-2"},
"calendar room delete": {"calendar", "room", "delete", "--event", "event-1", "--rooms", "room-1,room-2"},
"calendar room search": {"calendar", "room", "search", "--room-name", "Fixture Room", "--group-id", "group-1", "--start", "2027-03-10T09:00:00+08:00", "--end", "2027-03-10T10:00:00+08:00", "--page", "1", "--limit", "7"},
"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"},
@@ -566,6 +600,108 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
// paramAliasCalendarPayloadCases keeps the full reviewed Calendar expansion
// separate from the long-lived app-c race process. Each case still executes
// both canonical and alias argv through the real PreParse/Cobra path and
// compares the final captured transport calls; the owning top-level test runs
// these allocations in a short-lived race-instrumented subprocess so all Root
// registrations are released together when that process exits.
var paramAliasCalendarPayloadCases = map[string]bool{
paramAliasPayloadCaseKey("calendar +agenda", "from"): true,
paramAliasPayloadCaseKey("calendar +agenda", "to"): true,
paramAliasPayloadCaseKey("calendar +agenda", "max-results"): true,
paramAliasPayloadCaseKey("calendar +agenda", "next-cursor"): true,
paramAliasPayloadCaseKey("calendar +agenda", "calendar-book-id"): true,
paramAliasPayloadCaseKey("calendar +attendee-list", "event-id"): true,
paramAliasPayloadCaseKey("calendar +attendee-list", "calendar-book-id"): true,
paramAliasPayloadCaseKey("calendar +book", "summary"): true,
paramAliasPayloadCaseKey("calendar +book", "attendee-names"): true,
paramAliasPayloadCaseKey("calendar +book-search", "keyword"): true,
paramAliasPayloadCaseKey("calendar +book-search", "search"): true,
paramAliasPayloadCaseKey("calendar +book-search", "name"): true,
paramAliasPayloadCaseKey("calendar +cancel-event", "event-id"): true,
paramAliasPayloadCaseKey("calendar +cancel-event", "id"): true,
paramAliasPayloadCaseKey("calendar +free", "name"): true,
paramAliasPayloadCaseKey("calendar +free-slots", "start-hour"): true,
paramAliasPayloadCaseKey("calendar +free-slots", "end-hour"): true,
paramAliasPayloadCaseKey("calendar +free-slots", "day-offset"): true,
paramAliasPayloadCaseKey("calendar +freebusy", "user-ids"): true,
paramAliasPayloadCaseKey("calendar +freebusy", "room-ids"): true,
paramAliasPayloadCaseKey("calendar +freebusy", "room-id"): true,
paramAliasPayloadCaseKey("calendar +my-free", "from"): true,
paramAliasPayloadCaseKey("calendar +my-free", "to"): true,
paramAliasPayloadCaseKey("calendar +invite", "id"): true,
paramAliasPayloadCaseKey("calendar +invite", "participant-names"): true,
paramAliasPayloadCaseKey("calendar +reschedule", "id"): true,
paramAliasPayloadCaseKey("calendar +reschedule", "from"): true,
paramAliasPayloadCaseKey("calendar +reschedule", "to"): true,
paramAliasPayloadCaseKey("calendar +room-groups", "page-size"): true,
paramAliasPayloadCaseKey("calendar +room-groups", "page-index"): true,
paramAliasPayloadCaseKey("calendar +room-search", "query"): true,
paramAliasPayloadCaseKey("calendar +suggest-time", "duration-minutes"): true,
paramAliasPayloadCaseKey("calendar +suggest-time", "attendee-names"): true,
paramAliasPayloadCaseKey("calendar +conflicts", "day-offset"): true,
paramAliasPayloadCaseKey("calendar busy search", "room-id"): true,
paramAliasPayloadCaseKey("calendar event create", "reminder-minutes"): true,
paramAliasPayloadCaseKey("calendar event create", "tz"): true,
paramAliasPayloadCaseKey("calendar event create", "room-id"): true,
paramAliasPayloadCaseKey("calendar event respond", "response-status"): true,
paramAliasPayloadCaseKey("calendar event suggest", "duration-minutes"): true,
paramAliasPayloadCaseKey("calendar event update", "tz"): true,
paramAliasPayloadCaseKey("calendar room add", "room-id"): true,
paramAliasPayloadCaseKey("calendar room delete", "room-id"): true,
paramAliasPayloadCaseKey("calendar room search", "room-group-id"): true,
paramAliasPayloadCaseKey("calendar +create", "summary"): true,
paramAliasPayloadCaseKey("calendar +create", "description"): true,
paramAliasPayloadCaseKey("calendar +create", "user-ids"): true,
paramAliasPayloadCaseKey("calendar +create", "room-ids"): true,
paramAliasPayloadCaseKey("calendar +create", "room-id"): true,
paramAliasPayloadCaseKey("calendar +create", "calendar-book-id"): true,
paramAliasPayloadCaseKey("calendar +create", "to"): true,
paramAliasPayloadCaseKey("calendar +create", "from"): true,
paramAliasPayloadCaseKey("calendar +get", "event-id"): true,
paramAliasPayloadCaseKey("calendar +get", "calendar-book-id"): true,
paramAliasPayloadCaseKey("calendar +room-find", "from"): true,
paramAliasPayloadCaseKey("calendar +room-find", "to"): true,
paramAliasPayloadCaseKey("calendar +room-find", "page-size"): true,
paramAliasPayloadCaseKey("calendar +room-find", "page-index"): true,
paramAliasPayloadCaseKey("calendar +room-find", "room-group-id"): true,
paramAliasPayloadCaseKey("calendar +room-find", "query"): true,
paramAliasPayloadCaseKey("calendar +rsvp", "event-id"): true,
paramAliasPayloadCaseKey("calendar +rsvp", "response-status"): true,
paramAliasPayloadCaseKey("calendar +search-event", "keyword"): true,
paramAliasPayloadCaseKey("calendar +search-event", "from"): true,
paramAliasPayloadCaseKey("calendar +search-event", "to"): true,
paramAliasPayloadCaseKey("calendar +search-event", "next-cursor"): true,
paramAliasPayloadCaseKey("calendar +search-event", "max-results"): true,
paramAliasPayloadCaseKey("calendar +suggestion", "user-ids"): true,
paramAliasPayloadCaseKey("calendar +suggestion", "duration-minutes"): true,
paramAliasPayloadCaseKey("calendar +suggestion", "from"): true,
paramAliasPayloadCaseKey("calendar +suggestion", "to"): true,
paramAliasPayloadCaseKey("calendar +suggestion", "tz"): true,
paramAliasPayloadCaseKey("calendar +update", "event-id"): true,
paramAliasPayloadCaseKey("calendar +update", "from"): true,
paramAliasPayloadCaseKey("calendar +update", "summary"): true,
paramAliasPayloadCaseKey("calendar +update", "description"): true,
paramAliasPayloadCaseKey("calendar +update", "add-user-ids"): true,
paramAliasPayloadCaseKey("calendar +update", "remove-user-ids"): true,
}
// paramAliasCalendarConfirmationCases selects one newly reviewed alias for
// every Calendar Shortcut whose runtime contract requires user confirmation.
// The complete Calendar matrix proves confirmed canonical/alias payload
// equality; these representatives additionally prove semantic normalization
// cannot cross the confirmation boundary before the first transport call.
var paramAliasCalendarConfirmationCases = map[string]bool{
paramAliasPayloadCaseKey("calendar +book", "summary"): true,
paramAliasPayloadCaseKey("calendar +cancel-event", "event-id"): true,
paramAliasPayloadCaseKey("calendar +create", "summary"): true,
paramAliasPayloadCaseKey("calendar +invite", "id"): true,
paramAliasPayloadCaseKey("calendar +reschedule", "from"): true,
paramAliasPayloadCaseKey("calendar +rsvp", "response-status"): true,
paramAliasPayloadCaseKey("calendar +update", "event-id"): true,
}
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
@@ -599,28 +735,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
}
executedRepresentatives[caseKey] = true
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
canonicalCaller := &paramAliasCaptureCaller{}
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
if canonicalErr != nil {
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)
}
aliasCaller := &paramAliasCaptureCaller{}
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if aliasErr != nil {
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")
}
normalizeParamAliasVolatileDefaults(fixture.Command, canonicalCaller, aliasCaller)
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)
}
assertParamAliasFinalPayloadEquivalent(t, fixture.Command, canonicalArgs, aliasArgs)
})
}
@@ -650,6 +765,118 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
}
}
func TestCrossPlatformCoverageReviewedCalendarParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
if os.Getenv(paramAliasCalendarPayloadChildEnv) != "1" {
command := exec.Command(
os.Args[0],
"-test.run=^TestCrossPlatformCoverageReviewedCalendarParamAliasesReachCanonicalEquivalentFinalPayloads$",
"-test.count=1",
"-test.timeout=5m",
)
command.Env = append(os.Environ(), paramAliasCalendarPayloadChildEnv+"=1")
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("Calendar param-alias payload subprocess failed: %v\n%s", err, strings.TrimSpace(string(output)))
}
return
}
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
executed := make(map[string]bool)
executedConfirmation := make(map[string]bool)
for _, fixture := range concepts.Fixture {
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
if !paramAliasCalendarPayloadCases[caseKey] {
continue
}
executed[caseKey] = true
fixture := fixture
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
if !ok {
t.Fatal("reviewed Calendar alias has no complete-command E2E template")
}
canonicalArgs := append([]string(nil), complete...)
aliasArgs, replacements := replaceLongFlag(canonicalArgs, fixture.Expect, fixture.Emitted)
if replacements != 1 {
t.Fatalf("complete Calendar command must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Expect, replacements, canonicalArgs)
}
assertParamAliasFinalPayloadEquivalent(t, fixture.Command, canonicalArgs, aliasArgs)
if paramAliasCalendarConfirmationCases[caseKey] {
executedConfirmation[caseKey] = true
assertParamAliasCannotBypassConfirmation(t, aliasArgs)
}
})
}
for caseKey := range paramAliasCalendarPayloadCases {
if !executed[caseKey] {
t.Errorf("Calendar final-payload case %q has no active reviewed fixture", caseKey)
}
}
if len(executed) != len(paramAliasCalendarPayloadCases) {
t.Fatalf("Calendar final-payload coverage = %d, want %d", len(executed), len(paramAliasCalendarPayloadCases))
}
for caseKey := range paramAliasCalendarConfirmationCases {
if !executedConfirmation[caseKey] {
t.Errorf("Calendar confirmation case %q has no active reviewed fixture", caseKey)
}
}
if len(executedConfirmation) != len(paramAliasCalendarConfirmationCases) {
t.Fatalf("Calendar confirmation coverage = %d, want %d", len(executedConfirmation), len(paramAliasCalendarConfirmationCases))
}
}
func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string) {
t.Helper()
unconfirmedArgs, removals := removeExactArg(aliasArgs, "--yes")
if removals != 1 {
t.Fatalf("confirmation template must contain --yes exactly once; removals=%d args=%v", removals, aliasArgs)
}
caller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...)
if ctx == nil {
t.Fatal("unconfirmed Calendar alias command skipped PreParse")
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" {
t.Fatalf("unconfirmed Calendar alias command error = %#v, want confirmation_required\nargs=%v", err, unconfirmedArgs)
}
if len(caller.calls) != 0 {
t.Fatalf("unconfirmed Calendar alias crossed the transport boundary: args=%v calls=%#v", unconfirmedArgs, caller.calls)
}
}
func assertParamAliasFinalPayloadEquivalent(t *testing.T, command string, canonicalArgs, aliasArgs []string) {
t.Helper()
canonicalCaller := &paramAliasCaptureCaller{}
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
if canonicalErr != nil {
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)
}
aliasCaller := &paramAliasCaptureCaller{}
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if aliasErr != nil {
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")
}
normalizeParamAliasVolatileDefaults(command, canonicalCaller, aliasCaller)
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)
}
}
func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
activeAliases := 0
for _, test := range paramAliasNewIMCases {
@@ -274,6 +274,7 @@ type agentExampleFiles struct {
markdown string
json string
batch string
job string
binary string
image string
}
@@ -283,12 +284,14 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles {
markdown := filepath.Join(root, "content.md")
jsonFile := filepath.Join(root, "report.json")
batch := filepath.Join(root, "styles.json")
job := filepath.Join(root, "job.json")
binary := filepath.Join(root, "report.pdf")
image := filepath.Join(root, "chart.png")
for path, content := range map[string][]byte{
markdown: []byte("# Agent dry-run fixture\n\nNo business call is allowed.\n"),
jsonFile: []byte(`[{"content":"Agent dry-run fixture","sort":"0","key":"fixture","contentType":"markdown","type":"1"}]`),
batch: []byte(`[{"sheetId":"Sheet1","range":"A1:B2","fontWeight":"bold"}]`),
job: []byte(`{"name":"Java 工程师","description":"服务端开发","jobNature":"FULL-TIME","requiredEdu":6,"minSalary":20000,"maxSalary":35000,"extData":{"headCount":1,"fullTimeExtData":{"salaryMonth":12}},"creatorUserId":"creator-user-id","ownerUserIds":["owner-user-id"]}`),
binary: []byte("%PDF-1.4\n%%EOF\n"),
image: {0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'},
} {
@@ -301,6 +304,7 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles {
markdown: "./" + filepath.Base(markdown),
json: "./" + filepath.Base(jsonFile),
batch: "./" + filepath.Base(batch),
job: "./" + filepath.Base(job),
binary: "./" + filepath.Base(binary),
image: "./" + filepath.Base(image),
}
@@ -351,6 +355,10 @@ func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []strin
replacement = files.markdown
case "contents-file":
replacement = files.json
case "from":
if strings.HasSuffix(strings.ToLower(value), "job.json") {
replacement = files.job
}
case "batch":
if strings.HasSuffix(strings.ToLower(value), "styles.json") {
replacement = files.batch
@@ -0,0 +1,74 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"bytes"
"encoding/json"
"reflect"
"testing"
)
func recruitSchemaLeaf(t *testing.T, canonical string, compact bool) map[string]any {
t.Helper()
root := NewRootCommand()
var stdout, stderr bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&stderr)
args := []string{"schema", canonical, "--format", "json"}
if compact {
args = append(args, "--compact")
}
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatalf("execute schema %s compact=%v: %v; stderr=%s", canonical, compact, err, stderr.String())
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode schema %s compact=%v: %v", canonical, compact, err)
}
return payload
}
func TestRecruitDeliveredSchemaPublishesResultAndPagination(t *testing.T) {
for _, canonical := range []string{"recruit.list_jobs", "recruit.get_job_detail", "recruit.create_job"} {
t.Run(canonical, func(t *testing.T) {
full := recruitSchemaLeaf(t, canonical, false)
compact := recruitSchemaLeaf(t, canonical, true)
if full["result"] == nil || compact["result"] == nil {
t.Fatalf("result missing: full=%#v compact=%#v", full["result"], compact["result"])
}
if !reflect.DeepEqual(full["result"], compact["result"]) {
t.Fatalf("full/compact result mismatch\nfull=%#v\ncompact=%#v", full["result"], compact["result"])
}
if canonical == "recruit.list_jobs" {
if full["pagination"] == nil || compact["pagination"] == nil {
t.Fatalf("list pagination missing: full=%#v compact=%#v", full["pagination"], compact["pagination"])
}
if !reflect.DeepEqual(full["pagination"], compact["pagination"]) {
t.Fatalf("full/compact pagination mismatch\nfull=%#v\ncompact=%#v", full["pagination"], compact["pagination"])
}
parameters, _ := full["parameters"].(map[string]any)
cursor, _ := parameters["cursor"].(map[string]any)
if cursor["type"] != "string" || cursor["interface_type"] != "number" {
t.Fatalf("cursor contract = %#v, want CLI string converted to MCP number", cursor)
}
size, _ := parameters["size"].(map[string]any)
if required, _ := size["required"].(bool); required {
t.Fatalf("size required = true, want false: %#v", size)
}
if size["default"] != "20" {
t.Fatalf("size default = %#v, want 20", size["default"])
}
compactParameters, _ := compact["parameters"].(map[string]any)
compactSize, _ := compactParameters["size"].(map[string]any)
if compactRequired, _ := compactSize["required"].(bool); compactRequired || compactSize["default"] != "20" {
t.Fatalf("compact size contract = %#v, want required=false default=20", compactSize)
}
} else if full["pagination"] != nil || compact["pagination"] != nil {
t.Fatalf("non-list pagination must be absent: full=%#v compact=%#v", full["pagination"], compact["pagination"])
}
})
}
}
+128 -26
View File
@@ -117,38 +117,114 @@ type CliSkillDTO struct {
// (skill_setup.go) MUST have a matching path value here — enforced by
// TestAgentSkillPathsCoversSetupHomes.
var agentSkillPaths = map[string]string{
// `agents` is the generic-agent sentinel: install scripts and `setup`
// special-case ~/.agents/skills as a no-checks-required fallback so a
// fresh machine without any IDE/agent registry still gets skills.
"agents": ".agents/skills",
"qoder": ".qoder/skills",
"qoderwork": ".qoderwork/skills",
// Universal agents. Those without an independent global directory map
// directly to the canonical ~/.agents/skills store.
"agents": ".agents/skills",
"amp": filepath.Join(".config", "agents", "skills"),
"antigravity": ".gemini/antigravity/skills",
"antigravity-cli": ".gemini/antigravity-cli/skills",
"codex": ".codex/skills",
"cursor": ".cursor/skills",
"deepagents": ".deepagents/agent/skills",
"firebender": ".firebender/skills",
"gemini-cli": ".gemini/skills",
"github-copilot": ".copilot/skills",
"opencode": filepath.Join(".config", "opencode", "skills"),
"replit": filepath.Join(".config", "agents", "skills"),
"universal": filepath.Join(".config", "agents", "skills"),
"cline": ".agents/skills",
"dexto": ".agents/skills",
"kimi-code-cli": ".agents/skills",
"loaf": ".agents/skills",
"warp": ".agents/skills",
"zed": ".agents/skills",
// Non-universal agents with global Skill directories.
"aider-desk": ".aider-desk/skills",
"astrbot": ".astrbot/data/skills",
"autohand-code": ".autohand/skills",
"augment": ".augment/skills",
"bob": ".bob/skills",
"claude-code": ".claude/skills",
"openclaw": ".openclaw/skills",
"codearts-agent": ".codeartsdoer/skills",
"codebuddy": ".codebuddy/skills",
"codemaker": ".codemaker/skills",
"codestudio": ".codestudio/skills",
"command-code": ".commandcode/skills",
"continue": ".continue/skills",
"cortex": ".snowflake/cortex/skills",
"crush": filepath.Join(".config", "crush", "skills"),
"devin": filepath.Join(".config", "devin", "skills"),
"droid": ".factory/skills",
"forgecode": ".forge/skills",
"goose": filepath.Join(".config", "goose", "skills"),
"grok": ".grok/skills",
"hermes-agent": ".hermes/skills",
"inference-sh": ".inferencesh/skills",
"jazz": ".jazz/skills",
"junie": ".junie/skills",
"iflow-cli": ".iflow/skills",
"kilo": ".kilocode/skills",
"kimchi": filepath.Join(".config", "kimchi", "harness", "skills"),
"kiro-cli": ".kiro/skills",
"kode": ".kode/skills",
"lingma": ".lingma/skills",
"mcpjam": ".mcpjam/skills",
"minimax-code": ".minimax/skills",
"mistral-vibe": ".vibe/skills",
"moxby": ".moxby/skills",
"mux": ".mux/skills",
"openhands": ".openhands/skills",
"ona": ".ona/skills",
"pi": ".pi/agent/skills",
"qoder": ".qoder/skills",
"qoder-cn": ".qoder-cn/skills",
"qwen-code": ".qwen/skills",
"reasonix": ".reasonix/skills",
"rovodev": ".rovodev/skills",
"roo": ".roo/skills",
"tabnine-cli": ".tabnine/agent/skills",
"terramind": ".terramind/skills",
"tinycloud": ".tinycloud/skills",
"trae": ".trae/skills",
"trae-cn": ".trae-cn/skills",
"windsurf": ".codeium/windsurf/skills",
"zcode": ".zcode/skills",
"zencoder": ".zencoder/skills",
"zenflow": ".zencoder/skills",
"neovate": ".neovate/skills",
"pochi": ".pochi/skills",
"adal": ".adal/skills",
// DWS compatibility aliases and DWS-only integrations.
"claude": ".claude/skills",
"cursor": ".cursor/skills",
"codex": ".codex/skills",
"zcode": ".zcode/skills",
"opencode": filepath.Join(".config", "opencode", "skills"),
// IDE / agent registries also probed by `dws skill setup --target all`.
"gemini": ".gemini/skills",
"github": ".github/skills",
"windsurf": ".windsurf/skills",
"augment": ".augment/skills",
"cline": ".cline/skills",
"amp": ".amp/skills",
"kiro": ".kiro/skills",
"trae": ".trae/skills",
"openclaw": ".openclaw/skills",
"hermes": ".hermes/skills",
"gemini": ".gemini/skills",
"github": ".copilot/skills",
"hermes": ".hermes/skills",
"kiro": ".kiro/skills",
"qoderwork": ".qoderwork/skills",
}
// Eve has project-scoped Skill directories but no upstream globalSkillsDir.
// Keep it in the advertised enumeration while failing explicitly instead of
// pretending that a global install configured Eve.
var unsupportedGlobalAgentTargets = map[string]string{
"eve": "Eve 不支持全局 Skill 安装,请在 Eve 项目内配置 agent/skills",
"promptscript": "PromptScript 不支持全局 Skill 安装,请在项目内使用 .agents/skills",
}
// supportedTargets returns a sorted, comma-separated list of supported
// targets. Sorted so help text and error messages stay stable across runs
// (Go map iteration order is intentionally randomized).
func supportedTargets() string {
targets := make([]string, 0, len(agentSkillPaths)+1)
targets := make([]string, 0, len(agentSkillPaths)+len(unsupportedGlobalAgentTargets)+1)
for target := range agentSkillPaths {
targets = append(targets, target)
}
for target := range unsupportedGlobalAgentTargets {
targets = append(targets, target)
}
sort.Strings(targets)
targets = append(targets, ".")
return strings.Join(targets, ", ")
@@ -182,11 +258,28 @@ func formatAgentSkillPathsForHelp() string {
sort.Strings(names)
var b strings.Builder
for _, n := range names {
fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, agentSkillPaths[n])
installPath := agentSkillPaths[n]
if isUniversalSkillInstallTarget(n) {
installPath = ".agents/skills"
}
fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, installPath)
}
return b.String()
}
// Universal Agents discover the shared ~/.agents/skills store directly. A
// marketplace install addressed to one of those Agent IDs must therefore
// publish to canonical instead of recreating an Agent-private duplicate.
func isUniversalSkillInstallTarget(target string) bool {
rel, ok := agentSkillPaths[target]
if !ok {
return false
}
base := filepath.Join("__home__", rel)
canonical := filepath.Join("__home__", ".agents", "skills")
return sameSkillSetupPath(base, canonical) || isUniversalSkillSetupBase(base)
}
func buildSkillCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "skill",
@@ -485,8 +578,13 @@ func resolveSkillTargetPath(target string) (string, error) {
return os.Getwd()
}
// Look up predefined agent paths
relPath, ok := agentSkillPaths[strings.ToLower(target)]
target = strings.ToLower(target)
if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported {
return "", errors.New(reason)
}
// Look up predefined agent paths.
_, ok := agentSkillPaths[target]
if !ok {
return "", fmt.Errorf("unsupported target")
}
@@ -496,7 +594,11 @@ func resolveSkillTargetPath(target string) (string, error) {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return filepath.Join(homeDir, relPath), nil
destination := resolveSkillSetupBase(homeDir, target)
if isUniversalSkillInstallTarget(target) {
destination = filepath.Join(homeDir, ".agents", "skills")
}
return destination, nil
}
// fetchSkillDownloadInfo calls the download API to get the skill download URL.
+40 -4
View File
@@ -28,6 +28,7 @@ import (
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestResolveSkillTargetPath(t *testing.T) {
@@ -57,19 +58,19 @@ func TestResolveSkillTargetPath(t *testing.T) {
{
name: "cursor target",
target: "cursor",
wantSuffix: filepath.Join(".cursor", "skills"),
wantSuffix: filepath.Join(".agents", "skills"),
wantErr: false,
},
{
name: "codex target",
target: "codex",
wantSuffix: filepath.Join(".codex", "skills"),
wantSuffix: filepath.Join(".agents", "skills"),
wantErr: false,
},
{
name: "opencode target",
target: "opencode",
wantSuffix: filepath.Join(".config", "opencode", "skills"),
wantSuffix: filepath.Join(".agents", "skills"),
wantErr: false,
},
{
@@ -118,6 +119,37 @@ func TestResolveSkillTargetPath(t *testing.T) {
}
}
func TestCrossPlatformCoverageUniversalSkillInstallTargetsUseCanonical(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillUserHomeDir, func() (string, error) { return home, nil })
if isUniversalSkillInstallTarget("missing-agent") {
t.Fatal("unknown Agent target classified as universal")
}
for _, target := range []string{
"amp", "antigravity", "antigravity-cli", "cline", "codex", "cursor",
"deepagents", "dexto", "firebender", "gemini", "gemini-cli", "github",
"github-copilot", "kimi-code-cli", "loaf", "opencode", "replit",
"universal", "warp", "zed",
} {
got, err := resolveSkillTargetPath(target)
if err != nil {
t.Fatalf("resolve %s: %v", target, err)
}
if want := filepath.Join(home, ".agents", "skills"); got != want {
t.Errorf("resolve %s = %s, want canonical %s", target, got, want)
}
}
for target, want := range map[string]string{
"claude": filepath.Join(home, ".claude", "skills"),
"qoder": filepath.Join(home, ".qoder", "skills"),
"zcode": filepath.Join(home, ".zcode", "skills"),
} {
if got, err := resolveSkillTargetPath(target); err != nil || got != want {
t.Errorf("resolve non-universal %s = %s, %v; want %s", target, got, err, want)
}
}
}
func TestResolveSkillTargetPathCurrentDir(t *testing.T) {
// Test "." target returns current working directory
cwd, err := os.Getwd()
@@ -477,8 +509,12 @@ func TestAgentSkillPathsCoversSetupHomes(t *testing.T) {
for _, p := range agentSkillPaths {
paths[p] = true
}
legacyCleanupOnly := map[string]bool{
".github/skills": true, ".windsurf/skills": true,
".cline/skills": true, ".amp/skills": true,
}
for _, home := range skillSetupAgentHomes {
if !paths[home] {
if !paths[home] && !legacyCleanupOnly[home] {
t.Errorf("skillSetupAgentHomes entry %q has no matching agentSkillPaths value — "+
"add it to agentSkillPaths so users can address it via --target <name>", home)
}
+459 -125
View File
@@ -6,6 +6,7 @@ import (
"io"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
@@ -23,22 +24,77 @@ import (
// agree on the install footprint.
var skillSetupAgentHomes = []string{
".agents/skills",
".config/agents/skills",
".gemini/antigravity/skills",
".gemini/antigravity-cli/skills",
".deepagents/agent/skills",
".firebender/skills",
".copilot/skills",
".config/opencode/skills",
".aider-desk/skills",
".astrbot/data/skills",
".autohand/skills",
".augment/skills",
".bob/skills",
".claude/skills",
".cursor/skills",
".openclaw/skills",
".codeartsdoer/skills",
".codebuddy/skills",
".codemaker/skills",
".codestudio/skills",
".commandcode/skills",
".continue/skills",
".snowflake/cortex/skills",
".config/crush/skills",
".config/devin/skills",
".factory/skills",
".forge/skills",
".config/goose/skills",
".grok/skills",
".hermes/skills",
".inferencesh/skills",
".jazz/skills",
".junie/skills",
".iflow/skills",
".kilocode/skills",
".config/kimchi/harness/skills",
".kiro/skills",
".kode/skills",
".lingma/skills",
".mcpjam/skills",
".minimax/skills",
".vibe/skills",
".moxby/skills",
".mux/skills",
".openhands/skills",
".ona/skills",
".pi/agent/skills",
".qoder/skills",
".qoder-cn/skills",
".qwen/skills",
".reasonix/skills",
".rovodev/skills",
".roo/skills",
".tabnine/agent/skills",
".terramind/skills",
".tinycloud/skills",
".trae/skills",
".trae-cn/skills",
".codeium/windsurf/skills",
".zcode/skills",
".zencoder/skills",
".neovate/skills",
".pochi/skills",
".adal/skills",
".qoderwork/skills",
// beta.6 compatibility roots: cleanup only.
".cursor/skills",
".gemini/skills",
".codex/skills",
".zcode/skills",
".github/skills",
".windsurf/skills",
".augment/skills",
".cline/skills",
".amp/skills",
".kiro/skills",
".trae/skills",
".openclaw/skills",
".hermes/skills",
}
const (
@@ -65,15 +121,20 @@ var (
skillSetupInteractive = isInteractiveTerminal
skillSetupReadDir = os.ReadDir
skillSetupStat = os.Stat
skillSetupLstat = os.Lstat
skillSetupGetenv = os.Getenv
skillSetupSymlink = os.Symlink
skillSetupExecutable = os.Executable
skillSetupGetwd = os.Getwd
skillSetupUserHomeDir = os.UserHomeDir
skillSetupRemoveAll = os.RemoveAll
skillSetupBackupAndRemove = upgrade.BackupAndRemoveSkillDir
skillSetupRestoreBackup = upgrade.RestoreSkillPath
skillSetupMkdirAll = os.MkdirAll
skillSetupWalk = filepath.Walk
skillSetupRel = filepath.Rel
skillSetupReadlink = os.Readlink
skillSetupEvalSymlinks = filepath.EvalSymlinks
skillSetupOpen = os.Open
skillSetupOpenFile = os.OpenFile
skillSetupWriteFile = os.WriteFile
@@ -82,7 +143,10 @@ var (
skillSetupReadState = skillstate.Read
skillSetupWriteState = skillstate.Write
skillSetupRemoveState = skillstate.Remove
skillSetupPublishPath = upgrade.PublishSkillPathNoReplace
skillSetupRollbackPaths = upgrade.RollbackSkillPathPublications
skillSetupNow = time.Now
skillSetupFoldPathCase = runtime.GOOS == "windows"
)
type skillSetupBackup struct {
@@ -91,9 +155,11 @@ type skillSetupBackup struct {
}
type skillSetupTargetPlan struct {
Destination string
Backups []skillSetupBackup
CleanupOnly bool
Destination string
CanonicalBase string
Backups []skillSetupBackup
CleanupOnly bool
LinkCanonical bool
}
type skillSetupPlan struct {
@@ -149,8 +215,9 @@ multi 模式支持按产品挑选:
备份失败时保留原目录并跳过该目标,绝不静默删除。
· 所有将被移除的目录都会在确认前逐条列出。
不带 --mode 时进入交互式询问;不带 --target 时铺到检测到的具体 Agent 目录;
未检测到具体 Agent 时才回退到 ~/.agents/skills,避免同一 Agent 扫描两份 Skill。
不带 --mode 时进入交互式询问;Skill 统一安装到 ~/.agents/skills。
DWS 会自动适配本机上检测到的 Agent;共享安装方式不可用时会自动改用兼容安装,
无需用户手动处理,也不会让同一个 Skill 重复出现。
skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`,
Example: ` dws skill setup --mode multi --target claude --dry-run
dws skill setup --mode multi --target claude`,
@@ -243,7 +310,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
}
}
}
// filtered 决定 multi 安装的清理语义:带 -s/--skill 或 -x/--exclude
// 时保持 additive(不动未列出的 sibling);全量安装与 install.sh /
// install.js 对齐,清掉不在 bundle 内且有明确 DWS 所有权记录的过期 Skill。
@@ -316,6 +382,17 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
if err != nil {
return err
}
if mode == skillSetupModeMulti && len(migrateEventMiscTargets) > 0 {
retiredNames := append([]string(nil), multiSkillNames...)
if installsEventMiscCompanion && !containsSkillName(retiredNames, multiMiscSkill) {
retiredNames = append(retiredNames, multiMiscSkill)
}
if retireErr := retireMigratedUniversalSkills(migrateEventMiscTargets, retiredNames, out); retireErr != nil {
// Retiring an obsolete universal copy installs nothing; report it and
// keep the successful installation rather than failing the run.
fmt.Fprintf(errOut, " ⚠️ %v\n", retireErr)
}
}
if skipped > 0 {
return fmt.Errorf(
"Skill 安装不完整(mode=%s, installed=%d, skipped=%d);修复失败原因后请重试 setup,或运行普通 upgrade 全量刷新预制 Skill",
@@ -350,7 +427,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
}
}
fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped)
fmt.Fprintln(out, "ℹ️ 若 Agent 会话已打开,请重启 Agent 或重新加载 Skills 后再验证路由。")
fmt.Fprintln(out, " 统一安装位置:~/.agents/skills")
fmt.Fprintln(out, " 已自动适配本机上检测到的 Agent")
fmt.Fprintln(out, "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效。")
return nil
}
@@ -910,8 +989,9 @@ func isSkillSourceRoot(path, mode string) bool {
}
// resolveSkillSetupTargets returns the list of absolute Agent home destinations.
// If target == "all", returns every agent home whose parent directory exists.
// Otherwise returns the single matching home (whether or not it currently exists).
// The canonical ~/.agents/skills destination is always first. If target ==
// "all", detected concrete Agent roots follow it. A specific target follows
// canonical as well so unknown/future Agents retain the universal copy.
//
// 末段约定:
// - mono → <agent-home>/dws (单 skill,整个 src 拷成一个 dws 目录)
@@ -923,15 +1003,88 @@ func resolveSkillSetupTargets(target, mode string) ([]string, error) {
}
target = strings.ToLower(strings.TrimSpace(target))
canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)
if target == "" || target == "all" {
return detectExistingAgentHomes(home, mode), nil
}
rel, ok := agentSkillPaths[target]
if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported {
return nil, errors.New(reason)
}
_, ok := agentSkillPaths[target]
if !ok {
return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets())
}
return []string{agentHomeForMode(filepath.Join(home, rel), mode)}, nil
dest := agentHomeForMode(resolveSkillSetupBase(home, target), mode)
if sameSkillSetupPath(dest, canonical) {
return []string{canonical}, nil
}
return []string{canonical, dest}, nil
}
func resolveOpenClawSetupBase(home string) string {
for _, name := range []string{".openclaw", ".clawdbot", ".moltbot"} {
base := filepath.Join(home, name)
if info, err := skillSetupStat(base); err == nil && info.IsDir() {
return filepath.Join(base, "skills")
}
}
return filepath.Join(home, ".openclaw", "skills")
}
func resolveSkillSetupBase(home, target string) string {
switch target {
case "claude", "claude-code":
if custom := strings.TrimSpace(skillSetupGetenv("CLAUDE_CONFIG_DIR")); custom != "" {
return filepath.Join(custom, "skills")
}
case "codex":
if custom := strings.TrimSpace(skillSetupGetenv("CODEX_HOME")); custom != "" {
return filepath.Join(custom, "skills")
}
case "hermes", "hermes-agent":
if custom := strings.TrimSpace(skillSetupGetenv("HERMES_HOME")); custom != "" {
return filepath.Join(custom, "skills")
}
case "autohand-code":
if custom := strings.TrimSpace(skillSetupGetenv("AUTOHAND_HOME")); custom != "" {
return filepath.Join(custom, "skills")
}
case "grok":
if custom := strings.TrimSpace(skillSetupGetenv("GROK_HOME")); custom != "" {
return filepath.Join(custom, "skills")
}
case "mistral-vibe":
if custom := strings.TrimSpace(skillSetupGetenv("VIBE_HOME")); custom != "" {
return filepath.Join(custom, "skills")
}
case "openclaw":
return resolveOpenClawSetupBase(home)
case "opencode", "amp", "replit", "universal", "crush", "devin", "goose", "kimchi":
configHome := strings.TrimSpace(skillSetupGetenv("XDG_CONFIG_HOME"))
if configHome == "" {
configHome = filepath.Join(home, ".config")
}
switch target {
case "opencode":
return filepath.Join(configHome, "opencode", "skills")
case "amp", "replit", "universal":
return filepath.Join(configHome, "agents", "skills")
case "crush":
return filepath.Join(configHome, "crush", "skills")
case "devin":
return filepath.Join(configHome, "devin", "skills")
case "goose":
return filepath.Join(configHome, "goose", "skills")
case "kimchi":
return filepath.Join(configHome, "kimchi", "harness", "skills")
}
case "github", "github-copilot":
return filepath.Join(home, ".copilot", "skills")
case "windsurf":
return filepath.Join(home, ".codeium", "windsurf", "skills")
}
return filepath.Join(home, agentSkillPaths[target])
}
// agentHomeForMode appends the mode-specific tail segment to an agent home base.
@@ -943,79 +1096,141 @@ func agentHomeForMode(base, mode string) string {
}
func detectExistingAgentHomes(home, mode string) []string {
var specific []string
canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)
dests := []string{canonical}
canonicalKey := skillSetupPathKey(canonical)
seen := map[string]bool{canonicalKey: true}
addDetected := func(rel, base string) {
detectedDir := filepath.Dir(base)
switch filepath.ToSlash(filepath.Clean(rel)) {
case ".config/kimchi/harness/skills", ".tabnine/agent/skills":
detectedDir = filepath.Dir(filepath.Dir(base))
case ".zcode/skills":
// Application bundles are machine-scoped detection signals. Keep this
// independent of HOME so setup matches npm, Shell, and PowerShell.
if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() {
detectedDir = ""
}
case ".minimax/skills":
if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() {
detectedDir = ""
}
}
if detectedDir != "" {
if info, err := skillSetupStat(detectedDir); err != nil || !info.IsDir() {
return
}
}
dest := agentHomeForMode(base, mode)
key := skillSetupPathKey(dest)
if !seen[key] {
seen[key] = true
dests = append(dests, dest)
}
}
for i, rel := range skillSetupAgentHomes {
if i == 0 {
continue
}
base := filepath.Join(home, rel)
parent := filepath.Dir(base)
if info, err := skillSetupStat(parent); err != nil || !info.IsDir() {
continue
switch filepath.ToSlash(filepath.Clean(rel)) {
case ".claude/skills":
base = resolveSkillSetupBase(home, "claude-code")
case ".codex/skills":
base = resolveSkillSetupBase(home, "codex")
case ".hermes/skills":
base = resolveSkillSetupBase(home, "hermes-agent")
case ".autohand/skills":
base = resolveSkillSetupBase(home, "autohand-code")
case ".grok/skills":
base = resolveSkillSetupBase(home, "grok")
case ".vibe/skills":
base = resolveSkillSetupBase(home, "mistral-vibe")
case ".openclaw/skills":
base = resolveSkillSetupBase(home, "openclaw")
case ".config/opencode/skills":
base = resolveSkillSetupBase(home, "opencode")
case ".config/agents/skills":
base = resolveSkillSetupBase(home, "amp")
case ".config/crush/skills":
base = resolveSkillSetupBase(home, "crush")
case ".config/devin/skills":
base = resolveSkillSetupBase(home, "devin")
case ".config/goose/skills":
base = resolveSkillSetupBase(home, "goose")
case ".config/kimchi/harness/skills":
base = resolveSkillSetupBase(home, "kimchi")
}
specific = append(specific, agentHomeForMode(base, mode))
addDetected(rel, base)
}
if len(specific) > 0 {
return specific
for _, target := range []string{"github-copilot", "windsurf"} {
addDetected(agentSkillPaths[target], resolveSkillSetupBase(home, target))
}
return []string{agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)}
return dests
}
func genericSkillCleanupTarget(dests []string, managed map[string]bool) (*skillSetupTargetPlan, error) {
// Derive HOME from a concrete Agent destination instead of resolving it a
// second time. The destinations were already resolved from HOME by the
// caller, and a later/transient UserHomeDir failure must not turn an
// otherwise valid setup plan into an error. Direct/custom destinations that
// do not match a known concrete Agent root have no generic-root migration.
home := ""
for _, dest := range dests {
base := dest
if filepath.Base(dest) == "dws" {
base = filepath.Dir(dest)
}
base = filepath.Clean(base)
for i, rel := range skillSetupAgentHomes {
if i == 0 {
continue
}
suffix := filepath.Clean(filepath.FromSlash(rel))
needle := string(filepath.Separator) + suffix
if strings.HasSuffix(base, needle) {
home = strings.TrimSuffix(base, needle)
break
}
}
if home != "" {
break
}
func skillSetupBaseForMode(dest, mode string) string {
if mode == skillSetupModeMono {
return filepath.Dir(dest)
}
if home == "" {
return nil, nil
}
genericBase := filepath.Join(home, ".agents", "skills")
return dest
}
target := &skillSetupTargetPlan{Destination: genericBase, CleanupOnly: true}
add := func(path, reason string) {
if info, statErr := skillSetupStat(path); statErr == nil && info.IsDir() {
target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: reason})
func isUniversalSkillSetupBase(base string) bool {
cleanBase := filepath.Clean(base)
if custom := strings.TrimSpace(skillSetupGetenv("CODEX_HOME")); custom != "" && sameSkillSetupPath(cleanBase, filepath.Join(custom, "skills")) {
return true
}
if custom := strings.TrimSpace(skillSetupGetenv("XDG_CONFIG_HOME")); custom != "" {
if sameSkillSetupPath(cleanBase, filepath.Join(custom, "agents", "skills")) ||
sameSkillSetupPath(cleanBase, filepath.Join(custom, "opencode", "skills")) {
return true
}
}
add(filepath.Join(genericBase, "dws"), skillSetupBackupMutual)
entries, readErr := skillSetupReadDir(genericBase)
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return nil, fmt.Errorf("扫描通用 Skill 根目录失败 %s: %w", genericBase, readErr)
}
for _, entry := range entries {
path := filepath.Join(genericBase, entry.Name())
if entry.IsDir() && isManagedDWSMultiSkillDir(path, managed) {
target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: skillSetupBackupStale})
base = filepath.ToSlash(cleanBase)
for rel := range map[string]bool{
".config/agents/skills": true, ".gemini/antigravity/skills": true,
".gemini/antigravity-cli/skills": true, ".codex/skills": true,
".cursor/skills": true, ".deepagents/agent/skills": true,
".firebender/skills": true, ".gemini/skills": true,
".copilot/skills": true, ".config/opencode/skills": true,
// beta.6 compatibility roots are cleanup-only.
".github/skills": true, ".windsurf/skills": true,
".cline/skills": true, ".amp/skills": true,
} {
if strings.HasSuffix(base, "/"+rel) {
return true
}
}
if len(target.Backups) == 0 {
return nil, nil
return false
}
func sameSkillSetupPath(left, right string) bool {
return skillSetupPathKey(left) == skillSetupPathKey(right)
}
func skillSetupPathKey(path string) string {
clean := filepath.Clean(path)
if skillSetupFoldPathCase {
clean = strings.ToLower(clean)
}
sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path })
return target, nil
return clean
}
func canonicalSkillSetupBase(dests []string, mode string) string {
for _, dest := range dests {
base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, mode)))
if strings.HasSuffix(base, "/.agents/skills") {
return skillSetupBaseForMode(dest, mode)
}
}
return ""
}
func samePhysicalSkillSetupPath(left, right string) bool {
leftReal, leftErr := skillSetupEvalSymlinks(left)
rightReal, rightErr := skillSetupEvalSymlinks(right)
return leftErr == nil && rightErr == nil && sameSkillSetupPath(leftReal, rightReal)
}
func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filtered bool) (*skillSetupPlan, error) {
@@ -1030,10 +1245,26 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt
}
sort.Strings(plan.MultiSkillNames)
sortedDests := append([]string(nil), dests...)
sort.Strings(sortedDests)
sort.Slice(sortedDests, func(i, j int) bool {
leftCanonical := strings.HasSuffix(filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(sortedDests[i], mode))), "/.agents/skills")
rightCanonical := strings.HasSuffix(filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(sortedDests[j], mode))), "/.agents/skills")
if leftCanonical != rightCanonical {
return leftCanonical
}
return sortedDests[i] < sortedDests[j]
})
managedNames := currentManagedSkillNames()
canonicalBase := canonicalSkillSetupBase(sortedDests, mode)
for _, dest := range sortedDests {
target := skillSetupTargetPlan{Destination: dest}
base := skillSetupBaseForMode(dest, mode)
target := skillSetupTargetPlan{Destination: dest, CanonicalBase: canonicalBase}
if canonicalBase != "" && filepath.Clean(base) != filepath.Clean(canonicalBase) {
if isUniversalSkillSetupBase(base) {
target.CleanupOnly = true
} else {
target.LinkCanonical = true
}
}
seen := map[string]bool{}
add := func(path, reason string) {
if seen[path] {
@@ -1077,26 +1308,22 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt
}
}
for _, path := range replacements {
info, statErr := skillSetupStat(path)
if target.LinkCanonical && samePhysicalSkillSetupPath(path, filepath.Join(target.CanonicalBase, filepath.Base(path))) {
continue
}
_, statErr := skillSetupLstat(path)
if statErr != nil {
if errors.Is(statErr, os.ErrNotExist) {
continue
}
return nil, fmt.Errorf("检查将被替换的 Skill 失败 %s: %w", path, statErr)
}
if info.IsDir() {
add(path, skillSetupBackupReplace)
}
add(path, skillSetupBackupReplace)
}
sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path })
plan.Targets = append(plan.Targets, target)
}
cleanupTarget, cleanupErr := genericSkillCleanupTarget(sortedDests, managedNames)
if cleanupErr != nil {
return nil, cleanupErr
}
if cleanupTarget != nil {
plan.Targets = append(plan.Targets, *cleanupTarget)
if !target.CleanupOnly || len(target.Backups) > 0 {
plan.Targets = append(plan.Targets, target)
}
}
return plan, nil
}
@@ -1140,6 +1367,33 @@ func configureEventMiscMigrationPlan(plan *skillSetupPlan, targets []string, ins
}
}
func retireMigratedUniversalSkills(targets, names []string, out io.Writer) error {
home, err := skillSetupUserHomeDir()
if err != nil {
return fmt.Errorf("无法解析 HOME 以退役 universal Agent 旧副本: %w", err)
}
seen := map[string]bool{}
var victims []skillSetupBackup
for _, target := range targets {
if !isUniversalSkillSetupBase(target) {
continue
}
for _, name := range names {
path := filepath.Join(target, name)
if seen[path] {
continue
}
seen[path] = true
victims = append(victims, skillSetupBackup{Path: path, Reason: skillSetupBackupReplace})
}
}
sort.Slice(victims, func(i, j int) bool { return victims[i].Path < victims[j].Path })
if _, err := backupSkillSetupTarget(home, victims, out); err != nil {
return fmt.Errorf("退役 universal Agent Event/misc 旧副本失败,已回滚: %w", err)
}
return nil
}
func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) {
fmt.Fprintf(out, "📦 将安装 skill:\n mode: %s\n source: %s\n", plan.Mode, plan.Source)
if plan.Mode == skillSetupModeMulti {
@@ -1148,12 +1402,14 @@ func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) {
fmt.Fprintf(out, " · %s\n", name)
}
}
fmt.Fprintln(out, " destinations:")
fmt.Fprintln(out, " 安装与适配位置:")
for _, target := range plan.Targets {
if target.CleanupOnly {
fmt.Fprintf(out, " - %s (仅迁移旧的通用 DWS 副本)\n", target.Destination)
fmt.Fprintf(out, " - %s(移除该 Agent 中的旧版 DWS Skills,改用统一安装位置)\n", target.Destination)
} else if target.LinkCanonical {
fmt.Fprintf(out, " - %s(自动配置此 Agent 使用统一安装位置)\n", target.Destination)
} else {
fmt.Fprintf(out, " - %s\n", target.Destination)
fmt.Fprintf(out, " - %s(统一安装位置)\n", target.Destination)
}
}
fmt.Fprintln(out, " 将备份并移除(先保存到 ~/.dws/skill-backups/):")
@@ -1324,8 +1580,12 @@ func installMultiSkillsWithEventMigration(
}
migrationSet := make(map[string]struct{}, len(migrationTargets))
physicalMigrationTargets := make([]string, 0, len(migrationTargets))
for _, dest := range migrationTargets {
migrationSet[dest] = struct{}{}
if !isUniversalSkillSetupBase(dest) {
physicalMigrationTargets = append(physicalMigrationTargets, dest)
}
}
var ordinaryTargets []string
for _, dest := range dests {
@@ -1334,23 +1594,47 @@ func installMultiSkillsWithEventMigration(
}
}
if len(ordinaryTargets) > 0 {
var canonicalTargets, otherOrdinaryTargets []string
for _, dest := range ordinaryTargets {
base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, skillSetupModeMulti)))
if strings.HasSuffix(base, "/.agents/skills") {
canonicalTargets = append(canonicalTargets, dest)
} else {
otherOrdinaryTargets = append(otherOrdinaryTargets, dest)
}
}
installOrdinary := func(names, targets []string) error {
if len(targets) == 0 {
return nil
}
var n, nSkipped int
n, nSkipped, err = skillSetupInstallMulti(src, skillNames, ordinaryTargets, out, errOut, filtered)
n, nSkipped, err = skillSetupInstallMulti(src, names, targets, out, errOut, filtered)
installed += n
skipped += nSkipped
if err != nil {
return installed, skipped, err
return err
}
if nSkipped > 0 {
return installed, skipped, fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped)
return fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped)
}
return nil
}
canonicalNames := append([]string(nil), skillNames...)
if !containsSkillName(canonicalNames, multiMiscSkill) {
canonicalNames = append(canonicalNames, multiMiscSkill)
sort.Strings(canonicalNames)
}
if err := installOrdinary(canonicalNames, canonicalTargets); err != nil {
return installed, skipped, err
}
if err := installOrdinary(skillNames, otherOrdinaryTargets); err != nil {
return installed, skipped, err
}
// The folded pair is excluded from the ordinary best-effort installer. All
// other selected skills (especially dingtalk-shared) must succeed before the
// old Event route is touched.
for _, dest := range migrationTargets {
for _, dest := range physicalMigrationTargets {
if cleanupErr := cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut); cleanupErr != nil {
return installed, skipped + len(skillNames), cleanupErr
}
@@ -1361,9 +1645,9 @@ func installMultiSkillsWithEventMigration(
prerequisiteNames = append(prerequisiteNames, name)
}
}
if len(prerequisiteNames) > 0 {
if len(prerequisiteNames) > 0 && len(physicalMigrationTargets) > 0 {
var n, nSkipped int
n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, migrationTargets, out, errOut, true)
n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, physicalMigrationTargets, out, errOut, true)
installed += n
skipped += nSkipped
if err != nil {
@@ -1374,7 +1658,7 @@ func installMultiSkillsWithEventMigration(
}
}
migrated, migrationErr := migrateEventMiscAtomically(src, migrationTargets, out, errOut)
migrated, migrationErr := migrateEventMiscAtomically(src, physicalMigrationTargets, out, errOut)
installed += migrated
if migrationErr != nil {
return installed, skipped, migrationErr
@@ -1646,9 +1930,32 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s
err = errors.Join(err, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
}
}()
realStageParent, realParentErr := skillSetupEvalSymlinks(stageParent)
if realParentErr != nil {
return stageRoot, nil, fmt.Errorf("解析 Agent Skill 物理目录失败 %s: %w", stageParent, realParentErr)
}
stageOne := func(src, dest string) error {
stagedDir := filepath.Join(stageRoot, filepath.Base(dest))
if target.LinkCanonical {
canonicalTarget := filepath.Join(target.CanonicalBase, filepath.Base(dest))
if samePhysicalSkillSetupPath(dest, canonicalTarget) {
return nil
}
realCanonicalTarget, realTargetErr := skillSetupEvalSymlinks(canonicalTarget)
if realTargetErr != nil {
return fmt.Errorf("解析 canonical Skill 失败 %s: %w", canonicalTarget, realTargetErr)
}
relTarget, relErr := skillSetupRel(realStageParent, realCanonicalTarget)
if relErr != nil {
return fmt.Errorf("计算 Skill 相对链接失败 %s: %w", canonicalTarget, relErr)
}
if linkErr := skillSetupSymlink(relTarget, stagedDir); linkErr != nil {
return fmt.Errorf("创建 Skill 链接失败 %s -> %s: %w", stagedDir, relTarget, linkErr)
}
staged = append(staged, skillSetupStagedDir{staged: stagedDir, dest: dest})
return nil
}
if err := skillSetupMkdirAll(stagedDir, 0o755); err != nil {
return fmt.Errorf("创建 Skill staging 目录失败 %s: %w", stagedDir, err)
}
@@ -1675,16 +1982,11 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s
// restoreSkillSetupTarget removes a partially published replacement and
// restores every original directory from its exact backup path.
func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir) error {
var restoreErr error
for i := len(published) - 1; i >= 0; i-- {
if err := skillSetupRemoveAll(published[i]); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("移除失败发布目录 %s: %w", published[i], err))
}
}
func restoreSkillSetupTarget(published []upgrade.SkillPathPublication, backups []skillSetupBackedUpDir) error {
restoreErr := skillSetupRollbackPaths(published)
for i := len(backups) - 1; i >= 0; i-- {
item := backups[i]
if _, err := skillSetupStat(item.original); err == nil {
if _, err := skillSetupLstat(item.original); err == nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复目标仍存在 %s;备份保留于 %s", item.original, item.backup))
continue
} else if !errors.Is(err, os.ErrNotExist) {
@@ -1695,7 +1997,7 @@ func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir
restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录失败 %s: %w;备份保留于 %s", filepath.Dir(item.original), err, item.backup))
continue
}
if err := skillSetupPublishRename(item.backup, item.original); err != nil {
if err := skillSetupRestoreBackup(item.backup, item.original); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w;备份保留于 %s", item.original, err, item.backup))
}
}
@@ -1728,45 +2030,53 @@ func backupSkillSetupTarget(home string, planned []skillSetupBackup, out io.Writ
}
func publishSkillSetupTarget(staged []skillSetupStagedDir, backups []skillSetupBackedUpDir) error {
published := make([]string, 0, len(staged))
published := make([]upgrade.SkillPathPublication, 0, len(staged))
for _, item := range staged {
// Record before rename so rollback also removes a destination created by
// a platform-specific partial failure.
published = append(published, item.dest)
if err := skillSetupPublishRename(item.staged, item.dest); err != nil {
publication, err := skillSetupPublishPath(item.staged, item.dest)
if err != nil {
publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", item.dest, err)
if restoreErr := restoreSkillSetupTarget(published, backups); restoreErr != nil {
return errors.Join(publishErr, fmt.Errorf("Skill setup 回滚不完整: %w", restoreErr))
}
return publishErr
}
published = append(published, publication)
}
return nil
}
func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (installed, skipped int, err error) {
home, homeErr := skillSetupUserHomeDir()
perTarget := 1
if plan.Mode == skillSetupModeMulti {
perTarget = len(plan.MultiSkillNames)
hasCanonicalDependents := false
for _, candidate := range plan.Targets {
if candidate.CanonicalBase != "" && !sameSkillSetupPath(skillSetupBaseForMode(candidate.Destination, plan.Mode), candidate.CanonicalBase) {
hasCanonicalDependents = true
break
}
}
for _, target := range plan.Targets {
perTarget := 1
if plan.Mode == skillSetupModeMulti {
perTarget = len(plan.MultiSkillNames)
}
isCanonical := target.CanonicalBase != "" && sameSkillSetupPath(skillSetupBaseForMode(target.Destination, plan.Mode), target.CanonicalBase)
if target.CleanupOnly {
if skipped > 0 {
continue
}
// Nothing is installed below a universal root, so a stale copy that
// resists retirement must not count as a skipped install: any skipped
// count fails the whole setup, even when every real target succeeded.
if homeErr != nil {
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留通用 Skill 副本 %s: %v\n", target.Destination, homeErr)
skipped++
fmt.Fprintf(errOut, " ⚠️ 无法解析 HOME,保留 universal Agent 旧副本 %s: %v\n", target.Destination, homeErr)
continue
}
if _, cleanupErr := backupSkillSetupTarget(home, target.Backups, out); cleanupErr != nil {
fmt.Fprintf(errOut, " ✗ 通用 Skill 副本迁移失败,已回滚 %s: %v\n", target.Destination, cleanupErr)
skipped++
fmt.Fprintf(errOut, " ⚠️ universal Agent 旧副本迁移失败,已回滚,可手动删除 %s: %v\n", target.Destination, cleanupErr)
}
continue
}
if len(target.Backups) > 0 && homeErr != nil {
if isCanonical && hasCanonicalDependents {
return installed, skipped + perTarget, fmt.Errorf("无法解析 HOME,canonical Skill 刷新中止 %s: %w", target.Destination, homeErr)
}
if plan.Mode == skillSetupModeMono {
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,跳过刷新(保留原目录) %s: %v\n", target.Destination, homeErr)
} else {
@@ -1777,7 +2087,16 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
}
stageRoot, staged, stageErr := stageSkillSetupTarget(plan, target)
if stageErr != nil && target.LinkCanonical {
fmt.Fprintf(errOut, " ℹ️ %s 无法使用共享安装方式,正在自动改用兼容安装\n", target.Destination)
fallback := target
fallback.LinkCanonical = false
stageRoot, staged, stageErr = stageSkillSetupTarget(plan, fallback)
}
if stageErr != nil {
if isCanonical && hasCanonicalDependents {
return installed, skipped + perTarget, fmt.Errorf("canonical Skill staging 失败 %s: %w", target.Destination, stageErr)
}
fmt.Fprintf(errOut, " ✗ Skill staging 失败,保留原集合 %s: %v\n", target.Destination, stageErr)
skipped += perTarget
continue
@@ -1787,6 +2106,9 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil {
backupErr = errors.Join(backupErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
}
if isCanonical && hasCanonicalDependents {
return installed, skipped + perTarget, fmt.Errorf("canonical Skill 备份失败,已执行回滚 %s: %w", target.Destination, backupErr)
}
fmt.Fprintf(errOut, " ✗ Skill 备份失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, backupErr)
skipped += perTarget
continue
@@ -1797,7 +2119,19 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
if cleanupErr != nil {
publishErr = errors.Join(publishErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
}
fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr)
if isCanonical && hasCanonicalDependents {
if errors.Is(publishErr, upgrade.ErrSkillPathPublicationUncertain) {
return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布状态不确定,目标可能属于并发写入并已保留 %s: %w", target.Destination, publishErr)
}
return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布失败,已执行回滚 %s: %w", target.Destination, publishErr)
}
if errors.Is(publishErr, upgrade.ErrSkillPathPublicationUncertain) {
// The destination may belong to a concurrent writer and was
// deliberately retained; the rollback refuses to displace it.
fmt.Fprintf(errOut, " ✗ Skill 发布状态不确定,目标可能属于并发写入并已保留 %s: %v\n", target.Destination, publishErr)
} else {
fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr)
}
skipped += perTarget
continue
}
@@ -1836,7 +2170,7 @@ func staleMultiSkillVictimsWithError(dest string, keep []string, managed ...map[
}
var victims []string
for _, e := range entries {
if !e.IsDir() || keepSet[e.Name()] {
if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || keepSet[e.Name()] {
continue
}
if !isManagedDWSMultiSkillDir(filepath.Join(dest, e.Name()), managed...) {
@@ -0,0 +1,160 @@
package app
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
)
func canonicalFailurePlan(t *testing.T) (string, *skillSetupPlan) {
t.Helper()
home := t.TempDir()
src := t.TempDir()
skill := filepath.Join(src, "dingtalk-chat")
if err := os.MkdirAll(skill, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skill, "SKILL.md"), []byte("chat"), 0o644); err != nil {
t.Fatal(err)
}
canonical := filepath.Join(home, ".agents", "skills")
dependent := filepath.Join(home, ".claude", "skills")
plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, dependent}, []string{"dingtalk-chat"}, true)
if err != nil {
t.Fatal(err)
}
return home, plan
}
func TestCrossPlatformCoverageSkillSetupCanonicalHomeBackupAndPublishFailures(t *testing.T) {
t.Run("home", func(t *testing.T) {
_, plan := canonicalFailurePlan(t)
canonical := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat")
if err := os.MkdirAll(canonical, 0o755); err != nil {
t.Fatal(err)
}
plan.Targets[0].Backups = []skillSetupBackup{{Path: canonical, Reason: skillSetupBackupReplace}}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home denied") })
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 刷新中止") {
t.Fatalf("home failure = %v", err)
}
})
t.Run("backup", func(t *testing.T) {
home, plan := canonicalFailurePlan(t)
victim := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat")
if err := os.MkdirAll(victim, 0o755); err != nil {
t.Fatal(err)
}
plan.Targets[0].Backups = []skillSetupBackup{{Path: victim, Reason: skillSetupBackupReplace}}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", errors.New("backup denied") })
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 备份失败") {
t.Fatalf("backup failure = %v", err)
}
})
t.Run("publish", func(t *testing.T) {
home, plan := canonicalFailurePlan(t)
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) {
return upgrade.SkillPathPublication{}, errors.New("publish denied")
})
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 发布失败") {
t.Fatalf("publish failure = %v", err)
}
})
t.Run("uncertain-publish", func(t *testing.T) {
home, plan := canonicalFailurePlan(t)
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) {
return upgrade.SkillPathPublication{}, fmt.Errorf("并发写入: %w", upgrade.ErrSkillPathPublicationUncertain)
})
errOut := &bytes.Buffer{}
_, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, errOut)
if err == nil || !strings.Contains(err.Error(), "canonical Skill 发布状态不确定") {
t.Fatalf("uncertain publish = %v", err)
}
// The destination was deliberately retained, so the canonical error
// must not claim a rollback happened.
if strings.Contains(err.Error(), "回滚") {
t.Fatalf("uncertain error must not claim a rollback: %v", err)
}
})
t.Run("uncertain dependent target is retained and reported", func(t *testing.T) {
home, plan := canonicalFailurePlan(t)
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
originalPublish := skillSetupPublishPath
testseam.Swap(t, &skillSetupPublishPath, func(staged, destination string) (upgrade.SkillPathPublication, error) {
if strings.HasPrefix(destination, filepath.Join(home, ".claude")) {
return upgrade.SkillPathPublication{}, fmt.Errorf("并发写入: %w", upgrade.ErrSkillPathPublicationUncertain)
}
return originalPublish(staged, destination)
})
errOut := &bytes.Buffer{}
_, skipped, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, errOut)
if err != nil {
t.Fatalf("dependent uncertain publish = %v", err)
}
if skipped != 1 {
t.Fatalf("skipped = %d, want 1", skipped)
}
if !strings.Contains(errOut.String(), "发布状态不确定") || strings.Contains(errOut.String(), "已执行回滚") {
t.Fatalf("errOut = %q, want retained-destination notice", errOut.String())
}
})
}
func TestCrossPlatformCoverageSkillSetupLinkResolutionFailures(t *testing.T) {
home, plan := canonicalFailurePlan(t)
canonicalTarget := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat")
if err := os.MkdirAll(canonicalTarget, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(canonicalTarget, "SKILL.md"), []byte("chat"), 0o644); err != nil {
t.Fatal(err)
}
linked := plan.Targets[1]
t.Run("physical-parent", func(t *testing.T) {
testseam.Swap(t, &skillSetupEvalSymlinks, func(path string) (string, error) {
if path == linked.Destination {
return "", errors.New("parent denied")
}
return filepath.EvalSymlinks(path)
})
if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "物理目录") {
t.Fatalf("physical parent error = %v", err)
}
})
t.Run("canonical-target", func(t *testing.T) {
testseam.Swap(t, &skillSetupEvalSymlinks, func(path string) (string, error) {
if path == canonicalTarget {
return "", errors.New("canonical denied")
}
return filepath.EvalSymlinks(path)
})
if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "解析 canonical") {
t.Fatalf("canonical target error = %v", err)
}
})
t.Run("relative-path", func(t *testing.T) {
testseam.Swap(t, &skillSetupRel, func(string, string) (string, error) { return "", errors.New("relative denied") })
if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "相对链接") {
t.Fatalf("relative path error = %v", err)
}
})
_ = home
}
+395
View File
@@ -0,0 +1,395 @@
package app
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupAgentHomes, []string{
".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills",
})
for _, parent := range []string{".codex", ".claude", ".openclaw"} {
if err := os.MkdirAll(filepath.Join(home, parent), 0o755); err != nil {
t.Fatal(err)
}
}
dests, err := resolveSkillSetupTargets("all", skillSetupModeMulti)
if err != nil {
t.Fatal(err)
}
canonical := filepath.Join(home, ".agents", "skills")
if len(dests) != 4 || dests[0] != canonical {
t.Fatalf("targets = %v", dests)
}
src := t.TempDir()
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
dir := filepath.Join(src, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(name), 0o644); err != nil {
t.Fatal(err)
}
}
oldCodex := filepath.Join(home, ".codex", "skills", "dingtalk-chat")
if err := os.MkdirAll(oldCodex, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil {
t.Fatal(err)
}
claudeSkills := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(claudeSkills, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(claudeSkills, "dingtalk-chat"), []byte("unexpected file"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink("missing-target", filepath.Join(claudeSkills, "dingtalk-shared")); err != nil {
t.Fatal(err)
}
plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false)
if err != nil {
t.Fatal(err)
}
installed, skipped, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{})
if err != nil || skipped != 0 || installed != 6 { // canonical + two linked Agents, two Skills each
t.Fatalf("execute = installed %d skipped %d err %v", installed, skipped, err)
}
if _, err := os.Lstat(oldCodex); !os.IsNotExist(err) {
t.Fatalf("Codex duplicate remains: %v", err)
}
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil {
t.Fatalf("canonical %s missing: %v", name, err)
}
for _, agent := range []string{".claude", ".openclaw"} {
link := filepath.Join(home, agent, "skills", name)
info, err := os.Lstat(link)
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("link %s = %#v, %v", link, info, err)
}
}
}
// Re-running setup must recognize the existing links as already correct;
// canonical refreshes in place without turning links into copied trees.
plan, err = buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false)
if err != nil {
t.Fatal(err)
}
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
for _, agent := range []string{".claude", ".openclaw"} {
info, err := os.Lstat(filepath.Join(home, agent, "skills", "dingtalk-chat"))
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("idempotent setup replaced %s link: %#v, %v", agent, info, err)
}
}
}
func TestCrossPlatformCoverageSkillSetupDetectsShallowAndApplicationAgents(t *testing.T) {
// Keep the destination HOME synthetic: app-bundle detection is deliberately
// machine-scoped and must not depend on the selected installation HOME.
home := t.TempDir()
testseam.Swap(t, &skillSetupGetenv, func(string) string { return "" })
for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
sentinel := filepath.Join(home, "app-sentinel")
if err := os.MkdirAll(sentinel, 0o755); err != nil {
t.Fatal(err)
}
appInfo, err := os.Stat(sentinel)
if err != nil {
t.Fatal(err)
}
zcodeApp := filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")
minimaxApp := filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")
originalStat := skillSetupStat
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
if path == zcodeApp || path == minimaxApp {
return appInfo, nil
}
return originalStat(path)
})
dests := detectExistingAgentHomes(home, skillSetupModeMulti)
for _, target := range []string{
filepath.Join(home, ".config", "kimchi", "harness", "skills"),
filepath.Join(home, ".tabnine", "agent", "skills"),
filepath.Join(home, ".zcode", "skills"),
filepath.Join(home, ".minimax", "skills"),
} {
if !containsSkillName(dests, target) {
t.Errorf("detected targets %v missing %s", dests, target)
}
}
}
func TestCrossPlatformCoverageSkillSetupCustomRootsAliasesAndUniversalTargets(t *testing.T) {
home := t.TempDir()
customClaude := filepath.Join(t.TempDir(), "claude")
customCodex := filepath.Join(t.TempDir(), "codex")
customHermes := filepath.Join(t.TempDir(), "hermes")
for _, root := range []string{customClaude, customCodex, customHermes, filepath.Join(home, ".moltbot"), filepath.Join(home, ".copilot"), filepath.Join(home, ".config", "opencode"), filepath.Join(home, ".config", "agents"), filepath.Join(home, ".codeium", "windsurf")} {
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupGetenv, func(name string) string {
switch name {
case "CLAUDE_CONFIG_DIR":
return customClaude
case "CODEX_HOME":
return customCodex
case "HERMES_HOME":
return customHermes
default:
return ""
}
})
dests, err := resolveSkillSetupTargets("all", skillSetupModeMulti)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
filepath.Join(home, ".agents", "skills"),
filepath.Join(customClaude, "skills"),
filepath.Join(customCodex, "skills"),
filepath.Join(customHermes, "skills"),
filepath.Join(home, ".moltbot", "skills"),
filepath.Join(home, ".copilot", "skills"),
filepath.Join(home, ".config", "opencode", "skills"),
filepath.Join(home, ".config", "agents", "skills"),
filepath.Join(home, ".codeium", "windsurf", "skills"),
} {
found := false
for _, got := range dests {
if sameSkillSetupPath(got, want) {
found = true
break
}
}
if !found {
t.Fatalf("resolved targets %v missing %s", dests, want)
}
}
if !isUniversalSkillSetupBase(filepath.Join(customCodex, "skills")) || !isUniversalSkillSetupBase(filepath.Join(home, ".config", "opencode", "skills")) || !isUniversalSkillSetupBase(filepath.Join(home, ".config", "agents", "skills")) {
t.Fatal("custom Codex, OpenCode, and Amp must be universal cleanup-only targets")
}
}
func TestCrossPlatformCoverageSkillSetupCanonicalFailureStopsDependentTargets(t *testing.T) {
home := t.TempDir()
canonical := filepath.Join(home, ".agents", "skills")
claude := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(claude, 0o755); err != nil {
t.Fatal(err)
}
oldClaude := filepath.Join(claude, "dingtalk-chat")
if err := os.MkdirAll(oldClaude, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(oldClaude, "SKILL.md"), []byte("old remains"), 0o644); err != nil {
t.Fatal(err)
}
src := t.TempDir()
if err := os.MkdirAll(filepath.Join(src, "dingtalk-chat"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "dingtalk-chat", "SKILL.md"), []byte("new"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, claude}, []string{"dingtalk-chat"}, true)
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return errors.New("canonical copy denied") })
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical") {
t.Fatalf("canonical failure = %v", err)
}
body, readErr := os.ReadFile(filepath.Join(oldClaude, "SKILL.md"))
if readErr != nil || string(body) != "old remains" {
t.Fatalf("dependent Claude target changed: %q, %v", body, readErr)
}
}
func TestCrossPlatformCoverageSkillSetupCanonicalCopyFallbackMessage(t *testing.T) {
home := t.TempDir()
canonical := filepath.Join(home, ".agents", "skills")
claude := filepath.Join(home, ".claude", "skills")
src := t.TempDir()
skillSrc := filepath.Join(src, "dingtalk-chat")
if err := os.MkdirAll(skillSrc, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("chat"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupSymlink, func(string, string) error { return errors.New("links unavailable") })
plan, err := buildSkillSetupPlan(
skillSetupModeMulti,
src,
[]string{canonical, claude},
[]string{"dingtalk-chat"},
false,
)
if err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut)
if err != nil || installed != 2 || skipped != 0 {
t.Fatalf("execute = installed %d skipped %d err %v", installed, skipped, err)
}
if !strings.Contains(errOut.String(), "自动改用兼容安装") {
t.Fatalf("human-readable fallback message missing: %s", errOut.String())
}
info, err := os.Lstat(filepath.Join(claude, "dingtalk-chat"))
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
t.Fatalf("copy fallback = %#v, %v", info, err)
}
}
func TestCrossPlatformCoverageUpstreamAgentEnumerationAndEffectiveRoots(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupGetenv, func(string) string { return "" })
expected := map[string]string{
"aider-desk": ".aider-desk/skills", "amp": ".config/agents/skills",
"antigravity": ".gemini/antigravity/skills", "antigravity-cli": ".gemini/antigravity-cli/skills",
"astrbot": ".astrbot/data/skills", "autohand-code": ".autohand/skills",
"augment": ".augment/skills", "bob": ".bob/skills", "claude-code": ".claude/skills",
"openclaw": ".openclaw/skills", "cline": ".agents/skills", "codearts-agent": ".codeartsdoer/skills",
"codebuddy": ".codebuddy/skills", "codemaker": ".codemaker/skills", "codestudio": ".codestudio/skills",
"codex": ".codex/skills", "command-code": ".commandcode/skills", "continue": ".continue/skills",
"cortex": ".snowflake/cortex/skills", "crush": ".config/crush/skills", "cursor": ".cursor/skills",
"deepagents": ".deepagents/agent/skills", "devin": ".config/devin/skills", "dexto": ".agents/skills",
"droid": ".factory/skills", "firebender": ".firebender/skills", "forgecode": ".forge/skills",
"gemini-cli": ".gemini/skills", "github-copilot": ".copilot/skills", "goose": ".config/goose/skills",
"grok": ".grok/skills", "hermes-agent": ".hermes/skills", "inference-sh": ".inferencesh/skills",
"jazz": ".jazz/skills", "junie": ".junie/skills", "iflow-cli": ".iflow/skills",
"kilo": ".kilocode/skills", "kimchi": ".config/kimchi/harness/skills", "kimi-code-cli": ".agents/skills",
"kiro-cli": ".kiro/skills", "kode": ".kode/skills", "lingma": ".lingma/skills", "loaf": ".agents/skills",
"mcpjam": ".mcpjam/skills", "minimax-code": ".minimax/skills", "mistral-vibe": ".vibe/skills",
"moxby": ".moxby/skills", "mux": ".mux/skills", "opencode": ".config/opencode/skills",
"openhands": ".openhands/skills", "ona": ".ona/skills", "pi": ".pi/agent/skills",
"qoder": ".qoder/skills", "qoder-cn": ".qoder-cn/skills", "qwen-code": ".qwen/skills",
"replit": ".config/agents/skills", "reasonix": ".reasonix/skills", "rovodev": ".rovodev/skills",
"roo": ".roo/skills", "tabnine-cli": ".tabnine/agent/skills", "terramind": ".terramind/skills",
"tinycloud": ".tinycloud/skills", "trae": ".trae/skills", "trae-cn": ".trae-cn/skills",
"universal": ".config/agents/skills", "warp": ".agents/skills", "windsurf": ".codeium/windsurf/skills",
"zed": ".agents/skills", "zcode": ".zcode/skills", "zencoder": ".zencoder/skills",
"zenflow": ".zencoder/skills", "neovate": ".neovate/skills", "pochi": ".pochi/skills", "adal": ".adal/skills",
}
if got := len(expected) + len(unsupportedGlobalAgentTargets); got != 76 {
t.Fatalf("upstream agent enumeration = %d, want 76", got)
}
for target, rel := range expected {
mapped, ok := agentSkillPaths[target]
if !ok || filepath.Clean(mapped) != filepath.Clean(rel) {
t.Errorf("agent %s map = %q, want %q", target, mapped, rel)
}
if got := resolveSkillSetupBase(home, target); !sameSkillSetupPath(got, filepath.Join(home, filepath.FromSlash(rel))) {
t.Errorf("agent %s effective root = %q, want %q", target, got, filepath.Join(home, rel))
}
}
for _, target := range []string{"eve", "promptscript"} {
if _, err := resolveSkillSetupTargets(target, skillSetupModeMulti); err == nil {
t.Errorf("%s unexpectedly resolved a global setup root", target)
}
if _, err := resolveSkillTargetPath(target); err == nil {
t.Errorf("%s unexpectedly resolved a marketplace install root", target)
}
}
if got := supportedTargets(); !strings.Contains(got, "eve") || !strings.Contains(got, "promptscript") {
t.Fatalf("supported targets omit no-global upstream agents: %s", got)
}
custom := map[string]string{
"AUTOHAND_HOME": filepath.Join(home, "autohand-home"), "CLAUDE_CONFIG_DIR": filepath.Join(home, "claude-home"),
"CODEX_HOME": filepath.Join(home, "codex-home"), "GROK_HOME": filepath.Join(home, "grok-home"),
"HERMES_HOME": filepath.Join(home, "hermes-home"), "VIBE_HOME": filepath.Join(home, "vibe-home"),
"XDG_CONFIG_HOME": filepath.Join(home, "xdg"),
}
testseam.Swap(t, &skillSetupGetenv, func(name string) string { return custom[name] })
customCases := map[string]string{
"autohand-code": filepath.Join(custom["AUTOHAND_HOME"], "skills"),
"claude-code": filepath.Join(custom["CLAUDE_CONFIG_DIR"], "skills"),
"codex": filepath.Join(custom["CODEX_HOME"], "skills"),
"grok": filepath.Join(custom["GROK_HOME"], "skills"),
"hermes-agent": filepath.Join(custom["HERMES_HOME"], "skills"),
"mistral-vibe": filepath.Join(custom["VIBE_HOME"], "skills"),
"amp": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"),
"replit": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"),
"universal": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"),
"crush": filepath.Join(custom["XDG_CONFIG_HOME"], "crush", "skills"),
"devin": filepath.Join(custom["XDG_CONFIG_HOME"], "devin", "skills"),
"goose": filepath.Join(custom["XDG_CONFIG_HOME"], "goose", "skills"),
"kimchi": filepath.Join(custom["XDG_CONFIG_HOME"], "kimchi", "harness", "skills"),
"opencode": filepath.Join(custom["XDG_CONFIG_HOME"], "opencode", "skills"),
}
for target, want := range customCases {
if got := resolveSkillSetupBase(home, target); !sameSkillSetupPath(got, want) {
t.Errorf("custom %s root = %q, want %q", target, got, want)
}
}
for _, target := range []string{"codex", "amp", "opencode"} {
if !isUniversalSkillSetupBase(resolveSkillSetupBase(home, target)) {
t.Errorf("custom %s root not classified universal", target)
}
}
}
func TestCrossPlatformCoverageOpenClawAliasPriority(t *testing.T) {
for _, tc := range []struct {
name string
dirs []string
want string
}{
{name: "default", want: ".openclaw"},
{name: "moltbot", dirs: []string{".moltbot"}, want: ".moltbot"},
{name: "clawdbot-before-moltbot", dirs: []string{".moltbot", ".clawdbot"}, want: ".clawdbot"},
{name: "openclaw-first", dirs: []string{".moltbot", ".clawdbot", ".openclaw"}, want: ".openclaw"},
} {
t.Run(tc.name, func(t *testing.T) {
home := t.TempDir()
for _, dir := range tc.dirs {
if err := os.MkdirAll(filepath.Join(home, dir), 0o755); err != nil {
t.Fatal(err)
}
}
if got := resolveOpenClawSetupBase(home); got != filepath.Join(home, tc.want, "skills") {
t.Fatalf("OpenClaw root = %q", got)
}
})
}
}
func TestCrossPlatformCoverageSkillSetupWindowsPathNormalization(t *testing.T) {
testseam.Swap(t, &skillSetupFoldPathCase, true)
if !sameSkillSetupPath(filepath.Join("Root", "Skills"), filepath.Join("root", "skills")) {
t.Fatal("case-insensitive platform path normalization failed")
}
}
@@ -15,6 +15,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
)
func useManagedSkillNames(t *testing.T, names ...string) {
@@ -336,12 +337,12 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailuresRestoreOldSet(t *test
return originalBackup(homeDir, dir)
})
} else {
originalRename := skillSetupPublishRename
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
originalPublish := skillSetupPublishPath
testseam.Swap(t, &skillSetupPublishPath, func(oldPath, newPath string) (upgrade.SkillPathPublication, error) {
if newPath == second && strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
return failure
return upgrade.SkillPathPublication{}, failure
}
return originalRename(oldPath, newPath)
return originalPublish(oldPath, newPath)
})
}
@@ -432,8 +433,8 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
t.Run("restore failure aggregation", func(t *testing.T) {
t.Run("remove published", func(t *testing.T) {
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return failure })
if err := restoreSkillSetupTarget([]string{"published"}, nil); !errors.Is(err, failure) {
testseam.Swap(t, &skillSetupRollbackPaths, func([]upgrade.SkillPathPublication) error { return failure })
if err := restoreSkillSetupTarget([]upgrade.SkillPathPublication{{Destination: "published"}}, nil); !errors.Is(err, failure) {
t.Fatalf("remove published error = %v", err)
}
})
@@ -445,14 +446,14 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
}
})
t.Run("stat", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "检查 Skill 恢复目标失败") {
t.Fatalf("restore stat error = %v", err)
}
})
t.Run("mkdir", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill 恢复目录失败") {
@@ -460,9 +461,9 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
}
})
t.Run("rename", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "恢复原 Skill 失败") {
t.Fatalf("restore rename error = %v", err)
@@ -479,10 +480,10 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
}
return "", failure
})
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
restoreErr := errors.New("restore failure")
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return restoreErr })
testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return restoreErr })
_, err := backupSkillSetupTarget("home", []skillSetupBackup{{Path: "first"}, {Path: "second"}}, io.Discard)
if !errors.Is(err, failure) || !errors.Is(err, restoreErr) {
t.Fatalf("backup rollback error = %v", err)
@@ -490,8 +491,11 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
})
t.Run("publish rollback failure", func(t *testing.T) {
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) {
return upgrade.SkillPathPublication{}, failure
})
testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure })
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
err := publishSkillSetupTarget(
[]skillSetupStagedDir{{staged: "staged", dest: "dest"}},
@@ -530,12 +534,12 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
t.Run("after publish failure", func(t *testing.T) {
plan := newPlan(t)
originalRename := skillSetupPublishRename
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
originalPublish := skillSetupPublishPath
testseam.Swap(t, &skillSetupPublishPath, func(oldPath, newPath string) (upgrade.SkillPathPublication, error) {
if strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
return failure
return upgrade.SkillPathPublication{}, failure
}
return originalRename(oldPath, newPath)
return originalPublish(oldPath, newPath)
})
originalRemoveAll := skillSetupRemoveAll
cleanupErr := errors.New("cleanup after publish failure")
@@ -0,0 +1,139 @@
package app
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// TestCrossPlatformCoverageSkillSetupEventMigrationRetiresUniversalFoldedCopies
// pins the P0 fix: when a beta.6 folded dingtalk-misc (carrying the personal
// Event route) exists only in a UNIVERSAL agent home (~/.codex/skills), the
// Event/misc migration must not leave physical duplicates there. Universal
// homes read ~/.agents/skills directly, so their old folded copies are retired
// (backed up) and the split standalone event + clean misc land in canonical.
func TestCrossPlatformCoverageSkillSetupEventMigrationRetiresUniversalFoldedCopies(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
codexSkills := filepath.Join(home, ".codex", "skills")
writeFoldedEventMisc(t, codexSkills)
// beta.6 physical copies alongside the folded misc.
for _, name := range []string{multiEventSkill, multiSharedSkill} {
if err := os.MkdirAll(filepath.Join(codexSkills, name), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(codexSkills, name, "SKILL.md"), []byte("beta6 "+name+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
canonical := filepath.Join(home, ".agents", "skills")
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "event", "--yes")
if err != nil {
t.Fatalf("setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout)
}
// P0: the universal home must not retain any physical dingtalk-* copy.
for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill} {
if _, err := os.Stat(filepath.Join(codexSkills, name)); !os.IsNotExist(err) {
t.Fatalf("universal .codex/skills still has physical %s (stat err=%v)", name, err)
}
}
// canonical owns the new standalone event + clean misc (+ shared).
for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill} {
if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil {
t.Fatalf("canonical missing %s: %v", name, err)
}
}
if err := validateCleanEventMiscRoot(filepath.Join(canonical, multiMiscSkill)); err != nil {
t.Fatalf("canonical misc still contains folded Event content: %v", err)
}
if _, _, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "event", "--yes"); err != nil {
t.Fatalf("idempotent rerun failed: %v", err)
}
}
func TestCrossPlatformCoverageSkillSetupUnrelatedSelectionPreservesUniversalFoldedPair(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
canonical := filepath.Join(home, ".agents", "skills")
codexSkills := filepath.Join(home, ".codex", "skills")
writeFoldedEventMisc(t, codexSkills)
writeOldStandaloneEvent(t, codexSkills)
chat := filepath.Join(codexSkills, "dingtalk-chat")
if err := os.MkdirAll(chat, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(chat, "SKILL.md"), []byte("keep chat\n"), 0o644); err != nil {
t.Fatal(err)
}
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc"})
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "doc", "--yes")
if err != nil {
t.Fatalf("selective doc install failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
}
if strings.Contains(stdout, "Event 原子迁移") {
t.Fatalf("unrelated selection migrated Event/misc: %s", stdout)
}
assertOldEventMiscPair(t, codexSkills)
if body, err := os.ReadFile(filepath.Join(chat, "SKILL.md")); err != nil || string(body) != "keep chat\n" {
t.Fatalf("unselected chat changed: body=%q err=%v", body, err)
}
}
func TestCrossPlatformCoverageSkillSetupUniversalRetirementFailures(t *testing.T) {
failure := errors.New("retirement denied")
t.Run("home", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
codex := filepath.Join(t.TempDir(), ".codex", "skills")
if err := retireMigratedUniversalSkills([]string{codex}, []string{multiEventSkill}, io.Discard); !errors.Is(err, failure) {
t.Fatalf("home failure = %v, want %v", err, failure)
}
})
t.Run("deduplicate", func(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
codex := filepath.Join(home, ".codex", "skills")
if err := retireMigratedUniversalSkills(
[]string{codex, codex},
[]string{multiEventSkill, multiEventSkill},
io.Discard,
); err != nil {
t.Fatalf("deduplicated retirement failed: %v", err)
}
})
// Retiring an obsolete universal copy installs nothing, so its failure is
// surfaced as a warning and the successful installation is kept.
t.Run("backup failure warns without failing the command", func(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
codex := filepath.Join(home, ".codex", "skills")
writeFoldedEventMisc(t, codex)
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
return "", failure
})
_, stderr, err := executeMultiSkillSetupTest(
t,
src,
[]string{filepath.Join(home, ".agents", "skills"), codex},
"--skill", "event", "--yes",
)
if err != nil {
t.Fatalf("retirement backup failure must not fail the command: %v", err)
}
if !strings.Contains(stderr, "退役 universal Agent") {
t.Fatalf("retirement backup failure must be reported, stderr = %q", stderr)
}
})
}
+35 -5
View File
@@ -523,15 +523,45 @@ func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing
}
})
t.Run("ordinary and prerequisite install errors", func(t *testing.T) {
t.Run("ordinary install error", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
copyCalls := 0
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return fail })
migration := filepath.Join(t.TempDir(), "migration")
ordinary := filepath.Join(t.TempDir(), "ordinary")
if _, _, err := installMultiSkillsWithEventMigration(src, []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); err == nil || !strings.Contains(err.Error(), "未执行迁移") {
t.Fatalf("ordinary install failure = %v", err)
}
if copyCalls == 0 {
t.Fatal("ordinary staging failure seam was not exercised")
}
})
t.Run("canonical ordinary install error", func(t *testing.T) {
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
return 0, 0, fail
})
home := t.TempDir()
canonical := filepath.Join(home, ".agents", "skills")
universalMigration := filepath.Join(home, ".codex", "skills")
if _, _, err := installMultiSkillsWithEventMigration(
"src",
[]string{multiEventSkill},
[]string{canonical, universalMigration},
[]string{universalMigration},
true,
io.Discard,
io.Discard,
); !errors.Is(err, fail) {
t.Fatalf("canonical ordinary install failure = %v, want %v", err, fail)
}
})
t.Run("prerequisite install error", func(t *testing.T) {
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
return 0, 0, fail
})
migration := filepath.Join(t.TempDir(), "migration")
ordinary := filepath.Join(t.TempDir(), "ordinary")
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) {
t.Fatalf("ordinary install failure = %v", err)
}
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) {
t.Fatalf("prerequisite install failure = %v", err)
}
+24 -36
View File
@@ -48,16 +48,13 @@ func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *t
backupCalls, copyCalls := []string{}, 0
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, path string) (string, error) {
backupCalls = append(backupCalls, path)
if err := os.RemoveAll(path); err != nil {
return "", err
}
return "backup", nil
})
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil })
testseam.Swap(t, &skillSetupWriteFile, func(string, []byte, os.FileMode) error { return nil })
testseam.Swap(t, &skillSetupPublishRename, func(src, dest string) error {
if err := os.RemoveAll(dest); err != nil {
return err
}
return os.Rename(src, dest)
})
dryRunCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
var dryRunOut bytes.Buffer
dryRunCmd.SetOut(&dryRunOut)
@@ -119,6 +116,9 @@ func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *t
// A filtered multi plan replaces only selected same-name skills and leaves
// unselected siblings out of the backup set.
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
t.Fatal(err)
}
filtered, err := buildSkillSetupPlan(skillSetupModeMulti, source, []string{dest}, []string{"dingtalk-a"}, true)
if err != nil {
t.Fatal(err)
@@ -149,55 +149,38 @@ func TestCrossPlatformCoverageSkillSetupMonoPlanIncludesSameNameTarget(t *testin
func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTarget(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".codex", "skills")
genericMono := filepath.Join(home, ".agents", "skills", "dws")
if err := os.MkdirAll(genericMono, 0o755); err != nil {
canonical := filepath.Join(home, ".agents", "skills")
oldCodex := filepath.Join(dest, "dingtalk-chat")
if err := os.MkdirAll(oldCodex, 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) {
return "", errors.New("transient HOME failure")
})
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true)
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{canonical, dest}, []string{"dingtalk-chat"}, true)
if err != nil {
t.Fatal(err)
}
if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != filepath.Dir(genericMono) {
t.Fatalf("generic cleanup target = %#v", plan.Targets)
if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != dest {
t.Fatalf("universal cleanup target = %#v", plan.Targets)
}
if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != genericMono {
t.Fatalf("generic cleanup backups = %#v", plan.Targets[1].Backups)
if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != oldCodex {
t.Fatalf("universal cleanup backups = %#v", plan.Targets[1].Backups)
}
var preview bytes.Buffer
renderSkillSetupPlan(&preview, plan)
if !strings.Contains(preview.String(), "仅迁移旧的通用 DWS 副本") {
t.Fatalf("generic cleanup preview missing: %s", preview.String())
if !strings.Contains(preview.String(), "改用统一安装位置") {
t.Fatalf("universal cleanup preview missing: %s", preview.String())
}
t.Run("managed multi and scan failure", func(t *testing.T) {
managedDir := filepath.Join(home, ".agents", "skills", "dingtalk-chat")
if err := os.MkdirAll(managedDir, 0o755); err != nil {
t.Fatal(err)
}
target, targetErr := genericSkillCleanupTarget([]string{dest}, map[string]bool{"dingtalk-chat": true})
if targetErr != nil || target == nil || len(target.Backups) != 2 {
t.Fatalf("managed generic cleanup = %#v, %v", target, targetErr)
}
failure := errors.New("generic scan failure")
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, targetErr := genericSkillCleanupTarget([]string{dest}, nil); !errors.Is(targetErr, failure) {
t.Fatalf("generic scan error = %v", targetErr)
}
if _, planErr := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true); !errors.Is(planErr, failure) {
t.Fatalf("generic cleanup plan error = %v", planErr)
}
})
}
func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.T) {
failure := errors.New("cleanup failure")
cleanup := skillSetupTargetPlan{Destination: "generic", CleanupOnly: true, Backups: []skillSetupBackup{{Path: "old"}}}
t.Run("prior skip suppresses cleanup", func(t *testing.T) {
t.Run("cleanup runs even after an install skip", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
plan := &skillSetupPlan{Mode: skillSetupModeMono, Source: "missing", Targets: []skillSetupTargetPlan{{Destination: "install"}, cleanup}}
installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, io.Discard)
@@ -206,11 +189,14 @@ func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.
}
})
// A cleanup-only target installs nothing, so its failure is a warning and
// must never increment skipped — runSkillSetup turns any skipped count into
// a hard error and would fail an otherwise complete installation.
t.Run("home failure keeps generic copy", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留通用 Skill 副本") {
if err != nil || skipped != 0 || !strings.Contains(stderr.String(), "保留 universal Agent 旧副本") {
t.Fatalf("cleanup HOME failure = (%d, %v, %q)", skipped, err, stderr.String())
}
})
@@ -220,7 +206,7 @@ func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "迁移失败") {
if err != nil || skipped != 0 || !strings.Contains(stderr.String(), "迁移失败") {
t.Fatalf("cleanup backup failure = (%d, %v, %q)", skipped, err, stderr.String())
}
})
@@ -247,11 +233,13 @@ func TestCrossPlatformCoverageSkillSetupPlanDeduplicatesAndFailsClosed(t *testin
t.Fatal(err)
}
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, failure })
if _, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{monoDest}, nil, false); err == nil || !strings.Contains(err.Error(), "\u68c0\u67e5\u5c06\u88ab\u66ff\u6362") {
t.Fatalf("replacement stat error = %v", err)
}
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-a"}, false); err == nil || !strings.Contains(err.Error(), "\u626b\u63cf\u8fc7\u671f") {
t.Fatalf("stale scan error = %v", err)
@@ -0,0 +1,85 @@
package app
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
)
func TestCrossPlatformCoverageSkillSetupRollbackRetainsConcurrentReplacement(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".agents", "skills")
first := filepath.Join(base, "dingtalk-first")
second := filepath.Join(base, "dingtalk-second")
writeSkillSetupFile(t, first, "old first")
writeSkillSetupFile(t, second, "old second")
backups, err := backupSkillSetupTarget(home, []skillSetupBackup{{Path: first}, {Path: second}}, io.Discard)
if err != nil {
t.Fatal(err)
}
stageRoot := filepath.Join(base, ".stage")
stagedFirst := filepath.Join(stageRoot, "dingtalk-first")
stagedSecond := filepath.Join(stageRoot, "dingtalk-second")
writeSkillSetupFile(t, stagedFirst, "new first")
writeSkillSetupFile(t, stagedSecond, "new second")
failure := errors.New("injected second setup publication failure")
originalPublish := skillSetupPublishPath
calls := 0
testseam.Swap(t, &skillSetupPublishPath, func(staged, destination string) (upgrade.SkillPathPublication, error) {
calls++
if calls == 2 {
if err := os.RemoveAll(first); err != nil {
t.Fatal(err)
}
writeSkillSetupFile(t, first, "concurrent")
return upgrade.SkillPathPublication{}, failure
}
return originalPublish(staged, destination)
})
err = publishSkillSetupTarget([]skillSetupStagedDir{
{staged: stagedFirst, dest: first},
{staged: stagedSecond, dest: second},
}, backups)
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "拒绝删除非本事务") {
t.Fatalf("setup transaction error = %v", err)
}
assertSkillSetupFile(t, first, "concurrent")
assertSkillSetupFile(t, second, "old second")
var firstBackup string
for _, item := range backups {
if item.original == first {
firstBackup = item.backup
break
}
}
if firstBackup == "" {
t.Fatal("first backup was not recorded")
}
assertSkillSetupFile(t, firstBackup, "old first")
}
func writeSkillSetupFile(t *testing.T, dir, content string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func assertSkillSetupFile(t *testing.T, dir, want string) {
t.Helper()
content, err := os.ReadFile(filepath.Join(dir, "SKILL.md"))
if err != nil || string(content) != want {
t.Fatalf("Skill content at %s = %q, %v; want %q", dir, content, err, want)
}
}
+15 -16
View File
@@ -297,12 +297,12 @@ func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) {
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 dest, got %d", len(got))
if len(got) != 2 {
t.Fatalf("expected canonical + Claude, got %d", len(got))
}
want := filepath.Join(home, ".claude", "skills", "dws")
if filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("expected %s, got %s", want, got[0])
if filepath.Clean(got[1]) != filepath.Clean(want) {
t.Fatalf("expected %s, got %s", want, got[1])
}
}
@@ -321,12 +321,12 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 dest, got %d", len(got))
if len(got) != 2 {
t.Fatalf("expected canonical + Claude, got %d", len(got))
}
want := filepath.Join(home, ".claude", "skills")
if filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("expected %s, got %s", want, got[0])
if filepath.Clean(got[1]) != filepath.Clean(want) {
t.Fatalf("expected %s, got %s", want, got[1])
}
}
@@ -343,8 +343,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsPrefersSpecificAgentRoot(t
t.Fatal(err)
}
want := filepath.Join(home, ".codex", "skills")
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want [%s]", got, want)
if len(got) != 2 || filepath.Clean(got[1]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want canonical + %s", got, want)
}
}
@@ -360,8 +360,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T)
t.Fatal(err)
}
want := filepath.Join(home, ".zcode", "skills")
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want [%s]", got, want)
if len(got) != 2 || filepath.Clean(got[1]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want canonical + %s", got, want)
}
explicit, err := resolveSkillSetupTargets("zcode", skillSetupModeMono)
@@ -369,8 +369,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T)
t.Fatal(err)
}
wantMono := filepath.Join(want, "dws")
if len(explicit) != 1 || filepath.Clean(explicit[0]) != filepath.Clean(wantMono) {
t.Fatalf("explicit zcode targets = %v, want [%s]", explicit, wantMono)
if len(explicit) != 2 || filepath.Clean(explicit[1]) != filepath.Clean(wantMono) {
t.Fatalf("explicit zcode targets = %v, want canonical + %s", explicit, wantMono)
}
}
@@ -1055,12 +1055,11 @@ func TestCrossPlatformCoverageSkillSetupSelectiveEventMigratesOnlyFoldedTargets(
if err := os.WriteFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"), []byte("keep sibling\n"), 0o644); err != nil {
t.Fatal(err)
}
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes")
if err != nil {
t.Fatalf("selective event setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout)
}
if !strings.Contains(stdout, "重新加载 Skills") {
if !strings.Contains(stdout, "请重启已打开的 Agent") {
t.Fatalf("completion should tell the user to reload skills: %s", stdout)
}
+3
View File
@@ -620,6 +620,9 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
for _, d := range succeeded {
fmt.Printf(" %s %s\n", ugDim("→"), ugCyan(shortenHome(d.Dir)))
}
for _, d := range result.RetireWarnings() {
fmt.Printf(" %s %s %s\n", ugYellow("⚠"), shortenHome(d.Dir), ugDim("旧副本未能迁移,可手动删除: "+d.Err.Error()))
}
} else {
fmt.Printf(" %s\n", ugGreen("✓"))
}
+9 -3
View File
@@ -292,6 +292,12 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
if stage == "install-failed-dir" {
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/failed", Status: upgradepkg.SkillDirFailed, Err: fail}}}, nil
}
if stage == "install-retire-warning" {
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{
{Dir: "/ok", Status: upgradepkg.SkillDirOK},
{Dir: "/stale", Status: upgradepkg.SkillDirRetireWarning, Err: errors.New("retirement refused")},
}}, nil
}
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/ok", Status: upgradepkg.SkillDirOK}}}, nil
}
}
@@ -300,7 +306,7 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
"ensure", "tag-error", "latest-error", "not-needed", "cancel", "find-binary", "temp-fallback", "temp-both",
"backup", "checksum-download", "checksum-read", "binary-download", "skills-download", "verify-binary", "verify-skills",
"extract-binary", "extract-tar", "binary-missing", "validate", "extract-skills", "skill-missing", "replace", "install", "install-failed-dir",
"success", "success-no-skills",
"success", "success-no-skills", "install-retire-warning",
} {
t.Run(stage, func(t *testing.T) {
configure(stage)
@@ -330,14 +336,14 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
opts.skipSkills = true
}
err := runUpgrade(context.Background(), opts)
wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills"
wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills" && stage != "install-retire-warning"
if wantError && err == nil {
t.Fatalf("stage %s succeeded", stage)
}
if !wantError && err != nil {
t.Fatalf("stage %s failed: %v", stage, err)
}
if (stage == "success" || stage == "success-no-skills") && !rb.cleaned {
if (stage == "success" || stage == "success-no-skills" || stage == "install-retire-warning") && !rb.cleaned {
t.Fatal("successful upgrade did not clean backups")
}
if stage == "success" {
+482 -1
View File
@@ -1773,6 +1773,439 @@ var generatedParamAliases = []ParamAliasEntry{
},
Blocked: []string{"at-user-ids", "staff-id", "uid", "user", "user-id", "userid"},
},
{
CLIPath: "calendar +agenda",
Aliases: map[string]string{
"begin": "start",
"calendar": "calendar-id",
"calendar-book-id": "calendar-id",
"end-date": "end",
"end-time": "end",
"from": "start",
"from-date": "start",
"max-result": "limit",
"max-results": "limit",
"max-time": "end",
"min-time": "start",
"next-cursor": "cursor",
"next-page-token": "cursor",
"next-token": "cursor",
"page-size": "limit",
"page-token": "cursor",
"per-page": "limit",
"since": "start",
"size": "limit",
"start-date": "start",
"start-time": "start",
"take": "limit",
"time-max": "end",
"time-min": "start",
"to": "end",
"top": "limit",
},
Blocked: []string{"acl-id", "count", "date", "event", "event-id", "id", "offset", "page", "room-id", "time"},
},
{
CLIPath: "calendar +attendee-list",
Aliases: map[string]string{
"calendar": "calendar-id",
"calendar-book-id": "calendar-id",
"calendar-event-id": "event",
"event-id": "event",
},
Blocked: []string{"acl-id", "room-id"},
Ambiguous: []string{"id"},
},
{
CLIPath: "calendar +book",
Aliases: map[string]string{
"attendee-names": "with",
"begin": "start",
"end-time": "end",
"from": "start",
"names": "with",
"participant-names": "with",
"start-time": "start",
"subject": "title",
"summary": "title",
"to": "end",
},
Blocked: []string{"attendees", "calendar-id", "calendar-name", "date", "end-date", "name", "open-dingtalk-ids", "participants", "room-id", "room-ids", "room-name", "rooms", "start-date", "time", "time-max", "time-min", "user", "user-id", "user-ids", "users"},
},
{
CLIPath: "calendar +book-search",
Aliases: map[string]string{
"keyword": "query",
"keywords": "query",
"name": "query",
"q": "query",
"search": "query",
"search-word": "query",
},
Blocked: []string{"subject", "text", "title"},
},
{
CLIPath: "calendar +cancel-event",
Aliases: map[string]string{
"calendar-event-id": "event",
"event-id": "event",
"id": "event",
},
Blocked: []string{"acl-id", "calendar-book-id", "calendar-id", "room-id"},
},
{
CLIPath: "calendar +conflicts",
Aliases: map[string]string{
"day-offset": "in-days",
"days-from-today": "in-days",
},
Blocked: []string{"days", "duration", "end", "from", "start", "to"},
},
{
CLIPath: "calendar +create",
Aliases: map[string]string{
"attendee-ids": "attendees",
"begin": "start",
"calendar": "calendar-id",
"calendar-book-id": "calendar-id",
"description": "desc",
"end-time": "end",
"freebusy": "free-busy",
"from": "start",
"room-id": "rooms",
"room-ids": "rooms",
"start-time": "start",
"subject": "title",
"summary": "title",
"time-zone": "timezone",
"to": "end",
"tz": "timezone",
"user-ids": "attendees",
"users": "attendees",
},
Blocked: []string{"acl-id", "attendee-name", "attendee-names", "calendar-name", "config", "date", "end-date", "event", "event-id", "field-description", "group-id", "id", "locale", "name", "offset", "open-dingtalk-ids", "participant-name", "participant-names", "rich-text-desc", "room", "room-name", "start-date", "time", "time-max", "time-min", "utc-offset", "who", "with"},
},
{
CLIPath: "calendar +free",
Aliases: map[string]string{
"begin": "start",
"end-date": "end",
"end-time": "end",
"from": "start",
"from-date": "start",
"max-time": "end",
"min-time": "start",
"name": "who",
"person": "who",
"person-name": "who",
"since": "start",
"start-date": "start",
"start-time": "start",
"time-max": "end",
"time-min": "start",
"to": "end",
},
Blocked: []string{"date", "time", "user", "user-id", "user-ids", "users", "with"},
},
{
CLIPath: "calendar +free-slots",
Aliases: map[string]string{
"day-offset": "in-days",
"days-from-today": "in-days",
"end-hour": "to",
"start-hour": "from",
},
Blocked: []string{"days", "duration", "end", "end-time", "start", "start-time", "time-max", "time-min"},
},
{
CLIPath: "calendar +freebusy",
Aliases: map[string]string{
"begin": "start",
"end-date": "end",
"end-time": "end",
"from": "start",
"from-date": "start",
"max-time": "end",
"min-time": "start",
"room-id": "rooms",
"room-ids": "rooms",
"since": "start",
"start-date": "start",
"start-time": "start",
"time-max": "end",
"time-min": "start",
"to": "end",
"user-ids": "users",
},
Blocked: []string{"at-user-ids", "attendee-names", "date", "group-id", "location", "name", "names", "participant-names", "room", "room-name", "staff-id", "time", "uid", "user", "user-id", "userid", "who", "with"},
},
{
CLIPath: "calendar +get",
Aliases: map[string]string{
"calendar": "calendar-id",
"calendar-book-id": "calendar-id",
"calendar-event-id": "event",
"event-id": "event",
},
Blocked: []string{"acl-id", "room-id"},
Ambiguous: []string{"id"},
},
{
CLIPath: "calendar +invite",
Aliases: map[string]string{
"attendee-names": "with",
"calendar-event-id": "event",
"event-id": "event",
"id": "event",
"names": "with",
"participant-names": "with",
},
Blocked: []string{"acl-id", "attendees", "calendar-book-id", "calendar-id", "open-dingtalk-ids", "participants", "room-id", "user", "user-id", "user-ids", "users"},
},
{
CLIPath: "calendar +my-free",
Aliases: map[string]string{
"begin": "start",
"end-date": "end",
"end-time": "end",
"from": "start",
"from-date": "start",
"max-time": "end",
"min-time": "start",
"since": "start",
"start-date": "start",
"start-time": "start",
"time-max": "end",
"time-min": "start",
"to": "end",
},
Blocked: []string{"date", "time"},
},
{
CLIPath: "calendar +reschedule",
Aliases: map[string]string{
"begin": "start",
"calendar-event-id": "event",
"end-time": "end",
"event-id": "event",
"from": "start",
"id": "event",
"start-time": "start",
"to": "end",
},
Blocked: []string{"acl-id", "calendar-book-id", "calendar-id", "date", "end-date", "room-id", "start-date", "time", "time-max", "time-min"},
},
{
CLIPath: "calendar +room-find",
Aliases: map[string]string{
"begin": "start",
"end-date": "end",
"end-time": "end",
"from": "start",
"from-date": "start",
"group": "group-id",
"max-result": "limit",
"max-results": "limit",
"max-time": "end",
"min-time": "start",
"name": "room-name",
"page-index": "page",
"page-size": "limit",
"per-page": "limit",
"query": "room-name",
"room-group-id": "group-id",
"since": "start",
"size": "limit",
"start-date": "start",
"start-time": "start",
"take": "limit",
"time-max": "end",
"time-min": "start",
"to": "end",
"top": "limit",
},
Blocked: []string{"count", "cursor", "date", "location", "next-cursor", "page-token", "room", "room-id", "room-ids", "rooms", "time"},
},
{
CLIPath: "calendar +room-groups",
Aliases: map[string]string{
"max-result": "limit",
"max-results": "limit",
"page-index": "page",
"page-size": "limit",
"per-page": "limit",
"size": "limit",
"take": "limit",
"top": "limit",
},
Blocked: []string{"count", "cursor", "page-token"},
},
{
CLIPath: "calendar +room-search",
Aliases: map[string]string{
"name": "room-name",
"query": "room-name",
},
Blocked: []string{"group-id", "location", "room", "room-id", "room-ids", "rooms"},
},
{
CLIPath: "calendar +rsvp",
Aliases: map[string]string{
"calendar": "calendar-id",
"calendar-book-id": "calendar-id",
"calendar-event-id": "event",
"event-id": "event",
"response": "status",
"response-status": "status",
},
Blocked: []string{"acl-id", "availability", "done", "free-busy", "room-id", "state"},
Ambiguous: []string{"id"},
},
{
CLIPath: "calendar +search-event",
Aliases: map[string]string{
"begin": "start",
"calendar": "calendar-id",
"calendar-book-id": "calendar-id",
"end-date": "end",
"end-time": "end",
"from": "start",
"from-date": "start",
"keyword": "query",
"keywords": "query",
"max-result": "limit",
"max-results": "limit",
"max-time": "end",
"min-time": "start",
"next-cursor": "cursor",
"next-page-token": "cursor",
"next-token": "cursor",
"page-size": "limit",
"page-token": "cursor",
"per-page": "limit",
"q": "query",
"search": "query",
"search-word": "query",
"since": "start",
"size": "limit",
"start-date": "start",
"start-time": "start",
"take": "limit",
"time-max": "end",
"time-min": "start",
"to": "end",
"top": "limit",
},
Blocked: []string{"acl-id", "count", "date", "event", "event-id", "id", "name", "offset", "page", "page-index", "room-id", "subject", "text", "time", "title"},
},
{
CLIPath: "calendar +suggest-time",
Aliases: map[string]string{
"attendee-names": "with",
"begin": "start",
"duration-minutes": "duration",
"end-date": "end",
"end-time": "end",
"from": "start",
"from-date": "start",
"max-time": "end",
"meeting-duration-minutes": "duration",
"min-time": "start",
"names": "with",
"participant-names": "with",
"since": "start",
"start-date": "start",
"start-time": "start",
"time-max": "end",
"time-min": "start",
"to": "end",
},
Blocked: []string{"attendees", "date", "open-dingtalk-ids", "participants", "remind-minutes", "time", "user", "user-id", "user-ids", "users"},
},
{
CLIPath: "calendar +suggestion",
Aliases: map[string]string{
"begin": "start",
"duration-minutes": "duration",
"end-date": "end",
"end-time": "end",
"from": "start",
"from-date": "start",
"max-time": "end",
"meeting-duration-minutes": "duration",
"min-time": "start",
"since": "start",
"start-date": "start",
"start-time": "start",
"time-max": "end",
"time-min": "start",
"time-zone": "timezone",
"to": "end",
"tz": "timezone",
"user-ids": "users",
},
Blocked: []string{"at-user-ids", "attendee-name", "attendee-names", "date", "locale", "name", "names", "offset", "open-dingtalk-ids", "participant-name", "participant-names", "remind-minutes", "room-id", "room-ids", "room-name", "rooms", "staff-id", "time", "uid", "user", "user-id", "userid", "utc-offset", "who", "with"},
},
{
CLIPath: "calendar +update",
Aliases: map[string]string{
"add-user-ids": "add-attendees",
"add-users": "add-attendees",
"begin": "start",
"calendar": "calendar-id",
"calendar-book-id": "calendar-id",
"calendar-event-id": "event",
"description": "desc",
"end-time": "end",
"event-id": "event",
"freebusy": "free-busy",
"from": "start",
"remove-user-ids": "remove-attendees",
"remove-users": "remove-attendees",
"start-time": "start",
"subject": "title",
"summary": "title",
"time-zone": "timezone",
"to": "end",
"tz": "timezone",
},
Blocked: []string{"acl-id", "attendee-name", "attendee-names", "calendar-name", "config", "date", "end-date", "field-description", "group-id", "locale", "name", "offset", "open-dingtalk-ids", "participant-name", "participant-names", "remind-minutes", "reminder-minutes", "rich-text-desc", "room-id", "room-ids", "room-name", "rooms", "start-date", "time", "time-max", "time-min", "utc-offset", "who", "with"},
Ambiguous: []string{"attendees", "id", "user-ids", "users"},
},
{
CLIPath: "calendar acl delete",
Blocked: []string{"calendar-book-id", "calendar-id", "event", "event-id", "room-id", "user-id"},
},
{
CLIPath: "calendar attachment add",
Blocked: []string{"attachments", "file", "file-id", "file-ids"},
},
{
CLIPath: "calendar attendee add",
Blocked: []string{"attendee-name", "attendee-names", "open-dingtalk-ids", "participant-name", "participant-names", "who", "with"},
},
{
CLIPath: "calendar attendee delete",
Blocked: []string{"attendee-name", "attendee-names", "open-dingtalk-ids", "participant-name", "participant-names", "who", "with"},
},
{
CLIPath: "calendar busy search",
Aliases: map[string]string{
"room-id": "rooms",
},
Blocked: []string{"attendee-name", "attendee-names", "group-id", "location", "name", "names", "participant-names", "room", "room-name", "who", "with"},
},
{
CLIPath: "calendar event create",
Aliases: map[string]string{
"reminder-minutes": "remind-minutes",
"reminder-offset-minutes": "remind-minutes",
"room-id": "rooms",
"time-zone": "timezone",
"tz": "timezone",
},
Blocked: []string{"at", "attendee-name", "attendee-names", "due", "duration", "group-id", "locale", "offset", "participant-name", "participant-names", "remind-at", "reminder-time", "room", "room-name", "utc-offset", "who", "with"},
},
{
CLIPath: "calendar event list",
Aliases: map[string]string{
@@ -1789,6 +2222,54 @@ var generatedParamAliases = []ParamAliasEntry{
},
Blocked: []string{"offset", "page", "time"},
},
{
CLIPath: "calendar event respond",
Aliases: map[string]string{
"response": "status",
"response-status": "status",
},
Blocked: []string{"availability", "done", "free-busy", "state"},
},
{
CLIPath: "calendar event suggest",
Aliases: map[string]string{
"duration-minutes": "duration",
"meeting-duration-minutes": "duration",
"time-zone": "timezone",
"tz": "timezone",
},
Blocked: []string{"attendee-name", "attendee-names", "from", "locale", "name", "names", "offset", "open-dingtalk-ids", "participant-names", "remind-minutes", "to", "utc-offset", "who", "with"},
},
{
CLIPath: "calendar event update",
Aliases: map[string]string{
"time-zone": "timezone",
"tz": "timezone",
},
Blocked: []string{"attendees", "group-id", "locale", "offset", "participants", "remind-minutes", "reminder-minutes", "room-id", "room-ids", "room-name", "rooms", "utc-offset"},
},
{
CLIPath: "calendar room add",
Aliases: map[string]string{
"room-id": "rooms",
},
Blocked: []string{"group-id", "location", "room", "room-name"},
},
{
CLIPath: "calendar room delete",
Aliases: map[string]string{
"room-id": "rooms",
},
Blocked: []string{"group-id", "location", "room", "room-name"},
},
{
CLIPath: "calendar room search",
Aliases: map[string]string{
"group": "group-id",
"room-group-id": "group-id",
},
Blocked: []string{"location", "room", "room-id", "room-ids", "rooms"},
},
{
CLIPath: "chat +bot-find",
Aliases: map[string]string{
@@ -2941,7 +3422,7 @@ var generatedParamAliases = []ParamAliasEntry{
"uid": "user",
"userid": "user",
},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
},
{
CLIPath: "chat group-role update",
File diff suppressed because one or more lines are too long
+35 -4
View File
@@ -230,9 +230,10 @@ const (
// - Validate / PostMount — orchestration only; must not register business flags
// or assemble business params that belong in Flags/ConstParams.
//
// Exactly one of RunE / Invoke / Orchestrate must be set; New validates this at
// construction time. corecmd stays dispatch-agnostic and never calls a backend:
// the adapters (FromLeafSpec / FromShortcut) supply the body.
// Exactly one of RunE / Invoke / ResultInvoke / Orchestrate must be set; New
// validates this at construction time. corecmd stays dispatch-agnostic and
// never calls a backend: the adapters (FromLeafSpec / FromShortcut) supply the
// body.
type Spec struct {
Use string
Short string
@@ -259,7 +260,8 @@ type Spec struct {
// backend call).
ConfirmFirst bool
// ConstParams are fixed toolArgs merged after flag assembly (e.g. precheckOnly).
// They are payload declaration, not user flags, and never satisfy Required.
// They are payload declaration, not user flags, never satisfy Required, and
// require an Invoke or ResultInvoke dispatcher that consumes assembled args.
ConstParams map[string]any
// Contract is the authoring-time leaf contract declaration (selection /
// interface / parameters / dry-run / identity). When non-empty, embed
@@ -371,7 +373,9 @@ func (c *Ctx) Yes() bool { return BoolFlag(c.cmd, "yes") }
// malformed spec can never run the pipeline — write-confirmation prompt
// included — and then silently exit 0 having done nothing.
func New(spec Spec) *cobra.Command {
spec.ConstParams = cloneConstParams(spec.ConstParams)
validateDispatchDecl(spec)
validateConstParamsDecl(spec)
validateSafetySpec(spec)
validateContractDecl(spec)
validateInputSpecs(spec.Use, spec.Flags)
@@ -399,6 +403,7 @@ func New(spec Spec) *cobra.Command {
if spec.PostMount != nil {
spec.PostMount(cmd)
}
attachInterfaceBoolConstParams(cmd, spec.ConstParams)
if spec.OutputRollout != "" {
output.SetCommandRollout(cmd, spec.OutputRollout)
}
@@ -462,6 +467,17 @@ func New(spec Spec) *cobra.Command {
return cmd
}
func cloneConstParams(params map[string]any) map[string]any {
if params == nil {
return nil
}
frozen := make(map[string]any, len(params))
for key, value := range params {
frozen[key] = value
}
return frozen
}
// ConfirmFirstAnnotation marks commands whose Spec declared ConfirmFirst. The
// delivery gate reads it to tell a declared guard-first command apart from an
// accidental confirm-before-validate inversion: guard-first is only legitimate
@@ -537,6 +553,21 @@ func validateDispatchDecl(spec Spec) {
}
}
func validateConstParamsDecl(spec Spec) {
if len(spec.ConstParams) == 0 {
return
}
if spec.Invoke == nil && spec.ResultInvoke == nil {
panic(fmt.Sprintf("command %q ConstParams require Invoke or ResultInvoke", spec.Use))
}
for _, flag := range spec.Flags {
key := bindKey(flag)
if _, conflicts := spec.ConstParams[key]; conflicts {
panic(fmt.Sprintf("command %q ConstParams key %q conflicts with flag --%s", spec.Use, key, flag.Name))
}
}
}
// validateSafetySpec rejects partial safety declarations. A zero value remains
// the historical read-only default, but once any field is authored all four
// independent Schema dimensions must be explicit.
+98 -2
View File
@@ -26,6 +26,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"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/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
@@ -1415,17 +1416,23 @@ func TestCrossPlatformCoverageBuildArgsIntArgDefaultFloor(t *testing.T) {
}
}
func TestNewCommandMergesConstParams(t *testing.T) {
func TestCrossPlatformCoverageNewCommandFreezesAndMergesConstParams(t *testing.T) {
var got map[string]any
declared := map[string]any{
"precheckOnly": false,
"convThreadEnabled": true,
}
cmd := New(Spec{
Use: "pub",
Flags: []FlagSpec{{Name: "id", Usage: "ID", Bind: "versionId", Trim: true}},
ConstParams: map[string]any{"precheckOnly": false},
ConstParams: declared,
Invoke: func(_ *Ctx, toolArgs map[string]any) error {
got = toolArgs
return nil
},
})
declared["precheckOnly"] = true
declared["forgedAfterNew"] = true
_ = cmd.Flags().Set("id", "V1")
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
@@ -1436,6 +1443,95 @@ func TestNewCommandMergesConstParams(t *testing.T) {
if got["precheckOnly"] != false {
t.Fatalf("precheckOnly = %#v, want false", got["precheckOnly"])
}
if got["convThreadEnabled"] != true {
t.Fatalf("convThreadEnabled = %#v, want true", got["convThreadEnabled"])
}
if _, exists := got["forgedAfterNew"]; exists {
t.Fatalf("caller mutation leaked into dispatch args: %#v", got)
}
evidence := InterfaceBoolConstParams(cmd)
if !reflect.DeepEqual(evidence, map[string]bool{"convThreadEnabled": true, "precheckOnly": false}) {
t.Fatalf("bool ConstParams evidence = %#v", evidence)
}
if got["precheckOnly"] != evidence["precheckOnly"] {
t.Fatalf("dispatch/evidence drift: args=%#v evidence=%#v", got, evidence)
}
}
func TestCrossPlatformCoverageNewCommandDoesNotProjectMixedConstParamsEvidence(t *testing.T) {
var got map[string]any
cmd := New(Spec{
Use: "mixed",
ConstParams: map[string]any{
"convThreadEnabled": true,
"retryLimit": 3,
},
Invoke: func(_ *Ctx, toolArgs map[string]any) error {
got = toolArgs
return nil
},
})
if evidence := InterfaceBoolConstParams(cmd); evidence != nil {
t.Fatalf("mixed ConstParams evidence = %#v; want missing evidence", evidence)
}
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
if got["convThreadEnabled"] != true || got["retryLimit"] != 3 {
t.Fatalf("mixed ConstParams dispatch args = %#v", got)
}
}
func TestCrossPlatformCoverageNewCommandRejectsInvalidConstParamsDispatch(t *testing.T) {
mustPanic := func(name string, spec Spec, needle string) {
t.Helper()
defer func() {
r := recover()
if r == nil {
t.Fatalf("%s: expected panic", name)
}
if msg, _ := r.(string); !strings.Contains(msg, needle) {
t.Fatalf("%s: panic=%v, want %q", name, r, needle)
}
}()
New(spec)
}
mustPanic("RunE", Spec{
Use: "run-e",
ConstParams: map[string]any{"fixed": true},
RunE: func(*cobra.Command, []string) error { return nil },
}, "ConstParams require Invoke or ResultInvoke")
mustPanic("Orchestrate", Spec{
Use: "orchestrate",
ConstParams: map[string]any{"fixed": true},
Orchestrate: func(*Ctx) error { return nil },
}, "ConstParams require Invoke or ResultInvoke")
mustPanic("explicit bind conflict", Spec{
Use: "bind-conflict",
Flags: []FlagSpec{{Name: "thread", Usage: "T", Bind: "convThreadEnabled"}},
ConstParams: map[string]any{"convThreadEnabled": true},
Invoke: func(*Ctx, map[string]any) error { return nil },
}, "conflicts with flag --thread")
mustPanic("default bind conflict", Spec{
Use: "default-bind-conflict",
Flags: []FlagSpec{{Name: "fixed-value", Usage: "F"}},
ConstParams: map[string]any{"fixedValue": true},
Invoke: func(*Ctx, map[string]any) error { return nil },
}, "conflicts with flag --fixed-value")
result := New(Spec{
Use: "result",
OutputRollout: output.RolloutUnifiedActive,
ConstParams: map[string]any{"fixed": true},
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
return output.Success(nil), nil
},
})
if evidence := InterfaceBoolConstParams(result); !evidence["fixed"] {
t.Fatalf("ResultInvoke ConstParams evidence = %#v", evidence)
}
}
func TestCrossPlatformCoverageNewCommandConfirmFirstAnnotationAndOrder(t *testing.T) {
@@ -0,0 +1,63 @@
// 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 corecmd
import (
"sync"
"github.com/spf13/cobra"
)
var interfaceBoolConstParamsRegistry sync.Map
// attachInterfaceBoolConstParams records framework-owned ConstParams evidence.
// Only a non-empty, entirely boolean declaration is representable in v1;
// empty or mixed declarations remove any evidence for the command.
func attachInterfaceBoolConstParams(cmd *cobra.Command, params map[string]any) {
if cmd == nil {
return
}
if len(params) == 0 {
interfaceBoolConstParamsRegistry.Delete(cmd)
return
}
bools := make(map[string]bool, len(params))
for key, value := range params {
boolValue, ok := value.(bool)
if !ok {
interfaceBoolConstParamsRegistry.Delete(cmd)
return
}
bools[key] = boolValue
}
interfaceBoolConstParamsRegistry.Store(cmd, bools)
}
// InterfaceBoolConstParams returns a clone of framework-owned boolean
// ConstParams evidence. Callers cannot mutate the private registry.
func InterfaceBoolConstParams(cmd *cobra.Command) map[string]bool {
if cmd == nil {
return nil
}
stored, ok := interfaceBoolConstParamsRegistry.Load(cmd)
if !ok {
return nil
}
params := stored.(map[string]bool)
clone := make(map[string]bool, len(params))
for key, value := range params {
clone[key] = value
}
return clone
}
@@ -0,0 +1,44 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package corecmd
import (
"reflect"
"testing"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageInterfaceBoolConstParamsRegistryClonesAndDeletes(t *testing.T) {
attachInterfaceBoolConstParams(nil, map[string]any{"ignored": true})
if got := InterfaceBoolConstParams(nil); got != nil {
t.Fatalf("nil command evidence = %#v, want nil", got)
}
cmd := &cobra.Command{Use: "send"}
declared := map[string]any{"convThreadEnabled": true, "precheckOnly": false}
attachInterfaceBoolConstParams(cmd, declared)
declared["convThreadEnabled"] = false
want := map[string]bool{"convThreadEnabled": true, "precheckOnly": false}
got := InterfaceBoolConstParams(cmd)
if !reflect.DeepEqual(got, want) {
t.Fatalf("registered bool ConstParams = %#v, want %#v", got, want)
}
got["convThreadEnabled"] = false
if again := InterfaceBoolConstParams(cmd); !reflect.DeepEqual(again, want) {
t.Fatalf("reader leaked mutable registry state: %#v", again)
}
attachInterfaceBoolConstParams(cmd, map[string]any{"convThreadEnabled": true, "retryLimit": 3})
if got := InterfaceBoolConstParams(cmd); got != nil {
t.Fatalf("mixed ConstParams retained evidence: %#v", got)
}
attachInterfaceBoolConstParams(cmd, map[string]any{"convThreadEnabled": true})
attachInterfaceBoolConstParams(cmd, nil)
if got := InterfaceBoolConstParams(cmd); got != nil {
t.Fatalf("empty ConstParams retained evidence: %#v", got)
}
}
+1
View File
@@ -132,6 +132,7 @@ func TestClientCreateRuleBasedSubscriptionsUsesDocumentedRuleParam(t *testing.T)
{"oa_approval_task_finished", EventOAApprovalTaskFinished, RuleOptions{}, map[string]any{}},
{"oa_approval_task_redirected", EventOAApprovalTaskRedirected, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_started", EventOAApprovalInstanceStarted, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_cc", EventOAApprovalInstanceCC, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_terminated", EventOAApprovalInstanceTerminated, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_finished", EventOAApprovalInstanceFinished, RuleOptions{}, map[string]any{}},
{"read_group", EventReadGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
+29
View File
@@ -180,6 +180,19 @@ type OAApprovalInstanceStartedOutput struct {
EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"`
}
type OAApprovalInstanceCCOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
Title string `json:"title" description:"审批标题"`
Status string `json:"status" description:"审批实例到达抄送节点时的状态"`
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
EventTime int64 `json:"event_time" description:"审批抄送事件业务时间" format:"timestamp_ms"`
}
type OAApprovalInstanceTerminatedOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
@@ -667,6 +680,19 @@ func projectOAApprovalEvent(ev transport.Event, base baseEventOutput, raw json.R
CreateTime: payload.Body.CreateTime,
EventTime: payload.EventTime,
}, nil
case EventOAApprovalInstanceCC:
return OAApprovalInstanceCCOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ProcessInstanceID: payload.Body.ProcessInstanceID,
ProcessCode: payload.Body.ProcessCode,
Title: payload.Body.Title,
Status: payload.Body.Status,
CreateTime: payload.Body.CreateTime,
EventTime: payload.EventTime,
}, nil
case EventOAApprovalInstanceTerminated:
return OAApprovalInstanceTerminatedOutput{
Type: base.Type,
@@ -871,6 +897,8 @@ func outputTypeForEvent(eventKey string) reflect.Type {
return reflect.TypeOf(OAApprovalTaskRedirectedOutput{})
case eventKey == EventOAApprovalInstanceStarted:
return reflect.TypeOf(OAApprovalInstanceStartedOutput{})
case eventKey == EventOAApprovalInstanceCC:
return reflect.TypeOf(OAApprovalInstanceCCOutput{})
case eventKey == EventOAApprovalInstanceTerminated:
return reflect.TypeOf(OAApprovalInstanceTerminatedOutput{})
case eventKey == EventOAApprovalInstanceFinished:
@@ -906,6 +934,7 @@ func isOAEvent(eventKey string) bool {
eventKey == EventOAApprovalTaskFinished ||
eventKey == EventOAApprovalTaskRedirected ||
eventKey == EventOAApprovalInstanceStarted ||
eventKey == EventOAApprovalInstanceCC ||
eventKey == EventOAApprovalInstanceTerminated ||
eventKey == EventOAApprovalInstanceFinished
}
+18
View File
@@ -173,6 +173,8 @@ func personalOAData(eventKey string) string {
body["finishTime"] = int64(1785229199000)
case EventOAApprovalInstanceStarted:
body["status"] = "RUNNING"
case EventOAApprovalInstanceCC:
body["status"] = "RUNNING"
case EventOAApprovalInstanceTerminated:
body["status"] = "TERMINATED"
body["finishTime"] = int64(1785229199000)
@@ -521,6 +523,21 @@ func TestCrossPlatformCoverageProjectOutputOAEvents(t *testing.T) {
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalInstanceCC,
want: OAApprovalInstanceCCOutput{
Type: EventOAApprovalInstanceCC,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
Title: "测试审批",
Status: "RUNNING",
CreateTime: 1785229100000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalInstanceTerminated,
want: OAApprovalInstanceTerminatedOutput{
@@ -785,6 +802,7 @@ func TestCrossPlatformCoverageProjectOutputRejectsInvalidOAPayloads(t *testing.T
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceCC,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
} {
+12
View File
@@ -43,6 +43,7 @@ const (
EventOAApprovalTaskFinished = "user_oa_approval_task_finished"
EventOAApprovalTaskRedirected = "user_oa_approval_task_redirected"
EventOAApprovalInstanceStarted = "user_oa_approval_instance_started"
EventOAApprovalInstanceCC = "user_oa_approval_instance_cc"
EventOAApprovalInstanceTerminated = "user_oa_approval_instance_terminated"
EventOAApprovalInstanceFinished = "user_oa_approval_instance_finished"
)
@@ -323,6 +324,17 @@ var definitions = []Definition{
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalInstanceCC,
DisplayName: "审批单抄送",
Description: "审批实例到达抄送节点,发送给被抄送人",
Category: "oa",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalInstanceTerminated,
DisplayName: "审批单终止",
+12
View File
@@ -50,6 +50,7 @@ func TestCatalogEnabledEvents(t *testing.T) {
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceCC,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
}
@@ -65,6 +66,7 @@ func TestOAEventCatalogDefinitions(t *testing.T) {
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceCC,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
}
@@ -158,6 +160,7 @@ func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) {
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceCC,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
} {
@@ -509,6 +512,13 @@ func TestOAEventSchemaDocumentsMatchOutputDTO(t *testing.T) {
"process_code", "title", "status", "create_time", "event_time",
},
},
{
eventKey: EventOAApprovalInstanceCC,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "create_time", "event_time",
},
},
{
eventKey: EventOAApprovalInstanceTerminated,
properties: []string{
@@ -633,6 +643,7 @@ func TestBuildRuleParamAllEvents(t *testing.T) {
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceCC,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
} {
@@ -845,6 +856,7 @@ func TestSupportsMessageFilter(t *testing.T) {
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceCC,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
"unknown_event",
+49 -8
View File
@@ -27,6 +27,38 @@ import (
"github.com/spf13/cobra"
)
func resolveChatGroupRoleSetUserRoleIDs(cmd *cobra.Command) ([]string, error) {
roleIDChanged := cmd.Flags().Changed("role-id")
roleIDsChanged := cmd.Flags().Changed("role-ids")
switch {
case roleIDChanged && roleIDsChanged:
// PreRunE rejects callers that explicitly pass both flags. Reaching this
// branch means the hidden legacy flag was promoted to satisfy Cobra's
// required marker on the public canonical flag.
return parseCSVValues(mustGetFlag(cmd, "role-ids")), nil
case roleIDChanged:
roleIDs := parseCSVValues(mustGetFlag(cmd, "role-id"))
if len(roleIDs) == 0 {
return nil, apperrors.NewValidation("--role-id 不能为空")
}
if len(roleIDs) > 1 {
return nil, apperrors.NewValidation("--role-id 只允许指定一个群身份")
}
return roleIDs, nil
case roleIDsChanged:
return parseCSVValues(mustGetFlag(cmd, "role-ids")), nil
default:
return nil, apperrors.NewValidation("缺少必填参数 --role-id")
}
}
func prepareChatGroupRoleSetUserRoleID(cmd *cobra.Command) error {
if cmd.Flags().Changed("role-id") && cmd.Flags().Changed("role-ids") {
return apperrors.NewValidation("--role-id 与 --role-ids 不能同时指定")
}
return promoteLegacyChatString(cmd, "role-id", "role-ids")
}
// promoteLegacyChatString copies an explicitly supplied legacy flag into the
// new canonical flag. Cobra validates MarkFlagRequired after PreRunE, and the
// overwrite also preserves the migration rule that a legacy value wins when
@@ -7753,17 +7785,23 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
chatGroupRoleSetUserCmd := &cobra.Command{
Use: "set-user",
Short: "设置用户的群身份(覆盖该用户的全部群身份)",
Example: ` dws chat group-role set-user --conversation-id <openConversationId> --user <userId> --role-ids roleId1,roleId2
Example: ` dws chat group-role set-user --conversation-id <openConversationId> --user <userId> --role-id <openRoleId>
# 查询人员: dws contact user search --keyword "姓名" --format json
# 查询 role-id: dws chat group-role list --conversation-id <openConversationId>`,
PreRunE: func(cmd *cobra.Command, args []string) error {
return prepareChatGroupRoleSetUserRoleID(cmd)
},
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "conversation-id", "role-ids"); err != nil {
if err := validateRequiredFlags(cmd, "conversation-id"); err != nil {
return err
}
if err := validateRequiredFlagWithAliases(cmd, "user", "userId"); err != nil {
return err
}
roleIDs := parseCSVValues(mustGetFlag(cmd, "role-ids"))
roleIDs, err := resolveChatGroupRoleSetUserRoleIDs(cmd)
if err != nil {
return err
}
user := flagOrFallback(cmd, "user", "userId")
toolArgs := map[string]any{
"openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"),
@@ -7798,13 +7836,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
},
Selection: contract.SelectionSpec{
AgentSummary: "为指定群成员设置自定义角色",
UseWhen: []string{"需要把一个或多个已有角色分配给成员时"},
UseWhen: []string{"需要把一个已有角色分配给成员时"},
AvoidWhen: []string{"创建新角色定义时使用 chat group-role add"},
Examples: []string{"dws chat group-role set-user --conversation-id <openConversationId> --user <userId> --role-ids roleId1,roleId2"},
Examples: []string{"dws chat group-role set-user --conversation-id <openConversationId> --user <userId> --role-id <openRoleId>"},
},
Parameters: []contract.ParamDecl{
{Name: "conversation-id", Property: "openConversationId"},
{Name: "role-ids", Property: "openRoleIds"},
{Name: "role-id", Property: "openRoleIds"},
{Name: "user", Property: "openDingTalkId"},
},
},
@@ -7814,8 +7852,11 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
chatGroupRoleSetUserCmd.Flags().String("user", "", "用户 userId(必填)")
chatGroupRoleSetUserCmd.Flags().String("userId", "", "--user 的别名")
_ = chatGroupRoleSetUserCmd.Flags().MarkHidden("userId")
chatGroupRoleSetUserCmd.Flags().String("role-ids", "", "群身份 openRoleId 列表,逗号分隔 (必填),传空字符串则清除该用户所有群身份")
_ = chatGroupRoleSetUserCmd.MarkFlagRequired("role-ids")
chatGroupRoleSetUserCmd.Flags().String("role-id", "", "群身份 openRoleId,由 group-role list 返回 (必填)")
chatGroupRoleSetUserCmd.Flags().String("role-ids", "", "已隐藏的兼容参数:群身份 openRoleId 列表,逗号分隔")
_ = chatGroupRoleSetUserCmd.Flags().MarkHidden("role-ids")
corecmd.AnnotateFlagAlias(chatGroupRoleSetUserCmd, "role-ids", "role-id")
_ = chatGroupRoleSetUserCmd.MarkFlagRequired("role-id")
chatGroupRoleRemoveUserCmd := &cobra.Command{
Use: "remove-user",
+151 -2
View File
@@ -243,6 +243,155 @@ func TestCrossPlatformCoverageChatGroupUpdateIconRejectsBlankMediaID(t *testing.
}
}
func TestChatGroupRoleSetUserAcceptsSingleRoleIDAndLegacyRoleIDs(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
tests := []struct {
name string
args []string
want []string
}{
{
name: "public single role id",
args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1"},
want: []string{"r1"},
},
{
name: "hidden legacy role ids",
args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1,r2"},
want: []string{"r1", "r2"},
},
{
name: "hidden legacy empty role ids",
args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids="},
want: nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
if err := runChatCoverageCommand(t, caller, tc.args...); err != nil {
t.Fatal(err)
}
if caller.calls != 1 {
t.Fatalf("tool calls = %d, want 1", caller.calls)
}
if got := caller.args["openRoleIds"]; !reflect.DeepEqual(got, tc.want) {
t.Fatalf("openRoleIds = %#v, want %#v", got, tc.want)
}
})
}
}
func TestChatGroupRoleSetUserRejectsConflictingRoleFlags(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller,
"group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1", "--role-ids=r2")
if err == nil {
t.Fatal("set-user accepted --role-id with --role-ids, want validation error")
}
if !strings.Contains(err.Error(), "--role-id 与 --role-ids 不能同时指定") {
t.Fatalf("error = %v", err)
}
if caller.calls != 0 {
t.Fatalf("tool calls = %d, want 0", caller.calls)
}
}
func TestChatGroupRoleSetUserRejectsMultiplePublicRoleIDs(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller,
"group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1,r2")
if err == nil {
t.Fatal("set-user accepted multiple --role-id values, want validation error")
}
if !strings.Contains(err.Error(), "--role-id 只允许指定一个群身份") {
t.Fatalf("error = %v", err)
}
if caller.calls != 0 {
t.Fatalf("tool calls = %d, want 0", caller.calls)
}
}
func TestChatGroupRoleSetUserRejectsMissingOrEmptyPublicRoleID(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
tests := []struct {
name string
args []string
want string
}{
{
name: "missing public role id",
args: []string{"group-role", "set-user", "--group=cid", "--user=D1"},
want: "role-id",
},
{
name: "empty public role id",
args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-id="},
want: "--role-id 不能为空",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller, tc.args...)
if err == nil {
t.Fatal("set-user accepted a missing or empty --role-id, want validation error")
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want substring %q", err, tc.want)
}
if caller.calls != 0 {
t.Fatalf("tool calls = %d, want 0", caller.calls)
}
})
}
}
func TestChatGroupRoleSetUserRoleIDResolverDefensiveBranches(t *testing.T) {
newCommand := func(t *testing.T) *cobra.Command {
t.Helper()
cmd := &cobra.Command{}
cmd.Flags().String("role-id", "", "")
cmd.Flags().String("role-ids", "", "")
return cmd
}
t.Run("legacy flag without pre-run promotion", func(t *testing.T) {
cmd := newCommand(t)
if err := cmd.Flags().Set("role-ids", "r1,r2"); err != nil {
t.Fatal(err)
}
got, err := resolveChatGroupRoleSetUserRoleIDs(cmd)
if err != nil {
t.Fatal(err)
}
if want := []string{"r1", "r2"}; !reflect.DeepEqual(got, want) {
t.Fatalf("role IDs = %#v, want %#v", got, want)
}
})
t.Run("no role flag", func(t *testing.T) {
_, err := resolveChatGroupRoleSetUserRoleIDs(newCommand(t))
if err == nil || !strings.Contains(err.Error(), "缺少必填参数 --role-id") {
t.Fatalf("error = %v, want missing --role-id validation", err)
}
})
}
func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
@@ -293,7 +442,7 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
{"group", "audit-join-validation", "--conversation-id=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"},
{"mark-read", "--conversation-id=cid", "--message-id=mid"},
{"text", "translate", "--query=hello", "--to=zh_CN"},
{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1"},
{"group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1"},
{"group-role", "remove-user", "--group=cid", "--user=D1", "--role-ids=r1"},
{"group-role", "query-user", "--group=cid", "--user=D1"},
{"group", "set-admin", "--group=cid", "--users=u1,D1"},
@@ -523,7 +672,7 @@ func TestCrossPlatformCoverageChatIMIDMigrationRequiredFlagErrors(t *testing.T)
{name: "role add missing conversation", path: []string{"group-role", "add"}, flag: map[string]string{"name": "role"}, want: "conversation-id"},
{name: "role update missing conversation", path: []string{"group-role", "update"}, flag: map[string]string{"role-id": "r1", "name": "role"}, want: "conversation-id"},
{name: "role remove missing conversation", path: []string{"group-role", "remove"}, flag: map[string]string{"role-id": "r1"}, want: "conversation-id"},
{name: "role set user missing conversation", path: []string{"group-role", "set-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"},
{name: "role set user missing conversation", path: []string{"group-role", "set-user"}, flag: map[string]string{"user": "D1", "role-id": "r1"}, want: "conversation-id"},
{name: "role remove user missing conversation", path: []string{"group-role", "remove-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"},
{name: "role query user missing conversation", path: []string{"group-role", "query-user"}, flag: map[string]string{"user": "D1"}, want: "conversation-id"},
{name: "bots missing legacy group", path: []string{"group", "bots"}, want: "group"},
@@ -58,6 +58,12 @@ func TestCrossPlatformCoverageFramework2MCPDataEdges(t *testing.T) {
t.Fatalf("decoded data=%#v err=%v", data, err)
}
InitDeps(&coverageErrorCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"count":7}`}}}})
data, err = CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil)
if err != nil || data.(map[string]any)["count"] != float64(7) {
t.Fatalf("legacy float64 number data=%#v err=%v", data, err)
}
InitDeps(&coverageErrorCaller{err: errors.New("transport failed")})
if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil {
t.Fatal("expected transport error")
@@ -73,6 +79,16 @@ func TestCrossPlatformCoverageFramework2MCPDataEdges(t *testing.T) {
if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil {
t.Fatal("expected invalid JSON error")
}
InitDeps(&coverageErrorCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{} {}`}}}})
if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil || !strings.Contains(err.Error(), "存在多个 JSON 值") {
t.Fatalf("multiple JSON values error = %v", err)
}
InitDeps(&coverageErrorCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{} {`}}}})
if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil || !strings.Contains(err.Error(), "解析 get_thing 返回失败") {
t.Fatalf("trailing invalid JSON error = %v", err)
}
}
func TestCrossPlatformCoverageFramework2LegacyTextAdapter(t *testing.T) {
+9 -1
View File
@@ -6,6 +6,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
@@ -305,7 +306,14 @@ func CallMCPToolDataOnServer(ctx context.Context, serverID, toolName string, arg
return map[string]any{}, nil
}
var data any
if err := json.Unmarshal([]byte(text), &data); err != nil {
decoder := json.NewDecoder(strings.NewReader(text))
if err := decoder.Decode(&data); err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", toolName, err))
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
err = fmt.Errorf("存在多个 JSON 值")
}
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", toolName, err))
}
return data, nil
+678
View File
@@ -0,0 +1,678 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
const (
recruitServerID = "recruit"
recruitCreateJobTool = "create_job"
recruitGetJobTool = "get_job_detail"
recruitListJobsTool = "list_jobs"
)
var recruitDryRun = &contract.DryRunSpec{
PreviewKind: contract.DryRunPreviewRequest,
RemoteReads: false,
}
var (
recruitListResult = &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"当前页招聘职位查询结果;续页信息位于 meta.pagination","properties":{"jobs":{"type":"array","description":"当前页职位记录","items":{"type":"object","description":"招聘职位摘要","properties":{"jobId":{"type":"string","description":"职位 ID"},"name":{"type":"string","description":"职位名称"},"status":{"type":"number","description":"职位状态枚举值"}},"additionalProperties":true}}},"additionalProperties":true}`),
}
recruitJobDetailResult = &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"招聘职位详情","properties":{"jobId":{"type":"string","description":"职位 ID"},"name":{"type":"string","description":"职位名称"},"description":{"type":"string","description":"职位描述"},"status":{"type":"number","description":"职位状态枚举值"}},"additionalProperties":true}`),
}
recruitCreateJobResult = &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"招聘职位创建结果","properties":{"jobId":{"type":"string","description":"新创建的职位 ID"}},"required":["jobId"],"additionalProperties":true}`),
}
)
func recruitCursorPagination() *contract.PaginationSpec {
return &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"}
}
func newRecruitCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "recruit",
Selection: contract.ProductSelectionDecl{
AgentSummary: "查询和创建钉钉招聘职位",
UseWhen: []string{"需要查询招聘职位列表、查看职位详情或创建职位时"},
AvoidWhen: []string{"查询企业员工与组织架构使用 contact;查询人才池、职业历程或绩效使用 hrbrain"},
},
})
root := &cobra.Command{
Use: "recruit",
Short: "钉钉招聘",
Long: "查询和创建钉钉招聘中的职位信息。",
RunE: groupRunE,
}
job := &cobra.Command{
Use: "job",
Short: "招聘职位管理",
RunE: groupRunE,
}
job.AddCommand(
newRecruitJobListCommand(),
newRecruitJobGetCommand(),
newRecruitJobCreateCommand(),
)
root.AddCommand(job)
return root
}
func newRecruitJobListCommand() *cobra.Command {
return NewLeafCommand(LeafSpec{
Use: "list",
Short: "查询招聘职位列表",
Long: "按职位 ID、关键词、状态、创建人、职位性质等条件分页查询招聘职位。",
Example: " dws recruit job list --keyword Java --status open --size 20 --format json\n dws recruit job list --job-ids JOB_ID_1,JOB_ID_2 --format json",
Server: recruitServerID,
Tool: recruitListJobsTool,
Safety: recruitSafetyRead(),
OutputRollout: output.RolloutUnifiedActive,
Flags: []LeafFlag{
{Name: "job-ids", Usage: "职位 ID,多个值用逗号分隔", Kind: LeafStringSlice, Bind: "jobIds"},
{Name: "required-edu", Usage: "学历要求枚举值", Kind: LeafInt, Bind: "requiredEdu"},
{Name: "status", Usage: "职位状态:draft/open/invalid/closed,多个值用逗号分隔", Bind: "statusList", Trim: true, OmitEmpty: true, Transform: transformRecruitStatuses},
{Name: "job-nature", Usage: "职位性质", Bind: "jobNature", Trim: true, OmitEmpty: true},
{Name: "campus", Usage: "是否为校园招聘", Kind: LeafBool, Bind: "campus"},
{Name: "start-modified-time", Usage: "修改时间范围起点", Bind: "startModifiedTime", Trim: true, OmitEmpty: true},
{Name: "end-modified-time", Usage: "修改时间范围终点", Bind: "endModifiedTime", Trim: true, OmitEmpty: true},
{Name: "creator-user-ids", Usage: "创建人 userId,多个值用逗号分隔", Kind: LeafStringSlice, Bind: "creatorUserIds"},
{Name: "keyword", Usage: "职位搜索关键词", Bind: "keyword", Trim: true, OmitEmpty: true},
{Name: "category", Usage: "职位分类", Bind: "category", Trim: true, OmitEmpty: true},
{Name: "cursor", Usage: "分页游标;首次查询不传,翻页时原样回填返回的 nextCursor", Bind: "cursor", Trim: true, OmitEmpty: true, Transform: transformRecruitCursor},
{Name: "size", Usage: "分页大小,默认 20", Kind: LeafInt, Bind: "size", Default: "20", ArgDefault: "20"},
},
Validate: validateRecruitList,
ResultCall: recruitResultCall,
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitListJobsTool, CanonicalPath: "recruit.list_jobs", CLIPath: "recruit job list", PrimaryCLIPath: "recruit job list"},
Description: "按条件分页查询招聘职位",
DryRun: recruitDryRun,
Result: recruitListResult,
Pagination: recruitCursorPagination(),
Interface: recruitMCPInterface(recruitListJobsTool),
Selection: contract.SelectionSpec{
AgentSummary: "按关键词、状态、创建人等条件分页查询招聘职位",
UseWhen: []string{"需要查找职位、筛选在招职位或取得 jobId 时"},
AvoidWhen: []string{"已经持有明确 jobId 且需要完整详情时使用 recruit job get"},
Examples: []string{`dws recruit job list --keyword "Java" --status open --size 20 --format json`},
},
Parameters: recruitListParamDecls(),
},
})
}
func recruitListToolArgs(args map[string]any) map[string]any {
params := map[string]any{"param": map[string]any{}, "size": args["size"]}
query := params["param"].(map[string]any)
for key, value := range args {
switch key {
case "cursor":
params[key] = value
case "size":
default:
query[key] = value
}
}
return params
}
func recruitResultCall(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) {
toolArgs := args
if tool == recruitListJobsTool {
toolArgs = recruitListToolArgs(args)
}
if deps.Caller.DryRun() {
return output.Success(map[string]any{
"tool": tool, "arguments": toolArgs, "executed": false,
}, output.WithDryRun()), nil
}
data, err := callRecruitMCPToolData(cmd.Context(), tool, toolArgs)
if err != nil {
return nil, err
}
clean, err := recruitBusinessResultData(data, tool)
if err != nil {
return recruitResponseFailure(err), nil
}
switch tool {
case recruitListJobsTool:
listData, meta, err := recruitListResultData(clean)
if err != nil {
return recruitInvalidResponse(err), nil
}
return output.Success(listData, output.WithMeta(meta)), nil
case recruitGetJobTool, recruitCreateJobTool:
if err := validateRecruitJobResult(clean, tool, args); err != nil {
return recruitInvalidResponse(err), nil
}
return output.Success(clean), nil
default:
return recruitInvalidResponse(fmt.Errorf("不支持校验 %s 的业务结果", tool)), nil
}
}
func validateRecruitJobResult(data map[string]any, tool string, args map[string]any) error {
jobID, ok := data["jobId"].(string)
if !ok || strings.TrimSpace(jobID) == "" {
return fmt.Errorf("%s 返回值缺少非空字符串字段 jobId", tool)
}
if tool != recruitGetJobTool {
return nil
}
requestedJobID, ok := args["jobId"].(string)
if !ok || strings.TrimSpace(requestedJobID) == "" {
return fmt.Errorf("%s 请求缺少非空字符串字段 jobId", tool)
}
if strings.TrimSpace(jobID) != strings.TrimSpace(requestedJobID) {
return fmt.Errorf("%s 返回的 jobId %q 与请求的 jobId %q 不一致", tool, jobID, requestedJobID)
}
return nil
}
func callRecruitMCPToolData(ctx context.Context, tool string, args map[string]any) (any, error) {
text, err := callMCPToolReturnTextOnServer(ctx, recruitServerID, tool, args)
if err != nil {
return nil, err
}
if strings.TrimSpace(text) == "" {
return map[string]any{}, nil
}
var data any
decoder := json.NewDecoder(strings.NewReader(text))
decoder.UseNumber()
if err := decoder.Decode(&data); err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
err = fmt.Errorf("存在多个 JSON 值")
}
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
}
return data, nil
}
func recruitResponseFailure(err error) output.CommandResult {
var businessFailure *recruitBusinessFailure
if errors.As(err, &businessFailure) {
return output.Failure(&output.ErrorInfo{
Type: "api", Message: businessFailure.Error(),
})
}
return recruitInvalidResponse(err)
}
type recruitBusinessFailure struct {
message string
}
func (e *recruitBusinessFailure) Error() string {
return e.message
}
func recruitBusinessResultData(data any, tool string) (map[string]any, error) {
object, ok := data.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s 返回值必须是 JSON 对象", tool)
}
successValue, hasSuccess := object["success"]
resultValue, hasResult := object["result"]
if !hasSuccess && !hasResult {
return object, nil
}
if !hasSuccess || !hasResult {
return nil, fmt.Errorf("%s 返回的 Connector 信封必须同时包含 success 和 result", tool)
}
success, ok := successValue.(bool)
if !ok {
return nil, fmt.Errorf("%s 返回的 Connector 信封字段 success 必须是布尔值", tool)
}
if !success {
message, _ := object["message"].(string)
if strings.TrimSpace(message) == "" {
message = "Connector 返回 success=false"
}
return nil, &recruitBusinessFailure{message: fmt.Sprintf("%s 调用失败: %s", tool, message)}
}
result, ok := resultValue.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s 返回值的 result 必须是 JSON 对象", tool)
}
return result, nil
}
func recruitInvalidResponse(err error) output.CommandResult {
return output.Failure(&output.ErrorInfo{
Type: "api", Subtype: "invalid_response", Message: err.Error(),
Hint: "保留原始响应并停止后续操作;不要依据不完整结果继续处理。",
})
}
func recruitListResultData(data any) (any, *output.Meta, error) {
object, ok := data.(map[string]any)
if !ok {
return nil, nil, fmt.Errorf("%s 返回值必须是 JSON 对象", recruitListJobsTool)
}
// The Connector envelope has already been removed by recruitResultCall.
// Only normalize the list business payload here so fields named success or
// result inside that payload cannot be mistaken for another envelope.
hasMore, ok := object["hasMore"].(bool)
if !ok {
return nil, nil, fmt.Errorf("list_jobs 返回值缺少布尔字段 hasMore")
}
nextToken := ""
if hasMore {
if raw, exists := object["nextCursor"]; exists && raw != nil {
var err error
nextToken, err = normalizeRecruitNextCursor(raw)
if err != nil {
return nil, nil, err
}
}
if nextToken == "" {
return nil, nil, fmt.Errorf("list_jobs 返回 hasMore=true 但缺少 nextCursor")
}
}
clean := make(map[string]any, len(object))
for key, value := range object {
if key != "hasMore" && key != "nextCursor" {
clean[key] = value
}
}
if jobs, exists := clean["list"]; exists {
if _, alreadyNormalized := clean["jobs"]; !alreadyNormalized {
clean["jobs"] = jobs
}
delete(clean, "list")
}
pagination := &output.Pagination{EndpointExhausted: !hasMore, NextToken: nextToken, Pages: 1}
meta := &output.Meta{Pagination: pagination}
if jobs, exists := clean["jobs"].([]any); exists {
meta.Count = output.NewCount(len(jobs))
pagination.Items = len(jobs)
}
return clean, meta, nil
}
func normalizeRecruitNextCursor(raw any) (string, error) {
var value string
switch cursor := raw.(type) {
case string:
value = strings.TrimSpace(cursor)
case json.Number:
value = string(cursor)
case float64:
value = strconv.FormatFloat(cursor, 'f', -1, 64)
default:
return "", fmt.Errorf("list_jobs 的 nextCursor 必须是字符串或数字")
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return "", fmt.Errorf("list_jobs 的 nextCursor 必须是可回填的非负十进制 int64 游标")
}
return strconv.FormatInt(parsed, 10), nil
}
func newRecruitJobGetCommand() *cobra.Command {
return NewLeafCommand(LeafSpec{
Use: "get",
Short: "查询招聘职位详情",
Long: "根据职位 ID 查询招聘职位详情。",
Example: " dws recruit job get --job-id JOB_ID --format json",
Server: recruitServerID,
Tool: recruitGetJobTool,
Safety: recruitSafetyRead(),
OutputRollout: output.RolloutUnifiedActive,
ResultCall: recruitResultCall,
Flags: []LeafFlag{{
Name: "job-id", Usage: "职位 ID(必填)", Bind: "jobId", Trim: true,
Required: true, RequiredHint: "--job-id 为必填",
}},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitGetJobTool, CanonicalPath: "recruit.get_job_detail", CLIPath: "recruit job get", PrimaryCLIPath: "recruit job get"},
Description: "根据职位 ID 查询招聘职位详情",
DryRun: recruitDryRun,
Result: recruitJobDetailResult,
Interface: recruitMCPInterface(recruitGetJobTool),
Selection: contract.SelectionSpec{
AgentSummary: "获取指定招聘职位的完整信息",
UseWhen: []string{"已经持有明确 jobId,需要查看职位详情时"},
AvoidWhen: []string{"不知道 jobId 时先使用 recruit job list 查询"},
Examples: []string{"dws recruit job get --job-id <JOB_ID> --format json"},
},
Parameters: []contract.ParamDecl{{Name: "job-id", Property: "jobId", Required: boolPtr(true), InterfaceType: "string"}},
},
})
}
func newRecruitJobCreateCommand() *cobra.Command {
return NewLeafCommand(LeafSpec{
Use: "create",
Short: "创建招聘职位",
Long: "从 JSON 文件读取职位信息并创建招聘职位。该操作会写入远端招聘系统,执行前必须确认。",
Example: " dws recruit job create --from ./job.json --dry-run --format json\n dws recruit job create --from ./job.json --format json",
Server: recruitServerID,
Tool: recruitCreateJobTool,
Safety: recruitSafetyCreate(),
OutputRollout: output.RolloutUnifiedActive,
ResultCall: recruitResultCall,
Flags: []LeafFlag{{
Name: "from", Usage: "职位 JSON 文件路径(必填)", Bind: "atsAddJobParam", Trim: true,
Required: true, RequiredHint: "--from 为必填", Transform: loadRecruitJobFile,
}},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitCreateJobTool, CanonicalPath: "recruit.create_job", CLIPath: "recruit job create", PrimaryCLIPath: "recruit job create"},
Description: "从 JSON 文件创建招聘职位",
DryRun: recruitDryRun,
Result: recruitCreateJobResult,
Interface: recruitMCPInterface(recruitCreateJobTool),
Selection: contract.SelectionSpec{
AgentSummary: "使用结构化 JSON 创建招聘职位",
UseWhen: []string{"用户明确要求新建职位,并已准备或同意生成职位 JSON 时;文件必须包含 name、description、jobNature、requiredEdu、extData、creatorUserId;jobNature 固定为 FULL-TIME;creatorUserId 必须使用真实创建人 userId;ownerUserIds 可选"},
AvoidWhen: []string{"仅查询职位时使用 recruit job list 或 recruit job get"},
Examples: []string{"dws recruit job create --from ./job.json --dry-run --format json"},
},
Parameters: []contract.ParamDecl{{Name: "from", Property: "atsAddJobParam", Required: boolPtr(true), InterfaceType: "object", Description: "职位 JSON 文件;CLI 校验后原样作为 atsAddJobParam 对象发送;creatorUserId 为必填的创建人 userId,ownerUserIds 为可选的负责人 userId 字符串数组"}},
},
})
}
func recruitListParamDecls() []contract.ParamDecl {
return []contract.ParamDecl{
{Name: "job-ids", Property: "param.jobIds", InterfaceType: "array"},
{Name: "required-edu", Property: "param.requiredEdu", InterfaceType: "number"},
{Name: "status", Property: "param.statusList", InterfaceType: "array", Enum: []string{"draft", "open", "invalid", "closed"}},
{Name: "job-nature", Property: "param.jobNature", InterfaceType: "string"},
{Name: "campus", Property: "param.campus", InterfaceType: "boolean"},
{Name: "start-modified-time", Property: "param.startModifiedTime", InterfaceType: "string"},
{Name: "end-modified-time", Property: "param.endModifiedTime", InterfaceType: "string"},
{Name: "creator-user-ids", Property: "param.creatorUserIds", InterfaceType: "array"},
{Name: "keyword", Property: "param.keyword", InterfaceType: "string"},
{Name: "category", Property: "param.category", InterfaceType: "string"},
{Name: "cursor", Property: "cursor", InterfaceType: "number"},
{Name: "size", Property: "size", InterfaceType: "number"},
}
}
func recruitMCPInterface(tool string) *contract.InterfaceSpec {
return &contract.InterfaceSpec{
Mode: contract.InterfaceModeMCP,
Availability: contract.InterfaceAvailable,
Ref: &contract.InterfaceRefSpec{ProductID: recruitServerID, RPCName: tool},
}
}
func recruitSafetyRead() contract.SafetySpec {
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
}
func recruitSafetyCreate() contract.SafetySpec {
return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "non_idempotent"}
}
func transformRecruitStatuses(raw string) (any, error) {
values := strings.Split(raw, ",")
statuses := make([]int, 0, len(values))
seen := make(map[int]bool, len(values))
lookup := map[string]int{"draft": 0, "open": 1, "invalid": 2, "closed": 3}
for _, value := range values {
name := strings.ToLower(strings.TrimSpace(value))
if name == "" {
continue
}
status, ok := lookup[name]
if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf("--status 不支持 %q,可选 draft/open/invalid/closed", value))
}
if !seen[status] {
statuses = append(statuses, status)
seen[status] = true
}
}
return statuses, nil
}
func transformRecruitCursor(raw string) (any, error) {
value := strings.TrimSpace(raw)
cursor, err := strconv.ParseInt(value, 10, 64)
if err != nil || cursor < 0 {
return nil, apperrors.NewValidation("--cursor 必须是大于或等于 0 的整数")
}
return cursor, nil
}
func validateRecruitList(cmd *cobra.Command, _ []string) error {
size, _ := cmd.Flags().GetInt("size")
if cmd.Flags().Changed("size") && (size < 1 || size > 100) {
return apperrors.NewValidation("--size 必须在 1 到 100 之间")
}
requiredEdu, _ := cmd.Flags().GetInt("required-edu")
if cmd.Flags().Changed("required-edu") && (requiredEdu < 1 || requiredEdu > 9) {
return apperrors.NewValidation("--required-edu 必须在 1 到 9 之间")
}
return nil
}
func loadRecruitJobFile(path string) (any, error) {
data, err := os.ReadFile(strings.TrimSpace(path))
if err != nil {
return nil, fmt.Errorf("读取职位 JSON 失败: %w", err)
}
var job map[string]any
if err := json.Unmarshal(data, &job); err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("职位文件不是有效的 JSON 对象: %v", err))
}
if job == nil {
return nil, apperrors.NewValidation("职位 JSON 顶层必须是对象")
}
if err := validateRecruitJob(job); err != nil {
return nil, err
}
return job, nil
}
func validateRecruitJob(job map[string]any) error {
for _, name := range []string{"name", "description", "jobNature", "requiredEdu", "extData", "creatorUserId"} {
value, ok := job[name]
if !ok || value == nil || (isString(value) && strings.TrimSpace(value.(string)) == "") {
return apperrors.NewValidation(fmt.Sprintf("职位 JSON 缺少必填字段 %s", name))
}
}
for _, name := range []string{"name", "description", "jobNature"} {
if !isString(job[name]) {
return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是字符串", name))
}
}
if job["jobNature"].(string) != "FULL-TIME" {
return apperrors.NewValidation("职位 JSON 字段 jobNature 当前仅支持 FULL-TIME")
}
requiredEdu, ok := job["requiredEdu"].(float64)
if !ok {
return apperrors.NewValidation("职位 JSON 字段 requiredEdu 必须是数字")
}
if requiredEdu < 1 || requiredEdu > 9 || requiredEdu != float64(int(requiredEdu)) {
return apperrors.NewValidation("职位 JSON 字段 requiredEdu 必须是 1 到 9 的整数")
}
minSalary, hasMinSalary, err := optionalRecruitNumber(job, "minSalary")
if err != nil {
return err
}
maxSalary, hasMaxSalary, err := optionalRecruitNumber(job, "maxSalary")
if err != nil {
return err
}
if hasMinSalary && hasMaxSalary && minSalary > maxSalary {
return apperrors.NewValidation("职位 JSON 中 minSalary 不能大于 maxSalary")
}
for _, name := range []string{"province", "city", "district", "category"} {
if value, exists := job[name]; exists && value != nil && !isString(value) {
return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是字符串", name))
}
}
if value, exists := job["campus"]; exists && value != nil {
if _, ok := value.(bool); !ok {
return apperrors.NewValidation("职位 JSON 字段 campus 必须是布尔值")
}
}
if err := validateRecruitIdentityFields(job); err != nil {
return err
}
if err := validateRecruitAddress(job); err != nil {
return err
}
extData, ok := job["extData"].(map[string]any)
if !ok {
return apperrors.NewValidation("职位 JSON 字段 extData 必须是对象")
}
if err := validateRecruitExtData(extData); err != nil {
return err
}
return nil
}
func optionalRecruitNumber(values map[string]any, name string) (float64, bool, error) {
value, exists := values[name]
if !exists || value == nil {
return 0, false, nil
}
number, ok := value.(float64)
if !ok {
return 0, false, apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是数字", name))
}
return number, true, nil
}
func validateRecruitIdentityFields(job map[string]any) error {
if _, ok := job["creatorUserId"].(string); !ok {
return apperrors.NewValidation("职位 JSON 字段 creatorUserId 必须是非空字符串")
}
if value, exists := job["ownerUserIds"]; exists && value != nil {
ownerUserIDs, ok := value.([]any)
if !ok {
return apperrors.NewValidation("职位 JSON 字段 ownerUserIds 必须是字符串数组")
}
for _, owner := range ownerUserIDs {
ownerUserID, ok := owner.(string)
if !ok || strings.TrimSpace(ownerUserID) == "" {
return apperrors.NewValidation("职位 JSON 字段 ownerUserIds 只能包含非空字符串")
}
}
}
return nil
}
func validateRecruitAddress(job map[string]any) error {
value, exists := job["address"]
if !exists || value == nil {
return nil
}
address, ok := value.(map[string]any)
if !ok {
return apperrors.NewValidation("职位 JSON 字段 address 必须是对象")
}
for _, name := range []string{"name", "detail", "longitude", "latitude"} {
field, exists := address[name]
text, ok := field.(string)
if !exists || !ok || strings.TrimSpace(text) == "" {
return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 address.%s 必须是非空字符串", name))
}
}
return nil
}
func validateRecruitExtData(extData map[string]any) error {
if value, exists := extData["headCount"]; exists && value != nil {
headCount, ok := value.(float64)
if !ok || headCount < 1 || headCount > 999 || headCount != float64(int(headCount)) {
return apperrors.NewValidation("职位 JSON 字段 extData.headCount 必须是 1 到 999 的整数")
}
}
if value, exists := extData["source"]; exists && value != nil && !isString(value) {
return apperrors.NewValidation("职位 JSON 字段 extData.source 必须是字符串")
}
if value, exists := extData["fullTimeExtData"]; exists && value != nil {
fullTime, ok := value.(map[string]any)
if !ok {
return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData 必须是对象")
}
if err := validateRecruitFullTimeExtData(fullTime); err != nil {
return err
}
}
if value, exists := extData["tags"]; exists && value != nil {
tags, ok := value.([]any)
if !ok {
return apperrors.NewValidation("职位 JSON 字段 extData.tags 必须是数组")
}
for _, value := range tags {
tag, ok := value.(map[string]any)
if !ok {
return apperrors.NewValidation("职位 JSON 字段 extData.tags 只能包含对象")
}
name, ok := tag["name"].(string)
if !ok || strings.TrimSpace(name) == "" {
return apperrors.NewValidation("职位 JSON 字段 extData.tags[].name 必须是非空字符串")
}
}
}
return nil
}
func validateRecruitFullTimeExtData(fullTime map[string]any) error {
salaryMonth, hasSalaryMonth, err := optionalRecruitNumber(fullTime, "salaryMonth")
if err != nil {
return err
}
if hasSalaryMonth && (salaryMonth < 12 || salaryMonth > 24 || salaryMonth != float64(int(salaryMonth))) {
return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData.salaryMonth 必须是 12 到 24 的整数")
}
minExperience, hasMinExperience, err := optionalRecruitNumber(fullTime, "minJobExperience")
if err != nil {
return err
}
maxExperience, hasMaxExperience, err := optionalRecruitNumber(fullTime, "maxJobExperience")
if err != nil {
return err
}
if hasMinExperience && minExperience < 0 {
return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData.minJobExperience 不能小于 0")
}
if hasMaxExperience && maxExperience < 0 {
return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData.maxJobExperience 不能小于 0")
}
if hasMinExperience && hasMaxExperience && minExperience > maxExperience {
return apperrors.NewValidation("职位 JSON 中 minJobExperience 不能大于 maxJobExperience")
}
return nil
}
func isString(value any) bool {
_, ok := value.(string)
return ok
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -31,7 +31,7 @@ func TestCrossPlatformCoveragePublicProductCommandsBuildCompleteUniqueTrees(t *t
for _, want := range []string{
"agoal", "aisearch", "aitable", "attendance", "calendar", "chat",
"contact", "devdoc", "ding", "doc", "drive", "live", "mail",
"markdown", "minutes", "oa", "report", "sheet", "todo", "wiki", "whiteboard",
"markdown", "minutes", "oa", "recruit", "report", "sheet", "todo", "wiki", "whiteboard",
} {
if !seenProducts[want] {
t.Errorf("public product %q was not registered", want)
+11
View File
@@ -0,0 +1,11 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
// 招聘是开源库显式维护的公开命令,不依赖生成式产品注册表。
func init() {
RegisterPublic(func() Handler {
return wukongHandler{name: "recruit", buildFn: newRecruitCommand}
})
}
@@ -0,0 +1,639 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package interfacesnapshot
import (
"bytes"
"encoding/json"
"fmt"
"io"
"reflect"
"strconv"
"strings"
)
const CommandMigrationManifestVersion = 1
const (
CommandMigrationPending = "pending"
CommandMigrationConsumed = "consumed"
CommandMigrationMove = "command_move"
CommandMigrationFlagExtraction = "flag_extraction"
)
// CommandMigrationManifest governs compatibility-preserving surface moves that
// cannot be represented as an in-command flag rename. The merge-base owns the
// authorization; the candidate copy is only a lifecycle receipt.
type CommandMigrationManifest struct {
Version int `json:"version"`
Migrations []CommandMigration `json:"migrations"`
}
type CommandMigration struct {
Kind string `json:"kind"`
Legacy CommandMigrationSide `json:"legacy"`
Replacement CommandMigrationSide `json:"replacement"`
LegacyFlag CommandMigrationFlag `json:"legacy_flag"`
Schema CommandMigrationSchema `json:"schema"`
State string `json:"state"`
Reason string `json:"reason"`
}
type CommandMigrationSide struct {
Command string `json:"command"`
Before CommandMigrationState `json:"before"`
After CommandMigrationState `json:"after"`
}
type CommandMigrationState struct {
Present bool `json:"present"`
Runnable bool `json:"runnable,omitempty"`
Hidden bool `json:"hidden,omitempty"`
}
type CommandMigrationFlag struct {
Name string `json:"name,omitempty"`
Before FlagMigrationState `json:"before"`
After FlagMigrationState `json:"after"`
}
type CommandMigrationSchema struct {
ProductID string `json:"product_id"`
SourceToolID string `json:"source_tool_id"`
ReplacementToolID string `json:"replacement_tool_id"`
Parameters []CommandParameterMigration `json:"parameters"`
}
type CommandParameterMigration struct {
From string `json:"from"`
To string `json:"to,omitempty"`
ReplacementConstant *CommandReplacementConstant `json:"replacement_constant,omitempty"`
}
type CommandReplacementConstant struct {
Property string `json:"property"`
Value bool `json:"value"`
}
func ReadCommandMigrationManifest(r io.Reader) (CommandMigrationManifest, error) {
data, err := io.ReadAll(r)
if err != nil {
return CommandMigrationManifest{}, fmt.Errorf("read command migration manifest: %w", err)
}
var manifest CommandMigrationManifest
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&manifest); err != nil {
return CommandMigrationManifest{}, err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return CommandMigrationManifest{}, fmt.Errorf("command migration manifest contains trailing multiple JSON values")
}
return CommandMigrationManifest{}, fmt.Errorf("read trailing command migration manifest data: %w", err)
}
strict := json.NewDecoder(bytes.NewReader(data))
strict.UseNumber()
if err := validateLabeledMigrationJSONValue(strict, "$", reflect.TypeOf(CommandMigrationManifest{}), "command"); err != nil {
return CommandMigrationManifest{}, err
}
if err := manifest.Validate(); err != nil {
return CommandMigrationManifest{}, err
}
return manifest, nil
}
func (m CommandMigrationManifest) Validate() error {
if m.Version != CommandMigrationManifestVersion {
return fmt.Errorf("unsupported command migration manifest version %d (want %d)", m.Version, CommandMigrationManifestVersion)
}
if m.Migrations == nil {
return fmt.Errorf("command migration manifest migrations must be an array")
}
seen := make(map[string]bool, len(m.Migrations))
for index, migration := range m.Migrations {
if err := migration.validate(); err != nil {
return fmt.Errorf("command migration %d: %w", index, err)
}
if seen[migration.key()] {
return fmt.Errorf("command migration %d duplicates %s", index, migration.displayKey())
}
seen[migration.key()] = true
}
return nil
}
func (m CommandMigration) validate() error {
if m.Kind != CommandMigrationMove && m.Kind != CommandMigrationFlagExtraction {
return fmt.Errorf("invalid kind %q", m.Kind)
}
if !isExactCommandPath(m.Legacy.Command) || !isExactCommandPath(m.Replacement.Command) {
return fmt.Errorf("legacy and replacement must be exact command paths rooted at dws")
}
if m.Legacy.Command == m.Replacement.Command {
return fmt.Errorf("legacy and replacement command paths must differ")
}
if strings.TrimSpace(m.Reason) == "" || m.Reason != strings.TrimSpace(m.Reason) {
return fmt.Errorf("migration must include a non-empty trimmed reason")
}
if m.State != CommandMigrationPending && m.State != CommandMigrationConsumed {
return fmt.Errorf("invalid state %q", m.State)
}
for label, state := range map[string]CommandMigrationState{
"legacy before": m.Legacy.Before,
"legacy after": m.Legacy.After,
"replacement before": m.Replacement.Before,
"replacement after": m.Replacement.After,
} {
if err := state.validate(label); err != nil {
return err
}
}
if !m.Legacy.Before.Present || !m.Legacy.Before.Runnable || m.Legacy.Before.Hidden ||
!m.Legacy.After.Present || !m.Legacy.After.Runnable {
return fmt.Errorf("legacy command must remain runnable and start visible")
}
if m.Replacement.Before.Present || !m.Replacement.After.Present || !m.Replacement.After.Runnable || m.Replacement.After.Hidden {
return fmt.Errorf("replacement command must migrate exactly from absent to visible runnable")
}
if err := m.Schema.validate(m.Kind); err != nil {
return err
}
switch m.Kind {
case CommandMigrationMove:
if strings.HasPrefix(m.Replacement.Command, m.Legacy.Command+" ") ||
strings.HasPrefix(m.Legacy.Command, m.Replacement.Command+" ") {
return fmt.Errorf("command_move legacy and replacement command paths must not have an ancestor relationship")
}
if !m.Legacy.After.Hidden {
return fmt.Errorf("command_move legacy command must migrate exactly from visible to hidden")
}
if m.LegacyFlag != (CommandMigrationFlag{}) {
return fmt.Errorf("command_move must not declare legacy_flag")
}
if m.Schema.SourceToolID != m.Schema.ReplacementToolID {
return fmt.Errorf("command_move must retain one stable Schema tool identity")
}
case CommandMigrationFlagExtraction:
if m.Legacy.After.Hidden || m.Legacy.Before != m.Legacy.After {
return fmt.Errorf("flag_extraction legacy command must remain visible and unchanged")
}
if err := m.LegacyFlag.validate(); err != nil {
return err
}
if m.Schema.SourceToolID == m.Schema.ReplacementToolID {
return fmt.Errorf("flag_extraction requires a distinct replacement Schema tool")
}
if m.LegacyFlag.Before.Type != "bool" || m.LegacyFlag.After.Type != "bool" {
return fmt.Errorf("flag_extraction legacy_flag must be an optional bool")
}
var extracted *CommandParameterMigration
constantCount := 0
for index := range m.Schema.Parameters {
parameter := &m.Schema.Parameters[index]
if parameter.ReplacementConstant == nil {
continue
}
constantCount++
if parameter.From == m.LegacyFlag.Name {
extracted = parameter
}
}
if constantCount != 1 {
return fmt.Errorf("flag_extraction must declare exactly one replacement constant")
}
if extracted == nil {
return fmt.Errorf("flag_extraction must map the exact extracted flag to replacement_constant")
}
if !extracted.ReplacementConstant.Value {
return fmt.Errorf("flag_extraction v1 replacement_constant must be true")
}
wantNoOpt := strconv.FormatBool(extracted.ReplacementConstant.Value)
if m.LegacyFlag.Before.NoOpt != wantNoOpt || m.LegacyFlag.After.NoOpt != wantNoOpt {
return fmt.Errorf("flag_extraction legacy_flag no_opt must equal replacement constant %q", wantNoOpt)
}
}
return nil
}
func (s CommandMigrationState) validate(label string) error {
if !s.Present {
if s.Runnable || s.Hidden {
return fmt.Errorf("%s absent state must not declare command attributes", label)
}
return nil
}
if !s.Runnable {
return fmt.Errorf("%s present state must be runnable", label)
}
return nil
}
func (f CommandMigrationFlag) validate() error {
if !isExactFlagName(f.Name) {
return fmt.Errorf("legacy_flag name must be an exact flag")
}
if err := f.Before.validate("legacy_flag before"); err != nil {
return err
}
if err := f.After.validate("legacy_flag after"); err != nil {
return err
}
if !f.Before.Present || !f.After.Present || f.Before.Hidden || !f.After.Hidden {
return fmt.Errorf("legacy_flag must migrate exactly from visible to hidden while remaining present")
}
if f.Before.Required || f.After.Required {
return fmt.Errorf("legacy_flag must remain optional before and after extraction")
}
before := f.Before
after := f.After
before.Hidden = false
after.Hidden = false
if before != after {
return fmt.Errorf("legacy_flag may change only hidden visibility")
}
return nil
}
func (s CommandMigrationSchema) validate(kind string) error {
for label, value := range map[string]string{
"product_id": s.ProductID,
"source_tool_id": s.SourceToolID,
"replacement_tool_id": s.ReplacementToolID,
} {
if !isExactSchemaIdentifier(value) {
return fmt.Errorf("schema %s must be an exact identifier", label)
}
}
if s.Parameters == nil {
return fmt.Errorf("schema parameters must be an array")
}
seenFrom := map[string]bool{}
seenTo := map[string]bool{}
seenConstantProperty := map[string]bool{}
for index, parameter := range s.Parameters {
if !isExactFlagName(parameter.From) {
return fmt.Errorf("schema parameter %d from must be an exact parameter name", index)
}
if seenFrom[parameter.From] {
return fmt.Errorf("schema parameter %d duplicates from %q", index, parameter.From)
}
seenFrom[parameter.From] = true
if kind == CommandMigrationMove {
if parameter.ReplacementConstant != nil {
return fmt.Errorf("command_move schema parameter %d must not declare replacement_constant", index)
}
if !isExactFlagName(parameter.To) || parameter.To == parameter.From {
return fmt.Errorf("command_move schema parameter %d requires a distinct exact to name", index)
}
if seenTo[parameter.To] {
return fmt.Errorf("schema parameter %d duplicates to %q", index, parameter.To)
}
seenTo[parameter.To] = true
continue
}
if kind != CommandMigrationFlagExtraction {
return fmt.Errorf("invalid command migration kind %q", kind)
}
hasTo := parameter.To != ""
hasConstant := parameter.ReplacementConstant != nil
if !hasTo && !hasConstant {
return fmt.Errorf("flag_extraction schema parameter %d requires an exact to or replacement_constant", index)
}
if hasTo && hasConstant {
return fmt.Errorf("flag_extraction schema parameter %d must declare exactly one target", index)
}
if hasTo {
if !isExactFlagName(parameter.To) {
return fmt.Errorf("flag_extraction schema parameter %d to must be an exact parameter name", index)
}
if seenTo[parameter.To] {
return fmt.Errorf("schema parameter %d duplicates to %q", index, parameter.To)
}
if seenConstantProperty[parameter.To] {
return fmt.Errorf("schema parameter %d duplicates parameter target %q", index, parameter.To)
}
seenTo[parameter.To] = true
continue
}
property := parameter.ReplacementConstant.Property
if !isExactSchemaIdentifier(property) {
return fmt.Errorf("flag_extraction schema parameter %d replacement_constant requires an exact property", index)
}
if seenConstantProperty[property] {
return fmt.Errorf("schema parameter %d duplicates replacement_constant property %q", index, property)
}
if seenTo[property] {
return fmt.Errorf("schema parameter %d duplicates parameter target %q", index, property)
}
seenConstantProperty[property] = true
}
return nil
}
func isExactSchemaIdentifier(value string) bool {
return value != "" && value == strings.TrimSpace(value) &&
!strings.ContainsAny(value, "*?[]{} /\\\t\r\n")
}
func (m CommandMigration) key() string {
return strings.Join([]string{m.Kind, m.Legacy.Command, m.Replacement.Command, m.Schema.ProductID, m.Schema.SourceToolID, m.Schema.ReplacementToolID}, "\x00")
}
func (m CommandMigration) displayKey() string {
return fmt.Sprintf("%s %q -> %q", m.Kind, m.Legacy.Command, m.Replacement.Command)
}
type commandMigrationPhase string
const (
commandMigrationBefore commandMigrationPhase = "before"
commandMigrationAfter commandMigrationPhase = "after"
commandMigrationPartial commandMigrationPhase = "partial"
)
func AuthorizeCommandMigrations(
current Snapshot,
references map[string]Snapshot,
authority CommandMigrationManifest,
candidate CommandMigrationManifest,
) ([]CommandMigration, error) {
if err := current.Validate(); err != nil {
return nil, fmt.Errorf("validate current interface snapshot: %w", err)
}
for label, snapshot := range references {
if err := snapshot.Validate(); err != nil {
return nil, fmt.Errorf("validate %s interface snapshot: %w", label, err)
}
}
if err := authority.Validate(); err != nil {
return nil, fmt.Errorf("validate approved command migrations: %w", err)
}
if err := candidate.Validate(); err != nil {
return nil, fmt.Errorf("validate candidate command migrations: %w", err)
}
type commandMoveTopologyCheck struct {
label string
snapshot Snapshot
manifest CommandMigrationManifest
}
topologyChecks := []commandMoveTopologyCheck{
{label: "approved", snapshot: current, manifest: authority},
{label: "candidate", snapshot: current, manifest: candidate},
}
if mergeBase, _, ok := flagMigrationAuthoritySnapshot(references); ok {
topologyChecks = append(topologyChecks,
commandMoveTopologyCheck{label: "approved base", snapshot: mergeBase, manifest: authority},
commandMoveTopologyCheck{label: "candidate base", snapshot: mergeBase, manifest: candidate},
)
}
for _, check := range topologyChecks {
if err := validateCommandMoveLegacyLeaves(check.snapshot, check.manifest); err != nil {
return nil, fmt.Errorf("validate %s command migration topology: %w", check.label, err)
}
}
return evaluateCommandMigrationLifecycle(current, references, authority, candidate)
}
func validateCommandMoveLegacyLeaves(snapshot Snapshot, manifest CommandMigrationManifest) error {
commands := commandIndex(snapshot)
for _, migration := range manifest.Migrations {
if migration.Kind != CommandMigrationMove {
continue
}
if _, present := commands[migration.Legacy.Command]; !present {
continue
}
for _, command := range snapshot.Commands {
if strings.HasPrefix(command.Path, migration.Legacy.Command+" ") {
return fmt.Errorf("command_move legacy command %q must be a leaf", migration.Legacy.Command)
}
}
}
return nil
}
func evaluateCommandMigrationLifecycle(
current Snapshot,
references map[string]Snapshot,
authority CommandMigrationManifest,
candidate CommandMigrationManifest,
) ([]CommandMigration, error) {
if len(authority.Migrations) == 0 && len(candidate.Migrations) == 0 {
return nil, nil
}
if _, ok := references["stable"]; !ok {
return nil, fmt.Errorf("command migration lifecycle requires a stable reference")
}
mergeBase, label, ok := flagMigrationAuthoritySnapshot(references)
if !ok {
return nil, fmt.Errorf("command migration lifecycle requires a main or merge-base reference")
}
authorityByKey := commandMigrationIndex(authority)
candidateByKey := commandMigrationIndex(candidate)
authorizations := make([]CommandMigration, 0, len(authority.Migrations))
for _, approved := range authority.Migrations {
basePhase := matchCommandMigrationPhase(mergeBase, approved)
wantBase := commandMigrationBefore
if approved.State == CommandMigrationConsumed {
wantBase = commandMigrationAfter
}
if basePhase != wantBase {
return nil, fmt.Errorf("approved command migration %s is %s in %s, want exact %s state for %s", approved.displayKey(), basePhase, label, wantBase, approved.State)
}
proposed, exists := candidateByKey[approved.key()]
if exists && !sameCommandMigrationApproval(approved, proposed) {
return nil, fmt.Errorf("candidate modified base-owned command migration %s", approved.displayKey())
}
currentPhase := matchCommandMigrationPhase(current, approved)
switch approved.State {
case CommandMigrationPending:
if !exists {
return nil, fmt.Errorf("candidate removed pending command migration %s", approved.displayKey())
}
switch currentPhase {
case commandMigrationBefore:
if proposed.State != CommandMigrationPending {
return nil, fmt.Errorf("candidate falsely consumed unchanged command migration %s", approved.displayKey())
}
case commandMigrationAfter:
if proposed.State != CommandMigrationConsumed {
return nil, fmt.Errorf("candidate completed command migration %s without marking it consumed", approved.displayKey())
}
authorizations = append(authorizations, approved)
default:
return nil, fmt.Errorf("candidate partially applied command migration %s", approved.displayKey())
}
case CommandMigrationConsumed:
if currentPhase != commandMigrationAfter {
return nil, fmt.Errorf("candidate drifted from consumed command migration %s", approved.displayKey())
}
allAfter := true
for _, reference := range references {
if matchCommandMigrationPhase(reference, approved) != commandMigrationAfter {
allAfter = false
break
}
}
if allAfter {
if exists {
return nil, fmt.Errorf("consumed command migration %s is stale after all references reached the after state", approved.displayKey())
}
continue
}
if !exists || proposed.State != CommandMigrationConsumed {
return nil, fmt.Errorf("candidate must retain consumed command migration %s until every reference reaches the after state", approved.displayKey())
}
authorizations = append(authorizations, approved)
}
}
for _, proposed := range candidate.Migrations {
if _, exists := authorityByKey[proposed.key()]; exists {
continue
}
if proposed.State != CommandMigrationPending {
return nil, fmt.Errorf("candidate-added command migration %s must start pending", proposed.displayKey())
}
if matchCommandMigrationPhase(mergeBase, proposed) != commandMigrationBefore {
return nil, fmt.Errorf("candidate-added command migration %s does not match the merge-base before state", proposed.displayKey())
}
if matchCommandMigrationPhase(current, proposed) != commandMigrationBefore {
return nil, fmt.Errorf("candidate-added command migration %s cannot authorize its own interface change", proposed.displayKey())
}
}
return authorizations, nil
}
func commandMigrationIndex(manifest CommandMigrationManifest) map[string]CommandMigration {
index := make(map[string]CommandMigration, len(manifest.Migrations))
for _, migration := range manifest.Migrations {
index[migration.key()] = migration
}
return index
}
func sameCommandMigrationApproval(left, right CommandMigration) bool {
left.State = ""
right.State = ""
return reflect.DeepEqual(left, right)
}
func matchCommandMigrationPhase(snapshot Snapshot, migration CommandMigration) commandMigrationPhase {
commands := commandIndex(snapshot)
legacy := commandMigrationStateForCommand(commands, migration.Legacy.Command)
replacement := commandMigrationStateForCommand(commands, migration.Replacement.Command)
before := legacy == migration.Legacy.Before && replacement == migration.Replacement.Before
after := legacy == migration.Legacy.After && replacement == migration.Replacement.After
if migration.Kind == CommandMigrationFlagExtraction {
command, exists := commands[migration.Legacy.Command]
flagState := FlagMigrationState{}
if exists {
flagState = flagMigrationStateForCommand(command, migration.LegacyFlag.Name)
}
before = before && flagState == migration.LegacyFlag.Before
after = after && flagState == migration.LegacyFlag.After
if replacement, replacementExists := commands[migration.Replacement.Command]; replacementExists {
after = after && commandMigrationReplacementConstantsMatch(replacement, migration)
} else {
after = false
}
}
if before {
return commandMigrationBefore
}
if after {
return commandMigrationAfter
}
return commandMigrationPartial
}
func commandMigrationReplacementConstantsMatch(command Command, migration CommandMigration) bool {
expected := make(map[string]bool)
for _, parameter := range migration.Schema.Parameters {
if parameter.ReplacementConstant == nil {
continue
}
expected[parameter.ReplacementConstant.Property] = parameter.ReplacementConstant.Value
}
return reflect.DeepEqual(command.BoolConstParams, expected)
}
func commandMigrationStateForCommand(commands map[string]Command, path string) CommandMigrationState {
command, exists := commands[path]
if !exists {
return CommandMigrationState{}
}
return CommandMigrationState{Present: true, Runnable: command.Runnable, Hidden: command.Hidden}
}
// CompareAllWithInterfaceMigrations applies both migration families to one
// ordinary report, so the two ledgers cannot mask each other's unrelated
// findings.
func CompareAllWithInterfaceMigrations(
current Snapshot,
references map[string]Snapshot,
flagAuthority FlagMigrationManifest,
flagCandidate FlagMigrationManifest,
commandAuthority CommandMigrationManifest,
commandCandidate CommandMigrationManifest,
) (Report, error) {
flagAuthorizations, err := AuthorizeFlagMigrations(current, references, flagAuthority, flagCandidate)
if err != nil {
return Report{}, err
}
commandAuthorizations, err := AuthorizeCommandMigrations(current, references, commandAuthority, commandCandidate)
if err != nil {
return Report{}, err
}
report := CompareAll(current, references)
for index := range report.Comparisons {
comparison := &report.Comparisons[index]
reference := references[comparison.Reference]
filtered := comparison.Blocking[:0]
for _, change := range comparison.Blocking {
if flagMigrationAuthorizesChange(current, reference, change, flagAuthorizations) ||
commandMigrationAuthorizesChange(current, reference, change, commandAuthorizations) {
continue
}
filtered = append(filtered, change)
}
comparison.Blocking = filtered
comparison.Compatible = len(filtered) == 0
}
report.Compatible = true
for _, comparison := range report.Comparisons {
if !comparison.Compatible {
report.Compatible = false
break
}
}
return report, nil
}
func commandMigrationAuthorizesChange(current, reference Snapshot, change Change, authorizations []CommandMigration) bool {
canonicalPath := acceptedPathIndex(reference)[change.Path]
if canonicalPath == "" {
canonicalPath = change.Path
}
for _, migration := range authorizations {
if canonicalPath != migration.Legacy.Command ||
matchCommandMigrationPhase(reference, migration) != commandMigrationBefore ||
matchCommandMigrationPhase(current, migration) != commandMigrationAfter {
continue
}
switch migration.Kind {
case CommandMigrationMove:
if change.Kind == "command_became_hidden" && change.Flag == "" {
return true
}
case CommandMigrationFlagExtraction:
if change.Kind == "flag_became_hidden" && change.Flag == migration.LegacyFlag.Name {
return true
}
}
}
return false
}
@@ -0,0 +1,744 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package interfacesnapshot
import (
"errors"
"os"
"strings"
"testing"
)
type commandMigrationErrorReader struct{}
func (commandMigrationErrorReader) Read([]byte) (int, error) {
return 0, errors.New("command migration read failure")
}
func TestApprovedCommandMigrationManifestRemainsValid(t *testing.T) {
manifest, err := os.Open("../../scripts/policy/interface-migrations/approved-command-migrations-v1.json")
if err != nil {
t.Fatalf("open approved command migration manifest: %v", err)
}
defer manifest.Close()
if _, err := ReadCommandMigrationManifest(manifest); err != nil {
t.Fatalf("approved command migration manifest is invalid: %v", err)
}
}
func TestCrossPlatformCoverageReadCommandMigrationManifestFailsClosed(t *testing.T) {
valid := commandMigrationManifestJSON()
if _, err := ReadCommandMigrationManifest(strings.NewReader(valid)); err != nil {
t.Fatalf("valid command migration manifest: %v", err)
}
validExtraction := flagExtractionCommandMigrationManifestJSON()
if _, err := ReadCommandMigrationManifest(strings.NewReader(validExtraction)); err != nil {
t.Fatalf("valid flag extraction command migration manifest: %v", err)
}
for _, test := range []struct {
input string
want string
}{
{strings.Replace(valid, `"state": "pending"`, `"state": "pending", "allow": true`, 1), "unknown field"},
{strings.Replace(valid, `"state": "pending"`, `"state": "pending", "state": "consumed"`, 1), "duplicate field"},
{strings.Replace(valid, `"version": 1`, `"version": null`, 1), "must be"},
{strings.Replace(valid, "dws chat message old", "dws chat *", 1), "exact command paths"},
{strings.Replace(valid, `"kind": "command_move"`, `"kind": "anything"`, 1), "invalid kind"},
{`{"version":1,"migrations":null}`, "must be an array"},
{strings.Replace(validExtraction, `"replacement_constant":{"property":"convThreadEnabled","value":true}`, `"replacement_constant":null`, 1), "must be an object"},
{strings.Replace(validExtraction, `,"value":true`, ``, 1), `must be true`},
{strings.Replace(validExtraction, `"value":true`, `"value":"true"`, 1), "bool"},
} {
if _, err := ReadCommandMigrationManifest(strings.NewReader(test.input)); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("ReadCommandMigrationManifest() error=%v, want %q", err, test.want)
}
}
if _, err := ReadCommandMigrationManifest(commandMigrationErrorReader{}); err == nil || !strings.Contains(err.Error(), "read failure") {
t.Fatalf("reader error=%v", err)
}
for _, input := range []string{valid + ` {}`, valid + ` {`} {
if _, err := ReadCommandMigrationManifest(strings.NewReader(input)); err == nil || !strings.Contains(err.Error(), "trailing") {
t.Fatalf("trailing input error=%v", err)
}
}
}
func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) {
validMove := commandMigrationManifest(CommandMigrationPending).Migrations[0]
validExtraction := commandMigrationManifest(CommandMigrationPending).Migrations[1]
duplicate := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{validMove, validMove}}
if err := duplicate.Validate(); err == nil || !strings.Contains(err.Error(), "duplicates") {
t.Fatalf("duplicate error=%v", err)
}
if err := (CommandMigrationManifest{Version: CommandMigrationManifestVersion}).Validate(); err == nil || !strings.Contains(err.Error(), "must be an array") {
t.Fatalf("nil migrations error=%v", err)
}
for _, test := range []struct {
name string
mutate func(*CommandMigration)
want string
}{
{"same command", func(m *CommandMigration) { m.Replacement.Command = m.Legacy.Command }, "must differ"},
{"empty reason", func(m *CommandMigration) { m.Reason = " " }, "non-empty trimmed"},
{"invalid state", func(m *CommandMigration) { m.State = "approved" }, "invalid state"},
{"invalid command state", func(m *CommandMigration) { m.Replacement.Before.Runnable = true }, "absent state"},
{"legacy contract", func(m *CommandMigration) { m.Legacy.Before.Hidden = true }, "remain runnable"},
{"replacement contract", func(m *CommandMigration) { m.Replacement.After.Hidden = true }, "absent to visible"},
{"schema contract", func(m *CommandMigration) { m.Schema.ProductID = "bad/id" }, "exact identifier"},
{"move not hidden", func(m *CommandMigration) { m.Legacy.After.Hidden = false }, "must migrate exactly"},
{"move flag", func(m *CommandMigration) { m.LegacyFlag.Name = "thread" }, "must not declare legacy_flag"},
{"move tool identity", func(m *CommandMigration) { m.Schema.ReplacementToolID = "chat.new" }, "stable Schema tool"},
} {
t.Run(test.name, func(t *testing.T) {
migration := validMove
test.mutate(&migration)
if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("validate error=%v, want %q", err, test.want)
}
})
}
for _, test := range []struct {
name string
mutate func(*CommandMigration)
want string
}{
{"legacy changed", func(m *CommandMigration) { m.Legacy.After.Hidden = true }, "remain visible"},
{"invalid legacy flag", func(m *CommandMigration) { m.LegacyFlag.Name = "bad name" }, "exact flag"},
{"same tool", func(m *CommandMigration) { m.Schema.ReplacementToolID = m.Schema.SourceToolID }, "distinct replacement"},
{"wrong parameter", func(m *CommandMigration) { m.Schema.Parameters[3].From = "other" }, "exact extracted flag"},
{"wrong type", func(m *CommandMigration) {
m.LegacyFlag.Before.Type = "string"
m.LegacyFlag.After.Type = "string"
}, "optional bool"},
{"wrong no opt", func(m *CommandMigration) {
m.LegacyFlag.Before.NoOpt = "false"
m.LegacyFlag.After.NoOpt = "false"
}, "no_opt"},
{"missing extracted mapping", func(m *CommandMigration) {
m.Schema.Parameters = m.Schema.Parameters[:3]
}, "exactly one replacement constant"},
{"duplicate extracted mapping", func(m *CommandMigration) {
m.Schema.Parameters = append(m.Schema.Parameters, m.Schema.Parameters[3])
}, "duplicates from"},
{"multiple constants", func(m *CommandMigration) {
m.Schema.Parameters[0] = CommandParameterMigration{
From: "name",
ReplacementConstant: &CommandReplacementConstant{
Property: "name",
Value: true,
},
}
}, "exactly one replacement constant"},
} {
t.Run(test.name, func(t *testing.T) {
migration := cloneCommandMigration(validExtraction)
test.mutate(&migration)
if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("validate error=%v, want %q", err, test.want)
}
})
}
falseConstant := cloneCommandMigration(validExtraction)
falseConstant.Schema.Parameters[3].ReplacementConstant.Value = false
falseConstant.LegacyFlag.Before.NoOpt = "false"
falseConstant.LegacyFlag.After.NoOpt = "false"
if err := falseConstant.validate(); err == nil || !strings.Contains(err.Error(), "must be true") {
t.Fatalf("false replacement constant validation error=%v, want v1 true-only rejection", err)
}
for _, state := range []CommandMigrationState{
{Runnable: true},
{Present: true},
} {
if err := state.validate("state"); err == nil {
t.Fatalf("invalid command state accepted: %#v", state)
}
}
for _, test := range []struct {
name string
mutate func(*CommandMigrationFlag)
want string
}{
{"name", func(f *CommandMigrationFlag) { f.Name = "bad name" }, "exact flag"},
{"before state", func(f *CommandMigrationFlag) { f.Before = FlagMigrationState{Present: true} }, "requires type"},
{"after state", func(f *CommandMigrationFlag) { f.After = FlagMigrationState{Present: true} }, "requires type"},
{"visibility", func(f *CommandMigrationFlag) { f.After.Hidden = false }, "visible to hidden"},
{"attribute", func(f *CommandMigrationFlag) { f.After.Type = "string" }, "only hidden visibility"},
} {
t.Run("flag "+test.name, func(t *testing.T) {
flag := validExtraction.LegacyFlag
test.mutate(&flag)
if err := flag.validate(); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("flag validate error=%v, want %q", err, test.want)
}
})
}
validSchema := validMove.Schema
for _, test := range []struct {
name string
kind string
mutate func(*CommandMigrationSchema)
want string
}{
{"identifier", CommandMigrationMove, func(s *CommandMigrationSchema) { s.ProductID = "bad/id" }, "exact identifier"},
{"nil parameters", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = nil }, "must be an array"},
{"bad from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].From = "bad name" }, "exact parameter"},
{"duplicate from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = append(s.Parameters, s.Parameters[0]) }, "duplicates from"},
{"bad to", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].To = s.Parameters[0].From }, "distinct exact"},
{"duplicate to", CommandMigrationMove, func(s *CommandMigrationSchema) {
s.Parameters = append(s.Parameters, CommandParameterMigration{From: "other", To: s.Parameters[0].To})
}, "duplicates to"},
{"move constant", CommandMigrationMove, func(s *CommandMigrationSchema) {
s.Parameters[0].ReplacementConstant = &CommandReplacementConstant{Property: "property", Value: true}
}, "must not declare replacement_constant"},
{"invalid kind", "anything", func(*CommandMigrationSchema) {}, "invalid command migration kind"},
} {
t.Run("schema "+test.name, func(t *testing.T) {
schema := validSchema
schema.Parameters = append([]CommandParameterMigration(nil), validSchema.Parameters...)
test.mutate(&schema)
if err := schema.validate(test.kind); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("schema validate error=%v, want %q", err, test.want)
}
})
}
for _, test := range []struct {
name string
mutate func(*CommandMigrationSchema)
want string
}{
{"missing target", func(s *CommandMigrationSchema) { s.Parameters[0].To = "" }, "requires an exact to or replacement_constant"},
{"both targets", func(s *CommandMigrationSchema) {
s.Parameters[0].ReplacementConstant = &CommandReplacementConstant{Property: "name", Value: true}
}, "exactly one target"},
{"bad to", func(s *CommandMigrationSchema) { s.Parameters[0].To = "bad name" }, "to must be an exact parameter name"},
{"bad constant property", func(s *CommandMigrationSchema) { s.Parameters[3].ReplacementConstant.Property = "bad/property" }, "exact property"},
{"duplicate parameter target", func(s *CommandMigrationSchema) { s.Parameters[1].To = s.Parameters[0].To }, "duplicates to"},
{"duplicate constant target", func(s *CommandMigrationSchema) {
s.Parameters[0] = CommandParameterMigration{From: "name", ReplacementConstant: &CommandReplacementConstant{Property: "convThreadEnabled", Value: true}}
}, "duplicates replacement_constant property"},
{"constant before parameter target", func(s *CommandMigrationSchema) {
s.Parameters[3].ReplacementConstant.Property = "name"
s.Parameters[0], s.Parameters[3] = s.Parameters[3], s.Parameters[0]
}, "duplicates parameter target"},
{"parameter target before constant", func(s *CommandMigrationSchema) {
s.Parameters[3].ReplacementConstant.Property = "name"
}, "duplicates parameter target"},
} {
t.Run("extraction schema "+test.name, func(t *testing.T) {
schema := cloneCommandMigration(validExtraction).Schema
test.mutate(&schema)
if err := schema.validate(CommandMigrationFlagExtraction); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("schema validate error=%v, want %q", err, test.want)
}
})
}
}
func TestCrossPlatformCoverageFlagExtractionRejectsRequiredLegacyFlag(t *testing.T) {
for _, test := range []struct {
name string
mutate func(*CommandMigrationFlag)
}{
{"before required", func(flag *CommandMigrationFlag) { flag.Before.Required = true }},
{"after required", func(flag *CommandMigrationFlag) { flag.After.Required = true }},
} {
t.Run(test.name, func(t *testing.T) {
migration := commandMigrationManifest(CommandMigrationPending).Migrations[1]
test.mutate(&migration.LegacyFlag)
if err := migration.validate(); err == nil || !strings.Contains(err.Error(), "optional") {
t.Fatalf("required legacy flag validation error=%v, want optional-only rejection", err)
}
})
}
}
func TestCrossPlatformCoverageCommandMoveRejectsRunnableParent(t *testing.T) {
migration := commandMigrationManifest(CommandMigrationPending).Migrations[0]
migration.Legacy.Command = "dws agoal"
migration.Replacement.Command = "dws goal"
migration.Schema = CommandMigrationSchema{
ProductID: "agoal",
SourceToolID: "agoal.root",
ReplacementToolID: "agoal.root",
Parameters: []CommandParameterMigration{},
}
authority := CommandMigrationManifest{
Version: CommandMigrationManifestVersion,
Migrations: []CommandMigration{migration},
}
consumed := authority
consumed.Migrations = append([]CommandMigration(nil), authority.Migrations...)
consumed.Migrations[0].State = CommandMigrationConsumed
before := testSnapshot(
testCommand("dws"),
testCommand("dws agoal"),
testCommand("dws agoal strategy"),
testCommand("dws agoal strategy list"),
)
after := testSnapshot(
testCommand("dws"),
Command{Path: "dws agoal", Runnable: true, Hidden: true},
testCommand("dws goal"),
)
_, err := AuthorizeCommandMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
authority,
consumed,
)
if err == nil || !strings.Contains(err.Error(), "must be a leaf") {
t.Fatalf("runnable parent command_move error=%v, want leaf-only rejection", err)
}
}
func TestCrossPlatformCoverageCommandMoveAllowsReplacementParent(t *testing.T) {
before := commandMigrationSnapshot(false, false)
after := commandMigrationSnapshot(true, false)
after.Commands = append(after.Commands, testCommand("dws chat topic new detail"))
pending := singleCommandMigrationManifest(CommandMigrationPending)
consumed := singleCommandMigrationManifest(CommandMigrationConsumed)
if err := validateCommandMoveLegacyLeaves(testSnapshot(testCommand("dws")), pending); err != nil {
t.Fatalf("absent legacy topology should remain a lifecycle concern: %v", err)
}
got, err := AuthorizeCommandMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil {
t.Fatalf("replacement parent command_move was rejected: %v", err)
}
if len(got) != 1 {
t.Fatalf("replacement parent command_move authorizations=%d, want 1", len(got))
}
}
func TestCrossPlatformCoverageCommandMoveRejectsAncestorOverlap(t *testing.T) {
for _, test := range []struct {
name string
legacy string
replacement string
}{
{
name: "legacy ancestor",
legacy: "dws chat message",
replacement: "dws chat message list",
},
{
name: "replacement ancestor",
legacy: "dws chat message list",
replacement: "dws chat message",
},
} {
t.Run(test.name, func(t *testing.T) {
migration := commandMigrationManifest(CommandMigrationPending).Migrations[0]
migration.Legacy.Command = test.legacy
migration.Replacement.Command = test.replacement
if err := migration.validate(); err == nil || !strings.Contains(err.Error(), "must not have an ancestor relationship") {
t.Fatalf("overlapping command_move error=%v, want ancestor-overlap rejection", err)
}
})
}
}
func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) {
before := commandMigrationSnapshot(false, false)
after := commandMigrationSnapshot(true, false)
partial := commandMigrationSnapshot(false, false)
partial.Commands = append(partial.Commands, testCommand("dws chat topic new"))
pending := singleCommandMigrationManifest(CommandMigrationPending)
consumed := singleCommandMigrationManifest(CommandMigrationConsumed)
empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{}, empty, empty); err != nil || len(got) != 0 {
t.Fatalf("empty lifecycle=%#v, %v", got, err)
}
for _, test := range []struct {
name string
current Snapshot
references map[string]Snapshot
authority CommandMigrationManifest
candidate CommandMigrationManifest
want string
}{
{"missing stable", before, map[string]Snapshot{"main": before}, pending, pending, "stable reference"},
{"missing base", before, map[string]Snapshot{"stable": before}, pending, pending, "main or merge-base"},
{"pending base after", after, map[string]Snapshot{"main": after, "stable": before}, pending, pending, "want exact before"},
{"modified approval", before, map[string]Snapshot{"main": before, "stable": before}, pending, modifiedCommandManifest(pending), "modified base-owned"},
{"pending removed", before, map[string]Snapshot{"main": before, "stable": before}, pending, empty, "removed pending"},
{"false consumed", before, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "falsely consumed"},
{"after pending receipt", after, map[string]Snapshot{"main": before, "stable": before}, pending, pending, "without marking it consumed"},
{"partial", partial, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "partially applied"},
{"consumed drift", before, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed, "drifted from consumed"},
{"stale receipt", after, map[string]Snapshot{"main": after, "stable": after}, consumed, consumed, "stale after all references"},
{"early cleanup", after, map[string]Snapshot{"main": after, "stable": before}, consumed, empty, "must retain consumed"},
{"consumed back to pending", after, map[string]Snapshot{"main": after, "stable": before}, consumed, pending, "must retain consumed"},
{"candidate added consumed", after, map[string]Snapshot{"main": before, "stable": before}, empty, consumed, "must start pending"},
{"candidate base mismatch", before, map[string]Snapshot{"main": after, "stable": before}, empty, pending, "does not match"},
} {
t.Run(test.name, func(t *testing.T) {
_, err := AuthorizeCommandMigrations(test.current, test.references, test.authority, test.candidate)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("lifecycle error=%v, want %q", err, test.want)
}
})
}
if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed); err != nil || len(got) != 1 {
t.Fatalf("retained consumed receipt=%#v, %v", got, err)
}
if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": after}, consumed, empty); err != nil || len(got) != 0 {
t.Fatalf("cleaned stale receipt=%#v, %v", got, err)
}
if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{"main": before, "stable": before}, empty, pending); err != nil || len(got) != 0 {
t.Fatalf("candidate pending plan=%#v, %v", got, err)
}
invalidSnapshot := before
invalidSnapshot.SchemaVersion = 0
for _, test := range []struct {
name string
current Snapshot
references map[string]Snapshot
authority CommandMigrationManifest
candidate CommandMigrationManifest
}{
{"current", invalidSnapshot, map[string]Snapshot{"main": before}, empty, empty},
{"reference", before, map[string]Snapshot{"main": invalidSnapshot}, empty, empty},
{"authority", before, map[string]Snapshot{"main": before}, CommandMigrationManifest{}, empty},
{"candidate", before, map[string]Snapshot{"main": before}, empty, CommandMigrationManifest{}},
} {
t.Run("invalid "+test.name, func(t *testing.T) {
if _, err := AuthorizeCommandMigrations(test.current, test.references, test.authority, test.candidate); err == nil {
t.Fatal("invalid authorization input accepted")
}
})
}
invalidFlags := FlagMigrationManifest{}
validFlags := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
validCommands := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
if _, err := CompareAllWithInterfaceMigrations(before, map[string]Snapshot{"main": before}, invalidFlags, validFlags, validCommands, validCommands); err == nil {
t.Fatal("combined compare accepted invalid flag lifecycle")
}
if _, err := CompareAllWithInterfaceMigrations(before, map[string]Snapshot{"main": before}, validFlags, validFlags, CommandMigrationManifest{}, validCommands); err == nil {
t.Fatal("combined compare accepted invalid command lifecycle")
}
if commandMigrationAuthorizesChange(before, before, Change{Kind: "command_removed", Path: "dws unrelated"}, pending.Migrations) {
t.Fatal("unrelated command change was authorized")
}
}
func TestCrossPlatformCoverageCommandMigrationLifecycleAndExactFiltering(t *testing.T) {
before := commandMigrationSnapshot(false, false)
after := commandMigrationSnapshot(true, false)
pending := commandMigrationManifest(CommandMigrationPending)
consumed := commandMigrationManifest(CommandMigrationConsumed)
emptyFlags := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
report, err := CompareAllWithInterfaceMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
emptyFlags,
emptyFlags,
pending,
consumed,
)
if err != nil {
t.Fatalf("governed command migration: %v", err)
}
if !report.Compatible {
t.Fatalf("exact command migration remained blocking: %#v", report.Comparisons)
}
unrelated := commandMigrationSnapshot(true, true)
report, err = CompareAllWithInterfaceMigrations(
unrelated,
map[string]Snapshot{"merge-base": before, "stable": before},
emptyFlags,
emptyFlags,
pending,
consumed,
)
if err != nil {
t.Fatal(err)
}
if report.Compatible || !hasChangeKind(report.Comparisons[0].Blocking, "flag_removed") {
t.Fatalf("unrelated flag removal was hidden: %#v", report.Comparisons)
}
emptyCommands := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
if _, err := AuthorizeCommandMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
emptyCommands,
pending,
); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") {
t.Fatalf("candidate self-authorization error=%v", err)
}
partial := commandMigrationSnapshot(true, false)
partial.Commands = partial.Commands[:len(partial.Commands)-1]
if _, err := AuthorizeCommandMigrations(
partial,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
); err == nil || !strings.Contains(err.Error(), "partially applied") {
t.Fatalf("partial command migration error=%v", err)
}
}
func TestCrossPlatformCoverageFlagExtractionRequiresReplacementConstantEvidence(t *testing.T) {
before := commandMigrationSnapshot(false, false)
after := commandMigrationSnapshot(true, false)
pending := flagExtractionCommandMigrationManifest(CommandMigrationPending)
consumed := flagExtractionCommandMigrationManifest(CommandMigrationConsumed)
empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
migration := pending.Migrations[0]
if phase := matchCommandMigrationPhase(before, migration); phase != commandMigrationBefore {
t.Fatalf("before phase=%s, want %s", phase, commandMigrationBefore)
}
if phase := matchCommandMigrationPhase(after, migration); phase != commandMigrationAfter {
t.Fatalf("after phase=%s, want %s", phase, commandMigrationAfter)
}
for _, test := range []struct {
name string
values map[string]bool
}{
{name: "missing", values: nil},
{name: "wrong property", values: map[string]bool{"otherProperty": true}},
{name: "wrong value", values: map[string]bool{"convThreadEnabled": false}},
{name: "extra", values: map[string]bool{"convThreadEnabled": true, "otherProperty": true}},
} {
t.Run(test.name, func(t *testing.T) {
current := withCommandBoolConstParams(after, "dws chat topic create", test.values)
if phase := matchCommandMigrationPhase(current, migration); phase != commandMigrationPartial {
t.Fatalf("phase=%s, want %s", phase, commandMigrationPartial)
}
if _, err := AuthorizeCommandMigrations(
current,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
); err == nil || !strings.Contains(err.Error(), "partially applied") {
t.Fatalf("constant evidence lifecycle error=%v, want partial rejection", err)
}
if _, err := AuthorizeCommandMigrations(
current,
map[string]Snapshot{"merge-base": before, "stable": before},
empty,
pending,
); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") {
t.Fatalf("constant evidence self-authorization error=%v", err)
}
})
}
}
func commandMigrationManifestJSON() string {
return `{
"version": 1,
"migrations": [{
"kind": "command_move",
"legacy": {
"command": "dws chat message old",
"before": {"present": true, "runnable": true},
"after": {"present": true, "runnable": true, "hidden": true}
},
"replacement": {
"command": "dws chat topic new",
"before": {"present": false},
"after": {"present": true, "runnable": true}
},
"schema": {
"product_id": "chat",
"source_tool_id": "chat.old",
"replacement_tool_id": "chat.old",
"parameters": [{"from": "old-id", "to": "new-id"}]
},
"state": "pending",
"reason": "Reviewed command move."
}]
}`
}
func flagExtractionCommandMigrationManifestJSON() string {
return `{
"version": 1,
"migrations": [{
"kind": "flag_extraction",
"legacy": {
"command": "dws chat group create",
"before": {"present": true, "runnable": true},
"after": {"present": true, "runnable": true}
},
"replacement": {
"command": "dws chat topic create",
"before": {"present": false},
"after": {"present": true, "runnable": true}
},
"legacy_flag": {
"name": "thread",
"before": {"present": true, "type": "bool", "no_opt": "true", "scope": "local"},
"after": {"present": true, "type": "bool", "hidden": true, "no_opt": "true", "scope": "local"}
},
"schema": {
"product_id": "chat",
"source_tool_id": "chat.create_group",
"replacement_tool_id": "chat.create_topic",
"parameters": [
{"from":"name","to":"name"},
{"from":"type","to":"type"},
{"from":"users","to":"users"},
{"from":"thread","replacement_constant":{"property":"convThreadEnabled","value":true}}
]
},
"state": "pending",
"reason": "Reviewed flag extraction."
}]
}`
}
func commandMigrationManifest(state string) CommandMigrationManifest {
move := CommandMigration{
Kind: CommandMigrationMove,
Legacy: CommandMigrationSide{
Command: "dws chat message old",
Before: CommandMigrationState{Present: true, Runnable: true},
After: CommandMigrationState{Present: true, Runnable: true, Hidden: true},
},
Replacement: CommandMigrationSide{
Command: "dws chat topic new",
Before: CommandMigrationState{},
After: CommandMigrationState{Present: true, Runnable: true},
},
Schema: CommandMigrationSchema{
ProductID: "chat",
SourceToolID: "chat.old",
ReplacementToolID: "chat.old",
Parameters: []CommandParameterMigration{{From: "old-id", To: "new-id"}},
},
State: state,
Reason: "Reviewed command move.",
}
extraction := CommandMigration{
Kind: CommandMigrationFlagExtraction,
Legacy: CommandMigrationSide{
Command: "dws chat group create",
Before: CommandMigrationState{Present: true, Runnable: true},
After: CommandMigrationState{Present: true, Runnable: true},
},
Replacement: CommandMigrationSide{
Command: "dws chat topic create",
Before: CommandMigrationState{},
After: CommandMigrationState{Present: true, Runnable: true},
},
LegacyFlag: CommandMigrationFlag{
Name: "thread",
Before: FlagMigrationState{Present: true, Type: "bool", NoOpt: "true", Scope: "local"},
After: FlagMigrationState{Present: true, Type: "bool", Hidden: true, NoOpt: "true", Scope: "local"},
},
Schema: CommandMigrationSchema{
ProductID: "chat",
SourceToolID: "chat.create_group",
ReplacementToolID: "chat.create_topic",
Parameters: []CommandParameterMigration{
{From: "name", To: "name"},
{From: "type", To: "type"},
{From: "users", To: "users"},
{
From: "thread",
ReplacementConstant: &CommandReplacementConstant{
Property: "convThreadEnabled",
Value: true,
},
},
},
},
State: state,
Reason: "Reviewed flag extraction.",
}
return CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{move, extraction}}
}
func flagExtractionCommandMigrationManifest(state string) CommandMigrationManifest {
manifest := commandMigrationManifest(state)
manifest.Migrations = manifest.Migrations[1:]
return manifest
}
func cloneCommandMigration(source CommandMigration) CommandMigration {
cloned := source
cloned.Schema.Parameters = append([]CommandParameterMigration(nil), source.Schema.Parameters...)
for index, parameter := range source.Schema.Parameters {
if parameter.ReplacementConstant == nil {
continue
}
constant := *parameter.ReplacementConstant
cloned.Schema.Parameters[index].ReplacementConstant = &constant
}
return cloned
}
func singleCommandMigrationManifest(state string) CommandMigrationManifest {
manifest := commandMigrationManifest(state)
manifest.Migrations = manifest.Migrations[:1]
return manifest
}
func modifiedCommandManifest(source CommandMigrationManifest) CommandMigrationManifest {
modified := source
modified.Migrations = append([]CommandMigration(nil), source.Migrations...)
modified.Migrations[0].Reason = "Modified reason."
return modified
}
func commandMigrationSnapshot(after, removeUnrelated bool) Snapshot {
oldFlags := []Flag{{Name: "old-id", Type: "string", Required: true}, {Name: "keep", Type: "string"}}
groupFlags := []Flag{{Name: "thread", Type: "bool", NoOpt: "true"}}
commands := []Command{
testCommand("dws"),
testCommand("dws chat group create", groupFlags...),
testCommand("dws chat message old", oldFlags...),
}
if after {
commands[1].LocalFlags[0].Hidden = true
commands[2].Hidden = true
if removeUnrelated {
commands[2].LocalFlags = commands[2].LocalFlags[:1]
}
topicCreate := testCommand("dws chat topic create")
topicCreate.BoolConstParams = map[string]bool{"convThreadEnabled": true}
commands = append(commands,
topicCreate,
testCommand("dws chat topic new", Flag{Name: "new-id", Type: "string", Required: true}),
)
}
return testSnapshot(commands...)
}
func withCommandBoolConstParams(snapshot Snapshot, path string, values map[string]bool) Snapshot {
cloned := snapshot
cloned.Commands = append([]Command(nil), snapshot.Commands...)
for index := range cloned.Commands {
if cloned.Commands[index].Path == path {
cloned.Commands[index].BoolConstParams = values
break
}
}
return cloned
}
+11
View File
@@ -71,6 +71,9 @@ func CompareAll(current Snapshot, references map[string]Snapshot) Report {
// alias),
// - flags accepted at each command path may not disappear, change type, or
// become required; an existing path may not gain a new required flag,
// - once bool ConstParams evidence exists, its exact property/value map is a
// durable contract; adding the first evidence to an older snapshot remains
// a silent bootstrap,
// - new commands and flags are allowed.
//
// The single exception to the type rule is an individually reviewed migration
@@ -153,6 +156,14 @@ func Compare(current, baseline Snapshot, reference string) Comparison {
}
func compareCommandContract(result *Comparison, acceptedPath string, oldCommand, newCommand Command) {
if len(oldCommand.BoolConstParams) > 0 && !reflect.DeepEqual(oldCommand.BoolConstParams, newCommand.BoolConstParams) {
result.Blocking = append(result.Blocking, Change{
Kind: "bool_const_params_changed",
Path: acceptedPath,
Before: fmt.Sprintf("%v", oldCommand.BoolConstParams),
After: fmt.Sprintf("%v", newCommand.BoolConstParams),
})
}
if oldCommand.Runnable && !newCommand.Runnable {
result.Blocking = append(result.Blocking, Change{
Kind: "command_became_non_runnable",
@@ -135,6 +135,63 @@ func TestCrossPlatformCoverageCompareAllowsRenameWhenOldPathIsAlias(t *testing.T
}
}
func TestCrossPlatformCoverageCompareAllowsBoolConstParamsBootstrapAddition(t *testing.T) {
base := testSnapshot(
testCommand("dws"),
testCommand("dws send"),
)
current := testSnapshot(
testCommand("dws"),
testCommand("dws send"),
)
current.Commands[1].BoolConstParams = map[string]bool{"convThreadEnabled": true}
comparison := Compare(current, base, "base")
if !comparison.Compatible || len(comparison.Blocking) != 0 || len(comparison.Additions) != 0 {
t.Fatalf("ordinary Compare treated bootstrapped bool ConstParams evidence as a compatibility change: %#v", comparison)
}
}
func TestCrossPlatformCoverageCompareBlocksBoolConstParamsDriftAfterBootstrap(t *testing.T) {
base := testSnapshot(
testCommand("dws"),
testCommand("dws send"),
)
base.Commands[1].BoolConstParams = map[string]bool{"convThreadEnabled": true}
for _, test := range []struct {
name string
current map[string]bool
}{
{name: "removed", current: nil},
{name: "value flipped", current: map[string]bool{"convThreadEnabled": false}},
{name: "extra key", current: map[string]bool{"convThreadEnabled": true, "other": false}},
} {
t.Run(test.name, func(t *testing.T) {
current := testSnapshot(
testCommand("dws"),
testCommand("dws send"),
)
current.Commands[1].BoolConstParams = test.current
comparison := Compare(current, base, "base")
if comparison.Compatible {
t.Fatalf("historical bool ConstParams drift was accepted: %#v", comparison)
}
if len(comparison.Blocking) != 1 {
t.Fatalf("blocking=%#v, want exactly one durable contract change", comparison.Blocking)
}
change := comparison.Blocking[0]
if change.Kind != "bool_const_params_changed" || change.Path != "dws send" {
t.Fatalf("blocking=%#v, want bool_const_params_changed at dws send", comparison.Blocking)
}
if len(comparison.Additions) != 0 {
t.Fatalf("bool ConstParams drift also produced additions: %#v", comparison.Additions)
}
})
}
}
func TestCrossPlatformCoverageCompareBlocksRemovedAlias(t *testing.T) {
base := testSnapshot(
testCommand("dws"),
+29 -17
View File
@@ -91,22 +91,32 @@ func validateFlagMigrationJSONSchema(data []byte) error {
}
func validateMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type) error {
return validateLabeledMigrationJSONValue(decoder, path, schema, "flag")
}
func validateLabeledMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type, label string) error {
token, err := decoder.Token()
if err != nil {
return fmt.Errorf("read flag migration manifest value at %s: %w", path, err)
return fmt.Errorf("read %s migration manifest value at %s: %w", label, path, err)
}
for schema.Kind() == reflect.Pointer {
if token == nil {
return migrationJSONTypeError(path, schema.Elem(), token, label)
}
schema = schema.Elem()
}
switch schema.Kind() {
case reflect.Struct:
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
return migrationJSONTypeError(path, schema, token)
return migrationJSONTypeError(path, schema, token, label)
}
fields := migrationJSONFields(schema)
seen := make(map[string]bool, len(fields))
for decoder.More() {
keyToken, keyErr := decoder.Token()
if keyErr != nil {
return fmt.Errorf("read flag migration manifest field at %s: %w", path, keyErr)
return fmt.Errorf("read %s migration manifest field at %s: %w", label, path, keyErr)
}
// encoding/json guarantees object member names are string tokens.
key := keyToken.(string)
@@ -115,57 +125,58 @@ func validateMigrationJSONValue(decoder *json.Decoder, path string, schema refle
for canonical := range fields {
if strings.EqualFold(key, canonical) {
return fmt.Errorf(
"flag migration manifest contains non-canonical field %q at %s (want %q)",
"%s migration manifest contains non-canonical field %q at %s (want %q)",
label,
key,
path,
canonical,
)
}
}
return fmt.Errorf("flag migration manifest contains unknown field %q at %s", key, path)
return fmt.Errorf("%s migration manifest contains unknown field %q at %s", label, key, path)
}
if seen[key] {
return fmt.Errorf("flag migration manifest contains duplicate field %q at %s", key, path)
return fmt.Errorf("%s migration manifest contains duplicate field %q at %s", label, key, path)
}
seen[key] = true
if err := validateMigrationJSONValue(decoder, path+"."+key, fieldSchema); err != nil {
if err := validateLabeledMigrationJSONValue(decoder, path+"."+key, fieldSchema, label); err != nil {
return err
}
}
if _, closeErr := decoder.Token(); closeErr != nil {
return fmt.Errorf("close flag migration manifest object at %s: %w", path, closeErr)
return fmt.Errorf("close %s migration manifest object at %s: %w", label, path, closeErr)
}
return nil
case reflect.Slice:
if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' {
return migrationJSONTypeError(path, schema, token)
return migrationJSONTypeError(path, schema, token, label)
}
for index := 0; decoder.More(); index++ {
if err := validateMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem()); err != nil {
if err := validateLabeledMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem(), label); err != nil {
return err
}
}
if _, closeErr := decoder.Token(); closeErr != nil {
return fmt.Errorf("close flag migration manifest array at %s: %w", path, closeErr)
return fmt.Errorf("close %s migration manifest array at %s: %w", label, path, closeErr)
}
return nil
case reflect.String:
if _, ok := token.(string); !ok {
return migrationJSONTypeError(path, schema, token)
return migrationJSONTypeError(path, schema, token, label)
}
return nil
case reflect.Int:
if _, ok := token.(json.Number); !ok {
return migrationJSONTypeError(path, schema, token)
return migrationJSONTypeError(path, schema, token, label)
}
return nil
case reflect.Bool:
if _, ok := token.(bool); !ok {
return migrationJSONTypeError(path, schema, token)
return migrationJSONTypeError(path, schema, token, label)
}
return nil
default:
return fmt.Errorf("flag migration manifest value at %s has unsupported Go schema type %s", path, schema)
return fmt.Errorf("%s migration manifest value at %s has unsupported Go schema type %s", label, path, schema)
}
}
@@ -188,9 +199,10 @@ func migrationJSONFields(schema reflect.Type) map[string]reflect.Type {
return fields
}
func migrationJSONTypeError(path string, want reflect.Type, token json.Token) error {
func migrationJSONTypeError(path string, want reflect.Type, token json.Token, label string) error {
return fmt.Errorf(
"flag migration manifest value at %s must be %s, got %s",
"%s migration manifest value at %s must be %s, got %s",
label,
path,
migrationJSONKindDescription(want),
migrationJSONTokenDescription(token),
+22 -15
View File
@@ -22,12 +22,13 @@ import (
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const SchemaVersion = 2
const SchemaVersion = 3
// FlagAliasOfAnnotation records a framework-originated reviewed relationship
// between a retained compatibility flag and an exact canonical Cobra flag.
@@ -66,13 +67,14 @@ type Snapshot struct {
// Command contains compatibility-relevant command metadata. Path always
// includes the root command name (for example, "dws chat message send").
type Command struct {
Path string `json:"path"`
Runnable bool `json:"runnable"`
Hidden bool `json:"hidden"`
Deprecated string `json:"deprecated,omitempty"`
Aliases []string `json:"aliases"`
LocalFlags []Flag `json:"local_flags"`
InheritedFlags []Flag `json:"inherited_flags"`
Path string `json:"path"`
Runnable bool `json:"runnable"`
Hidden bool `json:"hidden"`
Deprecated string `json:"deprecated,omitempty"`
Aliases []string `json:"aliases"`
LocalFlags []Flag `json:"local_flags"`
InheritedFlags []Flag `json:"inherited_flags"`
BoolConstParams map[string]bool `json:"bool_const_params,omitempty"`
}
// Flag contains the stable pflag contract visible at a command node.
@@ -126,14 +128,16 @@ func Capture(root *cobra.Command) Snapshot {
}
aliases = compactSorted(aliases)
boolConstParams := corecmd.InterfaceBoolConstParams(cmd)
snapshot.Commands = append(snapshot.Commands, Command{
Path: path,
Runnable: cmd.Runnable(),
Hidden: cmd.Hidden,
Deprecated: strings.TrimSpace(cmd.Deprecated),
Aliases: aliases,
LocalFlags: captureFlags(cmd.LocalFlags()),
InheritedFlags: captureFlags(cmd.InheritedFlags()),
Path: path,
Runnable: cmd.Runnable(),
Hidden: cmd.Hidden,
Deprecated: strings.TrimSpace(cmd.Deprecated),
Aliases: aliases,
LocalFlags: captureFlags(cmd.LocalFlags()),
InheritedFlags: captureFlags(cmd.InheritedFlags()),
BoolConstParams: boolConstParams,
})
children := append([]*cobra.Command(nil), cmd.Commands()...)
@@ -198,6 +202,9 @@ func (s Snapshot) Validate() error {
return fmt.Errorf("interface snapshot contains duplicate command path %q", command.Path)
}
seenCommands[command.Path] = true
if command.BoolConstParams != nil && len(command.BoolConstParams) == 0 {
return fmt.Errorf("command %q must omit empty bool_const_params", command.Path)
}
if err := validateFlags(command.Path, "local", command.LocalFlags); err != nil {
return err
}
+94 -4
View File
@@ -17,6 +17,7 @@ import (
"bytes"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
@@ -98,16 +99,105 @@ func TestCrossPlatformCoverageCaptureUsesStableNoiseRulesAndFlagScopes(t *testin
func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) {
input := bytes.NewBufferString(`{
"schema_version": 2,
"rules": {"excluded_command_subtrees": [], "excluded_flags": []},
"commands": [],
"future_field": true
"schema_version": 3,
"rules": {"excluded_command_subtrees": [], "excluded_flags": []},
"commands": [],
"future_field": true
}`)
if _, err := Read(input); err == nil {
t.Fatal("Read accepted an unknown field")
}
}
func TestCrossPlatformCoverageCaptureRoundTripsFrameworkBoolConstParams(t *testing.T) {
root := &cobra.Command{Use: "dws"}
leaf := corecmd.New(corecmd.Spec{
Use: "send",
ConstParams: map[string]any{"convThreadEnabled": true, "precheckOnly": false},
Invoke: func(*corecmd.Ctx, map[string]any) error { return nil },
})
root.AddCommand(leaf)
snapshot := Capture(root)
got := commandIndex(snapshot)["dws send"].BoolConstParams
want := map[string]bool{"convThreadEnabled": true, "precheckOnly": false}
if !reflect.DeepEqual(got, want) {
t.Fatalf("captured bool ConstParams = %#v, want %#v", got, want)
}
var encoded bytes.Buffer
if err := Write(&encoded, snapshot); err != nil {
t.Fatalf("Write: %v", err)
}
decoded, err := Read(bytes.NewReader(encoded.Bytes()))
if err != nil {
t.Fatalf("Read: %v", err)
}
if got := commandIndex(decoded)["dws send"].BoolConstParams; !reflect.DeepEqual(got, want) {
t.Fatalf("round-tripped bool ConstParams = %#v, want %#v", got, want)
}
}
func TestCrossPlatformCoverageCaptureIgnoresHandwrittenLegacyConstParamsAnnotations(t *testing.T) {
root := &cobra.Command{Use: "dws"}
direct := &cobra.Command{
Use: "direct",
Run: func(*cobra.Command, []string) {},
Annotations: map[string]string{
"dws.compat.bool_const_params": `{"forged":true}`,
"dws.compat.const_params_origin": "corecmd.const_params.v1",
},
}
businessConstructor := func() *cobra.Command {
payloadKey := strings.Join([]string{"dws.compat.bool_", "const_params"}, "")
originKey := strings.Join([]string{"dws.compat.const_", "params_origin"}, "")
origin := strings.Join([]string{"corecmd.const_", "params.v1"}, "")
return &cobra.Command{
Use: "business",
Run: func(*cobra.Command, []string) {},
Annotations: map[string]string{
payloadKey: `{"forged":true}`,
originKey: origin,
},
}
}
root.AddCommand(direct, businessConstructor())
snapshot := Capture(root)
if err := snapshot.Validate(); err != nil {
t.Fatalf("handwritten legacy annotations affected snapshot validity: %v", err)
}
for _, path := range []string{"dws direct", "dws business"} {
if got := commandIndex(snapshot)[path].BoolConstParams; got != nil {
t.Fatalf("%s forged bool ConstParams evidence = %#v", path, got)
}
}
var encoded bytes.Buffer
if err := Write(&encoded, snapshot); err != nil {
t.Fatalf("Write snapshot without forged evidence: %v", err)
}
}
func TestCrossPlatformCoverageReadRejectsMalformedBoolConstParams(t *testing.T) {
for _, payload := range []string{`{"fixed":"true"}`, `{}`} {
input := bytes.NewBufferString(`{
"schema_version": 3,
"rules": {"excluded_command_subtrees": [], "excluded_flags": []},
"commands": [{
"path": "dws send",
"runnable": true,
"aliases": [],
"local_flags": [],
"inherited_flags": [],
"bool_const_params": ` + payload + `
}]
}`)
if _, err := Read(input); err == nil {
t.Fatalf("Read accepted malformed bool_const_params %s", payload)
}
}
}
func TestCrossPlatformCoverageCaptureRoundTripsFrameworkFlagAlias(t *testing.T) {
if FlagAliasOfAnnotation != runtimeannotate.AnnotationFlagAliasOf {
t.Fatalf(
@@ -437,7 +437,7 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T
}{
{
name: "user group mentions",
args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"},
args: []string{"--identity", "user", "--group", "cid", "--text", "@" + fixtureCurrentDOpenID + " <@" + fixtureCurrentDOpenID2 + "> x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"},
want: map[string]any{"atOpenDingTalkIds": []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}, "atAll": true},
},
{
@@ -505,6 +505,69 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T
}
}
func TestCrossPlatformCoverageMessagesSendRejectsMismatchedCurrentUserMentions(t *testing.T) {
tests := []struct {
name string
body string
atIDs string
atAll bool
wantErr string
}{
{
name: "declared member missing from body",
body: "请查收",
atIDs: fixtureCurrentDOpenID,
wantErr: "必须在正文中使用对应",
},
{
name: "declared member only appears as a longer token prefix",
body: "@" + fixtureCurrentDOpenID + "_suffix 请查收",
atIDs: fixtureCurrentDOpenID,
wantErr: "必须在正文中使用对应",
},
{
name: "body member missing from declaration",
body: "<@" + fixtureCurrentDOpenID + "> 请查收",
wantErr: "必须同时通过 --at-open-dingtalk-ids 声明",
},
{
name: "body at-all missing from declaration",
body: "<@all> 请查收",
wantErr: "必须同时指定 --at-all",
},
{
name: "bare body member missing from declaration",
body: "@" + fixtureCurrentDOpenID + " 请查收",
wantErr: "必须同时通过 --at-open-dingtalk-ids 声明",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
args := []string{
"chat", "+messages-send", "--identity", "user",
"--group", "cid", "--markdown", tt.body, "--yes",
}
if tt.atIDs != "" {
args = append(args, "--at-open-dingtalk-ids", tt.atIDs)
}
if tt.atAll {
args = append(args, "--at-all")
}
root.SetArgs(args)
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %v, want %q", err, tt.wantErr)
}
if len(fake.calls) != 0 {
t.Fatalf("invalid mention contract reached lower service: %#v", fake.calls)
}
})
}
}
func TestCrossPlatformCoverageUnifiedSendUnsupportedIdentityGuard(t *testing.T) {
flags := append([]shortcut.Flag(nil), MessagesSend.Flags...)
for i := range flags {
+113
View File
@@ -585,6 +585,119 @@ func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileFlow(t *testing.T)
}
}
func TestCrossPlatformCoverageMessagesSendCurrentUserDirectLocalFileUsesTransportSpecificTargets(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("direct.bin", []byte("direct-file"), 0o600); err != nil {
t.Fatal(err)
}
uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
t.Errorf("upload method = %s", r.Method)
}
if _, err := io.Copy(io.Discard, r.Body); err != nil {
t.Error(err)
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(uploadServer.Close)
tests := []struct {
name string
targetArgs []string
requestedType string
wantOpenID string
wantUserResolve bool
}{
{
name: "explicit open id file",
targetArgs: []string{"--open-dingtalk-id", fixtureCurrentDOpenID},
requestedType: "file",
wantOpenID: fixtureCurrentDOpenID,
},
{
name: "resolved user audio",
targetArgs: []string{"--user", "user-id"},
requestedType: "audio",
wantOpenID: "D-resolved",
wantUserResolve: true,
},
{
name: "explicit open id video",
targetArgs: []string{"--open-dingtalk-id", fixtureCurrentDOpenID2},
requestedType: "video",
wantOpenID: fixtureCurrentDOpenID2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/init_conversation_file_upload": `{"resourceUrl":"` + uploadServer.URL + `","uploadKey":"upload-key"}`,
"im/commit_conversation_file_upload": `{"result":{"dentryId":31,"spaceId":41}}`,
"chat/send_personal_message": `{"result":{"openMessageId":"sent-direct-file"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
args := []string{
"chat", "+messages-send", "--identity", "user",
"--msg-type", tt.requestedType,
"--file", "./direct.bin",
"--idempotency-key", "direct-" + tt.requestedType,
"--yes",
}
args = append(args, tt.targetArgs...)
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
callOffset := 0
if tt.wantUserResolve {
if len(fake.calls) == 0 || fake.calls[0].product != "contact" ||
fake.calls[0].tool != "search_contact_by_key_word" {
t.Fatalf("resolution call = %#v", fake.calls)
}
callOffset = 1
}
if len(fake.calls) != callOffset+3 {
t.Fatalf("calls = %#v, want resolution plus init, commit, send", fake.calls)
}
initCall := fake.calls[callOffset]
commitCall := fake.calls[callOffset+1]
sendCall := fake.calls[callOffset+2]
for _, call := range []larkAlignmentCall{initCall, commitCall} {
if call.product != "im" || call.args["openDingTalkId"] != tt.wantOpenID {
t.Fatalf("upload call = %#v, want openDingTalkId %q", call, tt.wantOpenID)
}
if _, exists := call.args["receiverOpenDingTalkId"]; exists {
t.Fatalf("send-only receiver field leaked into upload call: %#v", call.args)
}
}
if initCall.tool != "init_conversation_file_upload" ||
commitCall.tool != "commit_conversation_file_upload" {
t.Fatalf("upload calls = %#v, %#v", initCall, commitCall)
}
if sendCall.product != "chat" || sendCall.tool != "send_personal_message" ||
sendCall.args["receiverOpenDingTalkId"] != tt.wantOpenID {
t.Fatalf("send call = %#v, want receiverOpenDingTalkId %q", sendCall, tt.wantOpenID)
}
if _, exists := sendCall.args["openDingTalkId"]; exists {
t.Fatalf("upload-only target field leaked into send call: %#v", sendCall.args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["requestedMessageType"] != tt.requestedType ||
payload["effectiveMessageType"] != "file" {
t.Fatalf("output = %#v", payload)
}
})
}
}
func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileDryRunAndFailures(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("fixture.bin", []byte("x"), 0o600); err != nil {
@@ -10,6 +10,7 @@ import (
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
@@ -337,8 +338,15 @@ func TestCrossPlatformCoverageMessagesSendRoutesIdentitySpecificTransports(t *te
}
}
if tt.bodyKey == "content" {
rawContent := call.args[tt.bodyKey].(string)
if strings.Contains(rawContent, `\u003c`) || strings.Contains(rawContent, `\u003e`) {
t.Errorf("content = %q; current-user mention tokens must remain literal", rawContent)
}
if !strings.Contains(rawContent, "<@"+fixtureCurrentDOpenID+">") {
t.Errorf("content = %q; missing literal current-user mention token", rawContent)
}
var content map[string]string
if err := json.Unmarshal([]byte(call.args[tt.bodyKey].(string)), &content); err != nil {
if err := json.Unmarshal([]byte(rawContent), &content); err != nil {
t.Fatal(err)
}
if content["text"] != tt.body {
+107 -5
View File
@@ -11,6 +11,7 @@ import (
"os"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
@@ -18,6 +19,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
@@ -181,6 +183,12 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
if contentType != "text" && contentType != "markdown" && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
return apperrors.NewValidation("user image/file/audio/video 当前不接受 @ 参数")
}
if (group != "" || chatQuery != "") && (contentType == "text" || contentType == "markdown") {
if err := validateCurrentUserMentionConsistency(
messagesSendBody(rt), atOpenIDs, rt.Bool("at-all")); err != nil {
return err
}
}
case "bot":
if chatQuery != "" || userQuery != "" {
return apperrors.NewValidation("--identity bot 当前不接受 --chat-query 或 --user-query;请传真实群 ID 或批量用户 ID")
@@ -234,6 +242,89 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
return nil
}
func validateCurrentUserMentionConsistency(body string, atOpenIDs []string, atAll bool) error {
declared := make(map[string]struct{}, len(atOpenIDs))
for _, id := range atOpenIDs {
declared[id] = struct{}{}
}
missingPlaceholders := make([]string, 0)
for _, id := range atOpenIDs {
if !containsCurrentUserMentionToken(body, id) {
missingPlaceholders = append(missingPlaceholders, id)
}
}
if len(missingPlaceholders) > 0 {
return apperrors.NewValidation(fmt.Sprintf(
"--at-open-dingtalk-ids 中的成员必须在正文中使用对应 <@openDingTalkId> 占位符;缺少: %s",
strings.Join(missingPlaceholders, ","),
))
}
undeclared := make([]string, 0)
for _, id := range currentUserMentionBodyIDs(body) {
if _, ok := declared[id]; !ok {
undeclared = append(undeclared, id)
}
}
if len(undeclared) > 0 {
return apperrors.NewValidation(fmt.Sprintf(
"正文中的成员 <@openDingTalkId> 占位符必须同时通过 --at-open-dingtalk-ids 声明;未声明: %s",
strings.Join(undeclared, ","),
))
}
if !atAll && containsCurrentUserMentionToken(body, "all") {
return apperrors.NewValidation("正文中的 <@all> 占位符必须同时指定 --at-all")
}
return nil
}
func containsCurrentUserMentionToken(body, id string) bool {
placeholder := "@" + id
for searchFrom := 0; ; {
offset := strings.Index(body[searchFrom:], placeholder)
if offset < 0 {
return false
}
end := searchFrom + offset + len(placeholder)
if end == len(body) {
return true
}
next, _ := utf8.DecodeRuneInString(body[end:])
if !unicode.IsLetter(next) && !unicode.IsDigit(next) && next != '_' && next != '-' {
return true
}
searchFrom = end
}
}
func currentUserMentionBodyIDs(body string) []string {
ids := make([]string, 0)
for searchFrom := 0; ; {
offset := strings.IndexByte(body[searchFrom:], '@')
if offset < 0 {
break
}
start := searchFrom + offset + 1
end := start
for end < len(body) {
current := body[end]
if (current < 'a' || current > 'z') &&
(current < 'A' || current > 'Z') &&
(current < '0' || current > '9') {
break
}
end++
}
id := body[start:end]
if targetresolver.LooksLikeCurrentDOpenDingTalkID(id) {
ids = appendUniqueShortcutString(ids, id)
}
searchFrom = end
}
return ids
}
func executeMessagesSend(rt *shortcut.RuntimeContext) error {
identity := messagesSendIdentity(rt)
body := messagesSendBody(rt)
@@ -499,7 +590,7 @@ func resolvedUserMarkdownParams(
if target.GroupID != "" {
body = helpers.NormalizeMessageMentions(body, atOpenIDs, atAll, true)
}
content, _ := json.Marshal(map[string]string{"title": title, "text": body})
content, _ := jsonutil.Marshal(map[string]string{"title": title, "text": body})
params := rt.AddAIMessageTag(map[string]any{
"msgType": "markdown",
"content": string(content),
@@ -573,8 +664,10 @@ func executeMessagesSendUserFile(
if err != nil {
return err
}
targetArgs := map[string]any{}
addMessagesSendUserTarget(targetArgs, group, openID)
uploadTargetArgs := map[string]any{}
addMessagesSendUserUploadTarget(uploadTargetArgs, group, openID)
sendTargetArgs := map[string]any{}
addMessagesSendUserTarget(sendTargetArgs, group, openID)
idempotencyKey := messagesSendIdempotencyKey(rt)
if rt.DryRun() {
return rt.Output(map[string]any{
@@ -587,6 +680,7 @@ func executeMessagesSendUserFile(
{
"identity": "user",
"tool": "init/commit_conversation_file_upload",
"target": uploadTargetArgs,
"file": map[string]any{
"path": rawPath,
"name": meta.FileName,
@@ -598,7 +692,7 @@ func executeMessagesSendUserFile(
"tool": "send_personal_message",
"requestedMessageType": requestedType,
"effectiveMessageType": "file",
"target": targetArgs,
"target": sendTargetArgs,
},
},
})
@@ -607,7 +701,7 @@ func executeMessagesSendUserFile(
rt.Command().Context(), messagesSendFileUploadTimeout)
defer cancelUpload()
commitText, err := helpers.UploadConversationLocalFile(
uploadContext, targetArgs, meta, idempotencyKey)
uploadContext, uploadTargetArgs, meta, idempotencyKey)
if err != nil {
return err
}
@@ -653,6 +747,14 @@ func addMessagesSendUserTarget(params map[string]any, group, openID string) {
params["receiverOpenDingTalkId"] = openID
}
func addMessagesSendUserUploadTarget(params map[string]any, group, openID string) {
if group != "" {
params["openConversationId"] = group
return
}
params["openDingTalkId"] = openID
}
func nonEmptyStringCount(values ...string) int {
count := 0
for _, value := range values {
+521
View File
@@ -0,0 +1,521 @@
package upgrade
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{
".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills",
})
for _, parent := range []string{".codex", ".claude", ".openclaw"} {
if err := os.MkdirAll(filepath.Join(home, parent), 0o755); err != nil {
t.Fatal(err)
}
}
// beta.6 wrote a physical copy into Codex. The canonical migration must
// remove it without treating a matching user/market name as DWS-owned.
oldCodex := filepath.Join(home, ".codex", "skills", "dingtalk-chat")
if err := os.MkdirAll(oldCodex, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil {
t.Fatal(err)
}
multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared")
result, err := UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.0.58-beta.6"})
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("upgrade = %#v, %v", result, err)
}
canonical := filepath.Join(home, ".agents", "skills")
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil {
t.Fatalf("canonical %s missing: %v", name, err)
}
if _, err := os.Lstat(filepath.Join(home, ".codex", "skills", name)); !os.IsNotExist(err) {
t.Fatalf("universal Codex duplicate remains for %s: %v", name, err)
}
for _, agent := range []string{".claude", ".openclaw"} {
link := filepath.Join(home, agent, "skills", name)
info, err := os.Lstat(link)
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("%s link = %#v, %v", link, info, err)
}
if _, err := os.Stat(filepath.Join(link, "SKILL.md")); err != nil {
t.Fatalf("%s does not resolve canonical content: %v", link, err)
}
}
}
result, err = UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.0.58-beta.6"})
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("idempotent upgrade = %#v, %v", result, err)
}
for _, agent := range []string{".claude", ".openclaw"} {
info, err := os.Lstat(filepath.Join(home, agent, "skills", "dingtalk-chat"))
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("idempotent upgrade replaced %s link: %#v, %v", agent, info, err)
}
}
}
func TestCrossPlatformCoverageCanonicalCustomRootsSymlinkedParentsAndLexicalVictims(t *testing.T) {
home := withFakeHome(t)
customCodex := filepath.Join(t.TempDir(), "codex")
customHermes := filepath.Join(t.TempDir(), "hermes")
externalClaude := filepath.Join(t.TempDir(), "claude")
for _, root := range []string{customCodex, customHermes, externalClaude, filepath.Join(home, ".clawdbot"), filepath.Join(home, ".codeium", "windsurf")} {
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.Symlink(externalClaude, filepath.Join(home, ".claude")); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &upgradeGetenv, func(name string) string {
switch name {
case "CODEX_HOME":
return customCodex
case "HERMES_HOME":
return customHermes
default:
return ""
}
})
oldCodex := filepath.Join(customCodex, "skills", "dingtalk-chat")
if err := os.MkdirAll(oldCodex, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil {
t.Fatal(err)
}
claudeBase := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(claudeBase, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(claudeBase, "dingtalk-chat"), []byte("unexpected file"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink("missing-target", filepath.Join(claudeBase, "dingtalk-shared")); err != nil {
t.Fatal(err)
}
multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared")
result, err := UpgradeSkillLocations(multiRoot)
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("upgrade = %#v, %v", result, err)
}
if _, err := os.Lstat(oldCodex); !os.IsNotExist(err) {
t.Fatalf("custom CODEX_HOME duplicate remains: %v", err)
}
for _, base := range []string{
claudeBase,
filepath.Join(customHermes, "skills"),
filepath.Join(home, ".clawdbot", "skills"),
filepath.Join(home, ".codeium", "windsurf", "skills"),
} {
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
path := filepath.Join(base, name)
info, statErr := os.Lstat(path)
if statErr != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("expected canonical link %s: %#v, %v", path, info, statErr)
}
body, readErr := os.ReadFile(filepath.Join(path, "SKILL.md"))
if readErr != nil || !strings.Contains(string(body), name) {
t.Fatalf("link %s does not resolve: %q, %v", path, body, readErr)
}
}
}
}
func TestCrossPlatformCoverageCanonicalSkillLinksFallBackToCopies(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &upgradeSymlink, func(string, string) error { return errors.New("links unavailable") })
multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat")
result, err := UpgradeSkillLocations(multiRoot)
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("upgrade = %#v, %v", result, err)
}
dest := filepath.Join(home, ".claude", "skills", "dingtalk-chat")
info, err := os.Lstat(dest)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
t.Fatalf("copy fallback = %#v, %v", info, err)
}
if _, err := os.Stat(filepath.Join(dest, "SKILL.md")); err != nil {
t.Fatalf("copy fallback content missing: %v", err)
}
}
func TestCrossPlatformCoverageConfiguredRootsDetectShallowAndApplicationAgents(t *testing.T) {
// Keep the destination HOME synthetic: app-bundle detection is deliberately
// machine-scoped and must not depend on the selected installation HOME.
home := t.TempDir()
testseam.Swap(t, &upgradeGetenv, func(string) string { return "" })
for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
sentinel := filepath.Join(home, "app-sentinel")
if err := os.MkdirAll(sentinel, 0o755); err != nil {
t.Fatal(err)
}
appInfo, err := os.Stat(sentinel)
if err != nil {
t.Fatal(err)
}
zcodeApp := filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")
minimaxApp := filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")
originalStat := upgradeStat
testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) {
if path == zcodeApp || path == minimaxApp {
return appInfo, nil
}
return originalStat(path)
})
want := map[string]bool{
".config/kimchi/harness/skills": false,
".tabnine/agent/skills": false,
".zcode/skills": false,
".minimax/skills": false,
}
for _, root := range configuredSkillRoots(home) {
label := filepath.ToSlash(root.label)
if _, ok := want[label]; ok {
want[label] = skillRootDetectedBase(root)
}
}
for label, detected := range want {
if !detected {
t.Errorf("root %s was not detected", label)
}
}
}
func TestCrossPlatformCoverageConfiguredAgentRootsMatchUpstreamAndCustomHomes(t *testing.T) {
home := t.TempDir()
custom := map[string]string{
"AUTOHAND_HOME": filepath.Join(home, "custom-autohand"),
"CLAUDE_CONFIG_DIR": filepath.Join(home, "custom-claude"),
"CODEX_HOME": filepath.Join(home, "custom-codex"),
"GROK_HOME": filepath.Join(home, "custom-grok"),
"HERMES_HOME": filepath.Join(home, "custom-hermes"),
"VIBE_HOME": filepath.Join(home, "custom-vibe"),
"XDG_CONFIG_HOME": filepath.Join(home, "custom-xdg"),
}
testseam.Swap(t, &upgradeGetenv, func(name string) string { return custom[name] })
roots := configuredSkillRoots(home)
if len(roots) != len(knownSkillDirs) {
t.Fatalf("configured unique roots = %d, want %d", len(roots), len(knownSkillDirs))
}
byLabel := make(map[string]resolvedSkillRoot, len(roots))
for _, root := range roots {
if _, duplicate := byLabel[root.label]; duplicate {
t.Fatalf("duplicate configured label %s", root.label)
}
byLabel[root.label] = root
}
wants := map[string]string{
".autohand/skills": filepath.Join(custom["AUTOHAND_HOME"], "skills"),
".claude/skills": filepath.Join(custom["CLAUDE_CONFIG_DIR"], "skills"),
".codex/skills": filepath.Join(custom["CODEX_HOME"], "skills"),
".grok/skills": filepath.Join(custom["GROK_HOME"], "skills"),
".hermes/skills": filepath.Join(custom["HERMES_HOME"], "skills"),
".vibe/skills": filepath.Join(custom["VIBE_HOME"], "skills"),
".config/agents/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"),
".config/crush/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "crush", "skills"),
".config/devin/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "devin", "skills"),
".config/goose/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "goose", "skills"),
".config/kimchi/harness/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "kimchi", "harness", "skills"),
".config/opencode/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "opencode", "skills"),
}
for label, want := range wants {
if got := byLabel[label].base; filepath.Clean(got) != filepath.Clean(want) {
t.Errorf("configured %s = %q, want %q", label, got, want)
}
}
for _, label := range []string{
".config/agents/skills", ".gemini/antigravity/skills", ".gemini/antigravity-cli/skills",
".codex/skills", ".cursor/skills", ".deepagents/agent/skills", ".firebender/skills",
".gemini/skills", ".copilot/skills", ".config/opencode/skills",
} {
if !byLabel[label].universal {
t.Errorf("%s not classified universal", label)
}
}
if !byLabel[".agents/skills"].canonical || byLabel[".claude/skills"].universal {
t.Fatal("canonical/non-universal classification drift")
}
}
func TestCrossPlatformCoverageUpgradeCanonicalFailureFailsFastUnconditionally(t *testing.T) {
for _, mode := range []string{"mono", "multi"} {
t.Run(mode, func(t *testing.T) {
withFakeHome(t)
// Canonical-only configuration (no non-universal dependents): a failed
// canonical publish must still fail the whole upgrade. Universal agents
// read canonical directly, so silently succeeding here would leave the
// machine without any usable Skill while reporting success.
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills"})
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("canonical denied") })
src := t.TempDir()
if mode == "mono" {
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("mono"), 0o644); err != nil {
t.Fatal(err)
}
} else {
src = writeMultiBundle(t, src, "dingtalk-chat")
}
result, err := UpgradeSkillLocations(src)
if err == nil || !strings.Contains(err.Error(), "canonical Skill 安装失败") {
t.Fatalf("canonical publish failure must propagate an error, got (%v)", err)
}
if result == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
t.Fatalf("result = %#v", result)
}
})
}
}
func TestCrossPlatformCoverageUpgradeOpenClawAliasPriority(t *testing.T) {
for _, tc := range []struct {
name string
dirs []string
want string
}{
{name: "default", want: ".openclaw"},
{name: "moltbot", dirs: []string{".moltbot"}, want: ".moltbot"},
{name: "clawdbot-before-moltbot", dirs: []string{".moltbot", ".clawdbot"}, want: ".clawdbot"},
{name: "openclaw-first", dirs: []string{".moltbot", ".clawdbot", ".openclaw"}, want: ".openclaw"},
} {
t.Run(tc.name, func(t *testing.T) {
home := t.TempDir()
for _, dir := range tc.dirs {
if err := os.MkdirAll(filepath.Join(home, dir), 0o755); err != nil {
t.Fatal(err)
}
}
if got := resolveOpenClawSkillBase(home); got != filepath.Join(home, tc.want, "skills") {
t.Fatalf("OpenClaw root = %q", got)
}
})
}
}
func TestCrossPlatformCoverageUpgradeLinkStagingFailureEdges(t *testing.T) {
home := t.TempDir()
canonical := filepath.Join(home, ".agents", "skills")
dest := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(filepath.Join(canonical, "dingtalk-chat"), 0o755); err != nil {
t.Fatal(err)
}
t.Run("mkdir", func(t *testing.T) {
testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return errors.New("mkdir denied") })
if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "创建 Agent Skill 目录") {
t.Fatalf("mkdir failure = %v", err)
}
})
t.Run("temp", func(t *testing.T) {
testseam.Swap(t, &upgradeMkdirTemp, func(string, string) (string, error) { return "", errors.New("temp denied") })
if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "链接 staging") {
t.Fatalf("temp failure = %v", err)
}
})
t.Run("physical-parent", func(t *testing.T) {
testseam.Swap(t, &upgradeEvalSymlinks, func(path string) (string, error) {
if path == dest {
return "", errors.New("physical denied")
}
return filepath.EvalSymlinks(path)
})
if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "物理目录") {
t.Fatalf("physical failure = %v", err)
}
})
t.Run("canonical-target", func(t *testing.T) {
target := filepath.Join(canonical, "dingtalk-chat")
testseam.Swap(t, &upgradeEvalSymlinks, func(path string) (string, error) {
if path == target {
return "", errors.New("canonical denied")
}
return filepath.EvalSymlinks(path)
})
if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "解析 canonical") {
t.Fatalf("canonical failure = %v", err)
}
})
t.Run("relative-path", func(t *testing.T) {
testseam.Swap(t, &upgradeRel, func(string, string) (string, error) { return "", errors.New("relative denied") })
if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "相对链接") {
t.Fatalf("relative failure = %v", err)
}
})
}
func TestCrossPlatformCoverageUpgradeAgentBranchFailures(t *testing.T) {
for _, mode := range []string{"mono", "multi"} {
t.Run(mode, func(t *testing.T) {
makeSource := func(t *testing.T) string {
t.Helper()
src := t.TempDir()
if mode == "mono" {
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("mono"), 0o644); err != nil {
t.Fatal(err)
}
return src
}
return writeMultiBundle(t, src, "dingtalk-chat")
}
t.Run("undetected-and-physical-alias", func(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills", ".qoder/skills"})
if err := os.MkdirAll(filepath.Join(home, ".qoder"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(home, ".agents", "skills"), filepath.Join(home, ".qoder", "skills")); err != nil {
t.Fatal(err)
}
result, err := UpgradeSkillLocations(makeSource(t))
if err != nil {
t.Fatal(err)
}
if len(result.Results) < 3 || result.Results[1].Status != SkillDirSkipped || result.Results[2].Status != SkillDirSkipped {
t.Fatalf("skip branches = %#v", result.Results)
}
})
t.Run("universal-retire-failure", func(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".cursor/skills"})
victimBase := filepath.Join(home, ".cursor", "skills")
victim := filepath.Join(victimBase, "dws")
if mode == "multi" {
victim = filepath.Join(victimBase, "dingtalk-chat")
}
if err := os.MkdirAll(victim, 0o755); err != nil {
t.Fatal(err)
}
if mode == "multi" {
useUpgradeManagedNames(t, "dingtalk-chat")
}
origRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) {
if src == victim {
return "", errors.New("retire denied")
}
return origRename(src, dst)
})
result, err := UpgradeSkillLocations(makeSource(t))
if err != nil {
t.Fatalf("retire failure must not fail the upgrade: %v", err)
}
if len(result.Failed()) != 0 {
t.Fatalf("retire failure must not count as an install failure: %#v", result.Failed())
}
warnings := result.RetireWarnings()
if len(warnings) != 1 || warnings[0].Err == nil {
t.Fatalf("retire warnings = %#v", warnings)
}
if len(result.Succeeded()) == 0 {
t.Fatalf("canonical must still be published: %#v", result)
}
if _, statErr := os.Lstat(victim); statErr != nil {
t.Fatalf("unretired copy must be preserved: %v", statErr)
}
})
t.Run("universal-retire-success", func(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".cursor/skills"})
victimBase := filepath.Join(home, ".cursor", "skills")
victim := filepath.Join(victimBase, "dws")
if mode == "multi" {
victim = filepath.Join(victimBase, "dingtalk-chat")
useUpgradeManagedNames(t, "dingtalk-chat")
}
if err := os.MkdirAll(victim, 0o755); err != nil {
t.Fatal(err)
}
result, err := UpgradeSkillLocations(makeSource(t))
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("retire success = %#v, %v", result, err)
}
if _, err := os.Lstat(victim); !os.IsNotExist(err) {
t.Fatalf("universal victim remains: %v", err)
}
})
t.Run("victim-scan-and-fallback-copy-failures", func(t *testing.T) {
t.Run("victim-scan", func(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
base := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(base, 0o755); err != nil {
t.Fatal(err)
}
origReadDir := upgradeReadDir
testseam.Swap(t, &upgradeReadDir, func(path string) ([]os.DirEntry, error) {
if path == base {
return nil, errors.New("scan denied")
}
return origReadDir(path)
})
result, err := UpgradeSkillLocations(makeSource(t))
if err != nil || len(result.Failed()) != 1 {
t.Fatalf("victim scan = %#v, %v", result, err)
}
})
t.Run("fallback-copy", func(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
base := filepath.Join(home, ".claude", "skills")
if err := os.MkdirAll(base, 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &upgradeSymlink, func(string, string) error { return errors.New("link denied") })
origCopy := upgradeCopyDir
testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error {
if strings.HasPrefix(filepath.Clean(dst), filepath.Clean(base)+string(filepath.Separator)) {
return errors.New("copy denied")
}
return origCopy(src, dst)
})
result, err := UpgradeSkillLocations(makeSource(t))
if err != nil || len(result.Failed()) != 1 {
t.Fatalf("fallback copy = %#v, %v", result, err)
}
})
})
})
}
}
func TestCrossPlatformCoverageUpgradeWindowsPathNormalizationAndRootDedup(t *testing.T) {
testseam.Swap(t, &upgradeFoldPathCase, true)
if got := skillRootPathKey(filepath.Join("Root", "Skills")); got != strings.ToLower(got) {
t.Fatalf("case-folded path key = %q", got)
}
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills", ".claude/skills"})
if roots := configuredSkillRoots(t.TempDir()); len(roots) != 2 {
t.Fatalf("deduplicated roots = %#v", roots)
}
}
+7 -4
View File
@@ -264,7 +264,7 @@ func TestCrossPlatformCoverageUpgradePathsAndSkillsEdges(t *testing.T) {
}
knownSkillDirs = []string{".agents/skills", ".real/skills", ".missing/skills", ".present/skills"}
result, err := UpgradeSkillLocations(source)
if err != nil || len(result.Succeeded()) != 1 || len(result.Failed()) != 0 {
if err != nil || len(result.Succeeded()) != 2 || len(result.Failed()) != 0 {
t.Fatalf("skill upgrade = %#v, %v", result, err)
}
knownSkillDirs = []string{".real/skills"}
@@ -579,7 +579,10 @@ func TestCrossPlatformCoverageUpgradePathInjectedFailures(t *testing.T) {
})
home := t.TempDir()
upgradeUserHomeDir = func() (string, error) { return home, nil }
knownSkillDirs = []string{".agents/skills"}
knownSkillDirs = []string{".agents/skills", ".claude/skills"}
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatal(err)
}
calls := 0
upgradeCopyDir = func(src, dst string) error {
calls++
@@ -593,8 +596,8 @@ func TestCrossPlatformCoverageUpgradePathInjectedFailures(t *testing.T) {
t.Fatal(err)
}
result, err := UpgradeSkillLocations(source)
if err != nil || len(result.Succeeded()) != 1 || len(result.Results) != 1 {
t.Fatalf("replaced primary fallback = %#v, %v", result, err)
if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
t.Fatalf("canonical failure = %#v, %v", result, err)
}
failure := errors.New("ensure failed")
upgradeEnsureDir = func(string, os.FileMode) error { return failure }
+521 -208
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
@@ -35,46 +36,133 @@ const (
// - test/scripts/package_script_test.go expectedPackagedSkillTargets
// - scripts/release/verify-package-managers.sh HOME_AGENT_PARENTS / HOME_SKILL_TARGETS
//
// The first entry (.agents/skills) is a generic fallback. It is used only
// when no concrete Agent home is detected; otherwise publishing there would
// duplicate every Skill for Agents (including Codex) that scan both roots.
// Subsequent entries are updated when their parent directory already exists.
// The first entry (.agents/skills) is the canonical global Skill directory,
// following the universal .agents/skills convention.
// Agents that understand the universal .agents convention read it directly;
// detected non-universal Agents receive relative links to this canonical
// copy (with a direct-copy fallback when links are unavailable).
var knownSkillDirs = []string{
".agents/skills",
".config/agents/skills",
".gemini/antigravity/skills",
".gemini/antigravity-cli/skills",
".deepagents/agent/skills",
".firebender/skills",
".copilot/skills",
".config/opencode/skills",
".aider-desk/skills",
".astrbot/data/skills",
".autohand/skills",
".augment/skills",
".bob/skills",
".claude/skills",
".cursor/skills",
".openclaw/skills",
".codeartsdoer/skills",
".codebuddy/skills",
".codemaker/skills",
".codestudio/skills",
".commandcode/skills",
".continue/skills",
".snowflake/cortex/skills",
".config/crush/skills",
".config/devin/skills",
".factory/skills",
".forge/skills",
".config/goose/skills",
".grok/skills",
".hermes/skills",
".inferencesh/skills",
".jazz/skills",
".junie/skills",
".iflow/skills",
".kilocode/skills",
".config/kimchi/harness/skills",
".kiro/skills",
".kode/skills",
".lingma/skills",
".mcpjam/skills",
".minimax/skills",
".vibe/skills",
".moxby/skills",
".mux/skills",
".openhands/skills",
".ona/skills",
".pi/agent/skills",
".qoder/skills",
".qoder-cn/skills",
".qwen/skills",
".reasonix/skills",
".rovodev/skills",
".roo/skills",
".tabnine/agent/skills",
".terramind/skills",
".tinycloud/skills",
".trae/skills",
".trae-cn/skills",
".codeium/windsurf/skills",
".zcode/skills",
".zencoder/skills",
".neovate/skills",
".pochi/skills",
".adal/skills",
// DWS-only target retained for product compatibility.
".qoderwork/skills",
// beta.6 compatibility roots: cleanup only, never publish new copies.
".cursor/skills",
".gemini/skills",
".codex/skills",
".zcode/skills",
".github/skills",
".windsurf/skills",
".augment/skills",
".cline/skills",
".amp/skills",
".kiro/skills",
".trae/skills",
".openclaw/skills",
".hermes/skills",
}
// universalSkillDirs are concrete Agent directories whose current clients
// natively scan ~/.agents/skills. Publishing another copy or link below these
// roots makes the same Skill appear twice. Keep this classification aligned
// with vercel-labs/skills' isUniversalAgent rule. Qoderwork is intentionally
// non-universal because it is a DWS-specific integration absent upstream.
var universalSkillDirs = map[string]bool{
".config/agents/skills": true,
".gemini/antigravity/skills": true,
".gemini/antigravity-cli/skills": true,
".codex/skills": true,
".cursor/skills": true,
".deepagents/agent/skills": true,
".firebender/skills": true,
".gemini/skills": true,
".copilot/skills": true,
".config/opencode/skills": true,
// beta.6 compatibility roots are cleanup-only as well.
".github/skills": true,
".windsurf/skills": true,
".cline/skills": true,
".amp/skills": true,
}
var (
upgradeUserHomeDir = os.UserHomeDir
upgradeExecutable = os.Executable
upgradeEvalSymlinks = filepath.EvalSymlinks
upgradeCopyDir = copyDir
upgradeEnsureDir = ensureDir
upgradeRemoveAll = os.RemoveAll
upgradeMkdirAll = os.MkdirAll
upgradeMkdirTemp = os.MkdirTemp
upgradeReadDir = os.ReadDir
upgradeStat = os.Stat
upgradeBuildProvenance = skillprovenance.Build
upgradeReadSkillState = skillstate.Read
upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") }
upgradeWriteSkillState = skillstate.Write
upgradeNow = time.Now
upgradeUserHomeDir = os.UserHomeDir
upgradeExecutable = os.Executable
upgradeEvalSymlinks = filepath.EvalSymlinks
upgradeCopyDir = copyDir
upgradeEnsureDir = ensureDir
upgradeRemoveAll = os.RemoveAll
upgradeMkdirAll = os.MkdirAll
upgradeMkdirTemp = os.MkdirTemp
upgradeReadDir = os.ReadDir
upgradeStat = os.Stat
upgradeLstat = os.Lstat
upgradeSymlink = os.Symlink
upgradeRel = filepath.Rel
upgradeGetenv = os.Getenv
upgradeBuildProvenance = skillprovenance.Build
upgradeReadSkillState = skillstate.Read
upgradePublishSkillPath = PublishSkillPathNoReplace
upgradeRollbackPublishedSkillPaths = RollbackSkillPathPublications
upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") }
upgradeWriteSkillState = skillstate.Write
upgradeNow = time.Now
upgradeFoldPathCase = runtime.GOOS == "windows"
)
// skillBackupSubdir is the user-level directory where skill directories are
@@ -85,43 +173,64 @@ const skillBackupSubdir = ".dws/skill-backups"
// backupAndRemoveSkillDir moves dir into <homeDir>/.dws/skill-backups/
// <stamp>/<rel> instead of destroying it, and returns the backup path. It is
// fail-safe: a directory that cannot be backed up is NOT removed and the
// fail-safe: a path that cannot be backed up is NOT removed and the
// error is returned so the caller can surface it (and never install the
// opposite layout next to it silently). Missing paths and regular files are
// no-ops ("", nil).
// opposite layout next to it silently). Missing paths are no-ops. Directories,
// links (including dangling links), and ordinary files are all preserved:
// an unexpected file at a managed destination must never be overwritten.
func backupAndRemoveSkillDir(homeDir, dir string) (string, error) {
info, err := upgradeStat(dir)
_, err := upgradeLstat(dir)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("检查技能目录失败 %s: %w", dir, err)
}
if !info.IsDir() {
return "", nil
}
rel, relErr := filepath.Rel(homeDir, dir)
if relErr != nil || rel == "." || strings.HasPrefix(rel, "..") {
rel = filepath.Base(dir)
}
name := strings.NewReplacer(string(filepath.Separator), "-", "/", "-").Replace(rel)
stamp := upgradeBackupStamp()
backupRoot := filepath.Join(homeDir, skillBackupSubdir, stamp)
target := filepath.Join(backupRoot, name)
for i := 1; ; i++ {
if _, err := os.Stat(target); os.IsNotExist(err) {
break
if err := upgradeMkdirAll(filepath.Join(homeDir, skillBackupSubdir), dirPermShared); err != nil {
return "", fmt.Errorf("创建备份根目录失败 %s: %w", filepath.Join(homeDir, skillBackupSubdir), err)
}
var backupRoot, target string
for i := 0; ; i++ {
candidate := stamp
if i > 0 {
candidate = fmt.Sprintf("%s-%d", stamp, i)
}
backupRoot = filepath.Join(homeDir, skillBackupSubdir, fmt.Sprintf("%s-%d", stamp, i))
target = filepath.Join(backupRoot, name)
if i > 1000 {
return "", fmt.Errorf("备份目录冲突无法解决: %s", target)
return "", fmt.Errorf("备份目录冲突无法解决: %s", filepath.Join(homeDir, skillBackupSubdir, candidate))
}
backupRoot = filepath.Join(homeDir, skillBackupSubdir, candidate)
target = filepath.Join(backupRoot, name)
if _, err := upgradeLstat(target); err == nil {
continue // payload name already taken in this stamp root
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("检查备份目标失败 %s: %w", target, err)
}
if err := skillPathMkdir(backupRoot, dirPermShared); err == nil {
// We created the root: stamp ownership before any payload moves
// in, so an interrupted backup never leaves an unmarked
// (never-prunable) stamp behind.
if err := skillBackupWriteMarker(backupRoot); err != nil {
// Bounded cleanup: the root is empty and ours alone.
_ = skillPathRemove(backupRoot)
return "", fmt.Errorf("写入备份所有权标记失败 %s: %w", backupRoot, err)
}
break
} else if !os.IsExist(err) {
return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err)
} else if skillBackupMarkerValid(backupRoot) {
break // proven ours: same-stamp payloads from this run reuse it
}
// A stamp-shaped directory we cannot prove ours must never be
// adopted — writing the marker or the payload into it would turn
// its foreign contents into prunable DWS backups.
}
if err := upgradeMkdirAll(backupRoot, dirPermShared); err != nil {
return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err)
}
if err := upgradeRename(dir, target); err != nil {
if err := moveSkillPathRecoverably(dir, target); err != nil {
return "", fmt.Errorf("备份技能目录失败 %s: %w", dir, err)
}
// Keep the backup directory bounded; a prune failure must not fail the
@@ -151,13 +260,17 @@ const (
SkillDirSkipped // agent not detected, directory skipped
SkillDirBlacklisted // blacklisted, never touched
SkillDirFailed // installation attempted but failed
// SkillDirRetireWarning marks a universal Agent whose obsolete private copy
// could not be retired. Nothing is installed below such a root, so the
// leftover is reported without counting as an install failure.
SkillDirRetireWarning
)
// SkillDirResult holds the per-directory install result.
type SkillDirResult struct {
Dir string // destination directory (e.g. ~/.claude/skills/dws)
Status SkillDirStatus // outcome
Err error // non-nil when Status == SkillDirFailed
Err error // non-nil when Status == SkillDirFailed or SkillDirRetireWarning
}
// SkillUpgradeResult aggregates the outcome of an UpgradeSkillLocations call.
@@ -187,6 +300,18 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult {
return out
}
// RetireWarnings returns universal Agent roots that still hold an obsolete
// private copy because retiring it failed.
func (r *SkillUpgradeResult) RetireWarnings() []SkillDirResult {
var out []SkillDirResult
for _, d := range r.Results {
if d.Status == SkillDirRetireWarning {
out = append(out, d)
}
}
return out
}
// UpgradeSkillLocations refreshes skills from extractedDir into agent homes.
// extractedDir may be a multi-skill bundle root (subdirectories each containing
// SKILL.md) or a legacy mono root (SKILL.md at its top level). Callers that
@@ -204,11 +329,13 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult {
// `dws skill setup --mode mono` flow.
//
// Strategy (matches npm install.js installSkillsToHomes):
// - Concrete agent dirs (claude, cursor, codex, ...) are updated when their
// parent directory exists (e.g. ~/.codex/ exists => user has Codex)
// - ~/.agents/skills/ is used only when no concrete Agent is detected
// - ~/.agents/skills/ is always the canonical global store
// - universal Agents (Codex, Cursor, Gemini, Cline, Amp, Copilot) read the
// canonical store directly, so old agent-specific copies are retired
// - detected non-universal Agents receive relative links to canonical;
// filesystems that reject links receive a direct-copy fallback
// - ~/.real/ and other blacklisted paths are NEVER touched
// - If no location was updated at all, fall back to ~/.agents/skills/
// - canonical publication is mandatory and fails the upgrade loudly
//
// Opposite-mode leftovers are backed up to ~/.dws/skill-backups/ and then
// removed so mono and multi never co-exist after an upgrade; a leftover that
@@ -277,9 +404,54 @@ func buildUpgradeProvenanceRecords(root string, names []string, version string)
// backups are kept.
const skillBackupKeep = 5
// The ownership marker inside a stamp root. Every DWS surface writes the
// exact same bytes (install.sh, install-skills.sh, install-event.sh,
// install-devapp.sh, the PowerShell installers, and build/npm/install.js):
// a stamp-shaped directory name alone is not ownership proof, because users
// and unrelated tools can create 20260819-120000-shaped names. Counting or
// deleting a backup root requires the marker to verify, and adopting an
// existing root for a new backup requires it first.
const (
skillBackupMarkerName = ".dws-skill-backup"
skillBackupMarkerContent = "dws skill backup v1\n"
)
var (
skillBackupReadMarker = func(root string) (string, error) {
data, err := os.ReadFile(filepath.Join(root, skillBackupMarkerName))
return string(data), err
}
skillBackupWriteMarker = func(root string) error {
return os.WriteFile(filepath.Join(root, skillBackupMarkerName), []byte(skillBackupMarkerContent), 0o644)
}
)
// skillBackupMarkerValid reports whether root carries the ownership marker
// with exactly the expected bytes; a missing, unreadable, or differently
// worded marker means the directory is foreign data.
func skillBackupMarkerValid(root string) bool {
body, err := skillBackupReadMarker(root)
return err == nil && body == skillBackupMarkerContent
}
// skillBackupStampRegexp matches only directory names DWS itself writes:
// UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry
// in the backup root is foreign (user data, unrelated tooling) and must be
// left untouched, so pruning is restricted to names it can prove DWS created.
var skillBackupStampRegexp = regexp.MustCompile(`^[0-9]{8}-[0-9]{6}(-[0-9]+)?$`)
// isSkillBackupStamp reports whether name is a DWS-created backup stamp.
func isSkillBackupStamp(name string) bool {
return skillBackupStampRegexp.MatchString(name)
}
// pruneSkillBackups removes the oldest backup directories when more than
// skillBackupKeep remain. Best-effort: a removal failure never aborts, but
// pruning failures are reported so callers can warn the user.
// skillBackupKeep remain. Only directories whose names match the DWS backup
// stamp format AND whose ownership marker verifies are candidates; unknown
// or unmarked directories — including stamp roots created before the
// ownership marker existed — are foreign data and preserved. Best-effort:
// a removal failure never aborts, but pruning failures are reported so
// callers can warn the user.
func pruneSkillBackups(homeDir string) error {
root := filepath.Join(homeDir, skillBackupSubdir)
entries, err := upgradeReadDir(root)
@@ -291,7 +463,12 @@ func pruneSkillBackups(homeDir string) error {
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
if !e.IsDir() || !isSkillBackupStamp(e.Name()) {
continue
}
// A stamp-shaped name alone is not ownership proof: only roots
// whose marker verifies are counted or deleted.
if skillBackupMarkerValid(filepath.Join(root, e.Name())) {
names = append(names, e.Name())
}
}
@@ -404,20 +581,15 @@ func uniqueSkillDirs(paths []string) []string {
// restoreSkillSet removes any newly published directories, then restores all
// original directories in reverse backup order.
func restoreSkillSet(published []string, backups []backedUpSkillDir) error {
var restoreErr error
for i := len(published) - 1; i >= 0; i-- {
if err := upgradeRemoveAll(published[i]); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("移除失败发布目录 %s: %w", published[i], err))
}
}
func restoreSkillSet(published []SkillPathPublication, backups []backedUpSkillDir) error {
restoreErr := upgradeRollbackPublishedSkillPaths(published)
for i := len(backups) - 1; i >= 0; i-- {
backup := backups[i]
if err := upgradeMkdirAll(filepath.Dir(backup.original), dirPermShared); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录 %s: %w", filepath.Dir(backup.original), err))
continue
}
if err := upgradeRename(backup.backup, backup.original); err != nil {
if err := moveSkillPathRecoverably(backup.backup, backup.original); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w", backup.original, err))
}
}
@@ -450,16 +622,17 @@ func publishStagedSkillSet(homeDir string, staged []stagedSkillDir, victims []st
if err != nil {
return err
}
published := make([]string, 0, len(staged))
published := make([]SkillPathPublication, 0, len(staged))
for _, skill := range staged {
if err := upgradeRename(skill.staged, skill.dest); err != nil {
publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, err)
publication, publishErr := upgradePublishSkillPath(skill.staged, skill.dest)
if publishErr != nil {
publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, publishErr)
if restoreErr := restoreSkillSet(published, backups); restoreErr != nil {
return errors.Join(publishErr, fmt.Errorf("恢复原 Skill 集合失败: %w", restoreErr))
}
return publishErr
}
published = append(published, skill.dest)
published = append(published, publication)
}
return nil
}
@@ -517,100 +690,274 @@ func publishMultiUpgradeTarget(homeDir, destBase, multiRoot string, skills []str
return publishStagedSkillSet(homeDir, staged, victims)
}
func hasDetectedSpecificSkillRoot(homeDir string) bool {
for _, agentDir := range knownSkillDirs {
if isGenericSkillRoot(agentDir) || isBlacklisted(agentDir) {
continue
}
parentGate := filepath.Dir(filepath.Join(homeDir, agentDir))
if info, err := upgradeStat(parentGate); err == nil && info.IsDir() {
return true
}
}
return false
}
func isGenericSkillRoot(agentDir string) bool {
return filepath.Clean(agentDir) == filepath.Clean(".agents/skills")
}
func retireGenericSkillRoot(homeDir string, managed map[string]bool) error {
base := filepath.Join(homeDir, ".agents", "skills")
func isUniversalSkillRoot(agentDir string) bool {
return universalSkillDirs[filepath.ToSlash(filepath.Clean(agentDir))]
}
type resolvedSkillRoot struct {
base string
label string
universal bool
canonical bool
}
func resolveOpenClawSkillBase(homeDir string) string {
for _, name := range []string{".openclaw", ".clawdbot", ".moltbot"} {
base := filepath.Join(homeDir, name)
if info, err := upgradeStat(base); err == nil && info.IsDir() {
return filepath.Join(base, "skills")
}
}
return filepath.Join(homeDir, ".openclaw", "skills")
}
func configuredSkillRoots(homeDir string) []resolvedSkillRoot {
var roots []resolvedSkillRoot
seen := map[string]bool{}
configHome := strings.TrimSpace(upgradeGetenv("XDG_CONFIG_HOME"))
if configHome == "" {
configHome = filepath.Join(homeDir, ".config")
}
add := func(base, label string, universal, canonical bool) {
key := skillRootPathKey(base)
if seen[key] {
return
}
seen[key] = true
roots = append(roots, resolvedSkillRoot{base: base, label: label, universal: universal, canonical: canonical})
}
for _, agentDir := range knownSkillDirs {
base := filepath.Join(homeDir, agentDir)
switch filepath.ToSlash(filepath.Clean(agentDir)) {
case ".claude/skills":
if custom := strings.TrimSpace(upgradeGetenv("CLAUDE_CONFIG_DIR")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".codex/skills":
if custom := strings.TrimSpace(upgradeGetenv("CODEX_HOME")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".hermes/skills":
if custom := strings.TrimSpace(upgradeGetenv("HERMES_HOME")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".autohand/skills":
if custom := strings.TrimSpace(upgradeGetenv("AUTOHAND_HOME")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".grok/skills":
if custom := strings.TrimSpace(upgradeGetenv("GROK_HOME")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".vibe/skills":
if custom := strings.TrimSpace(upgradeGetenv("VIBE_HOME")); custom != "" {
base = filepath.Join(custom, "skills")
}
case ".openclaw/skills":
base = resolveOpenClawSkillBase(homeDir)
case ".config/agents/skills":
base = filepath.Join(configHome, "agents", "skills")
case ".config/opencode/skills":
base = filepath.Join(configHome, "opencode", "skills")
case ".config/crush/skills":
base = filepath.Join(configHome, "crush", "skills")
case ".config/devin/skills":
base = filepath.Join(configHome, "devin", "skills")
case ".config/goose/skills":
base = filepath.Join(configHome, "goose", "skills")
case ".config/kimchi/harness/skills":
base = filepath.Join(configHome, "kimchi", "harness", "skills")
}
add(base, agentDir, isUniversalSkillRoot(agentDir), isGenericSkillRoot(agentDir))
}
return roots
}
func skillRootDetectedBase(root resolvedSkillRoot) bool {
detectedDir := filepath.Dir(root.base)
switch filepath.ToSlash(filepath.Clean(root.label)) {
case ".config/kimchi/harness/skills":
detectedDir = filepath.Dir(filepath.Dir(root.base))
case ".tabnine/agent/skills":
detectedDir = filepath.Dir(filepath.Dir(root.base))
case ".zcode/skills":
// Application bundles are machine-scoped detection signals. Keep this
// independent of HOME so upgrade matches npm, Shell, and PowerShell.
if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() {
return true
}
case ".minimax/skills":
if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() {
return true
}
}
info, err := upgradeStat(detectedDir)
return err == nil && info.IsDir()
}
func samePhysicalSkillRoot(left, right string) bool {
leftReal, leftErr := upgradeEvalSymlinks(left)
rightReal, rightErr := upgradeEvalSymlinks(right)
if leftErr != nil || rightErr != nil {
return false
}
return skillRootPathKey(leftReal) == skillRootPathKey(rightReal)
}
func skillRootPathKey(path string) string {
clean := filepath.Clean(path)
if upgradeFoldPathCase {
clean = strings.ToLower(clean)
}
return clean
}
func retireManagedSkillRoot(homeDir, base string, managed map[string]bool) error {
victims, err := managedMultiSkillVictims(base, managed)
if err != nil {
return err
}
victims = append(victims, filepath.Join(base, "dws"))
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("迁移通用 Skill 根目录失败: %w", err)
return fmt.Errorf("迁移 Agent Skill 旧副本失败: %w", err)
}
return nil
}
func stageLinkedSkillSet(destBase, canonicalBase, prefix string, names []string) (stageRoot string, staged []stagedSkillDir, err error) {
if err := upgradeMkdirAll(destBase, dirPermShared); err != nil {
return "", nil, fmt.Errorf("创建 Agent Skill 目录失败 %s: %w", destBase, err)
}
stageRoot, err = upgradeMkdirTemp(destBase, prefix)
if err != nil {
return "", nil, fmt.Errorf("创建 Skill 链接 staging 失败 %s: %w", destBase, err)
}
defer func() {
if err != nil {
_ = upgradeRemoveAll(stageRoot)
}
}()
realDestBase, realErr := upgradeEvalSymlinks(destBase)
if realErr != nil {
return stageRoot, nil, fmt.Errorf("解析 Agent Skill 物理目录失败 %s: %w", destBase, realErr)
}
for _, name := range names {
target := filepath.Join(canonicalBase, name)
realTarget, targetErr := upgradeEvalSymlinks(target)
if targetErr != nil {
return stageRoot, nil, fmt.Errorf("解析 canonical Skill 失败 %s: %w", target, targetErr)
}
relTarget, relErr := upgradeRel(realDestBase, realTarget)
if relErr != nil {
return stageRoot, nil, fmt.Errorf("计算 Skill 相对链接失败 %s: %w", target, relErr)
}
stagedPath := filepath.Join(stageRoot, name)
if linkErr := upgradeSymlink(relTarget, stagedPath); linkErr != nil {
return stageRoot, nil, fmt.Errorf("创建 Skill 链接失败 %s -> %s: %w", stagedPath, relTarget, linkErr)
}
staged = append(staged, stagedSkillDir{staged: stagedPath, dest: filepath.Join(destBase, name)})
}
return stageRoot, staged, nil
}
func publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase string, names []string, victims []string) error {
needed := make([]string, 0, len(names))
correct := make(map[string]bool, len(names))
for _, name := range names {
dest := filepath.Join(destBase, name)
canonical := filepath.Join(canonicalBase, name)
if samePhysicalSkillRoot(dest, canonical) {
correct[filepath.Clean(dest)] = true
continue
}
needed = append(needed, name)
}
filteredVictims := make([]string, 0, len(victims))
for _, victim := range victims {
if !correct[filepath.Clean(victim)] {
filteredVictims = append(filteredVictims, victim)
}
}
stageRoot, staged, err := stageLinkedSkillSet(destBase, canonicalBase, ".dws-link-set-", needed)
if err != nil {
return err
}
defer func() { _ = upgradeRemoveAll(stageRoot) }()
return publishStagedSkillSet(homeDir, staged, filteredVictims)
}
// upgradeMonoSkillLocations is the legacy mono behavior: one dws/ directory
// per agent home.
func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, error) {
result := &SkillUpgradeResult{}
managedNames := readManagedSkillNames(homeDir)
hasSpecificRoot := hasDetectedSpecificSkillRoot(homeDir)
canonicalBase := filepath.Join(homeDir, ".agents", "skills")
canonicalDest := filepath.Join(canonicalBase, "dws")
if err := publishMonoUpgradeTarget(homeDir, canonicalBase, skillSrc, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirFailed, Err: err})
// Canonical is the mandatory store: universal agents read it directly, so a
// failed canonical publish means nobody received the upgrade. Always fail
// loud — including universal-only machines — instead of reporting success
// with nothing installed.
return result, fmt.Errorf("canonical Skill 安装失败: %w", err)
}
result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirOK})
for _, agentDir := range knownSkillDirs {
destDir := filepath.Join(homeDir, agentDir, "dws")
if isBlacklisted(agentDir) {
for _, root := range configuredSkillRoots(homeDir) {
if root.canonical {
continue
}
destBase := root.base
destDir := filepath.Join(destBase, "dws")
if isBlacklisted(root.label) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted})
continue
}
if isGenericSkillRoot(agentDir) && hasSpecificRoot {
if !skillRootDetectedBase(root) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
continue
}
if !isGenericSkillRoot(agentDir) {
parentGate := filepath.Dir(filepath.Join(homeDir, agentDir))
if _, err := upgradeStat(parentGate); os.IsNotExist(err) {
if samePhysicalSkillRoot(destBase, canonicalBase) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
continue
}
if root.universal {
if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil {
// Nothing is installed below a universal root, so a stale copy that
// resists retirement is a warning, not an install failure.
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirRetireWarning, Err: err})
} else {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
}
continue
}
victims, victimErr := monoUpgradeVictims(destBase, destDir, managedNames)
if victimErr != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: victimErr})
continue
}
if err := publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase, []string{"dws"}, victims); err != nil {
// An uncertain destination may belong to a concurrent writer.
// The copy fallback below would back that object aside and
// replace it — exactly what the sentinel forbids — so surface
// the uncertain state instead of retrying over it.
if errors.Is(err, ErrSkillPathPublicationUncertain) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err})
continue
}
// Match the upstream installer: platforms/filesystems that reject
// links receive a direct copy rather than losing Agent support.
if copyErr := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); copyErr != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: errors.Join(err, copyErr)})
continue
}
}
if err := publishMonoUpgradeTarget(homeDir, filepath.Join(homeDir, agentDir), skillSrc, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err})
continue
}
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirOK})
}
if hasSpecificRoot && len(result.Succeeded()) > 0 {
genericBase := filepath.Join(homeDir, ".agents", "skills")
if err := retireGenericSkillRoot(homeDir, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirFailed, Err: err})
} else {
result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirSkipped})
}
}
// Fallback: if nothing succeeded, force the primary location. The multi
// leftovers under the primary base are the usual reason the primary
// install failed, so clean them first — failing loud like the multi
// fallback — instead of letting mono and multi co-exist marked OK.
if len(result.Succeeded()) == 0 && !hasSpecificRoot {
destBase := filepath.Join(homeDir, ".agents", "skills")
dest := filepath.Join(destBase, "dws")
if err := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); err != nil {
return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err)
}
// Replace the earlier failed entry for this dir (if any) or append a new one
replaced := false
for idx, r := range result.Results {
if r.Dir == dest {
result.Results[idx] = SkillDirResult{Dir: dest, Status: SkillDirOK}
replaced = true
break
}
}
if !replaced {
result.Results = append(result.Results, SkillDirResult{Dir: dest, Status: SkillDirOK})
}
}
// Best-effort: refresh the user-level mono cache so that
// `dws skill setup --mode mono` fallbacks stay on the upgraded version
@@ -633,61 +980,62 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk
result := &SkillUpgradeResult{}
managedNames := readManagedSkillNames(homeDir)
hasSpecificRoot := hasDetectedSpecificSkillRoot(homeDir)
canonicalBase := filepath.Join(homeDir, ".agents", "skills")
if err := publishMultiUpgradeTarget(homeDir, canonicalBase, multiRoot, skills, skillSet, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirFailed, Err: err})
// Canonical is the mandatory store (see the mono branch): fail loud even
// on universal-only machines instead of reporting success with nothing
// installed.
return result, fmt.Errorf("canonical Skill 安装失败: %w", err)
}
result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirOK})
for _, agentDir := range knownSkillDirs {
destBase := filepath.Join(homeDir, agentDir)
if isBlacklisted(agentDir) {
for _, root := range configuredSkillRoots(homeDir) {
if root.canonical {
continue
}
destBase := root.base
if isBlacklisted(root.label) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted})
continue
}
if isGenericSkillRoot(agentDir) && hasSpecificRoot {
if !skillRootDetectedBase(root) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
continue
}
if !isGenericSkillRoot(agentDir) {
parentGate := filepath.Dir(destBase)
if _, err := upgradeStat(parentGate); os.IsNotExist(err) {
if samePhysicalSkillRoot(destBase, canonicalBase) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
continue
}
if root.universal {
if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil {
// Nothing is installed below a universal root, so a stale copy that
// resists retirement is a warning, not an install failure.
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirRetireWarning, Err: err})
} else {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
}
continue
}
victims, victimErr := multiUpgradeVictims(destBase, skillSet, skills, managedNames)
if victimErr != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: victimErr})
continue
}
if err := publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase, skills, victims); err != nil {
// An uncertain destination may belong to a concurrent writer;
// the copy fallback must not displace it (see the mono branch).
if errors.Is(err, ErrSkillPathPublicationUncertain) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err})
continue
}
if copyErr := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); copyErr != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: errors.Join(err, copyErr)})
continue
}
}
if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err})
continue
}
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK})
}
if hasSpecificRoot && len(result.Succeeded()) > 0 {
genericBase := filepath.Join(homeDir, ".agents", "skills")
if err := retireGenericSkillRoot(homeDir, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirFailed, Err: err})
} else {
result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirSkipped})
}
}
// Fallback: if nothing succeeded, force the primary location
if len(result.Succeeded()) == 0 && !hasSpecificRoot {
destBase := filepath.Join(homeDir, ".agents", "skills")
if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil {
return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err)
}
// Replace the earlier failed entry for this dir (if any) or append a new one
replaced := false
for idx, r := range result.Results {
if r.Dir == destBase {
result.Results[idx] = SkillDirResult{Dir: destBase, Status: SkillDirOK}
replaced = true
break
}
}
if !replaced {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK})
}
}
// Best-effort: refresh the user-level caches so that `dws skill setup`
// fallbacks stay on the upgraded version. The release zip ships both
@@ -762,23 +1110,6 @@ func skillTreeHasRoot(dir string) bool {
return err == nil && !info.IsDir()
}
// cleanupMultiLeftovers backs up + removes every proven DWS-managed
// multi-mode skill directory inside one agent home before mono is installed.
// A missing base directory simply means no leftovers; any other read failure
// is reported so mono never silently co-exists with multi. Removal is
// reversible: each leftover is preserved under ~/.dws/skill-backups/ and a
// backup failure aborts the removal for that home.
func cleanupMultiLeftovers(homeDir, baseDir string) error {
victims, err := managedMultiSkillVictims(baseDir, readManagedSkillNames(homeDir))
if err != nil {
return err
}
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("备份并清理 multi 残留失败: %w", err)
}
return nil
}
func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]string, error) {
entries, err := upgradeReadDir(baseDir)
if err != nil {
@@ -789,7 +1120,7 @@ func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]str
}
victims := make([]string, 0)
for _, e := range entries {
if !e.IsDir() || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) {
if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) {
continue
}
victims = append(victims, filepath.Join(baseDir, e.Name()))
@@ -797,24 +1128,6 @@ func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]str
return victims, nil
}
// cleanupOppositeModeLeftovers backs up + removes, inside one agent home, the
// legacy mono directory (dws/) and every proven DWS-managed multi skill
// directory that is not part of the new bundle. A dingtalk-* prefix alone is
// not proof of ownership because market/user skills may use the same prefix.
// Removal is reversible: each
// directory is preserved under ~/.dws/skill-backups/ and a backup failure
// aborts the removal for that home.
func cleanupOppositeModeLeftovers(homeDir, destBase string, skillSet map[string]bool) error {
victims, err := oppositeModeSkillVictims(destBase, skillSet, readManagedSkillNames(homeDir))
if err != nil {
return err
}
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("备份并清理对面模式残留失败: %w", err)
}
return nil
}
func oppositeModeSkillVictims(destBase string, skillSet map[string]bool, managed ...map[string]bool) ([]string, error) {
victims := []string{filepath.Join(destBase, "dws")}
entries, err := upgradeReadDir(destBase)
@@ -825,7 +1138,7 @@ func oppositeModeSkillVictims(destBase string, skillSet map[string]bool, managed
return nil, fmt.Errorf("读取技能目录失败 %s: %w", destBase, err)
}
for _, e := range entries {
if !e.IsDir() || skillSet[e.Name()] || !isManagedMultiSkillDir(filepath.Join(destBase, e.Name()), managed...) {
if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || skillSet[e.Name()] || !isManagedMultiSkillDir(filepath.Join(destBase, e.Name()), managed...) {
continue
}
victims = append(victims, filepath.Join(destBase, e.Name()))
+231 -238
View File
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
@@ -22,6 +23,16 @@ func withFakeHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home, nil })
// Host-installed macOS apps must not make a fake HOME appear to have extra
// detected Agents. Tests that exercise application-based detection can
// override this seam explicitly.
testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) {
applications := filepath.Join(string(filepath.Separator), "Applications")
if path == filepath.Join(applications, "ZCode.app") || path == filepath.Join(applications, "MiniMax Code.app") {
return nil, os.ErrNotExist
}
return os.Stat(path)
})
return home
}
@@ -124,8 +135,7 @@ func TestCrossPlatformCoverageBundleSkillNamesLayouts(t *testing.T) {
func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
home := withFakeHome(t)
// A concrete Agent root wins over the generic .agents fallback; .claude
// installs and .cursor is skipped.
// .agents is canonical; detected Claude receives links and Cursor is skipped.
agentsBase := filepath.Join(home, ".agents", "skills")
claudeBase := filepath.Join(home, ".claude", "skills")
for _, base := range []string{agentsBase, claudeBase} {
@@ -193,17 +203,19 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
// Succeeded entries report the agent home base in multi mode.
succeeded := result.Succeeded()
if len(succeeded) != 1 {
t.Fatalf("Succeeded() len = %d, want 1 (%v)", len(succeeded), result.Results)
if len(succeeded) != 2 {
t.Fatalf("Succeeded() len = %d, want 2 (%v)", len(succeeded), result.Results)
}
wantDirs := map[string]bool{claudeBase: true}
wantDirs := map[string]bool{agentsBase: true, claudeBase: true}
for _, d := range succeeded {
if !wantDirs[d.Dir] {
t.Errorf("unexpected succeeded dir %q", d.Dir)
}
}
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
t.Fatalf("generic mono duplicate still visible: %v", err)
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
t.Fatalf("canonical Skill missing: %v", err)
}
}
// Multi cache refreshed under the fake home.
@@ -216,7 +228,7 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
}
}
func TestCrossPlatformCoverageUpgradeUsesAgentSpecificRootWithoutGenericDuplicate(t *testing.T) {
func TestCrossPlatformCoverageUpgradeUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".codex/skills"})
@@ -241,12 +253,12 @@ func TestCrossPlatformCoverageUpgradeUsesAgentSpecificRootWithoutGenericDuplicat
t.Fatalf("UpgradeSkillLocationsWithOptions() = %#v, %v", result, err)
}
want := filepath.Join(codexBase, "dingtalk-chat", "SKILL.md")
want := filepath.Join(genericBase, "dingtalk-chat", "SKILL.md")
if _, err := os.Stat(want); err != nil {
t.Fatalf("canonical Codex Skill missing at %s: %v", want, err)
t.Fatalf("canonical Skill missing at %s: %v", want, err)
}
for _, duplicate := range []string{
filepath.Join(genericBase, "dingtalk-chat", "SKILL.md"),
filepath.Join(codexBase, "dingtalk-chat"),
filepath.Join(genericBase, "dws", "multi", "dingtalk-chat", "SKILL.md"),
} {
if _, err := os.Stat(duplicate); !os.IsNotExist(err) {
@@ -502,6 +514,9 @@ func TestUpgradeMonoCleansPreStateOfficialAndPreservesCustom(t *testing.T) {
// mono-only package path).
func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure(t *testing.T) {
home := withFakeHome(t)
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatal(err)
}
agentsBase := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil {
t.Fatal(err)
@@ -526,21 +541,15 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure(
}
result, err := UpgradeSkillLocations(mono)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if failed := result.Failed(); len(failed) != 0 {
t.Fatalf("fallback should replace the failed entry with OK, got failed=%v", failed)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 (%v)", got, result.Results)
if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
t.Fatalf("canonical copy failure = %#v, %v", result, err)
}
data, err := os.ReadFile(filepath.Join(agentsBase, "dws", "SKILL.md"))
if err != nil {
t.Fatalf("mono fallback install missing: %v", err)
}
if string(data) != "# mono" {
t.Errorf("mono content = %q, want refreshed package", data)
if string(data) != "old" {
t.Errorf("mono content = %q, want original preserved", data)
}
}
@@ -574,22 +583,22 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t *
}
result, err := UpgradeSkillLocations(mono)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v (fallback should not run: .claude succeeded)", err)
if err == nil {
t.Fatal("canonical read failure must fail the upgrade")
}
failed := result.Failed()
if len(failed) != 1 {
t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results)
}
wantDir := agentsBase
wantDir := filepath.Join(agentsBase, "dws")
if failed[0].Dir != wantDir || failed[0].Err == nil {
t.Fatalf("failed entry = %#v, want dir %q with non-nil err", failed[0], wantDir)
}
if !strings.Contains(failed[0].Err.Error(), "读取技能目录失败") {
t.Fatalf("failed err should mention the read failure, got %v", failed[0].Err)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 (.claude)", got)
if got := len(result.Succeeded()); got != 0 {
t.Fatalf("Succeeded() len = %d, want 0", got)
}
// Mono must NOT have been laid down next to the unreadable multi state.
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
@@ -603,6 +612,9 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t *
// installing multi next to the stale skills.
func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t *testing.T) {
home := withFakeHome(t)
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatal(err)
}
agentsBase := filepath.Join(home, ".agents", "skills")
staleDir := filepath.Join(agentsBase, "dingtalk-stale")
if err := os.MkdirAll(staleDir, 0o755); err != nil {
@@ -613,10 +625,10 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t
}
useUpgradeManagedNames(t, filepath.Base(staleDir))
origRename := upgradeRename
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
origRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) {
if strings.Contains(src, "dingtalk-stale") {
return errors.New("injected backup failure")
return "", errors.New("injected backup failure")
}
return origRename(src, dst)
})
@@ -628,8 +640,8 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t
if err == nil {
t.Fatal("multi fallback cleanup failure must return an error")
}
if !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("error should mention the fallback cleanup failure, got %v", err)
if !strings.Contains(err.Error(), "canonical Skill 安装失败") {
t.Fatalf("error should mention the canonical cleanup failure, got %v", err)
}
if failed := result.Failed(); len(failed) != 1 {
t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results)
@@ -643,8 +655,17 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t
}
}
// seedSkillBackupMarker stamps a backup root as DWS-owned, the way every
// install surface does before any payload moves in.
func seedSkillBackupMarker(t *testing.T, root string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, skillBackupMarkerName), []byte(skillBackupMarkerContent), 0o644); err != nil {
t.Fatal(err)
}
}
// TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges pins the fail-safe
// contract of the backup helper: non-dir paths are no-ops, a colliding backup
// contract of the backup helper: all lexical paths are preserved, a colliding backup
// target gets a numbered stamp, and any failure (mkdir / rename / unresolvable
// collision) leaves the original directory untouched with an error.
func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
@@ -654,8 +675,8 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
// backup. Inject it through the path seam so this branch is portable to
// Windows, where chmod-based permission failures are not reliable.
statFailurePath := filepath.Join(home, "stat-failure")
origStat := upgradeStat
testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) {
origStat := upgradeLstat
testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) {
if path == statFailurePath {
return nil, errors.New("injected stat failure")
}
@@ -665,16 +686,16 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
t.Fatalf("stat failure = (%q, %v), want wrapped error", got, err)
}
// Regular file: no-op, no backup.
// Regular file: preserve it instead of allowing a later rename to overwrite it.
filePath := filepath.Join(home, "not-a-dir")
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if got, err := backupAndRemoveSkillDir(home, filePath); got != "" || err != nil {
t.Fatalf("regular file = (%q, %v), want no-op", got, err)
if got, err := backupAndRemoveSkillDir(home, filePath); got == "" || err != nil {
t.Fatalf("regular file = (%q, %v), want backup", got, err)
}
if _, err := os.Stat(filePath); err != nil {
t.Fatalf("regular file must survive: %v", err)
if _, err := os.Stat(filePath); !os.IsNotExist(err) {
t.Fatalf("regular file must move into backup: %v", err)
}
// A skill directory outside home falls back to its basename instead of
@@ -722,6 +743,46 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
t.Fatalf("victim must be gone after backup, stat err=%v", err)
}
// A dangling link at the first backup target is still an occupied lexical
// path and must select a numbered target rather than being overwritten.
danglingVictim := filepath.Join(home, ".agents", "skills", "dangling-collision")
if err := os.MkdirAll(danglingVictim, 0o755); err != nil {
t.Fatal(err)
}
danglingTaken := filepath.Join(home, skillBackupSubdir, "20260810-000000", ".agents-skills-dangling-collision")
if err := os.Symlink("missing-backup", danglingTaken); err == nil {
got, err = backupAndRemoveSkillDir(home, danglingVictim)
if err != nil {
t.Fatalf("backup with dangling collision error = %v", err)
}
want = filepath.Join(home, skillBackupSubdir, "20260810-000000-1", ".agents-skills-dangling-collision")
if got != want {
t.Fatalf("dangling collision backup path = %q, want %q", got, want)
}
} else if runtime.GOOS != "windows" {
t.Fatal(err)
}
// An unreadable target probe is not equivalent to a free path.
probeVictim := filepath.Join(home, "probe-victim")
if err := os.MkdirAll(probeVictim, 0o755); err != nil {
t.Fatal(err)
}
probeTarget := filepath.Join(home, skillBackupSubdir, "20260810-000000", "probe-victim")
testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) {
if path == probeTarget {
return nil, errors.New("target probe denied")
}
return os.Lstat(path)
})
if _, err := backupAndRemoveSkillDir(home, probeVictim); err == nil || !strings.Contains(err.Error(), "检查备份目标失败") {
t.Fatalf("target probe error = %v", err)
}
if _, err := os.Stat(probeVictim); err != nil {
t.Fatalf("probe victim must survive target check failure: %v", err)
}
testseam.Swap(t, &upgradeLstat, os.Lstat)
// Unresolvable collision (>1000 numbered stamps taken) fails and keeps dir.
victim2 := filepath.Join(home, "victim2")
if err := os.MkdirAll(victim2, 0o755); err != nil {
@@ -749,7 +810,7 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
t.Fatal(err)
}
testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return errors.New("mkdir denied") })
if _, err := backupAndRemoveSkillDir(home, victim3); err == nil || !strings.Contains(err.Error(), "创建备份目录失败") {
if _, err := backupAndRemoveSkillDir(home, victim3); err == nil || !strings.Contains(err.Error(), "创建备份根目录失败") {
t.Fatalf("mkdir error = %v", err)
}
if _, err := os.Stat(victim3); err != nil {
@@ -762,14 +823,14 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
if err := os.MkdirAll(victim4, 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("rename denied") })
testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", errors.New("rename denied") })
if _, err := backupAndRemoveSkillDir(home, victim4); err == nil || !strings.Contains(err.Error(), "备份技能目录失败") {
t.Fatalf("rename error = %v", err)
}
if _, err := os.Stat(victim4); err != nil {
t.Fatalf("victim4 must survive rename failure: %v", err)
}
testseam.Swap(t, &upgradeRename, os.Rename)
testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace)
}
// TestCrossPlatformCoveragePruneSkillBackupsEdges pins the backup retention:
@@ -779,9 +840,11 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) {
home := t.TempDir()
root := filepath.Join(home, skillBackupSubdir)
for i := 0; i < skillBackupKeep+2; i++ {
if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260810-00000%d", i)), 0o755); err != nil {
stampDir := filepath.Join(root, fmt.Sprintf("20260810-00000%d", i))
if err := os.MkdirAll(stampDir, 0o755); err != nil {
t.Fatal(err)
}
seedSkillBackupMarker(t, stampDir)
}
if err := pruneSkillBackups(home); err != nil {
t.Fatalf("pruneSkillBackups() error = %v", err)
@@ -814,9 +877,11 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) {
// Removal failure is reported as the first error. Seed more than
// skillBackupKeep dirs again so the prune loop actually runs.
for i := 0; i < skillBackupKeep+2; i++ {
if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260811-00000%d", i)), 0o755); err != nil {
stampDir := filepath.Join(root, fmt.Sprintf("20260811-00000%d", i))
if err := os.MkdirAll(stampDir, 0o755); err != nil {
t.Fatal(err)
}
seedSkillBackupMarker(t, stampDir)
}
testseam.Swap(t, &upgradeRemoveAll, func(string) error { return errors.New("remove denied") })
if err := pruneSkillBackups(home); err == nil {
@@ -825,6 +890,86 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) {
testseam.Swap(t, &upgradeRemoveAll, os.RemoveAll)
}
// TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown pins that
// pruneSkillBackups only retires directories whose names match the DWS backup
// stamp format. Any foreign directory in the backup root must survive even when
// more than skillBackupKeep stamped backups exist.
func TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown(t *testing.T) {
home := t.TempDir()
root := filepath.Join(home, skillBackupSubdir)
for i := 0; i < skillBackupKeep+2; i++ {
stampDir := filepath.Join(root, fmt.Sprintf("20260910-00000%d", i))
if err := os.MkdirAll(stampDir, 0o755); err != nil {
t.Fatal(err)
}
seedSkillBackupMarker(t, stampDir)
}
// A collision-suffixed stamp is still a valid DWS backup and is eligible.
suffixed := filepath.Join(root, "20260910-000000-7")
if err := os.MkdirAll(suffixed, 0o755); err != nil {
t.Fatal(err)
}
seedSkillBackupMarker(t, suffixed)
// Stamp-shaped names without a verifying marker are foreign data too:
// the name format alone proves nothing.
unmarked := filepath.Join(root, "20260901-000000")
if err := os.MkdirAll(unmarked, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(unmarked, "user-data.txt"), []byte("must survive"), 0o644); err != nil {
t.Fatal(err)
}
wrongMarker := filepath.Join(root, "20260902-000000")
if err := os.MkdirAll(wrongMarker, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(wrongMarker, skillBackupMarkerName), []byte("not dws"), 0o644); err != nil {
t.Fatal(err)
}
unknown := []string{
"user-personal-backup",
"20260910-000000-abc", // stamp-shaped but non-numeric suffix
"20260910-00000", // too short
"notes",
"20260901-000000", // stamp-shaped but unmarked
"20260902-000000", // stamp-shaped but wrong marker content
}
for _, name := range unknown {
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
t.Fatal(err)
}
}
if err := pruneSkillBackups(home); err != nil {
t.Fatalf("pruneSkillBackups() error = %v", err)
}
for _, name := range unknown {
if _, err := os.Stat(filepath.Join(root, name)); err != nil {
t.Errorf("unknown backup entry %s must be preserved, stat err=%v", name, err)
}
}
// Only marker-proven backups beyond skillBackupKeep are pruned; foreign
// entries (including unmarked stamp-shaped ones) are excluded from the
// count, so the proven total stays at skillBackupKeep.
entries, err := os.ReadDir(root)
if err != nil {
t.Fatal(err)
}
proven := 0
for _, e := range entries {
if isSkillBackupStamp(e.Name()) && skillBackupMarkerValid(filepath.Join(root, e.Name())) {
proven++
}
}
if proven != skillBackupKeep {
t.Errorf("marker-proven backups after prune = %d, want %d", proven, skillBackupKeep)
}
if data, err := os.ReadFile(filepath.Join(unmarked, "user-data.txt")); err != nil || string(data) != "must survive" {
t.Errorf("unmarked stamp-shaped data must survive, got %q, %v", data, err)
}
}
// TestCrossPlatformCoverageResolveSkillSrcLayouts pins every fallback branch of
// the mono-source resolver and the multi-bundle child lookup.
func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) {
@@ -888,88 +1033,27 @@ func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) {
// TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges pins the mono
// path's fail-loud backup semantics and every primary-fallback outcome.
func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) {
originalDirs := append([]string(nil), knownSkillDirs...)
t.Cleanup(func() { knownSkillDirs = originalDirs })
mono := t.TempDir()
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
t.Fatal(err)
}
// Per-home backup failure marks that home failed; another home still wins.
home := withFakeHome(t)
knownSkillDirs = []string{".agents/skills", ".claude/skills"}
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
os.MkdirAll(filepath.Join(home, ".agents", "skills", "dws"), 0o755)
os.WriteFile(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md"), []byte("old"), 0o644)
os.MkdirAll(filepath.Join(home, ".claude"), 0o755)
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) {
if strings.Contains(src, ".agents") {
return errors.New("backup denied")
return "", errors.New("backup denied")
}
return os.Rename(src, dst)
return renameSkillPathNoReplace(src, dst)
})
result, err := UpgradeSkillLocations(mono)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil {
t.Fatalf("failed = %v, want exactly 1 backup-failed home", failed)
if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
t.Fatalf("canonical backup failure = %#v, %v", result, err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
t.Fatalf("failed home must keep its original dws/: %v", err)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 (.claude)", got)
}
testseam.Swap(t, &upgradeRename, os.Rename)
// Fallback cleanup failure: only a blacklisted home in the main loop, then
// the primary cleanup hits a stale dir whose backup fails.
home2 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil })
knownSkillDirs = []string{".real/skills"}
stale := filepath.Join(home2, ".agents", "skills", "dingtalk-stale")
os.MkdirAll(stale, 0o755)
useUpgradeManagedNames(t, filepath.Base(stale))
testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("backup denied") })
if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("fallback cleanup error = %v", err)
}
if _, err := os.Stat(stale); err != nil {
t.Fatalf("stale dir must survive failed cleanup: %v", err)
}
// Fallback backup failure of the primary dws/ itself.
home3 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil })
os.MkdirAll(filepath.Join(home3, ".agents", "skills", "dws"), 0o755)
if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("fallback backup error = %v", err)
}
testseam.Swap(t, &upgradeRename, os.Rename)
// Fallback copy failure: everything fails loud.
home4 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home4, nil })
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("copy denied") })
if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("fallback copy error = %v", err)
}
testseam.Swap(t, &upgradeCopyDir, copyDir)
// Fallback append (no prior entry for the primary dir): a blacklisted-only
// main loop leaves no primary entry, so the fallback appends a fresh OK.
home5 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home5, nil })
result, err = UpgradeSkillLocations(mono)
if err != nil {
t.Fatalf("fallback append error = %v", err)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 appended primary", got)
}
if _, err := os.Stat(filepath.Join(home5, ".agents", "skills", "dws", "SKILL.md")); err != nil {
t.Fatalf("fallback append install missing: %v", err)
t.Fatalf("failed canonical home must keep its original dws/: %v", err)
}
}
@@ -977,15 +1061,10 @@ func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) {
// path's blacklisted branch, per-skill backup/copy failures, and the primary
// fallback outcomes (cleanup OK + backup/copy failure, fresh append).
func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) {
originalDirs := append([]string(nil), knownSkillDirs...)
t.Cleanup(func() { knownSkillDirs = originalDirs })
extract := t.TempDir()
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat")
// Blacklisted entry is reported, non-blacklisted installs.
home := withFakeHome(t)
knownSkillDirs = []string{".real/skills", ".agents/skills"}
testseam.Swap(t, &knownSkillDirs, []string{".real/skills", ".agents/skills"})
os.MkdirAll(filepath.Join(home, ".agents"), 0o755)
result, err := UpgradeSkillLocations(multiRoot)
if err != nil {
@@ -1003,119 +1082,33 @@ func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) {
if _, err := os.Stat(filepath.Join(home, ".real")); !os.IsNotExist(err) {
t.Fatalf("blacklisted home must never be touched, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("canonical install missing: %v", err)
}
}
// A detected concrete Agent root wins over .agents. A failure while retiring
// the old generic copy is surfaced after the concrete root succeeds.
home2 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil })
knownSkillDirs = []string{".agents/skills", ".claude/skills"}
os.MkdirAll(filepath.Join(home2, ".agents", "skills", "dingtalk-chat"), 0o755)
os.MkdirAll(filepath.Join(home2, ".claude"), 0o755)
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
if strings.Contains(src, ".agents") {
return errors.New("backup denied")
}
return os.Rename(src, dst)
})
result, err = UpgradeSkillLocations(multiRoot)
// The production wrappers that used to bundle these two steps were removed as
// dead code; the test keeps exercising the live building blocks they composed.
func cleanupMultiLeftoversForTest(homeDir, baseDir string) error {
victims, err := managedMultiSkillVictims(baseDir, readManagedSkillNames(homeDir))
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
return err
}
if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil {
t.Fatalf("failed = %v, want 1 backup-failed home", failed)
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("备份并清理 multi 残留失败: %w", err)
}
if _, err := os.Stat(filepath.Join(home2, ".agents", "skills", "dingtalk-chat")); err != nil {
t.Fatalf("failed home must keep its original skill: %v", err)
}
testseam.Swap(t, &upgradeRename, os.Rename)
return nil
}
// Per-skill copy failure on the concrete home fails that home; with no
// successful concrete target, the generic copy is left untouched.
home3 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil })
os.MkdirAll(filepath.Join(home3, ".claude"), 0o755)
testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error {
if strings.Contains(dst, ".claude") {
return errors.New("copy denied")
}
return copyDir(src, dst)
})
result, err = UpgradeSkillLocations(multiRoot)
func cleanupOppositeModeLeftoversForTest(homeDir, destBase string, skillSet map[string]bool) error {
victims, err := oppositeModeSkillVictims(destBase, skillSet, readManagedSkillNames(homeDir))
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
return err
}
if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil {
t.Fatalf("failed = %v, want 1 copy-failed home", failed)
}
testseam.Swap(t, &upgradeCopyDir, copyDir)
// Fallback backup failure: the same-name bundle skill refresh is what
// fails (cleanup succeeds, the per-skill backup does not).
home4 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home4, nil })
knownSkillDirs = []string{".real/skills"}
os.MkdirAll(filepath.Join(home4, ".agents", "skills", "dingtalk-chat"), 0o755)
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
if strings.Contains(src, "dingtalk-chat") {
return errors.New("backup denied")
}
return os.Rename(src, dst)
})
if _, err := UpgradeSkillLocations(multiRoot); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("fallback backup error = %v", err)
}
testseam.Swap(t, &upgradeRename, os.Rename)
// Fallback copy failure.
home5 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home5, nil })
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("copy denied") })
if _, err := UpgradeSkillLocations(multiRoot); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
t.Fatalf("fallback copy error = %v", err)
}
testseam.Swap(t, &upgradeCopyDir, copyDir)
// Fallback append: blacklisted-only main loop → fresh OK entry appended.
home6 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home6, nil })
result, err = UpgradeSkillLocations(multiRoot)
if err != nil {
t.Fatalf("fallback append error = %v", err)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 appended primary (%v)", got, result.Results)
}
if _, err := os.Stat(filepath.Join(home6, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("fallback append install missing: %v", err)
}
// Fallback replace: the main loop fails the primary home on a transient
// copy error, the fallback retry succeeds and replaces the failed entry.
home7 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home7, nil })
knownSkillDirs = []string{".agents/skills"}
origCopy := upgradeCopyDir
attempts := 0
testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error {
attempts++
if attempts == 1 {
return errors.New("transient copy failure")
}
return origCopy(src, dst)
})
result, err = UpgradeSkillLocations(multiRoot)
if err != nil {
t.Fatalf("fallback replace error = %v", err)
}
if got := len(result.Succeeded()); got != 1 {
t.Fatalf("Succeeded() len = %d, want 1 replaced primary (%v)", got, result.Results)
}
if failed := result.Failed(); len(failed) != 0 {
t.Fatalf("failed entry must be replaced by OK, got %v", failed)
}
if _, err := os.Stat(filepath.Join(home7, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("fallback replace install missing: %v", err)
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("备份并清理对面模式残留失败: %w", err)
}
return nil
}
// TestCrossPlatformCoverageCleanupLeftoversEdges pins the cleanup helpers:
@@ -1127,40 +1120,40 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) {
// Read failure (non-ENOENT) surfaces from both cleanups.
testseam.Swap(t, &upgradeReadDir, func(string) ([]os.DirEntry, error) { return nil, errors.New("read denied") })
if err := cleanupMultiLeftovers(home, base); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") {
t.Fatalf("cleanupMultiLeftovers read error = %v", err)
if err := cleanupMultiLeftoversForTest(home, base); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") {
t.Fatalf("cleanupMultiLeftoversForTest read error = %v", err)
}
if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") {
t.Fatalf("cleanupOppositeModeLeftovers read error = %v", err)
if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") {
t.Fatalf("cleanupOppositeModeLeftoversForTest read error = %v", err)
}
testseam.Swap(t, &upgradeReadDir, os.ReadDir)
// Backup failure of a multi leftover aborts cleanupMultiLeftovers.
os.MkdirAll(filepath.Join(base, "dingtalk-stale"), 0o755)
useUpgradeManagedNames(t, "dingtalk-stale")
testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("backup denied") })
if err := cleanupMultiLeftovers(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") {
testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", errors.New("backup denied") })
if err := cleanupMultiLeftoversForTest(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") {
t.Fatalf("cleanupMultiLeftovers backup error = %v", err)
}
// Backup failure of the mono leftover aborts the opposite-mode cleanup.
os.MkdirAll(filepath.Join(base, "dws"), 0o755)
if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") {
if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") {
t.Fatalf("opposite cleanup mono backup error = %v", err)
}
testseam.Swap(t, &upgradeRename, os.Rename)
testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace)
// Backup failure of a stale (non-mono) skill aborts with its own message.
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) {
if strings.Contains(src, "dingtalk-stale") {
return errors.New("backup denied")
return "", errors.New("backup denied")
}
return os.Rename(src, dst)
return renameSkillPathNoReplace(src, dst)
})
if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") {
if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") {
t.Fatalf("opposite cleanup stale backup error = %v", err)
}
testseam.Swap(t, &upgradeRename, os.Rename)
testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace)
// Success matrix: mono leftover + stale skill removed into backups, bundle
// skill and regular file preserved.
@@ -1168,7 +1161,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) {
skillSet := map[string]bool{"dingtalk-keep": true}
os.MkdirAll(filepath.Join(base, "dingtalk-keep"), 0o755)
os.WriteFile(filepath.Join(base, "regular-file"), []byte("x"), 0o644)
if err := cleanupOppositeModeLeftovers(home, base, skillSet); err != nil {
if err := cleanupOppositeModeLeftoversForTest(home, base, skillSet); err != nil {
t.Fatalf("cleanupOppositeModeLeftovers() error = %v", err)
}
for _, gone := range []string{"dws", "dingtalk-stale"} {
@@ -1188,7 +1181,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) {
}
// cleanupMultiLeftovers on a missing base is a no-op.
if err := cleanupMultiLeftovers(home, filepath.Join(home, "missing")); err != nil {
if err := cleanupMultiLeftoversForTest(home, filepath.Join(home, "missing")); err != nil {
t.Fatalf("missing base must be a no-op, got %v", err)
}
}
@@ -0,0 +1,113 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// TestCrossPlatformCoverageSkillBackupOwnership pins the backup ownership
// contract: a stamp-shaped directory name alone proves nothing, so a backup
// root this transaction writes into is either created fresh (mkdir claim,
// marker stamped before any payload moves in) or an existing root whose
// marker already verifies. A same-stamp foreign directory is never adopted —
// writing the marker or the payload into it would turn its contents into
// prunable DWS backups.
func TestCrossPlatformCoverageSkillBackupOwnership(t *testing.T) {
t.Run("unproven stamp root is never adopted", func(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &upgradeBackupStamp, func() string { return "20260101-120000" })
foreign := filepath.Join(home, skillBackupSubdir, "20260101-120000")
if err := os.MkdirAll(foreign, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(foreign, "user-data.txt"), []byte("must survive"), 0o644); err != nil {
t.Fatal(err)
}
victim := filepath.Join(home, "skills", "dws")
seedUpgradeSkill(t, victim, "payload", false)
backup, err := backupAndRemoveSkillDir(home, victim)
if err != nil {
t.Fatal(err)
}
if want := filepath.Join(home, skillBackupSubdir, "20260101-120000-1", "skills-dws"); backup != want {
t.Fatalf("backup = %s, want suffixed %s", backup, want)
}
assertUpgradeSkillContent(t, backup, "payload")
// The foreign root keeps its data and never receives our marker.
if data, readErr := os.ReadFile(filepath.Join(foreign, "user-data.txt")); readErr != nil || string(data) != "must survive" {
t.Fatalf("foreign data = %q, %v", data, readErr)
}
if _, statErr := os.Lstat(filepath.Join(foreign, skillBackupMarkerName)); !os.IsNotExist(statErr) {
t.Fatalf("foreign root must not receive the ownership marker, stat err=%v", statErr)
}
if !skillBackupMarkerValid(filepath.Dir(backup)) {
t.Fatal("the freshly claimed root must carry the ownership marker")
}
})
t.Run("same-stamp payloads reuse the proven root", func(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &upgradeBackupStamp, func() string { return "20260101-120000" })
first := filepath.Join(home, "skills", "dws")
second := filepath.Join(home, "agents", "claude", "dws")
seedUpgradeSkill(t, first, "first", false)
seedUpgradeSkill(t, second, "second", false)
backupA, err := backupAndRemoveSkillDir(home, first)
if err != nil {
t.Fatal(err)
}
backupB, err := backupAndRemoveSkillDir(home, second)
if err != nil {
t.Fatal(err)
}
if filepath.Dir(backupA) != filepath.Dir(backupB) {
t.Fatalf("same-stamp backups split roots: %s vs %s", backupA, backupB)
}
root := filepath.Dir(backupA)
if !skillBackupMarkerValid(root) {
t.Fatal("reused root must keep a verifying marker")
}
assertUpgradeSkillContent(t, backupA, "first")
assertUpgradeSkillContent(t, backupB, "second")
})
t.Run("marker write failure refuses the backup and cleans the empty root", func(t *testing.T) {
home := t.TempDir()
victim := filepath.Join(home, "skills", "dws")
seedUpgradeSkill(t, victim, "payload", false)
testseam.Swap(t, &skillBackupWriteMarker, func(string) error { return errors.New("marker denied") })
_, err := backupAndRemoveSkillDir(home, victim)
if err == nil || !strings.Contains(err.Error(), "备份所有权标记失败") {
t.Fatalf("marker write failure = %v", err)
}
assertUpgradeSkillContent(t, victim, "payload")
entries, readErr := os.ReadDir(filepath.Join(home, skillBackupSubdir))
if readErr != nil {
t.Fatal(readErr)
}
if len(entries) != 0 {
t.Fatalf("empty unowned stamp root must be cleaned, left %v", entries)
}
})
t.Run("backup root creation failure surfaces", func(t *testing.T) {
home := t.TempDir()
victim := filepath.Join(home, "skills", "dws")
seedUpgradeSkill(t, victim, "payload", false)
testseam.Swap(t, &skillPathMkdir, func(string, os.FileMode) error { return os.ErrPermission })
_, err := backupAndRemoveSkillDir(home, victim)
if !errors.Is(err, os.ErrPermission) {
t.Fatalf("root creation failure = %v", err)
}
assertUpgradeSkillContent(t, victim, "payload")
})
}
+363
View File
@@ -0,0 +1,363 @@
package upgrade
import (
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
)
var (
skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) }
skillPathRenameNoReplace = renameSkillPathNoReplace
skillPathCopy = copySkillPathLexically
skillPathVerify = verifySkillPathCopy
skillPathRemoveAll = os.RemoveAll
skillPathMkdirAll = os.MkdirAll
skillPathMkdirTemp = os.MkdirTemp
skillPathChmod = os.Chmod
skillPathLstat = os.Lstat
skillPathMkdir = os.Mkdir
skillPathLink = os.Link
skillPathRemove = os.Remove
skillPathReadDir = os.ReadDir
skillPathFileIdentity = skillPathFileIdentityImpl
skillPathFileIncarnation = skillPathFileIncarnationImpl
skillPathSameFileIdentity = skillPathSameFileIdentityImpl
skillPathReadlink = os.Readlink
skillPathSymlink = os.Symlink
skillPathOpen = os.Open
skillPathOpenFile = os.OpenFile
skillPathCopyBytes = io.Copy
skillPathSync = func(file *os.File) error { return file.Sync() }
skillPathWalkDir = filepath.WalkDir
)
// moveSkillPathRecoverably moves one managed Skill path without weakening the
// backup contract when source and destination are on different filesystems.
// The cross-device path publishes a fully copied and verified sibling staging
// path before removing the source. Any failure before source removal leaves the
// source intact; a removal failure deliberately leaves both verified copies.
func moveSkillPathRecoverably(src, dst string) (err error) {
if _, statErr := skillPathLstat(dst); statErr == nil {
return fmt.Errorf("目标已存在: %s", dst)
} else if !os.IsNotExist(statErr) {
return fmt.Errorf("检查移动目标失败 %s: %w", dst, statErr)
}
if err := skillPathMkdirAll(filepath.Dir(dst), dirPermShared); err != nil {
return fmt.Errorf("创建移动目标目录失败 %s: %w", filepath.Dir(dst), err)
}
// moveSkillPathRecoverably is not a publication path; ignore any
// claim identity returned by the rename — no ownership check follows
// on this destination.
if _, renameErr := skillPathRenameNoReplace(src, dst); renameErr == nil {
return nil
} else if !isCrossDeviceError(renameErr) {
return fmt.Errorf("移动 Skill 路径失败 %s -> %s: %w", src, dst, renameErr)
}
stageRoot, err := skillPathMkdirTemp(filepath.Dir(dst), "."+filepath.Base(dst)+".cross-device-")
if err != nil {
return fmt.Errorf("创建跨设备 Skill staging 失败 %s: %w", dst, err)
}
stage := filepath.Join(stageRoot, "payload")
stageCleaned := false
defer func() {
if stageCleaned {
return
}
makeSkillPathTreeWritable(stageRoot)
if cleanupErr := skillPathRemoveAll(stageRoot); cleanupErr != nil {
err = errors.Join(err, fmt.Errorf("清理跨设备 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
}
}()
if err := skillPathCopy(src, stage); err != nil {
return fmt.Errorf("复制跨设备 Skill staging 失败 %s: %w", stage, err)
}
if err := skillPathVerify(src, stage); err != nil {
return fmt.Errorf("校验跨设备 Skill staging 失败 %s: %w", stage, err)
}
stageInfo, err := skillPathLstat(stage)
if err != nil {
return fmt.Errorf("检查跨设备 Skill staging 失败 %s: %w", stage, err)
}
if stageInfo.IsDir() {
// Darwin refuses to rename a read-only directory even when both parent
// directories are writable. The exact source mode was verified above;
// temporarily add owner access for publication, then restore it before
// the final verification and before the source is removed.
if err := skillPathChmod(stage, stageInfo.Mode().Perm()|0o700); err != nil {
return fmt.Errorf("准备跨设备 Skill staging 发布失败 %s: %w", stage, err)
}
}
// Cross-device staging publishes via a self-owned staging directory,
// so the claim identity is not consulted; discard it.
if _, err := skillPathRenameNoReplace(stage, dst); err != nil {
return fmt.Errorf("发布跨设备 Skill 备份失败 %s: %w", dst, err)
}
if stageInfo.IsDir() {
if err := skillPathChmod(dst, stageInfo.Mode().Perm()); err != nil {
return fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", dst, err)
}
}
if err := skillPathVerify(src, dst); err != nil {
return fmt.Errorf("校验已发布 Skill 备份失败 %s: %w", dst, err)
}
if err := skillPathRemoveAll(stageRoot); err != nil {
return fmt.Errorf("清理已发布 Skill staging 失败 %s: %w", stageRoot, err)
}
stageCleaned = true
if err := removePublishedSkillSource(src); err != nil {
return fmt.Errorf("Skill 目标已发布但源路径删除失败(源 %s 与目标 %s 均保留): %w", src, dst, err)
}
return nil
}
type skillPathDirMode struct {
path string
mode os.FileMode
}
func removePublishedSkillSource(src string) error {
dirModes, err := prepareSkillPathTreeRemoval(src)
if err != nil {
return err
}
removeErr := skillPathRemoveAll(src)
if removeErr == nil {
if _, statErr := skillPathLstat(src); os.IsNotExist(statErr) {
return nil
} else if statErr == nil {
removeErr = errors.New("源路径仍存在")
} else {
removeErr = fmt.Errorf("无法确认源路径已删除: %w", statErr)
}
}
return errors.Join(removeErr, restoreSkillPathDirModes(dirModes))
}
func prepareSkillPathTreeRemoval(root string) ([]skillPathDirMode, error) {
var modes []skillPathDirMode
err := skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if !entry.IsDir() {
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
mode := info.Mode().Perm()
if mode&0o700 == 0o700 {
return nil
}
if err := skillPathChmod(path, mode|0o700); err != nil {
return err
}
modes = append(modes, skillPathDirMode{path: path, mode: mode})
return nil
})
if err != nil {
return nil, errors.Join(err, restoreSkillPathDirModes(modes))
}
return modes, nil
}
func restoreSkillPathDirModes(modes []skillPathDirMode) error {
var restoreErr error
for i := len(modes) - 1; i >= 0; i-- {
item := modes[i]
if _, err := skillPathLstat(item.path); os.IsNotExist(err) {
continue
} else if err != nil {
restoreErr = errors.Join(restoreErr, err)
continue
}
if err := skillPathChmod(item.path, item.mode); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复源目录权限失败 %s: %w", item.path, err))
}
}
return restoreErr
}
func makeSkillPathTreeWritable(root string) {
// Best effort only: RemoveAll below remains the source of truth and its
// error is returned. This preparation lets it traverse read-only staging
// directories left by a copy or verification failure.
_ = skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr == nil && entry.IsDir() {
_ = skillPathChmod(path, 0o700)
}
return nil
})
}
// RestoreSkillPath restores a previously recorded backup using the same
// rename-first, verified cross-device fallback as backup publication.
func RestoreSkillPath(backup, original string) error {
return moveSkillPathRecoverably(backup, original)
}
func copySkillPathLexically(src, dst string) error {
info, err := skillPathLstat(src)
if err != nil {
return err
}
switch {
case info.IsDir():
// Staging must remain writable while children are copied. Restore the
// source mode only after the directory is complete so read-only Skill
// directories (for example 0555) can still be migrated lexically.
if err := skillPathMkdir(dst, 0o700); err != nil {
return err
}
entries, err := skillPathReadDir(src)
if err != nil {
return err
}
for _, entry := range entries {
if err := copySkillPathLexically(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil {
return err
}
}
return os.Chmod(dst, info.Mode().Perm())
case info.Mode()&os.ModeSymlink != 0:
target, err := skillPathReadlink(src)
if err != nil {
return err
}
return skillPathSymlink(target, dst)
case info.Mode().IsRegular():
return copyRegularSkillFile(src, dst, info.Mode().Perm())
default:
return fmt.Errorf("不支持复制特殊 Skill 路径 %s(mode=%s)", src, info.Mode())
}
}
func copyRegularSkillFile(src, dst string, mode os.FileMode) (err error) {
in, err := skillPathOpen(src)
if err != nil {
return err
}
defer func() { err = errors.Join(err, in.Close()) }()
out, err := skillPathOpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
defer func() { err = errors.Join(err, out.Close()) }()
if _, err := skillPathCopyBytes(out, in); err != nil {
return err
}
if err := skillPathSync(out); err != nil {
return err
}
return os.Chmod(dst, mode)
}
func verifySkillPathCopy(src, dst string) error {
srcInfo, err := skillPathLstat(src)
if err != nil {
return err
}
dstInfo, err := skillPathLstat(dst)
if err != nil {
return err
}
if srcInfo.Mode()&os.ModeType != dstInfo.Mode()&os.ModeType {
return fmt.Errorf("路径类型不一致: %s (%s) != %s (%s)", src, srcInfo.Mode(), dst, dstInfo.Mode())
}
switch {
case srcInfo.IsDir():
if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() {
return fmt.Errorf("目录权限不一致: %s (%s) != %s (%s)", src, srcInfo.Mode().Perm(), dst, dstInfo.Mode().Perm())
}
srcNames, err := skillPathEntryNames(src)
if err != nil {
return err
}
dstNames, err := skillPathEntryNames(dst)
if err != nil {
return err
}
if len(srcNames) != len(dstNames) {
return fmt.Errorf("目录项数量不一致: %s (%d) != %s (%d)", src, len(srcNames), dst, len(dstNames))
}
for i, name := range srcNames {
if name != dstNames[i] {
return fmt.Errorf("目录项不一致: %s != %s", name, dstNames[i])
}
if err := verifySkillPathCopy(filepath.Join(src, name), filepath.Join(dst, name)); err != nil {
return err
}
}
return nil
case srcInfo.Mode()&os.ModeSymlink != 0:
srcTarget, err := skillPathReadlink(src)
if err != nil {
return err
}
dstTarget, err := skillPathReadlink(dst)
if err != nil {
return err
}
if srcTarget != dstTarget {
return fmt.Errorf("符号链接目标不一致: %q != %q", srcTarget, dstTarget)
}
return nil
case srcInfo.Mode().IsRegular():
if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() {
return fmt.Errorf("文件权限不一致: %s (%s) != %s (%s)", src, srcInfo.Mode().Perm(), dst, dstInfo.Mode().Perm())
}
if srcInfo.Size() != dstInfo.Size() {
return fmt.Errorf("文件大小不一致: %s (%d) != %s (%d)", src, srcInfo.Size(), dst, dstInfo.Size())
}
srcDigest, err := skillPathFileDigest(src)
if err != nil {
return err
}
dstDigest, err := skillPathFileDigest(dst)
if err != nil {
return err
}
if srcDigest != dstDigest {
return fmt.Errorf("文件内容摘要不一致: %s != %s", src, dst)
}
return nil
default:
return fmt.Errorf("不支持校验特殊 Skill 路径 %s(mode=%s)", src, srcInfo.Mode())
}
}
func skillPathEntryNames(dir string) ([]string, error) {
entries, err := skillPathReadDir(dir)
if err != nil {
return nil, err
}
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
sort.Strings(names)
return names, nil
}
func skillPathFileDigest(path string) ([sha256.Size]byte, error) {
f, err := skillPathOpen(path)
if err != nil {
return [sha256.Size]byte{}, err
}
defer f.Close()
hash := sha256.New()
if _, err := skillPathCopyBytes(hash, f); err != nil {
return [sha256.Size]byte{}, err
}
var digest [sha256.Size]byte
copy(digest[:], hash.Sum(nil))
return digest, nil
}
@@ -0,0 +1,529 @@
package upgrade
import (
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillPathMoveSystemErrors(t *testing.T) {
t.Run("destination probe", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
original := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == dst {
return nil, os.ErrPermission
}
return original(path)
})
if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("destination parent", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
testseam.Swap(t, &skillPathMkdirAll, func(string, os.FileMode) error { return os.ErrPermission })
if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("staging creation", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", os.ErrPermission })
if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("failed staging cleanup", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
testseam.Swap(t, &skillPathCopy, func(string, string) error { return errors.New("copy failed") })
testseam.Swap(t, &skillPathRemoveAll, func(string) error { return errors.New("cleanup failed") })
err := moveSkillPathRecoverably(src, dst)
if err == nil || !strings.Contains(err.Error(), "copy failed") || !strings.Contains(err.Error(), "cleanup failed") {
t.Fatalf("error = %v", err)
}
})
t.Run("missing copied staging", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
testseam.Swap(t, &skillPathCopy, func(string, string) error { return nil })
testseam.Swap(t, &skillPathVerify, func(string, string) error { return nil })
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "检查跨设备") {
t.Fatalf("error = %v", err)
}
})
for _, tc := range []struct {
name string
path func(src, dst string) string
want string
}{
{name: "prepare staging mode", path: func(_, dst string) string { return filepath.Join(filepath.Dir(dst), "payload") }, want: "准备跨设备"},
{name: "restore published mode", path: func(_, dst string) string { return dst }, want: "恢复已发布"},
} {
t.Run(tc.name, func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
original := skillPathChmod
testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error {
if tc.name == "prepare staging mode" && strings.HasSuffix(path, string(filepath.Separator)+"payload") {
return errors.New("chmod failed")
}
if tc.name == "restore published mode" && path == dst {
return errors.New("chmod failed")
}
return original(path, mode)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v", err)
}
})
}
t.Run("published staging cleanup", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
original := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if strings.Contains(filepath.Base(path), ".cross-device-") {
return errors.New("cleanup failed")
}
return original(path)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "清理已发布") {
t.Fatalf("error = %v", err)
}
})
t.Run("source deletion cannot be confirmed", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
removalAttempted := false
originalLstat := skillPathLstat
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if path == src {
removalAttempted = true
return nil
}
return originalRemove(path)
})
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == src && removalAttempted {
return nil, os.ErrPermission
}
return originalLstat(path)
})
if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
}
func TestCrossPlatformCoverageSkillPathPermissionRestorationErrors(t *testing.T) {
t.Run("source preparation error", func(t *testing.T) {
testseam.Swap(t, &skillPathWalkDir, func(string, fs.WalkDirFunc) error {
return errors.New("prepare failed")
})
if err := removePublishedSkillSource("ignored"); err == nil || !strings.Contains(err.Error(), "prepare failed") {
t.Fatalf("error = %v", err)
}
})
t.Run("walk error", func(t *testing.T) {
testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error {
return fn(root, nil, errors.New("walk failed"))
})
if _, err := prepareSkillPathTreeRemoval("ignored"); err == nil || !strings.Contains(err.Error(), "walk failed") {
t.Fatalf("error = %v", err)
}
})
t.Run("writable mode preparation", func(t *testing.T) {
entry := skillPathModeDirEntry{mode: os.ModeDir | 0o500}
testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error {
return fn(root, entry, nil)
})
var chmodMode os.FileMode
testseam.Swap(t, &skillPathChmod, func(_ string, mode os.FileMode) error {
chmodMode = mode
return nil
})
modes, err := prepareSkillPathTreeRemoval("source")
if err != nil {
t.Fatal(err)
}
if chmodMode != 0o700 || len(modes) != 1 || modes[0].path != "source" || modes[0].mode != 0o500 {
t.Fatalf("prepared mode = %v, records = %#v", chmodMode, modes)
}
})
t.Run("writable mode preparation chmod error", func(t *testing.T) {
entry := skillPathModeDirEntry{mode: os.ModeDir | 0o500}
testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error {
return fn(root, entry, nil)
})
testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error {
return errors.New("chmod failed")
})
if _, err := prepareSkillPathTreeRemoval("source"); err == nil || !strings.Contains(err.Error(), "chmod failed") {
t.Fatalf("error = %v", err)
}
})
t.Run("entry info error", func(t *testing.T) {
testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error {
return fn(root, skillPathErrorDirEntry{}, nil)
})
if _, err := prepareSkillPathTreeRemoval("ignored"); err == nil || !strings.Contains(err.Error(), "info failed") {
t.Fatalf("error = %v", err)
}
})
t.Run("missing restored directory", func(t *testing.T) {
if err := restoreSkillPathDirModes([]skillPathDirMode{{path: filepath.Join(t.TempDir(), "missing"), mode: 0o555}}); err != nil {
t.Fatal(err)
}
})
t.Run("restore probe error", func(t *testing.T) {
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission })
if err := restoreSkillPathDirModes([]skillPathDirMode{{path: "blocked", mode: 0o555}}); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("restore chmod error", func(t *testing.T) {
path := t.TempDir()
testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { return os.ErrPermission })
if err := restoreSkillPathDirModes([]skillPathDirMode{{path: path, mode: 0o555}}); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
}
func TestCrossPlatformCoverageSkillPathLexicalCopyErrors(t *testing.T) {
t.Run("source lstat", func(t *testing.T) {
if err := copySkillPathLexically(filepath.Join(t.TempDir(), "missing"), "unused"); !os.IsNotExist(err) {
t.Fatalf("error = %v", err)
}
})
t.Run("destination mkdir", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.Mkdir(src, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("occupied"), 0o600); err != nil {
t.Fatal(err)
}
if err := copySkillPathLexically(src, dst); err == nil {
t.Fatal("expected mkdir failure")
}
})
t.Run("source readdir", func(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "src")
if err := os.Mkdir(src, 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, os.ErrPermission })
if err := copySkillPathLexically(src, filepath.Join(root, "dst")); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("nested copy", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.Mkdir(src, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "child"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
original := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if filepath.Base(path) == "child" {
return nil, os.ErrPermission
}
return original(path)
})
if err := copySkillPathLexically(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("readlink", func(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "link")
if err := os.Symlink("target", src); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", os.ErrPermission })
if err := copySkillPathLexically(src, filepath.Join(root, "dst")); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
t.Run("special path", func(t *testing.T) {
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) {
return skillPathFakeInfo{mode: os.ModeNamedPipe}, nil
})
if err := copySkillPathLexically("special", "dst"); err == nil || !strings.Contains(err.Error(), "特殊") {
t.Fatalf("error = %v", err)
}
})
t.Run("regular open", func(t *testing.T) {
if err := copyRegularSkillFile(filepath.Join(t.TempDir(), "missing"), "dst", 0o600); !os.IsNotExist(err) {
t.Fatalf("error = %v", err)
}
})
t.Run("regular destination open", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("occupied"), 0o600); err != nil {
t.Fatal(err)
}
if err := copyRegularSkillFile(src, dst, 0o600); err == nil {
t.Fatal("expected exclusive create failure")
}
})
for _, tc := range []struct {
name string
swap func(*testing.T)
}{
{name: "copy", swap: func(t *testing.T) {
testseam.Swap(t, &skillPathCopyBytes, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("copy failed") })
}},
{name: "sync", swap: func(t *testing.T) {
testseam.Swap(t, &skillPathSync, func(*os.File) error { return errors.New("sync failed") })
}},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
tc.swap(t)
if err := copyRegularSkillFile(src, dst, 0o600); err == nil || !strings.Contains(err.Error(), tc.name) {
t.Fatalf("error = %v", err)
}
})
}
}
func TestCrossPlatformCoverageSkillPathVerificationErrors(t *testing.T) {
root := t.TempDir()
t.Run("source and destination lstat", func(t *testing.T) {
if err := verifySkillPathCopy(filepath.Join(root, "missing-src"), "dst"); !os.IsNotExist(err) {
t.Fatalf("source error = %v", err)
}
src := filepath.Join(root, "source")
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, filepath.Join(root, "missing-dst")); !os.IsNotExist(err) {
t.Fatalf("destination error = %v", err)
}
})
t.Run("directory and file modes", func(t *testing.T) {
for _, tc := range []struct {
name string
src os.FileMode
dst os.FileMode
}{
{name: "directory", src: os.ModeDir | 0o700, dst: os.ModeDir | 0o755},
{name: "file", src: 0o600, dst: 0o644},
} {
t.Run(tc.name, func(t *testing.T) {
src, dst := "src", "dst"
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == src {
return skillPathFakeInfo{mode: tc.src}, nil
}
return skillPathFakeInfo{mode: tc.dst}, nil
})
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "权限") {
t.Fatalf("error = %v", err)
}
})
}
})
t.Run("directory read errors", func(t *testing.T) {
for _, failDest := range []bool{false, true} {
t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) {
base := t.TempDir()
src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst")
if err := os.Mkdir(src, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(dst, 0o700); err != nil {
t.Fatal(err)
}
original := skillPathReadDir
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if (path == dst) == failDest {
return nil, os.ErrPermission
}
return original(path)
})
if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
}
})
t.Run("nested mismatch", func(t *testing.T) {
base := t.TempDir()
src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst")
if err := os.Mkdir(src, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(dst, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "child"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dst, "child"), 0o600); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "类型") {
t.Fatalf("error = %v", err)
}
})
t.Run("symlink reads", func(t *testing.T) {
base := t.TempDir()
src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst")
if err := os.Symlink("one", src); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
if err := os.Symlink("one", dst); err != nil {
t.Fatal(err)
}
for _, failDest := range []bool{false, true} {
t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) {
original := skillPathReadlink
testseam.Swap(t, &skillPathReadlink, func(path string) (string, error) {
if (path == dst) == failDest {
return "", os.ErrPermission
}
return original(path)
})
if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
}
})
t.Run("file digest opens", func(t *testing.T) {
base := t.TempDir()
src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst")
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
for _, failDest := range []bool{false, true} {
t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) {
original := skillPathOpen
testseam.Swap(t, &skillPathOpen, func(path string) (*os.File, error) {
if (path == dst) == failDest {
return nil, os.ErrPermission
}
return original(path)
})
if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
})
}
})
t.Run("special path", func(t *testing.T) {
fake := skillPathFakeInfo{mode: os.ModeNamedPipe}
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return fake, nil })
if err := verifySkillPathCopy("src", "dst"); err == nil || !strings.Contains(err.Error(), "特殊") {
t.Fatalf("error = %v", err)
}
})
t.Run("entry names", func(t *testing.T) {
if _, err := skillPathEntryNames(filepath.Join(root, "missing-dir")); !os.IsNotExist(err) {
t.Fatalf("error = %v", err)
}
})
t.Run("digest open and read", func(t *testing.T) {
if _, err := skillPathFileDigest(filepath.Join(root, "missing-file")); !os.IsNotExist(err) {
t.Fatalf("open error = %v", err)
}
file := filepath.Join(root, "digest-file")
if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathCopyBytes, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("read failed") })
if _, err := skillPathFileDigest(file); err == nil || !strings.Contains(err.Error(), "read failed") {
t.Fatalf("read error = %v", err)
}
})
}
type skillPathFakeInfo struct {
mode os.FileMode
}
func (skillPathFakeInfo) Name() string { return "fake" }
func (skillPathFakeInfo) Size() int64 { return 0 }
func (info skillPathFakeInfo) Mode() os.FileMode { return info.mode }
func (skillPathFakeInfo) ModTime() time.Time { return time.Time{} }
func (info skillPathFakeInfo) IsDir() bool { return info.mode.IsDir() }
func (skillPathFakeInfo) Sys() any { return nil }
type skillPathErrorDirEntry struct{}
func (skillPathErrorDirEntry) Name() string { return "broken" }
func (skillPathErrorDirEntry) IsDir() bool { return true }
func (skillPathErrorDirEntry) Type() os.FileMode { return os.ModeDir }
func (skillPathErrorDirEntry) Info() (os.FileInfo, error) { return nil, errors.New("info failed") }
type skillPathModeDirEntry struct {
mode os.FileMode
}
func (skillPathModeDirEntry) Name() string { return "directory" }
func (skillPathModeDirEntry) IsDir() bool { return true }
func (entry skillPathModeDirEntry) Type() os.FileMode { return entry.mode.Type() }
func (entry skillPathModeDirEntry) Info() (os.FileInfo, error) {
return skillPathFakeInfo{mode: entry.mode}, nil
}
+451
View File
@@ -0,0 +1,451 @@
package upgrade
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillPathCrossDeviceMove(t *testing.T) {
t.Run("directory file and lexical links", func(t *testing.T) {
root := t.TempDir()
t.Cleanup(func() { makeSkillPathTreeWritable(root) })
src := filepath.Join(root, "external", "dingtalk-chat")
dst := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat")
if err := os.MkdirAll(filepath.Join(src, "references"), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("chat\n"), 0o640); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "references", "guide.md"), []byte("guide\n"), 0o600); err != nil {
t.Fatal(err)
}
linksCreated := true
if err := os.Symlink("SKILL.md", filepath.Join(src, "skill-link")); err != nil {
if runtime.GOOS != "windows" {
t.Fatal(err)
}
linksCreated = false
}
if linksCreated {
if err := os.Symlink("missing.md", filepath.Join(src, "dangling-link")); err != nil {
t.Fatal(err)
}
}
forceCrossDeviceRename(t, src, dst)
if err := moveSkillPathRecoverably(src, dst); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(src); !os.IsNotExist(err) {
t.Fatalf("source must be removed: %v", err)
}
if got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")); err != nil || string(got) != "chat\n" {
t.Fatalf("copied file = %q, %v", got, err)
}
if info, err := os.Stat(filepath.Join(dst, "SKILL.md")); err != nil {
t.Fatalf("copied file stat = %v, %v", info, err)
} else if runtime.GOOS != "windows" && info.Mode().Perm() != 0o640 {
t.Fatalf("copied file mode = %v; want 0640", info.Mode().Perm())
}
if linksCreated {
for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} {
info, err := os.Lstat(filepath.Join(dst, name))
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("%s must remain a symlink: %v, %v", name, info, err)
}
if got, err := os.Readlink(filepath.Join(dst, name)); err != nil || got != want {
t.Fatalf("%s target = %q, %v; want %q", name, got, err, want)
}
}
}
assertNoCrossDeviceStage(t, dst)
})
t.Run("regular file root", func(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source-file")
dst := filepath.Join(root, "backup", "source-file")
if err := os.WriteFile(src, []byte("state"), 0o600); err != nil {
t.Fatal(err)
}
forceCrossDeviceRename(t, src, dst)
if err := moveSkillPathRecoverably(src, dst); err != nil {
t.Fatal(err)
}
if got, err := os.ReadFile(dst); err != nil || string(got) != "state" {
t.Fatalf("backup file = %q, %v", got, err)
}
})
t.Run("read-only directory mode is restored after copying children", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX directory permission semantics are unavailable")
}
root := t.TempDir()
t.Cleanup(func() { makeSkillPathTreeWritable(root) })
src := filepath.Join(root, "external", "dingtalk-chat")
dst := filepath.Join(root, "backup", "dingtalk-chat")
if err := os.MkdirAll(src, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("read only\n"), 0o444); err != nil {
t.Fatal(err)
}
if err := os.Chmod(src, 0o555); err != nil {
t.Fatal(err)
}
forceCrossDeviceRename(t, src, dst)
if err := moveSkillPathRecoverably(src, dst); err != nil {
t.Fatal(err)
}
if info, err := os.Stat(dst); err != nil || info.Mode().Perm() != 0o555 {
t.Fatalf("copied directory mode = %v, %v", info, err)
}
if got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")); err != nil || string(got) != "read only\n" {
t.Fatalf("copied read-only file = %q, %v", got, err)
}
})
t.Run("rollback uses the same cross-device contract", func(t *testing.T) {
root := t.TempDir()
backup := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat")
original := filepath.Join(root, "external", "claude", "skills", "dingtalk-chat")
if err := os.MkdirAll(backup, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(backup, "SKILL.md"), []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
forceCrossDeviceRename(t, backup, original)
if err := RestoreSkillPath(backup, original); err != nil {
t.Fatal(err)
}
if got, err := os.ReadFile(filepath.Join(original, "SKILL.md")); err != nil || string(got) != "old" {
t.Fatalf("restored content = %q, %v", got, err)
}
if _, err := os.Lstat(backup); !os.IsNotExist(err) {
t.Fatalf("backup must be consumed after verified restore: %v", err)
}
})
}
func TestCrossPlatformCoverageSkillPathCrossDeviceFailures(t *testing.T) {
t.Run("non cross-device rename never copies", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
copied := false
testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", os.ErrPermission })
testseam.Swap(t, &skillPathCopy, func(string, string) error { copied = true; return nil })
if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
t.Fatalf("error = %v", err)
}
if copied {
t.Fatal("permission failure must not enter cross-device copy fallback")
}
assertSkillSourcePreserved(t, src)
})
for _, tc := range []struct {
name string
copy func(string, string) error
verify func(string, string) error
}{
{name: "copy failure", copy: func(string, string) error { return errors.New("copy failed") }},
{name: "verification failure", verify: func(string, string) error { return errors.New("verify failed") }},
} {
t.Run(tc.name, func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
if tc.copy != nil {
testseam.Swap(t, &skillPathCopy, tc.copy)
}
if tc.verify != nil {
testseam.Swap(t, &skillPathVerify, tc.verify)
}
if err := moveSkillPathRecoverably(src, dst); err == nil {
t.Fatal("expected failure")
}
assertSkillSourcePreserved(t, src)
if _, err := os.Lstat(dst); !os.IsNotExist(err) {
t.Fatalf("formal backup must not publish: %v", err)
}
assertNoCrossDeviceStage(t, dst)
})
}
t.Run("staging publish failure", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) (string, error) {
if from == src && to == dst {
return "", testCrossDeviceError()
}
if to == dst {
return "", errors.New("publish failed")
}
return originalRename(from, to)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "publish failed") {
t.Fatalf("error = %v", err)
}
assertSkillSourcePreserved(t, src)
assertNoCrossDeviceStage(t, dst)
})
t.Run("published verification failure preserves both", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
calls := 0
testseam.Swap(t, &skillPathVerify, func(string, string) error {
calls++
if calls == 2 {
return errors.New("published verify failed")
}
return nil
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "published verify failed") {
t.Fatalf("error = %v", err)
}
assertSkillSourcePreserved(t, src)
if _, err := os.Lstat(dst); err != nil {
t.Fatalf("verified backup candidate must remain: %v", err)
}
assertNoCrossDeviceStage(t, dst)
})
t.Run("source removal failure preserves both", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if path == src {
return errors.New("remove failed")
}
return originalRemove(path)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "均保留") {
t.Fatalf("error = %v", err)
}
assertSkillSourcePreserved(t, src)
if _, err := os.Lstat(dst); err != nil {
t.Fatalf("formal backup must remain: %v", err)
}
})
t.Run("read-only source mode is restored after removal failure", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX directory permission semantics are unavailable")
}
src, dst := makeSkillMoveFixture(t)
if err := os.Chmod(src, 0o555); err != nil {
t.Fatal(err)
}
forceCrossDeviceRename(t, src, dst)
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if path == src {
return errors.New("remove failed")
}
return originalRemove(path)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "均保留") {
t.Fatalf("error = %v", err)
}
if info, err := os.Stat(src); err != nil || info.Mode().Perm() != 0o555 {
t.Fatalf("source directory mode after failed removal = %v, %v", info, err)
}
if err := os.Chmod(src, 0o755); err != nil {
t.Fatal(err)
}
})
t.Run("source permission preparation failure preserves both", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX directory permission semantics are unavailable")
}
src, dst := makeSkillMoveFixture(t)
if err := os.Chmod(src, 0o555); err != nil {
t.Fatal(err)
}
forceCrossDeviceRename(t, src, dst)
originalChmod := skillPathChmod
testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error {
if path == src {
return errors.New("chmod failed")
}
return originalChmod(path, mode)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "chmod failed") {
t.Fatalf("error = %v", err)
}
if err := originalChmod(src, 0o755); err != nil {
t.Fatal(err)
}
})
t.Run("lying remover is detected", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
forceCrossDeviceRename(t, src, dst)
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if path == src {
return nil
}
return originalRemove(path)
})
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "仍存在") {
t.Fatalf("error = %v", err)
}
assertSkillSourcePreserved(t, src)
})
t.Run("same-filesystem fast path", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
if err := moveSkillPathRecoverably(src, dst); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(src); !os.IsNotExist(err) {
t.Fatalf("source still exists: %v", err)
}
})
t.Run("existing destination is never overwritten", func(t *testing.T) {
src, dst := makeSkillMoveFixture(t)
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatal(err)
}
if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "目标已存在") {
t.Fatalf("error = %v", err)
}
assertSkillSourcePreserved(t, src)
})
}
func TestCrossPlatformCoverageVerifySkillPathCopyMismatches(t *testing.T) {
t.Run("type", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(dst, 0o700); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "类型") {
t.Fatalf("error = %v", err)
}
})
t.Run("directory entries", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.Mkdir(src, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(dst, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "one"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "数量") {
t.Fatalf("error = %v", err)
}
if err := os.WriteFile(filepath.Join(dst, "two"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "目录项") {
t.Fatalf("error = %v", err)
}
})
t.Run("file size and digest", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.WriteFile(src, []byte("same"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("longer"), 0o600); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "大小") {
t.Fatalf("error = %v", err)
}
if err := os.WriteFile(dst, []byte("diff"), 0o600); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "摘要") {
t.Fatalf("error = %v", err)
}
})
t.Run("symlink target", func(t *testing.T) {
root := t.TempDir()
src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
if err := os.Symlink("one", src); err != nil {
if runtime.GOOS == "windows" {
t.Skip("symlink creation unavailable")
}
t.Fatal(err)
}
if err := os.Symlink("two", dst); err != nil {
t.Fatal(err)
}
if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "链接目标") {
t.Fatalf("error = %v", err)
}
})
}
func forceCrossDeviceRename(t *testing.T, src, dst string) {
t.Helper()
originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) (string, error) {
if from == src && to == dst {
return "", testCrossDeviceError()
}
return originalRename(from, to)
})
}
func makeSkillMoveFixture(t *testing.T) (string, string) {
t.Helper()
root := t.TempDir()
t.Cleanup(func() { makeSkillPathTreeWritable(root) })
src := filepath.Join(root, "external", "dingtalk-chat")
dst := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat")
if err := os.MkdirAll(src, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
return src, dst
}
func assertSkillSourcePreserved(t *testing.T, src string) {
t.Helper()
if got, err := os.ReadFile(filepath.Join(src, "SKILL.md")); err != nil || string(got) != "old" {
t.Fatalf("source content = %q, %v", got, err)
}
}
func assertNoCrossDeviceStage(t *testing.T, dst string) {
t.Helper()
entries, err := os.ReadDir(filepath.Dir(dst))
if err != nil {
if os.IsNotExist(err) {
return
}
t.Fatal(err)
}
prefix := "." + filepath.Base(dst) + ".cross-device-"
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), prefix) {
t.Fatalf("cross-device staging leaked: %s", entry.Name())
}
}
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
package upgrade
import (
"errors"
"syscall"
)
func isCrossDeviceError(err error) bool {
return errors.Is(err, syscall.EXDEV)
}
@@ -0,0 +1,16 @@
//go:build !windows
package upgrade
import (
"os"
"syscall"
)
func testCrossDeviceError() error {
return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: syscall.EXDEV}
}
func testNoReplaceUnsupportedErrors() []error {
return []error{syscall.EINVAL, syscall.ENOSYS, errNoReplaceRenameUnsupported}
}
@@ -0,0 +1,13 @@
//go:build windows
package upgrade
import (
"errors"
"golang.org/x/sys/windows"
)
func isCrossDeviceError(err error) bool {
return errors.Is(err, windows.ERROR_NOT_SAME_DEVICE)
}
@@ -0,0 +1,24 @@
//go:build windows
package upgrade
import (
"os"
"testing"
"golang.org/x/sys/windows"
)
func testCrossDeviceError() error {
return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: windows.ERROR_NOT_SAME_DEVICE}
}
func testNoReplaceUnsupportedErrors() []error {
return []error{errNoReplaceRenameUnsupported}
}
func TestCrossPlatformCoverageWindowsCrossDeviceError(t *testing.T) {
if !isCrossDeviceError(testCrossDeviceError()) {
t.Fatal("ERROR_NOT_SAME_DEVICE must enter the cross-device fallback")
}
}
+241
View File
@@ -0,0 +1,241 @@
package upgrade
import (
"crypto/sha256"
"errors"
"fmt"
"hash"
"os"
"path/filepath"
"sort"
)
// ErrSkillPathPublicationUncertain marks a publication whose transactional
// outcome cannot be determined safely: this transaction did occupy the
// destination at some point, but a stronger witness — the fresh mkdir-claim
// identity captured by the child-move fallback — now indicates that the
// object currently at the destination may belong to a concurrent writer.
// Callers must not automatically retract, replace, or retry-copy over the
// destination; instead they should surface the state so the user or a
// higher layer can inspect it. Errors wrapping this sentinel are safe to
// test with errors.Is.
var ErrSkillPathPublicationUncertain = errors.New("Skill 发布状态不确定")
// SkillPathPublication records enough immutable identity to prove that a
// destination still belongs to the transaction that published it. The
// fingerprint is intentionally private so callers cannot forge records.
type SkillPathPublication struct {
Destination string
fingerprint [sha256.Size]byte
identity os.FileInfo
incarnation string
fileID string
}
// PublishSkillPathNoReplace atomically publishes a staged path without ever
// replacing a destination created after the backup phase.
//
// The rename returns a mkdir-claim identity when (and only when) the
// child-move fallback published dest. That identity is the tamperproof
// witness that dest is still the mkdir claim this transaction created
// (unlike the staged shell, whose presence alone is compatible with a
// wholesale replacement of dest by a concurrent writer). When the claim
// identity no longer matches dest, publication reports uncertain state
// (ErrSkillPathPublicationUncertain) and keeps dest — auto-retracting or
// overwriting would delete the concurrent writer's data.
func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication, error) {
identity, err := skillPathLstat(staged)
if err != nil {
return SkillPathPublication{}, fmt.Errorf("读取待发布 Skill 身份失败 %s: %w", staged, err)
}
fingerprint, err := fingerprintSkillPath(staged)
if err != nil {
return SkillPathPublication{}, fmt.Errorf("计算待发布 Skill 身份失败 %s: %w", staged, err)
}
stagedFileID := skillPathFileIdentity(staged)
claimIdentity, err := skillPathRenameNoReplace(staged, destination)
if err != nil {
return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err)
}
// If the child-move fallback published dest, the claim identity must
// still describe the object at destination. A wholesale replacement of
// dest between the mkdir claim and this check produces a different
// identity — dest is a foreign object and must not be treated as this
// transaction's publication. An unreadable live identity (dest already
// vanished, or the platform cannot report one) defers to the standard
// confirmation checks below instead of guessing.
if claimIdentity != "" {
if liveIdentity := skillPathFileIdentity(destination); liveIdentity != "" && liveIdentity != claimIdentity {
return SkillPathPublication{}, fmt.Errorf("%w:child-move 发布后目标 %s 已被替换;目标保留", ErrSkillPathPublicationUncertain, destination)
}
}
publishedIdentity, err := skillPathLstat(destination)
if err != nil {
return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): %w", destination, err)
}
publishedFingerprint, err := fingerprintSkillPath(destination)
if err != nil {
return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): %w", destination, err)
}
publishedFileID := skillPathFileIdentity(destination)
// When claim identity is available (child-move path), it is the
// authoritative witness of "dest is our object"; the staged inode
// necessarily differs from the mkdir-claim inode. Only enforce the
// staged-inode identity proof when there is no claim identity — that
// is, when the atomic primitive or the fast-path rename consumed the
// staged inode into dest.
if claimIdentity == "" && !skillPathIdentityProven(identity, publishedIdentity, stagedFileID, publishedFileID) {
return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination)
}
if publishedFingerprint != fingerprint {
return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): staging 内容已变化", destination)
}
return SkillPathPublication{
Destination: destination,
fingerprint: fingerprint,
identity: publishedIdentity,
incarnation: skillPathFileIncarnation(publishedIdentity),
fileID: publishedFileID,
}, nil
}
// RollbackSkillPathPublications removes only objects that can still be proven
// to have been published by this transaction. Each live path is first claimed
// into a private sibling quarantine. A concurrent replacement is restored when
// possible, otherwise retained in quarantine and reported explicitly.
func RollbackSkillPathPublications(publications []SkillPathPublication) error {
var rollbackErr error
for i := len(publications) - 1; i >= 0; i-- {
if err := rollbackSkillPathPublication(publications[i]); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
return rollbackErr
}
func rollbackSkillPathPublication(publication SkillPathPublication) (err error) {
destination := publication.Destination
quarantineRoot, err := skillPathMkdirTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".rollback-")
if err != nil {
return fmt.Errorf("创建 Skill 回滚隔离目录失败 %s: %w", destination, err)
}
quarantine := filepath.Join(quarantineRoot, "payload")
cleanupRoot := func() error {
if cleanupErr := skillPathRemoveAll(quarantineRoot); cleanupErr != nil {
return fmt.Errorf("清理 Skill 回滚隔离目录失败 %s: %w", quarantineRoot, cleanupErr)
}
return nil
}
liveIdentity, liveIdentityErr := skillPathLstat(destination)
if os.IsNotExist(liveIdentityErr) {
return cleanupRoot()
}
liveFingerprint, liveFingerprintErr := fingerprintSkillPath(destination)
liveVerificationErr := errors.Join(liveIdentityErr, liveFingerprintErr)
if liveVerificationErr != nil {
return errors.Join(
fmt.Errorf("拒绝删除无法验证的 Skill %s: %w", destination, liveVerificationErr),
cleanupRoot(),
)
}
if publication.identity == nil ||
!skillPathIdentityProven(publication.identity, liveIdentity, publication.fileID, skillPathFileIdentity(destination)) ||
publication.incarnation != skillPathFileIncarnation(liveIdentity) ||
liveFingerprint != publication.fingerprint {
return errors.Join(
fmt.Errorf("拒绝删除非本事务 Skill %s: 发布对象身份已变化", destination),
cleanupRoot(),
)
}
if err := skillPathRename(destination, quarantine); err != nil {
cleanupErr := cleanupRoot()
if os.IsNotExist(err) {
return cleanupErr
}
return errors.Join(fmt.Errorf("隔离待回滚 Skill 失败 %s: %w", destination, err), cleanupErr)
}
actualIdentity, identityErr := skillPathLstat(quarantine)
actual, fingerprintErr := fingerprintSkillPath(quarantine)
if identityErr == nil && fingerprintErr == nil &&
skillPathIdentityProven(publication.identity, actualIdentity, publication.fileID, skillPathFileIdentity(quarantine)) &&
actual == publication.fingerprint {
if removeErr := removePublishedSkillSource(quarantine); removeErr != nil {
return fmt.Errorf("移除事务发布的 Skill 失败 %s(隔离于 %s): %w", destination, quarantine, removeErr)
}
return cleanupRoot()
}
verificationErr := errors.Join(identityErr, fingerprintErr)
if verificationErr == nil {
verificationErr = errors.New("发布对象身份已变化")
}
// Rollback restore is not a publication path; ignore any claim
// identity returned by the rename — no ownership check follows.
if _, restoreErr := skillPathRenameNoReplace(quarantine, destination); restoreErr != nil {
return fmt.Errorf("拒绝删除非本事务 Skill %s: %v;并发对象保留于 %s,恢复原路径失败: %w", destination, verificationErr, quarantine, restoreErr)
}
return errors.Join(
fmt.Errorf("拒绝删除非本事务 Skill %s: %w", destination, verificationErr),
cleanupRoot(),
)
}
func fingerprintSkillPath(path string) ([sha256.Size]byte, error) {
h := sha256.New()
if err := fingerprintSkillPathInto(h, path); err != nil {
return [sha256.Size]byte{}, err
}
var result [sha256.Size]byte
copy(result[:], h.Sum(nil))
return result, nil
}
func fingerprintSkillPathInto(h hash.Hash, path string) error {
info, err := skillPathLstat(path)
if err != nil {
return err
}
writeFingerprintField(h, info.Mode().Type().String())
writeFingerprintField(h, info.Mode().Perm().String())
switch {
case info.IsDir():
entries, err := skillPathReadDir(path)
if err != nil {
return err
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
writeFingerprintField(h, fmt.Sprintf("%d", len(entries)))
for _, entry := range entries {
writeFingerprintField(h, entry.Name())
if err := fingerprintSkillPathInto(h, filepath.Join(path, entry.Name())); err != nil {
return err
}
}
return nil
case info.Mode()&os.ModeSymlink != 0:
target, err := skillPathReadlink(path)
if err != nil {
return err
}
writeFingerprintField(h, target)
return nil
case info.Mode().IsRegular():
digest, err := skillPathFileDigest(path)
if err != nil {
return err
}
writeFingerprintField(h, fmt.Sprintf("%d", info.Size()))
_, err = h.Write(digest[:])
return err
default:
return fmt.Errorf("不支持识别特殊 Skill 路径 %s(mode=%s)", path, info.Mode())
}
}
func writeFingerprintField(h hash.Hash, value string) {
_, _ = fmt.Fprintf(h, "%d:", len(value))
_, _ = h.Write([]byte(value))
}
@@ -0,0 +1,50 @@
//go:build darwin
package upgrade
import (
"fmt"
"os"
"syscall"
)
func skillPathFileIncarnationImpl(info os.FileInfo) string {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return fmt.Sprintf("unknown:%T", info.Sys())
}
return fmt.Sprintf("%d:%d:%d:%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Gen,
stat.Ctimespec.Sec, stat.Ctimespec.Nsec, stat.Birthtimespec.Sec, stat.Birthtimespec.Nsec)
}
func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool {
return os.SameFile(left, right)
}
// skillPathFileIdentityImpl reports the device/inode pair from lstat. POSIX
// has no open-by-ID handle, but dev:ino identifies the object for its
// lifetime and is the same witness os.SameFile compares. It anchors the
// mkdir-claim identity of the child-move fallback: a wholesale replacement
// of the claimed destination reports a different identity, except in the
// classic inode-reuse race on recycled-inode filesystems, which the
// publication fingerprint backstop still catches. An unreadable or
// unrecognizable path reports "" and callers treat that as "no witness".
func skillPathFileIdentityImpl(path string) string {
info, err := skillPathLstat(path)
if err != nil {
return ""
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return ""
}
return fmt.Sprintf("%d:%d", stat.Dev, stat.Ino)
}
// skillPathIdentityProven stays SameFile-based on POSIX: the file ID above is
// the same dev:ino witness, so preferring it would add no strength while
// making the proof refuse publishes whose staged lstat (and therefore staged
// identity) could not be read.
func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool {
return skillPathSameFileIdentity(staged, published)
}
@@ -0,0 +1,49 @@
//go:build linux
package upgrade
import (
"fmt"
"os"
"syscall"
)
func skillPathFileIncarnationImpl(info os.FileInfo) string {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return fmt.Sprintf("unknown:%T", info.Sys())
}
return fmt.Sprintf("%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Ctim.Sec, stat.Ctim.Nsec)
}
func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool {
return os.SameFile(left, right)
}
// skillPathFileIdentityImpl reports the device/inode pair from lstat. POSIX
// has no open-by-ID handle, but dev:ino identifies the object for its
// lifetime and is the same witness os.SameFile compares. It anchors the
// mkdir-claim identity of the child-move fallback: a wholesale replacement
// of the claimed destination reports a different identity, except in the
// classic inode-reuse race on recycled-inode filesystems (notably tmpfs),
// which the publication fingerprint backstop still catches. An unreadable
// or unrecognizable path reports "" and callers treat that as "no witness".
func skillPathFileIdentityImpl(path string) string {
info, err := skillPathLstat(path)
if err != nil {
return ""
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return ""
}
return fmt.Sprintf("%d:%d", stat.Dev, stat.Ino)
}
// skillPathIdentityProven stays SameFile-based on POSIX: the file ID above is
// the same dev:ino witness, so preferring it would add no strength while
// making the proof refuse publishes whose staged lstat (and therefore staged
// identity) could not be read.
func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool {
return skillPathSameFileIdentity(staged, published)
}
@@ -0,0 +1,27 @@
//go:build !linux && !darwin && !windows
package upgrade
import (
"fmt"
"os"
)
func skillPathFileIncarnationImpl(info os.FileInfo) string {
return fmt.Sprintf("%d:%d:%s", info.ModTime().UnixNano(), info.Size(), info.Mode())
}
func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool {
return os.SameFile(left, right)
}
// skillPathFileIdentityImpl reports no identity on platforms whose stat
// layout we do not decode; the child-move claim witness degrades gracefully
// to the publication fingerprint backstop.
func skillPathFileIdentityImpl(_ string) string {
return ""
}
func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool {
return skillPathSameFileIdentity(staged, published)
}
@@ -0,0 +1,64 @@
//go:build windows
package upgrade
import (
"fmt"
"os"
"syscall"
"golang.org/x/sys/windows"
)
func skillPathFileIncarnationImpl(info os.FileInfo) string {
stat, ok := info.Sys().(*syscall.Win32FileAttributeData)
if !ok {
return fmt.Sprintf("unknown:%T", info.Sys())
}
return fmt.Sprintf("%d:%d", stat.CreationTime.HighDateTime, stat.CreationTime.LowDateTime)
}
// skillPathSameFileIdentity returns false because os.FileInfo on Windows does
// not expose the stable file ID (VolumeSerialNumber / FileIndex from
// GetFileInformationByHandle), which is the only primitive that distinguishes
// a recreated file from the original after NTFS file tunneling restores the
// creation time. Callers must use skillPathIdentityProven, which compares file
// IDs obtained via GetFileInformationByHandle.
func skillPathSameFileIdentityImpl(_, _ os.FileInfo) bool {
return false
}
// skillPathFileIdentityImpl queries the stable volume serial number and file
// index via GetFileInformationByHandle. Unlike creation time (which NTFS file
// tunneling can restore for a recreated same-named object), the file index
// uniquely identifies the file on the volume for its lifetime.
//
// FILE_FLAG_OPEN_REPARSE_POINT opens symlinks themselves rather than following
// them to the target. This matters at publish time because a staged symlink
// carries a relative target computed for the final destination, which may not
// resolve from the staging directory. Opening the reparse point yields a file
// ID that is stable across the rename and is the correct identity to compare.
func skillPathFileIdentityImpl(path string) (id string) {
if p, err := windows.UTF16PtrFromString(path); err == nil {
if handle, err := windows.CreateFile(
p, windows.FILE_READ_ATTRIBUTES,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil, windows.OPEN_EXISTING,
windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0,
); err == nil {
defer windows.CloseHandle(handle)
var info windows.ByHandleFileInformation
if err := windows.GetFileInformationByHandle(handle, &info); err == nil {
id = fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow)
}
}
}
return
}
// skillPathIdentityProven is the real identity proof on Windows. An empty
// expected value means the file ID could not be obtained at publish time, so
// identity cannot be proven and the caller must refuse the auto-delete.
func skillPathIdentityProven(_, _ os.FileInfo, expected, actual string) bool {
return expected != "" && expected == actual
}
+532
View File
@@ -0,0 +1,532 @@
package upgrade
import (
"crypto/sha256"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillPublicationNoClobberAndOwnedRollback(t *testing.T) {
t.Run("existing destination is never replaced", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "new", false)
seedUpgradeSkill(t, destination, "concurrent", false)
if _, err := PublishSkillPathNoReplace(staged, destination); err == nil {
t.Fatal("expected no-replace publication to fail")
}
assertUpgradeSkillContent(t, destination, "concurrent")
assertUpgradeSkillContent(t, staged, "new")
})
t.Run("owned publication is removed", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "new", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(destination); !os.IsNotExist(err) {
t.Fatalf("owned publication remains: %v", err)
}
})
t.Run("concurrent replacement is quarantined instead of deleted", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "new", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
if err := os.RemoveAll(destination); err != nil {
t.Fatal(err)
}
// Even a byte-for-byte identical replacement is not owned by this
// transaction: inode identity, not content alone, is authoritative.
seedUpgradeSkill(t, destination, "new", false)
err = RollbackSkillPathPublications([]SkillPathPublication{publication})
if err == nil || !strings.Contains(err.Error(), "拒绝删除非本事务") {
t.Fatalf("replacement rollback error = %v", err)
}
assertUpgradeSkillContent(t, destination, "new")
})
t.Run("tunneled replacement with same creation time is still refused", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "new", false)
// NTFS file tunneling can restore the original creation time for a
// same-named recreation, defeating the incarnation check. The file ID
// (or inode on Unix) must still differ and block the rollback.
testseam.Swap(t, &skillPathFileIncarnation, func(os.FileInfo) string { return "tunneled" })
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
// Simulate the identity change that a concurrent replacement causes.
// On Windows, skillPathIdentityProven compares the recorded file ID
// from GetFileInformationByHandle with the live one and ignores the
// SameFile seam; NTFS can reuse the freed MFT record for the
// recreation, so the live probe is perturbed through the file-ID
// seam as well. On Unix, where the file ID is advisory, this swap
// replaces the os.SameFile check (which can return true for a
// recreated file on tmpfs due to inode reuse) so the test is
// deterministic across filesystems.
testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false })
originalFileIdentity := skillPathFileIdentity
testseam.Swap(t, &skillPathFileIdentity, func(path string) string {
return originalFileIdentity(path) + ":replaced"
})
if err := os.RemoveAll(destination); err != nil {
t.Fatal(err)
}
seedUpgradeSkill(t, destination, "new", false)
err = RollbackSkillPathPublications([]SkillPathPublication{publication})
if err == nil || !strings.Contains(err.Error(), "拒绝删除非本事务") {
t.Fatalf("tunneled replacement rollback error = %v", err)
}
assertUpgradeSkillContent(t, destination, "new")
})
}
func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) {
failure := errors.New("injected publication failure")
t.Run("platform no-replace path encoding", func(t *testing.T) {
if _, err := renameSkillPathNoReplace("invalid\x00source", filepath.Join(t.TempDir(), "dest")); err == nil {
t.Fatal("expected invalid source path failure")
}
source := filepath.Join(t.TempDir(), "source")
if err := os.WriteFile(source, []byte("value"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := renameSkillPathNoReplace(source, "invalid\x00destination"); err == nil {
t.Fatal("expected invalid destination path failure")
}
})
t.Run("publish identity", func(t *testing.T) {
if _, err := PublishSkillPathNoReplace(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dest")); err == nil {
t.Fatal("expected identity failure")
}
})
t.Run("publish fingerprint", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
seedUpgradeSkill(t, staged, "value", false)
originalLstat := skillPathLstat
calls := 0
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
calls++
if calls == 2 {
return nil, failure
}
return originalLstat(path)
})
if _, err := PublishSkillPathNoReplace(staged, filepath.Join(root, "dest")); !errors.Is(err, failure) {
t.Fatal("expected fingerprint failure")
}
})
t.Run("publish confirmation missing", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) {
if _, err := originalRename(source, target); err != nil {
return "", err
}
return "", os.RemoveAll(target)
})
if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "对象保留") {
t.Fatalf("publish confirmation error = %v", err)
}
})
t.Run("publish confirmation identity changed", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
// A real rename consumed the staged path (fast path), but the object
// at the destination must be proven to be the staged one. Whether a
// same-content swap is observable through inode/file-ID comparison
// depends on the filesystem — CI runners' ext4/overlayfs recycle
// inodes eagerly and NTFS can immediately reuse the freed MFT record,
// so reproducing the swap physically is not portable. Force the
// failure through both underlying primitives the platform-specific
// proof consults: os.SameFile (Unix) and the stable file ID
// (Windows), pinning the confirmation's rejection on every platform.
testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false })
originalFileIdentity := skillPathFileIdentity
stagedID := ""
testseam.Swap(t, &skillPathFileIdentity, func(path string) string {
id := originalFileIdentity(path)
if stagedID == "" {
stagedID = id
return id
}
// The published probe must report a different object than the
// staged one so the proof fails on both platforms.
return id + ":swapped"
})
if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 身份已变化") {
t.Fatalf("publish identity error = %v", err)
}
assertUpgradeSkillContent(t, destination, "value")
})
t.Run("publish confirmation fingerprint", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
originalRename := skillPathRenameNoReplace
published := false
testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) {
if _, err := originalRename(source, target); err != nil {
return "", err
}
published = true
return "", nil
})
originalReadDir := skillPathReadDir
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if published && path == destination {
return nil, failure
}
return originalReadDir(path)
})
if _, err := PublishSkillPathNoReplace(staged, destination); !errors.Is(err, failure) || !strings.Contains(err.Error(), "内容失败") {
t.Fatalf("publish fingerprint confirmation error = %v", err)
}
})
t.Run("rollback temp", func(t *testing.T) {
testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", failure })
if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: "dest"}}); !errors.Is(err, failure) {
t.Fatalf("temp error = %v", err)
}
})
t.Run("missing destination cleanup", func(t *testing.T) {
if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "missing")}}); err != nil {
t.Fatal(err)
}
})
t.Run("missing destination cleanup failure", func(t *testing.T) {
testseam.Swap(t, &skillPathRemoveAll, func(string) error { return failure })
if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "missing")}}); !errors.Is(err, failure) {
t.Fatalf("cleanup error = %v", err)
}
})
t.Run("quarantine rename", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathRename, func(string, string) error { return failure })
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("quarantine error = %v", err)
}
})
t.Run("live verification", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalReadDir := skillPathReadDir
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if path == destination {
return nil, failure
}
return originalReadDir(path)
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("live verification error = %v", err)
}
})
t.Run("destination disappears before quarantine", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathRename, func(source, target string) error {
if err := os.RemoveAll(source); err != nil {
return err
}
return &os.PathError{Op: "rename", Path: source, Err: os.ErrNotExist}
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); err != nil {
t.Fatal(err)
}
})
t.Run("owned remove", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if filepath.Base(path) == "payload" {
return failure
}
return originalRemove(path)
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("remove error = %v", err)
}
})
t.Run("quarantined fingerprint", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "value", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if filepath.Base(path) == "payload" {
return nil, failure
}
return originalLstat(path)
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("fingerprint error = %v", err)
}
})
t.Run("mismatch restore and cleanup", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "published", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalRename := skillPathRename
testseam.Swap(t, &skillPathRename, func(source, target string) error {
if source == destination {
if err := os.RemoveAll(source); err != nil {
return err
}
seedUpgradeSkill(t, source, "replacement", false)
}
return originalRename(source, target)
})
originalRemove := skillPathRemoveAll
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
if strings.Contains(filepath.Base(path), ".destination.rollback-") {
return failure
}
return originalRemove(path)
})
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
t.Fatalf("mismatch cleanup error = %v", err)
}
assertUpgradeSkillContent(t, destination, "replacement")
})
t.Run("mismatch restore failure", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "published", false)
publication, err := PublishSkillPathNoReplace(staged, destination)
if err != nil {
t.Fatal(err)
}
originalRename := skillPathRename
testseam.Swap(t, &skillPathRename, func(source, target string) error {
if source == destination {
if err := os.RemoveAll(source); err != nil {
return err
}
seedUpgradeSkill(t, source, "replacement", false)
}
return originalRename(source, target)
})
testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", failure })
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) || !strings.Contains(err.Error(), "并发对象保留") {
t.Fatalf("mismatch restore error = %v", err)
}
})
t.Run("fingerprint read failures and lexical types", func(t *testing.T) {
t.Run("directory ordering", func(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"b", "a"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
t.Fatal(err)
}
}
if _, err := fingerprintSkillPath(dir); err != nil {
t.Fatal(err)
}
})
t.Run("directory read", func(t *testing.T) {
dir := t.TempDir()
testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, err := fingerprintSkillPath(dir); !errors.Is(err, failure) {
t.Fatalf("read dir error = %v", err)
}
})
t.Run("recursive child", func(t *testing.T) {
dir := t.TempDir()
child := filepath.Join(dir, "child")
if err := os.WriteFile(child, []byte("value"), 0o644); err != nil {
t.Fatal(err)
}
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == child {
return nil, failure
}
return originalLstat(path)
})
if _, err := fingerprintSkillPath(dir); !errors.Is(err, failure) {
t.Fatalf("child error = %v", err)
}
})
t.Run("symlink read", func(t *testing.T) {
link := filepath.Join(t.TempDir(), "link")
if err := os.Symlink("missing", link); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", failure })
if _, err := fingerprintSkillPath(link); !errors.Is(err, failure) {
t.Fatalf("readlink error = %v", err)
}
})
t.Run("symlink success", func(t *testing.T) {
link := filepath.Join(t.TempDir(), "link")
if err := os.Symlink("missing", link); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
if _, err := fingerprintSkillPath(link); err != nil {
t.Fatal(err)
}
})
t.Run("file digest", func(t *testing.T) {
file := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(file, []byte("value"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathOpen, func(string) (*os.File, error) { return nil, failure })
if _, err := fingerprintSkillPath(file); !errors.Is(err, failure) {
t.Fatalf("digest error = %v", err)
}
})
t.Run("special", func(t *testing.T) {
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) {
return skillPathFakeInfo{mode: os.ModeNamedPipe}, nil
})
if err := fingerprintSkillPathInto(sha256.New(), "special"); err == nil {
t.Fatal("expected special path error")
}
})
t.Run("unknown platform identity shape", func(t *testing.T) {
if got := skillPathFileIncarnation(skillPathFakeInfo{}); got == "" {
t.Fatal("empty incarnation")
}
})
t.Run("synthetic info does not match same-file identity", func(t *testing.T) {
fake := skillPathFakeInfo{}
if skillPathSameFileIdentity(fake, fake) {
t.Fatal("synthetic info should not match")
}
})
})
}
func TestCrossPlatformCoverageUpgradeRollbackRetainsConcurrentReplacement(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".agents", "skills")
first := filepath.Join(base, "dingtalk-first")
second := filepath.Join(base, "dingtalk-second")
seedUpgradeSkill(t, first, "old first", false)
seedUpgradeSkill(t, second, "old second", false)
stageRoot := filepath.Join(base, ".stage")
stagedFirst := filepath.Join(stageRoot, "dingtalk-first")
stagedSecond := filepath.Join(stageRoot, "dingtalk-second")
seedUpgradeSkill(t, stagedFirst, "new first", false)
seedUpgradeSkill(t, stagedSecond, "new second", false)
failure := errors.New("injected second publication failure")
originalPublish := upgradePublishSkillPath
calls := 0
testseam.Swap(t, &upgradePublishSkillPath, func(staged, destination string) (SkillPathPublication, error) {
calls++
if calls == 2 {
if err := os.RemoveAll(first); err != nil {
t.Fatal(err)
}
seedUpgradeSkill(t, first, "concurrent", false)
return SkillPathPublication{}, failure
}
return originalPublish(staged, destination)
})
err := publishStagedSkillSet(home, []stagedSkillDir{
{staged: stagedFirst, dest: first},
{staged: stagedSecond, dest: second},
}, []string{first, second})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "拒绝删除非本事务") {
t.Fatalf("transaction error = %v", err)
}
assertUpgradeSkillContent(t, first, "concurrent")
assertUpgradeSkillContent(t, second, "old second")
matches, globErr := filepath.Glob(filepath.Join(home, ".dws", "skill-backups", "*", ".agents-skills-dingtalk-first", "SKILL.md"))
if globErr != nil || len(matches) != 1 {
t.Fatalf("retained old backup = %v, %v", matches, globErr)
}
content, readErr := os.ReadFile(matches[0])
if readErr != nil || string(content) != "old first" {
t.Fatalf("retained backup content = %q, %v", content, readErr)
}
}
@@ -0,0 +1,451 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// childMoveDirEntry fakes one directory entry for seam-driven claim scans.
type childMoveDirEntry struct {
name string
isDir bool
}
func (entry childMoveDirEntry) Name() string { return entry.name }
func (entry childMoveDirEntry) IsDir() bool { return entry.isDir }
func (entry childMoveDirEntry) Type() os.FileMode { return entry.info().Mode().Type() }
func (entry childMoveDirEntry) Info() (os.FileInfo, error) { return entry.info(), nil }
func (entry childMoveDirEntry) info() os.FileInfo {
if entry.isDir {
return skillPathFakeInfo{mode: os.ModeDir | 0o755}
}
return skillPathFakeInfo{mode: 0o644}
}
// forceChildMove routes every directory publication through the child-move
// slow path: the atomic primitive reports the flag unsupported and the
// fast-path rename fails the way platforms that refuse renaming over any
// existing directory do.
func forceChildMove(t *testing.T) {
t.Helper()
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist })
}
// TestCrossPlatformCoverageChildMoveEdges pins the error and dispatch
// branches of the child-move directory fallback on every platform: the
// platform coverage gates only execute TestCrossPlatformCoverage-named
// tests, so these branches must be driven through seams rather than the
// platform-specific filesystem behaviors that cover them incidentally
// elsewhere.
func TestCrossPlatformCoverageChildMoveEdges(t *testing.T) {
t.Run("directory source stat failure surfaces", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
original := skillPathLstat
calls := 0
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == source {
calls++
if calls > 1 {
return nil, os.ErrPermission
}
}
return original(path)
})
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) {
t.Fatalf("directory stat failure = %v", err)
}
})
t.Run("claim read failure surfaces and reports the cleanup check", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if path == destination {
return nil, os.ErrPermission
}
return os.ReadDir(path)
})
_, err := renameSkillPathNoReplace(source, destination)
if !strings.Contains(err.Error(), "降级发布 Skill 目录失败") || !strings.Contains(err.Error(), "检查降级发布目标") {
t.Fatalf("claim read failure = %v", err)
}
})
t.Run("claim vanished during cleanup is tolerated", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
destinationReads := 0
original := skillPathReadDir
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if path == destination {
destinationReads++
if destinationReads == 1 {
return nil, os.ErrPermission
}
return nil, os.ErrNotExist
}
return original(path)
})
_, err := renameSkillPathNoReplace(source, destination)
if !strings.Contains(err.Error(), "降级发布 Skill 目录失败") || strings.Contains(err.Error(), "检查降级发布目标") {
t.Fatalf("vanished claim cleanup = %v", err)
}
})
t.Run("file child link failure surfaces and the empty claim cleanup reports", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrPermission })
testseam.Swap(t, &skillPathRemove, func(path string) error {
if path == destination {
return os.ErrPermission
}
return os.Remove(path)
})
_, err := renameSkillPathNoReplace(source, destination)
if !strings.Contains(err.Error(), "发布 Skill 文件失败") || !strings.Contains(err.Error(), "清理降级发布目标") {
t.Fatalf("link failure = %v", err)
}
assertUpgradeSkillContent(t, source, "payload")
})
t.Run("file child collision reports ErrExist", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrExist })
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrExist) {
t.Fatalf("file child collision = %v, want ErrExist", err)
}
})
t.Run("later child failure rolls earlier children back", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
if err := os.WriteFile(filepath.Join(source, "second"), []byte("second"), 0o644); err != nil {
t.Fatal(err)
}
forceChildMove(t)
// The second child's link fails; the forced rename failure then also
// breaks the rollback rename of the already relocated first child.
testseam.Swap(t, &skillPathLink, func(source, _ string) error {
if filepath.Base(source) == "second" {
return os.ErrPermission
}
return os.Link(source, filepath.Join(destination, filepath.Base(source)))
})
_, err := renameSkillPathNoReplace(source, destination)
if !strings.Contains(err.Error(), "迁移 Skill 目录项失败") {
t.Fatalf("child failure rollback = %v", err)
}
// The forced rename failure also breaks the rollback restore, so the
// relocated child stays at the destination — retained, never deleted.
assertUpgradeSkillContent(t, destination, "payload")
})
t.Run("live claim read failure aborts with rollback", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
destinationReads := 0
original := skillPathReadDir
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if path == destination {
destinationReads++
if destinationReads == 2 {
return nil, os.ErrPermission
}
}
return original(path)
})
_, err := renameSkillPathNoReplace(source, destination)
if !strings.Contains(err.Error(), "确认降级发布目标") {
t.Fatalf("live claim read failure = %v", err)
}
})
t.Run("foreign claim entry aborts the move", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
destinationReads := 0
original := skillPathReadDir
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if path == destination {
destinationReads++
if destinationReads == 2 {
return []os.DirEntry{
childMoveDirEntry{name: "SKILL.md"},
childMoveDirEntry{name: "foreign"},
}, nil
}
}
return original(path)
})
_, err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, os.ErrExist) || !strings.Contains(err.Error(), "并发目录项") {
t.Fatalf("foreign claim entry = %v", err)
}
})
t.Run("claim mode restore failure aborts with rollback", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { return os.ErrPermission })
_, err := renameSkillPathNoReplace(source, destination)
if !strings.Contains(err.Error(), "恢复已发布 Skill 目录权限") {
t.Fatalf("mode restore failure = %v", err)
}
})
t.Run("source shell removal failure surfaces with the destination retained", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
testseam.Swap(t, &skillPathRemove, func(path string) error {
if path == source {
return os.ErrPermission
}
return os.Remove(path)
})
_, err := renameSkillPathNoReplace(source, destination)
if !strings.Contains(err.Error(), "清理已迁移 Skill 源目录") {
t.Fatalf("shell removal failure = %v", err)
}
assertUpgradeSkillContent(t, destination, "payload")
})
t.Run("nested directories and mixed children publish", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
if err := os.MkdirAll(filepath.Join(source, "nested"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "nested", "inner.txt"), []byte("inner"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "link"), []byte("stub"), 0o644); err != nil {
t.Fatal(err)
}
forceChildMove(t)
// The "link" child is presented to the dispatcher as a symlink; its
// recreation is simulated through seams so the test needs no
// symlink privileges (Windows runners refuse unprivileged symlinks).
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == filepath.Join(source, "link") {
return skillPathFakeInfo{mode: os.ModeSymlink}, nil
}
return originalLstat(path)
})
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil })
testseam.Swap(t, &skillPathSymlink, func(string, string) error { return nil })
claim, err := renameSkillPathNoReplace(source, destination)
if err != nil {
t.Fatal(err)
}
if claim == "" {
t.Fatal("child-move must return the claim identity")
}
assertUpgradeSkillContent(t, destination, "payload")
if data, readErr := os.ReadFile(filepath.Join(destination, "nested", "inner.txt")); readErr != nil || string(data) != "inner" {
t.Fatalf("nested child = %q, %v", data, readErr)
}
if _, statErr := os.Lstat(filepath.Join(source, "link")); !os.IsNotExist(statErr) {
t.Fatalf("simulated symlink child must have its source consumed, stat err=%v", statErr)
}
if _, statErr := os.Lstat(source); !os.IsNotExist(statErr) {
t.Fatalf("source must be gone, stat err=%v", statErr)
}
})
t.Run("special-type child is refused", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == filepath.Join(source, "SKILL.md") {
return mockSpecialInfo{}, nil
}
return originalLstat(path)
})
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, errNoReplaceRenameUnsupported) {
t.Fatalf("special child = %v, want unsupported", err)
}
})
t.Run("symlink child readlink failure surfaces", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == filepath.Join(source, "SKILL.md") {
return skillPathFakeInfo{mode: os.ModeSymlink}, nil
}
return originalLstat(path)
})
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", os.ErrPermission })
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) {
t.Fatalf("symlink readlink failure = %v", err)
}
})
t.Run("symlink child collision reports ErrExist", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == filepath.Join(source, "SKILL.md") {
return skillPathFakeInfo{mode: os.ModeSymlink}, nil
}
return originalLstat(path)
})
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil })
testseam.Swap(t, &skillPathSymlink, func(string, string) error { return os.ErrExist })
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrExist) {
t.Fatalf("symlink collision = %v, want ErrExist", err)
}
})
t.Run("symlink child publish failure surfaces", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == filepath.Join(source, "SKILL.md") {
return skillPathFakeInfo{mode: os.ModeSymlink}, nil
}
return originalLstat(path)
})
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil })
testseam.Swap(t, &skillPathSymlink, func(string, string) error { return os.ErrPermission })
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) {
t.Fatalf("symlink publish failure = %v", err)
}
})
t.Run("child stat failure surfaces", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
originalLstat := skillPathLstat
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
if path == filepath.Join(source, "SKILL.md") {
return nil, os.ErrPermission
}
return originalLstat(path)
})
if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) {
t.Fatalf("child stat failure = %v", err)
}
assertUpgradeSkillContent(t, source, "payload")
})
t.Run("source read failure aborts with the empty claim cleaned", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
forceChildMove(t)
original := skillPathReadDir
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
if path == source {
return nil, os.ErrPermission
}
return original(path)
})
_, err := renameSkillPathNoReplace(source, destination)
if !strings.Contains(err.Error(), "读取源 Skill 目录失败") {
t.Fatalf("source read failure = %v", err)
}
if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) {
t.Fatalf("empty claim must be cleaned, stat err=%v", statErr)
}
assertUpgradeSkillContent(t, source, "payload")
})
t.Run("file identity degrades on unreadable and unrecognizable objects", func(t *testing.T) {
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission })
if id := skillPathFileIdentityImpl("unreadable"); id != "" {
t.Fatalf("unreadable identity = %q, want empty", id)
}
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return skillPathFakeInfo{mode: 0o644}, nil })
if id := skillPathFileIdentityImpl("synthetic"); id != "" {
t.Fatalf("unrecognizable identity = %q, want empty", id)
}
})
t.Run("published content drift after the fast-path rename surfaces", func(t *testing.T) {
dir := t.TempDir()
staged := filepath.Join(dir, "staged")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, staged, "value", false)
originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) {
if _, err := originalRename(source, target); err != nil {
return "", err
}
// The published bytes drift after the rename while the object
// keeps its identity, so only the fingerprint check can refuse.
if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("drifted"), 0o644); err != nil {
return "", err
}
return "", nil
})
if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 内容已变化") {
t.Fatalf("content drift = %v", err)
}
})
}
+290
View File
@@ -0,0 +1,290 @@
package upgrade
import (
"errors"
"fmt"
"os"
"path/filepath"
)
// errNoReplaceRenameUnsupported marks a platform that has no atomic no-replace
// rename primitive at all.
var errNoReplaceRenameUnsupported = errors.New("平台不支持原子 no-replace rename")
var skillPathRenameNoReplaceAtomic = renameSkillPathNoReplaceAtomic
// renameSkillPathNoReplace moves source onto destination and never replaces an
// object that already occupies destination. On success it also returns the
// fresh mkdir-claim identity captured by the child-move directory fallback
// (empty when the atomic primitive or the empty-target fast path published
// dest, or when the platform cannot report a stable file identity). The
// publication path uses that identity as a tamperproof witness that dest is
// still the mkdir claim this transaction created; every other caller ignores
// it.
//
// The atomic primitives (Linux RENAME_NOREPLACE, Darwin RENAME_EXCL) require
// support from the underlying filesystem and report EINVAL/ENOTSUP when it is
// missing — rename(2) lists only ext4, btrfs, tmpfs and cifs, so NFS, FUSE and
// overlayfs homes reject the flag. Failing there would abort the whole install
// on those filesystems, so the fallback uses truly atomic no-clobber primitives
// that work on every POSIX filesystem: os.Mkdir for directories (fails with
// EEXIST if the path is occupied), os.Link for regular files (fails with
// EEXIST if the path is occupied), and os.Symlink for symbolic links (same
// EEXIST guarantee). Neither primitive has a TOCTOU window.
func renameSkillPathNoReplace(source, destination string) (string, error) {
err := skillPathRenameNoReplaceAtomic(source, destination)
if err == nil {
return "", nil
}
if !errors.Is(err, errNoReplaceRenameUnsupported) && !isNoReplaceRenameUnsupported(err) {
return "", err
}
return renameSkillPathNoReplaceFallback(source, destination)
}
// renameSkillPathNoReplaceFallback is used when the kernel no-replace rename
// flag is unavailable. It dispatches on the source type to pick an atomic
// no-clobber primitive that does not require filesystem support. When the
// directory slow path publishes via child-move, the returned claim identity
// is the destination file identity captured immediately after mkdir; every
// other outcome returns an empty claim identity.
func renameSkillPathNoReplaceFallback(source, destination string) (string, error) {
info, err := skillPathLstat(source)
if err != nil {
return "", fmt.Errorf("读取源 Skill 身份失败 %s: %w", source, err)
}
switch {
case info.IsDir():
return renameSkillDirNoReplaceFallback(source, destination)
case info.Mode().IsRegular():
return "", renameSkillFileNoReplaceFallback(source, destination)
default:
return "", &os.LinkError{
Op: "rename", Old: source, New: destination,
Err: fmt.Errorf("%w: 源路径类型不支持降级发布", errNoReplaceRenameUnsupported),
}
}
}
// renameSkillDirNoReplaceFallback claims the destination once with mkdir and
// never unlinks it for the remainder of the transaction. The fast path
// renames the source over the claim; on platforms that permit replacing an
// empty directory (Linux) that rename only ever touches the claim we own.
// Platforms that refuse to rename over any directory (macOS returns EEXIST
// even for an empty target) take the slow path instead — but only after
// proving the claim is still empty: a rename that failed because a
// concurrent writer landed inside the claim must abort with the destination
// retained, never fall through to child moves whose per-entry primitives
// would collide with (and rollback would delete) the foreign data. The slow
// path moves the source children into the claim one by one, each through an
// atomic no-clobber primitive (mkdir claim for directories, os.Link for
// files, os.Symlink for links), so a same-name concurrent entry aborts the
// move and a foreign entry of any name — detected by re-reading the claim —
// retains the destination instead of letting the rollback delete it. The
// destination claim is removed only while still empty, so a foreign entry
// always survives a failed publication. On success the source is fully
// consumed either way: the fast-path rename moves it onto the claim, and
// the child-move slow path removes the emptied source shell after the last
// child relocates.
//
// The returned claim identity is non-empty only when the child-move slow
// path published dest and the platform can report a stable file identity;
// the fast-path rename consumes the mkdir claim (dest inherits the source
// inode) and returns "", as does an unsuccessful publish. Callers that need
// a tamperproof ownership witness (see PublishSkillPathNoReplace) compare
// the returned identity against skillPathFileIdentity(destination) at the
// moment of the ownership check — a wholesale replacement of dest after
// the claim produces a different identity.
//
// The old fallback (mkdir → rename → remove → rename) had a TOCTOU window
// between the second remove and the second rename during which a concurrent
// writer could create a file or empty directory at dest that the second
// os.Rename would silently overwrite. This implementation removes that
// window: the claim is never unlinked between the fast-path rename attempt
// and publication, and child-move relies exclusively on atomic no-clobber
// primitives that fail with EEXIST rather than replace.
func renameSkillDirNoReplaceFallback(source, destination string) (string, error) {
sourceInfo, err := skillPathLstat(source)
if err != nil {
return "", fmt.Errorf("读取源 Skill 身份失败 %s: %w", source, err)
}
if err := skillPathMkdir(destination, 0o700); err != nil {
if os.IsExist(err) {
return "", &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist}
}
return "", fmt.Errorf("声明 Skill 发布目标失败 %s: %w", destination, err)
}
// Capture the claim identity immediately after mkdir but before any
// children move in. A concurrent replacement of destination (unlink +
// recreate) after this point produces a different identity.
claimID := skillPathFileIdentity(destination)
if err := skillPathRename(source, destination); err == nil {
// Fast path consumed the source: destination inode is now the
// staged inode, not the mkdir claim. Return no claim identity —
// the caller falls back to the standard staged-identity proof.
return "", nil
}
claimEntries, claimErr := skillPathReadDir(destination)
if claimErr != nil {
return "", errors.Join(
fmt.Errorf("降级发布 Skill 目录失败 %s: %w", destination, claimErr),
removeClaimIfEmpty(destination),
)
}
if len(claimEntries) > 0 {
// A concurrent writer populated the empty claim before we could
// take the slow path. Falling through to child-move would let
// its per-entry no-clobber primitives collide with the foreign
// data; abort with the destination retained instead.
return "", fmt.Errorf("降级发布中止,目标在声明后被并发写入 %s: %w", destination, os.ErrExist)
}
if err := moveSkillDirChildrenIntoClaim(source, destination, sourceInfo.Mode().Perm()); err != nil {
return "", fmt.Errorf("降级发布 Skill 目录失败 %s: %w", destination, err)
}
// Child-move consumed every source entry, so the emptied source shell is
// ours by construction. os.Remove refuses a non-empty directory, so a
// concurrent writer inside the staging tree fails the publication loudly
// instead of being silently discarded with the shell.
if err := skillPathRemove(source); err != nil && !os.IsNotExist(err) {
return "", fmt.Errorf("清理已迁移 Skill 源目录失败 %s: %w", source, err)
}
// Child-move succeeded: dest is still the mkdir claim we created.
return claimID, nil
}
// moveSkillDirChildNoReplace moves one child of a staged directory into the
// claimed destination through an atomic no-clobber primitive: directories
// recursively claim with mkdir, regular files publish via os.Link, and
// symlinks are recreated with os.Symlink. Every primitive fails with EEXIST
// when the destination entry was taken concurrently, so no step can replace
// a foreign object.
func moveSkillDirChildNoReplace(source, destination string) error {
info, err := skillPathLstat(source)
if err != nil {
return fmt.Errorf("读取源 Skill 目录项失败 %s: %w", source, err)
}
switch {
case info.IsDir():
// The nested fallback consumes the nested source on success — the
// fast-path rename moves it away and the child-move slow path
// removes the emptied shell. The nested claim identity is not a
// witness for this transaction (only the top-level destination's
// identity is), so it is discarded here.
_, err := renameSkillDirNoReplaceFallback(source, destination)
return err
case info.Mode().IsRegular():
return renameSkillFileNoReplaceFallback(source, destination)
case info.Mode()&os.ModeSymlink != 0:
return renameSkillSymlinkNoReplaceFallback(source, destination)
default:
return &os.LinkError{
Op: "rename", Old: source, New: destination,
Err: fmt.Errorf("%w: 源路径类型不支持降级发布", errNoReplaceRenameUnsupported),
}
}
}
// removeClaimIfEmpty deletes the claimed destination only while it still
// holds no entries. A foreign entry that appeared after the claim must never
// be removed: the rollback has no ownership proof for it.
func removeClaimIfEmpty(destination string) error {
entries, err := skillPathReadDir(destination)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("检查降级发布目标失败 %s: %w", destination, err)
}
if len(entries) > 0 {
return nil
}
if err := skillPathRemove(destination); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("清理降级发布目标失败 %s: %w", destination, err)
}
return nil
}
// moveSkillDirChildrenIntoClaim moves every top-level child of source into
// the claimed destination — each through moveSkillDirChildNoReplace, so a
// concurrently created same-name entry fails atomically — and restores the
// source mode on the claim. The claim is re-read after the moves: an entry
// count above the source's means a different-named foreign object landed
// mid-move, and the publication aborts with the destination retained. Any
// failure moves the already relocated children back and removes the claim
// only while it is empty, leaving the source intact and foreign entries in
// place.
func moveSkillDirChildrenIntoClaim(source, destination string, sourceMode os.FileMode) error {
entries, err := skillPathReadDir(source)
if err != nil {
return errors.Join(
fmt.Errorf("读取源 Skill 目录失败 %s: %w", source, err),
removeClaimIfEmpty(destination),
)
}
moved := make([]string, 0, len(entries))
rollback := func() error {
var rollbackErr error
for i := len(moved) - 1; i >= 0; i-- {
if err := skillPathRename(filepath.Join(destination, moved[i]), filepath.Join(source, moved[i])); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
rollbackErr = errors.Join(rollbackErr, removeClaimIfEmpty(destination))
return rollbackErr
}
for _, entry := range entries {
name := entry.Name()
if err := moveSkillDirChildNoReplace(filepath.Join(source, name), filepath.Join(destination, name)); err != nil {
return errors.Join(fmt.Errorf("迁移 Skill 目录项失败 %s: %w", name, err), rollback())
}
moved = append(moved, name)
}
live, liveErr := skillPathReadDir(destination)
if liveErr != nil {
return errors.Join(
fmt.Errorf("确认降级发布目标失败 %s: %w", destination, liveErr),
rollback(),
)
}
if len(live) > len(entries) {
return errors.Join(
fmt.Errorf("降级发布中止,目标出现并发目录项 %s: %w", destination, os.ErrExist),
rollback(),
)
}
if err := skillPathChmod(destination, sourceMode); err != nil {
return errors.Join(
fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", destination, err),
rollback(),
)
}
return nil
}
// renameSkillFileNoReplaceFallback uses os.Link, which atomically fails if the
// destination exists, then removes the source to complete the move.
func renameSkillFileNoReplaceFallback(source, destination string) error {
if err := skillPathLink(source, destination); err != nil {
if os.IsExist(err) {
return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist}
}
return fmt.Errorf("发布 Skill 文件失败 %s: %w", destination, err)
}
return skillPathRemove(source)
}
// renameSkillSymlinkNoReplaceFallback recreates the link at the destination
// with os.Symlink — atomic, EEXIST when taken — and then removes the source.
func renameSkillSymlinkNoReplaceFallback(source, destination string) error {
target, err := skillPathReadlink(source)
if err != nil {
return fmt.Errorf("读取源 Skill 链接失败 %s: %w", source, err)
}
if err := skillPathSymlink(target, destination); err != nil {
if os.IsExist(err) {
return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist}
}
return fmt.Errorf("发布 Skill 链接失败 %s: %w", destination, err)
}
return skillPathRemove(source)
}
@@ -0,0 +1,22 @@
//go:build darwin
package upgrade
import (
"errors"
"syscall"
"golang.org/x/sys/unix"
)
func renameSkillPathNoReplaceAtomic(source, destination string) error {
return unix.RenamexNp(source, destination, unix.RENAME_EXCL)
}
// Volumes whose filesystem does not implement renamex_np (several network and
// FUSE mounts) reject RENAME_EXCL instead of performing the rename.
func isNoReplaceRenameUnsupported(err error) bool {
return errors.Is(err, syscall.ENOTSUP) ||
errors.Is(err, syscall.EINVAL) ||
errors.Is(err, syscall.ENOSYS)
}
@@ -0,0 +1,23 @@
//go:build linux
package upgrade
import (
"errors"
"syscall"
"golang.org/x/sys/unix"
)
func renameSkillPathNoReplaceAtomic(source, destination string) error {
return unix.Renameat2(unix.AT_FDCWD, source, unix.AT_FDCWD, destination, unix.RENAME_NOREPLACE)
}
// rename(2) documents EINVAL as "the filesystem does not support one of the
// flags", which is what NFS, FUSE and overlayfs return; ENOSYS covers kernels
// without renameat2 at all.
func isNoReplaceRenameUnsupported(err error) bool {
return errors.Is(err, syscall.EINVAL) ||
errors.Is(err, syscall.ENOSYS) ||
errors.Is(err, syscall.EOPNOTSUPP)
}
@@ -0,0 +1,9 @@
//go:build !linux && !darwin && !windows
package upgrade
func renameSkillPathNoReplaceAtomic(_, _ string) error {
return errNoReplaceRenameUnsupported
}
func isNoReplaceRenameUnsupported(error) bool { return false }
@@ -0,0 +1,300 @@
package upgrade
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
type mockSpecialInfo struct{}
func (mockSpecialInfo) Name() string { return "source" }
func (mockSpecialInfo) Size() int64 { return 0 }
func (mockSpecialInfo) Mode() os.FileMode { return os.ModeNamedPipe }
func (mockSpecialInfo) ModTime() time.Time { return time.Time{} }
func (mockSpecialInfo) IsDir() bool { return false }
func (mockSpecialInfo) Sys() interface{} { return nil }
// TestCrossPlatformCoverageNoReplaceRenameFallback pins the degradation path for
// filesystems that reject RENAME_NOREPLACE / RENAME_EXCL (NFS, FUSE, overlayfs).
// Those installs must still publish, and must still refuse to clobber an
// occupied destination. The fallback uses atomic no-clobber primitives
// (os.Mkdir for directories, os.Link for files) instead of a TOCTOU-prone
// check-then-rename.
func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) {
unsupported := testNoReplaceUnsupportedErrors()
t.Run("publishes a file when the filesystem rejects the flag", func(t *testing.T) {
for _, unsupportedErr := range unsupported {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return unsupportedErr })
if _, err := renameSkillPathNoReplace(source, destination); err != nil {
t.Fatalf("fallback must publish for %v, got %v", unsupportedErr, err)
}
if data, err := os.ReadFile(destination); err != nil || string(data) != "payload" {
t.Fatalf("destination = %q, %v", data, err)
}
if _, err := os.Lstat(source); !os.IsNotExist(err) {
t.Fatalf("source must be gone, stat err=%v", err)
}
}
})
t.Run("publishes a directory when the filesystem rejects the flag", func(t *testing.T) {
for _, unsupportedErr := range unsupported {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return unsupportedErr })
if _, err := renameSkillPathNoReplace(source, destination); err != nil {
t.Fatalf("fallback must publish for %v, got %v", unsupportedErr, err)
}
assertUpgradeSkillContent(t, destination, "payload")
if _, err := os.Lstat(source); !os.IsNotExist(err) {
t.Fatalf("source must be gone, stat err=%v", err)
}
}
})
t.Run("still refuses an occupied destination for files", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
for _, path := range []string{source, destination} {
if err := os.WriteFile(path, []byte(filepath.Base(path)), 0o644); err != nil {
t.Fatal(err)
}
}
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
_, err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, os.ErrExist) {
t.Fatalf("occupied destination must report ErrExist, got %v", err)
}
if data, readErr := os.ReadFile(destination); readErr != nil || string(data) != "destination" {
t.Fatalf("destination must be untouched, got %q, %v", data, readErr)
}
if _, statErr := os.Lstat(source); statErr != nil {
t.Fatalf("source must be preserved: %v", statErr)
}
})
t.Run("file link failure with non-EEXIST error surfaces", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrPermission })
_, err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, os.ErrPermission) {
t.Fatalf("link error must surface, got %v", err)
}
if _, statErr := os.Lstat(source); statErr != nil {
t.Fatalf("source must be preserved: %v", statErr)
}
})
t.Run("refuses an occupied directory destination via mkdir EEXIST", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
seedUpgradeSkill(t, destination, "existing", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
_, err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, os.ErrExist) {
t.Fatalf("occupied destination must report ErrExist, got %v", err)
}
assertUpgradeSkillContent(t, destination, "existing")
assertUpgradeSkillContent(t, source, "payload")
})
t.Run("concurrent entry in the claimed directory aborts with the destination retained", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
testseam.Swap(t, &skillPathRename, func(_, dst string) error {
// A concurrent writer lands inside the empty claim before the
// slow path can take it; the fast-path rename then fails the
// way it would against a populated target.
if err := os.WriteFile(filepath.Join(dst, "foreign"), []byte("foreign"), 0o644); err != nil {
return err
}
return os.ErrExist
})
_, err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, os.ErrExist) || !strings.Contains(err.Error(), "并发写入") {
t.Fatalf("concurrent claim entry must abort with ErrExist, got %v", err)
}
if data, readErr := os.ReadFile(filepath.Join(destination, "foreign")); readErr != nil || string(data) != "foreign" {
t.Fatalf("foreign entry must be retained, got %q, %v", data, readErr)
}
assertUpgradeSkillContent(t, source, "payload")
})
t.Run("a genuine error is not retried", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
calls := 0
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error {
calls++
return os.ErrPermission
})
if _, err := renameSkillPathNoReplace(source, filepath.Join(dir, "destination")); !errors.Is(err, os.ErrPermission) {
t.Fatalf("permission error must surface, got %v", err)
}
if calls != 1 {
t.Fatalf("atomic attempts = %d, want 1", calls)
}
})
t.Run("source stat failure surfaces without rename", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
destination := filepath.Join(dir, "destination")
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission })
_, err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, os.ErrPermission) {
t.Fatalf("stat error must surface, got %v", err)
}
if _, statErr := os.Lstat(source); statErr != nil {
t.Fatalf("source must be preserved: %v", statErr)
}
})
t.Run("cross-device errors still reach the copy fallback", func(t *testing.T) {
crossDevice := testCrossDeviceError()
if isNoReplaceRenameUnsupported(crossDevice) {
t.Fatal("cross-device error must not be treated as an unsupported flag")
}
dir := t.TempDir()
source := filepath.Join(dir, "source")
if err := os.MkdirAll(source, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "SKILL.md"), []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
destination := filepath.Join(dir, "backup", "destination")
original := skillPathRenameNoReplaceAtomic
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(from, to string) error {
if from == source {
return crossDevice
}
return original(from, to)
})
if err := moveSkillPathRecoverably(source, destination); err != nil {
t.Fatalf("cross-device move = %v", err)
}
if data, err := os.ReadFile(filepath.Join(destination, "SKILL.md")); err != nil || string(data) != "payload" {
t.Fatalf("cross-device destination = %q, %v", data, err)
}
})
t.Run("mkdir failure with non-EEXIST error surfaces", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
testseam.Swap(t, &skillPathMkdir, func(string, os.FileMode) error { return os.ErrPermission })
_, err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, os.ErrPermission) {
t.Fatalf("mkdir error must surface, got %v", err)
}
assertUpgradeSkillContent(t, source, "payload")
})
t.Run("remove failure after rename failure surfaces", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
testseam.Swap(t, &skillPathRename, func(string, string) error { return errors.New("rename failed") })
removeErr := errors.New("simulated remove failure")
testseam.Swap(t, &skillPathRemove, func(string) error { return removeErr })
_, err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, removeErr) {
t.Fatalf("remove error must surface, got %v", err)
}
if _, statErr := os.Lstat(destination); statErr != nil {
t.Fatalf("destination must still exist after remove failure: %v", statErr)
}
assertUpgradeSkillContent(t, source, "payload")
_ = os.RemoveAll(destination)
})
t.Run("fast-path rename failure alone publishes through child-move", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
// macOS refuses to rename over any directory, empty or not. The
// claim's verified emptiness — not the rename error — decides
// whether the child-move slow path may proceed.
testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist })
if _, err := renameSkillPathNoReplace(source, destination); err != nil {
t.Fatalf("child-move must publish despite rename failure, got %v", err)
}
assertUpgradeSkillContent(t, destination, "payload")
if _, err := os.Lstat(source); !os.IsNotExist(err) {
t.Fatalf("source must be gone, stat err=%v", err)
}
})
t.Run("directory publish when first rename replaces empty dir", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
originalRename := skillPathRename
testseam.Swap(t, &skillPathRename, func(src, dst string) error {
_ = os.Remove(dst)
return originalRename(src, dst)
})
if _, err := renameSkillPathNoReplace(source, destination); err != nil {
t.Fatalf("publish must succeed when rename replaces empty dir, got %v", err)
}
assertUpgradeSkillContent(t, destination, "payload")
})
t.Run("non-regular non-directory source fails safely", func(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return mockSpecialInfo{}, nil })
_, err := renameSkillPathNoReplace(source, destination)
if !errors.Is(err, errNoReplaceRenameUnsupported) {
t.Fatalf("unsupported error must surface for special files, got %v", err)
}
})
}
@@ -0,0 +1,22 @@
//go:build windows
package upgrade
import "golang.org/x/sys/windows"
func renameSkillPathNoReplaceAtomic(source, destination string) error {
sourcePtr, err := windows.UTF16PtrFromString(source)
if err != nil {
return err
}
destinationPtr, err := windows.UTF16PtrFromString(destination)
if err != nil {
return err
}
return windows.MoveFile(sourcePtr, destinationPtr)
}
// MoveFile without MOVEFILE_REPLACE_EXISTING already refuses an occupied
// destination on every supported Windows version, so there is nothing to
// degrade to.
func isNoReplaceRenameUnsupported(error) bool { return false }
@@ -92,10 +92,10 @@ func TestCrossPlatformCoverageMultiUpgradeTransactionPreservesOldSet(t *testing.
t.Run("backup failure restores earlier victims", func(t *testing.T) {
home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t)
originalRename := upgradeRename
testseam.Swap(t, &upgradeRename, func(src, dest string) error {
originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) (string, error) {
if src == filepath.Join(base, "dingtalk-b") {
return failure
return "", failure
}
return originalRename(src, dest)
})
@@ -107,14 +107,14 @@ func TestCrossPlatformCoverageMultiUpgradeTransactionPreservesOldSet(t *testing.
t.Run("publish failure rolls back complete set", func(t *testing.T) {
home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t)
originalRename := upgradeRename
originalPublish := upgradePublishSkillPath
failed := false
testseam.Swap(t, &upgradeRename, func(src, dest string) error {
testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) {
if !failed && strings.Contains(src, ".dws-upgrade-multi-") && filepath.Base(dest) == "dingtalk-b" {
failed = true
return failure
return SkillPathPublication{}, failure
}
return originalRename(src, dest)
return originalPublish(src, dest)
})
if err := publishMultiUpgradeTarget(home, base, multiRoot, skills, skillSet); !errors.Is(err, failure) {
t.Fatalf("publish failure = %v, want injected failure", err)
@@ -156,14 +156,14 @@ func TestCrossPlatformCoverageMonoUpgradeTransactionPreservesOldSet(t *testing.T
t.Run("publish failure rolls back mono and multi", func(t *testing.T) {
home, base, monoRoot := seed(t)
originalRename := upgradeRename
originalPublish := upgradePublishSkillPath
failed := false
testseam.Swap(t, &upgradeRename, func(src, dest string) error {
testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) {
if !failed && strings.Contains(src, ".dws-upgrade-mono-") && filepath.Base(dest) == "dws" {
failed = true
return failure
return SkillPathPublication{}, failure
}
return originalRename(src, dest)
return originalPublish(src, dest)
})
if err := publishMonoUpgradeTarget(home, base, monoRoot); !errors.Is(err, failure) {
t.Fatalf("publish failure = %v, want injected failure", err)
@@ -211,10 +211,10 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T)
})
t.Run("restore reports removal and directory failures", func(t *testing.T) {
testseam.Swap(t, &upgradeRemoveAll, func(string) error { return failure })
testseam.Swap(t, &upgradeRollbackPublishedSkillPaths, func([]SkillPathPublication) error { return failure })
testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return restoreFailure })
err := restoreSkillSet(
[]string{filepath.Join(t.TempDir(), "published")},
[]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "published")}},
[]backedUpSkillDir{{original: filepath.Join(t.TempDir(), "old"), backup: filepath.Join(t.TempDir(), "backup")}},
)
if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) {
@@ -223,7 +223,7 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T)
})
t.Run("restore reports rename failure", func(t *testing.T) {
testseam.Swap(t, &upgradeRename, func(string, string) error { return restoreFailure })
testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", restoreFailure })
err := restoreSkillSet(nil, []backedUpSkillDir{{original: filepath.Join(t.TempDir(), "old"), backup: filepath.Join(t.TempDir(), "backup")}})
if !errors.Is(err, restoreFailure) {
t.Fatalf("restore rename error = %v", err)
@@ -236,13 +236,13 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T)
second := filepath.Join(home, "second")
seedUpgradeSkill(t, first, "first", false)
seedUpgradeSkill(t, second, "second", false)
originalRename := upgradeRename
testseam.Swap(t, &upgradeRename, func(src, dest string) error {
originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) (string, error) {
switch {
case src == second:
return failure
return "", failure
case strings.Contains(filepath.ToSlash(src), skillBackupSubdir):
return restoreFailure
return "", restoreFailure
default:
return originalRename(src, dest)
}
@@ -259,16 +259,15 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T)
staged := filepath.Join(home, "skills", ".stage", "dws")
seedUpgradeSkill(t, old, "old", false)
seedUpgradeSkill(t, staged, "new", false)
originalRename := upgradeRename
testseam.Swap(t, &upgradeRename, func(src, dest string) error {
switch {
case src == staged:
return failure
case strings.Contains(filepath.ToSlash(src), skillBackupSubdir):
return restoreFailure
default:
return originalRename(src, dest)
testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) {
return SkillPathPublication{}, failure
})
originalRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) (string, error) {
if strings.Contains(filepath.ToSlash(src), skillBackupSubdir) {
return "", restoreFailure
}
return originalRename(src, dest)
})
err := publishStagedSkillSet(home, []stagedSkillDir{{staged: staged, dest: old}}, []string{old})
if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) {
@@ -0,0 +1,176 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// TestCrossPlatformCoverageUncertainPublicationState pins the contract behind
// ErrSkillPathPublicationUncertain: a child-move publication whose mkdir
// claim no longer describes the destination reports the uncertain sentinel
// instead of guessing, keeps the destination in place, and stops upstream
// retry paths (the linked-copy fallback) from displacing the concurrent
// writer's object.
func TestCrossPlatformCoverageUncertainPublicationState(t *testing.T) {
t.Run("destination replaced after child-move reports uncertain and is retained", func(t *testing.T) {
root := t.TempDir()
staged := filepath.Join(root, "staged")
destination := filepath.Join(root, "destination")
seedUpgradeSkill(t, staged, "payload", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
// Force the child-move slow path on every platform (Linux would
// otherwise publish through the fast-path rename over the claim).
testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist })
// The identity seam simulates the wholesale replacement: the mkdir
// claim captures "claim", every later read of the destination
// reports the replacement's "foreign" identity. This keeps the
// test deterministic on platforms without a stable file identity.
destinationReads := 0
testseam.Swap(t, &skillPathFileIdentity, func(path string) string {
if path != destination {
return ""
}
destinationReads++
if destinationReads == 1 {
return "claim"
}
return "foreign"
})
_, err := PublishSkillPathNoReplace(staged, destination)
if !errors.Is(err, ErrSkillPathPublicationUncertain) {
t.Fatalf("replacement after child-move = %v, want uncertain sentinel", err)
}
if destinationReads != 2 {
t.Fatalf("destination identity reads = %d, want 2 (claim capture + ownership check)", destinationReads)
}
// Nothing was rolled back over the retained destination.
assertUpgradeSkillContent(t, destination, "payload")
})
t.Run("child-move reports the real claim identity where the platform supports it", func(t *testing.T) {
dir := t.TempDir()
if id := skillPathFileIdentity(dir); id == "" {
t.Skip("platform reports no stable file identity; the claim witness degrades to the fingerprint backstop")
}
source := filepath.Join(dir, "source")
destination := filepath.Join(dir, "destination")
seedUpgradeSkill(t, source, "payload", false)
testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist })
claim, err := renameSkillPathNoReplace(source, destination)
if err != nil {
t.Fatal(err)
}
if claim == "" {
t.Fatal("child-move must return the mkdir-claim identity on platforms that report one")
}
if live := skillPathFileIdentity(destination); live == "" || live != claim {
t.Fatalf("destination identity = %q, want the published claim %q", live, claim)
}
})
t.Run("uncertain publication passes through the staged-set transaction", func(t *testing.T) {
home := t.TempDir()
old := filepath.Join(home, "skills", "dws")
staged := filepath.Join(home, "skills", ".stage", "dws")
seedUpgradeSkill(t, old, "old", false)
seedUpgradeSkill(t, staged, "new", false)
testseam.Swap(t, &upgradePublishSkillPath, func(string, string) (SkillPathPublication, error) {
return SkillPathPublication{}, fmt.Errorf("并发写入: %w", ErrSkillPathPublicationUncertain)
})
err := publishStagedSkillSet(home, []stagedSkillDir{{staged: staged, dest: old}}, []string{old})
if !errors.Is(err, ErrSkillPathPublicationUncertain) {
t.Fatalf("staged-set publish = %v, want uncertain passthrough", err)
}
// The pre-publish victim is restored; the uncertain destination is
// never retried or overwritten.
assertUpgradeSkillContent(t, old, "old")
})
t.Run("mono copy fallback does not retry an uncertain destination", func(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".agents", "skills")
seedUpgradeSkill(t, filepath.Join(base, "dws"), "old mono", false)
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
monoRoot := t.TempDir()
if err := os.WriteFile(filepath.Join(monoRoot, "SKILL.md"), []byte("new mono"), 0o644); err != nil {
t.Fatal(err)
}
linkedDest := filepath.Join(home, ".claude", "skills", "dws")
linkedAttempts := 0
originalPublish := upgradePublishSkillPath
testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) {
if dest == linkedDest {
linkedAttempts++
return SkillPathPublication{}, fmt.Errorf("%w:链接发布状态不确定", ErrSkillPathPublicationUncertain)
}
return originalPublish(src, dest)
})
result, err := upgradeMonoSkillLocations(home, monoRoot)
if err != nil {
t.Fatalf("mono upgrade = %v", err)
}
if linkedAttempts != 1 {
t.Fatalf("linked publish attempts = %d, want exactly 1 (the copy fallback must not retry an uncertain destination)", linkedAttempts)
}
recorded := false
for _, entry := range result.Results {
if entry.Dir == linkedDest && entry.Status == SkillDirFailed && errors.Is(entry.Err, ErrSkillPathPublicationUncertain) {
recorded = true
}
}
if !recorded {
t.Fatalf("results = %#v, want a recorded uncertain failure for %s", result.Results, linkedDest)
}
})
t.Run("multi copy fallback does not retry an uncertain destination", func(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".agents", "skills")
seedUpgradeSkill(t, filepath.Join(base, "dingtalk-a"), "old a", false)
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-a")
linkedBase := filepath.Join(home, ".claude", "skills")
linkedAttempts := 0
originalPublish := upgradePublishSkillPath
testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) {
if strings.HasPrefix(dest, linkedBase) {
linkedAttempts++
return SkillPathPublication{}, fmt.Errorf("%w:链接发布状态不确定", ErrSkillPathPublicationUncertain)
}
return originalPublish(src, dest)
})
result, err := upgradeMultiSkillLocations(home, multiRoot, []string{"dingtalk-a"})
if err != nil {
t.Fatalf("multi upgrade = %v", err)
}
if linkedAttempts != 1 {
t.Fatalf("linked publish attempts = %d, want exactly 1 (the copy fallback must not retry an uncertain destination)", linkedAttempts)
}
recorded := false
for _, entry := range result.Results {
if entry.Dir == linkedBase && entry.Status == SkillDirFailed && errors.Is(entry.Err, ErrSkillPathPublicationUncertain) {
recorded = true
}
}
if !recorded {
t.Fatalf("results = %#v, want a recorded uncertain failure for %s", result.Results, linkedBase)
}
})
}
+11 -5
View File
@@ -43,11 +43,11 @@ func defaultHooks() *Hooks {
}
}
// openSupplementServers returns helper-only MCP endpoints owned by the open
// CLI. They are callable by explicit server ID but are deliberately excluded
// from VisibleProducts, so no top-level product command is generated. They
// stay separate from syncdata.StaticServers because only explicitly wired CLI
// helpers may call them; they are not public MCP product surfaces.
// openSupplementServers returns explicitly wired MCP endpoints owned by the
// open CLI. They are callable by explicit server ID but are deliberately
// excluded from VisibleProducts, so this hook never generates a top-level
// product command. A public command using one of these endpoints must register
// its Cobra tree explicitly via helpers.RegisterPublic.
func openSupplementServers() []ServerInfo {
return []ServerInfo{
{
@@ -60,6 +60,12 @@ func openSupplementServers() []ServerInfo {
Name: "钉钉白板",
Endpoint: "https://mcp-gw.dingtalk.com/server/whiteboard",
},
{
ID: "recruit",
Name: "钉钉招聘",
Endpoint: "https://mcp-gw.dingtalk.com/server/f69b54ada16c57b603c0e5e1c36f464ba73dcee28d64bb701ff2682c259c0cff",
Prefixes: []string{"recruit", "job"},
},
}
}
+8
View File
@@ -107,7 +107,12 @@ func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) {
servers := openSupplementServers()
foundMCPMeta := false
foundWhiteboard := false
foundRecruit := false
for _, server := range servers {
if server.ID == "recruit" {
foundRecruit = server.Endpoint == "https://mcp-gw.dingtalk.com/server/f69b54ada16c57b603c0e5e1c36f464ba73dcee28d64bb701ff2682c259c0cff" &&
len(server.Prefixes) == 2 && server.Prefixes[0] == "recruit" && server.Prefixes[1] == "job"
}
if server.ID == "whiteboard" {
foundWhiteboard = server.Endpoint == "https://mcp-gw.dingtalk.com/server/whiteboard"
}
@@ -128,4 +133,7 @@ func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) {
if !foundWhiteboard {
t.Fatal("openSupplementServers() missing helper-only whiteboard endpoint")
}
if !foundRecruit {
t.Fatal("openSupplementServers() missing explicitly wired recruit endpoint")
}
}
+591 -19
View File
@@ -22,9 +22,529 @@ $InstallDir = if ($env:DWS_INSTALL_DIR) { $env:DWS_INSTALL_DIR } else { Join-Pat
$NoSkills = $env:DWS_NO_SKILLS -eq "1"
$SkillName = "dingtalk-misc"
# vercel-labs/skills agents.ts (c6f69c6). Each row is
# id|universal|effective global directory; '-' means no global directory.
$AgentRegistryRows = @(
"aider-desk|0|.aider-desk\skills", "amp|1|.config\agents\skills",
"antigravity|1|.gemini\antigravity\skills", "antigravity-cli|1|.gemini\antigravity-cli\skills",
"astrbot|0|.astrbot\data\skills", "autohand-code|0|.autohand\skills", "augment|0|.augment\skills",
"bob|0|.bob\skills", "claude-code|0|.claude\skills", "openclaw|0|.openclaw\skills",
"cline|1|.agents\skills", "codearts-agent|0|.codeartsdoer\skills", "codebuddy|0|.codebuddy\skills",
"codemaker|0|.codemaker\skills", "codestudio|0|.codestudio\skills", "codex|1|.codex\skills",
"command-code|0|.commandcode\skills", "continue|0|.continue\skills", "cortex|0|.snowflake\cortex\skills",
"crush|0|.config\crush\skills", "cursor|1|.cursor\skills", "deepagents|1|.deepagents\agent\skills",
"devin|0|.config\devin\skills", "dexto|1|.agents\skills", "droid|0|.factory\skills", "eve|0|-",
"firebender|1|.firebender\skills", "forgecode|0|.forge\skills", "gemini-cli|1|.gemini\skills",
"github-copilot|1|.copilot\skills", "goose|0|.config\goose\skills", "grok|0|.grok\skills",
"hermes-agent|0|.hermes\skills", "inference-sh|0|.inferencesh\skills", "jazz|0|.jazz\skills",
"junie|0|.junie\skills", "iflow-cli|0|.iflow\skills", "kilo|0|.kilocode\skills",
"kimchi|0|.config\kimchi\harness\skills", "kimi-code-cli|1|.agents\skills", "kiro-cli|0|.kiro\skills",
"kode|0|.kode\skills", "lingma|0|.lingma\skills", "loaf|1|.agents\skills", "mcpjam|0|.mcpjam\skills",
"minimax-code|0|.minimax\skills", "mistral-vibe|0|.vibe\skills", "moxby|0|.moxby\skills",
"mux|0|.mux\skills", "opencode|1|.config\opencode\skills", "openhands|0|.openhands\skills",
"ona|0|.ona\skills", "pi|0|.pi\agent\skills", "qoder|0|.qoder\skills", "qoder-cn|0|.qoder-cn\skills",
"qwen-code|0|.qwen\skills", "replit|1|.config\agents\skills", "reasonix|0|.reasonix\skills",
"rovodev|0|.rovodev\skills", "roo|0|.roo\skills", "tabnine-cli|0|.tabnine\agent\skills",
"terramind|0|.terramind\skills", "tinycloud|0|.tinycloud\skills", "trae|0|.trae\skills",
"trae-cn|0|.trae-cn\skills", "warp|1|.agents\skills", "windsurf|0|.codeium\windsurf\skills",
"zed|1|.agents\skills", "zcode|0|.zcode\skills", "zencoder|0|.zencoder\skills",
"zenflow|0|.zencoder\skills", "neovate|0|.neovate\skills", "pochi|0|.pochi\skills",
"promptscript|1|-", "adal|0|.adal\skills", "universal|1|.config\agents\skills"
)
$LegacyAgentCleanupRows = @(
"dws-qoderwork|0|.qoderwork\skills", "dws-legacy-github|1|.github\skills",
"dws-legacy-amp|1|.amp\skills", "dws-legacy-cline|1|.cline\skills",
"dws-legacy-windsurf|1|.windsurf\skills"
)
function Say($m) { Write-Host " $m" }
function Die($m) { Write-Host " X $m" -ForegroundColor Red; exit 1 }
function Resolve-AgentBase([string]$row) {
$parts = $row.Split('|')
$id = $parts[0]
if ($parts[2] -eq "-") { return $null }
switch ($id) {
"autohand-code" { if ($env:AUTOHAND_HOME) { return Join-Path $env:AUTOHAND_HOME "skills" } }
"claude-code" { if ($env:CLAUDE_CONFIG_DIR) { return Join-Path $env:CLAUDE_CONFIG_DIR "skills" } }
"codex" { if ($env:CODEX_HOME) { return Join-Path $env:CODEX_HOME "skills" } }
"grok" { if ($env:GROK_HOME) { return Join-Path $env:GROK_HOME "skills" } }
"hermes-agent" { if ($env:HERMES_HOME) { return Join-Path $env:HERMES_HOME "skills" } }
"mistral-vibe" { if ($env:VIBE_HOME) { return Join-Path $env:VIBE_HOME "skills" } }
"openclaw" {
foreach ($legacy in @(".openclaw", ".clawdbot", ".moltbot")) {
$candidate = Join-Path $HOME $legacy
if (Test-Path $candidate -PathType Container) { return Join-Path $candidate "skills" }
}
}
{ $_ -in @("amp", "replit", "universal") } {
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $HOME ".config" }
return Join-Path $xdg "agents\skills"
}
{ $_ -in @("crush", "devin", "goose", "kimchi", "opencode") } {
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $HOME ".config" }
$child = switch ($id) { "crush" { "crush\skills" }; "devin" { "devin\skills" }; "goose" { "goose\skills" }; "kimchi" { "kimchi\harness\skills" }; default { "opencode\skills" } }
return Join-Path $xdg $child
}
}
return Join-Path $HOME $parts[2]
}
function Test-AgentBaseDetected([string]$id, [string]$base) {
$parent = Split-Path $base -Parent
switch ($id) {
"kimchi" { return Test-Path (Split-Path $parent -Parent) -PathType Container }
"tabnine-cli" { return Test-Path (Split-Path $parent -Parent) -PathType Container }
"zcode" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/ZCode.app" -PathType Container) }
"minimax-code" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/MiniMax Code.app" -PathType Container) }
default { return Test-Path $parent -PathType Container }
}
}
function Test-PathLexically([string]$path) {
try { Get-Item -LiteralPath $path -Force -ErrorAction Stop | Out-Null; return $true } catch {}
$parent = Split-Path $path -Parent; $leaf = Split-Path $path -Leaf
if (!(Test-Path $parent -PathType Container)) { return $false }
return $null -ne (Get-ChildItem -LiteralPath $parent -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -eq $leaf } | Select-Object -First 1)
}
function Move-DevSkillPath([string]$Source, [string]$Destination) {
if (Test-PathLexically $Destination) { throw "Skill move destination already exists: $Destination" }
$sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop
if ($sourceItem.PSIsContainer) {
[System.IO.Directory]::Move($Source, $Destination)
} else {
[System.IO.File]::Copy($Source, $Destination, $false)
try {
Remove-DevSkillPathLexically $Source
} catch {
$removeErr = $_
try {
Remove-DevSkillPathLexically $Destination
} catch {
throw "Skill move state uncertain; source $Source and dest $Destination retained: $removeErr; retract failed: $_"
}
throw $removeErr
}
}
}
function Test-DevCrossDeviceMoveError([System.Management.Automation.ErrorRecord]$Record) {
$exception = $Record.Exception
while ($null -ne $exception) {
if (($exception.HResult -band 0xffff) -eq 17) { return $true }
$exception = $exception.InnerException
}
return $false
}
function Copy-DevSkillMetadata($SourceItem, [string]$Destination) {
(Get-Item -LiteralPath $Destination -Force -ErrorAction Stop).Attributes = $SourceItem.Attributes
$nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop"
if ($nativeWindows) {
Set-Acl -LiteralPath $Destination -AclObject (Get-Acl -LiteralPath $SourceItem.FullName -ErrorAction Stop) -ErrorAction Stop
} else {
$mode = [System.IO.File]::GetUnixFileMode($SourceItem.FullName)
[System.IO.File]::SetUnixFileMode($Destination, $mode)
}
}
function Get-DevSkillPermissionFingerprint([string]$Path) {
$nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop"
if ($nativeWindows) { return (Get-Acl -LiteralPath $Path -ErrorAction Stop).Sddl }
return [string][System.IO.File]::GetUnixFileMode($Path)
}
function Copy-DevSkillPathLexically([string]$Source, [string]$Destination) {
$item = Get-Item -LiteralPath $Source -Force -ErrorAction Stop
if ($item.LinkType) {
$itemType = if ($item.LinkType -eq "Junction") { "Junction" } else { "SymbolicLink" }
New-Item -ItemType $itemType -Path $Destination -Target $item.Target -ErrorAction Stop | Out-Null
return
}
if ($item.PSIsContainer) {
New-Item -ItemType Directory -Path $Destination -ErrorAction Stop | Out-Null
foreach ($child in @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop)) {
Copy-DevSkillPathLexically $child.FullName (Join-Path $Destination $child.Name)
}
Copy-DevSkillMetadata $item $Destination
return
}
if ($item -isnot [System.IO.FileInfo]) { throw "unsupported special Skill path $Source" }
[System.IO.File]::Copy($Source, $Destination, $false)
Copy-DevSkillMetadata $item $Destination
}
function Assert-DevSkillPathCopy([string]$Source, [string]$Destination) {
$sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop
$destinationItem = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop
if ([bool]$sourceItem.LinkType -ne [bool]$destinationItem.LinkType -or
$sourceItem.PSIsContainer -ne $destinationItem.PSIsContainer) {
throw "Skill path type mismatch: $Source != $Destination"
}
if ($sourceItem.LinkType) {
if ($sourceItem.LinkType -ne $destinationItem.LinkType -or
($sourceItem.Target -join "`0") -ne ($destinationItem.Target -join "`0")) {
throw "Skill link target mismatch: $Source != $Destination"
}
return
}
if ((Get-DevSkillPermissionFingerprint $Source) -ne (Get-DevSkillPermissionFingerprint $Destination)) {
throw "Skill path permissions mismatch: $Source != $Destination"
}
if ($sourceItem.PSIsContainer) {
$sourceChildren = @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop | Sort-Object -Property Name)
$destinationChildren = @(Get-ChildItem -LiteralPath $Destination -Force -ErrorAction Stop | Sort-Object -Property Name)
if ($sourceChildren.Count -ne $destinationChildren.Count) { throw "Skill directory entries mismatch: $Source != $Destination" }
for ($i = 0; $i -lt $sourceChildren.Count; $i++) {
if ($sourceChildren[$i].Name -ne $destinationChildren[$i].Name) { throw "Skill directory entries mismatch: $Source != $Destination" }
Assert-DevSkillPathCopy $sourceChildren[$i].FullName $destinationChildren[$i].FullName
}
return
}
if ($sourceItem.Length -ne $destinationItem.Length) { throw "Skill file size mismatch: $Source != $Destination" }
if ((Get-FileHash -LiteralPath $Source -Algorithm SHA256 -ErrorAction Stop).Hash -ne
(Get-FileHash -LiteralPath $Destination -Algorithm SHA256 -ErrorAction Stop).Hash) {
throw "Skill file digest mismatch: $Source != $Destination"
}
}
function Remove-DevSkillPathLexically([string]$Path) {
$item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
if ($item.PSIsContainer -and !$item.LinkType) {
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop
} else {
Remove-Item -LiteralPath $Path -Force -ErrorAction Stop
}
}
# Removes a staging directory that may hold junction/symlink children. Link
# children must be removed non-recursively first: Windows PowerShell 5.1 can
# follow reparse points during Remove-Item -Recurse and would delete the
# canonical store's contents through the staged link.
function Remove-DevLinkStageRoot([string]$StageRoot) {
if (!(Test-Path -LiteralPath $StageRoot)) { return $true }
$ok = $true
foreach ($child in @(Get-ChildItem -LiteralPath $StageRoot -Force -ErrorAction SilentlyContinue)) {
try { Remove-DevSkillPathLexically $child.FullName } catch { $ok = $false }
}
try { Remove-Item -LiteralPath $StageRoot -Force -ErrorAction Stop } catch { $ok = $false }
return $ok
}
function Move-DevSkillPathRecoverably([string]$Source, [string]$Destination) {
if (Test-PathLexically $Destination) { throw "move destination already exists: $Destination" }
$destinationParent = Split-Path $Destination -Parent
New-Item -ItemType Directory -Path $destinationParent -Force -ErrorAction Stop | Out-Null
try {
Move-DevSkillPath $Source $Destination
return
} catch {
if (!(Test-DevCrossDeviceMoveError $_)) { throw }
}
$stageRoot = Join-Path $destinationParent ("." + (Split-Path $Destination -Leaf) + ".cross-device-" + [guid]::NewGuid().ToString("N"))
$stage = Join-Path $stageRoot "payload"
New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null
$published = $false
try {
Copy-DevSkillPathLexically $Source $stage
Assert-DevSkillPathCopy $Source $stage
Move-DevSkillPath $stage $Destination
$published = $true
try {
Assert-DevSkillPathCopy $Source $Destination
if (!(Remove-DevLinkStageRoot $stageRoot)) {
throw "Skill staging cleanup failed: $stageRoot"
}
} catch {
$postErr = $_
try {
# Retract only after proving the destination is still this
# transaction's copy (mirrors install.ps1's
# Remove-PublishedSkillPathSafely retract): a path-blind
# lexical removal would delete a concurrent replacement.
if (Test-PathLexically $Destination) {
Remove-VerifiedDevSkillPublication $Destination $Source ""
}
} catch {
throw "Skill move state uncertain: $postErr; failed to retract $Destination`: $_; source $Source and dest $Destination retained"
}
throw "Skill move failed, dest retracted, source retained ${Source}: $postErr"
}
try { Remove-DevSkillPathLexically $Source } catch {
throw "Skill target published but source removal failed; both retained ($Source, $Destination): $_"
}
if (Test-PathLexically $Source) { throw "Skill source still exists; both retained ($Source, $Destination)" }
} catch {
$failure = $_
if (Test-Path -LiteralPath $stageRoot) {
if (!(Remove-DevLinkStageRoot $stageRoot)) {
if (-not $published) {
throw "$failure; cross-device Skill staging cleanup failed $stageRoot (backup and original retained)"
}
}
}
throw $failure
}
}
# Get-DevSkillBackupName encodes the HOME-relative path of a backed-up Skill
# directory ('.codex\skills\dingtalk-misc' → '.codex-skills-dingtalk-misc') so
# copies retired from different Agent roots stay distinguishable. Mirrors
# build/npm/install.js and internal/upgrade/paths.go; paths outside HOME fall
# back to the bare leaf.
function Get-DevSkillBackupName([string]$Dir) {
$leaf = Split-Path $Dir -Leaf
try {
$full = [System.IO.Path]::GetFullPath($Dir)
$root = [System.IO.Path]::GetFullPath($HOME).TrimEnd([char[]]@('\', '/'))
} catch { return $leaf }
if ([string]::IsNullOrWhiteSpace($root)) { return $leaf }
foreach ($sep in @('\', '/')) {
$prefix = $root + $sep
if ($full.Length -gt $prefix.Length -and
$full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
$rel = $full.Substring($prefix.Length).Trim([char[]]@('\', '/'))
if (![string]::IsNullOrWhiteSpace($rel)) { return ($rel -replace '[\\/]+', '-') }
}
}
return $leaf
}
# Keep $HOME\.dws\skill-backups bounded to the newest stamped roots, matching
# skillBackupKeep / pruneSkillBackups in internal/upgrade/paths.go. Roots this
# run created are never pruned: a rollback still needs them.
$DevSkillBackupKeep = 5
$script:DevSkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
# Ownership marker: every stamp root DWS creates carries .dws-skill-backup
# with exactly "dws skill backup v1" + LF — the same bytes internal/upgrade/
# paths.go, install.sh, and build/npm/install.js write. A stamp-shaped name
# alone is not ownership proof, so pruning only deletes directories whose
# marker content verifies.
$DevSkillBackupMarkerFile = ".dws-skill-backup"
$DevSkillBackupMarkerBody = "dws skill backup v1"
# Write-DevSkillBackupMarker stamps a freshly created stamp root as DWS-owned.
# [IO.File]::WriteAllText pins the exact LF-terminated bytes (Set-Content
# would append a platform newline on Windows PowerShell 5.1).
function Write-DevSkillBackupMarker([string]$Root) {
[System.IO.File]::WriteAllText(
(Join-Path $Root $DevSkillBackupMarkerFile),
"$DevSkillBackupMarkerBody`n",
[System.Text.UTF8Encoding]::new($false))
}
# Test-DevSkillBackupMarker reports whether a stamp root carries the ownership
# marker. The check normalizes CRLF→LF and drops trailing newlines before
# comparing, so this surface accepts every surface's exact-LF bytes (writer
# and checker agree); any other content, or a missing/unreadable marker,
# means foreign data.
function Test-DevSkillBackupMarker([string]$Dir) {
try {
$marker = Join-Path $Dir $DevSkillBackupMarkerFile
if (![System.IO.File]::Exists($marker)) { return $false }
$body = [System.IO.File]::ReadAllText($marker).Replace("`r`n", "`n").TrimEnd("`r", "`n")
return ($body -eq $DevSkillBackupMarkerBody)
} catch {
return $false
}
}
# Removes a whole stamp root child-first without ever following a reparse
# point: link children are deleted non-recursively, real directories are
# recursed the same way, and only an emptied directory is removed. Backup
# trees can contain junctions/symlinks (victims are collected before the
# physical-equality filter), and Windows PowerShell 5.1 can follow reparse
# points during Remove-Item -Recurse — the invariant Remove-DevLinkStageRoot
# enforces for staging roots, applied at every depth here.
function Remove-DevSkillBackupTreeLexically([string]$Path) {
$item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
if ($item.PSIsContainer -and !$item.LinkType) {
foreach ($child in @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop)) {
Remove-DevSkillBackupTreeLexically $child.FullName
}
}
Remove-Item -LiteralPath $Path -Force -ErrorAction Stop
}
function Remove-OldDevSkillBackups {
$root = Join-Path $HOME ".dws\skill-backups"
if (!(Test-Path -LiteralPath $root -PathType Container)) { return }
# Only directories whose names match the DWS backup stamp format (UTC
# yyyyMMdd-HHmmss, optional -N collision suffix) AND whose ownership
# marker verifies are candidates; anything else is foreign data —
# preserved and never counted against $DevSkillBackupKeep.
$dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' -and (Test-DevSkillBackupMarker $_.FullName) } |
Sort-Object -Property Name)
$excess = $dirs.Count - $DevSkillBackupKeep
foreach ($dir in $dirs) {
if ($excess -le 0) { break }
if ($script:DevSkillBackupRootsThisRun.Contains($dir.FullName)) { continue }
try { Remove-DevSkillBackupTreeLexically $dir.FullName } catch { }
$excess--
}
}
function Backup-DevSkill([string]$path, [ref]$BackupPath) {
if ($null -ne $BackupPath) { $BackupPath.Value = "" }
if (!(Test-PathLexically $path)) { return }
$backupBase = Join-Path $HOME ".dws\skill-backups"
$stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss')
$name = Get-DevSkillBackupName $path
$backupRoot = Join-Path $backupBase $stamp
$target = Join-Path $backupRoot $name
$i = 1
while (Test-PathLexically $target) {
$backupRoot = Join-Path $backupBase "$stamp-$i"
$target = Join-Path $backupRoot $name
$i++
if ($i -gt 1000) { throw "备份目录冲突无法解决,保留原目录: $path" }
}
New-Item -ItemType Directory -Path $backupRoot -Force -ErrorAction Stop | Out-Null
# Stamp ownership immediately after creating the stamp root and before
# any skill directory moves into it, so an interrupted backup can never
# leave an unmarked (never-prunable) stamp behind.
try {
Write-DevSkillBackupMarker $backupRoot
} catch {
# The removal stays non-recursive so a pre-existing non-empty root
# (foreign data) is never destroyed; a failed marker write must not
# leave an empty unowned stamp root behind either.
Remove-Item -LiteralPath $backupRoot -Force -ErrorAction SilentlyContinue
throw
}
Move-DevSkillPathRecoverably $path $target
if ($null -ne $BackupPath) { $BackupPath.Value = $target }
try {
$script:DevSkillBackupRootsThisRun.Add([System.IO.Path]::GetFullPath($backupRoot)) | Out-Null
Remove-OldDevSkillBackups
} catch {
Say "⚠️ 旧备份清理失败(备份本身已成功): $($_.Exception.Message)"
}
}
function Get-DevSkillLinkSignature([string]$Path) {
try { $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop } catch { return $null }
if (!$item.LinkType) { return $null }
return ([string]$item.LinkType + "`0" + ((@($item.Target) | ForEach-Object { [string]$_ }) -join "`0"))
}
function New-DevRollbackFailure([string]$Message) {
$failure = [System.InvalidOperationException]::new($Message)
$failure.Data["DWSRollbackFailed"] = $true
return $failure
}
function Remove-VerifiedDevSkillPublication(
[string]$Destination,
[string]$PublishedSource,
[string]$PublishedLinkSignature
) {
$parent = Split-Path $Destination -Parent
$quarantine = Join-Path $parent (".dws-dev-rollback-" + [guid]::NewGuid().ToString("N"))
Move-DevSkillPath $Destination $quarantine
try {
if (![string]::IsNullOrWhiteSpace($PublishedLinkSignature)) {
if ((Get-DevSkillLinkSignature $quarantine) -ne $PublishedLinkSignature) {
throw "published Skill link was replaced by another process: $Destination"
}
} elseif (![string]::IsNullOrWhiteSpace($PublishedSource)) {
Assert-DevSkillPathCopy $PublishedSource $quarantine
} else {
throw "Skill restore destination is occupied by another process: $Destination"
}
Remove-DevSkillPathLexically $quarantine
} catch {
$failure = $_
if (Test-PathLexically $quarantine) {
try {
if (Test-PathLexically $Destination) { throw "original path is occupied" }
Move-DevSkillPath $quarantine $Destination
} catch {
throw "$failure; concurrent object retained at $quarantine`: $_"
}
}
throw $failure
}
}
function Restore-DevSkillBackup(
[string]$Destination,
[string]$Backup,
[string]$PublishedSource = "",
[string]$PublishedLinkSignature = ""
) {
if (Test-PathLexically $Destination) {
Remove-VerifiedDevSkillPublication $Destination $PublishedSource $PublishedLinkSignature
}
if (![string]::IsNullOrWhiteSpace($Backup)) {
Move-DevSkillPathRecoverably $Backup $Destination
}
}
function Publish-DevSkillCopy([string]$Source, [string]$Destination) {
$parent = Split-Path $Destination -Parent
New-Item -ItemType Directory -Path $parent -Force -ErrorAction Stop | Out-Null
$stageRoot = Join-Path $parent (".dws-dev-copy-" + [guid]::NewGuid().ToString("N"))
$stage = Join-Path $stageRoot "payload"
$backup = ""
$publishedSource = ""
New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null
try {
Copy-DevSkillPathLexically $Source $stage
Assert-DevSkillPathCopy $Source $stage
Backup-DevSkill $Destination ([ref]$backup)
try {
Move-DevSkillPath $stage $Destination
$publishedSource = $Source
Assert-DevSkillPathCopy $Source $Destination
} catch {
$publishFailure = $_
try { Restore-DevSkillBackup $Destination $backup $publishedSource "" } catch {
throw (New-DevRollbackFailure "$publishFailure; Skill rollback failed; backup retained at $backup`: $_")
}
throw $publishFailure
}
} finally {
Remove-DevLinkStageRoot $stageRoot | Out-Null
}
}
function Publish-DevSkillJunction([string]$Target, [string]$Destination) {
$parent = Split-Path $Destination -Parent
New-Item -ItemType Directory -Path $parent -Force -ErrorAction Stop | Out-Null
$stageRoot = Join-Path $parent (".dws-dev-link-" + [guid]::NewGuid().ToString("N"))
$stage = Join-Path $stageRoot "payload"
$backup = ""
$publishedSignature = ""
New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null
try {
New-Item -ItemType Junction -Path $stage -Target ([System.IO.Path]::GetFullPath($Target)) -ErrorAction Stop | Out-Null
Backup-DevSkill $Destination ([ref]$backup)
try {
Move-DevSkillPath $stage $Destination
$published = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop
if (!$published.LinkType) { throw "published Skill path is not a junction: $Destination" }
$publishedSignature = Get-DevSkillLinkSignature $Destination
if ([string]::IsNullOrEmpty($publishedSignature)) { throw "published Skill link identity is unavailable: $Destination" }
} catch {
$publishFailure = $_
try { Restore-DevSkillBackup $Destination $backup "" $publishedSignature } catch {
throw (New-DevRollbackFailure "$publishFailure; Skill rollback failed; backup retained at $backup`: $_")
}
throw $publishFailure
}
} finally {
# The staged payload is a junction to the canonical store; it must be
# removed lexically or Windows PowerShell 5.1 recursion could delete
# canonical content through the reparse point.
Remove-DevLinkStageRoot $stageRoot | Out-Null
}
}
function Get-Arch {
if ($env:DWS_ARCH -eq "amd64" -or $env:DWS_ARCH -eq "arm64") { return $env:DWS_ARCH }
try {
@@ -40,6 +560,26 @@ function Get-Arch {
}
}
function Assert-DevReleaseAssetChecksum([string]$AssetPath, [string]$AssetName, [string]$TempDir) {
$checksums = Join-Path $TempDir "checksums.txt"
if (!(Test-Path -LiteralPath $checksums -PathType Leaf)) {
try {
Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/checksums.txt" `
-OutFile $checksums -UseBasicParsing -ErrorAction Stop
} catch {
Die "Could not download checksums.txt; refusing unverified release assets."
}
}
$line = Get-Content -LiteralPath $checksums | Where-Object {
$_ -match "^[0-9A-Fa-f]{64}[ ]+[*]?$([regex]::Escape($AssetName))$"
} | Select-Object -First 1
if (-not $line) { Die "$AssetName is missing from checksums.txt." }
$expected = ($line -split '\s+')[0].ToLowerInvariant()
$actual = (Get-FileHash -LiteralPath $AssetPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
if ($actual -ne $expected) { Die "SHA256 checksum mismatch for $AssetName." }
Say "SHA256 checksum verified: $AssetName"
}
# Read the releases list (newest first) and take the top tag, so this also works
# if a release is ever published as a prerelease (which /releases/latest skips).
if (-not $Version) {
@@ -70,6 +610,7 @@ try {
Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/$asset" `
-OutFile $zip -UseBasicParsing
} catch { Die "Binary download failed - does release $Version have $asset?" }
Assert-DevReleaseAssetChecksum $zip $asset $tmp
Expand-Archive -Path $zip -DestinationPath $tmp -Force
$exe = Get-ChildItem -Path $tmp -Recurse -Filter "dws.exe" | Select-Object -First 1
@@ -84,6 +625,7 @@ if (-not $NoSkills) {
$skzip = Join-Path $tmp "dws-skills.zip"
Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/dws-skills.zip" `
-OutFile $skzip -UseBasicParsing
Assert-DevReleaseAssetChecksum $skzip "dws-skills.zip" $tmp
$skdir = Join-Path $tmp "sk"
Expand-Archive -Path $skzip -DestinationPath $skdir -Force
@@ -95,33 +637,63 @@ if (-not $NoSkills) {
if ($src) {
# cache so `dws skill setup --mode multi` can find a source later
$cache = Join-Path $HOME ".dws\skills\multi\$SkillName"
if (Test-Path $cache) { Remove-Item -Recurse -Force $cache }
New-Item -ItemType Directory -Path $cache -Force | Out-Null
Copy-Item -Path "$src\*" -Destination $cache -Recurse -Force
Publish-DevSkillCopy $src $cache
$agentDirs = @(
".agents\skills", ".claude\skills", ".cursor\skills", ".qoder\skills", ".qoderwork\skills",
".gemini\skills", ".codex\skills", ".github\skills", ".windsurf\skills", ".augment\skills",
".cline\skills", ".amp\skills", ".kiro\skills", ".trae\skills", ".openclaw\skills",
".hermes\skills", ".config\opencode\skills"
)
$installed = 0; $idx = 0
foreach ($d in $agentDirs) {
$base = Join-Path $HOME $d
$parent = Split-Path $base -Parent
if ($idx -gt 0 -and -not (Test-Path $parent)) { $idx++; continue }
$canonicalBase = Join-Path $HOME ".agents\skills"
$canonical = Join-Path $canonicalBase $SkillName
Publish-DevSkillCopy $src $canonical
$installed = 1
$failed = 0
$retireFailed = 0
$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($row in @($AgentRegistryRows) + @($LegacyAgentCleanupRows)) {
$parts = $row.Split('|'); $universal = $parts[1] -eq "1"
$base = Resolve-AgentBase $row
if ([string]::IsNullOrWhiteSpace($base)) { continue }
$baseKey = [System.IO.Path]::GetFullPath($base).TrimEnd([char[]]@('\', '/'))
if (!$seen.Add($baseKey) -or $baseKey -eq [System.IO.Path]::GetFullPath($canonicalBase).TrimEnd([char[]]@('\', '/'))) { continue }
if (!(Test-AgentBaseDetected $parts[0] $base)) { continue }
$dest = Join-Path $base $SkillName
if (Test-Path $dest) { Remove-Item -Recurse -Force $dest }
New-Item -ItemType Directory -Path $dest -Force | Out-Null
Copy-Item -Path "$src\*" -Destination $dest -Recurse -Force
$installed++; $idx++
if ($universal) {
# Cleanup-only migration: universal Agents read the
# canonical store directly, so nothing is installed here. A
# retire failure only leaves an obsolete copy behind and
# must never fail an otherwise complete install.
try { Backup-DevSkill $dest } catch {
Say "⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): $dest ($($_.Exception.Message))"
$retireFailed++
}
continue
}
# Per-agent degrade like install.sh: a failed link→copy fallback
# skips that agent loudly instead of aborting the whole loop.
try {
New-Item -ItemType Directory -Path $base -Force -ErrorAction Stop | Out-Null
try { Publish-DevSkillJunction $canonical $dest }
catch {
# A failed rollback leaves the old backup as the only
# trusted copy. Do not run a second publisher over that
# state; surface the failure and continue other agents.
if ($_.Exception.Data["DWSRollbackFailed"]) { throw }
Publish-DevSkillCopy $canonical $dest
}
} catch {
Say "⚠️ Agent Skill 目标安装失败,已跳过: $dest ($($_.Exception.Message))"
$failed++
continue
}
$installed++
}
if ($failed -gt 0) { Die "有 $failed 个 Agent 目标安装 $SkillName 失败(其余目标已安装)" }
if ($retireFailed -gt 0) {
Say "⚠️ 有 $retireFailed 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)"
}
Say "Skill dingtalk-misc -> $installed agent dir(s)"
} else {
Say "(dingtalk-misc not found in skills bundle; skipped)"
}
} catch {
Say "(skill install skipped: $($_.Exception.Message))"
Die "Skill install failed: $($_.Exception.Message)"
}
}
+480 -13
View File
@@ -26,6 +26,448 @@ say() { printf ' %s\n' "$@"; }
err() { printf ' ❌ %s\n' "$@" >&2; exit 1; }
need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; }
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'
elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}'
else return 1
fi
}
verify_release_asset() {
name="$1"; file="$2"
checksums="$tmp/checksums.txt"
if [ ! -f "$checksums" ]; then
curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/checksums.txt" -o "$checksums" \
|| err "Could not download checksums.txt; refusing unverified release assets."
fi
expected="$(awk -v asset="$name" '$2 == asset {print $1; exit}' "$checksums")"
[ -n "$expected" ] || err "${name} is missing from checksums.txt."
actual="$(sha256_file "$file")" || err "Could not compute SHA256 for ${name}."
[ "$actual" = "$expected" ] || err "SHA256 checksum mismatch for ${name}."
say "✅ SHA256 checksum verified: ${name}"
}
# perm_of <path>
# Prints the octal permission bits of path. Linux stat and BSD/macOS stat
# disagree on flags, so try both spellings and fail loudly when neither is
# available.
perm_of() {
_po_mode="$(stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1" 2>/dev/null)" || return 1
printf '%s\n' "$_po_mode"
}
# discard_stage <dir>
# Best-effort removal of our own staging tree. Read-only directories copied
# from the skill bundle resist rm -rf, so grant owner write access first.
discard_stage() {
chmod -R u+w "$1" 2>/dev/null || true
rm -rf "$1" 2>/dev/null || true
}
# count_tree_entries <dir>
count_tree_entries() {
_cte_n=0
for _cte_e in "$1"/* "$1"/.[!.]* "$1"/..?*; do
[ -e "$_cte_e" ] || [ -L "$_cte_e" ] || continue
_cte_n=$((_cte_n+1))
done
printf '%s\n' "$_cte_n"
}
# publish_tree_children <stage> <dest> <manifest>
# Publishes every child of the staging directory into the claimed dest through
# an atomic no-clobber primitive: directories claim with mkdir and recurse,
# regular files publish via ln (a hard link is a single atomic rename-class
# syscall that fails with EEXIST when the target exists) and then drop the
# staging source, and symlinks are recreated with ln -s. A POSIX `mv` cannot
# be used here: renaming a directory onto a concurrently created same-name
# (empty) directory replaces it, and mv offers no kernel-level no-replace.
# Every primitive therefore refuses a concurrently created object instead of
# replacing it. The absolute destination path of each published entry is
# appended to the manifest so the rollback only ever retracts entries this
# transaction published. The recursion runs in a subshell so the caller's
# loop variables survive; manifest appends and filesystem effects persist.
# After each level the destination is re-counted: more entries than the
# staging side had means a different-named concurrent writer landed
# mid-publish, and the whole publication aborts with the destination kept.
publish_tree_children() {
_ptc_stage="$1"; _ptc_dest="$2"; _ptc_manifest="$3"
_ptc_staged="$(count_tree_entries "$_ptc_stage")"
for _ptc_child in "$_ptc_stage"/* "$_ptc_stage"/.[!.]* "$_ptc_stage"/..?*; do
[ -e "$_ptc_child" ] || [ -L "$_ptc_child" ] || continue
_ptc_name="$(basename "$_ptc_child")"
_ptc_target="${_ptc_dest%/}/$_ptc_name"
if [ -L "$_ptc_child" ]; then
_ptc_link="$(readlink "$_ptc_child")" || return 1
ln -s "$_ptc_link" "$_ptc_target" 2>/dev/null || {
[ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1
printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2
return 1
}
rm -f "$_ptc_child" || return 1
elif [ -d "$_ptc_child" ]; then
_ptc_mode="$(perm_of "$_ptc_child")" || return 1
# The staging tree is ours alone: skill trees ship read-only
# directories (0555), which would block unlinking the children and
# removing the emptied shell. Grant owner write access for the move;
# the published target receives the recorded mode below.
chmod u+w "$_ptc_child" || return 1
mkdir "$_ptc_target" 2>/dev/null || {
[ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1
printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2
return 1
}
( publish_tree_children "$_ptc_child" "$_ptc_target" "$_ptc_manifest" ) || return 1
chmod "$_ptc_mode" "$_ptc_target" || return 1
rmdir "$_ptc_child" 2>/dev/null || return 1
else
ln "$_ptc_child" "$_ptc_target" 2>/dev/null || {
[ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1
printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2
return 1
}
rm -f "$_ptc_child" || return 1
fi
printf '%s\n' "$_ptc_target" >> "$_ptc_manifest"
done
_ptc_live="$(count_tree_entries "$_ptc_dest")"
if [ "$_ptc_live" -gt "$_ptc_staged" ]; then
printf ' 目标出现并发目录项,中止发布并保留 %s\n' "$_ptc_dest" >&2
return 1
fi
return 0
}
# retract_published_children <stage_root> <dest_root> <manifest>
# Moves the manifest-recorded entries back into the staging tree in reverse
# publication order, so deeper entries move before (or together with, when a
# containing directory retraction carries them) the directories that hold
# them. Only entries this transaction published are touched; a concurrently
# created object is never retracted. An entry that no longer exists was
# carried away with an ancestor; a genuine move failure stops the retraction
# so the caller reports the retained destination.
retract_published_children() {
_rpc_stage_root="$1"; _rpc_dest_root="$2"; _rpc_manifest="$3"
[ -s "$_rpc_manifest" ] || return 0
_rpc_rev="${_rpc_manifest}.rev"
sed '1!G;h;$!d' "$_rpc_manifest" > "$_rpc_rev" || { rm -f "$_rpc_rev"; return 1; }
while IFS= read -r _rpc_entry; do
[ -n "$_rpc_entry" ] || continue
_rpc_rel="${_rpc_entry#"${_rpc_dest_root%/}/"}"
_rpc_back="${_rpc_stage_root%/}/$_rpc_rel"
mkdir -p "$(dirname "$_rpc_back")" 2>/dev/null || break
rmdir "$_rpc_back" 2>/dev/null || true
mv "$_rpc_entry" "$_rpc_back" 2>/dev/null || {
[ -e "$_rpc_entry" ] || [ -L "$_rpc_entry" ] || continue
break
}
done < "$_rpc_rev"
rm -f "$_rpc_rev"
return 0
}
copy_tree() {
src="$1"
dest="$2"
parent="$(dirname "$dest")"
mkdir -p "$parent" || return 1
stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1
manifest="$(mktemp "$parent/.dws-skill.txn.XXXXXX")" || { rm -rf "$stage"; return 1; }
if ! cp -R "$src/." "$stage/"; then discard_stage "$stage"; rm -f "$manifest"; return 1; fi
chmod u+w "$stage" 2>/dev/null || true
backup="$(backup_skill_dir "$dest")" || { discard_stage "$stage"; rm -f "$manifest"; return 1; }
_ct_mode="$(perm_of "$src")" || { discard_stage "$stage"; rm -f "$manifest"; return 1; }
# mkdir is the atomic no-replace claim: it fails when the path is already
# occupied, and the claim is then held for the whole transaction. Children
# publish into the claim through per-entry no-clobber primitives (see
# publish_tree_children) so a concurrent writer can never be replaced; the
# rollback only retracts what the manifest recorded, and the claim itself
# is removed only while still empty, so foreign entries always survive.
if mkdir "$dest" 2>/dev/null; then
if publish_tree_children "$stage" "$dest" "$manifest"; then
chmod "$_ct_mode" "$dest" 2>/dev/null || true
discard_stage "$stage"
rm -f "$manifest"
return 0
fi
retract_published_children "$stage" "$dest" "$manifest"
rmdir "$dest" 2>/dev/null || true
fi
discard_stage "$stage"
rm -f "$manifest"
if [ -n "$backup" ]; then
restore_manifest="$(mktemp "$parent/.dws-skill.txn.XXXXXX")" || return 1
if mkdir "$dest" 2>/dev/null; then
# The restore publishes the backup through the same no-clobber
# discipline: a concurrent writer that took the emptied path (or any
# entry inside it) is refused and the backup is kept intact instead
# of being partially drained by an overwriting move.
if publish_tree_children "$backup" "$dest" "$restore_manifest"; then
rmdir "$backup" 2>/dev/null || true
else
retract_published_children "$backup" "$dest" "$restore_manifest"
rmdir "$dest" 2>/dev/null || true
printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2
fi
else
printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2
fi
rm -f "$restore_manifest"
fi
return 1
}
# refresh_cache_tree <src> <dest>
# Ephemeral ~/.dws/skills cache refresh, identical to install-event.sh. The tree
# is byte-reproducible from the release asset, so it is never archived into
# ~/.dws/skill-backups; unlike the previous `rm -rf` + `cp` it only deletes the
# old cache after the replacement is fully staged, so an interrupted install can
# no longer leave a half-copied cache behind.
refresh_cache_tree() {
src="$1"; dest="$2"; parent="$(dirname "$dest")"
mkdir -p "$parent" || return 1
stage="$(mktemp -d "$parent/.dws-cache.tmp.XXXXXX")" || return 1
if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi
old=""
if [ -e "$dest" ] || [ -L "$dest" ]; then
old="$(mktemp -d "$parent/.dws-cache.old.XXXXXX")" || { rm -rf "$stage"; return 1; }
rmdir "$old" || { rm -rf "$stage" "$old"; return 1; }
if ! mv "$dest" "$old"; then rm -rf "$stage"; return 1; fi
fi
if mv "$stage" "$dest"; then
if [ -n "$old" ] && ! rm -rf "$old"; then
printf ' ⚠️ 新缓存已生效,但旧缓存清理失败: %s\n' "$old" >&2
fi
return 0
fi
rm -rf "$stage"
if [ -n "$old" ] && ! mv "$old" "$dest"; then
printf ' ⚠️ 缓存刷新失败,原缓存保留在 %s\n' "$old" >&2
fi
return 1
}
backup_skill_dir() {
victim="$1"
[ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; }
# Prune before creating this run's archive; every stamp this run has already
# created is registered, so pruning can only remove earlier-run batches.
prune_skill_backups
stamp="$(date -u +%Y%m%d-%H%M%S)"
name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')"
backup_root="$HOME/.dws/skill-backups/$stamp"; backup="$backup_root/$name"; i=0
# Bump not only when the payload path is taken but also when the stamp
# root exists without a verified ownership marker: a same-second foreign
# directory must never be stamped DWS-owned and made prunable. A
# marker-verified root from this run's same second stays reusable.
while [ -e "$backup" ] || [ -L "$backup" ] ||
{ [ -d "$backup_root" ] && ! is_current_run_backup_stamp "$backup_root" &&
[ "$(cat "$backup_root/.dws-skill-backup" 2>/dev/null)" != "dws skill backup v1" ]; }; do
i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name"
# Same bail-out as install-skills.sh: never spin forever on a pathological
# backup root, report it and keep the original directory instead.
if [ "$i" -gt 1000 ]; then
printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$victim" >&2
return 1
fi
done
record_current_run_backup_stamp "$backup_root"
# Freshness must be sampled before mkdir: the collision loop tests the
# payload path, so a second backup in the same stamp second reuses this
# root while a sibling payload from this run already lives in it.
fresh=1
[ ! -d "$backup_root" ] || fresh=0
mkdir -p "$backup_root" || return 1
# Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a
# stamp-shaped name alone is not evidence, so pruning only ever removes
# roots carrying this marker — it must exist before the payload moves in.
printf '%s\n' 'dws skill backup v1' > "$backup_root/.dws-skill-backup" || {
# Non-recursive cleanup, sibling protection: only a root this call
# created may be dropped, and only the marker plus an empty root (rmdir
# refuses a non-empty directory) — rm -rf here would destroy a completed
# same-second sibling backup whose original is already moved away.
if [ "$fresh" -eq 1 ]; then
rm -f "$backup_root/.dws-skill-backup"
rmdir "$backup_root" 2>/dev/null || true
fi
return 1
}
mv "$victim" "$backup" || return 1
printf '%s\n' "$backup"
}
# prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup
# directories from earlier runs under $HOME/.dws/skill-backups, matching Go's
# skillBackupKeep / pruneSkillBackups. Only stamp-shaped roots carrying the
# .dws-skill-backup marker with the exact expected content are counted or
# removed: foreign data with a stamp-like name never consumes a keep slot
# and is never deleted, silently. Stamp directories created by the
# current process are never pruned (mirroring Go's run-root registry and
# install.js's currentRunBackupRoots), so a migration that retires more than
# SKILL_BACKUP_KEEP batches stays reversible. Glob order is lexicographic and
# the stamps are UTC YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort:
# a removal failure is reported and never fatal.
SKILL_BACKUP_KEEP=5
# Stamp directories created by this run, recorded by backup_skill_dir so
# pruning can never delete a backup the running migration may still need to
# roll back to.
CURRENT_RUN_BACKUP_STAMPS=""
record_current_run_backup_stamp() {
case " $CURRENT_RUN_BACKUP_STAMPS " in
*" $1 "*) return 0 ;;
esac
CURRENT_RUN_BACKUP_STAMPS="${CURRENT_RUN_BACKUP_STAMPS} $1"
}
# is_current_run_backup_stamp reports whether the stamp root was created by
# this very process. Such a root is ours by construction and stays reusable
# even when its marker cannot be re-verified mid-run (for example a
# permission failure after the first payload moved in).
is_current_run_backup_stamp() {
case " $CURRENT_RUN_BACKUP_STAMPS " in
*" $1 "*) return 0 ;;
esac
return 1
}
# is_skill_backup_stamp accepts only directory names with the stamp shape DWS
# itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix.
# Shape is necessary but not sufficient — pruning additionally verifies the
# .dws-skill-backup ownership marker — while any other entry in the backup
# root is foreign (user data, unrelated tooling) and is preserved so pruning
# can never remove a directory it cannot prove DWS created.
is_skill_backup_stamp() {
case "$1" in
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
return 0 ;;
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*)
# base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip
# those 16 chars and require the remainder to be all digits.
_isbs_suffix="${1#????????????????}"
case "$_isbs_suffix" in
""|*[!0-9]*) return 1 ;;
esac
return 0 ;;
esac
return 1
}
prune_skill_backups() {
prune_root="$HOME/.dws/skill-backups"
[ -d "$prune_root" ] || return 0
prune_total=0
for prune_entry in "$prune_root"/*; do
[ -d "$prune_entry" ] || continue
is_skill_backup_stamp "${prune_entry##*/}" || continue
[ "$(cat "$prune_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue
prune_total=$((prune_total + 1))
done
[ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0
prune_drop=$((prune_total - SKILL_BACKUP_KEEP))
for prune_entry in "$prune_root"/*; do
[ "$prune_drop" -gt 0 ] || break
[ -d "$prune_entry" ] || continue
is_skill_backup_stamp "${prune_entry##*/}" || continue
[ "$(cat "$prune_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue
case " $CURRENT_RUN_BACKUP_STAMPS " in
*" $prune_entry "*) continue ;;
esac
rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2
prune_drop=$((prune_drop - 1))
done
}
same_physical_skill() {
[ -d "$1" ] && [ -d "$2" ] || return 1
left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1
right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1
[ "$left" = "$right" ]
}
is_cleanup_only_agent_dir() {
case "$1" in
.config/agents/skills|.gemini/antigravity/skills|.gemini/antigravity-cli/skills|.codex/skills|.cursor/skills|.deepagents/agent/skills|.firebender/skills|.gemini/skills|.copilot/skills|.config/opencode/skills|.github/skills|.windsurf/skills|.cline/skills|.amp/skills) return 0 ;;
*) return 1 ;;
esac
}
agent_skill_dirs() {
printf '%s\n' \
.config/agents/skills .gemini/antigravity/skills .gemini/antigravity-cli/skills .codex/skills .cursor/skills .deepagents/agent/skills .firebender/skills .gemini/skills .copilot/skills .config/opencode/skills \
.aider-desk/skills .astrbot/data/skills .autohand/skills .augment/skills .bob/skills .claude/skills .openclaw/skills .codeartsdoer/skills .codebuddy/skills .codemaker/skills .codestudio/skills .commandcode/skills .continue/skills .snowflake/cortex/skills .config/crush/skills .config/devin/skills .factory/skills .forge/skills .config/goose/skills .grok/skills .hermes/skills .inferencesh/skills .jazz/skills .junie/skills .iflow/skills .kilocode/skills .config/kimchi/harness/skills .kiro/skills .kode/skills .lingma/skills .mcpjam/skills .minimax/skills .vibe/skills .moxby/skills .mux/skills .openhands/skills .ona/skills .pi/agent/skills .qoder/skills .qoder-cn/skills .qwen/skills .reasonix/skills .rovodev/skills .roo/skills .tabnine/agent/skills .terramind/skills .tinycloud/skills .trae/skills .trae-cn/skills .codeium/windsurf/skills .zcode/skills .zencoder/skills .neovate/skills .pochi/skills .adal/skills \
.qoderwork/skills .github/skills .windsurf/skills .cline/skills .amp/skills
}
resolve_agent_skill_base() {
agent_dir="$1"; base="$HOME/$agent_dir"
case "$agent_dir" in
.claude/skills) [ -n "${CLAUDE_CONFIG_DIR:-}" ] && base="$CLAUDE_CONFIG_DIR/skills" ;;
.codex/skills) [ -n "${CODEX_HOME:-}" ] && base="$CODEX_HOME/skills" ;;
.hermes/skills) [ -n "${HERMES_HOME:-}" ] && base="$HERMES_HOME/skills" ;;
.autohand/skills) [ -n "${AUTOHAND_HOME:-}" ] && base="$AUTOHAND_HOME/skills" ;;
.grok/skills) [ -n "${GROK_HOME:-}" ] && base="$GROK_HOME/skills" ;;
.vibe/skills) [ -n "${VIBE_HOME:-}" ] && base="$VIBE_HOME/skills" ;;
.openclaw/skills) for legacy in .openclaw .clawdbot .moltbot; do [ -d "$HOME/$legacy" ] && { base="$HOME/$legacy/skills"; break; }; done ;;
.config/*) base="${XDG_CONFIG_HOME:-$HOME/.config}/${agent_dir#.config/}" ;;
esac
printf '%s\n' "$base"
}
agent_skill_base_detected() {
agent_dir="$1"; base="$2"
case "$agent_dir" in
.config/kimchi/harness/skills|.tabnine/agent/skills) [ -d "$(dirname "$(dirname "$base")")" ] ;;
.zcode/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/ZCode.app" ] ;;
.minimax/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;;
*) [ -d "$(dirname "$base")" ] ;;
esac
}
link_or_copy_skill() {
canonical="$1"; src="$2"; dest="$3"
same_physical_skill "$dest" "$canonical" && return 0
parent="$(dirname "$dest")"; mkdir -p "$parent" || return 1
parent_real="$(CDPATH= cd -- "$parent" && pwd -P)" || return 1
target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1
relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j<nt?"/":""); if(out=="")out="."; print out }')"
stage="$(mktemp -d "$parent/.dws-link.tmp.XXXXXX")" || return 1
if ! ln -s "$relative" "$stage/skill" 2>/dev/null; then
rm -rf "$stage"
if copy_tree "$src" "$dest"; then
printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$dest" >&2
return 0
fi
return 1
fi
backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; }
# Create the link directly at the destination: symlink(2) refuses an
# occupied path with EEXIST, so publication itself is the atomic no-replace
# check (`mv` after the backup could still replace a file or symlink a
# concurrent writer created in between). A directory that slipped in turns
# `ln -s` into a container: remove exactly our nested link and roll back.
if ln -s "$relative" "$dest" 2>/dev/null && [ -L "$dest" ]; then
rm -rf "$stage"
return 0
fi
nested="$dest/${relative##*/}"
if [ -L "$nested" ] && [ "$(readlink "$nested" 2>/dev/null)" = "$relative" ]; then
rm -f "$nested" || true
fi
rm -rf "$stage"
if [ -n "$backup" ]; then
if [ -e "$dest" ] || [ -L "$dest" ]; then
printf ' ⚠️ %s 在发布期间被并发写入占用,原 Skill 保留于备份: %s\n' "$dest" "$backup" >&2
elif ! mv "$backup" "$dest"; then
printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2
fi
fi
return 1
}
need_cmd curl
detect_os() {
@@ -81,20 +523,43 @@ install_skill() {
# cache so `dws skill setup --mode multi` can find a source later
cache="$HOME/.dws/skills/multi/$SKILL_NAME"
rm -rf "$cache"; mkdir -p "$cache"; cp -R "$src/." "$cache/"
refresh_cache_tree "$src" "$cache" || { printf ' ⚠️ Skill 缓存刷新失败: %s\n' "$cache" >&2; return 1; }
installed=0; idx=0
for agent_dir in \
.agents/skills .claude/skills .cursor/skills .qoder/skills .qoderwork/skills \
.gemini/skills .codex/skills .github/skills .windsurf/skills .augment/skills \
.cline/skills .amp/skills .kiro/skills .trae/skills .openclaw/skills .hermes/skills \
.config/opencode/skills
do
base="$HOME/$agent_dir"
if [ "$idx" -gt 0 ] && [ ! -e "$(dirname "$base")" ]; then idx=$((idx + 1)); continue; fi
dest="$base/$SKILL_NAME"; rm -rf "$dest"; mkdir -p "$dest"; cp -R "$src/." "$dest/"
installed=$((installed + 1)); idx=$((idx + 1))
canonical="$HOME/.agents/skills/$SKILL_NAME"
copy_tree "$src" "$canonical" || return 1
installed=1
failed=0
retire_failed=0
for agent_dir in $(agent_skill_dirs); do
base="$(resolve_agent_skill_base "$agent_dir")"
agent_skill_base_detected "$agent_dir" "$base" || continue
same_physical_skill "$base" "$HOME/.agents/skills" && continue
if is_cleanup_only_agent_dir "$agent_dir"; then
# Cleanup-only: universal Agents read the canonical store directly, so
# nothing is installed here. A retire failure only leaves an obsolete copy
# behind and must never fail an otherwise complete install.
if ! backup_skill_dir "$base/$SKILL_NAME" >/dev/null; then
printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): %s\n' "$base/$SKILL_NAME" >&2
retire_failed=$((retire_failed + 1))
fi
continue
fi
# Per-agent degrade like install.sh: a failed target is reported and
# skipped loudly instead of aborting the remaining agents mid-loop.
if ! link_or_copy_skill "$canonical" "$src" "$base/$SKILL_NAME"; then
printf ' ⚠️ Agent 目标安装失败,已跳过: %s\n' "$base/$SKILL_NAME" >&2
failed=$((failed + 1))
continue
fi
installed=$((installed + 1))
done
if [ "$retire_failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" >&2
fi
if [ "$failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 目标安装 %s 失败\n' "$failed" "$SKILL_NAME" >&2
return 1
fi
say "✅ Skill dingtalk-misc → ${installed} agent dir(s)"
}
@@ -119,6 +584,7 @@ main() {
say "⬇ Downloading ${asset} ..."
curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/${asset}" -o "$tmp/$asset" \
|| err "Binary download failed — does release ${DEVAPP_VERSION} have ${asset}?"
verify_release_asset "$asset" "$tmp/$asset"
if [ "$os" = "windows" ]; then
need_cmd unzip; unzip -q "$tmp/$asset" -d "$tmp"
else
@@ -132,10 +598,11 @@ main() {
# 2) dev skill from the release's skills bundle
if [ "$NO_SKILLS" != "1" ]; then
if curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/dws-skills.zip" -o "$tmp/skills.zip" 2>/dev/null; then
verify_release_asset "dws-skills.zip" "$tmp/skills.zip"
mkdir -p "$tmp/sk"
if command -v unzip >/dev/null 2>&1; then unzip -q "$tmp/skills.zip" -d "$tmp/sk"; else tar -xf "$tmp/skills.zip" -C "$tmp/sk"; fi
say ""
install_skill "$tmp/sk"
install_skill "$tmp/sk" || err "dingtalk-misc Skill 安装失败,详见上方告警"
else
say " (no dws-skills.zip in release ${DEVAPP_VERSION}; skill skipped)"
fi
+485 -29
View File
@@ -39,6 +39,28 @@ say() { printf ' %s\n' "$@"; }
err() { printf ' ERROR: %s\n' "$@" >&2; exit 1; }
need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; }
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'
elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}'
else return 1
fi
}
verify_release_asset() {
name="$1"; file="$2"
checksums="$tmp/checksums.txt"
if [ ! -f "$checksums" ]; then
curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/checksums.txt" -o "$checksums" \
|| err "Could not download checksums.txt; refusing unverified release assets."
fi
expected="$(awk -v asset="$name" '$2 == asset {print $1; exit}' "$checksums")"
[ -n "$expected" ] || err "${name} is missing from checksums.txt."
actual="$(sha256_file "$file")" || err "Could not compute SHA256 for ${name}."
[ "$actual" = "$expected" ] || err "SHA256 checksum mismatch for ${name}."
say "SHA256 checksum verified: ${name}"
}
detect_os() {
case "$(uname -s)" in
Linux*) echo linux ;;
@@ -89,39 +111,467 @@ resolve_event_version() {
[ -n "$EVENT_VERSION" ] || err "No stable release found on ${EVENT_REPO}. Set EVENT_VERSION to a published release tag."
}
# perm_of <path>
# Prints the octal permission bits of path. Linux stat and BSD/macOS stat
# disagree on flags, so try both spellings and fail loudly when neither is
# available.
perm_of() {
_po_mode="$(stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1" 2>/dev/null)" || return 1
printf '%s\n' "$_po_mode"
}
# discard_stage <dir>
# Best-effort removal of our own staging tree. Read-only directories copied
# from the skill bundle resist rm -rf, so grant owner write access first.
discard_stage() {
chmod -R u+w "$1" 2>/dev/null || true
rm -rf "$1" 2>/dev/null || true
}
# count_tree_entries <dir>
count_tree_entries() {
_cte_n=0
for _cte_e in "$1"/* "$1"/.[!.]* "$1"/..?*; do
[ -e "$_cte_e" ] || [ -L "$_cte_e" ] || continue
_cte_n=$((_cte_n+1))
done
printf '%s\n' "$_cte_n"
}
# publish_tree_children <stage> <dest> <manifest>
# Publishes every child of the staging directory into the claimed dest through
# an atomic no-clobber primitive: directories claim with mkdir and recurse,
# regular files publish via ln (a hard link is a single atomic rename-class
# syscall that fails with EEXIST when the target exists) and then drop the
# staging source, and symlinks are recreated with ln -s. A POSIX `mv` cannot
# be used here: renaming a directory onto a concurrently created same-name
# (empty) directory replaces it, and mv offers no kernel-level no-replace.
# Every primitive therefore refuses a concurrently created object instead of
# replacing it. The absolute destination path of each published entry is
# appended to the manifest so the rollback only ever retracts entries this
# transaction published. The recursion runs in a subshell so the caller's
# loop variables survive; manifest appends and filesystem effects persist.
# After each level the destination is re-counted: more entries than the
# staging side had means a different-named concurrent writer landed
# mid-publish, and the whole publication aborts with the destination kept.
publish_tree_children() {
_ptc_stage="$1"; _ptc_dest="$2"; _ptc_manifest="$3"
_ptc_staged="$(count_tree_entries "$_ptc_stage")"
for _ptc_child in "$_ptc_stage"/* "$_ptc_stage"/.[!.]* "$_ptc_stage"/..?*; do
[ -e "$_ptc_child" ] || [ -L "$_ptc_child" ] || continue
_ptc_name="$(basename "$_ptc_child")"
_ptc_target="${_ptc_dest%/}/$_ptc_name"
if [ -L "$_ptc_child" ]; then
_ptc_link="$(readlink "$_ptc_child")" || return 1
ln -s "$_ptc_link" "$_ptc_target" 2>/dev/null || {
[ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1
printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2
return 1
}
rm -f "$_ptc_child" || return 1
elif [ -d "$_ptc_child" ]; then
_ptc_mode="$(perm_of "$_ptc_child")" || return 1
# The staging tree is ours alone: skill trees ship read-only
# directories (0555), which would block unlinking the children and
# removing the emptied shell. Grant owner write access for the move;
# the published target receives the recorded mode below.
chmod u+w "$_ptc_child" || return 1
mkdir "$_ptc_target" 2>/dev/null || {
[ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1
printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2
return 1
}
( publish_tree_children "$_ptc_child" "$_ptc_target" "$_ptc_manifest" ) || return 1
chmod "$_ptc_mode" "$_ptc_target" || return 1
rmdir "$_ptc_child" 2>/dev/null || return 1
else
ln "$_ptc_child" "$_ptc_target" 2>/dev/null || {
[ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1
printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2
return 1
}
rm -f "$_ptc_child" || return 1
fi
printf '%s\n' "$_ptc_target" >> "$_ptc_manifest"
done
_ptc_live="$(count_tree_entries "$_ptc_dest")"
if [ "$_ptc_live" -gt "$_ptc_staged" ]; then
printf ' 目标出现并发目录项,中止发布并保留 %s\n' "$_ptc_dest" >&2
return 1
fi
return 0
}
# retract_published_children <stage_root> <dest_root> <manifest>
# Moves the manifest-recorded entries back into the staging tree in reverse
# publication order, so deeper entries move before (or together with, when a
# containing directory retraction carries them) the directories that hold
# them. Only entries this transaction published are touched; a concurrently
# created object is never retracted. An entry that no longer exists was
# carried away with an ancestor; a genuine move failure stops the retraction
# so the caller reports the retained destination.
retract_published_children() {
_rpc_stage_root="$1"; _rpc_dest_root="$2"; _rpc_manifest="$3"
[ -s "$_rpc_manifest" ] || return 0
_rpc_rev="${_rpc_manifest}.rev"
sed '1!G;h;$!d' "$_rpc_manifest" > "$_rpc_rev" || { rm -f "$_rpc_rev"; return 1; }
while IFS= read -r _rpc_entry; do
[ -n "$_rpc_entry" ] || continue
_rpc_rel="${_rpc_entry#"${_rpc_dest_root%/}/"}"
_rpc_back="${_rpc_stage_root%/}/$_rpc_rel"
mkdir -p "$(dirname "$_rpc_back")" 2>/dev/null || break
rmdir "$_rpc_back" 2>/dev/null || true
mv "$_rpc_entry" "$_rpc_back" 2>/dev/null || {
[ -e "$_rpc_entry" ] || [ -L "$_rpc_entry" ] || continue
break
}
done < "$_rpc_rev"
rm -f "$_rpc_rev"
return 0
}
copy_tree() {
src="$1"
dest="$2"
rm -rf "$dest"
mkdir -p "$dest"
cp -R "$src/." "$dest/"
parent="$(dirname "$dest")"
mkdir -p "$parent" || return 1
stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1
manifest="$(mktemp "$parent/.dws-skill.txn.XXXXXX")" || { rm -rf "$stage"; return 1; }
if ! cp -R "$src/." "$stage/"; then discard_stage "$stage"; rm -f "$manifest"; return 1; fi
chmod u+w "$stage" 2>/dev/null || true
backup="$(backup_skill_dir "$dest")" || { discard_stage "$stage"; rm -f "$manifest"; return 1; }
_ct_mode="$(perm_of "$src")" || { discard_stage "$stage"; rm -f "$manifest"; return 1; }
# mkdir is the atomic no-replace claim: it fails when the path is already
# occupied, and the claim is then held for the whole transaction. Children
# publish into the claim through per-entry no-clobber primitives (see
# publish_tree_children) so a concurrent writer can never be replaced; the
# rollback only retracts what the manifest recorded, and the claim itself
# is removed only while still empty, so foreign entries always survive.
if mkdir "$dest" 2>/dev/null; then
if publish_tree_children "$stage" "$dest" "$manifest"; then
chmod "$_ct_mode" "$dest" 2>/dev/null || true
discard_stage "$stage"
rm -f "$manifest"
return 0
fi
retract_published_children "$stage" "$dest" "$manifest"
rmdir "$dest" 2>/dev/null || true
fi
discard_stage "$stage"
rm -f "$manifest"
if [ -n "$backup" ]; then
restore_manifest="$(mktemp "$parent/.dws-skill.txn.XXXXXX")" || return 1
if mkdir "$dest" 2>/dev/null; then
# The restore publishes the backup through the same no-clobber
# discipline: a concurrent writer that took the emptied path (or any
# entry inside it) is refused and the backup is kept intact instead
# of being partially drained by an overwriting move.
if publish_tree_children "$backup" "$dest" "$restore_manifest"; then
rmdir "$backup" 2>/dev/null || true
else
retract_published_children "$backup" "$dest" "$restore_manifest"
rmdir "$dest" 2>/dev/null || true
printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2
fi
else
printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2
fi
rm -f "$restore_manifest"
fi
return 1
}
# refresh_cache_tree <src> <dest>
# Same staged, atomic swap as copy_tree, but for the ephemeral ~/.dws/skills
# caches: those trees are byte-reproducible from the release asset, so archiving
# every stale generation into ~/.dws/skill-backups only grows that directory
# without bound. The previous cache is deleted, exactly like install-devapp.sh —
# but only after the replacement is fully staged and published.
refresh_cache_tree() {
src="$1"
dest="$2"
parent="$(dirname "$dest")"
mkdir -p "$parent" || return 1
stage="$(mktemp -d "$parent/.dws-cache.tmp.XXXXXX")" || return 1
if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi
old=""
if [ -e "$dest" ] || [ -L "$dest" ]; then
old="$(mktemp -d "$parent/.dws-cache.old.XXXXXX")" || { rm -rf "$stage"; return 1; }
rmdir "$old" || { rm -rf "$stage" "$old"; return 1; }
if ! mv "$dest" "$old"; then rm -rf "$stage"; return 1; fi
fi
if mv "$stage" "$dest"; then
if [ -n "$old" ] && ! rm -rf "$old"; then
printf ' ⚠️ 新缓存已生效,但旧缓存清理失败: %s\n' "$old" >&2
fi
return 0
fi
rm -rf "$stage"
if [ -n "$old" ] && ! mv "$old" "$dest"; then
printf ' ⚠️ 缓存刷新失败,原缓存保留在 %s\n' "$old" >&2
fi
return 1
}
backup_skill_dir() {
victim="$1"
[ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; }
# Prune before creating this run's archive; every stamp this run has already
# created is registered, so pruning can only remove earlier-run batches.
prune_skill_backups
stamp="$(date -u +%Y%m%d-%H%M%S)"
name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')"
backup_root="$HOME/.dws/skill-backups/$stamp"
backup="$backup_root/$name"
i=0
# Bump not only when the payload path is taken but also when the stamp
# root exists without a verified ownership marker: a same-second foreign
# directory must never be stamped DWS-owned and made prunable. A
# marker-verified root from this run's same second stays reusable.
while [ -e "$backup" ] || [ -L "$backup" ] ||
{ [ -d "$backup_root" ] && ! is_current_run_backup_stamp "$backup_root" &&
[ "$(cat "$backup_root/.dws-skill-backup" 2>/dev/null)" != "dws skill backup v1" ]; }; do
i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name"
# Same bail-out as install-skills.sh: never spin forever on a pathological
# backup root, report it and keep the original directory instead.
if [ "$i" -gt 1000 ]; then
printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$victim" >&2
return 1
fi
done
record_current_run_backup_stamp "$backup_root"
# Freshness must be sampled before mkdir: the collision loop tests the
# payload path, so a second backup in the same stamp second reuses this
# root while a sibling payload from this run already lives in it.
fresh=1
[ ! -d "$backup_root" ] || fresh=0
mkdir -p "$backup_root" || return 1
# Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a
# stamp-shaped name alone is not evidence, so pruning only ever removes
# roots carrying this marker — it must exist before the payload moves in.
printf '%s\n' 'dws skill backup v1' > "$backup_root/.dws-skill-backup" || {
# Non-recursive cleanup, sibling protection: only a root this call
# created may be dropped, and only the marker plus an empty root (rmdir
# refuses a non-empty directory) — rm -rf here would destroy a completed
# same-second sibling backup whose original is already moved away.
if [ "$fresh" -eq 1 ]; then
rm -f "$backup_root/.dws-skill-backup"
rmdir "$backup_root" 2>/dev/null || true
fi
return 1
}
mv "$victim" "$backup" || return 1
printf '%s\n' "$backup"
}
# prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup
# directories from earlier runs under $HOME/.dws/skill-backups, matching Go's
# skillBackupKeep / pruneSkillBackups. Only stamp-shaped roots carrying the
# .dws-skill-backup marker with the exact expected content are counted or
# removed: foreign data with a stamp-like name never consumes a keep slot
# and is never deleted, silently. Stamp directories created by the
# current process are never pruned (mirroring Go's run-root registry and
# install.js's currentRunBackupRoots), so a migration that retires more than
# SKILL_BACKUP_KEEP batches stays reversible. Glob order is lexicographic and
# the stamps are UTC YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort:
# a removal failure is reported and never fatal.
SKILL_BACKUP_KEEP=5
# Stamp directories created by this run, recorded by backup_skill_dir so
# pruning can never delete a backup the running migration may still need to
# roll back to.
CURRENT_RUN_BACKUP_STAMPS=""
record_current_run_backup_stamp() {
case " $CURRENT_RUN_BACKUP_STAMPS " in
*" $1 "*) return 0 ;;
esac
CURRENT_RUN_BACKUP_STAMPS="${CURRENT_RUN_BACKUP_STAMPS} $1"
}
# is_current_run_backup_stamp reports whether the stamp root was created by
# this very process. Such a root is ours by construction and stays reusable
# even when its marker cannot be re-verified mid-run (for example a
# permission failure after the first payload moved in).
is_current_run_backup_stamp() {
case " $CURRENT_RUN_BACKUP_STAMPS " in
*" $1 "*) return 0 ;;
esac
return 1
}
# is_skill_backup_stamp accepts only directory names with the stamp shape DWS
# itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix.
# Shape is necessary but not sufficient — pruning additionally verifies the
# .dws-skill-backup ownership marker — while any other entry in the backup
# root is foreign (user data, unrelated tooling) and is preserved so pruning
# can never remove a directory it cannot prove DWS created.
is_skill_backup_stamp() {
case "$1" in
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
return 0 ;;
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*)
# base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip
# those 16 chars and require the remainder to be all digits.
_isbs_suffix="${1#????????????????}"
case "$_isbs_suffix" in
""|*[!0-9]*) return 1 ;;
esac
return 0 ;;
esac
return 1
}
prune_skill_backups() {
prune_root="$HOME/.dws/skill-backups"
[ -d "$prune_root" ] || return 0
prune_total=0
for prune_entry in "$prune_root"/*; do
[ -d "$prune_entry" ] || continue
is_skill_backup_stamp "${prune_entry##*/}" || continue
[ "$(cat "$prune_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue
prune_total=$((prune_total + 1))
done
[ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0
prune_drop=$((prune_total - SKILL_BACKUP_KEEP))
for prune_entry in "$prune_root"/*; do
[ "$prune_drop" -gt 0 ] || break
[ -d "$prune_entry" ] || continue
is_skill_backup_stamp "${prune_entry##*/}" || continue
[ "$(cat "$prune_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue
case " $CURRENT_RUN_BACKUP_STAMPS " in
*" $prune_entry "*) continue ;;
esac
rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2
prune_drop=$((prune_drop - 1))
done
}
same_physical_skill() {
[ -d "$1" ] && [ -d "$2" ] || return 1
left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1
right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1
[ "$left" = "$right" ]
}
is_cleanup_only_agent_dir() {
case "$1" in
.config/agents/skills|.gemini/antigravity/skills|.gemini/antigravity-cli/skills|.codex/skills|.cursor/skills|.deepagents/agent/skills|.firebender/skills|.gemini/skills|.copilot/skills|.config/opencode/skills|.github/skills|.windsurf/skills|.cline/skills|.amp/skills) return 0 ;;
*) return 1 ;;
esac
}
agent_skill_dirs() {
printf '%s\n' \
.config/agents/skills .gemini/antigravity/skills .gemini/antigravity-cli/skills .codex/skills .cursor/skills .deepagents/agent/skills .firebender/skills .gemini/skills .copilot/skills .config/opencode/skills \
.aider-desk/skills .astrbot/data/skills .autohand/skills .augment/skills .bob/skills .claude/skills .openclaw/skills .codeartsdoer/skills .codebuddy/skills .codemaker/skills .codestudio/skills .commandcode/skills .continue/skills .snowflake/cortex/skills .config/crush/skills .config/devin/skills .factory/skills .forge/skills .config/goose/skills .grok/skills .hermes/skills .inferencesh/skills .jazz/skills .junie/skills .iflow/skills .kilocode/skills .config/kimchi/harness/skills .kiro/skills .kode/skills .lingma/skills .mcpjam/skills .minimax/skills .vibe/skills .moxby/skills .mux/skills .openhands/skills .ona/skills .pi/agent/skills .qoder/skills .qoder-cn/skills .qwen/skills .reasonix/skills .rovodev/skills .roo/skills .tabnine/agent/skills .terramind/skills .tinycloud/skills .trae/skills .trae-cn/skills .codeium/windsurf/skills .zcode/skills .zencoder/skills .neovate/skills .pochi/skills .adal/skills \
.qoderwork/skills .github/skills .windsurf/skills .cline/skills .amp/skills
}
resolve_agent_skill_base() {
agent_dir="$1"; base="$HOME/$agent_dir"
case "$agent_dir" in
.claude/skills) [ -n "${CLAUDE_CONFIG_DIR:-}" ] && base="$CLAUDE_CONFIG_DIR/skills" ;;
.codex/skills) [ -n "${CODEX_HOME:-}" ] && base="$CODEX_HOME/skills" ;;
.hermes/skills) [ -n "${HERMES_HOME:-}" ] && base="$HERMES_HOME/skills" ;;
.autohand/skills) [ -n "${AUTOHAND_HOME:-}" ] && base="$AUTOHAND_HOME/skills" ;;
.grok/skills) [ -n "${GROK_HOME:-}" ] && base="$GROK_HOME/skills" ;;
.vibe/skills) [ -n "${VIBE_HOME:-}" ] && base="$VIBE_HOME/skills" ;;
.openclaw/skills) for legacy in .openclaw .clawdbot .moltbot; do [ -d "$HOME/$legacy" ] && { base="$HOME/$legacy/skills"; break; }; done ;;
.config/*) base="${XDG_CONFIG_HOME:-$HOME/.config}/${agent_dir#.config/}" ;;
esac
printf '%s\n' "$base"
}
agent_skill_base_detected() {
agent_dir="$1"; base="$2"
case "$agent_dir" in
.config/kimchi/harness/skills|.tabnine/agent/skills) [ -d "$(dirname "$(dirname "$base")")" ] ;;
.zcode/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/ZCode.app" ] ;;
.minimax/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;;
*) [ -d "$(dirname "$base")" ] ;;
esac
}
link_or_copy_skill() {
canonical="$1"; src="$2"; dest="$3"
same_physical_skill "$dest" "$canonical" && return 0
parent="$(dirname "$dest")"; mkdir -p "$parent" || return 1
parent_real="$(CDPATH= cd -- "$parent" && pwd -P)" || return 1
target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1
relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j<nt?"/":""); if(out=="")out="."; print out }')"
stage="$(mktemp -d "$parent/.dws-link.tmp.XXXXXX")" || return 1
if ! ln -s "$relative" "$stage/skill" 2>/dev/null; then
rm -rf "$stage"
if copy_tree "$src" "$dest"; then
printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$dest" >&2
return 0
fi
return 1
fi
backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; }
# Create the link directly at the destination: symlink(2) refuses an
# occupied path with EEXIST, so publication itself is the atomic no-replace
# check (`mv` after the backup could still replace a file or symlink a
# concurrent writer created in between). A directory that slipped in turns
# `ln -s` into a container: remove exactly our nested link and roll back.
if ln -s "$relative" "$dest" 2>/dev/null && [ -L "$dest" ]; then
rm -rf "$stage"
return 0
fi
nested="$dest/${relative##*/}"
if [ -L "$nested" ] && [ "$(readlink "$nested" 2>/dev/null)" = "$relative" ]; then
rm -f "$nested" || true
fi
rm -rf "$stage"
if [ -n "$backup" ]; then
if [ -e "$dest" ] || [ -L "$dest" ]; then
printf ' ⚠️ %s 在发布期间被并发写入占用,原 Skill 保留于备份: %s\n' "$dest" "$backup" >&2
elif ! mv "$backup" "$dest"; then
printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2
fi
fi
return 1
}
install_skill_to_homes() {
src="$1"
skill_name="$2"
installed=0
idx=0
for agent_dir in \
.agents/skills .claude/skills .cursor/skills .qoder/skills .qoderwork/skills \
.gemini/skills .codex/skills .github/skills .windsurf/skills .augment/skills \
.cline/skills .amp/skills .kiro/skills .trae/skills .openclaw/skills .hermes/skills \
.config/opencode/skills
do
base="$HOME/$agent_dir"
if [ "$idx" -gt 0 ] && [ ! -e "$(dirname "$base")" ]; then
idx=$((idx + 1))
canonical="$HOME/.agents/skills/$skill_name"
copy_tree "$src" "$canonical" || return 1
installed=1
failed=0
retire_failed=0
for agent_dir in $(agent_skill_dirs); do
base="$(resolve_agent_skill_base "$agent_dir")"
agent_skill_base_detected "$agent_dir" "$base" || continue
same_physical_skill "$base" "$HOME/.agents/skills" && continue
if is_cleanup_only_agent_dir "$agent_dir"; then
# Cleanup-only: universal Agents read the canonical store directly, so
# nothing is installed here. A retire failure only leaves an obsolete copy
# behind and must never fail an otherwise complete install.
if ! backup_skill_dir "$base/$skill_name" >/dev/null; then
printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): %s\n' "$base/$skill_name" >&2
retire_failed=$((retire_failed + 1))
fi
continue
fi
# Per-agent degrade like install.sh: a failed target is reported and
# skipped loudly instead of aborting the remaining agents mid-loop.
if ! link_or_copy_skill "$canonical" "$src" "$base/$skill_name"; then
printf ' ⚠️ Agent 目标安装失败,已跳过: %s\n' "$base/$skill_name" >&2
failed=$((failed + 1))
continue
fi
copy_tree "$src" "$base/$skill_name"
installed=$((installed + 1))
idx=$((idx + 1))
done
if [ "$installed" -eq 0 ]; then
copy_tree "$src" "$HOME/.agents/skills/$skill_name"
installed=1
if [ "$retire_failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" >&2
fi
if [ "$failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 目标安装 %s 失败\n' "$failed" "$skill_name" >&2
return 1
fi
printf '%s\n' "$installed"
}
@@ -176,17 +626,21 @@ install_skills_from_bundle() {
event_cache="$HOME/.dws/skills/multi/$EVENT_SKILL_NAME"
shared_cache="$HOME/.dws/skills/multi/$SHARED_SKILL_NAME"
misc_cache="$HOME/.dws/skills/multi/$MISC_SKILL_NAME"
copy_tree "$event_src" "$event_cache"
copy_tree "$shared_src" "$shared_cache"
copy_tree "$misc_src" "$misc_cache"
refresh_cache_tree "$event_src" "$event_cache" || err "${EVENT_SKILL_NAME} 缓存刷新失败: ${event_cache}"
refresh_cache_tree "$shared_src" "$shared_cache" || err "${SHARED_SKILL_NAME} 缓存刷新失败: ${shared_cache}"
refresh_cache_tree "$misc_src" "$misc_cache" || err "${MISC_SKILL_NAME} 缓存刷新失败: ${misc_cache}"
mono_cache="$HOME/.dws/skills/mono"
copy_tree "$mono_src" "$mono_cache"
refresh_cache_tree "$mono_src" "$mono_cache" || err "mono ${MONO_SKILL_NAME} 缓存刷新失败: ${mono_cache}"
event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")"
shared_installed="$(install_skill_to_homes "$shared_src" "$SHARED_SKILL_NAME")"
misc_installed="$(install_skill_to_homes "$misc_src" "$MISC_SKILL_NAME")"
mono_installed="$(install_skill_to_homes "$mono_src" "$MONO_SKILL_NAME")"
event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")" \
|| err "${EVENT_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警"
shared_installed="$(install_skill_to_homes "$shared_src" "$SHARED_SKILL_NAME")" \
|| err "${SHARED_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警"
misc_installed="$(install_skill_to_homes "$misc_src" "$MISC_SKILL_NAME")" \
|| err "${MISC_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警"
mono_installed="$(install_skill_to_homes "$mono_src" "$MONO_SKILL_NAME")" \
|| err "mono ${MONO_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警"
say "Skill ${EVENT_SKILL_NAME} -> ${event_installed} agent dir(s)"
say "Skill ${SHARED_SKILL_NAME} -> ${shared_installed} agent dir(s)"
@@ -212,6 +666,7 @@ install_binary() {
say "Downloading ${asset} ..."
curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/${asset}" -o "$tmp/$asset" \
|| err "Binary download failed. Does release ${EVENT_VERSION} have ${asset}?"
verify_release_asset "$asset" "$tmp/$asset"
if [ "$os" = "windows" ]; then
extract_zip "$tmp/$asset" "$tmp/bin"
@@ -240,6 +695,7 @@ install_skills() {
say "Downloading dws-skills.zip ..."
curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/dws-skills.zip" -o "$tmp/dws-skills.zip" \
|| err "dws-skills.zip download failed for release ${EVENT_VERSION}"
verify_release_asset "dws-skills.zip" "$tmp/dws-skills.zip"
mkdir -p "$tmp/skills"
extract_zip "$tmp/dws-skills.zip" "$tmp/skills"
+670 -138
View File
@@ -53,24 +53,54 @@ DWS_LAST_SKILL_BACKUP=""
backup_and_remove_skill_dir() {
_bed_dir="$1"
DWS_LAST_SKILL_BACKUP=""
[ -d "$_bed_dir" ] || return 0
[ -e "$_bed_dir" ] || [ -L "$_bed_dir" ] || return 0
_bed_root="${HOME}/.dws/skill-backups"
_bed_stamp="$(date -u +%Y%m%d-%H%M%S)"
_bed_name="$(basename "$_bed_dir")"
_bed_target="$_bed_root/$_bed_stamp/$_bed_name"
_bed_stamp_root="$_bed_root/$_bed_stamp"
_bed_target="$_bed_stamp_root/$_bed_name"
_bed_i=1
while [ -e "$_bed_target" ]; do
_bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name"
# Bump not only when the payload path is taken but also when the stamp
# root exists without a verified ownership marker: a same-second foreign
# directory must never be stamped DWS-owned and made prunable. A
# marker-verified root from this run's same second stays reusable.
while [ -e "$_bed_target" ] || [ -L "$_bed_target" ] ||
{ [ -d "$_bed_stamp_root" ] && ! is_current_run_backup_stamp "$_bed_stamp_root" &&
[ "$(cat "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null)" != "dws skill backup v1" ]; }; do
_bed_stamp_root="$_bed_root/$_bed_stamp-$_bed_i"
_bed_target="$_bed_stamp_root/$_bed_name"
_bed_i=$((_bed_i + 1))
if [ "$_bed_i" -gt 1000 ]; then
printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$_bed_dir"
return 1
fi
done
mkdir -p "$(dirname "$_bed_target")" 2>/dev/null || {
_bed_stamp_root="$(dirname "$_bed_target")"
record_current_run_backup_stamp "$_bed_stamp_root"
# Freshness must be sampled before mkdir: the collision loop tests the
# payload path, so a second backup in the same stamp second reuses this
# root while a sibling payload from this run already lives in it.
_bed_fresh=1
[ ! -d "$_bed_stamp_root" ] || _bed_fresh=0
mkdir -p "$_bed_stamp_root" 2>/dev/null || {
printf ' ⚠️ 无法创建备份目录,保留原目录 %s\n' "$_bed_dir"
return 1
}
# Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a
# stamp-shaped name alone is not evidence, so pruning only ever removes
# roots carrying this marker — it must exist before any payload moves in.
printf '%s\n' 'dws skill backup v1' > "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null || {
# Non-recursive cleanup, sibling protection: only a root this call
# created may be dropped, and only the marker plus an empty root (rmdir
# refuses a non-empty directory) — rm -rf here would destroy a completed
# same-second sibling backup whose original is already moved away.
if [ "$_bed_fresh" -eq 1 ]; then
rm -f "$_bed_stamp_root/.dws-skill-backup"
rmdir "$_bed_stamp_root" 2>/dev/null || true
fi
printf ' ⚠️ 无法写入备份所有权标记,保留原目录 %s\n' "$_bed_dir"
return 1
}
if mv "$_bed_dir" "$_bed_target" 2>/dev/null; then
DWS_LAST_SKILL_BACKUP="$_bed_target"
printf ' × 已备份并移除 %s → %s\n' "$_bed_dir" "$_bed_target"
@@ -80,6 +110,88 @@ backup_and_remove_skill_dir() {
return 1
}
# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped
# backup directories from earlier runs under $HOME/.dws/skill-backups,
# matching Go's skillBackupKeep / pruneSkillBackups. Only stamp-shaped roots
# carrying the .dws-skill-backup marker with the exact expected content are
# counted or removed: foreign data with a stamp-like name never consumes a
# keep slot and is never deleted, silently. Stamp directories created
# by the current process are never pruned (mirroring Go's run-root registry
# and install.js's currentRunBackupRoots), so a migration that retires more
# than DWS_SKILL_BACKUP_KEEP batches stays reversible. Best-effort: a removal
# failure is reported, never fatal.
DWS_SKILL_BACKUP_KEEP=5
# Stamp directories created by this run, recorded by
# backup_and_remove_skill_dir so pruning can never delete a backup the
# running migration may still need to roll back to.
DWS_CURRENT_RUN_BACKUP_STAMPS=""
record_current_run_backup_stamp() {
case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in
*" $1 "*) return 0 ;;
esac
DWS_CURRENT_RUN_BACKUP_STAMPS="${DWS_CURRENT_RUN_BACKUP_STAMPS} $1"
}
# is_current_run_backup_stamp reports whether the stamp root was created by
# this very process. Such a root is ours by construction and stays reusable
# even when its marker cannot be re-verified mid-run (for example a
# permission failure after the first payload moved in).
is_current_run_backup_stamp() {
case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in
*" $1 "*) return 0 ;;
esac
return 1
}
# is_skill_backup_stamp accepts only directory names with the stamp shape DWS
# itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix.
# Shape is necessary but not sufficient — pruning additionally verifies the
# .dws-skill-backup ownership marker — while any other entry in the backup
# root is foreign (user data, unrelated tooling) and is preserved so pruning
# can never remove a directory it cannot prove DWS created.
is_skill_backup_stamp() {
case "$1" in
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
return 0 ;;
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*)
# base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip
# those 16 chars and require the remainder to be all digits.
_isbs_suffix="${1#????????????????}"
case "$_isbs_suffix" in
""|*[!0-9]*) return 1 ;;
esac
return 0 ;;
esac
return 1
}
prune_skill_backups() {
_psb_root="${HOME}/.dws/skill-backups"
[ -d "$_psb_root" ] || return 0
_psb_total=0
for _psb_entry in "$_psb_root"/*; do
[ -d "$_psb_entry" ] || continue
is_skill_backup_stamp "${_psb_entry##*/}" || continue
[ "$(cat "$_psb_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue
_psb_total=$((_psb_total + 1))
done
[ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0
_psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP))
for _psb_entry in "$_psb_root"/*; do
[ "$_psb_drop" -gt 0 ] || break
[ -d "$_psb_entry" ] || continue
is_skill_backup_stamp "${_psb_entry##*/}" || continue
[ "$(cat "$_psb_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue
case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in
*" $_psb_entry "*) continue ;;
esac
rm -rf "$_psb_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$_psb_entry"
_psb_drop=$((_psb_drop - 1))
done
}
# A dingtalk-* prefix alone is not ownership evidence: market/user skills may
# use it too. Ownership comes from the centralized skills-state.json.
is_managed_multi_skill_dir() {
@@ -123,6 +235,46 @@ sha256_stdin() {
fi
}
# sha256_file <path>: content digest used by skill_dir_content_digest. Never
# emits on failure. Callers that capture its status directly detect that
# failure; skill_dir_content_digest cannot — see the pipeline caveat there:
# POSIX sh returns only the right-hand pipeline status, so a subshell exit on
# its left side is discarded and digest-failure detection stays best-effort.
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" 2>/dev/null | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'
elif command -v openssl >/dev/null 2>&1; then
openssl dgst -sha256 -r "$1" 2>/dev/null | awk '{print $1}'
else
return 1
fi
}
verify_release_asset_checksum() {
_checksum_asset="$1"
_checksum_path="$2"
_checksum_dir="$3"
_checksum_url="$(asset_url checksums.txt)"
[ -n "$_checksum_url" ] || { printf '❌ Could not resolve checksums.txt for %s.\n' "$VERSION" >&2; return 1; }
curl -fsSL "$_checksum_url" -o "$_checksum_dir/checksums.txt" || {
printf '❌ Could not download checksums.txt for %s; refusing unverified %s.\n' "$VERSION" "$_checksum_asset" >&2
return 1
}
_checksum_expected="$(awk -v file="$_checksum_asset" '$2 == file {print $1; exit}' "$_checksum_dir/checksums.txt")"
[ -n "$_checksum_expected" ] || { printf '❌ %s is missing from checksums.txt.\n' "$_checksum_asset" >&2; return 1; }
_checksum_actual="$(sha256_stdin < "$_checksum_path")" || {
printf '❌ Could not compute SHA256 for %s; install sha256sum, shasum, or openssl.\n' "$_checksum_asset" >&2
return 1
}
[ "$_checksum_actual" = "$_checksum_expected" ] || {
printf '❌ SHA256 checksum mismatch for %s. Expected %s, got %s.\n' "$_checksum_asset" "$_checksum_expected" "$_checksum_actual" >&2
return 1
}
printf ' ✅ SHA256 checksum verified: %s\n' "$_checksum_asset"
}
digest_skill_dir() {
_digest_dir="$1"
_digest="$({
@@ -189,24 +341,147 @@ backup_and_record_skill_dir() {
fi
}
# restore_quarantine_no_replace <quarantined> <dest>
# Puts an unmatched quarantined object back onto dest without replacing
# anything that occupied dest after the claim. Directories use mkdir-claim
# plus child moves; links are created at dest; files hard-link then drop
# the quarantine copy.
restore_quarantine_no_replace() {
_rqn_src="$1"
_rqn_dest="$2"
if [ -L "$_rqn_src" ]; then
_rqn_target="$(readlink "$_rqn_src")" || return 1
if ! ln -s "$_rqn_target" "$_rqn_dest" 2>/dev/null || ! [ -L "$_rqn_dest" ]; then
_rqn_nested="$_rqn_dest/${_rqn_target##*/}"
if [ -L "$_rqn_nested" ] && [ "$(readlink "$_rqn_nested" 2>/dev/null)" = "$_rqn_target" ]; then
rm -f "$_rqn_nested"
fi
return 1
fi
rm -f "$_rqn_src"
return 0
fi
if [ -d "$_rqn_src" ]; then
mkdir "$_rqn_dest" 2>/dev/null || return 1
_rqn_failed=0
for _rqn_child in "$_rqn_src"/* "$_rqn_src"/.[!.]* "$_rqn_src"/..?*; do
[ -e "$_rqn_child" ] || [ -L "$_rqn_child" ] || continue
if ! mv "$_rqn_child" "$_rqn_dest/"; then
_rqn_failed=1
break
fi
done
if [ "$_rqn_failed" -eq 0 ]; then
rmdir "$_rqn_src" 2>/dev/null || return 1
return 0
fi
for _rqn_child in "$_rqn_dest"/* "$_rqn_dest"/.[!.]* "$_rqn_dest"/..?*; do
[ -e "$_rqn_child" ] || [ -L "$_rqn_child" ] || continue
mv "$_rqn_child" "$_rqn_src/" || return 1
done
rmdir "$_rqn_dest" 2>/dev/null || return 1
return 1
fi
if [ -f "$_rqn_src" ]; then
# Shell `ln` treats an occupied directory as a container. Refuse that
# dest and drop any nested name instead of leaving a hardlink inside it.
if ! ln "$_rqn_src" "$_rqn_dest" 2>/dev/null || [ -d "$_rqn_dest" ]; then
_rqn_nested="$_rqn_dest/$(basename "$_rqn_src")"
[ -f "$_rqn_nested" ] && rm -f "$_rqn_nested"
return 1
fi
rm -f "$_rqn_src"
return 0
fi
return 1
}
# quarantine_retract_or_restore <dest> <expected-inode> [expected-link-target]
# Prove ownership at dest first (inode plus child names for directories).
# Quarantining before that check would move a concurrent replacement off its
# original path. Linux overlayfs recycles inodes, so inode alone is not
# enough — a dest whose children no longer match the recorded publication
# stays put. After a matching dest is claimed, identity is re-checked in
# quarantine; a mismatch is restored with no-replace.
quarantine_retract_or_restore() {
_qrr_dest="$1"
_qrr_inode="$2"
_qrr_link_target="${3-}"
if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then
return 0
fi
_qrr_owned=0
if [ -n "$_qrr_link_target" ]; then
if [ -L "$_qrr_dest" ] &&
[ "$(readlink "$_qrr_dest" 2>/dev/null)" = "$_qrr_link_target" ] &&
[ "$(skill_link_inode "$_qrr_dest")" = "$_qrr_inode" ]; then
_qrr_owned=1
fi
elif [ "$(skill_dir_identity "$_qrr_dest")" = "$_qrr_inode" ]; then
_qrr_owned=1
fi
if [ "$_qrr_owned" -eq 0 ]; then
printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s\n' "$_qrr_dest"
return 1
fi
_qrr_parent="$(dirname "$_qrr_dest")"
_qrr_base="$(basename "$_qrr_dest")"
_qrr_root="$(mktemp -d "$_qrr_parent/.${_qrr_base}.rollback.XXXXXX")" || return 1
_qrr_payload="$_qrr_root/payload"
if ! mv "$_qrr_dest" "$_qrr_payload"; then
rmdir "$_qrr_root" 2>/dev/null || rm -rf "$_qrr_root"
if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then
return 0
fi
printf ' ⚠️ 无法隔离待回滚 Skill: %s\n' "$_qrr_dest"
return 1
fi
_qrr_owned=0
if [ -n "$_qrr_link_target" ]; then
if [ -L "$_qrr_payload" ] &&
[ "$(readlink "$_qrr_payload" 2>/dev/null)" = "$_qrr_link_target" ] &&
[ "$(skill_link_inode "$_qrr_payload")" = "$_qrr_inode" ]; then
_qrr_owned=1
fi
elif [ "$(skill_dir_identity "$_qrr_payload")" = "$_qrr_inode" ]; then
_qrr_owned=1
fi
if [ "$_qrr_owned" -eq 1 ]; then
rm -rf "$_qrr_root" || return 1
return 0
fi
if restore_quarantine_no_replace "$_qrr_payload" "$_qrr_dest"; then
rmdir "$_qrr_root" 2>/dev/null || true
printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s\n' "$_qrr_dest"
return 1
fi
printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s(并发对象保留于 %s)\n' "$_qrr_dest" "$_qrr_payload"
return 1
}
# restore_multi_skill_set <published-manifest> <backup-manifest>
# Removes partial new publications, then restores every old directory from
# its exact backup path. Paths containing newlines are outside the supported
# its exact backup path. Dest is claimed into quarantine first; identity is
# re-checked there so a concurrent replacement is renamed back instead of
# being deleted by path. Paths containing newlines are outside the supported
# installer path contract; spaces are preserved.
restore_multi_skill_set() {
_rms_published="$1"
_rms_backups="$2"
_rms_ok=1
if [ -f "$_rms_published" ]; then
while IFS= read -r _rms_dest; do
[ -n "$_rms_dest" ] || continue
rm -rf "$_rms_dest" || _rms_ok=0
while IFS= read -r _rms_entry; do
[ -n "$_rms_entry" ] || continue
_rms_dest="${_rms_entry%:*}"
_rms_inode="${_rms_entry##*:}"
[ "$_rms_dest" != "$_rms_entry" ] || { printf ' ⚠️ 跳过格式异常的发布记录: %s\n' "$_rms_entry"; _rms_ok=0; continue; }
quarantine_retract_or_restore "$_rms_dest" "$_rms_inode" || _rms_ok=0
done < "$_rms_published"
fi
if [ -f "$_rms_backups" ]; then
while IFS= read -r _rms_original && IFS= read -r _rms_backup; do
[ -n "$_rms_backup" ] || continue
if [ -e "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then
if ! mkdir -p "$(dirname "$_rms_original")" || ! restore_quarantine_no_replace "$_rms_backup" "$_rms_original"; then
printf ' ⚠️ 无法恢复原 Skill: %s(备份保留于 %s)\n' "$_rms_original" "$_rms_backup"
_rms_ok=0
fi
@@ -215,6 +490,135 @@ restore_multi_skill_set() {
[ "$_rms_ok" -eq 1 ]
}
# A link publication manifest stores destination/target/inode triples. Rollback
# only removes links that still have the identity created by this transaction;
# a path concurrently replaced with a file, directory, or new link is preserved.
skill_link_inode() {
_sli_entry="$(ls -di "$1" 2>/dev/null)" || return 1
printf '%s\n' "$_sli_entry" | awk '{print $1}'
}
# Directory publication identity is inode, a stable child-name list, and a
# recursive content digest. Inode reuse on Linux overlayfs would otherwise
# make a concurrent directory look owned, and an in-place content edit after
# publish (same inode, same names) would otherwise be retracted as this
# transaction's object. The token has no colon so dest:identity manifest
# parsing stays dest="${entry%:*}" / identity="${entry##*:}".
skill_dir_identity() {
_sdi_inode="$(skill_link_inode "$1")" || return 1
if [ -L "$1" ] || [ ! -d "$1" ]; then
printf '%s\n' "$_sdi_inode"
return 0
fi
_sdi_names="$(LC_ALL=C ls -A "$1" 2>/dev/null | tr '\n' ',')"
_sdi_digest="$(skill_dir_content_digest "$1")" || return 1
printf '%s,%s,%s\n' "$_sdi_inode" "$_sdi_names" "$_sdi_digest"
}
# Recursive content digest over a published tree, mirroring the Go
# fingerprint: sorted relative paths emit type+permission bits plus either a
# content sha256 or the symlink target, and the whole line stream feeds
# sha256_stdin. Failure detection is asymmetric and best-effort: with no hash
# tool installed sha256_stdin itself returns 1 and the identity genuinely
# fails, but an unreadable file only exits the left-hand subshell — /bin/sh
# has no pipefail, so the pipeline returns sha256_stdin's status and the
# digest is computed over a truncated stream instead of failing.
skill_dir_content_digest() {
(
cd "$1" 2>/dev/null || exit 1
find . -print 2>/dev/null |
LC_ALL=C sort |
while IFS= read -r _sdcd_path; do
_sdcd_mode="$(ls -ld "$_sdcd_path" 2>/dev/null | cut -c1-10)"
if [ -L "$_sdcd_path" ]; then
printf '%s|%s|link|%s\n' "$_sdcd_path" "$_sdcd_mode" "$(readlink "$_sdcd_path" 2>/dev/null)"
elif [ -d "$_sdcd_path" ]; then
printf '%s|%s|dir\n' "$_sdcd_path" "$_sdcd_mode"
elif [ -f "$_sdcd_path" ]; then
_sdcd_hash="$(sha256_file "$_sdcd_path")" || exit 1
printf '%s|%s|file|%s\n' "$_sdcd_path" "$_sdcd_mode" "$_sdcd_hash"
else
printf '%s|%s|other\n' "$_sdcd_path" "$_sdcd_mode"
fi
done
) | sha256_stdin
}
skill_link_matches() {
[ -L "$1" ] || return 1
_slm_target="$(readlink "$1" 2>/dev/null)" || return 1
[ "$_slm_target" = "$2" ] || return 1
_slm_expected_inode="${3-}"
[ -z "$_slm_expected_inode" ] || [ "$(skill_link_inode "$1")" = "$_slm_expected_inode" ]
}
restore_linked_skill_set() {
_rls_published="$1"
_rls_backups="$2"
_rls_ok=1
if [ -f "$_rls_published" ]; then
while IFS= read -r _rls_dest && IFS= read -r _rls_target && IFS= read -r _rls_inode; do
[ -n "$_rls_dest" ] || continue
quarantine_retract_or_restore "$_rls_dest" "$_rls_inode" "$_rls_target" || _rls_ok=0
done < "$_rls_published"
fi
restore_multi_skill_set /dev/null "$_rls_backups" || _rls_ok=0
[ "$_rls_ok" -eq 1 ]
}
cleanup_nested_staged_link() {
_cnsl_nested="$1/$2"
_cnsl_target="$3"
_cnsl_inode="${4-}"
if skill_link_matches "$_cnsl_nested" "$_cnsl_target" "$_cnsl_inode"; then
rm -f "$_cnsl_nested"
fi
}
# publish_skill_dir_no_replace <staged-dir> <dest> <published-manifest>
# Publishes a staged Skill directory with an atomic no-replace claim: mkdir
# fails with EEXIST when anything a concurrent writer created occupies the
# destination, so the claim itself is the existence check — plain `mv` after a
# backup could still replace a concurrently created object. Staged children
# then move into the claim one by one (each rename targets a path inside a
# directory this transaction owns, so no step replaces a foreign object; the
# staging directory is created under the same umask as the claim, so their
# modes match by construction). A failed child move relocates the children
# back and removes only the claim. On success the destination's inode is
# recorded as <dest>:<inode,child-names,content-digest> so rollback only ever
# deletes this transaction's object even when the filesystem recycles the
# claim inode or the published files are edited in place afterwards.
publish_skill_dir_no_replace() {
_psd_stage="$1"
_psd_dest="$2"
_psd_manifest="$3"
if ! mkdir "$_psd_dest" 2>/dev/null; then
return 1
fi
_psd_failed=0
for _psd_child in "$_psd_stage"/* "$_psd_stage"/.[!.]* "$_psd_stage"/..?*; do
[ -e "$_psd_child" ] || [ -L "$_psd_child" ] || continue
if ! mv "$_psd_child" "$_psd_dest/"; then
_psd_failed=1
break
fi
done
if [ "$_psd_failed" -eq 0 ]; then
_psd_inode="$(skill_dir_identity "$_psd_dest")" || _psd_failed=1
if [ "$_psd_failed" -eq 0 ] && printf '%s:%s\n' "$_psd_dest" "$_psd_inode" >> "$_psd_manifest"; then
return 0
fi
_psd_failed=1
fi
for _psd_child in "$_psd_dest"/* "$_psd_dest"/.[!.]* "$_psd_dest"/..?*; do
[ -e "$_psd_child" ] || [ -L "$_psd_child" ] || continue
mv "$_psd_child" "$_psd_stage/" || return 1
done
rmdir "$_psd_dest" 2>/dev/null || return 1
return 1
}
# publish_skill_cache <source> <cache-dir>
# Stages a complete sibling cache before publishing it. Any copy or publish
# failure leaves the previous cache in place (or in the reported recovery dir
@@ -408,16 +812,99 @@ multi_tree_has_skills() {
return 1
}
# Move DWS-owned copies out of the generic root once a concrete Agent root is
# active. This prevents Agents such as Codex from discovering duplicates.
retire_generic_skill_root() {
# Exact upstream registry (76 IDs): id|classification|effective-global-root.
# `-` means no global directory; `.agents/skills` means canonical-direct.
# Not readonly: the library must stay re-sourceable inside one shell.
DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills'
upstream_agent_registry() {
for _uar_record in $DWS_UPSTREAM_AGENT_REGISTRY; do printf '%s\n' "$_uar_record"; done
}
# DWS-only compatibility roots, same id|classification|root format. Qoderwork
# stays a real non-universal install target; the other four are global paths
# older DWS installers wrote by mistake, so they are retired like universal
# roots. Kept separate so the upstream pin above stays byte-comparable.
DWS_LEGACY_AGENT_ROOTS='dws-qoderwork|N|.qoderwork/skills dws-legacy-github|U|.github/skills dws-legacy-windsurf|U|.windsurf/skills dws-legacy-cline|U|.cline/skills dws-legacy-amp|U|.amp/skills'
legacy_agent_roots() {
for _lar_record in $DWS_LEGACY_AGENT_ROOTS; do printf '%s\n' "$_lar_record"; done
}
# ~/.agents/skills is the canonical store under the universal convention.
# Universal Agents read it directly; other Agents receive relative links and
# fall back to copies only when links are unavailable.
#
# Both agent_skill_dirs and is_universal_agent_dir are DERIVED from the pinned
# registries above, so a registry edit changes real install behaviour instead of
# only a test count, and pin/behaviour drift is impossible by construction.
is_universal_agent_dir() {
for _iua_record in $DWS_UPSTREAM_AGENT_REGISTRY $DWS_LEGACY_AGENT_ROOTS; do
[ "${_iua_record##*|}" = "$1" ] || continue
case "$_iua_record" in
*"|U|"*) return 0 ;;
esac
done
return 1
}
# Effective global roots, in pinned registry order, de-duplicated. Records
# without a global root (`-`) and canonical-direct records (`.agents/skills`)
# are not per-Agent targets and are skipped.
agent_skill_dirs() {
{ upstream_agent_registry; legacy_agent_roots; } | awk -F'|' '
$3 != "-" && $3 != ".agents/skills" && !seen[$3]++ { print $3 }'
}
resolve_agent_skill_base() {
_ras_root="$1"; _ras_agent="$2"
case "$_ras_agent" in
".claude/skills") [ -n "${CLAUDE_CONFIG_DIR:-}" ] && { printf '%s\n' "$CLAUDE_CONFIG_DIR/skills"; return; } ;;
".codex/skills") [ -n "${CODEX_HOME:-}" ] && { printf '%s\n' "$CODEX_HOME/skills"; return; } ;;
".hermes/skills") [ -n "${HERMES_HOME:-}" ] && { printf '%s\n' "$HERMES_HOME/skills"; return; } ;;
".autohand/skills") [ -n "${AUTOHAND_HOME:-}" ] && { printf '%s\n' "$AUTOHAND_HOME/skills"; return; } ;;
".grok/skills") [ -n "${GROK_HOME:-}" ] && { printf '%s\n' "$GROK_HOME/skills"; return; } ;;
".vibe/skills") [ -n "${VIBE_HOME:-}" ] && { printf '%s\n' "$VIBE_HOME/skills"; return; } ;;
".openclaw/skills")
for _ras_name in .openclaw .clawdbot .moltbot; do
[ -d "$_ras_root/$_ras_name" ] && { printf '%s\n' "$_ras_root/$_ras_name/skills"; return; }
done ;;
".config/opencode/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/opencode/skills"; return ;;
".config/agents/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/agents/skills"; return ;;
".config/crush/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/crush/skills"; return ;;
".config/devin/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/devin/skills"; return ;;
".config/goose/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/goose/skills"; return ;;
".config/kimchi/harness/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/kimchi/harness/skills"; return ;;
esac
printf '%s\n' "$_ras_root/$_ras_agent"
}
agent_skill_base_detected() {
_asbd_agent="$1"; _asbd_base="$2"
case "$_asbd_agent" in
".config/kimchi/harness/skills"|".tabnine/agent/skills") [ -d "$(dirname "$(dirname "$_asbd_base")")" ] ;;
".zcode/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/ZCode.app" ] ;;
".minimax/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;;
*) [ -d "$(dirname "$_asbd_base")" ] ;;
esac
}
same_physical_skill_root() {
[ -d "$1" ] && [ -d "$2" ] || return 1
# CDPATH= and -- are required: an exported CDPATH makes `cd` echo the resolved
# directory into the command substitution, which would silently report two
# identical roots as different.
_sps_left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1
_sps_right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1
[ "$_sps_left" = "$_sps_right" ]
}
retire_agent_skill_root() {
_rgs_root="$1"
_rgs_base="$_rgs_root/.agents/skills"
_rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-generic.XXXXXX")" || return 1
_rgs_base="$2"
_rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-agent.XXXXXX")" || return 1
_rgs_backups="$_rgs_stage/backups"
: > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; }
for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do
[ -d "$_rgs_victim" ] || continue
[ -e "$_rgs_victim" ] || [ -L "$_rgs_victim" ] || continue
if [ "$(basename "$_rgs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_rgs_victim"; then
continue
fi
@@ -430,76 +917,162 @@ retire_generic_skill_root() {
rm -rf "$_rgs_stage"
}
# link_canonical_skills_to_base <root> <base> <mode> [bundle-src]
# <bundle-src> is the installed multi bundle and is REQUIRED in multi mode: the
# link set must come from the bundle, never from the whole canonical store.
# ~/.agents/skills is now shared, so third-party/user Skills live there too and
# must not be published into every detected Agent root (they are absent from
# skills-state.json, so nothing could ever reclaim those links). Go
# publishLinkedUpgradeTarget and build/npm/install.js pass the bundle skill list
# the same way.
link_canonical_skills_to_base() {
_lcs_root="$1"; _lcs_base="$2"; _lcs_mode="$3"; _lcs_bundle="${4-}"
_lcs_canonical="$_lcs_root/.agents/skills"
mkdir -p "$_lcs_base" || return 1
same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0
if [ "$_lcs_mode" = "mono" ]; then
_lcs_names="dws"
else
[ -n "$_lcs_bundle" ] && [ -d "$_lcs_bundle" ] || return 1
_lcs_names=""
for _lcs_skill in "$_lcs_bundle"/*/; do
[ -f "${_lcs_skill}SKILL.md" ] || continue
_lcs_names="$_lcs_names $(basename "$_lcs_skill")"
done
[ -n "$_lcs_names" ] || return 1
fi
# Collision pre-flight: the victim loop below deliberately refuses to back up
# entries that are not DWS-managed, and publication must never replace them.
# Report the exact colliding paths before touching anything, so the fall back
# to the legacy full-copy layout is observable and actionable.
_lcs_collision=0
for _lcs_name in $_lcs_names; do
_lcs_dest="$_lcs_base/$_lcs_name"
{ [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; } || continue
same_physical_skill_root "$_lcs_dest" "$_lcs_canonical/$_lcs_name" && continue
[ "$_lcs_name" = "dws" ] && continue
is_managed_multi_skill_dir "$_lcs_dest" && continue
printf ' ⚠️ %s 已存在且不是 DWS 安装的 Skill,无法在此建立指向 %s 的共享链接\n' "$_lcs_dest" "$_lcs_canonical/$_lcs_name"
_lcs_collision=1
done
if [ "$_lcs_collision" -eq 1 ]; then
printf ' ⚠️ 已保留上述目录(不会删除用户数据),该 Agent 改用独立副本方式安装;\n'
printf ' 移走或改名后重新运行安装即可切换为共享 %s 布局。\n' "$_lcs_canonical"
return 1
fi
_lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1
_lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1
_lcs_backups="$_lcs_stage/.backups"; _lcs_published="$_lcs_stage/.published"
_lcs_stage_token="$(basename "$_lcs_stage")"
: > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; }
: > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; }
_lcs_publish_names=""
for _lcs_name in $_lcs_names; do
if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then continue; fi
_lcs_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; }
_lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j<nt?"/":""); if(out=="")out="."; print out }')"
_lcs_stage_name="${_lcs_stage_token}.${_lcs_name}"
ln -s "$_lcs_link_target" "$_lcs_stage/$_lcs_stage_name" || { rm -rf "$_lcs_stage"; return 1; }
_lcs_publish_names="$_lcs_publish_names $_lcs_name"
done
for _lcs_victim in "$_lcs_base/dws" "$_lcs_base"/*; do
[ -e "$_lcs_victim" ] || [ -L "$_lcs_victim" ] || continue
[ "$_lcs_victim" = "$_lcs_stage" ] && continue
same_physical_skill_root "$_lcs_victim" "$_lcs_canonical/$(basename "$_lcs_victim")" && continue
if [ "$(basename "$_lcs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_lcs_victim"; then continue; fi
if ! backup_and_record_skill_dir "$_lcs_victim" "$_lcs_backups"; then
restore_multi_skill_set /dev/null "$_lcs_backups" || true
rm -rf "$_lcs_stage"; return 1
fi
done
for _lcs_name in $_lcs_publish_names; do
_lcs_dest="$_lcs_base/$_lcs_name"
_lcs_stage_name="${_lcs_stage_token}.${_lcs_name}"
_lcs_staged="$_lcs_stage/$_lcs_stage_name"
_lcs_link_target="$(readlink "$_lcs_staged" 2>/dev/null)" || {
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
}
# Publish by creating the link directly at the destination: symlink(2)
# refuses an occupied path with EEXIST, so the creation itself is the
# atomic no-replace check. `mv` after any pre-check could still replace a
# file or symlink a concurrent writer created in between, and `ln -P`
# stays unusable (absent from BusyBox `ln`, which silently degraded every
# non-universal Agent to the copy layout on Alpine and most containers).
if ! ln -s "$_lcs_link_target" "$_lcs_dest" 2>/dev/null; then
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
fi
# A directory that appeared at the destination turns `ln -s` into a
# container: the link lands inside it under the target basename. Remove
# exactly our link after an identity check and roll back; the foreign
# directory stays untouched.
if [ ! -L "$_lcs_dest" ]; then
_lcs_nested_name="${_lcs_link_target##*/}"
_lcs_nested_inode="$(skill_link_inode "$_lcs_dest/$_lcs_nested_name" 2>/dev/null)" || _lcs_nested_inode=""
cleanup_nested_staged_link "$_lcs_dest" "$_lcs_nested_name" "$_lcs_link_target" "$_lcs_nested_inode" || true
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
fi
_lcs_inode="$(skill_link_inode "$_lcs_dest")" || {
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
}
if ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
fi
if ! printf '%s\n%s\n%s\n' "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode" >> "$_lcs_published"; then
if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
fi
printf ' ↪ Skills → %s\n' "$_lcs_dest"
done
rm -rf "$_lcs_stage"
}
# Same semantics as build/npm/install.js installMultiSkillsToHomes (root = DWS_SKILLS_ROOT or PWD).
install_multi_skills_to_root() {
multi_src="$1"
root="$2"
installed=0
attempted=0
attempted=1
failed=0
idx=0
specific_agents=0
for specific_dir in \
".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
".trae/skills" ".openclaw/skills" ".hermes/skills"
retire_failed=0
if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi
[ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n'; return 1; }
for agent_dir in $(agent_skill_dirs)
do
[ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
done
for agent_dir in \
".agents/skills" \
".claude/skills" \
".cursor/skills" \
".qoder/skills" \
".qoderwork/skills" \
".gemini/skills" \
".codex/skills" \
".zcode/skills" \
".github/skills" \
".windsurf/skills" \
".augment/skills" \
".cline/skills" \
".amp/skills" \
".kiro/skills" \
".trae/skills" \
".openclaw/skills" \
".hermes/skills"
do
if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
idx=$((idx + 1))
continue
fi
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
idx=$((idx + 1))
continue
fi
base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")"
agent_skill_base_detected "$agent_dir" "$base_dir" || continue
same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
attempted=$((attempted + 1))
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
if is_universal_agent_dir "$agent_dir"; then
retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1))
continue
fi
if link_canonical_skills_to_base "$root" "$base_dir" multi "$multi_src"; then
installed=$((installed + 1))
else
failed=$((failed + 1))
printf ' ⚠️ 跳过 %s(备份或复制失败,未完成 multi 安装)\n' "$base_dir"
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$base_dir"
installed=$((installed + 1))
else
failed=$((failed + 1))
fi
fi
idx=$((idx + 1))
done
if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
retire_generic_skill_root "$root" || failed=$((failed + 1))
fi
if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then
installed=$((installed + 1))
fi
if [ "$installed" -eq 0 ]; then
printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n'
return 1
fi
if [ "$retire_failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed"
fi
if [ "$failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 目标安装失败\n' "$failed"
return 1
fi
write_skills_state "$multi_src" "install-skills.sh" || return 1
printf ' ✅ DWS Skills 安装完成\n'
printf ' 统一安装位置:%s/.agents/skills\n' "$root"
printf ' 已自动适配本机上检测到的 Agent\n'
printf ' ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效\n'
}
_install_multi_to_base() {
@@ -574,12 +1147,7 @@ _install_multi_to_base() {
[ -f "${skill_dir}SKILL.md" ] || continue
_name="$(basename "$skill_dir")"
_dest="$_base/$_name"
printf '%s\n' "$_dest" >> "$_ms_published" || {
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
rm -rf "$_ms_stage"
return 1
}
if ! mv "$_ms_stage/$_name" "$_dest"; then
if ! publish_skill_dir_no_replace "$_ms_stage/$_name" "$_dest" "$_ms_published"; then
printf ' ⚠️ multi Skill 集合发布失败,正在恢复原集合: %s\n' "$_dest"
restore_multi_skill_set "$_ms_published" "$_ms_backups" || printf ' ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径\n'
rm -rf "$_ms_stage"
@@ -633,12 +1201,7 @@ _install_mono_to_base() {
done
_mono_dest="$_mono_base/$SKILL_NAME"
printf '%s\n' "$_mono_dest" >> "$_mono_published" || {
restore_multi_skill_set "$_mono_published" "$_mono_backups" || true
rm -rf "$_mono_stage"
return 1
}
if ! mv "$_mono_stage/$SKILL_NAME" "$_mono_dest"; then
if ! publish_skill_dir_no_replace "$_mono_stage/$SKILL_NAME" "$_mono_dest" "$_mono_published"; then
printf ' ⚠️ mono Skill 集合发布失败,正在恢复原集合: %s\n' "$_mono_dest"
restore_multi_skill_set "$_mono_published" "$_mono_backups" || printf ' ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径\n'
rm -rf "$_mono_stage"
@@ -654,84 +1217,48 @@ install_skills_to_root() {
skill_src="$1"
root="$2"
installed=0
attempted=0
attempted=1
failed=0
idx=0
specific_agents=0
for specific_dir in \
".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
".trae/skills" ".openclaw/skills" ".hermes/skills"
retire_failed=0
if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$root/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi
[ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n'; return 1; }
for agent_dir in $(agent_skill_dirs)
do
[ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
done
for agent_dir in \
".agents/skills" \
".claude/skills" \
".cursor/skills" \
".qoder/skills" \
".qoderwork/skills" \
".gemini/skills" \
".codex/skills" \
".zcode/skills" \
".github/skills" \
".windsurf/skills" \
".augment/skills" \
".cline/skills" \
".amp/skills" \
".kiro/skills" \
".trae/skills" \
".openclaw/skills" \
".hermes/skills"
do
if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
idx=$((idx + 1))
continue
fi
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
idx=$((idx + 1))
continue
fi
base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")"
agent_skill_base_detected "$agent_dir" "$base_dir" || continue
same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
attempted=$((attempted + 1))
if [ "$root" = "$HOME" ]; then
label="~/$agent_dir/$SKILL_NAME"
else
label="$root/$agent_dir/$SKILL_NAME"
if is_universal_agent_dir "$agent_dir"; then
retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1))
continue
fi
if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then
if link_canonical_skills_to_base "$root" "$base_dir" mono; then
installed=$((installed + 1))
else
failed=$((failed + 1))
if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then
printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$base_dir"
installed=$((installed + 1))
else
failed=$((failed + 1))
fi
fi
idx=$((idx + 1))
done
if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
retire_generic_skill_root "$root" || failed=$((failed + 1))
fi
if [ "$attempted" -eq 0 ]; then
if [ "$root" = "$HOME" ]; then
flabel="~/.agents/skills/$SKILL_NAME"
else
flabel="$root/.agents/skills/$SKILL_NAME"
fi
if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$flabel"; then
installed=$((installed + 1))
else
failed=$((failed + 1))
fi
fi
if [ "$installed" -eq 0 ]; then
printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n'
return 1
fi
if [ "$retire_failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed"
fi
if [ "$failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 目标安装 mono Skill 失败\n' "$failed"
return 1
fi
rm -f "$DWS_STATE_ROOT/skills-state.json"
printf ' ✅ DWS Skills 安装完成\n'
printf ' 统一安装位置:%s/.agents/skills\n' "$root"
printf ' 已自动适配本机上检测到的 Agent\n'
printf ' ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效\n'
}
# ── Main ─────────────────────────────────────────────────────────────────────
@@ -755,6 +1282,7 @@ main() {
[ -n "$ASSET_URL" ] || { printf '❌ Could not resolve download URL for dws-skills.zip (version %s).\n' "$VERSION" >&2; exit 1; }
printf ' ⬇ Downloading skills from GitHub Releases: %s (%s)\n' "$REPO" "$VERSION"
curl -fsSL "$ASSET_URL" -o "$TMPDIR_WORK/dws-skills.zip"
verify_release_asset_checksum "dws-skills.zip" "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK"
extract_zip "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK/extracted"
# Prefer the explicit mono/ subtree; fall back to legacy nested or zip root.
@@ -803,6 +1331,10 @@ main() {
fi
fi
# Every transaction of this run has finished, so old stamped archives can no
# longer be needed for a rollback.
prune_skill_backups
printf '\n'
printf ' 📖 Skill includes:\n'
printf ' • SKILL.md — Main skill with product overview and intent routing\n'

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