Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e8469a175 | ||
|
|
2292a49c6a | ||
|
|
cf43cf1b47 | ||
|
|
344104268a | ||
|
|
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 | ||
|
|
7cfaa1ca74 | ||
|
|
f8af8dc1dc | ||
|
|
bb6fd2f256 | ||
|
|
580c4d201b | ||
|
|
37cd629335 | ||
|
|
0c0e2b3ce1 | ||
|
|
7d16c9693f | ||
|
|
1dee02d900 | ||
|
|
7664f04fda | ||
|
|
8177a06296 | ||
|
|
c674366aea | ||
|
|
ce5815a606 | ||
|
|
d211b79a80 | ||
|
|
de8df0fa6f | ||
|
|
9ff31cdd55 | ||
|
|
fde37f7896 | ||
|
|
31902a987e |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use links (Windows junctions) with safe copy fallback; custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract through a checked rename instead of failing the install. Failing to retire an obsolete Agent copy is now a warning rather than an install failure, and every install surface prunes `~/.dws/skill-backups` to the newest 5 batches. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor.
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
+730
-122
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,6 @@
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
"node": ">=16.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,24 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen
|
||||
|
||||
## 8. Upgrade 与恢复语义
|
||||
|
||||
升级器对每个 Agent 目标执行:
|
||||
升级器始终先发布 `~/.agents/skills` canonical 集合。固定兼容注册表中被分类为
|
||||
universal 的 Agent 不再保留 Agent 私有副本;检测到的
|
||||
非 universal Agent(如 Claude、OpenClaw、Hermes、Windsurf)使用指向 canonical
|
||||
的目录链接,Windows 使用 junction。链接不可用时回退为内容完整的直接复制。
|
||||
自定义 `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 +165,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 +181,26 @@ 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。这些文件系统不得让安装整体失败,而是降级
|
||||
为“显式存在性检查 + 普通 rename”:不覆盖既有目标的契约保持不变,仅失去原子性,
|
||||
其检查与 rename 之间的竞态窗口与普通 rename 本身一致,并由发布后的身份与内容校验
|
||||
兜底。Windows `MoveFile` 本身即拒绝已存在的目标,无需降级。
|
||||
|
||||
备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行
|
||||
`dws skill setup --mode mono`。
|
||||
|
||||
@@ -192,6 +229,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 +247,11 @@ Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行
|
||||
- 复制失败不留下 Agent 可见的残缺官方目录;
|
||||
- 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill;
|
||||
- Windows、macOS、Linux 的路径和覆盖率门禁;
|
||||
- symlinked parent、Windows junction、链接失败复制回退与 broken link 修复;
|
||||
- Claude/Codex/Hermes 自定义根目录及 OpenClaw 历史目录优先级;
|
||||
- `CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`XDG_CONFIG_HOME` 等自定义根跨文件系统时的
|
||||
正向备份、反向恢复、普通链接及 dangling symlink 词法保留;
|
||||
- copy、verify、publish、remove 各阶段故障,以及非跨设备权限错误不得进入复制回退;
|
||||
- npm、Shell、PowerShell 与包管理器安装冒烟。
|
||||
|
||||
## 13. 后续演进
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
+449
-124
@@ -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,6 +2119,9 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
|
||||
if cleanupErr != nil {
|
||||
publishErr = errors.Join(publishErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
|
||||
}
|
||||
if isCanonical && hasCanonicalDependents {
|
||||
return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布失败,已执行回滚 %s: %w", target.Destination, publishErr)
|
||||
}
|
||||
fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr)
|
||||
skipped += perTarget
|
||||
continue
|
||||
@@ -1836,7 +2161,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,118 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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" {
|
||||
|
||||
@@ -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) 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 }
|
||||
|
||||
+441
-199
@@ -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,21 +173,19 @@ 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)
|
||||
@@ -109,8 +195,11 @@ func backupAndRemoveSkillDir(homeDir, dir string) (string, error) {
|
||||
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 := upgradeLstat(target); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
break
|
||||
}
|
||||
return "", fmt.Errorf("检查备份目标失败 %s: %w", target, err)
|
||||
}
|
||||
backupRoot = filepath.Join(homeDir, skillBackupSubdir, fmt.Sprintf("%s-%d", stamp, i))
|
||||
target = filepath.Join(backupRoot, name)
|
||||
@@ -121,7 +210,7 @@ func backupAndRemoveSkillDir(homeDir, dir string) (string, error) {
|
||||
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 +240,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 +280,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 +309,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 +384,22 @@ func buildUpgradeProvenanceRecords(root string, names []string, version string)
|
||||
// backups are kept.
|
||||
const skillBackupKeep = 5
|
||||
|
||||
// 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 are candidates; unknown directories are 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 +411,7 @@ func pruneSkillBackups(homeDir string) error {
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
if e.IsDir() && isSkillBackupStamp(e.Name()) {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
@@ -404,20 +524,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 +565,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 +633,266 @@ 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 {
|
||||
// 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 +915,56 @@ 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 {
|
||||
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 +1039,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 +1049,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 +1057,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 +1067,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,8 +625,8 @@ 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) error {
|
||||
if strings.Contains(src, "dingtalk-stale") {
|
||||
return errors.New("injected backup failure")
|
||||
}
|
||||
@@ -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)
|
||||
@@ -644,7 +656,7 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t
|
||||
}
|
||||
|
||||
// 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 +666,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 +677,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 +734,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 {
|
||||
@@ -762,14 +814,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) 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:
|
||||
@@ -825,6 +877,60 @@ 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++ {
|
||||
if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260910-00000%d", i)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// A collision-suffixed stamp is still a valid DWS backup and is eligible.
|
||||
if err := os.MkdirAll(filepath.Join(root, "20260910-000000-7"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unknown := []string{
|
||||
"user-personal-backup",
|
||||
"20260910-000000-abc", // stamp-shaped but non-numeric suffix
|
||||
"20260910-00000", // too short
|
||||
"notes",
|
||||
}
|
||||
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 stamped backups beyond skillBackupKeep are pruned; foreign entries
|
||||
// are excluded from the count, so the stamp total stays at skillBackupKeep.
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stamps := 0
|
||||
for _, e := range entries {
|
||||
if isSkillBackupStamp(e.Name()) {
|
||||
stamps++
|
||||
}
|
||||
}
|
||||
if stamps != skillBackupKeep {
|
||||
t.Errorf("stamped backups after prune = %d, want %d", stamps, skillBackupKeep)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageResolveSkillSrcLayouts pins every fallback branch of
|
||||
// the mono-source resolver and the multi-bundle child lookup.
|
||||
func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) {
|
||||
@@ -888,88 +994,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) error {
|
||||
if strings.Contains(src, ".agents") {
|
||||
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 +1022,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 +1043,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 +1081,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) 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) error {
|
||||
if strings.Contains(src, "dingtalk-stale") {
|
||||
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 +1122,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 +1142,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,358 @@
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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) 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) 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) 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,197 @@
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// 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.
|
||||
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)
|
||||
if err := skillPathRenameNoReplace(staged, destination); err != nil {
|
||||
return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err)
|
||||
}
|
||||
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)
|
||||
if !skillPathIdentityProven(identity, publishedIdentity, stagedFileID, publishedFileID) || 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("发布对象身份已变化")
|
||||
}
|
||||
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,30 @@
|
||||
//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)
|
||||
}
|
||||
|
||||
func skillPathFileIdentityImpl(_ string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool {
|
||||
return skillPathSameFileIdentity(staged, published)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//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)
|
||||
}
|
||||
|
||||
func skillPathFileIdentityImpl(_ string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool {
|
||||
return skillPathSameFileIdentity(staged, published)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//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)
|
||||
}
|
||||
|
||||
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,513 @@
|
||||
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 file IDs from
|
||||
// GetFileInformationByHandle and ignores this seam. On Unix, where the
|
||||
// file ID is unavailable, 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 })
|
||||
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) 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)
|
||||
originalRename := skillPathRenameNoReplace
|
||||
testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) error {
|
||||
if err := originalRename(source, target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(target); err != nil {
|
||||
return err
|
||||
}
|
||||
seedUpgradeSkill(t, target, "replacement", false)
|
||||
return nil
|
||||
})
|
||||
if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 身份已变化") {
|
||||
t.Fatalf("publish identity error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
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) 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) 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,97 @@
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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) and os.Link for regular files (fails with
|
||||
// EEXIST if the path is occupied). Neither primitive has a TOCTOU window.
|
||||
func renameSkillPathNoReplace(source, destination 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.
|
||||
func renameSkillPathNoReplaceFallback(source, destination 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 atomically claims the destination with
|
||||
// mkdir (which fails with EEXIST if the path is already occupied) and then
|
||||
// replaces the empty, self-owned directory with a plain rename. On Linux
|
||||
// rename(2) replaces an empty directory directly. On Darwin and Windows
|
||||
// rename(2) refuses to replace any existing directory, so the empty dir is
|
||||
// removed and the rename retried. If someone populated the claimed directory
|
||||
// between mkdir and remove, Remove fails (ENOTEMPTY) and no data is lost.
|
||||
// If someone creates a new path at the destination between remove and rename,
|
||||
// the second Rename detects it and fails. Neither step has a TOCTOU window
|
||||
// that could silently clobber data.
|
||||
func renameSkillDirNoReplaceFallback(source, destination string) error {
|
||||
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)
|
||||
}
|
||||
if err := skillPathRename(source, destination); err == nil {
|
||||
return nil
|
||||
}
|
||||
if rmErr := skillPathRemove(destination); rmErr != nil {
|
||||
return fmt.Errorf("清理已声明 Skill 目标失败 %s: %w", destination, rmErr)
|
||||
}
|
||||
if err := skillPathRename(source, destination); err != nil {
|
||||
return fmt.Errorf("发布 Skill 到已声明目标失败 %s: %w", destination, err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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,307 @@
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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 creation between mkdir and rename is detected", 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 })
|
||||
renameErr := errors.New("simulated ENOTEMPTY: destination populated by another process")
|
||||
testseam.Swap(t, &skillPathRename, func(string, string) error { return renameErr })
|
||||
var removeCalled bool
|
||||
testseam.Swap(t, &skillPathRemove, func(path string) error {
|
||||
if path == destination {
|
||||
removeCalled = true
|
||||
}
|
||||
return os.Remove(path)
|
||||
})
|
||||
err := renameSkillPathNoReplace(source, destination)
|
||||
if !errors.Is(err, renameErr) {
|
||||
t.Fatalf("rename failure must surface, got %v", err)
|
||||
}
|
||||
if !removeCalled {
|
||||
t.Fatal("empty destination must be cleaned up after rename failure")
|
||||
}
|
||||
if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("destination must not exist after cleanup, stat err=%v", statErr)
|
||||
}
|
||||
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("directory retry rename after removing claimed 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 })
|
||||
calls := 0
|
||||
originalRename := skillPathRename
|
||||
testseam.Swap(t, &skillPathRename, func(src, dst string) error {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return os.ErrExist
|
||||
}
|
||||
return originalRename(src, dst)
|
||||
})
|
||||
if err := renameSkillPathNoReplace(source, destination); err != nil {
|
||||
t.Fatalf("retry must publish, 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,8 +92,8 @@ 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) error {
|
||||
if src == filepath.Join(base, "dingtalk-b") {
|
||||
return failure
|
||||
}
|
||||
@@ -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) 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,8 +236,8 @@ 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) error {
|
||||
switch {
|
||||
case src == second:
|
||||
return failure
|
||||
@@ -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):
|
||||
testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) {
|
||||
return SkillPathPublication{}, failure
|
||||
})
|
||||
originalRename := skillPathRenameNoReplace
|
||||
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) error {
|
||||
if strings.Contains(filepath.ToSlash(src), skillBackupSubdir) {
|
||||
return restoreFailure
|
||||
default:
|
||||
return originalRename(src, dest)
|
||||
}
|
||||
return originalRename(src, dest)
|
||||
})
|
||||
err := publishStagedSkillSet(home, []stagedSkillDir{{staged: staged, dest: old}}, []string{old})
|
||||
if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) {
|
||||
|
||||
+496
-19
@@ -22,9 +22,434 @@ $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]::Move($Source, $Destination)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
try {
|
||||
Copy-DevSkillPathLexically $Source $stage
|
||||
Assert-DevSkillPathCopy $Source $stage
|
||||
Move-DevSkillPath $stage $Destination
|
||||
Assert-DevSkillPathCopy $Source $Destination
|
||||
if (!(Remove-DevLinkStageRoot $stageRoot)) {
|
||||
throw "Skill staging cleanup failed (published content untouched): $stageRoot"
|
||||
}
|
||||
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)) {
|
||||
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)
|
||||
|
||||
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) are candidates; any
|
||||
# other entry is foreign data and is preserved.
|
||||
$dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' } |
|
||||
Sort-Object -Property Name)
|
||||
$excess = $dirs.Count - $DevSkillBackupKeep
|
||||
foreach ($dir in $dirs) {
|
||||
if ($excess -le 0) { break }
|
||||
if ($script:DevSkillBackupRootsThisRun.Contains($dir.FullName)) { continue }
|
||||
Remove-Item -LiteralPath $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
||||
$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
|
||||
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 +465,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 +515,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 +530,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 +542,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)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+246
-13
@@ -26,6 +26,214 @@ 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}"
|
||||
}
|
||||
|
||||
copy_tree() {
|
||||
src="$1"; dest="$2"; parent="$(dirname "$dest")"
|
||||
mkdir -p "$parent" || return 1
|
||||
stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1
|
||||
if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi
|
||||
backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; }
|
||||
if mv "$stage" "$dest"; then return 0; fi
|
||||
rm -rf "$stage"
|
||||
if [ -n "$backup" ] && ! mv "$backup" "$dest"; then
|
||||
printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2
|
||||
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, so the backup the caller is about
|
||||
# to depend on for rollback can never be a prune candidate.
|
||||
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
|
||||
while [ -e "$backup" ] || [ -L "$backup" ]; 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
|
||||
mkdir -p "$backup_root" || return 1
|
||||
mv "$victim" "$backup" || return 1
|
||||
printf '%s\n' "$backup"
|
||||
}
|
||||
|
||||
# prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup
|
||||
# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep /
|
||||
# pruneSkillBackups. 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
|
||||
|
||||
# is_skill_backup_stamp accepts 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 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
|
||||
prune_total=$((prune_total + 1))
|
||||
done
|
||||
[ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0
|
||||
prune_drop=$((prune_total - SKILL_BACKUP_KEEP))
|
||||
prune_seen=0
|
||||
for prune_entry in "$prune_root"/*; do
|
||||
[ -d "$prune_entry" ] || continue
|
||||
is_skill_backup_stamp "${prune_entry##*/}" || continue
|
||||
prune_seen=$((prune_seen + 1))
|
||||
[ "$prune_seen" -le "$prune_drop" ] || break
|
||||
rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2
|
||||
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; }
|
||||
if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi
|
||||
rm -rf "$stage"
|
||||
if [ -n "$backup" ] && ! mv "$backup" "$dest"; then
|
||||
printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
need_cmd curl
|
||||
|
||||
detect_os() {
|
||||
@@ -81,20 +289,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 +350,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 +364,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
|
||||
|
||||
+253
-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 ;;
|
||||
@@ -92,36 +114,232 @@ resolve_event_version() {
|
||||
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
|
||||
if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi
|
||||
backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; }
|
||||
if mv "$stage" "$dest"; then return 0; fi
|
||||
rm -rf "$stage"
|
||||
if [ -n "$backup" ] && ! mv "$backup" "$dest"; then
|
||||
printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2
|
||||
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, so the backup the caller is about
|
||||
# to depend on for rollback can never be a prune candidate.
|
||||
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
|
||||
while [ -e "$backup" ] || [ -L "$backup" ]; 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
|
||||
mkdir -p "$backup_root" || return 1
|
||||
mv "$victim" "$backup" || return 1
|
||||
printf '%s\n' "$backup"
|
||||
}
|
||||
|
||||
# prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup
|
||||
# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep /
|
||||
# pruneSkillBackups. 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
|
||||
|
||||
# is_skill_backup_stamp accepts 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 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
|
||||
prune_total=$((prune_total + 1))
|
||||
done
|
||||
[ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0
|
||||
prune_drop=$((prune_total - SKILL_BACKUP_KEEP))
|
||||
prune_seen=0
|
||||
for prune_entry in "$prune_root"/*; do
|
||||
[ -d "$prune_entry" ] || continue
|
||||
is_skill_backup_stamp "${prune_entry##*/}" || continue
|
||||
prune_seen=$((prune_seen + 1))
|
||||
[ "$prune_seen" -le "$prune_drop" ] || break
|
||||
rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2
|
||||
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; }
|
||||
if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi
|
||||
rm -rf "$stage"
|
||||
if [ -n "$backup" ] && ! mv "$backup" "$dest"; then
|
||||
printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2
|
||||
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 +394,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 +434,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 +463,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"
|
||||
|
||||
+368
-119
@@ -53,13 +53,13 @@ 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_i=1
|
||||
while [ -e "$_bed_target" ]; do
|
||||
while [ -e "$_bed_target" ] || [ -L "$_bed_target" ]; do
|
||||
_bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name"
|
||||
_bed_i=$((_bed_i + 1))
|
||||
if [ "$_bed_i" -gt 1000 ]; then
|
||||
@@ -80,6 +80,54 @@ backup_and_remove_skill_dir() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped backup
|
||||
# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep /
|
||||
# pruneSkillBackups. Best-effort: a removal failure is reported, never fatal.
|
||||
# Only safe to call once every backup manifest of this run has been consumed,
|
||||
# because a single transaction can span more than one timestamp.
|
||||
DWS_SKILL_BACKUP_KEEP=5
|
||||
|
||||
# is_skill_backup_stamp accepts 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 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
|
||||
_psb_total=$((_psb_total + 1))
|
||||
done
|
||||
[ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0
|
||||
_psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP))
|
||||
_psb_seen=0
|
||||
for _psb_entry in "$_psb_root"/*; do
|
||||
[ -d "$_psb_entry" ] || continue
|
||||
is_skill_backup_stamp "${_psb_entry##*/}" || continue
|
||||
_psb_seen=$((_psb_seen + 1))
|
||||
[ "$_psb_seen" -le "$_psb_drop" ] || break
|
||||
rm -rf "$_psb_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$_psb_entry"
|
||||
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 +171,29 @@ sha256_stdin() {
|
||||
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="$({
|
||||
@@ -206,7 +277,7 @@ restore_multi_skill_set() {
|
||||
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 [ -e "$_rms_original" ] || [ -L "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then
|
||||
printf ' ⚠️ 无法恢复原 Skill: %s(备份保留于 %s)\n' "$_rms_original" "$_rms_backup"
|
||||
_rms_ok=0
|
||||
fi
|
||||
@@ -215,6 +286,50 @@ 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}'
|
||||
}
|
||||
|
||||
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
|
||||
if skill_link_matches "$_rls_dest" "$_rls_target" "$_rls_inode"; then
|
||||
rm -f "$_rls_dest" || _rls_ok=0
|
||||
else
|
||||
printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s\n' "$_rls_dest"
|
||||
_rls_ok=0
|
||||
fi
|
||||
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_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 +523,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 +628,158 @@ 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
|
||||
}
|
||||
# The staged symlink keeps its inode across the rename below (the staging
|
||||
# directory is a sibling inside the same Agent root), so the identity
|
||||
# recorded in the publication manifest stays exact.
|
||||
_lcs_inode="$(skill_link_inode "$_lcs_staged")" || {
|
||||
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
|
||||
}
|
||||
# Anything still present here appeared after the pre-flight scan, i.e. a
|
||||
# concurrent writer. Never let `mv` clobber it.
|
||||
if [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; then
|
||||
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
|
||||
fi
|
||||
# Publish by moving the staged symlink. `mv` is POSIX; `ln -P` is not (it is
|
||||
# absent from BusyBox `ln`, which silently degraded every non-universal
|
||||
# Agent to the copy layout on Alpine and most containers). install-event.sh
|
||||
# and install-devapp.sh already publish this way. The staged basename stays
|
||||
# unique, so a destination a concurrent process turned into a directory can
|
||||
# only receive our link and post-validation removes exactly that.
|
||||
if ! mv "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then
|
||||
cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" "$_lcs_inode" || true
|
||||
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() {
|
||||
@@ -654,84 +934,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 +999,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 +1048,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'
|
||||
|
||||
+689
-119
@@ -52,27 +52,103 @@ $LegacyOfficialMultiSkills = @(
|
||||
"dingtalk-todo", "dingtalk-wiki", "dws-shared"
|
||||
)
|
||||
|
||||
# Agent skill base directories (same order as build/npm/install.js AGENT_DIRS).
|
||||
$AgentDirs = @(
|
||||
".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"
|
||||
# Agent registry synchronized with vercel-labs/skills agents.ts (c6f69c6).
|
||||
# Universal agents read the canonical store directly; their separate historical
|
||||
# global directories are cleanup-only. Non-universal agents get junctions to
|
||||
# canonical (or complete copies when junction creation is unavailable).
|
||||
$AgentRegistry = @(
|
||||
[pscustomobject]@{ Id = "aider-desk"; Universal = $false; Dir = ".aider-desk\skills" },
|
||||
[pscustomobject]@{ Id = "amp"; Universal = $true; Dir = ".config\agents\skills" },
|
||||
[pscustomobject]@{ Id = "antigravity"; Universal = $true; Dir = ".gemini\antigravity\skills" },
|
||||
[pscustomobject]@{ Id = "antigravity-cli"; Universal = $true; Dir = ".gemini\antigravity-cli\skills" },
|
||||
[pscustomobject]@{ Id = "astrbot"; Universal = $false; Dir = ".astrbot\data\skills" },
|
||||
[pscustomobject]@{ Id = "autohand-code"; Universal = $false; Dir = ".autohand\skills" },
|
||||
[pscustomobject]@{ Id = "augment"; Universal = $false; Dir = ".augment\skills" },
|
||||
[pscustomobject]@{ Id = "bob"; Universal = $false; Dir = ".bob\skills" },
|
||||
[pscustomobject]@{ Id = "claude-code"; Universal = $false; Dir = ".claude\skills" },
|
||||
[pscustomobject]@{ Id = "openclaw"; Universal = $false; Dir = ".openclaw\skills" },
|
||||
[pscustomobject]@{ Id = "cline"; Universal = $true; Dir = ".agents\skills" },
|
||||
[pscustomobject]@{ Id = "codearts-agent"; Universal = $false; Dir = ".codeartsdoer\skills" },
|
||||
[pscustomobject]@{ Id = "codebuddy"; Universal = $false; Dir = ".codebuddy\skills" },
|
||||
[pscustomobject]@{ Id = "codemaker"; Universal = $false; Dir = ".codemaker\skills" },
|
||||
[pscustomobject]@{ Id = "codestudio"; Universal = $false; Dir = ".codestudio\skills" },
|
||||
[pscustomobject]@{ Id = "codex"; Universal = $true; Dir = ".codex\skills" },
|
||||
[pscustomobject]@{ Id = "command-code"; Universal = $false; Dir = ".commandcode\skills" },
|
||||
[pscustomobject]@{ Id = "continue"; Universal = $false; Dir = ".continue\skills" },
|
||||
[pscustomobject]@{ Id = "cortex"; Universal = $false; Dir = ".snowflake\cortex\skills" },
|
||||
[pscustomobject]@{ Id = "crush"; Universal = $false; Dir = ".config\crush\skills" },
|
||||
[pscustomobject]@{ Id = "cursor"; Universal = $true; Dir = ".cursor\skills" },
|
||||
[pscustomobject]@{ Id = "deepagents"; Universal = $true; Dir = ".deepagents\agent\skills" },
|
||||
[pscustomobject]@{ Id = "devin"; Universal = $false; Dir = ".config\devin\skills" },
|
||||
[pscustomobject]@{ Id = "dexto"; Universal = $true; Dir = ".agents\skills" },
|
||||
[pscustomobject]@{ Id = "droid"; Universal = $false; Dir = ".factory\skills" },
|
||||
[pscustomobject]@{ Id = "eve"; Universal = $false; Dir = $null },
|
||||
[pscustomobject]@{ Id = "firebender"; Universal = $true; Dir = ".firebender\skills" },
|
||||
[pscustomobject]@{ Id = "forgecode"; Universal = $false; Dir = ".forge\skills" },
|
||||
[pscustomobject]@{ Id = "gemini-cli"; Universal = $true; Dir = ".gemini\skills" },
|
||||
[pscustomobject]@{ Id = "github-copilot"; Universal = $true; Dir = ".copilot\skills" },
|
||||
[pscustomobject]@{ Id = "goose"; Universal = $false; Dir = ".config\goose\skills" },
|
||||
[pscustomobject]@{ Id = "grok"; Universal = $false; Dir = ".grok\skills" },
|
||||
[pscustomobject]@{ Id = "hermes-agent"; Universal = $false; Dir = ".hermes\skills" },
|
||||
[pscustomobject]@{ Id = "inference-sh"; Universal = $false; Dir = ".inferencesh\skills" },
|
||||
[pscustomobject]@{ Id = "jazz"; Universal = $false; Dir = ".jazz\skills" },
|
||||
[pscustomobject]@{ Id = "junie"; Universal = $false; Dir = ".junie\skills" },
|
||||
[pscustomobject]@{ Id = "iflow-cli"; Universal = $false; Dir = ".iflow\skills" },
|
||||
[pscustomobject]@{ Id = "kilo"; Universal = $false; Dir = ".kilocode\skills" },
|
||||
[pscustomobject]@{ Id = "kimchi"; Universal = $false; Dir = ".config\kimchi\harness\skills" },
|
||||
[pscustomobject]@{ Id = "kimi-code-cli"; Universal = $true; Dir = ".agents\skills" },
|
||||
[pscustomobject]@{ Id = "kiro-cli"; Universal = $false; Dir = ".kiro\skills" },
|
||||
[pscustomobject]@{ Id = "kode"; Universal = $false; Dir = ".kode\skills" },
|
||||
[pscustomobject]@{ Id = "lingma"; Universal = $false; Dir = ".lingma\skills" },
|
||||
[pscustomobject]@{ Id = "loaf"; Universal = $true; Dir = ".agents\skills" },
|
||||
[pscustomobject]@{ Id = "mcpjam"; Universal = $false; Dir = ".mcpjam\skills" },
|
||||
[pscustomobject]@{ Id = "minimax-code"; Universal = $false; Dir = ".minimax\skills" },
|
||||
[pscustomobject]@{ Id = "mistral-vibe"; Universal = $false; Dir = ".vibe\skills" },
|
||||
[pscustomobject]@{ Id = "moxby"; Universal = $false; Dir = ".moxby\skills" },
|
||||
[pscustomobject]@{ Id = "mux"; Universal = $false; Dir = ".mux\skills" },
|
||||
[pscustomobject]@{ Id = "opencode"; Universal = $true; Dir = ".config\opencode\skills" },
|
||||
[pscustomobject]@{ Id = "openhands"; Universal = $false; Dir = ".openhands\skills" },
|
||||
[pscustomobject]@{ Id = "ona"; Universal = $false; Dir = ".ona\skills" },
|
||||
[pscustomobject]@{ Id = "pi"; Universal = $false; Dir = ".pi\agent\skills" },
|
||||
[pscustomobject]@{ Id = "qoder"; Universal = $false; Dir = ".qoder\skills" },
|
||||
[pscustomobject]@{ Id = "qoder-cn"; Universal = $false; Dir = ".qoder-cn\skills" },
|
||||
[pscustomobject]@{ Id = "qwen-code"; Universal = $false; Dir = ".qwen\skills" },
|
||||
[pscustomobject]@{ Id = "replit"; Universal = $true; Dir = ".config\agents\skills" },
|
||||
[pscustomobject]@{ Id = "reasonix"; Universal = $false; Dir = ".reasonix\skills" },
|
||||
[pscustomobject]@{ Id = "rovodev"; Universal = $false; Dir = ".rovodev\skills" },
|
||||
[pscustomobject]@{ Id = "roo"; Universal = $false; Dir = ".roo\skills" },
|
||||
[pscustomobject]@{ Id = "tabnine-cli"; Universal = $false; Dir = ".tabnine\agent\skills" },
|
||||
[pscustomobject]@{ Id = "terramind"; Universal = $false; Dir = ".terramind\skills" },
|
||||
[pscustomobject]@{ Id = "tinycloud"; Universal = $false; Dir = ".tinycloud\skills" },
|
||||
[pscustomobject]@{ Id = "trae"; Universal = $false; Dir = ".trae\skills" },
|
||||
[pscustomobject]@{ Id = "trae-cn"; Universal = $false; Dir = ".trae-cn\skills" },
|
||||
[pscustomobject]@{ Id = "warp"; Universal = $true; Dir = ".agents\skills" },
|
||||
[pscustomobject]@{ Id = "windsurf"; Universal = $false; Dir = ".codeium\windsurf\skills" },
|
||||
[pscustomobject]@{ Id = "zed"; Universal = $true; Dir = ".agents\skills" },
|
||||
[pscustomobject]@{ Id = "zcode"; Universal = $false; Dir = ".zcode\skills" },
|
||||
[pscustomobject]@{ Id = "zencoder"; Universal = $false; Dir = ".zencoder\skills" },
|
||||
[pscustomobject]@{ Id = "zenflow"; Universal = $false; Dir = ".zencoder\skills" },
|
||||
[pscustomobject]@{ Id = "neovate"; Universal = $false; Dir = ".neovate\skills" },
|
||||
[pscustomobject]@{ Id = "pochi"; Universal = $false; Dir = ".pochi\skills" },
|
||||
[pscustomobject]@{ Id = "promptscript"; Universal = $true; Dir = $null },
|
||||
[pscustomobject]@{ Id = "adal"; Universal = $false; Dir = ".adal\skills" },
|
||||
[pscustomobject]@{ Id = "universal"; Universal = $true; Dir = ".config\agents\skills" }
|
||||
)
|
||||
|
||||
# DWS compatibility targets that are not part of the upstream registry.
|
||||
# Qoderwork remains a non-universal install target; the other entries are
|
||||
# migration cleanup targets only and intentionally do not count as agents.
|
||||
$LegacyAgentCleanupTargets = @(
|
||||
[pscustomobject]@{ Id = "dws-qoderwork"; Universal = $false; Dir = ".qoderwork\skills" },
|
||||
[pscustomobject]@{ Id = "dws-legacy-github"; Universal = $true; Dir = ".github\skills" },
|
||||
[pscustomobject]@{ Id = "dws-legacy-amp"; Universal = $true; Dir = ".amp\skills" },
|
||||
[pscustomobject]@{ Id = "dws-legacy-cline"; Universal = $true; Dir = ".cline\skills" },
|
||||
[pscustomobject]@{ Id = "dws-legacy-windsurf"; Universal = $true; Dir = ".windsurf\skills" }
|
||||
)
|
||||
|
||||
# Kept as a compatibility surface for policy tests and downstream packagers.
|
||||
$AgentDirs = @($AgentRegistry | Where-Object { $null -ne $_.Dir } | ForEach-Object { $_.Dir })
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function Write-Say {
|
||||
@@ -182,7 +258,226 @@ function Move-SkillPath {
|
||||
[string]$Source,
|
||||
[string]$Destination
|
||||
)
|
||||
Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop
|
||||
if (Test-SkillPathLexically -Path $Destination) {
|
||||
throw "Skill move destination already exists: $Destination"
|
||||
}
|
||||
$sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop
|
||||
if ($sourceItem.PSIsContainer) {
|
||||
# Move-Item treats an existing directory as a container and nests the
|
||||
# source below it. Directory.Move has the exact rename semantics this
|
||||
# transaction requires: an occupied destination fails without touching
|
||||
# either path.
|
||||
[System.IO.Directory]::Move($Source, $Destination)
|
||||
} else {
|
||||
[System.IO.File]::Move($Source, $Destination)
|
||||
}
|
||||
}
|
||||
|
||||
function Test-CrossDeviceMoveError {
|
||||
param([System.Management.Automation.ErrorRecord]$Record)
|
||||
$exception = $Record.Exception
|
||||
while ($null -ne $exception) {
|
||||
# Win32 ERROR_NOT_SAME_DEVICE is 17 (0x11). Move-Item surfaces it as
|
||||
# the low word of an IOException HRESULT.
|
||||
if (($exception.HResult -band 0xffff) -eq 17) { return $true }
|
||||
$exception = $exception.InnerException
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Copy-SkillPathMetadata {
|
||||
param($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-SkillPathPermissionFingerprint {
|
||||
param([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-SkillPathLexically {
|
||||
param([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-SkillPathLexically -Source $child.FullName -Destination (Join-Path $Destination $child.Name)
|
||||
}
|
||||
Copy-SkillPathMetadata -SourceItem $item -Destination $Destination
|
||||
return
|
||||
}
|
||||
if ($item -isnot [System.IO.FileInfo]) {
|
||||
throw "不支持复制特殊 Skill 路径 $Source"
|
||||
}
|
||||
[System.IO.File]::Copy($Source, $Destination, $false)
|
||||
Copy-SkillPathMetadata -SourceItem $item -Destination $Destination
|
||||
}
|
||||
|
||||
function Assert-SkillPathCopy {
|
||||
param([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 路径类型不一致: $Source != $Destination"
|
||||
}
|
||||
if ($sourceItem.LinkType) {
|
||||
if ($sourceItem.LinkType -ne $destinationItem.LinkType -or
|
||||
($sourceItem.Target -join "`0") -ne ($destinationItem.Target -join "`0")) {
|
||||
throw "Skill 链接目标不一致: $Source != $Destination"
|
||||
}
|
||||
return
|
||||
}
|
||||
if ((Get-SkillPathPermissionFingerprint -Path $Source) -ne
|
||||
(Get-SkillPathPermissionFingerprint -Path $Destination)) {
|
||||
throw "Skill 路径权限不一致: $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 目录项数量不一致: $Source != $Destination"
|
||||
}
|
||||
for ($i = 0; $i -lt $sourceChildren.Count; $i++) {
|
||||
if ($sourceChildren[$i].Name -ne $destinationChildren[$i].Name) {
|
||||
throw "Skill 目录项不一致: $Source != $Destination"
|
||||
}
|
||||
Assert-SkillPathCopy -Source $sourceChildren[$i].FullName -Destination $destinationChildren[$i].FullName
|
||||
}
|
||||
return
|
||||
}
|
||||
if ($sourceItem.Length -ne $destinationItem.Length) {
|
||||
throw "Skill 文件大小不一致: $Source != $Destination"
|
||||
}
|
||||
$sourceHash = (Get-FileHash -LiteralPath $Source -Algorithm SHA256 -ErrorAction Stop).Hash
|
||||
$destinationHash = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256 -ErrorAction Stop).Hash
|
||||
if ($sourceHash -ne $destinationHash) {
|
||||
throw "Skill 文件内容摘要不一致: $Source != $Destination"
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-SkillPathLexically {
|
||||
param([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 unpublished junction/symlink
|
||||
# children. Every link child must be removed non-recursively first: Windows
|
||||
# PowerShell 5.1 (the advertised irm|iex surface) can follow reparse points
|
||||
# during Remove-Item -Recurse and would delete the canonical store's contents
|
||||
# through the staged link. CI runs pwsh 7, which masks this behavior.
|
||||
function Remove-LinkStageRoot {
|
||||
param([string]$StageRoot)
|
||||
if (!(Test-Path -LiteralPath $StageRoot)) { return $true }
|
||||
$ok = $true
|
||||
foreach ($child in @(Get-ChildItem -LiteralPath $StageRoot -Force -ErrorAction SilentlyContinue)) {
|
||||
try { Remove-SkillPathLexically -Path $child.FullName } catch { $ok = $false }
|
||||
}
|
||||
try {
|
||||
Remove-Item -LiteralPath $StageRoot -Force -ErrorAction Stop
|
||||
} catch {
|
||||
$ok = $false
|
||||
}
|
||||
return $ok
|
||||
}
|
||||
|
||||
# Resolves a path to its physical location, dereferencing junction/symlink
|
||||
# reparse points at any depth. Resolve-Path alone is lexical and never
|
||||
# recognizes DWS's own junctions, so every rerun would back them up and
|
||||
# recreate them. Mirrors EvalSymlinks (Go), realpathSync (npm), and cd -P
|
||||
# (shell) on the other installer surfaces.
|
||||
function Get-PhysicalSkillPath {
|
||||
param([string]$Path, [int]$Depth = 0)
|
||||
if ($Depth -gt 40) { return $null }
|
||||
$parent = Split-Path $Path -Parent
|
||||
if ([string]::IsNullOrWhiteSpace($parent)) { return $Path }
|
||||
$parentPhysical = Get-PhysicalSkillPath -Path $parent -Depth ($Depth + 1)
|
||||
if ($null -eq $parentPhysical) { return $null }
|
||||
$leaf = Split-Path $Path -Leaf
|
||||
if ([string]::IsNullOrEmpty($leaf)) { return $parentPhysical }
|
||||
$candidate = Join-Path $parentPhysical $leaf
|
||||
try { $item = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop } catch { return $null }
|
||||
$target = @($item.Target) | Where-Object { $_ } | Select-Object -First 1
|
||||
if ($item.LinkType -and $target) {
|
||||
$targetPath = ""
|
||||
if ([System.IO.Path]::IsPathRooted([string]$target)) {
|
||||
$targetPath = [string]$target
|
||||
} else {
|
||||
$targetPath = Join-Path $parentPhysical ([string]$target)
|
||||
}
|
||||
# Resolve the target recursively as well. A custom HOME or canonical
|
||||
# root can itself sit below another junction, which must compare equal
|
||||
# to the fully physical path just like EvalSymlinks/realpath do.
|
||||
return Get-PhysicalSkillPath -Path ([System.IO.Path]::GetFullPath($targetPath)) -Depth ($Depth + 1)
|
||||
}
|
||||
return $candidate
|
||||
}
|
||||
|
||||
# Same-volume moves remain atomic. For a cross-volume backup/restore, stage on
|
||||
# the destination filesystem, copy links lexically, verify, publish, then
|
||||
# remove the source. Before source removal every failure keeps the original;
|
||||
# a removal failure deliberately leaves both verified copies and fails loud.
|
||||
function Move-SkillPathRecoverably {
|
||||
param([string]$Source, [string]$Destination)
|
||||
if (Test-SkillPathLexically -Path $Destination) { throw "移动目标已存在: $Destination" }
|
||||
$destinationParent = Split-Path $Destination -Parent
|
||||
New-Item -ItemType Directory -Path $destinationParent -Force -ErrorAction Stop | Out-Null
|
||||
try {
|
||||
Move-SkillPath -Source $Source -Destination $Destination
|
||||
return
|
||||
} catch {
|
||||
if (!(Test-CrossDeviceMoveError -Record $_)) { 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
|
||||
try {
|
||||
Copy-SkillPathLexically -Source $Source -Destination $stage
|
||||
Assert-SkillPathCopy -Source $Source -Destination $stage
|
||||
Move-SkillPath -Source $stage -Destination $Destination
|
||||
Assert-SkillPathCopy -Source $Source -Destination $Destination
|
||||
if (!(Remove-LinkStageRoot -StageRoot $stageRoot)) {
|
||||
throw "Skill staging 清理失败(已发布内容不受影响): $stageRoot"
|
||||
}
|
||||
try {
|
||||
Remove-SkillPathLexically -Path $Source
|
||||
} catch {
|
||||
throw "Skill 目标已发布但源路径删除失败(源 $Source 与目标 $Destination 均保留): $_"
|
||||
}
|
||||
if (Test-SkillPathLexically -Path $Source) {
|
||||
throw "Skill 目标已发布但源路径仍存在(源 $Source 与目标 $Destination 均保留)"
|
||||
}
|
||||
} catch {
|
||||
$failure = $_
|
||||
if (Test-Path -LiteralPath $stageRoot) {
|
||||
if (!(Remove-LinkStageRoot -StageRoot $stageRoot)) {
|
||||
throw "$failure;跨设备 Skill staging 清理失败 $stageRoot(备份与原路径均保留)"
|
||||
}
|
||||
}
|
||||
throw $failure
|
||||
}
|
||||
}
|
||||
|
||||
function Get-Arch {
|
||||
@@ -264,6 +559,26 @@ function Get-GiteeAssetUrl {
|
||||
return ""
|
||||
}
|
||||
|
||||
function Assert-ReleaseAssetChecksum {
|
||||
param([string]$AssetPath, [string]$AssetName, [string]$TempDir)
|
||||
if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" }
|
||||
if (-not $checksumUrl) { Write-Err "Could not resolve checksums.txt for $Version." }
|
||||
$checksumPath = Join-Path $TempDir "checksums.txt"
|
||||
try {
|
||||
Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Err "Could not download checksums.txt for $Version; refusing unverified $AssetName."
|
||||
}
|
||||
$expectedLine = Get-Content -LiteralPath $checksumPath | Where-Object {
|
||||
$_ -match "^[0-9A-Fa-f]{64}[ ]+[*]?$([regex]::Escape($AssetName))$"
|
||||
} | Select-Object -First 1
|
||||
if (-not $expectedLine) { Write-Err "$AssetName is missing from checksums.txt." }
|
||||
$expected = ($expectedLine -split '\s+')[0].ToLowerInvariant()
|
||||
$actual = (Get-FileHash -LiteralPath $AssetPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
|
||||
if ($actual -ne $expected) { Write-Err "SHA256 checksum mismatch for $AssetName. Expected $expected, got $actual." }
|
||||
Write-Say "✅ SHA256 checksum verified: $AssetName"
|
||||
}
|
||||
|
||||
function Resolve-Source {
|
||||
# Explicit DWS_GITEE_REPO wins; else probe GitHub and fall back to Gitee when unreachable.
|
||||
if ($GiteeRepo -ne "") { return }
|
||||
@@ -389,6 +704,61 @@ function Publish-SkillCache {
|
||||
}
|
||||
}
|
||||
|
||||
# Get-SkillBackupName encodes the HOME-relative path of a backed-up Skill
|
||||
# directory ('.codex\skills\dingtalk-chat' → '.codex-skills-dingtalk-chat') so
|
||||
# copies retired from different Agent roots stay distinguishable inside one
|
||||
# stamp. Mirrors build/npm/install.js and internal/upgrade/paths.go. Paths
|
||||
# outside HOME fall back to the bare leaf.
|
||||
function Get-SkillBackupName {
|
||||
param([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
|
||||
}
|
||||
|
||||
# SkillBackupKeep bounds $HOME\.dws\skill-backups growth: only the newest
|
||||
# stamped backup directories are kept, matching skillBackupKeep and
|
||||
# pruneSkillBackups in internal/upgrade/paths.go.
|
||||
$SkillBackupKeep = 5
|
||||
$script:SkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
|
||||
# Remove-OldSkillBackups deletes the oldest stamped backup directories once
|
||||
# more than $SkillBackupKeep remain. Stamps sort lexicographically in
|
||||
# chronological order, so name order is age order. Roots created during this
|
||||
# run are never pruned: an in-flight transaction still needs them to roll
|
||||
# back. Best-effort — a prune failure never fails the install.
|
||||
function Remove-OldSkillBackups {
|
||||
$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) are candidates; any
|
||||
# other entry is foreign data and is preserved.
|
||||
$dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' } |
|
||||
Sort-Object -Property Name)
|
||||
$excess = $dirs.Count - $SkillBackupKeep
|
||||
foreach ($dir in $dirs) {
|
||||
if ($excess -le 0) { break }
|
||||
if ($script:SkillBackupRootsThisRun.Contains($dir.FullName)) { continue }
|
||||
Remove-Item -LiteralPath $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
||||
$excess--
|
||||
}
|
||||
}
|
||||
|
||||
# Backup-SkillDir moves $Dir into $HOME\.dws\skill-backups\<stamp>\<name>
|
||||
# instead of destroying it (non-interactive installs cannot confirm, so
|
||||
# removals must stay reversible). Missing paths are a no-op success. On any
|
||||
@@ -400,14 +770,16 @@ function Backup-SkillDir {
|
||||
[ref]$BackupPath
|
||||
)
|
||||
if ($null -ne $BackupPath) { $BackupPath.Value = "" }
|
||||
if (!(Test-Path $Dir -PathType Container)) { return $true }
|
||||
if (!(Test-SkillPathLexically -Path $Dir)) { return $true }
|
||||
$backupRoot = Join-Path $HOME ".dws\skill-backups"
|
||||
$stamp = [DateTime]::UtcNow.ToString("yyyyMMdd-HHmmss")
|
||||
$name = Split-Path $Dir -Leaf
|
||||
$target = Join-Path (Join-Path $backupRoot $stamp) $name
|
||||
$name = Get-SkillBackupName -Dir $Dir
|
||||
$targetRoot = Join-Path $backupRoot $stamp
|
||||
$target = Join-Path $targetRoot $name
|
||||
$i = 1
|
||||
while (Test-Path $target) {
|
||||
$target = Join-Path (Join-Path $backupRoot "$stamp-$i") $name
|
||||
while (Test-SkillPathLexically -Path $target) {
|
||||
$targetRoot = Join-Path $backupRoot "$stamp-$i"
|
||||
$target = Join-Path $targetRoot $name
|
||||
$i++
|
||||
if ($i -gt 1000) {
|
||||
Write-Say "⚠️ 备份目录冲突,保留原目录 $Dir"
|
||||
@@ -416,16 +788,84 @@ function Backup-SkillDir {
|
||||
}
|
||||
try {
|
||||
New-Item -ItemType Directory -Path (Split-Path $target -Parent) -Force -ErrorAction Stop | Out-Null
|
||||
Move-SkillPath -Source $Dir -Destination $target
|
||||
Move-SkillPathRecoverably -Source $Dir -Destination $target
|
||||
} catch {
|
||||
Write-Say "⚠️ 备份失败,保留原目录 $Dir"
|
||||
Write-Say "⚠️ 备份失败,保留原目录 $Dir`: $_"
|
||||
return $false
|
||||
}
|
||||
if ($null -ne $BackupPath) { $BackupPath.Value = $target }
|
||||
try {
|
||||
$script:SkillBackupRootsThisRun.Add([System.IO.Path]::GetFullPath($targetRoot)) | Out-Null
|
||||
Remove-OldSkillBackups
|
||||
} catch {
|
||||
Write-Say "⚠️ 旧备份清理失败(备份本身已成功): $_"
|
||||
}
|
||||
Write-Say " × 已备份并移除 $Dir → $target"
|
||||
return $true
|
||||
}
|
||||
|
||||
function Get-SkillLinkSignature {
|
||||
param([string]$Path)
|
||||
try {
|
||||
$item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
|
||||
} catch {
|
||||
return $null
|
||||
}
|
||||
if (!$item.LinkType) { return $null }
|
||||
$targets = @($item.Target) | ForEach-Object { [string]$_ }
|
||||
return ([string]$item.LinkType + "`0" + ($targets -join "`0"))
|
||||
}
|
||||
|
||||
function New-PublishedSkillLinkRecord {
|
||||
param([string]$Path)
|
||||
$signature = Get-SkillLinkSignature -Path $Path
|
||||
if ([string]::IsNullOrEmpty($signature)) {
|
||||
throw "published Skill path is not a link: $Path"
|
||||
}
|
||||
return [pscustomobject]@{ Path = $Path; LinkSignature = $signature }
|
||||
}
|
||||
|
||||
function New-PublishedSkillCopyRecord {
|
||||
param([string]$Path, [string]$Source)
|
||||
Assert-SkillPathCopy -Source $Source -Destination $Path
|
||||
return [pscustomobject]@{ Path = $Path; Source = $Source }
|
||||
}
|
||||
|
||||
function Remove-PublishedSkillPathSafely {
|
||||
param($Record)
|
||||
$path = [string]$Record.Path
|
||||
$parent = Split-Path $path -Parent
|
||||
$quarantine = Join-Path $parent (".dws-link-rollback-" + [guid]::NewGuid().ToString("N"))
|
||||
|
||||
# Claim the current directory entry with an exact same-filesystem rename.
|
||||
# If another process replaced our link, verification below fails and that
|
||||
# object is moved back instead of ever being recursively removed.
|
||||
Move-SkillPath -Source $path -Destination $quarantine
|
||||
try {
|
||||
if ($Record.LinkSignature) {
|
||||
if ((Get-SkillLinkSignature -Path $quarantine) -ne [string]$Record.LinkSignature) {
|
||||
throw "发布链接已被其他进程替换,拒绝删除"
|
||||
}
|
||||
} elseif ($Record.Source) {
|
||||
Assert-SkillPathCopy -Source ([string]$Record.Source) -Destination $quarantine
|
||||
} else {
|
||||
throw "发布路径缺少事务身份,拒绝删除"
|
||||
}
|
||||
Remove-SkillPathLexically -Path $quarantine
|
||||
} catch {
|
||||
$failure = $_
|
||||
if (Test-SkillPathLexically -Path $quarantine) {
|
||||
try {
|
||||
if (Test-SkillPathLexically -Path $path) { throw "原路径已被占用" }
|
||||
Move-SkillPath -Source $quarantine -Destination $path
|
||||
} catch {
|
||||
throw "$failure;并发对象保留于 $quarantine`: $_"
|
||||
}
|
||||
}
|
||||
throw $failure
|
||||
}
|
||||
}
|
||||
|
||||
function Restore-MultiSkillSet {
|
||||
param(
|
||||
[array]$Published,
|
||||
@@ -433,23 +873,29 @@ function Restore-MultiSkillSet {
|
||||
)
|
||||
$ok = $true
|
||||
for ($i = $Published.Count - 1; $i -ge 0; $i--) {
|
||||
$publishedItem = $Published[$i]
|
||||
$publishedPath = if ($publishedItem -is [string]) { $publishedItem } else { [string]$publishedItem.Path }
|
||||
try {
|
||||
if (Test-Path $Published[$i]) {
|
||||
Remove-Item -LiteralPath $Published[$i] -Recurse -Force -ErrorAction Stop
|
||||
if (Test-SkillPathLexically -Path $publishedPath) {
|
||||
if (!($publishedItem -is [string])) {
|
||||
Remove-PublishedSkillPathSafely -Record $publishedItem
|
||||
} else {
|
||||
Remove-SkillPathLexically -Path $publishedPath
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Say "⚠️ 无法移除失败发布目录 $($Published[$i]): $_"
|
||||
Write-Say "⚠️ 无法移除失败发布目录 $publishedPath`: $_"
|
||||
$ok = $false
|
||||
}
|
||||
}
|
||||
for ($i = $Backups.Count - 1; $i -ge 0; $i--) {
|
||||
$item = $Backups[$i]
|
||||
try {
|
||||
if (Test-Path $item.Original) {
|
||||
if (Test-SkillPathLexically -Path $item.Original) {
|
||||
throw "恢复目标仍存在"
|
||||
}
|
||||
New-Item -ItemType Directory -Path (Split-Path $item.Original -Parent) -Force -ErrorAction Stop | Out-Null
|
||||
Move-SkillPath -Source $item.Backup -Destination $item.Original
|
||||
Move-SkillPathRecoverably -Source $item.Backup -Destination $item.Original
|
||||
} catch {
|
||||
Write-Say "⚠️ 无法恢复原 Skill $($item.Original);备份保留于 $($item.Backup): $_"
|
||||
$ok = $false
|
||||
@@ -458,13 +904,81 @@ function Restore-MultiSkillSet {
|
||||
return $ok
|
||||
}
|
||||
|
||||
function Move-GenericSkillRootToBackup {
|
||||
param([string]$Root)
|
||||
function Test-SkillPathLexically {
|
||||
param([string]$Path)
|
||||
if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
|
||||
try {
|
||||
Get-Item -LiteralPath $Path -Force -ErrorAction Stop | Out-Null
|
||||
return $true
|
||||
} catch {
|
||||
$parent = Split-Path $Path -Parent
|
||||
$leaf = Split-Path $Path -Leaf
|
||||
if ([string]::IsNullOrWhiteSpace($parent) -or !(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 Resolve-AgentSkillBase {
|
||||
param([string]$Root, $Agent)
|
||||
if ($null -eq $Agent.Dir) { return $null }
|
||||
switch ($Agent.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 ($name in @(".openclaw", ".clawdbot", ".moltbot")) {
|
||||
$candidate = Join-Path $Root $name
|
||||
if (Test-Path $candidate -PathType Container) { return (Join-Path $candidate "skills") }
|
||||
}
|
||||
}
|
||||
{ $_ -in @("amp", "replit", "universal") } {
|
||||
$configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" }
|
||||
return (Join-Path $configHome "agents\skills")
|
||||
}
|
||||
{ $_ -in @("crush", "devin", "goose", "opencode") } {
|
||||
$configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" }
|
||||
$child = switch ($Agent.Id) { "crush" { "crush\skills" }; "devin" { "devin\skills" }; "goose" { "goose\skills" }; default { "opencode\skills" } }
|
||||
return (Join-Path $configHome $child)
|
||||
}
|
||||
"kimchi" {
|
||||
$configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" }
|
||||
return (Join-Path $configHome "kimchi\harness\skills")
|
||||
}
|
||||
}
|
||||
return (Join-Path $Root $Agent.Dir)
|
||||
}
|
||||
|
||||
function Test-AgentSkillBaseDetected {
|
||||
param([string]$BaseDir, $Agent)
|
||||
$parent = Split-Path $BaseDir -Parent
|
||||
switch ($Agent.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-SamePhysicalSkillRoot {
|
||||
param([string]$Left, [string]$Right)
|
||||
if (!(Test-Path $Left) -or !(Test-Path $Right)) { return $false }
|
||||
$leftPhysical = Get-PhysicalSkillPath -Path $Left
|
||||
$rightPhysical = Get-PhysicalSkillPath -Path $Right
|
||||
if ($null -eq $leftPhysical -or $null -eq $rightPhysical) { return $false }
|
||||
return $leftPhysical.TrimEnd([char[]]@('\', '/')) -ieq $rightPhysical.TrimEnd([char[]]@('\', '/'))
|
||||
}
|
||||
|
||||
function Move-AgentSkillRootToBackup {
|
||||
param([string]$Root, [string]$BaseDir)
|
||||
|
||||
$baseDir = Join-Path $Root ".agents\skills"
|
||||
$victims = [System.Collections.Generic.List[string]]::new()
|
||||
$victims.Add((Join-Path $baseDir $SkillName))
|
||||
foreach ($existing in Get-ChildItem -Path $baseDir -Directory -ErrorAction SilentlyContinue) {
|
||||
foreach ($existing in Get-ChildItem -Path $baseDir -Force -ErrorAction SilentlyContinue) {
|
||||
if (Test-ManagedMultiSkillDir -Dir $existing.FullName) {
|
||||
$victims.Add($existing.FullName)
|
||||
}
|
||||
@@ -476,7 +990,7 @@ function Move-GenericSkillRootToBackup {
|
||||
if (!$seen.Add($victim)) { continue }
|
||||
$backupPath = ""
|
||||
if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) {
|
||||
throw "通用 Skill 副本备份失败: $victim"
|
||||
throw "Agent Skill 旧副本备份失败: $victim"
|
||||
}
|
||||
if ($backupPath) {
|
||||
$backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath }
|
||||
@@ -485,11 +999,81 @@ function Move-GenericSkillRootToBackup {
|
||||
return $true
|
||||
} catch {
|
||||
Restore-MultiSkillSet -Published @() -Backups $backups | Out-Null
|
||||
Write-Say "⚠️ 通用 Skill 副本迁移失败,已回滚: $_"
|
||||
Write-Say "⚠️ Agent Skill 旧副本迁移失败,已回滚: $_"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Publish-CanonicalSkillLinks {
|
||||
param([string]$Root, [string]$BaseDir, [string]$Mode, [string[]]$BundleNames = @())
|
||||
$canonical = Join-Path $Root ".agents\skills"
|
||||
if (!(Test-Path $BaseDir)) { New-Item -ItemType Directory -Path $BaseDir -Force | Out-Null }
|
||||
if (Test-SamePhysicalSkillRoot -Left $BaseDir -Right $canonical) { return $true }
|
||||
$stageRoot = Join-Path $BaseDir (".dws-link-set-" + [guid]::NewGuid().ToString("N"))
|
||||
$backups = @()
|
||||
$published = @()
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $stageRoot -Force -ErrorAction Stop | Out-Null
|
||||
if ($Mode -eq "mono") {
|
||||
$names = @($SkillName)
|
||||
} elseif ($BundleNames.Count -gt 0) {
|
||||
# The canonical store is SHARED: enumerate the link set from the
|
||||
# installed bundle, never from ~\.agents\skills, or third-party and
|
||||
# user skills would be republished into every Agent root.
|
||||
$names = @($BundleNames)
|
||||
} else {
|
||||
# No bundle list means we cannot tell DWS skills apart from the
|
||||
# user's own entries in the shared store. Fail this Agent (the
|
||||
# caller degrades to a copy install) instead of guessing, matching
|
||||
# link_canonical_skills_to_base in scripts/install-skills.sh.
|
||||
throw "multi mode requires the installed bundle skill names"
|
||||
}
|
||||
$publishNames = @()
|
||||
foreach ($name in $names) {
|
||||
if (Test-SamePhysicalSkillRoot -Left (Join-Path $BaseDir $name) -Right (Join-Path $canonical $name)) { continue }
|
||||
$absoluteTarget = [System.IO.Path]::GetFullPath((Join-Path $canonical $name))
|
||||
New-Item -ItemType Junction -Path (Join-Path $stageRoot $name) -Target $absoluteTarget -ErrorAction Stop | Out-Null
|
||||
$publishNames += $name
|
||||
}
|
||||
$victims = [System.Collections.Generic.List[string]]::new()
|
||||
$victims.Add((Join-Path $BaseDir $SkillName))
|
||||
foreach ($existing in Get-ChildItem -Path $BaseDir -Force -ErrorAction SilentlyContinue) {
|
||||
if ($existing.FullName -eq $stageRoot) { continue }
|
||||
if (Test-ManagedMultiSkillDir -Dir $existing.FullName) { $victims.Add($existing.FullName) }
|
||||
}
|
||||
# Every published name replaces whatever occupies its destination, even
|
||||
# when that copy predates central ownership metadata; the loop below
|
||||
# still skips destinations that are already correct links.
|
||||
foreach ($name in $names) {
|
||||
$victims.Add((Join-Path $BaseDir $name))
|
||||
}
|
||||
$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($victim in $victims) {
|
||||
if (!$seen.Add($victim)) { continue }
|
||||
if (Test-SamePhysicalSkillRoot -Left $victim -Right (Join-Path $canonical (Split-Path $victim -Leaf))) { continue }
|
||||
$backupPath = ""
|
||||
if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) { throw "Skill 备份失败: $victim" }
|
||||
if ($backupPath) { $backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath } }
|
||||
}
|
||||
foreach ($name in $publishNames) {
|
||||
$dest = Join-Path $BaseDir $name
|
||||
Move-SkillPath -Source (Join-Path $stageRoot $name) -Destination $dest
|
||||
# Only record a publication after the staged junction has occupied
|
||||
# the exact destination. Rollback also checks this identity before
|
||||
# deleting, so a concurrent user directory is never removed.
|
||||
$published += New-PublishedSkillLinkRecord -Path $dest
|
||||
Write-Say "↪ Skills → $dest"
|
||||
}
|
||||
return $true
|
||||
} catch {
|
||||
Restore-MultiSkillSet -Published $published -Backups $backups | Out-Null
|
||||
Write-Say "⚠️ Skill 链接发布失败,已回滚: $BaseDir ($_)"
|
||||
return $false
|
||||
} finally {
|
||||
Remove-LinkStageRoot -StageRoot $stageRoot | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
function Copy-SkillToDir {
|
||||
param([string]$SkillSrc, [string]$Dest, [string]$Label)
|
||||
|
||||
@@ -616,26 +1200,7 @@ function Install-Binary {
|
||||
$archivePath = Join-Path $tmpDir $archiveName
|
||||
Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath -UseBasicParsing
|
||||
|
||||
# Download and verify SHA256 checksum
|
||||
if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" }
|
||||
try {
|
||||
$checksumPath = Join-Path $tmpDir "checksums.txt"
|
||||
Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing
|
||||
$checksumContent = Get-Content $checksumPath
|
||||
$expectedLine = $checksumContent | Where-Object { $_ -match [regex]::Escape($archiveName) }
|
||||
if ($expectedLine) {
|
||||
$expected = ($expectedLine -split '\s+')[0]
|
||||
$actual = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToLower()
|
||||
if ($actual -ne $expected.ToLower()) {
|
||||
Write-Err "SHA256 checksum mismatch! Expected $expected, got $actual. Aborting."
|
||||
}
|
||||
Write-Say "✅ SHA256 checksum verified"
|
||||
} else {
|
||||
Write-Say "⚠️ Archive not found in checksums.txt; skipping verification"
|
||||
}
|
||||
} catch {
|
||||
Write-Say "⚠️ Could not download checksums.txt; skipping verification"
|
||||
}
|
||||
Assert-ReleaseAssetChecksum -AssetPath $archivePath -AssetName $archiveName -TempDir $tmpDir
|
||||
|
||||
Write-Say "📦 Extracting..."
|
||||
Expand-Archive -Path $archivePath -DestinationPath $tmpDir -Force
|
||||
@@ -784,8 +1349,8 @@ function Install-MonoToBase {
|
||||
}
|
||||
}
|
||||
|
||||
$published += $dest
|
||||
Move-SkillPath -Source $stagedSkill -Destination $dest
|
||||
$published += New-PublishedSkillCopyRecord -Path $dest -Source $SkillSrc
|
||||
} catch {
|
||||
$transactionError = $_
|
||||
if (!(Restore-MultiSkillSet -Published $published -Backups $backups)) {
|
||||
@@ -811,46 +1376,37 @@ function Install-SkillsToHomes {
|
||||
)
|
||||
|
||||
$installed = 0
|
||||
$attempted = 0
|
||||
$attempted = 1
|
||||
$failed = 0
|
||||
$specificAgents = @($AgentDirs | Select-Object -Skip 1 | Where-Object {
|
||||
Test-Path (Split-Path (Join-Path $Root $_) -Parent)
|
||||
})
|
||||
for ($i = 0; $i -lt $AgentDirs.Count; $i++) {
|
||||
if ($i -eq 0 -and $specificAgents.Count -gt 0) { continue }
|
||||
$agentDir = $AgentDirs[$i]
|
||||
$baseDir = Join-Path $Root $agentDir
|
||||
$parentGate = Split-Path $baseDir -Parent
|
||||
if ($i -gt 0 -and !(Test-Path $parentGate)) {
|
||||
$canonical = Join-Path $Root ".agents\skills"
|
||||
if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $canonical -Label "~\.agents\skills\$SkillName") { $installed++ } else { $failed++ }
|
||||
if ($installed -eq 0) { return $false }
|
||||
$seenBases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($agent in @($AgentRegistry) + @($LegacyAgentCleanupTargets)) {
|
||||
$baseDir = Resolve-AgentSkillBase -Root $Root -Agent $agent
|
||||
if ([string]::IsNullOrWhiteSpace($baseDir)) { continue }
|
||||
$baseKey = [System.IO.Path]::GetFullPath($baseDir).TrimEnd([char[]]@('\', '/'))
|
||||
if (!$seenBases.Add($baseKey)) { continue }
|
||||
if (!(Test-AgentSkillBaseDetected -BaseDir $baseDir -Agent $agent)) { continue }
|
||||
if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue }
|
||||
$attempted++
|
||||
if ($agent.Universal) {
|
||||
# Cleanup-only migration: universal Agents read the canonical store
|
||||
# directly, so nothing is installed here. Leaving an obsolete copy
|
||||
# behind is a warning, never a reason to fail an install whose
|
||||
# canonical store and links all succeeded.
|
||||
if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) {
|
||||
Write-Say "⚠️ Agent Skill 旧副本迁移失败(不影响本次安装): $baseDir"
|
||||
}
|
||||
continue
|
||||
}
|
||||
$attempted++
|
||||
if ($Root -eq $HOME) {
|
||||
$label = "~\$agentDir\$SkillName"
|
||||
} else {
|
||||
$label = Join-Path $Root (Join-Path $agentDir $SkillName)
|
||||
}
|
||||
$copied = Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label $label
|
||||
if ($copied) {
|
||||
if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "mono") {
|
||||
$installed++
|
||||
} else {
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
if ($specificAgents.Count -gt 0 -and $installed -gt 0) {
|
||||
if (!(Move-GenericSkillRootToBackup -Root $Root)) { $failed++ }
|
||||
}
|
||||
if ($attempted -eq 0) {
|
||||
$fallback = Join-Path (Join-Path $Root ".agents\skills") $SkillName
|
||||
if ($Root -eq $HOME) {
|
||||
$flabel = "~\.agents\skills\$SkillName"
|
||||
} else {
|
||||
$flabel = Join-Path $Root (Join-Path ".agents\skills" $SkillName)
|
||||
}
|
||||
if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir (Split-Path $fallback -Parent) -Label $flabel) {
|
||||
$installed++
|
||||
} else {
|
||||
$failed++
|
||||
if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label (Join-Path $baseDir $SkillName)) {
|
||||
Write-Say "ℹ️ $baseDir 已自动使用兼容方式安装,可正常使用"
|
||||
$installed++
|
||||
} else { $failed++ }
|
||||
}
|
||||
}
|
||||
if ($installed -eq 0) {
|
||||
@@ -862,6 +1418,10 @@ function Install-SkillsToHomes {
|
||||
return $false
|
||||
}
|
||||
Remove-Item -LiteralPath (Join-Path $SkillStateRoot "skills-state.json") -Force -ErrorAction SilentlyContinue
|
||||
Write-Say "✅ DWS Skills 安装完成"
|
||||
Write-Say " 统一安装位置:$canonical"
|
||||
Write-Say " 已自动适配本机上检测到的 Agent"
|
||||
Write-Say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效"
|
||||
return $true
|
||||
}
|
||||
|
||||
@@ -889,35 +1449,40 @@ function Install-MultiSkillsToHomes {
|
||||
)
|
||||
|
||||
$installed = 0
|
||||
$attempted = 0
|
||||
$attempted = 1
|
||||
$failed = 0
|
||||
$specificAgents = @($AgentDirs | Select-Object -Skip 1 | Where-Object {
|
||||
Test-Path (Split-Path (Join-Path $Root $_) -Parent)
|
||||
})
|
||||
for ($i = 0; $i -lt $AgentDirs.Count; $i++) {
|
||||
if ($i -eq 0 -and $specificAgents.Count -gt 0) { continue }
|
||||
$agentDir = $AgentDirs[$i]
|
||||
$baseDir = Join-Path $Root $agentDir
|
||||
$parentGate = Split-Path $baseDir -Parent
|
||||
if ($i -gt 0 -and !(Test-Path $parentGate)) {
|
||||
$canonical = Join-Path $Root ".agents\skills"
|
||||
# The link set must come from the installed bundle, not from the shared
|
||||
# canonical store (which may also hold user/third-party skills).
|
||||
$bundleNames = @(Get-ChildItem -Path $MultiSrc -Directory -ErrorAction SilentlyContinue | Where-Object {
|
||||
Test-Path (Join-Path $_.FullName "SKILL.md")
|
||||
} | ForEach-Object { $_.Name })
|
||||
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $canonical -Root $Root -AgentDir ".agents\skills") { $installed++ } else { $failed++ }
|
||||
if ($installed -eq 0) { return $false }
|
||||
$seenBases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($agent in @($AgentRegistry) + @($LegacyAgentCleanupTargets)) {
|
||||
$baseDir = Resolve-AgentSkillBase -Root $Root -Agent $agent
|
||||
if ([string]::IsNullOrWhiteSpace($baseDir)) { continue }
|
||||
$baseKey = [System.IO.Path]::GetFullPath($baseDir).TrimEnd([char[]]@('\', '/'))
|
||||
if (!$seenBases.Add($baseKey)) { continue }
|
||||
if (!(Test-AgentSkillBaseDetected -BaseDir $baseDir -Agent $agent)) { continue }
|
||||
if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue }
|
||||
$attempted++
|
||||
if ($agent.Universal) {
|
||||
# Cleanup-only migration (see Install-SkillsToHomes): a retire
|
||||
# failure must not fail an otherwise complete install.
|
||||
if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) {
|
||||
Write-Say "⚠️ Agent Skill 旧副本迁移失败(不影响本次安装): $baseDir"
|
||||
}
|
||||
continue
|
||||
}
|
||||
$attempted++
|
||||
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agentDir) {
|
||||
if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "multi" -BundleNames $bundleNames) {
|
||||
$installed++
|
||||
} else {
|
||||
Write-Say "⚠️ 跳过 $baseDir(备份失败,未安装 multi)"
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
if ($specificAgents.Count -gt 0 -and $installed -gt 0) {
|
||||
if (!(Move-GenericSkillRootToBackup -Root $Root)) { $failed++ }
|
||||
}
|
||||
if ($attempted -eq 0) {
|
||||
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir (Join-Path $Root ".agents\skills") -Root $Root -AgentDir ".agents\skills") {
|
||||
$installed++
|
||||
} else {
|
||||
$failed++
|
||||
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agent.Dir) {
|
||||
Write-Say "ℹ️ $baseDir 已自动使用兼容方式安装,可正常使用"
|
||||
$installed++
|
||||
} else { $failed++ }
|
||||
}
|
||||
}
|
||||
if ($installed -eq 0) {
|
||||
@@ -929,6 +1494,10 @@ function Install-MultiSkillsToHomes {
|
||||
return $false
|
||||
}
|
||||
Write-SkillsState -MultiSrc $MultiSrc
|
||||
Write-Say "✅ DWS Skills 安装完成"
|
||||
Write-Say " 统一安装位置:$canonical"
|
||||
Write-Say " 已自动适配本机上检测到的 Agent"
|
||||
Write-Say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效"
|
||||
return $true
|
||||
}
|
||||
|
||||
@@ -990,8 +1559,8 @@ function Install-MultiToBase {
|
||||
|
||||
foreach ($skillDir in $skillDirs) {
|
||||
$dest = Join-Path $BaseDir $skillDir.Name
|
||||
$published += $dest
|
||||
Move-SkillPath -Source (Join-Path $stageRoot $skillDir.Name) -Destination $dest
|
||||
$published += New-PublishedSkillCopyRecord -Path $dest -Source $skillDir.FullName
|
||||
}
|
||||
} catch {
|
||||
$transactionError = $_
|
||||
@@ -1072,6 +1641,7 @@ function Install-Skills {
|
||||
Write-Err "Cannot download skills from GitHub and no local source checkout found."
|
||||
}
|
||||
}
|
||||
Assert-ReleaseAssetChecksum -AssetPath $zipPath -AssetName "dws-skills.zip" -TempDir $tmpDir
|
||||
|
||||
$extractRoot = Join-Path $tmpDir "skills"
|
||||
Expand-Archive -Path $zipPath -DestinationPath $extractRoot -Force
|
||||
|
||||
+385
-153
@@ -71,13 +71,13 @@ 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_i=1
|
||||
while [ -e "$_bed_target" ]; do
|
||||
while [ -e "$_bed_target" ] || [ -L "$_bed_target" ]; do
|
||||
_bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name"
|
||||
_bed_i=$((_bed_i + 1))
|
||||
if [ "$_bed_i" -gt 1000 ]; then
|
||||
@@ -98,6 +98,54 @@ backup_and_remove_skill_dir() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped backup
|
||||
# directories under $HOME/.dws/skill-backups, matching Go's skillBackupKeep /
|
||||
# pruneSkillBackups. Best-effort: a removal failure is reported, never fatal.
|
||||
# Only safe to call once every backup manifest of this run has been consumed,
|
||||
# because a single transaction can span more than one timestamp.
|
||||
DWS_SKILL_BACKUP_KEEP=5
|
||||
|
||||
# is_skill_backup_stamp accepts 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 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
|
||||
_psb_total=$((_psb_total + 1))
|
||||
done
|
||||
[ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0
|
||||
_psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP))
|
||||
_psb_seen=0
|
||||
for _psb_entry in "$_psb_root"/*; do
|
||||
[ -d "$_psb_entry" ] || continue
|
||||
is_skill_backup_stamp "${_psb_entry##*/}" || continue
|
||||
_psb_seen=$((_psb_seen + 1))
|
||||
[ "$_psb_seen" -le "$_psb_drop" ] || break
|
||||
rm -rf "$_psb_entry" || say " ⚠️ 旧 Skill 备份清理失败: $_psb_entry"
|
||||
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() {
|
||||
@@ -141,6 +189,23 @@ sha256_stdin() {
|
||||
fi
|
||||
}
|
||||
|
||||
verify_release_asset_checksum() {
|
||||
_checksum_asset="$1"
|
||||
_checksum_path="$2"
|
||||
_checksum_dir="$3"
|
||||
_checksum_url="$(asset_url checksums.txt)"
|
||||
[ -n "$_checksum_url" ] || err "Could not resolve checksums.txt for ${VERSION}."
|
||||
download "$_checksum_url" "$_checksum_dir/checksums.txt" 2>/dev/null || \
|
||||
err "Could not download checksums.txt for ${VERSION}; refusing unverified ${_checksum_asset}."
|
||||
_checksum_expected="$(awk -v file="$_checksum_asset" '$2 == file {print $1; exit}' "$_checksum_dir/checksums.txt")"
|
||||
[ -n "$_checksum_expected" ] || err "${_checksum_asset} is missing from checksums.txt."
|
||||
_checksum_actual="$(sha256_stdin < "$_checksum_path")" || \
|
||||
err "Could not compute SHA256 for ${_checksum_asset}; install sha256sum, shasum, or openssl."
|
||||
[ "$_checksum_actual" = "$_checksum_expected" ] || \
|
||||
err "SHA256 checksum mismatch for ${_checksum_asset}. Expected ${_checksum_expected}, got ${_checksum_actual}."
|
||||
say "✅ SHA256 checksum verified: ${_checksum_asset}"
|
||||
}
|
||||
|
||||
digest_skill_dir() {
|
||||
_digest_dir="$1"
|
||||
_digest="$({
|
||||
@@ -231,7 +296,7 @@ restore_multi_skill_set() {
|
||||
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 [ -e "$_rms_original" ] || [ -L "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then
|
||||
say " ⚠️ 无法恢复原 Skill: $_rms_original(备份保留于 $_rms_backup)"
|
||||
_rms_ok=0
|
||||
fi
|
||||
@@ -240,6 +305,50 @@ 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}'
|
||||
}
|
||||
|
||||
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
|
||||
if skill_link_matches "$_rls_dest" "$_rls_target" "$_rls_inode"; then
|
||||
rm -f "$_rls_dest" || _rls_ok=0
|
||||
else
|
||||
say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_rls_dest"
|
||||
_rls_ok=0
|
||||
fi
|
||||
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_cache <source> <cache-dir>
|
||||
# Copies a complete cache into a sibling staging directory, then publishes it
|
||||
# with rename. Copy/publish failures retain the previous cache; if restoration
|
||||
@@ -643,17 +752,100 @@ _install_mono_to_base() {
|
||||
say "✅ Skills → ${_mono_label} (${_mono_count} files)"
|
||||
}
|
||||
|
||||
# Move DWS-owned copies out of the generic root once a concrete Agent root is
|
||||
# active. This prevents Agents such as Codex from discovering the same Skill
|
||||
# through both ~/.agents/skills and ~/.codex/skills.
|
||||
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 from vercel-labs/skills, in pinned registry order and
|
||||
# de-duplicated (amp/replit/universal and zencoder/zenflow share a root).
|
||||
# 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
|
||||
@@ -666,6 +858,130 @@ 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
|
||||
say " ⚠️ $_lcs_dest 已存在且不是 DWS 安装的 Skill,无法在此建立指向 $_lcs_canonical/$_lcs_name 的共享链接"
|
||||
_lcs_collision=1
|
||||
done
|
||||
if [ "$_lcs_collision" -eq 1 ]; then
|
||||
say " ⚠️ 已保留上述目录(不会删除用户数据),该 Agent 改用独立副本方式安装;"
|
||||
say " 移走或改名后重新运行安装即可切换为共享 $_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
|
||||
}
|
||||
# The staged symlink keeps its inode across the rename below (the staging
|
||||
# directory is a sibling inside the same Agent root), so the identity
|
||||
# recorded in the publication manifest stays exact.
|
||||
_lcs_inode="$(skill_link_inode "$_lcs_staged")" || {
|
||||
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
|
||||
rm -rf "$_lcs_stage"
|
||||
return 1
|
||||
}
|
||||
# Anything still present here appeared after the pre-flight scan, i.e. a
|
||||
# concurrent writer. Never let `mv` clobber it.
|
||||
if [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; then
|
||||
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
|
||||
rm -rf "$_lcs_stage"
|
||||
return 1
|
||||
fi
|
||||
# Publish by moving the staged symlink. `mv` is POSIX; `ln -P` is not (it is
|
||||
# absent from BusyBox `ln`, which silently degraded every non-universal
|
||||
# Agent to the copy layout on Alpine and most containers). install-event.sh
|
||||
# and install-devapp.sh already publish this way. The staged basename stays
|
||||
# unique, so a destination a concurrent process turned into a directory can
|
||||
# only receive our link and post-validation removes exactly that.
|
||||
if ! mv "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then
|
||||
cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" "$_lcs_inode" || true
|
||||
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
|
||||
say " ↪ Skills → $_lcs_dest"
|
||||
done
|
||||
rm -rf "$_lcs_stage"
|
||||
}
|
||||
|
||||
# Install skill tree into all agent homes (same rules as build/npm/install.js installSkillsToHomes).
|
||||
# Installing mono removes proven DWS-managed multi leftovers for mutual exclusion,
|
||||
# mirroring `dws skill setup --mode mono`.
|
||||
@@ -673,90 +989,48 @@ install_skills_to_homes() {
|
||||
skill_src="$1"
|
||||
root="${HOME}"
|
||||
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" "~/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi
|
||||
[ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"; 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))
|
||||
case "$root" in
|
||||
"$HOME")
|
||||
label="~/$agent_dir/$SKILL_NAME"
|
||||
;;
|
||||
*)
|
||||
label="$root/$agent_dir/$SKILL_NAME"
|
||||
;;
|
||||
esac
|
||||
if _install_mono_to_base "$skill_src" "$base_dir" "$label"; 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" mono; then
|
||||
installed=$((installed + 1))
|
||||
else
|
||||
failed=$((failed + 1))
|
||||
if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then
|
||||
say " ℹ️ $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
|
||||
case "$root" in
|
||||
"$HOME")
|
||||
flabel="~/.agents/skills/$SKILL_NAME"
|
||||
;;
|
||||
*)
|
||||
flabel="$root/.agents/skills/$SKILL_NAME"
|
||||
;;
|
||||
esac
|
||||
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
|
||||
say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"
|
||||
return 1
|
||||
fi
|
||||
if [ "$retire_failed" -gt 0 ]; then
|
||||
printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed"
|
||||
fi
|
||||
if [ "$failed" -gt 0 ]; then
|
||||
say " ⚠️ 有 ${failed} 个 Agent 目标安装 mono Skill 失败"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$SKILL_STATE_ROOT/skills-state.json"
|
||||
say "✅ DWS Skills 安装完成"
|
||||
say " 统一安装位置:$root/.agents/skills"
|
||||
say " 已自动适配本机上检测到的 Agent"
|
||||
say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效"
|
||||
}
|
||||
|
||||
# multi_tree_has_skills returns 0 only when the given multi bundle directory
|
||||
@@ -783,71 +1057,48 @@ install_multi_skills_to_homes() {
|
||||
multi_src="$1"
|
||||
root="${HOME}"
|
||||
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 ] || { say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"; 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))
|
||||
say " ⚠️ 跳过 ${base_dir}(备份或复制失败,未完成 multi 安装)"
|
||||
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
|
||||
say " ℹ️ $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
|
||||
say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"
|
||||
return 1
|
||||
fi
|
||||
if [ "$retire_failed" -gt 0 ]; then
|
||||
printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed"
|
||||
fi
|
||||
if [ "$failed" -gt 0 ]; then
|
||||
say " ⚠️ 有 ${failed} 个 Agent 目标安装失败"
|
||||
return 1
|
||||
fi
|
||||
write_skills_state "$multi_src" "install.sh" || return 1
|
||||
say "✅ DWS Skills 安装完成"
|
||||
say " 统一安装位置:$root/.agents/skills"
|
||||
say " 已自动适配本机上检测到的 Agent"
|
||||
say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效"
|
||||
}
|
||||
|
||||
_install_multi_to_base() {
|
||||
@@ -1018,32 +1269,7 @@ install_binary() {
|
||||
|
||||
download "$download_url" "$tmpdir/$archive_name"
|
||||
|
||||
# Download and verify SHA256 checksum
|
||||
checksum_url="$(asset_url checksums.txt)"
|
||||
if download "$checksum_url" "$tmpdir/checksums.txt" 2>/dev/null; then
|
||||
expected="$(awk -v file="$archive_name" '$2 == file {print $1; exit}' "$tmpdir/checksums.txt")"
|
||||
if [ -n "$expected" ]; then
|
||||
if need_cmd sha256sum; then
|
||||
actual="$(sha256sum "$tmpdir/$archive_name" | awk '{print $1}')"
|
||||
elif need_cmd shasum; then
|
||||
actual="$(shasum -a 256 "$tmpdir/$archive_name" | awk '{print $1}')"
|
||||
else
|
||||
actual=""
|
||||
fi
|
||||
if [ -n "$actual" ] && [ "$actual" != "$expected" ]; then
|
||||
err "SHA256 checksum mismatch! Expected ${expected}, got ${actual}. Aborting."
|
||||
fi
|
||||
if [ -n "$actual" ]; then
|
||||
say "✅ SHA256 checksum verified"
|
||||
else
|
||||
say "⚠️ Could not compute checksum (sha256sum/shasum not found); skipping verification"
|
||||
fi
|
||||
else
|
||||
say "⚠️ Archive not found in checksums.txt; skipping verification"
|
||||
fi
|
||||
else
|
||||
say "⚠️ Could not download checksums.txt; skipping verification"
|
||||
fi
|
||||
verify_release_asset_checksum "$archive_name" "$tmpdir/$archive_name" "$tmpdir"
|
||||
|
||||
say "📦 Extracting..."
|
||||
tar xzf "$tmpdir/$archive_name" -C "$tmpdir"
|
||||
@@ -1108,6 +1334,8 @@ install_skills() {
|
||||
fi
|
||||
fi
|
||||
|
||||
verify_release_asset_checksum "$skills_archive" "$tmpdir_skills/$skills_archive" "$tmpdir_skills"
|
||||
|
||||
extract_root="$tmpdir_skills/skills"
|
||||
mkdir -p "$extract_root"
|
||||
if ! extract_zip "$tmpdir_skills/$skills_archive" "$extract_root" 2>/dev/null; then
|
||||
@@ -1206,6 +1434,10 @@ main() {
|
||||
install_skills
|
||||
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'
|
||||
say "🎉 Installation complete!"
|
||||
say ""
|
||||
|
||||
@@ -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="$(
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type packagedAgent struct {
|
||||
universal bool
|
||||
path string
|
||||
}
|
||||
|
||||
var upstreamAgentIDs = strings.Fields(`
|
||||
aider-desk amp antigravity antigravity-cli astrbot autohand-code augment bob
|
||||
claude-code openclaw cline codearts-agent codebuddy codemaker codestudio codex
|
||||
command-code continue cortex crush cursor deepagents devin dexto droid eve
|
||||
firebender forgecode gemini-cli github-copilot goose grok hermes-agent inference-sh
|
||||
jazz junie iflow-cli kilo kimchi kimi-code-cli kiro-cli kode lingma loaf mcpjam
|
||||
minimax-code mistral-vibe moxby mux opencode openhands ona pi qoder qoder-cn
|
||||
qwen-code replit reasonix rovodev roo tabnine-cli terramind tinycloud trae trae-cn
|
||||
warp windsurf zed zcode zencoder zenflow neovate pochi promptscript adal universal
|
||||
`)
|
||||
|
||||
func TestAgentRegistriesMatchUpstream76(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := filepath.Join("..", "..")
|
||||
installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1"))
|
||||
devappPS := readTestFile(t, filepath.Join(root, "scripts", "install-devapp.ps1"))
|
||||
verifier := readTestFile(t, filepath.Join(root, "scripts", "release", "verify-package-managers.sh"))
|
||||
|
||||
wantIDs := append([]string(nil), upstreamAgentIDs...)
|
||||
sort.Strings(wantIDs)
|
||||
registries := map[string]map[string]packagedAgent{
|
||||
"install.ps1": parseInstallPowerShellRegistry(t, installPS),
|
||||
"install-devapp.ps1": parsePipeRegistry(t, between(t, devappPS, "$AgentRegistryRows = @(", ")\n$LegacyAgentCleanupRows")),
|
||||
"verifier": parsePipeRegistry(t, between(t, verifier, "UPSTREAM_AGENT_REGISTRY='", "'\n\n# DWS-only")),
|
||||
}
|
||||
|
||||
for name, registry := range registries {
|
||||
gotIDs := make([]string, 0, len(registry))
|
||||
universal := 0
|
||||
for id, agent := range registry {
|
||||
gotIDs = append(gotIDs, id)
|
||||
if agent.universal {
|
||||
universal++
|
||||
}
|
||||
}
|
||||
sort.Strings(gotIDs)
|
||||
if strings.Join(gotIDs, "\n") != strings.Join(wantIDs, "\n") {
|
||||
t.Fatalf("%s agent IDs differ from upstream 76\ngot: %v\nwant: %v", name, gotIDs, wantIDs)
|
||||
}
|
||||
if universal != 19 || len(registry)-universal != 57 {
|
||||
t.Fatalf("%s classification = %d universal/%d non-universal, want 19/57", name, universal, len(registry)-universal)
|
||||
}
|
||||
}
|
||||
|
||||
canonical := registries["install.ps1"]
|
||||
for name, got := range registries {
|
||||
for id, want := range canonical {
|
||||
if got[id] != want {
|
||||
t.Errorf("%s[%s] = %+v, want %+v", name, id, got[id], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for id, wantPath := range map[string]string{
|
||||
"amp": ".config/agents/skills",
|
||||
"github-copilot": ".copilot/skills",
|
||||
"opencode": ".config/opencode/skills",
|
||||
"windsurf": ".codeium/windsurf/skills",
|
||||
"cline": ".agents/skills",
|
||||
"eve": "-",
|
||||
"promptscript": "-",
|
||||
} {
|
||||
if got := canonical[id].path; got != wantPath {
|
||||
t.Errorf("upstream path for %s = %q, want %q", id, got, wantPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDWSCompatibilityAgentTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := filepath.Join("..", "..")
|
||||
installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1"))
|
||||
legacyBlock := between(t, installPS, "$LegacyAgentCleanupTargets = @(", ")\n\n# Kept as")
|
||||
if !strings.Contains(legacyBlock, `Id = "dws-qoderwork"; Universal = $false`) {
|
||||
t.Error("DWS Qoderwork compatibility target must remain non-universal")
|
||||
}
|
||||
for _, id := range []string{
|
||||
"dws-legacy-github", "dws-legacy-amp", "dws-legacy-cline", "dws-legacy-windsurf",
|
||||
} {
|
||||
if !strings.Contains(legacyBlock, `Id = "`+id+`"; Universal = $true`) {
|
||||
t.Errorf("legacy cleanup registry missing cleanup-only target %s", id)
|
||||
}
|
||||
}
|
||||
for _, id := range upstreamAgentIDs {
|
||||
if strings.HasPrefix(id, "dws-") {
|
||||
t.Fatalf("DWS compatibility target %s leaked into upstream registry", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPowerShellUsesAbsoluteJunctionTargetsAndCopyFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
installPS := readTestFile(t, filepath.Join("..", "..", "scripts", "install.ps1"))
|
||||
for _, want := range []string{
|
||||
`$absoluteTarget = [System.IO.Path]::GetFullPath((Join-Path $canonical $name))`,
|
||||
`New-Item -ItemType Junction -Path (Join-Path $stageRoot $name) -Target $absoluteTarget`,
|
||||
`Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir`,
|
||||
`Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir`,
|
||||
} {
|
||||
if !strings.Contains(installPS, want) {
|
||||
t.Errorf("install.ps1 missing Windows junction/fallback contract %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentInstallersCarryUpstreamShallowAndApplicationDetection(t *testing.T) {
|
||||
t.Parallel()
|
||||
checks := map[string][]string{
|
||||
"build/npm/install.js": {"agentTargetDetected", `case "kimchi"`, `case "tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
|
||||
"scripts/install.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
|
||||
"scripts/install-skills.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
|
||||
"scripts/install-event.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
|
||||
"scripts/install-devapp.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
|
||||
"scripts/install.ps1": {"Test-AgentSkillBaseDetected", `"kimchi"`, `"tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
|
||||
"scripts/install-devapp.ps1": {"Test-AgentBaseDetected", `"kimchi"`, `"tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"},
|
||||
}
|
||||
for rel, wants := range checks {
|
||||
body := readTestFile(t, filepath.Join("..", "..", filepath.FromSlash(rel)))
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("%s missing detection contract %q", rel, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPowerShellResolvesCustomAndLegacyAgentHomes(t *testing.T) {
|
||||
pwsh, err := exec.LookPath("pwsh")
|
||||
if err != nil {
|
||||
if runtime.GOOS == "windows" {
|
||||
pwsh, err = exec.LookPath("powershell")
|
||||
}
|
||||
if err != nil {
|
||||
t.Skip("PowerShell is not available")
|
||||
}
|
||||
}
|
||||
|
||||
root := filepath.Join("..", "..")
|
||||
installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1"))
|
||||
cut := strings.LastIndex(installPS, "# ── Main")
|
||||
if cut < 0 {
|
||||
t.Fatal("install.ps1 main section not found")
|
||||
}
|
||||
home := t.TempDir()
|
||||
custom := filepath.Join(t.TempDir(), "custom roots")
|
||||
xdg := filepath.Join(custom, "xdg")
|
||||
clawdbot := filepath.Join(home, ".clawdbot")
|
||||
if err := os.MkdirAll(clawdbot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
prefix := strings.ReplaceAll(installPS[:cut], "$HOME", "$env:DWS_TEST_HOME")
|
||||
prefix += `
|
||||
$ids = @("amp", "autohand-code", "claude-code", "codex", "grok", "hermes-agent", "mistral-vibe", "openclaw", "goose", "kimchi", "opencode")
|
||||
foreach ($id in $ids) {
|
||||
$agent = $AgentRegistry | Where-Object { $_.Id -eq $id } | Select-Object -First 1
|
||||
Write-Output ($id + "|" + (Resolve-AgentSkillBase -Root $env:DWS_TEST_HOME -Agent $agent))
|
||||
}
|
||||
exit 0
|
||||
`
|
||||
harness := filepath.Join(t.TempDir(), "resolve-agent-roots.ps1")
|
||||
mustWriteFile(t, harness, []byte(prefix), 0o644)
|
||||
|
||||
envRoots := map[string]string{
|
||||
"AUTOHAND_HOME": filepath.Join(custom, "autohand"),
|
||||
"CLAUDE_CONFIG_DIR": filepath.Join(custom, "claude"),
|
||||
"CODEX_HOME": filepath.Join(custom, "codex"),
|
||||
"GROK_HOME": filepath.Join(custom, "grok"),
|
||||
"HERMES_HOME": filepath.Join(custom, "hermes"),
|
||||
"VIBE_HOME": filepath.Join(custom, "vibe"),
|
||||
"XDG_CONFIG_HOME": xdg,
|
||||
}
|
||||
cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harness)
|
||||
cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home)
|
||||
for key, value := range envRoots {
|
||||
cmd.Env = append(cmd.Env, key+"="+value)
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("PowerShell root resolver failed: %v\n%s", err, output)
|
||||
}
|
||||
want := map[string]string{
|
||||
"amp": filepath.Join(xdg, "agents", "skills"),
|
||||
"autohand-code": filepath.Join(envRoots["AUTOHAND_HOME"], "skills"),
|
||||
"claude-code": filepath.Join(envRoots["CLAUDE_CONFIG_DIR"], "skills"),
|
||||
"codex": filepath.Join(envRoots["CODEX_HOME"], "skills"),
|
||||
"grok": filepath.Join(envRoots["GROK_HOME"], "skills"),
|
||||
"hermes-agent": filepath.Join(envRoots["HERMES_HOME"], "skills"),
|
||||
"mistral-vibe": filepath.Join(envRoots["VIBE_HOME"], "skills"),
|
||||
"openclaw": filepath.Join(clawdbot, "skills"),
|
||||
"goose": filepath.Join(xdg, "goose", "skills"),
|
||||
"kimchi": filepath.Join(xdg, "kimchi", "harness", "skills"),
|
||||
"opencode": filepath.Join(xdg, "opencode", "skills"),
|
||||
}
|
||||
got := make(map[string]string)
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
|
||||
parts := strings.SplitN(strings.TrimSpace(line), "|", 2)
|
||||
if len(parts) == 2 {
|
||||
got[parts[0]] = filepath.Clean(parts[1])
|
||||
}
|
||||
}
|
||||
for id, path := range want {
|
||||
if got[id] != filepath.Clean(path) {
|
||||
t.Errorf("resolved %s = %q, want %q\noutput:\n%s", id, got[id], path, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readTestFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s): %v", path, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func between(t *testing.T, text, start, end string) string {
|
||||
t.Helper()
|
||||
i := strings.Index(text, start)
|
||||
if i < 0 {
|
||||
t.Fatalf("start marker %q missing", start)
|
||||
}
|
||||
i += len(start)
|
||||
j := strings.Index(text[i:], end)
|
||||
if j < 0 {
|
||||
t.Fatalf("end marker %q missing", end)
|
||||
}
|
||||
return text[i : i+j]
|
||||
}
|
||||
|
||||
func parseInstallPowerShellRegistry(t *testing.T, text string) map[string]packagedAgent {
|
||||
t.Helper()
|
||||
block := between(t, text, "$AgentRegistry = @(", ")\n\n# DWS compatibility targets")
|
||||
re := regexp.MustCompile(`Id = "([^"]+)"; Universal = \$(true|false); Dir = (?:"([^"]+)"|\$null)`)
|
||||
result := make(map[string]packagedAgent)
|
||||
for _, match := range re.FindAllStringSubmatch(block, -1) {
|
||||
path := strings.ReplaceAll(match[3], `\`, "/")
|
||||
if path == "" {
|
||||
path = "-"
|
||||
}
|
||||
result[match[1]] = packagedAgent{universal: match[2] == "true", path: path}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parsePipeRegistry(t *testing.T, block string) map[string]packagedAgent {
|
||||
t.Helper()
|
||||
re := regexp.MustCompile(`([a-z0-9-]+)\|([01])\|([.a-z0-9_\\/-]+)`)
|
||||
result := make(map[string]packagedAgent)
|
||||
for _, match := range re.FindAllStringSubmatch(block, -1) {
|
||||
path := strings.ReplaceAll(match[3], `\`, "/")
|
||||
if _, exists := result[match[1]]; exists {
|
||||
t.Fatalf("duplicate agent %q in pipe registry", match[1])
|
||||
}
|
||||
result[match[1]] = packagedAgent{universal: match[2] == "1", path: path}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -16,22 +16,38 @@
|
||||
* skills, mono leftover dws/ and stale
|
||||
* dingtalk-* are removed, and the
|
||||
* ~/.dws/skills/{multi,mono} caches fill.
|
||||
* 2. empty multi/ tree — warns and falls back to mono instead of
|
||||
* 2. universal topology — canonical store installed, pre-existing private
|
||||
* copies retired and recoverable under
|
||||
* ~/.dws/skill-backups/<stamp>/<home-rel-name>.
|
||||
* 3. second run — idempotent: an already-correct link is neither
|
||||
* re-backed-up nor rewritten.
|
||||
* 4. unretireable universal root — a cleanup-only failure WARNS; canonical
|
||||
* store, non-universal links and skills-state.json
|
||||
* are all still written and the install exits 0.
|
||||
* 5. empty multi/ tree — warns and falls back to mono instead of
|
||||
* crashing postinstall; a previously good multi
|
||||
* cache is NOT wiped.
|
||||
* 3. bogus mode — DWS_SKILL_MODE=bogus exits non-zero with an
|
||||
* 6. bogus mode — DWS_SKILL_MODE=bogus exits non-zero with an
|
||||
* "invalid DWS_SKILL_MODE" error.
|
||||
* 4. multi-only zip, mono — mono install is skipped with a warning; the
|
||||
* 7. multi-only zip, mono — mono install is skipped with a warning; the
|
||||
* staging root is NOT copied into a dws/ dir.
|
||||
* 5. multi backup failure — preserves mono, writes no multi skill, and
|
||||
* 8. multi backup failure — preserves mono, writes no multi skill, and
|
||||
* reports postinstall failure.
|
||||
* 6. mono backup failure — preserves multi, writes no mono skill, and
|
||||
* 9. mono backup failure — preserves multi, writes no mono skill, and
|
||||
* reports postinstall failure.
|
||||
* 7. mono switch — migrates only centrally owned multi Skills.
|
||||
* 8. cache copy failure — preserves the previous complete cache.
|
||||
* 9. multi publish failure — restores the complete previous Skill set.
|
||||
* 10. multi backup failure — restores every earlier successful backup.
|
||||
* 11. mono transaction failure — restores every managed multi Skill after
|
||||
* 10. mono switch — migrates only centrally owned multi Skills.
|
||||
* 11. cache copy failure — preserves the previous complete cache.
|
||||
* 12. no-replace publish — an occupied destination that is not a victim is
|
||||
* refused, not replaced or linked into.
|
||||
* 13. simulated win32 — junction type + ABSOLUTE canonical target and
|
||||
* the rename publish path with its no-replace
|
||||
* guard, exercised by swapping process.platform
|
||||
* instead of requiring a Windows host.
|
||||
* 14. backup retention — ~/.dws/skill-backups keeps the newest 5 stamps.
|
||||
* 15. unknown preservation — prune never removes non-DWS directories in skill-backups.
|
||||
* 16. multi publish failure — restores the complete previous Skill set.
|
||||
* 17. multi backup failure — restores every earlier successful backup.
|
||||
* 18. mono transaction failure — restores every managed multi Skill after
|
||||
* later backup or mono publish failure.
|
||||
*
|
||||
* Requirements: unix host with tar/zip/unzip on PATH (the same tools
|
||||
@@ -62,10 +78,34 @@ const repoRoot = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)),
|
||||
const installJsSource = path.join(repoRoot, "build", "npm", "install.js");
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
UPSTREAM_AGENTS,
|
||||
resolvedAgentTargets,
|
||||
agentTargetDetected,
|
||||
isSkillBackupStamp,
|
||||
pruneSkillBackups,
|
||||
backupAndRemoveSkillDir,
|
||||
movePathRecoverablySync,
|
||||
publishCacheAtomically,
|
||||
publishCanonicalLinksAtomically,
|
||||
publishManagedMonoSkillSetAtomically,
|
||||
publishManagedMultiSkillSetAtomically,
|
||||
} = require(installJsSource);
|
||||
|
||||
assert.equal(UPSTREAM_AGENTS.length, 76, "the complete upstream Agent registry is pinned");
|
||||
assert.equal(new Set(UPSTREAM_AGENTS.map(({ id }) => id)).size, 76, "upstream Agent IDs are unique");
|
||||
assert.equal(UPSTREAM_AGENTS.filter(({ universal }) => universal).length, 19, "upstream universal classification is pinned");
|
||||
assert.equal(UPSTREAM_AGENTS.filter(({ universal }) => !universal).length, 57, "upstream non-universal classification is pinned");
|
||||
assert.equal(UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir === null).length, 2, "no-global Agents are retained in the registry");
|
||||
assert.equal(UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir === ".agents/skills").length, 6, "canonical-direct Agents need no target");
|
||||
assert.equal(resolvedAgentTargets(path.join(os.tmpdir(), "dws-registry-home")).length, 70, "65 upstream roots, qoderwork, and 4 migration roots are deduplicated");
|
||||
const detectionHome = fs.mkdtempSync(path.join(os.tmpdir(), "dws-agent-detect-"));
|
||||
fs.mkdirSync(path.join(detectionHome, ".config", "kimchi"), { recursive: true });
|
||||
fs.mkdirSync(path.join(detectionHome, ".tabnine"), { recursive: true });
|
||||
const detectionTargets = resolvedAgentTargets(detectionHome);
|
||||
for (const id of ["kimchi", "tabnine-cli"]) {
|
||||
assert.equal(agentTargetDetected(detectionTargets.find((target) => target.id === id)), true, `${id} shallow install marker is detected`);
|
||||
}
|
||||
fs.rmSync(detectionHome, { recursive: true, force: true });
|
||||
const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`];
|
||||
|
||||
if (process.platform === "win32" || !assetName) {
|
||||
@@ -107,6 +147,193 @@ function writeManagedState(home, names) {
|
||||
writeFile(path.join(home, ".dws", "skills-state.json"), `${JSON.stringify(state, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function crossDeviceError() {
|
||||
return Object.assign(new Error("injected cross-device rename"), { code: "EXDEV" });
|
||||
}
|
||||
|
||||
// withSimulatedWin32 runs fn with process.platform === "win32" so the
|
||||
// Windows-only publish branches (junction type, absolute link target, rename
|
||||
// publish + its no-replace guard) get real execution coverage on a unix host.
|
||||
// fs.symlinkSync ignores the type argument off Windows, so the same call is
|
||||
// safe here. Create every temp path BEFORE calling: os.tmpdir() follows the
|
||||
// simulated platform.
|
||||
function withSimulatedWin32(fn) {
|
||||
const original = process.platform;
|
||||
const set = (value) =>
|
||||
Object.defineProperty(process, "platform", { value, configurable: true, enumerable: true, writable: false });
|
||||
set("win32");
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
set(original);
|
||||
}
|
||||
}
|
||||
|
||||
function runCrossDeviceMoveContract() {
|
||||
const roots = [];
|
||||
const fixture = (name) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `dws-installjs-exdev-${name}-`));
|
||||
roots.push(root);
|
||||
const src = path.join(root, "external", "skill");
|
||||
const dest = path.join(root, "home", ".dws", "skill-backups", "stamp", "skill");
|
||||
writeFile(path.join(src, "SKILL.md"), "old skill\n", 0o640);
|
||||
return { root, src, dest };
|
||||
};
|
||||
const injectedRename = (source, target) => {
|
||||
if (path.basename(source) === "skill" && path.basename(target) === "skill") {
|
||||
throw crossDeviceError();
|
||||
}
|
||||
fs.renameSync(source, target);
|
||||
};
|
||||
try {
|
||||
{
|
||||
const { src, dest } = fixture("success");
|
||||
fs.symlinkSync("SKILL.md", path.join(src, "skill-link"));
|
||||
fs.symlinkSync("missing.md", path.join(src, "dangling-link"));
|
||||
movePathRecoverablySync(src, dest, { renameFn: injectedRename });
|
||||
assert.equal(fs.lstatSync(dest).isDirectory(), true);
|
||||
assert.equal(fs.statSync(path.join(dest, "SKILL.md")).mode & 0o777, 0o640);
|
||||
assert.equal(fs.lstatSync(path.join(dest, "skill-link")).isSymbolicLink(), true);
|
||||
assert.equal(fs.readlinkSync(path.join(dest, "skill-link")), "SKILL.md");
|
||||
assert.equal(fs.lstatSync(path.join(dest, "dangling-link")).isSymbolicLink(), true);
|
||||
assert.equal(fs.readlinkSync(path.join(dest, "dangling-link")), "missing.md");
|
||||
assert.equal(fs.existsSync(src), false, "verified EXDEV move removes source last");
|
||||
|
||||
const restored = path.join(path.dirname(src), "restored-skill");
|
||||
movePathRecoverablySync(dest, restored, {
|
||||
renameFn(source, target) {
|
||||
if (source === dest && target === restored) throw crossDeviceError();
|
||||
fs.renameSync(source, target);
|
||||
},
|
||||
});
|
||||
assert.equal(fs.readFileSync(path.join(restored, "SKILL.md"), "utf8"), "old skill\n");
|
||||
assert.equal(fs.existsSync(dest), false, "EXDEV rollback consumes verified backup");
|
||||
}
|
||||
|
||||
for (const [name, options] of [
|
||||
["copy", { copyFn() { throw new Error("copy failed"); } }],
|
||||
["verify", { verifyFn() { throw new Error("verify failed"); } }],
|
||||
]) {
|
||||
const { root, src, dest } = fixture(name);
|
||||
assert.throws(
|
||||
() => movePathRecoverablySync(src, dest, { renameFn: injectedRename, ...options }),
|
||||
new RegExp(`${name} failed`),
|
||||
);
|
||||
assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n");
|
||||
assert.equal(fs.existsSync(dest), false);
|
||||
const backupParent = path.dirname(dest);
|
||||
if (fs.existsSync(backupParent)) {
|
||||
assert.equal(
|
||||
fs.readdirSync(backupParent).some((entry) => entry.startsWith(".skill.cross-device-")),
|
||||
false,
|
||||
`${name} failure cleans destination-filesystem staging`,
|
||||
);
|
||||
}
|
||||
assert.ok(root);
|
||||
}
|
||||
|
||||
{
|
||||
const { src, dest } = fixture("remove");
|
||||
assert.throws(
|
||||
() => movePathRecoverablySync(src, dest, {
|
||||
renameFn: injectedRename,
|
||||
removeFn(target) {
|
||||
if (target === src) throw new Error("remove failed");
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
},
|
||||
}),
|
||||
/both copies retained .*remove failed/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n");
|
||||
assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "old skill\n");
|
||||
}
|
||||
|
||||
{
|
||||
const { src, dest } = fixture("read-only");
|
||||
fs.chmodSync(src, 0o555);
|
||||
movePathRecoverablySync(src, dest, { renameFn: injectedRename });
|
||||
assert.equal(fs.statSync(dest).mode & 0o777, 0o555, "read-only directory mode is restored after publish");
|
||||
assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "old skill\n");
|
||||
fs.chmodSync(dest, 0o755);
|
||||
}
|
||||
|
||||
{
|
||||
const { src, dest } = fixture("read-only-remove-failure");
|
||||
fs.chmodSync(src, 0o555);
|
||||
assert.throws(
|
||||
() => movePathRecoverablySync(src, dest, {
|
||||
renameFn: injectedRename,
|
||||
removeFn(target) {
|
||||
if (target === src) throw new Error("remove failed");
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
},
|
||||
}),
|
||||
/both copies retained .*remove failed/,
|
||||
);
|
||||
assert.equal(fs.statSync(src).mode & 0o777, 0o555, "failed removal restores source directory mode");
|
||||
fs.chmodSync(src, 0o755);
|
||||
fs.chmodSync(dest, 0o755);
|
||||
}
|
||||
|
||||
{
|
||||
const { src, dest } = fixture("permission");
|
||||
let copied = false;
|
||||
const permission = Object.assign(new Error("permission denied"), { code: "EACCES" });
|
||||
assert.throws(
|
||||
() => movePathRecoverablySync(src, dest, {
|
||||
renameFn() { throw permission; },
|
||||
copyFn() { copied = true; },
|
||||
}),
|
||||
/permission denied/,
|
||||
);
|
||||
assert.equal(copied, false, "EACCES must not be mistaken for EXDEV");
|
||||
assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n");
|
||||
}
|
||||
|
||||
{
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-dangling-collision-"));
|
||||
roots.push(root);
|
||||
const home = path.join(root, "home");
|
||||
const victim = path.join(home, ".agents", "skills", "dingtalk-chat");
|
||||
writeFile(path.join(victim, "SKILL.md"), "old\n");
|
||||
const stamp = "20260813-150000";
|
||||
const occupied = path.join(home, ".dws", "skill-backups", stamp, ".agents-skills-dingtalk-chat");
|
||||
fs.mkdirSync(path.dirname(occupied), { recursive: true });
|
||||
fs.symlinkSync("missing-backup", occupied);
|
||||
const backup = backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => stamp });
|
||||
assert.ok(backup.includes(`${stamp}-1`), "dangling backup target selects a numbered sibling");
|
||||
assert.equal(fs.readlinkSync(occupied), "missing-backup", "dangling collision is never overwritten");
|
||||
}
|
||||
|
||||
{
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-link-rollback-"));
|
||||
roots.push(root);
|
||||
const home = path.join(root, "home");
|
||||
const canonical = path.join(home, ".agents", "skills");
|
||||
const base = path.join(home, ".cursor", "skills");
|
||||
const canonicalSkill = path.join(canonical, "dingtalk-chat");
|
||||
const victim = path.join(base, "dingtalk-chat");
|
||||
writeFile(path.join(canonicalSkill, "SKILL.md"), "new\n");
|
||||
writeFile(path.join(victim, "SKILL.md"), "old\n");
|
||||
assert.throws(
|
||||
() => publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [victim], {
|
||||
renameFn(source, target) {
|
||||
if (source === victim || (source.includes(`${path.sep}skill-backups${path.sep}`) && target === victim)) {
|
||||
throw crossDeviceError();
|
||||
}
|
||||
fs.renameSync(source, target);
|
||||
},
|
||||
publishLinkFn() { throw new Error("link publish failed"); },
|
||||
}),
|
||||
/link publish failed/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(path.join(victim, "SKILL.md"), "utf8"), "old\n");
|
||||
}
|
||||
} finally {
|
||||
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// stagePkg builds a fake npm package whose assets/dws-skills.zip contains
|
||||
// exactly the given zip entries ({ "relative/path": "content" }) plus any
|
||||
// listed empty directories. Returns { tmp, pkg, home }.
|
||||
@@ -133,8 +360,8 @@ function stagePkg(zipEntries, emptyDirs = []) {
|
||||
return { tmp, pkg, home: path.join(tmp, "home") };
|
||||
}
|
||||
|
||||
function runInstall(pkg, home, skillMode) {
|
||||
const env = { ...process.env, HOME: home };
|
||||
function runInstall(pkg, home, skillMode, extraEnv = {}) {
|
||||
const env = { ...process.env, HOME: home, ...extraEnv };
|
||||
if (skillMode !== undefined) {
|
||||
env.DWS_SKILL_MODE = skillMode;
|
||||
} else {
|
||||
@@ -193,14 +420,32 @@ scenario("multi install lays out sibling skills and caches", () => {
|
||||
}
|
||||
});
|
||||
|
||||
scenario("Codex uses its canonical root without a generic duplicate", () => {
|
||||
scenario("pinned universal global topology installs canonical and retires private copies", () => {
|
||||
const { tmp, pkg, home } = stagePkg({
|
||||
"mono/SKILL.md": "# mono fixture\n",
|
||||
"multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n",
|
||||
"multi/dws-shared/SKILL.md": "# dws-shared\n",
|
||||
});
|
||||
try {
|
||||
writeFile(path.join(home, ".codex", "config.toml"), "model = \"test\"\n");
|
||||
// The pinned upstream installer treats global universal Agents as
|
||||
// canonical-only even when their registry also retains a distinct
|
||||
// globalSkillsDir for legacy discovery/removal. Exercise every unique
|
||||
// non-canonical universal root so this distinction cannot drift again.
|
||||
const retiredUniversalRoots = [
|
||||
".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",
|
||||
];
|
||||
for (const root of retiredUniversalRoots) {
|
||||
writeFile(path.join(home, root, "dingtalk-chat", "SKILL.md"), `beta.6 copy in ${root}\n`);
|
||||
}
|
||||
writeFile(
|
||||
path.join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"),
|
||||
"old nested duplicate\n",
|
||||
@@ -209,13 +454,15 @@ scenario("Codex uses its canonical root without a generic duplicate", () => {
|
||||
const res = runInstall(pkg, home, "multi");
|
||||
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md")),
|
||||
"Codex canonical Skill installed",
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")),
|
||||
"generic flat duplicate not installed",
|
||||
fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")),
|
||||
"universal canonical Skill installed",
|
||||
);
|
||||
for (const root of retiredUniversalRoots) {
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(home, root, "dingtalk-chat")),
|
||||
`beta.6 universal duplicate migrated from ${root}`,
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(home, ".agents", "skills", "dws")),
|
||||
"legacy nested duplicate retired",
|
||||
@@ -225,7 +472,7 @@ scenario("Codex uses its canonical root without a generic duplicate", () => {
|
||||
}
|
||||
});
|
||||
|
||||
scenario("ZCode uses its canonical root without a generic duplicate", () => {
|
||||
scenario("ZCode links its Agent root to the canonical store", () => {
|
||||
const { tmp, pkg, home } = stagePkg({
|
||||
"mono/SKILL.md": "# mono fixture\n",
|
||||
"multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n",
|
||||
@@ -241,8 +488,10 @@ scenario("ZCode uses its canonical root without a generic duplicate", () => {
|
||||
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")),
|
||||
"ZCode canonical Skill installed",
|
||||
"ZCode Skill resolves through canonical link",
|
||||
);
|
||||
assert.ok(fs.lstatSync(path.join(home, ".zcode", "skills", "dingtalk-chat")).isSymbolicLink());
|
||||
assert.ok(fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")));
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(home, ".agents", "skills", "dws")),
|
||||
"legacy generic duplicate retired",
|
||||
@@ -252,6 +501,172 @@ scenario("ZCode uses its canonical root without a generic duplicate", () => {
|
||||
}
|
||||
});
|
||||
|
||||
scenario("a second install run is idempotent and preserves correct links", () => {
|
||||
const { tmp, pkg, home } = stagePkg({
|
||||
"mono/SKILL.md": "# mono fixture\n",
|
||||
"multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n",
|
||||
});
|
||||
try {
|
||||
writeFile(path.join(home, ".zcode", "v2", "config.json"), "{}\n");
|
||||
|
||||
const first = runInstall(pkg, home, "multi", { XDG_CONFIG_HOME: path.join(home, ".config") });
|
||||
assert.equal(first.status, 0, `exit=${first.status}\nstdout=${first.stdout}\nstderr=${first.stderr}`);
|
||||
const linked = path.join(home, ".zcode", "skills", "dingtalk-chat");
|
||||
const firstTarget = fs.readlinkSync(linked);
|
||||
const firstState = JSON.parse(fs.readFileSync(path.join(home, ".dws", "skills-state.json"), "utf8"));
|
||||
|
||||
const second = runInstall(pkg, home, "multi", { XDG_CONFIG_HOME: path.join(home, ".config") });
|
||||
assert.equal(second.status, 0, `exit=${second.status}\nstdout=${second.stdout}\nstderr=${second.stderr}`);
|
||||
assert.equal(fs.lstatSync(linked).isSymbolicLink(), true, "an already-correct link stays a link");
|
||||
assert.equal(fs.readlinkSync(linked), firstTarget, "an already-correct link is not rewritten");
|
||||
assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n");
|
||||
assert.ok(
|
||||
!second.stdout.includes(`已备份并移除 ${linked}`),
|
||||
"an already-correct link must not be backed up again",
|
||||
);
|
||||
const secondState = JSON.parse(fs.readFileSync(path.join(home, ".dws", "skills-state.json"), "utf8"));
|
||||
assert.deepEqual(secondState.official_skills, firstState.official_skills, "state is stable across runs");
|
||||
assert.deepEqual(
|
||||
secondState.managed_skills.map((record) => record.digest),
|
||||
firstState.managed_skills.map((record) => record.digest),
|
||||
"re-installing the same bundle yields the same provenance digests",
|
||||
);
|
||||
assert.ok(
|
||||
fs.readdirSync(path.join(home, ".dws", "skill-backups")).length <= 5,
|
||||
"repeated installs keep the backup root bounded",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
scenario("unretireable universal copy warns but still completes the install", () => {
|
||||
const { tmp, pkg, home } = stagePkg({
|
||||
"mono/SKILL.md": "# mono fixture\n",
|
||||
"multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n",
|
||||
});
|
||||
const codexSkills = path.join(home, ".codex", "skills");
|
||||
try {
|
||||
// Non-universal Agent that must still receive its link.
|
||||
writeFile(path.join(home, ".zcode", "v2", "config.json"), "{}\n");
|
||||
// Universal Agent whose obsolete copy cannot be moved out: r-x on the
|
||||
// parent means rename() fails with EACCES.
|
||||
writeFile(path.join(codexSkills, "dingtalk-chat", "SKILL.md"), "old codex copy\n");
|
||||
fs.chmodSync(codexSkills, 0o500);
|
||||
|
||||
const res = runInstall(pkg, home, "multi", {
|
||||
XDG_CONFIG_HOME: path.join(home, ".config"),
|
||||
CODEX_HOME: "",
|
||||
});
|
||||
assert.equal(
|
||||
res.status,
|
||||
0,
|
||||
`a cleanup-only failure must not fail the install\nexit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`,
|
||||
);
|
||||
assert.match(res.stderr, /旧副本迁移失败/, "the retire failure is reported as a warning");
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")),
|
||||
"canonical store is still installed",
|
||||
);
|
||||
const linked = path.join(home, ".zcode", "skills", "dingtalk-chat");
|
||||
assert.equal(fs.lstatSync(linked).isSymbolicLink(), true, "non-universal link is still installed");
|
||||
assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n");
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(home, ".dws", "skills-state.json")),
|
||||
"skills-state.json is written even when a retire failed",
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(codexSkills, "dingtalk-chat", "SKILL.md"), "utf8"),
|
||||
"old codex copy\n",
|
||||
"a copy that cannot be backed up is never deleted",
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
fs.chmodSync(codexSkills, 0o700);
|
||||
} catch {}
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
scenario("upstream Agent roots honor XDG and custom homes with relative links", () => {
|
||||
const { tmp, pkg, home } = stagePkg({
|
||||
"mono/SKILL.md": "# mono fixture\n",
|
||||
"multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n",
|
||||
});
|
||||
const xdg = path.join(tmp, "xdg config");
|
||||
const autohand = path.join(tmp, "autohand home");
|
||||
const claude = path.join(tmp, "claude config");
|
||||
const codex = path.join(tmp, "codex home");
|
||||
const hermes = path.join(tmp, "hermes home");
|
||||
try {
|
||||
writeFile(path.join(xdg, "goose", "config.yaml"), "enabled: true\n");
|
||||
writeFile(path.join(xdg, "agents", "detection.marker"), "amp\n");
|
||||
writeFile(path.join(xdg, "agents", "skills", "dingtalk-chat", "SKILL.md"), "old amp copy\n");
|
||||
writeFile(path.join(autohand, "config.json"), "{}\n");
|
||||
writeFile(path.join(claude, "config.json"), "{}\n");
|
||||
writeFile(path.join(claude, "skills", "dingtalk-chat", "SKILL.md"), "old claude copy\n");
|
||||
writeFile(path.join(codex, "config.toml"), "model=test\n");
|
||||
writeFile(path.join(codex, "skills", "dingtalk-chat", "SKILL.md"), "old codex copy\n");
|
||||
writeFile(path.join(hermes, "config.json"), "{}\n");
|
||||
writeFile(path.join(hermes, "skills", "dingtalk-chat", "SKILL.md"), "old hermes copy\n");
|
||||
writeFile(path.join(home, ".qoderwork", "config.json"), "{}\n");
|
||||
writeFile(path.join(home, ".amp", "skills", "dingtalk-chat", "SKILL.md"), "old DWS path\n");
|
||||
|
||||
const res = runInstall(pkg, home, "multi", {
|
||||
XDG_CONFIG_HOME: xdg,
|
||||
AUTOHAND_HOME: autohand,
|
||||
CLAUDE_CONFIG_DIR: claude,
|
||||
CODEX_HOME: codex,
|
||||
HERMES_HOME: hermes,
|
||||
});
|
||||
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
|
||||
|
||||
for (const linked of [
|
||||
path.join(xdg, "goose", "skills", "dingtalk-chat"),
|
||||
path.join(autohand, "skills", "dingtalk-chat"),
|
||||
path.join(claude, "skills", "dingtalk-chat"),
|
||||
path.join(hermes, "skills", "dingtalk-chat"),
|
||||
path.join(home, ".qoderwork", "skills", "dingtalk-chat"),
|
||||
]) {
|
||||
assert.ok(fs.lstatSync(linked).isSymbolicLink(), `${linked} is linked`);
|
||||
assert.ok(!path.isAbsolute(fs.readlinkSync(linked)), `${linked} uses a relative link`);
|
||||
assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n");
|
||||
}
|
||||
const backupText = [];
|
||||
const backupNames = [];
|
||||
const collectBackupText = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
backupNames.push(entry.name);
|
||||
collectBackupText(full);
|
||||
} else if (entry.isFile()) backupText.push(fs.readFileSync(full, "utf8"));
|
||||
}
|
||||
};
|
||||
collectBackupText(path.join(home, ".dws", "skill-backups"));
|
||||
assert.ok(backupText.includes("old claude copy\n"), "CLAUDE_CONFIG_DIR content is recoverably backed up");
|
||||
assert.ok(backupText.includes("old hermes copy\n"), "HERMES_HOME content is recoverably backed up");
|
||||
// Retired universal copies are the PR's advertised recovery story: every
|
||||
// one of them must be readable back out of ~/.dws/skill-backups.
|
||||
assert.ok(backupText.includes("old amp copy\n"), "retired XDG agents copy is recoverable");
|
||||
assert.ok(backupText.includes("old codex copy\n"), "retired CODEX_HOME copy is recoverable");
|
||||
assert.ok(backupText.includes("old DWS path\n"), "retired legacy ~/.amp copy is recoverable");
|
||||
assert.ok(
|
||||
backupNames.includes(".amp-skills-dingtalk-chat"),
|
||||
`backup names record the HOME-relative origin, got ${backupNames.join(",")}`,
|
||||
);
|
||||
for (const retired of [
|
||||
path.join(xdg, "agents", "skills", "dingtalk-chat"),
|
||||
path.join(codex, "skills", "dingtalk-chat"),
|
||||
path.join(home, ".amp", "skills", "dingtalk-chat"),
|
||||
]) {
|
||||
assert.ok(!fs.existsSync(retired), `legacy/universal duplicate retired: ${retired}`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
scenario("empty multi/ tree falls back to mono and keeps the old multi cache", () => {
|
||||
const { tmp, pkg, home } = stagePkg({
|
||||
"SKILL.md": "# mono root copy\n",
|
||||
@@ -407,6 +822,233 @@ scenario("cache copy failure preserves the previous complete cache", () => {
|
||||
}
|
||||
});
|
||||
|
||||
scenario("canonical link publication never clobbers or deletes concurrent user data", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-canonical-race-"));
|
||||
try {
|
||||
{
|
||||
// Real no-replace property: the destination is occupied by data this
|
||||
// transaction never backed up (it is not a victim), so the production
|
||||
// publisher must refuse rather than replace or link into it. No stub
|
||||
// creates or destroys anything here.
|
||||
const home = path.join(tmp, "no-clobber-home");
|
||||
const canonical = path.join(home, ".agents", "skills");
|
||||
const base = path.join(home, ".cursor", "skills");
|
||||
const destination = path.join(base, "dingtalk-chat");
|
||||
writeFile(path.join(canonical, "dingtalk-chat", "SKILL.md"), "new\n");
|
||||
writeFile(path.join(destination, "concurrent-user-data.txt"), "must survive\n");
|
||||
assert.throws(
|
||||
() => publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], []),
|
||||
/Skill publish destination already exists/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(path.join(destination, "concurrent-user-data.txt"), "utf8"), "must survive\n");
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(destination),
|
||||
["concurrent-user-data.txt"],
|
||||
"refused publication must not leave anything inside the occupied destination",
|
||||
);
|
||||
assert.equal(fs.lstatSync(destination).isDirectory(), true, "the user's directory is still a directory");
|
||||
assert.ok(
|
||||
!fs.readdirSync(base).some((name) => name.startsWith(".dws-link-set.tmp-")),
|
||||
"refused publication cleans its staging root",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const home = path.join(tmp, "rollback-home");
|
||||
const canonical = path.join(home, ".agents", "skills");
|
||||
const base = path.join(home, ".cursor", "skills");
|
||||
const first = path.join(base, "dingtalk-first");
|
||||
const second = path.join(base, "dingtalk-second");
|
||||
for (const name of ["dingtalk-first", "dingtalk-second"]) {
|
||||
writeFile(path.join(canonical, name, "SKILL.md"), `new ${name}\n`);
|
||||
writeFile(path.join(base, name, "SKILL.md"), `old ${name}\n`);
|
||||
}
|
||||
let publishCalls = 0;
|
||||
assert.throws(
|
||||
() => publishCanonicalLinksAtomically(
|
||||
home,
|
||||
canonical,
|
||||
base,
|
||||
["dingtalk-first", "dingtalk-second"],
|
||||
[first, second],
|
||||
{
|
||||
publishLinkFn(source, target) {
|
||||
publishCalls += 1;
|
||||
if (publishCalls === 1) {
|
||||
const result = childProcess.spawnSync("ln", ["-P", source, target], { encoding: "utf8" });
|
||||
if (result.status !== 0) throw new Error(result.stderr || "ln -P failed");
|
||||
return;
|
||||
}
|
||||
fs.unlinkSync(first);
|
||||
writeFile(path.join(first, "concurrent-user-data.txt"), "must survive\n");
|
||||
throw new Error("injected second canonical publish failure");
|
||||
},
|
||||
},
|
||||
),
|
||||
/concurrent object retained|rollback failed/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(path.join(first, "SKILL.md"), "utf8"), "old dingtalk-first\n");
|
||||
assert.equal(fs.readFileSync(path.join(second, "SKILL.md"), "utf8"), "old dingtalk-second\n");
|
||||
const retained = fs.readdirSync(base)
|
||||
.filter((name) => name.startsWith(".dingtalk-first.rollback-"))
|
||||
.map((name) => path.join(base, name, "payload", "concurrent-user-data.txt"))
|
||||
.filter((candidate) => fs.existsSync(candidate));
|
||||
assert.equal(retained.length, 1, "concurrent replacement must be retained in quarantine");
|
||||
assert.equal(fs.readFileSync(retained[0], "utf8"), "must survive\n");
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
scenario("simulated win32 publishes junctions with absolute canonical targets", () => {
|
||||
// Temp roots are created before entering the simulated platform: os.tmpdir()
|
||||
// would otherwise resolve through Windows environment variables.
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-win32-link-"));
|
||||
try {
|
||||
const home = path.join(tmp, "home");
|
||||
const canonical = path.join(home, ".agents", "skills");
|
||||
const canonicalSkill = path.join(canonical, "dingtalk-chat");
|
||||
const base = path.join(home, ".zcode", "skills");
|
||||
const dest = path.join(base, "dingtalk-chat");
|
||||
writeFile(path.join(canonicalSkill, "SKILL.md"), "# dingtalk-chat\n");
|
||||
writeFile(path.join(dest, "SKILL.md"), "old zcode copy\n");
|
||||
|
||||
const symlinkCalls = [];
|
||||
const renameCalls = [];
|
||||
withSimulatedWin32(() => {
|
||||
publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [dest], {
|
||||
symlinkFn(target, linkPath, type) {
|
||||
symlinkCalls.push({ target, linkPath, type });
|
||||
fs.symlinkSync(target, linkPath, type);
|
||||
},
|
||||
publishRenameFn(source, destination) {
|
||||
renameCalls.push({ source, destination });
|
||||
fs.renameSync(source, destination);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(symlinkCalls.length, 1, "one staged link per bundle skill");
|
||||
assert.equal(symlinkCalls[0].type, "junction", "Windows must publish junctions, not symlinks");
|
||||
assert.equal(path.isAbsolute(symlinkCalls[0].target), true, "junctions require an absolute target");
|
||||
assert.equal(symlinkCalls[0].target, fs.realpathSync(canonicalSkill), "junction targets the canonical store");
|
||||
assert.equal(renameCalls.length, 1, "win32 publishes by rename, not by ln -P");
|
||||
assert.equal(renameCalls[0].destination, dest);
|
||||
assert.equal(fs.readlinkSync(dest), fs.realpathSync(canonicalSkill), "published junction kept its absolute target");
|
||||
assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "# dingtalk-chat\n");
|
||||
const backupRoot = path.join(home, ".dws", "skill-backups");
|
||||
const stamps = fs.readdirSync(backupRoot);
|
||||
assert.equal(stamps.length, 1, "the replaced copy produced exactly one backup stamp");
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(path.join(backupRoot, stamps[0])),
|
||||
[".zcode-skills-dingtalk-chat"],
|
||||
"backups encode the HOME-relative origin of the retired copy",
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(backupRoot, stamps[0], ".zcode-skills-dingtalk-chat", "SKILL.md"), "utf8"),
|
||||
"old zcode copy\n",
|
||||
"the replaced copy stays recoverable",
|
||||
);
|
||||
|
||||
// FIX 5: the win32 rename path must never replace an object that appeared
|
||||
// at the destination (libuv passes MOVEFILE_REPLACE_EXISTING).
|
||||
const raceBase = path.join(home, ".qoder", "skills");
|
||||
const occupied = path.join(raceBase, "dingtalk-chat");
|
||||
writeFile(path.join(occupied, "concurrent-user-data.txt"), "must survive\n");
|
||||
let renames = 0;
|
||||
withSimulatedWin32(() => {
|
||||
assert.throws(
|
||||
() => publishCanonicalLinksAtomically(home, canonical, raceBase, ["dingtalk-chat"], [], {
|
||||
publishRenameFn(source, destination) {
|
||||
renames += 1;
|
||||
fs.renameSync(source, destination);
|
||||
},
|
||||
}),
|
||||
/Skill publish destination already exists/,
|
||||
);
|
||||
});
|
||||
assert.equal(renames, 0, "win32 publish must not rename over an existing destination");
|
||||
assert.deepEqual(fs.readdirSync(occupied), ["concurrent-user-data.txt"]);
|
||||
assert.equal(fs.readFileSync(path.join(occupied, "concurrent-user-data.txt"), "utf8"), "must survive\n");
|
||||
assert.equal(process.platform !== "win32", true, "the simulated platform is restored");
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
scenario("skill backups stay bounded to the newest five stamps", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-backup-prune-"));
|
||||
const home = path.join(tmp, "home");
|
||||
const backupRoot = path.join(home, ".dws", "skill-backups");
|
||||
try {
|
||||
for (const stamp of [
|
||||
"20260101-000001", "20260101-000002", "20260101-000003",
|
||||
"20260101-000004", "20260101-000005", "20260101-000006", "20260101-000007",
|
||||
]) {
|
||||
writeFile(path.join(backupRoot, stamp, "placeholder", "SKILL.md"), `${stamp}\n`);
|
||||
}
|
||||
const victim = path.join(home, ".codex", "skills", "dingtalk-chat");
|
||||
writeFile(path.join(victim, "SKILL.md"), "old codex copy\n");
|
||||
|
||||
const backup = backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => "20260102-000000" });
|
||||
|
||||
const stamps = fs.readdirSync(backupRoot).sort();
|
||||
assert.equal(stamps.length, 5, `backup root must stay bounded, got ${stamps.join(",")}`);
|
||||
assert.ok(stamps.includes("20260102-000000"), "the backup from this run survives pruning");
|
||||
assert.ok(!stamps.includes("20260101-000001"), "the oldest stamps are pruned first");
|
||||
assert.ok(!stamps.includes("20260101-000003"), "pruning removes exactly the excess");
|
||||
assert.ok(stamps.includes("20260101-000004"), "the newest kept stamps are retained");
|
||||
assert.equal(path.basename(backup), ".codex-skills-dingtalk-chat");
|
||||
assert.equal(fs.readFileSync(path.join(backup, "SKILL.md"), "utf8"), "old codex copy\n");
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
scenario("prune preserves unknown directories in skill-backups", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-prune-unknown-"));
|
||||
const home = path.join(tmp, "home");
|
||||
const backupRoot = path.join(home, ".dws", "skill-backups");
|
||||
try {
|
||||
for (const stamp of [
|
||||
"20260101-000001", "20260101-000002", "20260101-000003",
|
||||
"20260101-000004", "20260101-000005", "20260101-000006", "20260101-000007",
|
||||
]) {
|
||||
writeFile(path.join(backupRoot, stamp, "placeholder", "SKILL.md"), `${stamp}\n`);
|
||||
}
|
||||
const unknowns = [
|
||||
"user-personal-backup",
|
||||
"20260101-00000", // too few digits in the time part
|
||||
"20260101-000000-abc", // non-numeric suffix
|
||||
"notes",
|
||||
"20260101", // missing time portion
|
||||
];
|
||||
for (const name of unknowns) {
|
||||
writeFile(path.join(backupRoot, name, "marker.txt"), `unknown:${name}\n`);
|
||||
}
|
||||
|
||||
pruneSkillBackups(home);
|
||||
|
||||
const remaining = fs.readdirSync(backupRoot).sort();
|
||||
const stamped = remaining.filter((n) => isSkillBackupStamp(n));
|
||||
assert.equal(stamped.length, 5, `stamped backups = ${stamped.length}, want 5`);
|
||||
assert.ok(!stamped.includes("20260101-000001"), "oldest stamps pruned first");
|
||||
assert.ok(!stamped.includes("20260101-000002"), "second-oldest pruned");
|
||||
assert.ok(stamped.includes("20260101-000005"), "newest kept stamps retained");
|
||||
for (const name of unknowns) {
|
||||
assert.ok(remaining.includes(name), `unknown directory ${name} must survive pruning`);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(backupRoot, name, "marker.txt"), "utf8"),
|
||||
`unknown:${name}\n`,
|
||||
`unknown directory ${name} contents must be intact`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
scenario("multi set publish failure restores the complete previous set", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-multi-set-"));
|
||||
const home = path.join(tmp, "home");
|
||||
@@ -431,6 +1073,13 @@ scenario("multi set publish failure restores the complete previous set", () => {
|
||||
[first, second],
|
||||
{
|
||||
renameFn(src, dest) {
|
||||
if (
|
||||
src === first ||
|
||||
src === second ||
|
||||
(src.includes(`${path.sep}skill-backups${path.sep}`) && (dest === first || dest === second))
|
||||
) {
|
||||
throw crossDeviceError();
|
||||
}
|
||||
if (src.includes(".dws-multi-set.tmp-") && path.basename(src) === "dingtalk-second") {
|
||||
throw new Error("injected second publish failure");
|
||||
}
|
||||
@@ -514,6 +1163,14 @@ for (const failureKind of ["backup", "publish"]) {
|
||||
if (failureKind === "backup" && src === second) {
|
||||
throw new Error("injected second backup failure");
|
||||
}
|
||||
if (
|
||||
failureKind === "publish" &&
|
||||
(src === first ||
|
||||
src === second ||
|
||||
(src.includes(`${path.sep}skill-backups${path.sep}`) && (target === first || target === second)))
|
||||
) {
|
||||
throw crossDeviceError();
|
||||
}
|
||||
if (
|
||||
failureKind === "publish" &&
|
||||
src.includes(".dws-mono-set.tmp-") &&
|
||||
@@ -539,6 +1196,10 @@ for (const failureKind of ["backup", "publish"]) {
|
||||
});
|
||||
}
|
||||
|
||||
process.stdout.write("• EXDEV copy/verify/remove and transaction rollback contract ... ");
|
||||
runCrossDeviceMoveContract();
|
||||
process.stdout.write("ok\n");
|
||||
|
||||
for (const [name, fn] of scenarios) {
|
||||
process.stdout.write(`• ${name} ... `);
|
||||
fn();
|
||||
|
||||
+1162
-26
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,10 @@ var expectedPackagedSkillTargets = []string{
|
||||
".augment/skills/dingtalk-shared",
|
||||
".cline/skills/dingtalk-shared",
|
||||
".amp/skills/dingtalk-shared",
|
||||
".copilot/skills/dingtalk-shared",
|
||||
".config/opencode/skills/dingtalk-shared",
|
||||
".config/agents/skills/dingtalk-shared",
|
||||
".codeium/windsurf/skills/dingtalk-shared",
|
||||
".kiro/skills/dingtalk-shared",
|
||||
".trae/skills/dingtalk-shared",
|
||||
".openclaw/skills/dingtalk-shared",
|
||||
@@ -70,9 +74,12 @@ func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) {
|
||||
t.Fatal("package-manager verifier still requires the version marker to occupy a strings(1) line")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"HOME_SPECIFIC_SKILL_BASES=",
|
||||
`$base/dingtalk-shared/SKILL.md`,
|
||||
`$base/dingtalk-misc/SKILL.md`,
|
||||
"UPSTREAM_AGENT_REGISTRY=",
|
||||
"verify_compatible_skill_base",
|
||||
`diff -qr "$canonical/$name" "$target"`,
|
||||
"broken canonical Skill link",
|
||||
"Skill link does not resolve to canonical source",
|
||||
"Skill copy fallback differs from canonical source",
|
||||
"unexpected mono Skill layout",
|
||||
`verify_npm_install "$tarball_path" "specific-agent-roots"`,
|
||||
`verify_npm_install "$tarball_path" "generic-fallback"`,
|
||||
@@ -128,11 +135,18 @@ func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T)
|
||||
}
|
||||
|
||||
verifyCmd := exec.Command("sh", verifierPath, "--npm-only")
|
||||
verifyCmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+distDir)
|
||||
hostXDG := filepath.Join(t.TempDir(), "host-xdg")
|
||||
verifyCmd.Env = replaceTestEnv(os.Environ(),
|
||||
"DWS_PACKAGE_DIST_DIR", distDir,
|
||||
"XDG_CONFIG_HOME", hostXDG,
|
||||
)
|
||||
output, err := verifyCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("verify-package-managers.sh error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if _, err := os.Stat(hostXDG); !os.IsNotExist(err) {
|
||||
t.Fatalf("package verifier touched inherited XDG_CONFIG_HOME %s: %v", hostXDG, err)
|
||||
}
|
||||
for _, scenario := range []string{"specific-agent-roots", "generic-fallback"} {
|
||||
if !strings.Contains(string(output), "verifying npm package install ("+scenario+")") {
|
||||
t.Errorf("verifier output is missing %s scenario:\n%s", scenario, output)
|
||||
@@ -140,6 +154,102 @@ func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackageManagerVerifierAcceptsOnlyCorrectLinksOrCopies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "verify-package-managers.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(scriptPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
cut := strings.Index(text, "\nverify_npm_install()")
|
||||
if cut < 0 {
|
||||
t.Fatal("verify-package-managers.sh helper boundary missing")
|
||||
}
|
||||
library := filepath.Join(t.TempDir(), "verifier-lib.sh")
|
||||
mustWriteFile(t, library, data[:cut], 0o755)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
prepare func(t *testing.T, home, base string)
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "correct-link",
|
||||
prepare: func(t *testing.T, _, base string) {
|
||||
t.Helper()
|
||||
// The canonical layout publishes RELATIVE links; the verifier now
|
||||
// rejects absolute targets, so the fixture must match production.
|
||||
if err := os.Symlink(filepath.Join("..", "..", ".agents", "skills", "dingtalk-shared"), filepath.Join(base, "dingtalk-shared")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "complete-copy",
|
||||
prepare: func(t *testing.T, _, base string) {
|
||||
t.Helper()
|
||||
mustWriteFile(t, filepath.Join(base, "dingtalk-shared", "SKILL.md"), []byte("canonical\n"), 0o644)
|
||||
},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "wrong-link",
|
||||
prepare: func(t *testing.T, home, base string) {
|
||||
t.Helper()
|
||||
wrong := filepath.Join(home, "wrong", "dingtalk-shared")
|
||||
mustWriteFile(t, filepath.Join(wrong, "SKILL.md"), []byte("canonical\n"), 0o644)
|
||||
if err := os.Symlink(wrong, filepath.Join(base, "dingtalk-shared")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "broken-link",
|
||||
prepare: func(t *testing.T, home, base string) {
|
||||
t.Helper()
|
||||
if err := os.Symlink(filepath.Join(home, "missing"), filepath.Join(base, "dingtalk-shared")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "different-copy",
|
||||
prepare: func(t *testing.T, _, base string) {
|
||||
t.Helper()
|
||||
mustWriteFile(t, filepath.Join(base, "dingtalk-shared", "SKILL.md"), []byte("different\n"), 0o644)
|
||||
},
|
||||
wantOK: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
base := filepath.Join(home, ".claude", "skills")
|
||||
mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md"), []byte("canonical\n"), 0o644)
|
||||
if err := os.MkdirAll(base, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tc.prepare(t, home, base)
|
||||
cmd := exec.Command("sh", "-c", `. "$DWS_TEST_LIBRARY"; verify_compatible_skill_base "$DWS_TEST_HOME" "$DWS_TEST_BASE"`)
|
||||
cmd.Env = append(os.Environ(), "DWS_TEST_LIBRARY="+library, "DWS_TEST_HOME="+home, "DWS_TEST_BASE="+base)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if tc.wantOK && err != nil {
|
||||
t.Fatalf("verifier rejected valid target: %v\n%s", err, output)
|
||||
}
|
||||
if !tc.wantOK && err == nil {
|
||||
t.Fatalf("verifier accepted invalid target\n%s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func seedDistArchive(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
file, err := os.Create(path)
|
||||
|
||||
Reference in New Issue
Block a user