Compare commits
192
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 | ||
|
|
e217901a6b | ||
|
|
109a2891de | ||
|
|
4e106cd5ad | ||
|
|
9858844158 | ||
|
|
843edd700d | ||
|
|
58ff0248b6 | ||
|
|
6c895c23ed | ||
|
|
510b120630 | ||
|
|
f253f865c7 | ||
|
|
8e34134dbc | ||
|
|
65885dd262 | ||
|
|
b354b371c9 | ||
|
|
15d289d3f3 | ||
|
|
57b5845eb3 | ||
|
|
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,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).
|
||||
|
||||
|
||||
+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. 后续演进
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -3422,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",
|
||||
|
||||
@@ -155,7 +155,6 @@
|
||||
"chat group-role update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
|
||||
"chat +chat-role-set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
|
||||
"chat group-role remove-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
|
||||
"chat group-role set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
|
||||
"chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."},
|
||||
"chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."},
|
||||
"doc block insert": {"block": ["before-block-id"], "note": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true},
|
||||
@@ -438,7 +437,6 @@
|
||||
{"command": "chat message send", "emitted": "file-path", "expect": "file", "via": "native:reviewed-compatibility-alias"},
|
||||
{"command": "chat category add-conv", "emitted": "category-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
|
||||
{"command": "chat category rename", "emitted": "category-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
|
||||
{"command": "chat group-role set-user", "emitted": "role-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
|
||||
{"command": "chat group-role update", "emitted": "role-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
|
||||
{"command": "chat message send-by-webhook", "emitted": "at-user-ids", "expect": "at-users", "via": "override:scoped-role"},
|
||||
{"command": "chat message send-by-bot", "emitted": "at-users", "expect": "at-user-ids", "via": "override:scoped-role"},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
+695
-175
File diff suppressed because it is too large
Load Diff
@@ -13,8 +13,13 @@ STABLE_REF=""
|
||||
CANDIDATE_REF="HEAD"
|
||||
SCHEMA_CHECKER_REL="scripts/policy/schema-compat/main.go"
|
||||
MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json"
|
||||
COMMAND_MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-command-migrations-v1.json"
|
||||
MIGRATIONS_REL="internal/interfacesnapshot/migrations.go"
|
||||
COMMAND_MIGRATIONS_REL="internal/interfacesnapshot/command_migrations.go"
|
||||
CORECMD_BRIDGE_REL="internal/corecmd/corecmd.go"
|
||||
ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go"
|
||||
CONST_PARAMS_REGISTRY_REL="internal/corecmd/interface_const_params.go"
|
||||
LEAF_ADAPTER_REL="internal/helpers/leaf.go"
|
||||
|
||||
usage() {
|
||||
printf '%s\n' "usage: $0 --base-ref <ref> [--stable-ref <ref>] [--candidate-ref <ref>]" >&2
|
||||
@@ -144,6 +149,17 @@ commit_path_is_regular_file() {
|
||||
[ "$2" = blob ] && [ "$4" = "$tree_path" ]
|
||||
}
|
||||
|
||||
commit_path_blob() {
|
||||
tree_commit="$1"
|
||||
tree_path="$2"
|
||||
tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")"
|
||||
[ -n "$tree_entry" ] || return 1
|
||||
set -- $tree_entry
|
||||
[ "$#" -eq 4 ] || return 1
|
||||
[ "$2" = blob ] && [ "$4" = "$tree_path" ] || return 1
|
||||
printf '%s\n' "$3"
|
||||
}
|
||||
|
||||
has_modern_interface_helper() {
|
||||
governance_commit="$1"
|
||||
commit_path_is_regular_file "$governance_commit" cmd/interface-snapshot/main.go &&
|
||||
@@ -173,6 +189,94 @@ has_any_schema_migration_governance_artifact() {
|
||||
return 1
|
||||
}
|
||||
|
||||
has_complete_schema_command_migration_governance() {
|
||||
governance_commit="$1"
|
||||
has_complete_schema_migration_governance "$governance_commit" &&
|
||||
commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATIONS_REL" &&
|
||||
commit_path_is_regular_file "$governance_commit" "$CONST_PARAMS_REGISTRY_REL" &&
|
||||
commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATION_MANIFEST_REL"
|
||||
}
|
||||
|
||||
has_any_schema_command_migration_governance_artifact() {
|
||||
governance_commit="$1"
|
||||
for relative_path in "$COMMAND_MIGRATIONS_REL" "$CONST_PARAMS_REGISTRY_REL" "$COMMAND_MIGRATION_MANIFEST_REL"; do
|
||||
if commit_path_exists "$governance_commit" "$relative_path"; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
check_candidate_const_params_source_policy() {
|
||||
source_policy_failed=false
|
||||
for token in attachInterfaceBoolConstParams InterfaceBoolConstParams interfaceBoolConstParamsRegistry; do
|
||||
matches="$(git -C "$CANDIDATE_WORKTREE" grep -l -w -F -e "$token" -- '*.go' || true)"
|
||||
[ -z "$matches" ] && continue
|
||||
while IFS= read -r relative_path; do
|
||||
[ -n "$relative_path" ] || continue
|
||||
case "$relative_path" in
|
||||
*_test.go)
|
||||
continue
|
||||
;;
|
||||
internal/corecmd/interface_const_params.go)
|
||||
continue
|
||||
;;
|
||||
internal/corecmd/corecmd.go)
|
||||
[ "$token" = attachInterfaceBoolConstParams ] && continue
|
||||
;;
|
||||
internal/interfacesnapshot/snapshot.go)
|
||||
[ "$token" = InterfaceBoolConstParams ] && continue
|
||||
;;
|
||||
esac
|
||||
if [ "$source_policy_failed" = false ]; then
|
||||
printf 'error: candidate production source may not access framework-owned bool ConstParams registry:\n' >&2
|
||||
fi
|
||||
printf ' %s: protected bool ConstParams registry identifier %s\n' "$relative_path" "$token" >&2
|
||||
source_policy_failed=true
|
||||
done <<EOF
|
||||
$matches
|
||||
EOF
|
||||
done
|
||||
if [ "$source_policy_failed" = true ]; then
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
install_authority_const_params_registry() {
|
||||
source_root="$1"
|
||||
worktree="$2"
|
||||
|
||||
[ "$source_root" = "$worktree" ] && return
|
||||
mkdir -p "$worktree/$(dirname "$CONST_PARAMS_REGISTRY_REL")"
|
||||
rm -f "$worktree/$CONST_PARAMS_REGISTRY_REL"
|
||||
cp "$source_root/$CONST_PARAMS_REGISTRY_REL" "$worktree/$CONST_PARAMS_REGISTRY_REL"
|
||||
}
|
||||
|
||||
require_complete_candidate_schema_command_governance() {
|
||||
if ! has_complete_schema_command_migration_governance "$CANDIDATE_COMMIT"; then
|
||||
printf 'error: candidate must preserve the complete Schema command migration governance artifact set at %s\n' \
|
||||
"$CANDIDATE_COMMIT" >&2
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
require_base_identical_command_migration_bridges() {
|
||||
base_command_manifest_blob="$(commit_path_blob "$BASE_COMMIT" "$COMMAND_MIGRATION_MANIFEST_REL")"
|
||||
candidate_command_manifest_blob="$(commit_path_blob "$CANDIDATE_COMMIT" "$COMMAND_MIGRATION_MANIFEST_REL")"
|
||||
[ "$base_command_manifest_blob" != "$candidate_command_manifest_blob" ] || return 0
|
||||
|
||||
for protected_bridge_path in "$CORECMD_BRIDGE_REL" "$CONST_PARAMS_REGISTRY_REL" "$LEAF_ADAPTER_REL"; do
|
||||
base_protected_bridge_blob="$(commit_path_blob "$BASE_COMMIT" "$protected_bridge_path" || true)"
|
||||
candidate_protected_bridge_blob="$(commit_path_blob "$CANDIDATE_COMMIT" "$protected_bridge_path" || true)"
|
||||
if [ -z "$base_protected_bridge_blob" ] ||
|
||||
[ "$candidate_protected_bridge_blob" != "$base_protected_bridge_blob" ]; then
|
||||
printf 'candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: %s\n' \
|
||||
"$protected_bridge_path" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
check_candidate_alias_source_policy() {
|
||||
source_policy_failed=false
|
||||
for token in \
|
||||
@@ -225,10 +329,12 @@ if ! has_complete_schema_migration_governance "$CANDIDATE_COMMIT"; then
|
||||
exit 2
|
||||
fi
|
||||
check_candidate_alias_source_policy
|
||||
check_candidate_const_params_source_policy
|
||||
|
||||
EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json"
|
||||
printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST"
|
||||
USE_MIGRATION_GOVERNANCE=false
|
||||
USE_COMMAND_MIGRATION_GOVERNANCE=false
|
||||
|
||||
if has_complete_schema_migration_governance "$BASE_COMMIT"; then
|
||||
USE_MIGRATION_GOVERNANCE=true
|
||||
@@ -252,6 +358,27 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
if has_complete_schema_command_migration_governance "$BASE_COMMIT"; then
|
||||
USE_COMMAND_MIGRATION_GOVERNANCE=true
|
||||
APPROVED_COMMAND_MANIFEST="$BASE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL"
|
||||
CANDIDATE_COMMAND_MANIFEST="$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL"
|
||||
require_complete_candidate_schema_command_governance
|
||||
require_base_identical_command_migration_bridges
|
||||
elif has_any_schema_command_migration_governance_artifact "$BASE_COMMIT"; then
|
||||
printf 'error: merge-base contains an incomplete Schema command migration governance artifact set: %s\n' \
|
||||
"$BASE_REF" >&2
|
||||
exit 2
|
||||
elif has_any_schema_command_migration_governance_artifact "$CANDIDATE_COMMIT"; then
|
||||
# Bootstrap keeps the old base-owned checker in control and therefore grants
|
||||
# no command migration authorization in the governance PR itself.
|
||||
require_complete_candidate_schema_command_governance
|
||||
fi
|
||||
|
||||
if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then
|
||||
install_authority_const_params_registry "$BASE_WORKTREE" "$CANDIDATE_WORKTREE"
|
||||
install_authority_const_params_registry "$BASE_WORKTREE" "$STABLE_WORKTREE"
|
||||
fi
|
||||
|
||||
BASE_BIN="$TMP_ROOT/base-dws"
|
||||
STABLE_BIN="$TMP_ROOT/stable-dws"
|
||||
CANDIDATE_BIN="$TMP_ROOT/candidate-dws"
|
||||
@@ -337,16 +464,28 @@ generate_interface_snapshot "$CANDIDATE_WORKTREE" "$CURRENT_INTERFACE_SNAPSHOT"
|
||||
generate_interface_snapshot "$BASE_WORKTREE" "$BASE_INTERFACE_SNAPSHOT"
|
||||
generate_interface_snapshot "$STABLE_WORKTREE" "$STABLE_INTERFACE_SNAPSHOT"
|
||||
|
||||
check_schema_contract "PR merge-base" "$BASE_REF" "$BASELINE" \
|
||||
--approved-flag-migrations "$APPROVED_MANIFEST" \
|
||||
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
|
||||
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
|
||||
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
|
||||
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
|
||||
check_with_migrations() {
|
||||
historical_kind="$1"
|
||||
historical_ref="$2"
|
||||
historical_baseline="$3"
|
||||
if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then
|
||||
check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \
|
||||
--approved-flag-migrations "$APPROVED_MANIFEST" \
|
||||
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
|
||||
--approved-command-migrations "$APPROVED_COMMAND_MANIFEST" \
|
||||
--candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST" \
|
||||
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
|
||||
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
|
||||
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
|
||||
else
|
||||
check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \
|
||||
--approved-flag-migrations "$APPROVED_MANIFEST" \
|
||||
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
|
||||
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
|
||||
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
|
||||
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
|
||||
fi
|
||||
}
|
||||
|
||||
check_schema_contract "stable" "$STABLE_REF" "$STABLE_BASELINE" \
|
||||
--approved-flag-migrations "$APPROVED_MANIFEST" \
|
||||
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
|
||||
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
|
||||
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
|
||||
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
|
||||
check_with_migrations "PR merge-base" "$BASE_REF" "$BASELINE"
|
||||
check_with_migrations "stable" "$STABLE_REF" "$STABLE_BASELINE"
|
||||
|
||||
@@ -12,7 +12,12 @@ BASE_REF=""
|
||||
STABLE_REF=""
|
||||
CANDIDATE_REF="HEAD"
|
||||
MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json"
|
||||
COMMAND_MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-command-migrations-v1.json"
|
||||
COMMAND_MIGRATIONS_REL="internal/interfacesnapshot/command_migrations.go"
|
||||
CORECMD_BRIDGE_REL="internal/corecmd/corecmd.go"
|
||||
ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go"
|
||||
CONST_PARAMS_REGISTRY_REL="internal/corecmd/interface_const_params.go"
|
||||
LEAF_ADAPTER_REL="internal/helpers/leaf.go"
|
||||
|
||||
usage() {
|
||||
printf '%s\n' "usage: $0 --base-ref <ref> --stable-ref <ref> [--candidate-ref <ref>]" >&2
|
||||
@@ -130,6 +135,17 @@ commit_path_is_regular_file() {
|
||||
[ "$2" = blob ] && [ "$4" = "$tree_path" ]
|
||||
}
|
||||
|
||||
commit_path_blob() {
|
||||
tree_commit="$1"
|
||||
tree_path="$2"
|
||||
tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")"
|
||||
[ -n "$tree_entry" ] || return 1
|
||||
set -- $tree_entry
|
||||
[ "$#" -eq 4 ] || return 1
|
||||
[ "$2" = blob ] && [ "$4" = "$tree_path" ] || return 1
|
||||
printf '%s\n' "$3"
|
||||
}
|
||||
|
||||
has_modern_interface_helper() {
|
||||
governance_commit="$1"
|
||||
commit_path_is_regular_file "$governance_commit" cmd/interface-snapshot/main.go &&
|
||||
@@ -166,6 +182,84 @@ require_complete_candidate_governance() {
|
||||
fi
|
||||
}
|
||||
|
||||
has_complete_command_migration_governance() {
|
||||
governance_commit="$1"
|
||||
has_complete_migration_governance "$governance_commit" &&
|
||||
commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATIONS_REL" &&
|
||||
commit_path_is_regular_file "$governance_commit" "$CONST_PARAMS_REGISTRY_REL" &&
|
||||
commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATION_MANIFEST_REL"
|
||||
}
|
||||
|
||||
has_any_command_migration_governance_artifact() {
|
||||
governance_commit="$1"
|
||||
for relative_path in "$COMMAND_MIGRATIONS_REL" "$CONST_PARAMS_REGISTRY_REL" "$COMMAND_MIGRATION_MANIFEST_REL"; do
|
||||
if commit_path_exists "$governance_commit" "$relative_path"; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
check_candidate_const_params_source_policy() {
|
||||
source_policy_failed=false
|
||||
for token in attachInterfaceBoolConstParams InterfaceBoolConstParams interfaceBoolConstParamsRegistry; do
|
||||
matches="$(git -C "$CANDIDATE_WORKTREE" grep -l -w -F -e "$token" -- '*.go' || true)"
|
||||
[ -z "$matches" ] && continue
|
||||
while IFS= read -r relative_path; do
|
||||
[ -n "$relative_path" ] || continue
|
||||
case "$relative_path" in
|
||||
*_test.go)
|
||||
continue
|
||||
;;
|
||||
internal/corecmd/interface_const_params.go)
|
||||
continue
|
||||
;;
|
||||
internal/corecmd/corecmd.go)
|
||||
[ "$token" = attachInterfaceBoolConstParams ] && continue
|
||||
;;
|
||||
internal/interfacesnapshot/snapshot.go)
|
||||
[ "$token" = InterfaceBoolConstParams ] && continue
|
||||
;;
|
||||
esac
|
||||
if [ "$source_policy_failed" = false ]; then
|
||||
printf 'candidate production source may not access framework-owned bool ConstParams registry:\n' >&2
|
||||
fi
|
||||
printf ' %s: protected bool ConstParams registry identifier %s\n' "$relative_path" "$token" >&2
|
||||
source_policy_failed=true
|
||||
done <<EOF
|
||||
$matches
|
||||
EOF
|
||||
done
|
||||
if [ "$source_policy_failed" = true ]; then
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
require_complete_candidate_command_governance() {
|
||||
if ! has_complete_command_migration_governance "$CANDIDATE_COMMIT"; then
|
||||
printf 'candidate must preserve the complete command migration governance artifact set at %s\n' \
|
||||
"$CANDIDATE_COMMIT" >&2
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
require_base_identical_command_migration_bridges() {
|
||||
base_command_manifest_blob="$(commit_path_blob "$BASE_COMMIT" "$COMMAND_MIGRATION_MANIFEST_REL")"
|
||||
candidate_command_manifest_blob="$(commit_path_blob "$CANDIDATE_COMMIT" "$COMMAND_MIGRATION_MANIFEST_REL")"
|
||||
[ "$base_command_manifest_blob" != "$candidate_command_manifest_blob" ] || return 0
|
||||
|
||||
for protected_bridge_path in "$CORECMD_BRIDGE_REL" "$CONST_PARAMS_REGISTRY_REL" "$LEAF_ADAPTER_REL"; do
|
||||
base_protected_bridge_blob="$(commit_path_blob "$BASE_COMMIT" "$protected_bridge_path" || true)"
|
||||
candidate_protected_bridge_blob="$(commit_path_blob "$CANDIDATE_COMMIT" "$protected_bridge_path" || true)"
|
||||
if [ -z "$base_protected_bridge_blob" ] ||
|
||||
[ "$candidate_protected_bridge_blob" != "$base_protected_bridge_blob" ]; then
|
||||
printf 'candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: %s\n' \
|
||||
"$protected_bridge_path" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
check_candidate_alias_source_policy() {
|
||||
source_policy_failed=false
|
||||
for token in \
|
||||
@@ -222,9 +316,36 @@ install_authority_helper() {
|
||||
fi
|
||||
}
|
||||
|
||||
install_authority_const_params_registry() {
|
||||
source_root="$1"
|
||||
worktree="$2"
|
||||
|
||||
[ "$source_root" = "$worktree" ] && return
|
||||
mkdir -p "$worktree/$(dirname "$CONST_PARAMS_REGISTRY_REL")"
|
||||
rm -f "$worktree/$CONST_PARAMS_REGISTRY_REL"
|
||||
cp "$source_root/$CONST_PARAMS_REGISTRY_REL" "$worktree/$CONST_PARAMS_REGISTRY_REL"
|
||||
}
|
||||
|
||||
EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json"
|
||||
printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST"
|
||||
USE_MIGRATION_GOVERNANCE=false
|
||||
USE_COMMAND_MIGRATION_GOVERNANCE=false
|
||||
|
||||
if has_complete_command_migration_governance "$BASE_COMMIT"; then
|
||||
USE_COMMAND_MIGRATION_GOVERNANCE=true
|
||||
APPROVED_COMMAND_MANIFEST="$BASE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL"
|
||||
CANDIDATE_COMMAND_MANIFEST="$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL"
|
||||
require_complete_candidate_command_governance
|
||||
require_base_identical_command_migration_bridges
|
||||
elif has_any_command_migration_governance_artifact "$BASE_COMMIT"; then
|
||||
printf 'merge-base contains an incomplete command migration governance artifact set: %s\n' \
|
||||
"$BASE_REF" >&2
|
||||
exit 2
|
||||
elif has_any_command_migration_governance_artifact "$CANDIDATE_COMMIT"; then
|
||||
# Bootstrap is safe because the base-owned comparator receives no new
|
||||
# authorization input and still performs the ordinary compatibility check.
|
||||
require_complete_candidate_command_governance
|
||||
fi
|
||||
|
||||
if has_complete_migration_governance "$BASE_COMMIT"; then
|
||||
USE_MIGRATION_GOVERNANCE=true
|
||||
@@ -233,6 +354,7 @@ if has_complete_migration_governance "$BASE_COMMIT"; then
|
||||
CANDIDATE_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL"
|
||||
require_complete_candidate_governance
|
||||
check_candidate_alias_source_policy
|
||||
check_candidate_const_params_source_policy
|
||||
|
||||
install_authority_helper "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE" true
|
||||
else
|
||||
@@ -252,6 +374,7 @@ else
|
||||
AUTHORITY_ROOT="$BASE_WORKTREE"
|
||||
require_complete_candidate_governance
|
||||
check_candidate_alias_source_policy
|
||||
check_candidate_const_params_source_policy
|
||||
BOOTSTRAP_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL"
|
||||
if ! cmp -s "$BOOTSTRAP_MANIFEST" "$EMPTY_MANIFEST"; then
|
||||
printf 'initial flag migration governance requires the canonical empty manifest: %s\n' "$BOOTSTRAP_MANIFEST" >&2
|
||||
@@ -262,6 +385,11 @@ fi
|
||||
|
||||
install_authority_helper "$AUTHORITY_ROOT" "$BASE_WORKTREE" "$USE_MIGRATION_GOVERNANCE"
|
||||
install_authority_helper "$AUTHORITY_ROOT" "$STABLE_WORKTREE" "$USE_MIGRATION_GOVERNANCE"
|
||||
if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then
|
||||
install_authority_const_params_registry "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE"
|
||||
install_authority_const_params_registry "$AUTHORITY_ROOT" "$BASE_WORKTREE"
|
||||
install_authority_const_params_registry "$AUTHORITY_ROOT" "$STABLE_WORKTREE"
|
||||
fi
|
||||
|
||||
generate_snapshot() {
|
||||
worktree="$1"
|
||||
@@ -280,7 +408,19 @@ generate_snapshot "$CANDIDATE_WORKTREE" "$CANDIDATE"
|
||||
generate_snapshot "$BASE_WORKTREE" "$BASELINE"
|
||||
generate_snapshot "$STABLE_WORKTREE" "$STABLE"
|
||||
|
||||
if [ "$USE_MIGRATION_GOVERNANCE" = true ]; then
|
||||
if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then
|
||||
(
|
||||
cd "$AUTHORITY_ROOT"
|
||||
go run ./cmd/interface-snapshot compare \
|
||||
--current "$CANDIDATE" \
|
||||
--base "$BASELINE" \
|
||||
--stable "$STABLE" \
|
||||
--approved-flag-migrations "$APPROVED_MANIFEST" \
|
||||
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
|
||||
--approved-command-migrations "$APPROVED_COMMAND_MANIFEST" \
|
||||
--candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST"
|
||||
)
|
||||
elif [ "$USE_MIGRATION_GOVERNANCE" = true ]; then
|
||||
(
|
||||
cd "$AUTHORITY_ROOT"
|
||||
go run ./cmd/interface-snapshot compare \
|
||||
|
||||
@@ -51,6 +51,8 @@ func main() {
|
||||
base := flags.String("base", "", "base snapshot")
|
||||
stable := flags.String("stable", "", "stable snapshot")
|
||||
_ = flags.String("approved-flag-migrations", "", "approved manifest")
|
||||
_ = flags.String("approved-command-migrations", "", "approved command manifest")
|
||||
_ = flags.String("candidate-command-migrations", "", "candidate command manifest")
|
||||
_ = flags.Parse(os.Args[2:])
|
||||
for _, path := range []string{*current, *base, *stable} {
|
||||
content, err := os.ReadFile(path)
|
||||
@@ -97,6 +99,27 @@ var (
|
||||
func AuthorityMarker() string { return "BASE" }
|
||||
`
|
||||
|
||||
const commandGovernedSnapshotSource = `package interfacesnapshot
|
||||
|
||||
import (
|
||||
"example.com/dws-command-compat-authority-test/internal/corecmd"
|
||||
"example.com/dws-command-compat-authority-test/internal/corecmd/runtimeannotate"
|
||||
)
|
||||
|
||||
var (
|
||||
_ = runtimeannotate.AnnotationFlagAliasOf
|
||||
_ = runtimeannotate.AnnotationFlagAliasOrigin
|
||||
_ = runtimeannotate.FlagAliasOriginCorecmdV1
|
||||
)
|
||||
|
||||
func AuthorityMarker() string {
|
||||
if corecmd.InterfaceBoolConstParams() != "BASE" {
|
||||
return "UNTRUSTED_CONST_PARAMS"
|
||||
}
|
||||
return "BASE"
|
||||
}
|
||||
`
|
||||
|
||||
const baseSnapshotSource = `package interfacesnapshot
|
||||
|
||||
func AuthorityMarker() string { return "BASE" }
|
||||
@@ -121,6 +144,54 @@ const (
|
||||
)
|
||||
`
|
||||
|
||||
func constParamsRegistrySource(marker string) string {
|
||||
return fmt.Sprintf(`package corecmd
|
||||
|
||||
type constParamsRegistry struct{}
|
||||
|
||||
func (constParamsRegistry) Store(any, any) {}
|
||||
|
||||
var interfaceBoolConstParamsRegistry constParamsRegistry
|
||||
|
||||
func attachInterfaceBoolConstParams() {}
|
||||
|
||||
func InterfaceBoolConstParams() string { return %q }
|
||||
`, marker)
|
||||
}
|
||||
|
||||
const baseCorecmdBridgeSource = `package corecmd
|
||||
|
||||
func boolConstParams(map[string]any) map[string]bool {
|
||||
return map[string]bool{"convThreadEnabled": true}
|
||||
}
|
||||
|
||||
func installConstParamsEvidence() { attachInterfaceBoolConstParams() }
|
||||
`
|
||||
|
||||
const forgedCorecmdBridgeSource = `package corecmd
|
||||
|
||||
func boolConstParams(map[string]any) map[string]bool {
|
||||
return map[string]bool{"convThreadEnabled": false}
|
||||
}
|
||||
|
||||
func installConstParamsEvidence() { attachInterfaceBoolConstParams() }
|
||||
`
|
||||
|
||||
const baseLeafAdapterSource = `package helpers
|
||||
|
||||
func forwardToolArgs(toolArgs map[string]any) map[string]any {
|
||||
return toolArgs
|
||||
}
|
||||
`
|
||||
|
||||
const forgedLeafAdapterSource = `package helpers
|
||||
|
||||
func forwardToolArgs(toolArgs map[string]any) map[string]any {
|
||||
delete(toolArgs, "convThreadEnabled")
|
||||
return toolArgs
|
||||
}
|
||||
`
|
||||
|
||||
const candidateNoopSource = `package main
|
||||
|
||||
import "fmt"
|
||||
@@ -131,11 +202,13 @@ func main() {
|
||||
`
|
||||
|
||||
type authorityScenario struct {
|
||||
name string
|
||||
baseGovernance string
|
||||
candidateMutation string
|
||||
want string
|
||||
wantAuthorityDecision bool
|
||||
name string
|
||||
baseGovernance string
|
||||
commandGovernance string
|
||||
commandManifestChanged bool
|
||||
candidateMutation string
|
||||
want string
|
||||
wantAuthorityDecision bool
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandCompatibilityUsesBaseOwnedAuthority(t *testing.T) {
|
||||
@@ -150,6 +223,85 @@ func TestCrossPlatformCoverageCommandCompatibilityUsesBaseOwnedAuthority(t *test
|
||||
baseGovernance: "none",
|
||||
wantAuthorityDecision: true,
|
||||
},
|
||||
{
|
||||
name: "command governed base owns bool ConstParams registry",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
candidateMutation: "change-const-params-protocol",
|
||||
wantAuthorityDecision: true,
|
||||
},
|
||||
{
|
||||
name: "command governance bootstrap preserves candidate bool ConstParams registry",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "bootstrap",
|
||||
wantAuthorityDecision: true,
|
||||
},
|
||||
{
|
||||
name: "command governed candidate cannot delete bool ConstParams registry",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
candidateMutation: "delete-const-params-contract",
|
||||
want: "must preserve the complete command migration governance artifact set",
|
||||
},
|
||||
{
|
||||
name: "same package candidate cannot call private ConstParams writer",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
candidateMutation: "forge-const-params-token",
|
||||
want: "may not access framework-owned bool ConstParams registry",
|
||||
},
|
||||
{
|
||||
name: "candidate cannot reopen private ConstParams reader",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
candidateMutation: "forge-const-params-reader",
|
||||
want: "may not access framework-owned bool ConstParams registry",
|
||||
},
|
||||
{
|
||||
name: "same package candidate cannot store directly in ConstParams registry",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
candidateMutation: "forge-const-params-registry-store",
|
||||
want: "may not access framework-owned bool ConstParams registry",
|
||||
},
|
||||
{
|
||||
name: "changed command manifest rejects forged corecmd bool ConstParams bridge",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
commandManifestChanged: true,
|
||||
candidateMutation: "forge-corecmd-bool-const-params",
|
||||
want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/corecmd.go",
|
||||
},
|
||||
{
|
||||
name: "changed command manifest rejects tampered ConstParams protocol",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
commandManifestChanged: true,
|
||||
candidateMutation: "change-const-params-protocol",
|
||||
want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/interface_const_params.go",
|
||||
},
|
||||
{
|
||||
name: "changed command manifest rejects tampered leaf adapter",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
commandManifestChanged: true,
|
||||
candidateMutation: "forge-leaf-adapter",
|
||||
want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/helpers/leaf.go",
|
||||
},
|
||||
{
|
||||
name: "changed command manifest allows base identical bridges",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
commandManifestChanged: true,
|
||||
wantAuthorityDecision: true,
|
||||
},
|
||||
{
|
||||
name: "unchanged command manifest allows independent corecmd change",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
candidateMutation: "forge-corecmd-bool-const-params",
|
||||
wantAuthorityDecision: true,
|
||||
},
|
||||
{
|
||||
name: "partial base fails closed",
|
||||
baseGovernance: "partial",
|
||||
@@ -274,6 +426,19 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) {
|
||||
default:
|
||||
t.Fatalf("unknown base governance state %q", test.baseGovernance)
|
||||
}
|
||||
switch test.commandGovernance {
|
||||
case "":
|
||||
case "complete":
|
||||
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), commandGovernedSnapshotSource, 0o644)
|
||||
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644)
|
||||
writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), baseCorecmdBridgeSource, 0o644)
|
||||
writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), constParamsRegistrySource("BASE"), 0o644)
|
||||
writeFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), baseLeafAdapterSource, 0o644)
|
||||
writeFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), "{\"version\":1,\"migrations\":[]}\n", 0o644)
|
||||
case "bootstrap":
|
||||
default:
|
||||
t.Fatalf("unknown command governance state %q", test.commandGovernance)
|
||||
}
|
||||
run(t, fixtureRoot, "git", "add", ".")
|
||||
run(t, fixtureRoot, "git", "commit", "-m", "base authority")
|
||||
baseRef := strings.TrimSpace(run(t, fixtureRoot, "git", "rev-parse", "HEAD"))
|
||||
@@ -302,6 +467,17 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) {
|
||||
0o644,
|
||||
)
|
||||
}
|
||||
if test.commandGovernance == "complete" || test.commandGovernance == "bootstrap" {
|
||||
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644)
|
||||
if test.commandGovernance == "bootstrap" {
|
||||
writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), constParamsRegistrySource("CANDIDATE"), 0o644)
|
||||
}
|
||||
commandManifest := "{\"version\":1,\"migrations\":[]}\n"
|
||||
if test.commandManifestChanged {
|
||||
commandManifest = "{\"version\":1,\"migrations\":[{\"candidate\":\"PENDING\"}]}\n"
|
||||
}
|
||||
writeFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), commandManifest, 0o644)
|
||||
}
|
||||
switch test.candidateMutation {
|
||||
case "":
|
||||
case "delete-alias-contract":
|
||||
@@ -314,6 +490,34 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) {
|
||||
"package evil\n\nconst forged = \"dws.compat.alias_of\"\n",
|
||||
0o644,
|
||||
)
|
||||
case "delete-const-params-contract":
|
||||
if err := os.Remove(filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go")); err != nil {
|
||||
t.Fatalf("remove candidate bool ConstParams registry: %v", err)
|
||||
}
|
||||
case "forge-const-params-token":
|
||||
writeFile(t,
|
||||
filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"),
|
||||
"package corecmd\n\nfunc forgeConstParams() { attachInterfaceBoolConstParams() }\n",
|
||||
0o644,
|
||||
)
|
||||
case "forge-const-params-reader":
|
||||
writeFile(t,
|
||||
filepath.Join(fixtureRoot, "internal", "evil", "forged.go"),
|
||||
"package evil\n\nconst forged = \"InterfaceBoolConstParams\"\n",
|
||||
0o644,
|
||||
)
|
||||
case "forge-const-params-registry-store":
|
||||
writeFile(t,
|
||||
filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"),
|
||||
"package corecmd\n\nfunc forgeConstParamsStore() { interfaceBoolConstParamsRegistry.Store(nil, map[string]bool{\"forged\": true}) }\n",
|
||||
0o644,
|
||||
)
|
||||
case "forge-corecmd-bool-const-params":
|
||||
writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), forgedCorecmdBridgeSource, 0o644)
|
||||
case "change-const-params-protocol":
|
||||
writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), constParamsRegistrySource("CANDIDATE"), 0o644)
|
||||
case "forge-leaf-adapter":
|
||||
writeFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), forgedLeafAdapterSource, 0o644)
|
||||
case "symlink-helper-parent":
|
||||
helperPath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot")
|
||||
alternatePath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot-alt")
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"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_conversation",
|
||||
"replacement_tool_id": "chat.create_topic_conversation",
|
||||
"parameters": [
|
||||
{"from": "name", "to": "name"},
|
||||
{"from": "type", "to": "type"},
|
||||
{"from": "users", "to": "users"},
|
||||
{
|
||||
"from": "thread",
|
||||
"replacement_constant": {
|
||||
"property": "convThreadEnabled",
|
||||
"value": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Move topic-conversation creation to the dedicated chat topic command while retaining the legacy --thread execution path."
|
||||
},
|
||||
{
|
||||
"kind": "command_move",
|
||||
"legacy": {
|
||||
"command": "dws chat message list-topic-replies",
|
||||
"before": {"present": true, "runnable": true},
|
||||
"after": {"present": true, "runnable": true, "hidden": true}
|
||||
},
|
||||
"replacement": {
|
||||
"command": "dws chat topic list-replies",
|
||||
"before": {"present": false},
|
||||
"after": {"present": true, "runnable": true}
|
||||
},
|
||||
"schema": {
|
||||
"product_id": "chat",
|
||||
"source_tool_id": "chat.list_topic_replies",
|
||||
"replacement_tool_id": "chat.list_topic_replies",
|
||||
"parameters": [
|
||||
{"from": "conversation-id", "to": "open-topic-id"},
|
||||
{"from": "topic-id", "to": "open-conv-thread-id"}
|
||||
]
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Move topic reply listing from chat message to the dedicated chat topic command while preserving the legacy executable command."
|
||||
},
|
||||
{
|
||||
"kind": "command_move",
|
||||
"legacy": {
|
||||
"command": "dws chat message forward-topic",
|
||||
"before": {"present": true, "runnable": true},
|
||||
"after": {"present": true, "runnable": true, "hidden": true}
|
||||
},
|
||||
"replacement": {
|
||||
"command": "dws chat topic forward",
|
||||
"before": {"present": false},
|
||||
"after": {"present": true, "runnable": true}
|
||||
},
|
||||
"schema": {
|
||||
"product_id": "chat",
|
||||
"source_tool_id": "chat.forward_topic",
|
||||
"replacement_tool_id": "chat.forward_topic",
|
||||
"parameters": [
|
||||
{"from": "src-conversation-id", "to": "src-open-topic-id"},
|
||||
{"from": "src-thread-id", "to": "src-open-conv-thread-id"},
|
||||
{"from": "dest-conversation-id", "to": "dest-open-conversation-id"}
|
||||
]
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Move topic forwarding from chat message to the dedicated chat topic command while preserving the legacy executable command."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -760,6 +760,39 @@
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
"command": "dws chat group-role set-user",
|
||||
"legacy": {
|
||||
"name": "role-ids",
|
||||
"before": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"scope": "local",
|
||||
"required": true
|
||||
},
|
||||
"after": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"scope": "local",
|
||||
"hidden": true,
|
||||
"alias_of": "role-id"
|
||||
}
|
||||
},
|
||||
"canonical": {
|
||||
"name": "role-id",
|
||||
"before": {
|
||||
"present": false
|
||||
},
|
||||
"after": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"scope": "local",
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"reason": "保留旧 argv 兼容性,并将单值群身份 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
"command": "dws chat group-role update",
|
||||
"legacy": {
|
||||
|
||||
@@ -0,0 +1,867 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCommandMigrationsAuthorizeOnlyExactProjection(t *testing.T) {
|
||||
baseline := schemaCommandMigrationContract(false)
|
||||
current := schemaCommandMigrationContract(true)
|
||||
migrations := schemaCommandMigrationAuthorizations()
|
||||
normalized, err := normalizeSchemaCommandMigrations(baseline, current, migrations)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize exact command migrations: %v", err)
|
||||
}
|
||||
if failures := checkCompatibility(normalized, current); len(failures) != 0 {
|
||||
t.Fatalf("exact command migrations remained incompatible: %v", failures)
|
||||
}
|
||||
|
||||
unrelated := cloneContract(current)
|
||||
product := unrelated.Products["chat"]
|
||||
tool := product.Tools["chat.move"]
|
||||
delete(tool.Parameters, "keep")
|
||||
product.Tools["chat.move"] = tool
|
||||
unrelated.Products["chat"] = product
|
||||
normalized, err = normalizeSchemaCommandMigrations(baseline, unrelated, migrations)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := strings.Join(checkCompatibility(normalized, unrelated), "\n"); !strings.Contains(failures, `lost parameter "keep"`) {
|
||||
t.Fatalf("unrelated parameter removal was hidden: %s", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCommandMigrationsFailClosedOnDrift(t *testing.T) {
|
||||
baseline := schemaCommandMigrationContract(false)
|
||||
migrations := schemaCommandMigrationAuthorizations()
|
||||
|
||||
parameterDrift := schemaCommandMigrationContract(true)
|
||||
product := parameterDrift.Products["chat"]
|
||||
tool := product.Tools["chat.move"]
|
||||
parameter := tool.Parameters["new-id"]
|
||||
parameter.Property = "differentProperty"
|
||||
tool.Parameters["new-id"] = parameter
|
||||
product.Tools["chat.move"] = tool
|
||||
parameterDrift.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, parameterDrift, migrations); err == nil || !strings.Contains(err.Error(), "changed a non-name field") {
|
||||
t.Fatalf("parameter drift error=%v", err)
|
||||
}
|
||||
|
||||
safetyDrift := schemaCommandMigrationContract(true)
|
||||
product = safetyDrift.Products["chat"]
|
||||
replacement := product.Tools["chat.create_topic"]
|
||||
replacement.Risk = "high"
|
||||
product.Tools["chat.create_topic"] = replacement
|
||||
safetyDrift.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, safetyDrift, migrations); err == nil || !strings.Contains(err.Error(), "changed interface or safety identity") {
|
||||
t.Fatalf("extraction safety drift error=%v", err)
|
||||
}
|
||||
|
||||
missingReplacement := schemaCommandMigrationContract(true)
|
||||
product = missingReplacement.Products["chat"]
|
||||
delete(product.Tools, "chat.create_topic")
|
||||
missingReplacement.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, missingReplacement, migrations); err == nil || !strings.Contains(err.Error(), "lacks replacement Schema tool") {
|
||||
t.Fatalf("missing replacement error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCommandMigrationRejectsUnregisteredRequiredParameters(t *testing.T) {
|
||||
baseline := schemaCommandMigrationContract(false)
|
||||
migrations := schemaCommandMigrationAuthorizations()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
parameter parameterSchema
|
||||
}{
|
||||
{name: "required", parameter: parameterSchema{Type: `"string"`, Property: "must", Required: true}},
|
||||
{name: "cli_required", parameter: parameterSchema{Type: `"string"`, Property: "must", CLIRequired: true}},
|
||||
{name: "required_when", parameter: parameterSchema{Type: `"string"`, Property: "must", RequiredWhen: "mode=forced"}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
current := schemaCommandMigrationContract(true)
|
||||
product := current.Products["chat"]
|
||||
tool := product.Tools["chat.move"]
|
||||
tool.Parameters["must"] = test.parameter
|
||||
product.Tools["chat.move"] = tool
|
||||
current.Products["chat"] = product
|
||||
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil ||
|
||||
!strings.Contains(err.Error(), `introduced unregistered required Schema parameter "must"`) {
|
||||
t.Fatalf("unregistered required parameter error=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
current := schemaCommandMigrationContract(true)
|
||||
product := current.Products["chat"]
|
||||
tool := product.Tools["chat.move"]
|
||||
tool.Parameters["optional"] = parameterSchema{Type: `"string"`, Property: "optional"}
|
||||
product.Tools["chat.move"] = tool
|
||||
current.Products["chat"] = product
|
||||
normalized, err := normalizeSchemaCommandMigrations(baseline, current, migrations)
|
||||
if err != nil {
|
||||
t.Fatalf("optional addition should remain compatible: %v", err)
|
||||
}
|
||||
if failures := checkCompatibility(normalized, current); len(failures) != 0 {
|
||||
t.Fatalf("optional addition should remain compatible: %v", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCommandMigrationRejectsParameterTargetCollision(t *testing.T) {
|
||||
baseline := schemaCommandMigrationContract(false)
|
||||
current := schemaCommandMigrationContract(true)
|
||||
migrations := schemaCommandMigrationAuthorizations()
|
||||
migrations[0].Schema.Parameters = []interfacesnapshot.CommandParameterMigration{{From: "old-id", To: "keep"}}
|
||||
|
||||
product := current.Products["chat"]
|
||||
tool := product.Tools["chat.move"]
|
||||
delete(tool.Parameters, "new-id")
|
||||
tool.Parameters["keep"] = baseline.Products["chat"].Tools["chat.move"].Parameters["old-id"]
|
||||
tool.Constraints = baseline.Products["chat"].Tools["chat.move"].Constraints
|
||||
product.Tools["chat.move"] = tool
|
||||
current.Products["chat"] = product
|
||||
|
||||
normalized, err := normalizeSchemaCommandMigrations(baseline, current, migrations)
|
||||
if err == nil {
|
||||
if failures := checkCompatibility(normalized, current); len(failures) != 0 {
|
||||
t.Fatalf("hostile target-collision fixture should otherwise pass compatibility: %v", failures)
|
||||
}
|
||||
t.Fatal("parameter target collision was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `target "keep" already exists in historical Schema tool`) {
|
||||
t.Fatalf("parameter target collision error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCommandMigrationNormalizationEdges(t *testing.T) {
|
||||
baseline := schemaCommandMigrationContract(false)
|
||||
current := schemaCommandMigrationContract(true)
|
||||
migrations := schemaCommandMigrationAuthorizations()
|
||||
|
||||
missingHistorical := cloneContract(baseline)
|
||||
product := missingHistorical.Products["chat"]
|
||||
delete(product.Tools, "chat.move")
|
||||
missingHistorical.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(missingHistorical, current, migrations); err != nil {
|
||||
t.Fatalf("missing historical tool should be a no-op: %v", err)
|
||||
}
|
||||
|
||||
missingCurrent := cloneContract(current)
|
||||
product = missingCurrent.Products["chat"]
|
||||
delete(product.Tools, "chat.move")
|
||||
missingCurrent.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, missingCurrent, migrations); err != nil {
|
||||
t.Fatalf("missing current source should remain for ordinary checker: %v", err)
|
||||
}
|
||||
|
||||
alreadyAfter := schemaCommandMigrationContract(true)
|
||||
if _, err := normalizeSchemaCommandMigrations(alreadyAfter, current, migrations[:1]); err != nil {
|
||||
t.Fatalf("already-after baseline should be a no-op: %v", err)
|
||||
}
|
||||
|
||||
wrongHistoricalPath := cloneContract(baseline)
|
||||
product = wrongHistoricalPath.Products["chat"]
|
||||
tool := product.Tools["chat.move"]
|
||||
tool.PrimaryCLIPath = "chat unrelated"
|
||||
product.Tools["chat.move"] = tool
|
||||
wrongHistoricalPath.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(wrongHistoricalPath, current, migrations); err == nil || !strings.Contains(err.Error(), "historical Schema tool") {
|
||||
t.Fatalf("wrong historical path error=%v", err)
|
||||
}
|
||||
|
||||
wrongCurrentPath := cloneContract(current)
|
||||
product = wrongCurrentPath.Products["chat"]
|
||||
tool = product.Tools["chat.move"]
|
||||
tool.PrimaryCLIPath = "chat unrelated"
|
||||
product.Tools["chat.move"] = tool
|
||||
wrongCurrentPath.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, wrongCurrentPath, migrations); err != nil {
|
||||
t.Fatalf("wrong current path should remain for ordinary checker: %v", err)
|
||||
}
|
||||
|
||||
missingHistoricalParameter := cloneContract(baseline)
|
||||
product = missingHistoricalParameter.Products["chat"]
|
||||
tool = product.Tools["chat.move"]
|
||||
delete(tool.Parameters, "old-id")
|
||||
product.Tools["chat.move"] = tool
|
||||
missingHistoricalParameter.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(missingHistoricalParameter, current, migrations); err == nil || !strings.Contains(err.Error(), "lacks parameter") {
|
||||
t.Fatalf("missing historical parameter error=%v", err)
|
||||
}
|
||||
|
||||
legacyPublished := cloneContract(current)
|
||||
product = legacyPublished.Products["chat"]
|
||||
tool = product.Tools["chat.move"]
|
||||
tool.Parameters["old-id"] = baseline.Products["chat"].Tools["chat.move"].Parameters["old-id"]
|
||||
product.Tools["chat.move"] = tool
|
||||
legacyPublished.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, legacyPublished, migrations); err == nil || !strings.Contains(err.Error(), "still publishes legacy") {
|
||||
t.Fatalf("legacy parameter error=%v", err)
|
||||
}
|
||||
|
||||
missingReplacementParameter := cloneContract(current)
|
||||
product = missingReplacementParameter.Products["chat"]
|
||||
tool = product.Tools["chat.move"]
|
||||
delete(tool.Parameters, "new-id")
|
||||
product.Tools["chat.move"] = tool
|
||||
missingReplacementParameter.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, missingReplacementParameter, migrations); err == nil || !strings.Contains(err.Error(), "does not publish replacement") {
|
||||
t.Fatalf("missing replacement parameter error=%v", err)
|
||||
}
|
||||
|
||||
constraintDrift := cloneContract(current)
|
||||
product = constraintDrift.Products["chat"]
|
||||
tool = product.Tools["chat.move"]
|
||||
tool.Constraints = `{"require_one_of":[["new-id"]]}`
|
||||
product.Tools["chat.move"] = tool
|
||||
constraintDrift.Products["chat"] = product
|
||||
normalized, err := normalizeSchemaCommandMigrations(baseline, constraintDrift, migrations)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := strings.Join(checkCompatibility(normalized, constraintDrift), "\n"); !strings.Contains(failures, "changed constraints") {
|
||||
t.Fatalf("constraint drift was hidden: %s", failures)
|
||||
}
|
||||
|
||||
extractionWrongSource := cloneContract(current)
|
||||
product = extractionWrongSource.Products["chat"]
|
||||
tool = product.Tools["chat.create_group"]
|
||||
tool.PrimaryCLIPath = "chat unrelated"
|
||||
product.Tools["chat.create_group"] = tool
|
||||
extractionWrongSource.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, extractionWrongSource, migrations[1:]); err != nil {
|
||||
t.Fatalf("wrong extraction source path should remain for ordinary checker: %v", err)
|
||||
}
|
||||
|
||||
extractionHistoricalMissing := cloneContract(baseline)
|
||||
product = extractionHistoricalMissing.Products["chat"]
|
||||
tool = product.Tools["chat.create_group"]
|
||||
delete(tool.Parameters, "thread")
|
||||
product.Tools["chat.create_group"] = tool
|
||||
extractionHistoricalMissing.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(extractionHistoricalMissing, current, migrations[1:]); err == nil || !strings.Contains(err.Error(), "historical Schema tool lacks") {
|
||||
t.Fatalf("missing extracted historical parameter error=%v", err)
|
||||
}
|
||||
|
||||
extractionStillPublished := cloneContract(current)
|
||||
product = extractionStillPublished.Products["chat"]
|
||||
tool = product.Tools["chat.create_group"]
|
||||
tool.Parameters["thread"] = baseline.Products["chat"].Tools["chat.create_group"].Parameters["thread"]
|
||||
product.Tools["chat.create_group"] = tool
|
||||
extractionStillPublished.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, extractionStillPublished, migrations[1:]); err == nil || !strings.Contains(err.Error(), "still publishes extracted") {
|
||||
t.Fatalf("still-published extraction error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaFlagExtractionRequiresCompleteReplacementProjection(t *testing.T) {
|
||||
t.Run("missing historical mapping", func(t *testing.T) {
|
||||
baseline := schemaCommandMigrationContract(false)
|
||||
current := schemaCommandMigrationContract(true)
|
||||
migrations := schemaCommandMigrationAuthorizations()
|
||||
parameters := make([]interfacesnapshot.CommandParameterMigration, 0, len(migrations[1].Schema.Parameters)-1)
|
||||
for _, parameter := range migrations[1].Schema.Parameters {
|
||||
if parameter.From != "users" {
|
||||
parameters = append(parameters, parameter)
|
||||
}
|
||||
}
|
||||
migrations[1].Schema.Parameters = parameters
|
||||
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil ||
|
||||
!strings.Contains(err.Error(), `does not map historical Schema parameter "users"`) {
|
||||
t.Fatalf("missing historical mapping error=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func([]interfacesnapshot.CommandMigration)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "duplicate historical mapping",
|
||||
mutate: func(migrations []interfacesnapshot.CommandMigration) {
|
||||
migrations[1].Schema.Parameters = append(
|
||||
migrations[1].Schema.Parameters,
|
||||
migrations[1].Schema.Parameters[0],
|
||||
)
|
||||
},
|
||||
want: "maps historical Schema parameter \"name\" more than once",
|
||||
},
|
||||
{
|
||||
name: "legacy mapping has two targets",
|
||||
mutate: func(migrations []interfacesnapshot.CommandMigration) {
|
||||
migrations[1].Schema.Parameters[3].To = "thread"
|
||||
},
|
||||
want: "must map to one replacement constant",
|
||||
},
|
||||
{
|
||||
name: "shared mapping has no target",
|
||||
mutate: func(migrations []interfacesnapshot.CommandMigration) {
|
||||
migrations[1].Schema.Parameters[0].To = ""
|
||||
},
|
||||
want: "must map to one replacement parameter",
|
||||
},
|
||||
{
|
||||
name: "duplicate replacement target",
|
||||
mutate: func(migrations []interfacesnapshot.CommandMigration) {
|
||||
migrations[1].Schema.Parameters[1].To = "name"
|
||||
},
|
||||
want: "is mapped from both \"name\" and \"type\"",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
baseline := schemaCommandMigrationContract(false)
|
||||
current := schemaCommandMigrationContract(true)
|
||||
migrations := schemaCommandMigrationAuthorizations()
|
||||
test.mutate(migrations)
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil ||
|
||||
!strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("invalid mapping error=%v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("replacement removes historical dry run", func(t *testing.T) {
|
||||
baseline := schemaCommandMigrationContract(false)
|
||||
mutateSchemaCommandMigrationTool(&baseline, "chat.create_group", func(tool *toolSchema) {
|
||||
tool.DryRun = `{"mode":"native"}`
|
||||
})
|
||||
current := schemaCommandMigrationContract(true)
|
||||
mutateSchemaCommandMigrationTool(¤t, "chat.create_group", func(tool *toolSchema) {
|
||||
tool.DryRun = `{"mode":"native"}`
|
||||
})
|
||||
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, current, schemaCommandMigrationAuthorizations()); err == nil ||
|
||||
!strings.Contains(err.Error(), "changed or removed dry_run") {
|
||||
t.Fatalf("replacement dry_run error=%v, want monotonic preservation rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replacement adds dry run", func(t *testing.T) {
|
||||
baseline := schemaCommandMigrationContract(false)
|
||||
current := schemaCommandMigrationContract(true)
|
||||
mutateSchemaCommandMigrationTool(¤t, "chat.create_topic", func(tool *toolSchema) {
|
||||
tool.DryRun = `{"mode":"native"}`
|
||||
})
|
||||
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, current, schemaCommandMigrationAuthorizations()); err != nil {
|
||||
t.Fatalf("additive replacement dry_run was rejected: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*schemaContract)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing shared parameter",
|
||||
mutate: func(current *schemaContract) {
|
||||
mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) {
|
||||
delete(tool.Parameters, "users")
|
||||
})
|
||||
},
|
||||
want: `does not publish mapped Schema parameter "users"`,
|
||||
},
|
||||
{
|
||||
name: "shared parameter field drift",
|
||||
mutate: func(current *schemaContract) {
|
||||
mutateSchemaCommandMigrationParameter(current, "chat.create_topic", "users", func(parameter *parameterSchema) {
|
||||
parameter.Property = "differentUserIds"
|
||||
})
|
||||
},
|
||||
want: "changed a non-name field",
|
||||
},
|
||||
{
|
||||
name: "extra replacement parameter",
|
||||
mutate: func(current *schemaContract) {
|
||||
mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) {
|
||||
tool.Parameters["extra"] = parameterSchema{Type: `"string"`, Property: "extra"}
|
||||
})
|
||||
},
|
||||
want: `publishes unmapped Schema parameter "extra"`,
|
||||
},
|
||||
{
|
||||
name: "constraints drift",
|
||||
mutate: func(current *schemaContract) {
|
||||
mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) {
|
||||
tool.Constraints = `{"require_one_of":[["name","type"]]}`
|
||||
})
|
||||
},
|
||||
want: "changed constraints",
|
||||
},
|
||||
{
|
||||
name: "positionals drift",
|
||||
mutate: func(current *schemaContract) {
|
||||
mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) {
|
||||
tool.Positionals[0].Name = "topic-name"
|
||||
})
|
||||
},
|
||||
want: "changed positionals",
|
||||
},
|
||||
{
|
||||
name: "positionals length drift",
|
||||
mutate: func(current *schemaContract) {
|
||||
mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) {
|
||||
tool.Positionals = append(tool.Positionals, positionalSchema{Name: "type", Index: 1, Type: "string"})
|
||||
})
|
||||
},
|
||||
want: "changed positionals",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
baseline := schemaCommandMigrationContract(false)
|
||||
current := schemaCommandMigrationContract(true)
|
||||
test.mutate(¤t)
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, current, schemaCommandMigrationAuthorizations()); err == nil ||
|
||||
!strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("replacement projection error=%v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaFlagExtractionValidatesBooleanConstant(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*schemaContract, *schemaContract, []interfacesnapshot.CommandMigration)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "constant property mismatch",
|
||||
mutate: func(_, _ *schemaContract, migrations []interfacesnapshot.CommandMigration) {
|
||||
migrations[1].Schema.Parameters[3].ReplacementConstant.Property = "differentProperty"
|
||||
},
|
||||
want: "constant property",
|
||||
},
|
||||
{
|
||||
name: "constant false",
|
||||
mutate: func(_, _ *schemaContract, migrations []interfacesnapshot.CommandMigration) {
|
||||
migrations[1].Schema.Parameters[3].ReplacementConstant.Value = false
|
||||
},
|
||||
want: "constant true",
|
||||
},
|
||||
{
|
||||
name: "legacy type",
|
||||
mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) {
|
||||
parameter.Type = `"string"`
|
||||
})
|
||||
},
|
||||
want: "must have boolean type",
|
||||
},
|
||||
{
|
||||
name: "legacy required",
|
||||
mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) {
|
||||
parameter.Required = true
|
||||
})
|
||||
},
|
||||
want: "must remain optional",
|
||||
},
|
||||
{
|
||||
name: "legacy cli required",
|
||||
mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) {
|
||||
parameter.CLIRequired = true
|
||||
})
|
||||
},
|
||||
want: "must remain optional",
|
||||
},
|
||||
{
|
||||
name: "legacy required when",
|
||||
mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) {
|
||||
parameter.RequiredWhen = "mode=topic"
|
||||
})
|
||||
},
|
||||
want: "must not declare required_when",
|
||||
},
|
||||
{
|
||||
name: "legacy default true",
|
||||
mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) {
|
||||
parameter.Default = "true"
|
||||
})
|
||||
},
|
||||
want: "default must be absent or false",
|
||||
},
|
||||
{
|
||||
name: "legacy interface default true",
|
||||
mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) {
|
||||
parameter.InterfaceDefault = "true"
|
||||
})
|
||||
},
|
||||
want: "interface_default must be absent or false",
|
||||
},
|
||||
{
|
||||
name: "legacy interface type",
|
||||
mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) {
|
||||
parameter.InterfaceType = "string"
|
||||
})
|
||||
},
|
||||
want: "interface_type must be empty or boolean",
|
||||
},
|
||||
{
|
||||
name: "legacy enum excludes true",
|
||||
mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) {
|
||||
parameter.Enum = []string{"false"}
|
||||
})
|
||||
},
|
||||
want: "enum must allow true",
|
||||
},
|
||||
{
|
||||
name: "legacy format",
|
||||
mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) {
|
||||
parameter.Format = "custom-bool"
|
||||
})
|
||||
},
|
||||
want: "format must be empty",
|
||||
},
|
||||
{
|
||||
name: "replacement publishes constant property",
|
||||
mutate: func(_, current *schemaContract, _ []interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) {
|
||||
tool.Parameters["thread-copy"] = parameterSchema{Type: `"boolean"`, Property: "threadEnabled"}
|
||||
})
|
||||
},
|
||||
want: "still publishes replacement constant property",
|
||||
},
|
||||
{
|
||||
name: "source renames constant property",
|
||||
mutate: func(_, current *schemaContract, _ []interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationTool(current, "chat.create_group", func(tool *toolSchema) {
|
||||
tool.Parameters["thread-copy"] = parameterSchema{Type: `"boolean"`, Property: "threadEnabled"}
|
||||
})
|
||||
},
|
||||
want: "source Schema tool still publishes replacement constant property",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
baseline := schemaCommandMigrationContract(false)
|
||||
current := schemaCommandMigrationContract(true)
|
||||
migrations := schemaCommandMigrationAuthorizations()
|
||||
test.mutate(&baseline, ¤t, migrations)
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil ||
|
||||
!strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("replacement constant error=%v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCommandMigrationLifecycleAndRun(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
baselinePath := filepath.Join(directory, "baseline.json")
|
||||
currentPath := filepath.Join(directory, "current.json")
|
||||
approvedPath := filepath.Join(directory, "approved.json")
|
||||
candidatePath := filepath.Join(directory, "candidate.json")
|
||||
currentSnapshotPath := filepath.Join(directory, "current-snapshot.json")
|
||||
baseSnapshotPath := filepath.Join(directory, "base-snapshot.json")
|
||||
stableSnapshotPath := filepath.Join(directory, "stable-snapshot.json")
|
||||
|
||||
writeSchemaContractFile(t, baselinePath, schemaCommandMigrationContract(false))
|
||||
writeRawSchemaContractFile(t, currentPath, schemaCommandMigrationContract(true))
|
||||
writeCommandMigrationManifestFile(t, approvedPath, schemaCommandMigrationManifest(interfacesnapshot.CommandMigrationPending))
|
||||
writeCommandMigrationManifestFile(t, candidatePath, schemaCommandMigrationManifest(interfacesnapshot.CommandMigrationConsumed))
|
||||
writeInterfaceSnapshotFile(t, currentSnapshotPath, schemaCommandMigrationSnapshot(true))
|
||||
writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaCommandMigrationSnapshot(false))
|
||||
writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaCommandMigrationSnapshot(false))
|
||||
|
||||
args := []string{
|
||||
"--check", baselinePath,
|
||||
"--current", currentPath,
|
||||
"--approved-command-migrations", approvedPath,
|
||||
"--candidate-command-migrations", candidatePath,
|
||||
"--migration-current-snapshot", currentSnapshotPath,
|
||||
"--migration-base-snapshot", baseSnapshotPath,
|
||||
"--migration-stable-snapshot", stableSnapshotPath,
|
||||
}
|
||||
var stdout, stderr strings.Builder
|
||||
if code := run(args, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("command migration run code=%d stderr=%s", code, stderr.String())
|
||||
}
|
||||
|
||||
paths := []string{approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath}
|
||||
for index := range paths {
|
||||
invalid := append([]string(nil), paths...)
|
||||
invalid[index] = filepath.Join(directory, "missing.json")
|
||||
if _, err := authorizeSchemaCommandMigrations(invalid[0], invalid[1], invalid[2], invalid[3], invalid[4]); err == nil {
|
||||
t.Fatalf("missing command migration input %d was accepted", index)
|
||||
}
|
||||
}
|
||||
|
||||
stderr.Reset()
|
||||
if code := run([]string{"--check", baselinePath, "--current", currentPath, "--approved-command-migrations", approvedPath}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "both command manifests") {
|
||||
t.Fatalf("partial command pair code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
stderr.Reset()
|
||||
if code := run([]string{
|
||||
"--check", baselinePath,
|
||||
"--current", currentPath,
|
||||
"--approved-command-migrations", approvedPath,
|
||||
"--candidate-command-migrations", candidatePath,
|
||||
}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "all three interface snapshots") {
|
||||
t.Fatalf("missing command snapshots code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
stderr.Reset()
|
||||
if code := run([]string{"--check", baselinePath, "--current", currentPath, "--migration-current-snapshot", currentSnapshotPath}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "require a flag or command") {
|
||||
t.Fatalf("orphan snapshot code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
badArgs := append([]string(nil), args...)
|
||||
badArgs[5] = filepath.Join(directory, "missing-approved.json")
|
||||
stderr.Reset()
|
||||
if code := run(badArgs, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "authorize Schema command migrations") {
|
||||
t.Fatalf("command authorization error code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
legacyCurrent := schemaCommandMigrationContract(true)
|
||||
product := legacyCurrent.Products["chat"]
|
||||
tool := product.Tools["chat.move"]
|
||||
tool.Parameters["old-id"] = schemaCommandMigrationContract(false).Products["chat"].Tools["chat.move"].Parameters["old-id"]
|
||||
product.Tools["chat.move"] = tool
|
||||
legacyCurrent.Products["chat"] = product
|
||||
writeRawSchemaContractFile(t, currentPath, legacyCurrent)
|
||||
stderr.Reset()
|
||||
if code := run(args, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "normalize approved Schema command migrations") {
|
||||
t.Fatalf("command normalization error code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
requiredAddition := schemaCommandMigrationContract(true)
|
||||
product = requiredAddition.Products["chat"]
|
||||
tool = product.Tools["chat.move"]
|
||||
tool.Parameters["must"] = parameterSchema{Type: `"string"`, Property: "must", Required: true, CLIRequired: true}
|
||||
product.Tools["chat.move"] = tool
|
||||
requiredAddition.Products["chat"] = product
|
||||
writeRawSchemaContractFile(t, currentPath, requiredAddition)
|
||||
stderr.Reset()
|
||||
if code := run(args, &stdout, &stderr); code != 2 ||
|
||||
!strings.Contains(stderr.String(), `introduced unregistered required Schema parameter "must"`) {
|
||||
t.Fatalf("unregistered required addition code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func schemaCommandMigrationContract(after bool) schemaContract {
|
||||
id := parameterSchema{Type: `"string"`, Property: "resourceId", Required: true, CLIRequired: true}
|
||||
keep := parameterSchema{Type: `"string"`, Property: "keep"}
|
||||
name := parameterSchema{Type: `"string"`, Property: "name"}
|
||||
groupType := parameterSchema{Type: `"string"`, Property: "type"}
|
||||
users := parameterSchema{Type: `"array"`, Property: "userIds"}
|
||||
thread := parameterSchema{
|
||||
Type: `"boolean"`,
|
||||
Property: "threadEnabled",
|
||||
InterfaceType: "boolean",
|
||||
Default: "false",
|
||||
InterfaceDefault: "false",
|
||||
Enum: []string{"false", "true"},
|
||||
}
|
||||
group := toolSchema{
|
||||
PrimaryCLIPath: "chat group create",
|
||||
InterfaceMode: "mcp",
|
||||
InterfaceRef: `{"product_id":"im","rpc_name":"create_group"}`,
|
||||
Availability: "available",
|
||||
Parameters: map[string]parameterSchema{
|
||||
"name": name,
|
||||
"thread": thread,
|
||||
"type": groupType,
|
||||
"users": users,
|
||||
},
|
||||
Constraints: `{"require_together":[["name","type"]]}`,
|
||||
Positionals: []positionalSchema{{Name: "name", Index: 0, Type: "string", Required: true}},
|
||||
Effect: "write",
|
||||
Risk: "medium",
|
||||
Confirmation: "not_required",
|
||||
Idempotency: "unknown",
|
||||
}
|
||||
move := toolSchema{
|
||||
PrimaryCLIPath: "chat message old",
|
||||
InterfaceMode: "mcp",
|
||||
InterfaceRef: `{"product_id":"chat","rpc_name":"move"}`,
|
||||
Availability: "available",
|
||||
Parameters: map[string]parameterSchema{"old-id": id, "keep": keep},
|
||||
Constraints: `{"require_together":[["keep","old-id"]]}`,
|
||||
Effect: "read",
|
||||
Risk: "low",
|
||||
Confirmation: "not_required",
|
||||
Idempotency: "idempotent",
|
||||
}
|
||||
tools := map[string]toolSchema{"chat.create_group": group, "chat.move": move}
|
||||
if after {
|
||||
delete(group.Parameters, "thread")
|
||||
tools["chat.create_group"] = group
|
||||
replacement := group
|
||||
replacement.Parameters = make(map[string]parameterSchema, len(group.Parameters))
|
||||
for name, parameter := range group.Parameters {
|
||||
replacement.Parameters[name] = parameter
|
||||
}
|
||||
replacement.Positionals = append([]positionalSchema(nil), group.Positionals...)
|
||||
replacement.PrimaryCLIPath = "chat topic create"
|
||||
tools["chat.create_topic"] = replacement
|
||||
delete(move.Parameters, "old-id")
|
||||
move.Parameters["new-id"] = id
|
||||
move.PrimaryCLIPath = "chat topic new"
|
||||
move.Constraints = `{"require_together":[["keep","new-id"]]}`
|
||||
tools["chat.move"] = move
|
||||
}
|
||||
return schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{"chat": {Tools: tools}}}
|
||||
}
|
||||
|
||||
func schemaCommandMigrationAuthorizations() []interfacesnapshot.CommandMigration {
|
||||
return []interfacesnapshot.CommandMigration{
|
||||
{
|
||||
Kind: interfacesnapshot.CommandMigrationMove,
|
||||
Legacy: interfacesnapshot.CommandMigrationSide{
|
||||
Command: "dws chat message old",
|
||||
Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
|
||||
After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true, Hidden: true},
|
||||
},
|
||||
Replacement: interfacesnapshot.CommandMigrationSide{
|
||||
Command: "dws chat topic new",
|
||||
After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
|
||||
},
|
||||
Schema: interfacesnapshot.CommandMigrationSchema{
|
||||
ProductID: "chat",
|
||||
SourceToolID: "chat.move",
|
||||
ReplacementToolID: "chat.move",
|
||||
Parameters: []interfacesnapshot.CommandParameterMigration{{From: "old-id", To: "new-id"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: interfacesnapshot.CommandMigrationFlagExtraction,
|
||||
Legacy: interfacesnapshot.CommandMigrationSide{
|
||||
Command: "dws chat group create",
|
||||
Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
|
||||
After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
|
||||
},
|
||||
Replacement: interfacesnapshot.CommandMigrationSide{
|
||||
Command: "dws chat topic create",
|
||||
After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
|
||||
},
|
||||
LegacyFlag: interfacesnapshot.CommandMigrationFlag{
|
||||
Name: "thread",
|
||||
Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "bool", NoOpt: "true", Scope: "local"},
|
||||
After: interfacesnapshot.FlagMigrationState{Present: true, Type: "bool", Hidden: true, NoOpt: "true", Scope: "local"},
|
||||
},
|
||||
Schema: interfacesnapshot.CommandMigrationSchema{
|
||||
ProductID: "chat",
|
||||
SourceToolID: "chat.create_group",
|
||||
ReplacementToolID: "chat.create_topic",
|
||||
Parameters: []interfacesnapshot.CommandParameterMigration{
|
||||
{From: "name", To: "name"},
|
||||
{From: "type", To: "type"},
|
||||
{From: "users", To: "users"},
|
||||
{
|
||||
From: "thread",
|
||||
ReplacementConstant: &interfacesnapshot.CommandReplacementConstant{
|
||||
Property: "threadEnabled",
|
||||
Value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func schemaCommandMigrationManifest(state string) interfacesnapshot.CommandMigrationManifest {
|
||||
migrations := schemaCommandMigrationAuthorizations()
|
||||
for index := range migrations {
|
||||
migrations[index].State = state
|
||||
migrations[index].Reason = "Reviewed Schema command migration."
|
||||
}
|
||||
return interfacesnapshot.CommandMigrationManifest{
|
||||
Version: interfacesnapshot.CommandMigrationManifestVersion,
|
||||
Migrations: migrations,
|
||||
}
|
||||
}
|
||||
|
||||
func schemaCommandMigrationSnapshot(after bool) interfacesnapshot.Snapshot {
|
||||
commands := []interfacesnapshot.Command{
|
||||
{Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
|
||||
{
|
||||
Path: "dws chat group create",
|
||||
Runnable: true,
|
||||
Aliases: []string{},
|
||||
LocalFlags: []interfacesnapshot.Flag{{
|
||||
Name: "thread", Type: "bool", NoOpt: "true",
|
||||
}},
|
||||
InheritedFlags: []interfacesnapshot.Flag{},
|
||||
},
|
||||
{Path: "dws chat message old", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
|
||||
}
|
||||
if after {
|
||||
commands[1].LocalFlags[0].Hidden = true
|
||||
commands[2].Hidden = true
|
||||
commands = append(commands,
|
||||
interfacesnapshot.Command{
|
||||
Path: "dws chat topic create",
|
||||
Runnable: true,
|
||||
Aliases: []string{},
|
||||
LocalFlags: []interfacesnapshot.Flag{},
|
||||
InheritedFlags: []interfacesnapshot.Flag{},
|
||||
BoolConstParams: map[string]bool{"threadEnabled": true},
|
||||
},
|
||||
interfacesnapshot.Command{Path: "dws chat topic new", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
|
||||
)
|
||||
}
|
||||
return interfacesnapshot.Snapshot{
|
||||
SchemaVersion: interfacesnapshot.SchemaVersion,
|
||||
Rules: interfacesnapshot.Rules{
|
||||
ExcludedCommandSubtrees: []string{"dws __complete", "dws __completeNoDesc", "dws completion", "dws help"},
|
||||
ExcludedFlags: []string{"help"},
|
||||
},
|
||||
Commands: commands,
|
||||
}
|
||||
}
|
||||
|
||||
func mutateSchemaCommandMigrationTool(contract *schemaContract, toolID string, mutate func(*toolSchema)) {
|
||||
product := contract.Products["chat"]
|
||||
tool := product.Tools[toolID]
|
||||
mutate(&tool)
|
||||
product.Tools[toolID] = tool
|
||||
contract.Products["chat"] = product
|
||||
}
|
||||
|
||||
func mutateSchemaCommandMigrationParameter(
|
||||
contract *schemaContract,
|
||||
toolID string,
|
||||
parameterName string,
|
||||
mutate func(*parameterSchema),
|
||||
) {
|
||||
mutateSchemaCommandMigrationTool(contract, toolID, func(tool *toolSchema) {
|
||||
parameter := tool.Parameters[parameterName]
|
||||
mutate(¶meter)
|
||||
tool.Parameters[parameterName] = parameter
|
||||
})
|
||||
}
|
||||
|
||||
func writeCommandMigrationManifestFile(t *testing.T, path string, manifest interfacesnapshot.CommandMigrationManifest) {
|
||||
t.Helper()
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.NewEncoder(file).Encode(manifest); err != nil {
|
||||
_ = file.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,7 @@ func main() {
|
||||
func run(args []string, stdout, stderr io.Writer) int {
|
||||
var normalizePath, checkPath, mergePath, currentPath string
|
||||
var approvedFlagMigrationsPath, candidateFlagMigrationsPath string
|
||||
var approvedCommandMigrationsPath, candidateCommandMigrationsPath string
|
||||
var migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath string
|
||||
flags := flag.NewFlagSet("schema-compat", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
@@ -95,6 +96,8 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
flags.StringVar(¤tPath, "current", "", "raw current complete Schema response")
|
||||
flags.StringVar(&approvedFlagMigrationsPath, "approved-flag-migrations", "", "base-owned approved flag migration manifest")
|
||||
flags.StringVar(&candidateFlagMigrationsPath, "candidate-flag-migrations", "", "detached candidate flag migration manifest")
|
||||
flags.StringVar(&approvedCommandMigrationsPath, "approved-command-migrations", "", "base-owned approved command migration manifest")
|
||||
flags.StringVar(&candidateCommandMigrationsPath, "candidate-command-migrations", "", "detached candidate command migration manifest")
|
||||
flags.StringVar(&migrationCurrentSnapshotPath, "migration-current-snapshot", "", "current interface snapshot used for migration authorization")
|
||||
flags.StringVar(&migrationBaseSnapshotPath, "migration-base-snapshot", "", "merge-base interface snapshot used for migration authorization")
|
||||
flags.StringVar(&migrationStableSnapshotPath, "migration-stable-snapshot", "", "stable interface snapshot used for migration authorization")
|
||||
@@ -112,25 +115,38 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintln(stderr, "exactly one of --normalize, --check, or --merge is required")
|
||||
return 2
|
||||
}
|
||||
migrationInputs := []string{
|
||||
approvedFlagMigrationsPath,
|
||||
candidateFlagMigrationsPath,
|
||||
flagMigrationPair := approvedFlagMigrationsPath != "" || candidateFlagMigrationsPath != ""
|
||||
commandMigrationPair := approvedCommandMigrationsPath != "" || candidateCommandMigrationsPath != ""
|
||||
if flagMigrationPair && (approvedFlagMigrationsPath == "" || candidateFlagMigrationsPath == "") {
|
||||
fmt.Fprintln(stderr, "Schema flag migration authorization requires both flag manifests")
|
||||
return 2
|
||||
}
|
||||
if commandMigrationPair && (approvedCommandMigrationsPath == "" || candidateCommandMigrationsPath == "") {
|
||||
fmt.Fprintln(stderr, "Schema command migration authorization requires both command manifests")
|
||||
return 2
|
||||
}
|
||||
migrationSnapshots := []string{
|
||||
migrationCurrentSnapshotPath,
|
||||
migrationBaseSnapshotPath,
|
||||
migrationStableSnapshotPath,
|
||||
}
|
||||
migrationInputCount := 0
|
||||
for _, path := range migrationInputs {
|
||||
migrationSnapshotCount := 0
|
||||
for _, path := range migrationSnapshots {
|
||||
if path != "" {
|
||||
migrationInputCount++
|
||||
migrationSnapshotCount++
|
||||
}
|
||||
}
|
||||
if migrationInputCount != 0 && migrationInputCount != len(migrationInputs) {
|
||||
fmt.Fprintln(stderr, "Schema flag migration authorization requires all five migration inputs")
|
||||
migrationsEnabled := flagMigrationPair || commandMigrationPair
|
||||
if migrationsEnabled && migrationSnapshotCount != len(migrationSnapshots) {
|
||||
fmt.Fprintln(stderr, "Schema migration authorization requires all three interface snapshots")
|
||||
return 2
|
||||
}
|
||||
if migrationInputCount != 0 && checkPath == "" {
|
||||
fmt.Fprintln(stderr, "Schema flag migration authorization is only valid with --check")
|
||||
if !migrationsEnabled && migrationSnapshotCount != 0 {
|
||||
fmt.Fprintln(stderr, "Schema migration snapshots require a flag or command migration manifest pair")
|
||||
return 2
|
||||
}
|
||||
if migrationsEnabled && checkPath == "" {
|
||||
fmt.Fprintln(stderr, "Schema migration authorization is only valid with --check")
|
||||
return 2
|
||||
}
|
||||
|
||||
@@ -159,7 +175,7 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintf(stderr, "read schema baseline: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
if migrationInputCount != 0 {
|
||||
if flagMigrationPair {
|
||||
migrations, err := authorizeSchemaFlagMigrations(
|
||||
approvedFlagMigrationsPath,
|
||||
candidateFlagMigrationsPath,
|
||||
@@ -177,6 +193,24 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
return 2
|
||||
}
|
||||
}
|
||||
if commandMigrationPair {
|
||||
migrations, err := authorizeSchemaCommandMigrations(
|
||||
approvedCommandMigrationsPath,
|
||||
candidateCommandMigrationsPath,
|
||||
migrationCurrentSnapshotPath,
|
||||
migrationBaseSnapshotPath,
|
||||
migrationStableSnapshotPath,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "authorize Schema command migrations: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
baseline, err = normalizeSchemaCommandMigrations(baseline, current, migrations)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "normalize approved Schema command migrations: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
failures := checkCompatibility(baseline, current)
|
||||
if len(failures) > 0 {
|
||||
fmt.Fprintln(stderr, "Schema backwards-compatibility check failed:")
|
||||
@@ -251,6 +285,49 @@ func readFlagMigrationManifestFile(path string) (interfacesnapshot.FlagMigration
|
||||
return interfacesnapshot.ReadFlagMigrationManifest(bytes.NewReader(data))
|
||||
}
|
||||
|
||||
func authorizeSchemaCommandMigrations(
|
||||
approvedManifestPath string,
|
||||
candidateManifestPath string,
|
||||
currentSnapshotPath string,
|
||||
baseSnapshotPath string,
|
||||
stableSnapshotPath string,
|
||||
) ([]interfacesnapshot.CommandMigration, error) {
|
||||
approved, err := readCommandMigrationManifestFile(approvedManifestPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read approved command migrations: %w", err)
|
||||
}
|
||||
candidate, err := readCommandMigrationManifestFile(candidateManifestPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read candidate command migrations: %w", err)
|
||||
}
|
||||
current, err := readInterfaceSnapshotFile(currentSnapshotPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migration current snapshot: %w", err)
|
||||
}
|
||||
base, err := readInterfaceSnapshotFile(baseSnapshotPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migration base snapshot: %w", err)
|
||||
}
|
||||
stable, err := readInterfaceSnapshotFile(stableSnapshotPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migration stable snapshot: %w", err)
|
||||
}
|
||||
return interfacesnapshot.AuthorizeCommandMigrations(
|
||||
current,
|
||||
map[string]interfacesnapshot.Snapshot{"merge-base": base, "stable": stable},
|
||||
approved,
|
||||
candidate,
|
||||
)
|
||||
}
|
||||
|
||||
func readCommandMigrationManifestFile(path string) (interfacesnapshot.CommandMigrationManifest, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return interfacesnapshot.CommandMigrationManifest{}, err
|
||||
}
|
||||
return interfacesnapshot.ReadCommandMigrationManifest(bytes.NewReader(data))
|
||||
}
|
||||
|
||||
func readInterfaceSnapshotFile(path string) (interfacesnapshot.Snapshot, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -1290,6 +1367,472 @@ func normalizeSchemaFlagMigrations(
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// normalizeSchemaCommandMigrations projects only the Schema consequences that
|
||||
// are coupled to an already-authorized CLI command migration. It rewrites a
|
||||
// cloned historical contract; the ordinary checker still rejects every field
|
||||
// not proven equivalent here.
|
||||
func normalizeSchemaCommandMigrations(
|
||||
baseline schemaContract,
|
||||
current schemaContract,
|
||||
migrations []interfacesnapshot.CommandMigration,
|
||||
) (schemaContract, error) {
|
||||
normalized := cloneContract(baseline)
|
||||
for _, migration := range migrations {
|
||||
oldProduct, productExists := baseline.Products[migration.Schema.ProductID]
|
||||
oldTool, toolExists := oldProduct.Tools[migration.Schema.SourceToolID]
|
||||
if !productExists || !toolExists {
|
||||
// The historical baseline predates this Schema tool, so it has no
|
||||
// compatibility surface for this migration.
|
||||
continue
|
||||
}
|
||||
newProduct, productExists := current.Products[migration.Schema.ProductID]
|
||||
newSource, sourceExists := newProduct.Tools[migration.Schema.SourceToolID]
|
||||
if !productExists || !sourceExists {
|
||||
// Preserve the baseline so the ordinary checker reports the removal.
|
||||
continue
|
||||
}
|
||||
legacyPath := strings.TrimPrefix(migration.Legacy.Command, "dws ")
|
||||
replacementPath := strings.TrimPrefix(migration.Replacement.Command, "dws ")
|
||||
if oldTool.PrimaryCLIPath == replacementPath {
|
||||
// A consumed receipt can still be needed for the stable baseline after
|
||||
// main has already reached the after state.
|
||||
continue
|
||||
}
|
||||
if oldTool.PrimaryCLIPath != legacyPath {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %s historical Schema tool %q has primary_cli_path %q",
|
||||
migration.Kind,
|
||||
migration.Schema.SourceToolID,
|
||||
oldTool.PrimaryCLIPath,
|
||||
)
|
||||
}
|
||||
|
||||
normalizedProduct := normalized.Products[migration.Schema.ProductID]
|
||||
normalizedTool := normalizedProduct.Tools[migration.Schema.SourceToolID]
|
||||
switch migration.Kind {
|
||||
case interfacesnapshot.CommandMigrationMove:
|
||||
if newSource.PrimaryCLIPath != replacementPath {
|
||||
continue
|
||||
}
|
||||
renames := make(map[string]string, len(migration.Schema.Parameters))
|
||||
renameTargets := make(map[string]struct{}, len(migration.Schema.Parameters))
|
||||
for _, parameter := range migration.Schema.Parameters {
|
||||
oldParameter, existed := oldTool.Parameters[parameter.From]
|
||||
if !existed {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q historical Schema tool lacks parameter %q",
|
||||
migration.Legacy.Command,
|
||||
parameter.From,
|
||||
)
|
||||
}
|
||||
if _, exists := oldTool.Parameters[parameter.To]; exists {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q Schema parameter target %q already exists in historical Schema tool %q",
|
||||
migration.Legacy.Command,
|
||||
parameter.To,
|
||||
migration.Schema.SourceToolID,
|
||||
)
|
||||
}
|
||||
if _, exists := newSource.Parameters[parameter.From]; exists {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q still publishes legacy Schema parameter %q",
|
||||
migration.Legacy.Command,
|
||||
parameter.From,
|
||||
)
|
||||
}
|
||||
newParameter, exists := newSource.Parameters[parameter.To]
|
||||
if !exists {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q does not publish replacement Schema parameter %q",
|
||||
migration.Replacement.Command,
|
||||
parameter.To,
|
||||
)
|
||||
}
|
||||
if err := validateEquivalentCommandSchemaParameter(migration, parameter, oldParameter, newParameter); err != nil {
|
||||
return schemaContract{}, err
|
||||
}
|
||||
delete(normalizedTool.Parameters, parameter.From)
|
||||
normalizedTool.Parameters[parameter.To] = newParameter
|
||||
renames[parameter.From] = parameter.To
|
||||
renameTargets[parameter.To] = struct{}{}
|
||||
}
|
||||
parameterNames := make([]string, 0, len(newSource.Parameters))
|
||||
for name := range newSource.Parameters {
|
||||
parameterNames = append(parameterNames, name)
|
||||
}
|
||||
sort.Strings(parameterNames)
|
||||
for _, name := range parameterNames {
|
||||
parameter := newSource.Parameters[name]
|
||||
if _, existed := oldTool.Parameters[name]; existed {
|
||||
continue
|
||||
}
|
||||
if _, approvedRename := renameTargets[name]; approvedRename {
|
||||
continue
|
||||
}
|
||||
if parameter.Required || parameter.CLIRequired || parameter.RequiredWhen != "" {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q replacement Schema tool %q introduced unregistered required Schema parameter %q",
|
||||
migration.Legacy.Command,
|
||||
migration.Schema.SourceToolID,
|
||||
name,
|
||||
)
|
||||
}
|
||||
}
|
||||
if oldTool.Constraints != newSource.Constraints {
|
||||
oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames)
|
||||
newConstraints, newOK := canonicalizeMigratedConstraints(newSource.Constraints, nil)
|
||||
if oldOK && newOK && oldConstraints == newConstraints {
|
||||
normalizedTool.Constraints = newSource.Constraints
|
||||
}
|
||||
}
|
||||
normalizedTool.PrimaryCLIPath = replacementPath
|
||||
|
||||
case interfacesnapshot.CommandMigrationFlagExtraction:
|
||||
if newSource.PrimaryCLIPath != legacyPath {
|
||||
continue
|
||||
}
|
||||
replacement, exists := newProduct.Tools[migration.Schema.ReplacementToolID]
|
||||
if !exists || replacement.PrimaryCLIPath != replacementPath {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved flag extraction %q lacks replacement Schema tool %q at %q",
|
||||
migration.Legacy.Command,
|
||||
migration.Schema.ReplacementToolID,
|
||||
replacementPath,
|
||||
)
|
||||
}
|
||||
if oldTool.InterfaceMode != replacement.InterfaceMode ||
|
||||
oldTool.InterfaceRef != replacement.InterfaceRef ||
|
||||
oldTool.Availability != replacement.Availability ||
|
||||
oldTool.Effect != replacement.Effect ||
|
||||
oldTool.Risk != replacement.Risk ||
|
||||
oldTool.Confirmation != replacement.Confirmation ||
|
||||
oldTool.Idempotency != replacement.Idempotency {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved flag extraction %q replacement Schema tool changed interface or safety identity",
|
||||
migration.Legacy.Command,
|
||||
)
|
||||
}
|
||||
if oldTool.DryRun != "" && oldTool.DryRun != replacement.DryRun {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved flag extraction %q replacement Schema tool changed or removed dry_run",
|
||||
migration.Legacy.Command,
|
||||
)
|
||||
}
|
||||
var err error
|
||||
normalizedTool, err = normalizeFlagExtractionSchemaMigration(
|
||||
migration,
|
||||
oldTool,
|
||||
newSource,
|
||||
replacement,
|
||||
normalizedTool,
|
||||
)
|
||||
if err != nil {
|
||||
return schemaContract{}, err
|
||||
}
|
||||
}
|
||||
normalizedProduct.Tools[migration.Schema.SourceToolID] = normalizedTool
|
||||
normalized.Products[migration.Schema.ProductID] = normalizedProduct
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeFlagExtractionSchemaMigration(
|
||||
migration interfacesnapshot.CommandMigration,
|
||||
oldTool toolSchema,
|
||||
newSource toolSchema,
|
||||
replacement toolSchema,
|
||||
normalizedSource toolSchema,
|
||||
) (toolSchema, error) {
|
||||
mappingsBySource := make(map[string]interfacesnapshot.CommandParameterMigration, len(migration.Schema.Parameters))
|
||||
for _, mapping := range migration.Schema.Parameters {
|
||||
if _, duplicate := mappingsBySource[mapping.From]; duplicate {
|
||||
return toolSchema{}, fmt.Errorf(
|
||||
"approved flag extraction %q maps historical Schema parameter %q more than once",
|
||||
migration.Legacy.Command,
|
||||
mapping.From,
|
||||
)
|
||||
}
|
||||
if _, exists := oldTool.Parameters[mapping.From]; !exists {
|
||||
return toolSchema{}, fmt.Errorf(
|
||||
"approved flag extraction %q historical Schema tool lacks parameter %q",
|
||||
migration.Legacy.Command,
|
||||
mapping.From,
|
||||
)
|
||||
}
|
||||
mappingsBySource[mapping.From] = mapping
|
||||
}
|
||||
|
||||
historicalNames := make([]string, 0, len(oldTool.Parameters))
|
||||
for name := range oldTool.Parameters {
|
||||
historicalNames = append(historicalNames, name)
|
||||
}
|
||||
sort.Strings(historicalNames)
|
||||
for _, name := range historicalNames {
|
||||
if _, mapped := mappingsBySource[name]; !mapped {
|
||||
return toolSchema{}, fmt.Errorf(
|
||||
"approved flag extraction %q does not map historical Schema parameter %q",
|
||||
migration.Legacy.Command,
|
||||
name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
legacyName := migration.LegacyFlag.Name
|
||||
legacyMapping, mapped := mappingsBySource[legacyName]
|
||||
if !mapped || legacyMapping.To != "" || legacyMapping.ReplacementConstant == nil {
|
||||
return toolSchema{}, fmt.Errorf(
|
||||
"approved flag extraction %q legacy Schema parameter %q must map to one replacement constant",
|
||||
migration.Legacy.Command,
|
||||
legacyName,
|
||||
)
|
||||
}
|
||||
legacyParameter := oldTool.Parameters[legacyName]
|
||||
if err := validateFlagExtractionBooleanConstant(migration, legacyParameter, legacyMapping.ReplacementConstant); err != nil {
|
||||
return toolSchema{}, err
|
||||
}
|
||||
constantProperty := legacyMapping.ReplacementConstant.Property
|
||||
if _, exists := newSource.Parameters[legacyName]; exists {
|
||||
return toolSchema{}, fmt.Errorf(
|
||||
"approved flag extraction %q still publishes extracted Schema parameter %q",
|
||||
migration.Legacy.Command,
|
||||
legacyName,
|
||||
)
|
||||
}
|
||||
sourceNames := make([]string, 0, len(newSource.Parameters))
|
||||
for name := range newSource.Parameters {
|
||||
sourceNames = append(sourceNames, name)
|
||||
}
|
||||
sort.Strings(sourceNames)
|
||||
for _, name := range sourceNames {
|
||||
if newSource.Parameters[name].Property == constantProperty {
|
||||
return toolSchema{}, fmt.Errorf(
|
||||
"approved flag extraction %q source Schema tool still publishes replacement constant property %q through parameter %q on %q",
|
||||
migration.Legacy.Command,
|
||||
constantProperty,
|
||||
name,
|
||||
migration.Schema.SourceToolID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
replacementNames := make([]string, 0, len(replacement.Parameters))
|
||||
for name := range replacement.Parameters {
|
||||
replacementNames = append(replacementNames, name)
|
||||
}
|
||||
sort.Strings(replacementNames)
|
||||
for _, name := range replacementNames {
|
||||
if replacement.Parameters[name].Property == constantProperty {
|
||||
return toolSchema{}, fmt.Errorf(
|
||||
"approved flag extraction %q replacement Schema tool %q still publishes replacement constant property %q through parameter %q",
|
||||
migration.Legacy.Command,
|
||||
migration.Schema.ReplacementToolID,
|
||||
constantProperty,
|
||||
name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
replacementTargets := make(map[string]string, len(historicalNames)-1)
|
||||
renames := make(map[string]string, len(historicalNames)-1)
|
||||
for _, sourceName := range historicalNames {
|
||||
if sourceName == legacyName {
|
||||
continue
|
||||
}
|
||||
mapping := mappingsBySource[sourceName]
|
||||
if mapping.To == "" || mapping.ReplacementConstant != nil {
|
||||
return toolSchema{}, fmt.Errorf(
|
||||
"approved flag extraction %q shared Schema parameter %q must map to one replacement parameter",
|
||||
migration.Legacy.Command,
|
||||
sourceName,
|
||||
)
|
||||
}
|
||||
if previous, duplicate := replacementTargets[mapping.To]; duplicate {
|
||||
return toolSchema{}, fmt.Errorf(
|
||||
"approved flag extraction %q replacement Schema parameter %q is mapped from both %q and %q",
|
||||
migration.Legacy.Command,
|
||||
mapping.To,
|
||||
previous,
|
||||
sourceName,
|
||||
)
|
||||
}
|
||||
replacementParameter, exists := replacement.Parameters[mapping.To]
|
||||
if !exists {
|
||||
return toolSchema{}, fmt.Errorf(
|
||||
"approved flag extraction %q replacement Schema tool %q does not publish mapped Schema parameter %q",
|
||||
migration.Legacy.Command,
|
||||
migration.Schema.ReplacementToolID,
|
||||
mapping.To,
|
||||
)
|
||||
}
|
||||
if err := validateEquivalentCommandSchemaParameter(migration, mapping, oldTool.Parameters[sourceName], replacementParameter); err != nil {
|
||||
return toolSchema{}, err
|
||||
}
|
||||
replacementTargets[mapping.To] = sourceName
|
||||
renames[sourceName] = mapping.To
|
||||
}
|
||||
for _, name := range replacementNames {
|
||||
if _, mapped := replacementTargets[name]; !mapped {
|
||||
return toolSchema{}, fmt.Errorf(
|
||||
"approved flag extraction %q replacement Schema tool %q publishes unmapped Schema parameter %q",
|
||||
migration.Legacy.Command,
|
||||
migration.Schema.ReplacementToolID,
|
||||
name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
oldConstraints, oldConstraintsOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames)
|
||||
replacementConstraints, replacementConstraintsOK := canonicalizeMigratedConstraints(replacement.Constraints, nil)
|
||||
if !oldConstraintsOK || !replacementConstraintsOK || oldConstraints != replacementConstraints {
|
||||
return toolSchema{}, fmt.Errorf(
|
||||
"approved flag extraction %q replacement Schema tool changed constraints",
|
||||
migration.Legacy.Command,
|
||||
)
|
||||
}
|
||||
if !equivalentMigratedPositionals(oldTool.Positionals, replacement.Positionals, renames) {
|
||||
return toolSchema{}, fmt.Errorf(
|
||||
"approved flag extraction %q replacement Schema tool changed positionals",
|
||||
migration.Legacy.Command,
|
||||
)
|
||||
}
|
||||
|
||||
delete(normalizedSource.Parameters, legacyName)
|
||||
return normalizedSource, nil
|
||||
}
|
||||
|
||||
func validateFlagExtractionBooleanConstant(
|
||||
migration interfacesnapshot.CommandMigration,
|
||||
parameter parameterSchema,
|
||||
constant *interfacesnapshot.CommandReplacementConstant,
|
||||
) error {
|
||||
if strings.TrimSpace(constant.Property) == "" || parameter.Property != constant.Property {
|
||||
return fmt.Errorf(
|
||||
"approved flag extraction %q replacement constant property %q does not match historical Schema property %q",
|
||||
migration.Legacy.Command,
|
||||
constant.Property,
|
||||
parameter.Property,
|
||||
)
|
||||
}
|
||||
if !constant.Value {
|
||||
return fmt.Errorf(
|
||||
"approved flag extraction %q replacement constant for Schema property %q must be constant true",
|
||||
migration.Legacy.Command,
|
||||
constant.Property,
|
||||
)
|
||||
}
|
||||
if parameter.Type != `"boolean"` {
|
||||
return fmt.Errorf(
|
||||
"approved flag extraction %q legacy Schema parameter %q must have boolean type",
|
||||
migration.Legacy.Command,
|
||||
migration.LegacyFlag.Name,
|
||||
)
|
||||
}
|
||||
if parameter.Required || parameter.CLIRequired {
|
||||
return fmt.Errorf(
|
||||
"approved flag extraction %q legacy Schema parameter %q must remain optional",
|
||||
migration.Legacy.Command,
|
||||
migration.LegacyFlag.Name,
|
||||
)
|
||||
}
|
||||
if parameter.RequiredWhen != "" {
|
||||
return fmt.Errorf(
|
||||
"approved flag extraction %q legacy Schema parameter %q must not declare required_when",
|
||||
migration.Legacy.Command,
|
||||
migration.LegacyFlag.Name,
|
||||
)
|
||||
}
|
||||
if parameter.Default != "" && parameter.Default != "false" {
|
||||
return fmt.Errorf(
|
||||
"approved flag extraction %q legacy Schema parameter %q default must be absent or false",
|
||||
migration.Legacy.Command,
|
||||
migration.LegacyFlag.Name,
|
||||
)
|
||||
}
|
||||
if parameter.InterfaceDefault != "" && parameter.InterfaceDefault != "false" {
|
||||
return fmt.Errorf(
|
||||
"approved flag extraction %q legacy Schema parameter %q interface_default must be absent or false",
|
||||
migration.Legacy.Command,
|
||||
migration.LegacyFlag.Name,
|
||||
)
|
||||
}
|
||||
if parameter.InterfaceType != "" && parameter.InterfaceType != "boolean" {
|
||||
return fmt.Errorf(
|
||||
"approved flag extraction %q legacy Schema parameter %q interface_type must be empty or boolean",
|
||||
migration.Legacy.Command,
|
||||
migration.LegacyFlag.Name,
|
||||
)
|
||||
}
|
||||
if parameter.Format != "" {
|
||||
return fmt.Errorf(
|
||||
"approved flag extraction %q legacy Schema parameter %q format must be empty",
|
||||
migration.Legacy.Command,
|
||||
migration.LegacyFlag.Name,
|
||||
)
|
||||
}
|
||||
if len(parameter.Enum) > 0 {
|
||||
allowsTrue := false
|
||||
for _, value := range parameter.Enum {
|
||||
if value == "true" {
|
||||
allowsTrue = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowsTrue {
|
||||
return fmt.Errorf(
|
||||
"approved flag extraction %q legacy Schema parameter %q enum must allow true",
|
||||
migration.Legacy.Command,
|
||||
migration.LegacyFlag.Name,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func equivalentMigratedPositionals(
|
||||
historical []positionalSchema,
|
||||
replacement []positionalSchema,
|
||||
renames map[string]string,
|
||||
) bool {
|
||||
if len(historical) != len(replacement) {
|
||||
return false
|
||||
}
|
||||
for index, positional := range historical {
|
||||
if renamed := renames[positional.Name]; renamed != "" {
|
||||
positional.Name = renamed
|
||||
}
|
||||
if positional != replacement[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateEquivalentCommandSchemaParameter(
|
||||
migration interfacesnapshot.CommandMigration,
|
||||
parameter interfacesnapshot.CommandParameterMigration,
|
||||
oldParameter parameterSchema,
|
||||
newParameter parameterSchema,
|
||||
) error {
|
||||
if oldParameter.Type != newParameter.Type ||
|
||||
oldParameter.Property != newParameter.Property ||
|
||||
oldParameter.InterfaceType != newParameter.InterfaceType ||
|
||||
oldParameter.Required != newParameter.Required ||
|
||||
oldParameter.CLIRequired != newParameter.CLIRequired ||
|
||||
oldParameter.RequiredWhen != newParameter.RequiredWhen ||
|
||||
oldParameter.Default != newParameter.Default ||
|
||||
oldParameter.InterfaceDefault != newParameter.InterfaceDefault ||
|
||||
oldParameter.Format != newParameter.Format ||
|
||||
!equalStringSlices(oldParameter.Enum, newParameter.Enum) {
|
||||
return fmt.Errorf(
|
||||
"approved command migration %q Schema parameter %q -> %q changed a non-name field",
|
||||
migration.Legacy.Command,
|
||||
parameter.From,
|
||||
parameter.To,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func schemaToolsByPrimaryPath(contract schemaContract, primaryPath string) []schemaToolRef {
|
||||
var matches []schemaToolRef
|
||||
for productID, product := range contract.Products {
|
||||
|
||||
@@ -1589,7 +1589,7 @@ func TestCrossPlatformCoverageSchemaMigrationCLIRequiresCompleteCheckInputs(t *t
|
||||
"--migration-stable-snapshot", "stable.json",
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run([]string{"--check", "baseline.json", "--current", "schema.json", "--approved-flag-migrations", "approved.json"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "all five") {
|
||||
if code := run([]string{"--check", "baseline.json", "--current", "schema.json", "--approved-flag-migrations", "approved.json"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "both flag manifests") {
|
||||
t.Fatalf("partial migration inputs code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ func main() {
|
||||
currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot")
|
||||
baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot")
|
||||
stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot")
|
||||
_ = flag.String("approved-command-migrations", "", "base command ledger")
|
||||
_ = flag.String("candidate-command-migrations", "", "candidate command ledger")
|
||||
flag.Parse()
|
||||
|
||||
if *normalize != "" {
|
||||
@@ -171,6 +173,8 @@ func main() {
|
||||
currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot")
|
||||
baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot")
|
||||
stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot")
|
||||
_ = flag.String("approved-command-migrations", "", "base command ledger")
|
||||
_ = flag.String("candidate-command-migrations", "", "candidate command ledger")
|
||||
flag.Parse()
|
||||
|
||||
if *normalize != "" {
|
||||
@@ -282,6 +286,27 @@ var (
|
||||
func AuthorityMarker() string { return "BASE" }
|
||||
`
|
||||
|
||||
const schemaCommandGovernedSnapshotSource = `package interfacesnapshot
|
||||
|
||||
import (
|
||||
"example.com/dws-schema-compat-authority-test/internal/corecmd"
|
||||
"example.com/dws-schema-compat-authority-test/internal/corecmd/runtimeannotate"
|
||||
)
|
||||
|
||||
var (
|
||||
_ = runtimeannotate.AnnotationFlagAliasOf
|
||||
_ = runtimeannotate.AnnotationFlagAliasOrigin
|
||||
_ = runtimeannotate.FlagAliasOriginCorecmdV1
|
||||
)
|
||||
|
||||
func AuthorityMarker() string {
|
||||
if corecmd.InterfaceBoolConstParams() != "BASE" {
|
||||
return "UNTRUSTED_CONST_PARAMS"
|
||||
}
|
||||
return "BASE"
|
||||
}
|
||||
`
|
||||
|
||||
const schemaBaseSnapshotSource = `package interfacesnapshot
|
||||
|
||||
func AuthorityMarker() string { return "BASE" }
|
||||
@@ -306,6 +331,54 @@ const (
|
||||
)
|
||||
`
|
||||
|
||||
func schemaConstParamsRegistrySource(marker string) string {
|
||||
return fmt.Sprintf(`package corecmd
|
||||
|
||||
type constParamsRegistry struct{}
|
||||
|
||||
func (constParamsRegistry) Store(any, any) {}
|
||||
|
||||
var interfaceBoolConstParamsRegistry constParamsRegistry
|
||||
|
||||
func attachInterfaceBoolConstParams() {}
|
||||
|
||||
func InterfaceBoolConstParams() string { return %q }
|
||||
`, marker)
|
||||
}
|
||||
|
||||
const schemaBaseCorecmdBridgeSource = `package corecmd
|
||||
|
||||
func boolConstParams(map[string]any) map[string]bool {
|
||||
return map[string]bool{"convThreadEnabled": true}
|
||||
}
|
||||
|
||||
func installConstParamsEvidence() { attachInterfaceBoolConstParams() }
|
||||
`
|
||||
|
||||
const schemaForgedCorecmdBridgeSource = `package corecmd
|
||||
|
||||
func boolConstParams(map[string]any) map[string]bool {
|
||||
return map[string]bool{"convThreadEnabled": false}
|
||||
}
|
||||
|
||||
func installConstParamsEvidence() { attachInterfaceBoolConstParams() }
|
||||
`
|
||||
|
||||
const schemaBaseLeafAdapterSource = `package helpers
|
||||
|
||||
func forwardToolArgs(toolArgs map[string]any) map[string]any {
|
||||
return toolArgs
|
||||
}
|
||||
`
|
||||
|
||||
const schemaForgedLeafAdapterSource = `package helpers
|
||||
|
||||
func forwardToolArgs(toolArgs map[string]any) map[string]any {
|
||||
delete(toolArgs, "convThreadEnabled")
|
||||
return toolArgs
|
||||
}
|
||||
`
|
||||
|
||||
const candidateNoopSource = `package main
|
||||
|
||||
import "fmt"
|
||||
@@ -327,12 +400,14 @@ func main() {
|
||||
}
|
||||
|
||||
type authorityScenario struct {
|
||||
name string
|
||||
baseGovernance string
|
||||
candidateMutation string
|
||||
checkerMode string
|
||||
want string
|
||||
authorityMarker string
|
||||
name string
|
||||
baseGovernance string
|
||||
commandGovernance string
|
||||
commandManifestChanged bool
|
||||
candidateMutation string
|
||||
checkerMode string
|
||||
want string
|
||||
authorityMarker string
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testing.T) {
|
||||
@@ -353,6 +428,85 @@ func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testi
|
||||
baseGovernance: "none",
|
||||
authorityMarker: "OLD_BASE_STABLE_SCHEMA_CHECKER_ENFORCED",
|
||||
},
|
||||
{
|
||||
name: "command governed base owns bool ConstParams contract",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
candidateMutation: "change-const-params-protocol",
|
||||
authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED",
|
||||
},
|
||||
{
|
||||
name: "command governance bootstrap preserves candidate bool ConstParams contract",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "bootstrap",
|
||||
authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED",
|
||||
},
|
||||
{
|
||||
name: "command governed candidate cannot delete bool ConstParams contract",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
candidateMutation: "delete-const-params-contract",
|
||||
want: "must preserve the complete Schema command migration governance artifact set",
|
||||
},
|
||||
{
|
||||
name: "same package candidate cannot call private ConstParams writer",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
candidateMutation: "forge-const-params-token",
|
||||
want: "may not access framework-owned bool ConstParams registry",
|
||||
},
|
||||
{
|
||||
name: "candidate cannot reopen private ConstParams reader",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
candidateMutation: "forge-const-params-reader",
|
||||
want: "may not access framework-owned bool ConstParams registry",
|
||||
},
|
||||
{
|
||||
name: "same package candidate cannot store directly in ConstParams registry",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
candidateMutation: "forge-const-params-registry-store",
|
||||
want: "may not access framework-owned bool ConstParams registry",
|
||||
},
|
||||
{
|
||||
name: "changed command manifest rejects forged corecmd bool ConstParams bridge",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
commandManifestChanged: true,
|
||||
candidateMutation: "forge-corecmd-bool-const-params",
|
||||
want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/corecmd.go",
|
||||
},
|
||||
{
|
||||
name: "changed command manifest rejects tampered ConstParams protocol",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
commandManifestChanged: true,
|
||||
candidateMutation: "change-const-params-protocol",
|
||||
want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/interface_const_params.go",
|
||||
},
|
||||
{
|
||||
name: "changed command manifest rejects tampered leaf adapter",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
commandManifestChanged: true,
|
||||
candidateMutation: "forge-leaf-adapter",
|
||||
want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/helpers/leaf.go",
|
||||
},
|
||||
{
|
||||
name: "changed command manifest allows base identical bridges",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
commandManifestChanged: true,
|
||||
authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED",
|
||||
},
|
||||
{
|
||||
name: "unchanged command manifest allows independent corecmd change",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
candidateMutation: "forge-corecmd-bool-const-params",
|
||||
authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED",
|
||||
},
|
||||
{
|
||||
name: "partial base fails closed",
|
||||
baseGovernance: "partial",
|
||||
@@ -479,6 +633,19 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
|
||||
default:
|
||||
t.Fatalf("unknown base governance state %q", test.baseGovernance)
|
||||
}
|
||||
switch test.commandGovernance {
|
||||
case "":
|
||||
case "complete":
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaCommandGovernedSnapshotSource, 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), schemaBaseCorecmdBridgeSource, 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), schemaConstParamsRegistrySource("BASE"), 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), schemaBaseLeafAdapterSource, 0o644)
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), "{\"version\":1,\"migrations\":[]}\n", 0o644)
|
||||
case "bootstrap":
|
||||
default:
|
||||
t.Fatalf("unknown command governance state %q", test.commandGovernance)
|
||||
}
|
||||
schemaRun(t, fixtureRoot, "git", "add", ".")
|
||||
schemaRun(t, fixtureRoot, "git", "commit", "-m", "base authority")
|
||||
baseRef := strings.TrimSpace(schemaRun(t, fixtureRoot, "git", "rev-parse", "HEAD"))
|
||||
@@ -508,6 +675,17 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
|
||||
candidateLedger = "{\"version\":1,\"migrations\":[{\"candidate\":\"COMMITTED\"}]}\n"
|
||||
}
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), candidateLedger, 0o644)
|
||||
if test.commandGovernance == "complete" || test.commandGovernance == "bootstrap" {
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644)
|
||||
if test.commandGovernance == "bootstrap" {
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), schemaConstParamsRegistrySource("CANDIDATE"), 0o644)
|
||||
}
|
||||
commandManifest := "{\"version\":1,\"migrations\":[]}\n"
|
||||
if test.commandManifestChanged {
|
||||
commandManifest = "{\"version\":1,\"migrations\":[{\"candidate\":\"PENDING\"}]}\n"
|
||||
}
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), commandManifest, 0o644)
|
||||
}
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "SNAPSHOT_MARKER"), "COMMITTED_CANDIDATE\n", 0o644)
|
||||
switch test.candidateMutation {
|
||||
case "":
|
||||
@@ -534,6 +712,34 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
|
||||
"package evil\n\nconst forged = \"dws.compat.alias_of\"\n",
|
||||
0o644,
|
||||
)
|
||||
case "delete-const-params-contract":
|
||||
if err := os.Remove(filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go")); err != nil {
|
||||
t.Fatalf("remove candidate bool ConstParams registry: %v", err)
|
||||
}
|
||||
case "forge-const-params-token":
|
||||
schemaWriteFile(t,
|
||||
filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"),
|
||||
"package corecmd\n\nfunc forgeConstParams() { attachInterfaceBoolConstParams() }\n",
|
||||
0o644,
|
||||
)
|
||||
case "forge-const-params-reader":
|
||||
schemaWriteFile(t,
|
||||
filepath.Join(fixtureRoot, "internal", "evil", "forged.go"),
|
||||
"package evil\n\nconst forged = \"InterfaceBoolConstParams\"\n",
|
||||
0o644,
|
||||
)
|
||||
case "forge-const-params-registry-store":
|
||||
schemaWriteFile(t,
|
||||
filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"),
|
||||
"package corecmd\n\nfunc forgeConstParamsStore() { interfaceBoolConstParamsRegistry.Store(nil, map[string]bool{\"forged\": true}) }\n",
|
||||
0o644,
|
||||
)
|
||||
case "forge-corecmd-bool-const-params":
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), schemaForgedCorecmdBridgeSource, 0o644)
|
||||
case "change-const-params-protocol":
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), schemaConstParamsRegistrySource("CANDIDATE"), 0o644)
|
||||
case "forge-leaf-adapter":
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), schemaForgedLeafAdapterSource, 0o644)
|
||||
case "symlink-helper-parent":
|
||||
helperPath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot")
|
||||
alternatePath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot-alt")
|
||||
|
||||
@@ -54,42 +54,92 @@ while [ "$#" -gt 0 ]; do
|
||||
done
|
||||
|
||||
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-package-verify-XXXXXX")"
|
||||
HOME_SPECIFIC_AGENT_PARENTS="
|
||||
.claude
|
||||
.cursor
|
||||
.qoder
|
||||
.qoderwork
|
||||
.gemini
|
||||
.codex
|
||||
.zcode
|
||||
.github
|
||||
.windsurf
|
||||
.augment
|
||||
.cline
|
||||
.amp
|
||||
.kiro
|
||||
.trae
|
||||
.openclaw
|
||||
.hermes
|
||||
"
|
||||
HOME_SPECIFIC_SKILL_BASES="
|
||||
.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
|
||||
"
|
||||
# vercel-labs/skills agents.ts (c6f69c6), exactly 76 upstream IDs.
|
||||
# Format: id|universal(1/0)|effective default global root; '-' has no global root.
|
||||
UPSTREAM_AGENT_REGISTRY='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'
|
||||
|
||||
# DWS-only compatibility and old wrong global paths. Qoderwork remains a
|
||||
# non-universal install target; the other entries verify migration cleanup.
|
||||
LEGACY_AGENT_CLEANUP_REGISTRY='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'
|
||||
cleanup() {
|
||||
if [ "$RUN_BREW" -eq 1 ] && command -v brew >/dev/null 2>&1 && [ -n "${BREW_TAP_NAME:-}" ]; then
|
||||
HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
||||
@@ -103,11 +153,61 @@ trap cleanup EXIT INT TERM
|
||||
|
||||
seed_specific_agent_homes() {
|
||||
home_root="$1"
|
||||
for parent in $HOME_SPECIFIC_AGENT_PARENTS; do
|
||||
mkdir -p "$home_root/$parent"
|
||||
seen=''
|
||||
for row in $UPSTREAM_AGENT_REGISTRY $LEGACY_AGENT_CLEANUP_REGISTRY; do
|
||||
id=${row%%|*}; rest=${row#*|}; path=${rest#*|}
|
||||
[ "$path" != '-' ] || continue
|
||||
base=$(resolve_agent_base "$home_root" "$id" "$path")
|
||||
[ -n "$base" ] || continue
|
||||
key=$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')
|
||||
printf '%s\n' "$seen" | grep -Fqx "$key" && continue
|
||||
seen="${seen}${key}
|
||||
"
|
||||
mkdir -p "${base%/skills}"
|
||||
done
|
||||
}
|
||||
|
||||
resolve_agent_base() {
|
||||
home_root="$1"; id="$2"; default_path="$3"
|
||||
case "$id" in
|
||||
autohand-code) printf '%s\n' "${AUTOHAND_HOME:-$home_root/.autohand}/skills" ;;
|
||||
claude-code) printf '%s\n' "${CLAUDE_CONFIG_DIR:-$home_root/.claude}/skills" ;;
|
||||
codex) printf '%s\n' "${CODEX_HOME:-$home_root/.codex}/skills" ;;
|
||||
grok) printf '%s\n' "${GROK_HOME:-$home_root/.grok}/skills" ;;
|
||||
hermes-agent) printf '%s\n' "${HERMES_HOME:-$home_root/.hermes}/skills" ;;
|
||||
mistral-vibe) printf '%s\n' "${VIBE_HOME:-$home_root/.vibe}/skills" ;;
|
||||
amp|replit|universal) printf '%s\n' "${XDG_CONFIG_HOME:-$home_root/.config}/agents/skills" ;;
|
||||
crush|devin|goose|kimchi|opencode) printf '%s\n' "${XDG_CONFIG_HOME:-$home_root/.config}/${default_path#.config/}" ;;
|
||||
openclaw)
|
||||
for alias in .openclaw .clawdbot .moltbot; do
|
||||
if [ -d "$home_root/$alias" ]; then printf '%s\n' "$home_root/$alias/skills"; return; fi
|
||||
done
|
||||
printf '%s\n' "$home_root/.openclaw/skills"
|
||||
;;
|
||||
*) printf '%s\n' "$home_root/$default_path" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Package verification must never inherit the runner's real Agent roots. In
|
||||
# particular, hosted Linux runners can define XDG_CONFIG_HOME globally; two
|
||||
# parallel verification jobs would then publish links into the same directory
|
||||
# and make one another's links appear broken. Keep every supported override
|
||||
# inside the scenario-specific temporary HOME instead.
|
||||
with_isolated_agent_env() (
|
||||
isolated_home=$1
|
||||
shift
|
||||
HOME="$isolated_home"
|
||||
AUTOHAND_HOME="$isolated_home/.autohand"
|
||||
CLAUDE_CONFIG_DIR="$isolated_home/.claude"
|
||||
CODEX_HOME="$isolated_home/.codex"
|
||||
GROK_HOME="$isolated_home/.grok"
|
||||
HERMES_HOME="$isolated_home/.hermes"
|
||||
VIBE_HOME="$isolated_home/.vibe"
|
||||
XDG_CONFIG_HOME="$isolated_home/.config"
|
||||
export HOME AUTOHAND_HOME CLAUDE_CONFIG_DIR CODEX_HOME GROK_HOME HERMES_HOME VIBE_HOME XDG_CONFIG_HOME
|
||||
"$@"
|
||||
)
|
||||
|
||||
verify_skill_base() {
|
||||
home_root="$1"
|
||||
base="$2"
|
||||
@@ -116,25 +216,71 @@ verify_skill_base() {
|
||||
[ ! -e "$home_root/$base/dws" ] || err "unexpected mono Skill layout found in $home_root/$base/dws"
|
||||
}
|
||||
|
||||
verify_skill_targets() {
|
||||
home_root="$1"
|
||||
found_specific=0
|
||||
for base in $HOME_SPECIFIC_SKILL_BASES; do
|
||||
parent="${base%/skills}"
|
||||
if [ -d "$home_root/$parent" ]; then
|
||||
verify_skill_base "$home_root" "$base"
|
||||
found_specific=1
|
||||
verify_compatible_skill_base() {
|
||||
home_root="$1"; base="$2"; canonical="$home_root/.agents/skills"
|
||||
for skill_md in "$canonical"/*/SKILL.md; do
|
||||
[ -f "$skill_md" ] || continue
|
||||
name=$(basename "$(dirname "$skill_md")")
|
||||
target="$base/$name"
|
||||
if [ -L "$target" ]; then
|
||||
link_target="$(readlink "$target")" || err "could not read canonical Skill link at $target"
|
||||
# Relative link targets are the headline invariant of the canonical store:
|
||||
# an absolute target breaks as soon as the home directory moves or the
|
||||
# store is relocated, so reject it even though it resolves today.
|
||||
case "$link_target" in
|
||||
/*) err "canonical Skill link must be relative at $target (target=$link_target)" ;;
|
||||
esac
|
||||
linked_real="$(CDPATH= cd -- "$target" 2>/dev/null && pwd -P)" || err "broken canonical Skill link at $target"
|
||||
canonical_real="$(CDPATH= cd -- "$canonical/$name" && pwd -P)" || err "canonical Skill missing at $canonical/$name"
|
||||
[ "$linked_real" = "$canonical_real" ] || err "Skill link does not resolve to canonical source at $target"
|
||||
elif [ -d "$target" ]; then
|
||||
diff -qr "$canonical/$name" "$target" >/dev/null || err "Skill copy fallback differs from canonical source at $target"
|
||||
else
|
||||
err "canonical Skill compatibility target missing at $target"
|
||||
fi
|
||||
done
|
||||
[ ! -e "$base/dws" ] && [ ! -L "$base/dws" ] || err "unexpected mono Skill layout found in $base/dws"
|
||||
}
|
||||
|
||||
if [ "$found_specific" -eq 0 ]; then
|
||||
verify_skill_base "$home_root" ".agents/skills"
|
||||
return
|
||||
fi
|
||||
# Universal Agents read the canonical store directly, so they must own no copy
|
||||
# of ANY canonical Skill. Iterate the real store instead of a hardcoded name
|
||||
# list: a hardcoded list silently passed leftover copies of every other
|
||||
# dingtalk-* Skill from earlier betas.
|
||||
verify_universal_skill_base() {
|
||||
home_root="$1"; base="$2"; canonical="$home_root/.agents/skills"
|
||||
for skill_md in "$canonical"/*/SKILL.md; do
|
||||
[ -f "$skill_md" ] || continue
|
||||
name=$(basename "$(dirname "$skill_md")")
|
||||
{ [ ! -e "$base/$name" ] && [ ! -L "$base/$name" ]; } || \
|
||||
err "unexpected duplicate Skill found in universal Agent root $base/$name"
|
||||
done
|
||||
# The mono layout is absent from a multi canonical store, so gate it directly.
|
||||
{ [ ! -e "$base/dws" ] && [ ! -L "$base/dws" ]; } || \
|
||||
err "unexpected duplicate Skill found in universal Agent root $base/dws"
|
||||
}
|
||||
|
||||
for name in dingtalk-shared dingtalk-misc dws; do
|
||||
[ ! -e "$home_root/.agents/skills/$name" ] || \
|
||||
err "unexpected generic Skill copy found in $home_root/.agents/skills/$name"
|
||||
verify_skill_targets() {
|
||||
home_root="$1"
|
||||
# The universal-convention canonical store is always present, regardless of
|
||||
# which concrete Agent homes existed at install time.
|
||||
verify_skill_base "$home_root" ".agents/skills"
|
||||
seen=''
|
||||
for row in $UPSTREAM_AGENT_REGISTRY $LEGACY_AGENT_CLEANUP_REGISTRY; do
|
||||
id=${row%%|*}; rest=${row#*|}; universal=${rest%%|*}; path=${rest#*|}
|
||||
[ "$path" != '-' ] || continue
|
||||
base=$(resolve_agent_base "$home_root" "$id" "$path")
|
||||
[ -n "$base" ] || continue
|
||||
key=$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')
|
||||
printf '%s\n' "$seen" | grep -Fqx "$key" && continue
|
||||
seen="${seen}${key}
|
||||
"
|
||||
[ "$base" != "$home_root/.agents/skills" ] || continue
|
||||
[ -d "${base%/skills}" ] || continue
|
||||
if [ "$universal" -eq 1 ]; then
|
||||
verify_universal_skill_base "$home_root" "$base"
|
||||
continue
|
||||
fi
|
||||
verify_compatible_skill_base "$home_root" "$base"
|
||||
done
|
||||
}
|
||||
|
||||
@@ -146,15 +292,15 @@ verify_npm_install() {
|
||||
npm_cache="$TMP_ROOT/npm-cache-$scenario"
|
||||
mkdir -p "$npm_home" "$npm_prefix" "$npm_cache"
|
||||
if [ "$scenario" = "specific-agent-roots" ]; then
|
||||
seed_specific_agent_homes "$npm_home"
|
||||
with_isolated_agent_env "$npm_home" seed_specific_agent_homes "$npm_home"
|
||||
fi
|
||||
|
||||
say "==> verifying npm package install ($scenario)"
|
||||
HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
|
||||
with_isolated_agent_env "$npm_home" env npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
|
||||
npm install -g "$tarball_path" >/dev/null
|
||||
|
||||
[ -x "$npm_prefix/bin/dws" ] || err "npm install did not expose dws in $npm_prefix/bin"
|
||||
HOME="$npm_home" "$npm_prefix/bin/dws" --help >/dev/null
|
||||
with_isolated_agent_env "$npm_home" "$npm_prefix/bin/dws" --help >/dev/null
|
||||
if [ -n "$EXPECTED_VERSION" ]; then
|
||||
vendor_bin="$npm_prefix/lib/node_modules/dingtalk-workspace-cli/vendor/dws"
|
||||
need_file "$vendor_bin"
|
||||
@@ -165,9 +311,9 @@ verify_npm_install() {
|
||||
if (pkg.version !== process.env.EXPECTED_VERSION) process.exit(1);
|
||||
' "$NPM_STAGE_DIR/package.json" || err "npm package.json version mismatch"
|
||||
fi
|
||||
[ "$VERIFY_SKILL_TARGETS" -eq 0 ] || verify_skill_targets "$npm_home"
|
||||
[ "$VERIFY_SKILL_TARGETS" -eq 0 ] || with_isolated_agent_env "$npm_home" verify_skill_targets "$npm_home"
|
||||
|
||||
HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
|
||||
with_isolated_agent_env "$npm_home" env npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
|
||||
npm uninstall -g dingtalk-workspace-cli >/dev/null
|
||||
}
|
||||
|
||||
@@ -176,6 +322,7 @@ verify_npm() {
|
||||
need_cmd node
|
||||
need_cmd tar
|
||||
need_cmd unzip
|
||||
need_cmd diff
|
||||
need_file "$NPM_STAGE_DIR/package.json"
|
||||
|
||||
tarball_name="$(
|
||||
|
||||
@@ -108,6 +108,10 @@ coverage:
|
||||
multi_skill: dingtalk-misc
|
||||
multi_refs:
|
||||
- references/whiteboard.md
|
||||
- mono: recruit
|
||||
multi_skill: dingtalk-misc
|
||||
multi_refs:
|
||||
- references/recruit.md
|
||||
|
||||
omit_coverage:
|
||||
- mono: simple
|
||||
|
||||
@@ -94,6 +94,7 @@ cli_version: ">=1.0.15"
|
||||
| `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
|
||||
| `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 + 知识库动态查询 | [wiki.md](./references/products/wiki.md) |
|
||||
| `whiteboard` | 文档内嵌白板:读取 OpenNodes、追加节点、整页重建 | [whiteboard.md](./references/products/whiteboard.md) |
|
||||
| `recruit` | 钉钉招聘:查询职位列表、获取职位详情、创建职位 | [recruit.md](./references/products/recruit.md) |
|
||||
| `event` | 个人 IM/OA 事件:监听消息、群生命周期、审批任务与审批实例事件,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) |
|
||||
|
||||
## 意图判断决策树
|
||||
@@ -121,6 +122,7 @@ cli_version: ">=1.0.15"
|
||||
用户提到"待办/TODO/任务提醒/循环待办" → `todo`
|
||||
用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki`
|
||||
用户提到"文档内嵌白板/画布/OpenNodes/白板节点/连接线/整页重建白板" → `whiteboard`;创建空白板卡片先走 `doc whiteboard insert`
|
||||
用户提到"招聘/职位/JD/在招职位/创建职位/职位详情" → `recruit`
|
||||
用户提到"监听有人@我/监听单聊或群消息/监听所有单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/订阅个人 IM 事件/实时接收钉钉事件/监听并自动回复消息/驱动 Agent 处理消息" → `event +listen-im`;群成员加入/退出、群改名/解散或明确原始 EventKey/Filter DSL → `event consume`
|
||||
用户提到"监听待我审批的任务/监听审批任务创建、完成或转交/监听审批单发起或终止/监听我发起的审批完成/监听审批实例完成/订阅 OA 事件/event consume user_oa_approval_*" → `event consume`
|
||||
|
||||
|
||||
@@ -437,23 +437,23 @@ Flags:
|
||||
--role-id string 群身份 openRoleId,由 group-role list 返回 (必填)
|
||||
```
|
||||
|
||||
#### 设置用户群身份 — 覆盖指定用户在群中的全部群身份(传空则清除所有身份)
|
||||
#### 设置用户群身份 — 覆盖指定用户在群中的全部群身份
|
||||
```
|
||||
Usage:
|
||||
dws chat group-role set-user [flags]
|
||||
Example:
|
||||
dws chat group-role set-user --group <openConversationId> --user <userId> --role-ids roleId1,roleId2
|
||||
dws chat group-role set-user --group <openConversationId> --user <userId> --role-id <openRoleId>
|
||||
# 查询人员: dws aisearch person --query "姓名" --dimension name
|
||||
# 查询 role-id: dws chat group-role list --group <openConversationId>
|
||||
Flags:
|
||||
--group string 群聊 openConversationId (必填)
|
||||
--user string 用户 userId(必填)
|
||||
--role-ids string 群身份 openRoleId 列表,逗号分隔 (必填),传空字符串则清除该用户所有群身份
|
||||
--group string 群聊 openConversationId (必填)
|
||||
--user string 用户 userId(必填)
|
||||
--role-id string 群身份 openRoleId,由 group-role list 返回
|
||||
```
|
||||
|
||||
#### 移除用户的指定群身份 — 从用户身上移除指定的群身份(不影响其他群身份)
|
||||
|
||||
> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。需要清除某人身份时,用 `chat group-role set-user --role-ids ""`(传空 role-ids 覆盖为无身份)兜底;但 set-user 会清掉该用户的**全部**身份,无法只移除其中一个。
|
||||
> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。set-user 会清掉该用户的**全部**身份,无法只移除其中一个。
|
||||
```
|
||||
Usage:
|
||||
dws chat group-role remove-user [flags]
|
||||
@@ -2457,7 +2457,7 @@ Flags:
|
||||
| `chat conversation-info` | `newCSpaceIdIM` | 独立钉盘存储流程的共享空间 ID;不是发送本地聊天附件的前置条件 |
|
||||
| `chat file upload` | 无(已下线) | 不要调用;常规发图/发文件用 `chat message send --msg-type file --file` |
|
||||
| `chat message list` | `openMsgId` | message read-status 的 --message-id |
|
||||
| `chat group-role list` | `openRoleId` | group-role update/remove/set-user/remove-user 的 --role-id |
|
||||
| `chat group-role list` | `openRoleId` | group-role update/remove/set-user 的 --role-id;remove-user 的 --role-ids |
|
||||
| `chat message create-text-emotion` | `emotionId` | add-text-emotion 的 --emotion-id |
|
||||
| `chat category list` | `categoryId` | category list-conversations 的 --category-id |
|
||||
| `chat group get-by-group-id` | `openConversationId` | 同 chat search,将群号转为 openConversationId |
|
||||
@@ -2494,7 +2494,7 @@ Flags:
|
||||
- `send-by-bot` 群聊传 `--conversation-id`,单聊传 `--users` 或 `--open-dingtalk-ids`,与群目标互斥且必选其一;机器人群聊 Markdown 引用回复必须同时传 `--reply <openMessageId>` 与 `--ref-sender <senderOpenDingTalkId>`,单聊、图片和文件不支持引用回复;群聊时可选 `--at-user-ids` @指定成员(传 userId 列表)或 `--at-open-dingtalk-ids` @指定成员(传 openDingTalkId 列表),content 中需包含对应 @标识;`--at-all` @所有人;群聊场景如果返回"机器人不存在"错误,需先通过 `chat group members add-bot --conversation-id <openConversationId> --robot-code <robot-code>` 将机器人邀请进群后再发送
|
||||
- `recall-by-bot` 群聊传 `--group` + `--keys`,单聊仅传 `--keys`(不传 `--group` 即为单聊撤回)
|
||||
- `send-by-webhook` 支持 `--at-all`、`--at-mobiles`、`--at-users` 进行 @ 操作,但需在 `--content` 中包含 `@userId` 或 `@手机号` 才能生效;`--at-all` @所有人时需在 `--content` 中包含 `@10`
|
||||
- `chat group-role` 系列命令用于管理群的自定义身份标签:`list` 查列表,`add` 创建,`update` 改名,`remove` 删除;`set-user` 覆盖某人全部身份(传空 --role-ids 则清除),`remove-user` 仅移除指定身份,`query-user` 查询某人当前身份;用户用 `--user <userId>`
|
||||
- `chat group-role` 系列命令用于管理群的自定义身份标签:`list` 查列表,`add` 创建,`update` 改名,`remove` 删除;`set-user` 用单个 `--role-id` 覆盖某人全部身份(不支持传空值清除),`remove-user` 仅移除指定身份,`query-user` 查询某人当前身份;用户用 `--user <userId>`
|
||||
- 消息**换行符**(`send` / `send-by-webhook` 使用 `--content`,`send-by-bot` 使用 `--text`)有两层要求:(1) 必须是**真实换行符** `U+000A`,不是字面量 `\n`;(2) Markdown 规范下单换行不生效,需用空行 `\n\n`(段落分隔)或行尾两空格 + 换行 / `<br>`(硬换行)
|
||||
- `chat group transfer-owner` 转让群主,需传 --group(openConversationId);新群主 userId 用 `--user`,openDingTalkId 用 `--new-owner`
|
||||
- `chat group invite-url` 获取群邀请链接,需传 --group(openConversationId),可选 --expires-seconds 指定有效期(秒,0=永久)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# 钉钉招聘
|
||||
|
||||
`dws recruit` 提供招聘职位列表、职位详情和职位创建能力。组织、业务标识和当前
|
||||
操作人由登录身份及 MCP Connector 注入,不要要求用户提供或猜测身份字段。
|
||||
|
||||
## 查询
|
||||
|
||||
```bash
|
||||
dws recruit job list --keyword "Java" --status open --size 20 --format json
|
||||
dws recruit job get --job-id JOB_ID --format json
|
||||
```
|
||||
|
||||
列表状态支持 `draft`、`open`、`invalid`、`closed`。首页不传 `--cursor`;
|
||||
`hasMore=true` 时把响应 `nextCursor` 回填。查询详情前必须从真实列表结果取得
|
||||
`jobId`。
|
||||
|
||||
## 创建
|
||||
|
||||
创建是非幂等写操作。先用 `--dry-run` 检查;实际执行必须经过用户明确确认,并
|
||||
交由 CLI 的确认流程处理:
|
||||
|
||||
```bash
|
||||
dws recruit job create --from ./job.json --dry-run --format json
|
||||
dws recruit job create --from ./job.json --format json
|
||||
```
|
||||
|
||||
JSON 必填字段为 `name`、`description`、`jobNature`、`requiredEdu`、`extData`、
|
||||
`creatorUserId`。
|
||||
`jobNature` 当前固定为 `FULL-TIME`;学历枚举为 1小学、2初中、3高中、4中专、
|
||||
5大专、6本科、7硕士、8博士、9其他。`minSalary`、`maxSalary` 可选;两者同时
|
||||
提供时最低薪资不得高于最高薪资。
|
||||
|
||||
`creatorUserId` 是当前创建人 userId,必须来自同一 profile 下的真实通讯录结果。
|
||||
`ownerUserIds` 可选;提供时是负责人 userId 的 JSON 字符串数组,也必须使用真实
|
||||
通讯录结果。禁止猜测任何 userId。`extData.headCount` 范围 1–999,薪资月份范围
|
||||
12–24;提供地址时必须包含地点名、详细地址和可信的经纬度。
|
||||
|
||||
文件只写职位对象,不要添加 MCP 信封字段 `atsAddJobParam`、`corpId`、`bizCode`
|
||||
或 `opUserId`;这些字段由 CLI 或 Connector 处理。
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dingtalk-misc
|
||||
description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批查询与处理/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板/DWS技能市场安装/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile。Use when 用户提到上述任一产品,或查待审批/同意拒绝转交撤销审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/搜索安装技能/开发者后台应用/人才池/员工档案/职业历程/绩效/原生.md文件/PAT授权/切换组织/跨组织/profile 等相关操作。未来审批任务或实例变化的实时监听不属于本 skill,应使用 dingtalk-event。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。
|
||||
description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批查询与处理/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板/钉钉招聘/DWS技能市场安装/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile。Use when 用户提到上述任一产品,或查待审批/同意拒绝转交撤销审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/招聘职位/JD/创建职位/搜索安装技能/开发者后台应用/人才池/员工档案/职业历程/绩效/原生.md文件/PAT授权/切换组织/跨组织/profile 等相关操作。未来审批任务或实例变化的实时监听不属于本 skill,应使用 dingtalk-event。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。
|
||||
metadata:
|
||||
cli_version: ">=0.2.14"
|
||||
category: product
|
||||
@@ -31,6 +31,7 @@ metadata:
|
||||
| 电子表格 / 工作表 / 单元格读写 / 公式 / 超链接 / 浮动图片 | 电子表格创建/读写/公式/超链接/浮动图片/导出 | `dws sheet` | [sheet.md](references/sheet.md) |
|
||||
| 开放平台文档 / API文档 / 接口文档 / 接口报错 | 开放平台开发文档搜索 | `dws devdoc` | [devdoc.md](references/devdoc.md) |
|
||||
| 白板 / 画布 / OpenNodes / 白板节点 | 读取和更新钉钉文档中的内嵌白板 | `dws whiteboard` | [whiteboard.md](references/whiteboard.md) |
|
||||
| 招聘 / 职位 / JD / 在招职位 / 创建职位 / 职位详情 | 钉钉招聘职位的查询、详情与创建 | `dws recruit` | [recruit.md](references/recruit.md) |
|
||||
| 搜索技能 / 找技能 / 安装技能 / 技能市场 / 安装 DWS mono 或 multi skill | DWS 技能市场搜索、下载、安装与内置技能部署 | `dws skill` | [skill.md](references/skill.md) |
|
||||
| 人才池 / 储备干部池 / 员工档案 / 职业历程 / 绩效记录 / 员工标签 / 组织大脑 / 人才搜索 | 组织大脑:人才池、员工档案专项模块与结构化人才搜索 | `dws hrbrain` | [hrbrain.md](references/hrbrain.md) |
|
||||
| 原生 Markdown / `.md` 原文 / 覆盖 Markdown / 局部替换 Markdown | 原生 `.md` 文件读取、创建、全量覆盖与局部替换 | `dws markdown` | [markdown.md](references/markdown.md) |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user