Compare commits
209
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d583247935 | ||
|
|
dfc3b028d7 | ||
|
|
95da8214a1 | ||
|
|
d8a3d5d6fd | ||
|
|
5ed7c3adce | ||
|
|
d1f1ab724b | ||
|
|
67505c6c83 | ||
|
|
61c39efb85 | ||
|
|
5b412ac196 | ||
|
|
c0e579fe6b | ||
|
|
c2ff4ab242 | ||
|
|
3fa85d19c9 | ||
|
|
33b76400bf | ||
|
|
2b417e2f2a | ||
|
|
c0e1ec576a | ||
|
|
2b3f482fdc | ||
|
|
95645e4f2c | ||
|
|
575303a5b0 | ||
|
|
d9b728f8e5 | ||
|
|
6240584ae2 | ||
|
|
cb46823280 | ||
|
|
fdcaa61587 | ||
|
|
3e481d296c | ||
|
|
22f87296cd | ||
|
|
c198d8577d | ||
|
|
33828b7858 | ||
|
|
0c80aa79e5 | ||
|
|
da62d11b35 | ||
|
|
a5d7fd05f1 | ||
|
|
cf13026f48 | ||
|
|
7a858b9732 | ||
|
|
00c337c438 | ||
|
|
27aca3ccc3 | ||
|
|
84036678dd | ||
|
|
d5031a89f0 | ||
|
|
ed1ebe5d03 | ||
|
|
0d22a4a1bd | ||
|
|
83f3b4f385 | ||
|
|
e217901a6b | ||
|
|
13d0ae66a6 | ||
|
|
63854705fd | ||
|
|
f3b0fcdc4c | ||
|
|
109a2891de | ||
|
|
4e106cd5ad | ||
|
|
9858844158 | ||
|
|
00ca448aa2 | ||
|
|
afe01d4b70 | ||
|
|
4c6db326f5 | ||
|
|
f10d552fd7 | ||
|
|
8b8756b00e | ||
|
|
ec59cf8065 | ||
|
|
2ffddbd5a0 | ||
|
|
843edd700d | ||
|
|
58ff0248b6 | ||
|
|
1d3c56f9fa | ||
|
|
502317db68 | ||
|
|
6c895c23ed | ||
|
|
6e3f528f48 | ||
|
|
d70e6b85b6 | ||
|
|
510b120630 | ||
|
|
f253f865c7 | ||
|
|
c8f83533fb | ||
|
|
8e34134dbc | ||
|
|
65885dd262 | ||
|
|
b354b371c9 | ||
|
|
15d289d3f3 | ||
|
|
57b5845eb3 | ||
|
|
75f08da197 | ||
|
|
3ac9b83565 | ||
|
|
234253e75f | ||
|
|
6a4803d85a | ||
|
|
d11e69fbdb | ||
|
|
a3566f39c4 | ||
|
|
a192e988c4 | ||
|
|
91d2e29925 | ||
|
|
26638cbd98 | ||
|
|
2f05649277 | ||
|
|
6c8e7e082b | ||
|
|
63a6de7b49 | ||
|
|
46b641f227 | ||
|
|
71da2dfded | ||
|
|
21395ed12d | ||
|
|
5f2344d16d | ||
|
|
e79efc70af | ||
|
|
7fa4ee7bac | ||
|
|
5b9234d8fe | ||
|
|
619319517a | ||
|
|
74513bae2f | ||
|
|
fcbddb0904 | ||
|
|
e84743615f | ||
|
|
a102447eb5 | ||
|
|
2b131a1031 | ||
|
|
5fdaea5f36 | ||
|
|
e8a320a06b | ||
|
|
59268a42a6 | ||
|
|
06661af43f | ||
|
|
951dd27f0c | ||
|
|
bc5e5db7ef | ||
|
|
a0accff258 | ||
|
|
f7a3e606f7 | ||
|
|
1d1aca5fd1 | ||
|
|
b199fd29cb | ||
|
|
93703113cb | ||
|
|
e5ed9e6e39 | ||
|
|
7924e84fb6 | ||
|
|
b4129c467d | ||
|
|
a12abdfb54 | ||
|
|
d9283b9a82 | ||
|
|
f1d40e26e1 | ||
|
|
0db91cfc44 | ||
|
|
f88be7ae21 | ||
|
|
e3313095ba | ||
|
|
c7882d7f72 | ||
|
|
92196738d3 | ||
|
|
0a4da58d8f | ||
|
|
f14332f143 | ||
|
|
62883b7940 | ||
|
|
6e20bc765f | ||
|
|
ecaefb416f | ||
|
|
f16feed896 | ||
|
|
d0a9dad079 | ||
|
|
e6c54cf777 | ||
|
|
7a97354d93 | ||
|
|
a46958c788 | ||
|
|
b664ace2f2 | ||
|
|
a789a2eea7 | ||
|
|
e4a1feccf0 | ||
|
|
3f39a9ddb1 | ||
|
|
b080b6e7c5 | ||
|
|
dc180f6d07 | ||
|
|
7b64576ea1 | ||
|
|
437dd234b2 | ||
|
|
04f78bcb15 | ||
|
|
9abcdb4deb | ||
|
|
c0da89e674 | ||
|
|
09fc5d993d | ||
|
|
8f3a9e9d4a | ||
|
|
567ea5d77c | ||
|
|
fc18f8fd04 | ||
|
|
a39ad4e6af | ||
|
|
301429e3aa | ||
|
|
0af5751d75 | ||
|
|
79f7ee80e0 | ||
|
|
44d640bbae | ||
|
|
06b4f3ba31 | ||
|
|
9f983be1ac | ||
|
|
80bca147e0 | ||
|
|
2cea069f55 | ||
|
|
208a6c0273 | ||
|
|
4e8469a175 | ||
|
|
b7a07abcb1 | ||
|
|
2292a49c6a | ||
|
|
cf43cf1b47 | ||
|
|
344104268a | ||
|
|
89027aa2e2 | ||
|
|
e45608bb13 | ||
|
|
ff6e2347f6 | ||
|
|
2d29f6601b | ||
|
|
fa887ccd26 | ||
|
|
30202e2b81 | ||
|
|
1203409185 | ||
|
|
0ba55350d8 | ||
|
|
14c2569cfb | ||
|
|
ae1565c0ff | ||
|
|
7581955892 | ||
|
|
24bbdda423 | ||
|
|
276658590b | ||
|
|
16d20b8178 | ||
|
|
dd89c67f4d | ||
|
|
75bf44b7be | ||
|
|
1bae872341 | ||
|
|
d1ecdcd551 | ||
|
|
a47740ca1c | ||
|
|
4ad321557f | ||
|
|
34dee96833 | ||
|
|
fc455f800c | ||
|
|
3556d28fdd | ||
|
|
618eb842a2 | ||
|
|
465acf1406 | ||
|
|
ce73a5b452 | ||
|
|
175b51cec0 | ||
|
|
53a71b08c7 | ||
|
|
3ef735bb3c | ||
|
|
44e18a4062 | ||
|
|
ccbacf84b3 | ||
|
|
7cfaa1ca74 | ||
|
|
d8f8f29062 | ||
|
|
f8af8dc1dc | ||
|
|
bb6fd2f256 | ||
|
|
580c4d201b | ||
|
|
4f754133a5 | ||
|
|
37cd629335 | ||
|
|
b447aac84b | ||
|
|
9703a21a2d | ||
|
|
0c0e2b3ce1 | ||
|
|
7d16c9693f | ||
|
|
1dee02d900 | ||
|
|
7664f04fda | ||
|
|
8177a06296 | ||
|
|
c674366aea | ||
|
|
ce5815a606 | ||
|
|
d211b79a80 | ||
|
|
de8df0fa6f | ||
|
|
9ff31cdd55 | ||
|
|
fde37f7896 | ||
|
|
31902a987e | ||
|
|
5ef52503ae | ||
|
|
8ee9fc3f48 | ||
|
|
19e19bcd2b |
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **招聘职位管理** (#976) — 新增招聘职位列表、详情查询和职位创建命令。
|
||||
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,6 @@
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
"node": ">=16.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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)` 为唯一精确键,并经历以下生命周期:
|
||||
|
||||
@@ -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. 后续演进
|
||||
|
||||
@@ -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 不提供的高级多事件控制",
|
||||
},
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 := ¶mAliasCaptureCaller{}
|
||||
_, 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 := ¶mAliasCaptureCaller{}
|
||||
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 := ¶mAliasCaptureCaller{}
|
||||
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 := ¶mAliasCaptureCaller{}
|
||||
_, 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 := ¶mAliasCaptureCaller{}
|
||||
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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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("✓"))
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
} {
|
||||
|
||||
@@ -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: "审批单终止",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()))
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
+791
-119
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user