Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1bd518043 | ||
|
|
bac4fded0d | ||
|
|
82bfddc1c2 | ||
|
|
8cf23ee7cb | ||
|
|
5777ea36e9 | ||
|
|
6eceebd701 | ||
|
|
4b898e9011 | ||
|
|
cb14ae96b3 | ||
|
|
aeb4b2dcaa | ||
|
|
09f9289deb | ||
|
|
bbb14c24dc | ||
|
|
79f4be31d5 | ||
|
|
e2a1be5e93 | ||
|
|
69911543c3 | ||
|
|
d4eba7fa96 | ||
|
|
bbc2eb111c | ||
|
|
b58b8c51bf | ||
|
|
a7678472ab | ||
|
|
f413db06be | ||
|
|
3d67d83110 | ||
|
|
9de722ab34 | ||
|
|
df088573fb | ||
|
|
eebd6b2a1c | ||
|
|
181f030350 | ||
|
|
6140e503ec | ||
|
|
9539ae8e40 | ||
|
|
b1bfe6002d | ||
|
|
8e8e3a3ce8 | ||
|
|
9f4e748404 | ||
|
|
0d4bd28a08 | ||
|
|
9264323b29 | ||
|
|
1744880648 | ||
|
|
246f4ebaf5 | ||
|
|
ec5f312fd6 | ||
|
|
3c81741e2e | ||
|
|
293c085634 | ||
|
|
f8258576ef | ||
|
|
f57c002ae7 | ||
|
|
b17634ef7d | ||
|
|
e0fd344a26 | ||
|
|
c38e988b14 | ||
|
|
773e76a1c6 | ||
|
|
f4e39a219b | ||
|
|
c170a464e1 | ||
|
|
4665b42bbf | ||
|
|
16273de554 | ||
|
|
aabee99e3f | ||
|
|
9e3a083c27 | ||
|
|
3a0d814276 | ||
|
|
f3ddbb2db0 | ||
|
|
fbdb5e8d4d | ||
|
|
cc7e7bf0e0 | ||
|
|
01a7b20026 | ||
|
|
62541947e7 | ||
|
|
596da1343e | ||
|
|
12c7b6eb89 | ||
|
|
bc3d92ccaf | ||
|
|
61adc87987 | ||
|
|
c515f7c1e5 | ||
|
|
8eae408e28 | ||
|
|
9f3df91584 | ||
|
|
37cccdbc0e | ||
|
|
b6851e641e | ||
|
|
e02e4a666d | ||
|
|
d5c8982c00 | ||
|
|
402429ac2a |
@@ -6,6 +6,51 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.58-beta.4] - 2026-08-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Multi-skill installation and upgrade** — fresh installs, `dws skill setup`,
|
||||
and `dws upgrade` now use the multi-skill layout by default. Existing mono
|
||||
installations migrate during upgrade; mono remains an explicit legacy option.
|
||||
- **Native streaming-card mentions** — `dws chat message send-card` now accepts
|
||||
`--at-open-dingtalk-ids` and `--at-all` for group cards and forwards them to
|
||||
`create_and_send_card`, matching the existing shortcut behavior without
|
||||
changing single-chat card creation.
|
||||
- **Expanded Minutes workflows** — 27 public Minutes shortcuts now cover
|
||||
upload, download, export, recording, analysis, sharing, and recovery flows;
|
||||
every write command keeps an explicit confirmation requirement.
|
||||
- **Chat command discovery** — 30 existing typed Chat commands are now
|
||||
available in the runtime Schema and Agent catalog, with sensitive writes
|
||||
carrying their required confirmation metadata.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat read results** — typed commands and shortcuts now expose a consistent
|
||||
top-level `messages` list with stable `messageId` and `text` fields while
|
||||
retaining existing response envelopes and fields.
|
||||
- **Wiki feed results** — Wiki feed list output now formats time fields and
|
||||
trims excess fields. Its `--limit` default is 10 and maximum is 20.
|
||||
- **Developer command results** — the `dev` and selected `devapp` commands now
|
||||
use the unified result envelope for consistent success, pending, partial,
|
||||
and failure reporting.
|
||||
- **Evaluation dispatch hardening** — `/eval` now uses a verifiable polling
|
||||
relay instead of direct access from the hosted runner, binding the workflow,
|
||||
comment, PR head, parameters, and result provenance.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Streaming-card update acknowledgement** — accepts the pre-production
|
||||
`success: true` response from `update_streaming_card` as affirmative write
|
||||
evidence while preserving explicit negative, conflicting, and bizId-drift
|
||||
failures, so Agents do not repeat an update that the service already applied.
|
||||
- **Text input bounds** — literal input, stdin, and `@file` inputs now all
|
||||
enforce the same byte limit; file reads validate the opened descriptor and
|
||||
cannot exceed the limit after a path replacement or file growth.
|
||||
- **Evaluation PR comments** — restores `/eval` PR conversation comments with
|
||||
the least required pull-request write permission and actionable GitHub 403
|
||||
diagnostics.
|
||||
|
||||
## [1.0.58-beta.3] - 2026-08-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -70,15 +70,17 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
|
||||
|
||||
| Mode | What gets installed | Best for |
|
||||
|------|----------------------|----------|
|
||||
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
| **multi** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **multi** (default) | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **mono** (legacy) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
|
||||
> Installs and upgrades default to `multi`. `mono` remains available via `DWS_SKILL_MODE=mono` or `dws skill setup --mode mono`. File issues if you hit problems.
|
||||
|
||||
How to pick:
|
||||
|
||||
- **Quick install** (one-liner above): non-interactive, installs `mono`.
|
||||
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) mono 2) multi` (default 1).
|
||||
- **Override via env**: `DWS_SKILL_MODE=multi curl -fsSL ... | sh`.
|
||||
- **Switch later**: `dws skill setup --mode multi` (or `--mode mono`) — re-run any time.
|
||||
- **Quick install** (one-liner above): non-interactive, installs `multi`.
|
||||
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) multi 2) mono` (default 1).
|
||||
- **Override via env**: `DWS_SKILL_MODE=mono curl -fsSL ... | sh`.
|
||||
- **Switch later**: `dws skill setup --mode mono` (or `--mode multi`) — review the listed paths and confirm interactively.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -391,19 +393,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
|
||||
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
|
||||
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`.
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), legacy.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. Default layout.
|
||||
|
||||
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
|
||||
|
||||
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
|
||||
|
||||
```bash
|
||||
# Install skills into current project (defaults to mono)
|
||||
# Install skills into current project (defaults to multi; DWS_SKILL_MODE=mono switches back)
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
|
||||
> `install.sh` installs under `$HOME/.agents/skills/` (global; multi layout is per-product siblings, mono is the `dws/` subdirectory); `install-skills.sh` installs under `./.agents/skills/` (current project).
|
||||
>
|
||||
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
|
||||
|
||||
@@ -413,13 +415,18 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
# Interactive: prompts for mode + target agents
|
||||
dws skill setup
|
||||
|
||||
# Install mono skill to every detected agent home (claude / cursor / codex / opencode / qoder)
|
||||
dws skill setup --mode mono --target all --yes
|
||||
# Preview the exact directories that mono setup would back up and replace
|
||||
dws skill setup --mode mono --target all --dry-run
|
||||
|
||||
# Install multi skills to a single agent home
|
||||
dws skill setup --mode multi --target cursor --yes
|
||||
# Run interactively and confirm the listed directories
|
||||
dws skill setup --mode mono --target all
|
||||
|
||||
# Point at a local source tree (e.g. a fork or work-in-progress)
|
||||
# Preview, then install multi skills to a single agent home with interactive confirmation
|
||||
dws skill setup --mode multi --target cursor --dry-run
|
||||
dws skill setup --mode multi --target cursor
|
||||
|
||||
# Point at a local source tree (e.g. a fork or work-in-progress), preview first
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
```
|
||||
|
||||
@@ -428,7 +435,11 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
| `--mode` | `mono` \| `multi` | Skill layout; defaults to interactive prompt |
|
||||
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home |
|
||||
| `--source` | path | Local source directory (overrides bundled skills) |
|
||||
| `--yes` | — | Skip confirmation prompts |
|
||||
| `--yes` | — | Scripting-only: skip the confirmation prompt. Removals are still backed up to `~/.dws/skill-backups/` first |
|
||||
|
||||
> The setup command can remove the opposite-mode layout (`dws/` for multi, DWS-managed multi Skills for mono) and stale managed Skills not in the bundle. DWS records ownership, installer version, source, and content digest centrally in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Exact official names shipped before the centralized state remain a frozen migration list. A `dingtalk-*` prefix alone never authorizes cleanup, so other same-prefix market/user Skills are preserved. Every removal is previewed before confirmation and preserved under `~/.dws/skill-backups/<timestamp>/`; a directory that cannot be backed up is never removed. In a non-interactive shell, first run `--dry-run` and inspect its output; only then may the caller explicitly choose the scripting-only confirmation bypass.
|
||||
|
||||
After a multi setup or upgrade, DWS stores the official bundle snapshot and centralized ownership metadata in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Every upgrade installs and overwrites the complete bundled Skill set from that release. Deleting or excluding a bundled Skill is not sticky: the next upgrade restores it. `dws upgrade --force` additionally allows reinstalling the current CLI version when no newer version is available.
|
||||
|
||||
Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.ps1`), `DWS_SKILL_SOURCE=<path>`.
|
||||
|
||||
@@ -726,7 +737,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
|
||||
<summary>Coming soon</summary>
|
||||
|
||||
- `conference` (video meetings)
|
||||
- Multi-skill mode (experimental) — per-product skills under `skills/multi/`; opt in via `dws skill setup --mode multi`
|
||||
- Multi-skill mode (default) — per-product skills under `skills/multi/`; installs and upgrades default to it, `dws skill setup --mode mono` switches back after interactive confirmation
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+28
-17
@@ -70,15 +70,17 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
| **multi** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **multi**(默认) | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **mono**(legacy) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
|
||||
> 安装与升级默认均为 multi。mono 仍可通过 `DWS_SKILL_MODE=mono` 或 `dws skill setup --mode mono` 使用。问题请提 issue 反馈。
|
||||
|
||||
怎么选:
|
||||
|
||||
- **快速安装**(上方一行 curl):非交互,默认装 `mono`。
|
||||
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) mono 2) multi` 选项(默认 1)。
|
||||
- **环境变量覆盖**:`DWS_SKILL_MODE=multi curl -fsSL ... | sh`。
|
||||
- **装完之后再切换**:`dws skill setup --mode multi`(或 `--mode mono`),随时重跑都行。
|
||||
- **快速安装**(上方一行 curl):非交互,默认装 `multi`。
|
||||
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) multi 2) mono` 选项(默认 1)。
|
||||
- **环境变量覆盖**:`DWS_SKILL_MODE=mono curl -fsSL ... | sh`。
|
||||
- **装完之后再切换**:`dws skill setup --mode mono`(或 `--mode multi`),核对列出的路径后交互确认。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -385,19 +387,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
|
||||
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),legacy。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。默认布局。
|
||||
|
||||
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
|
||||
|
||||
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
|
||||
|
||||
```bash
|
||||
# 安装 skills 到当前项目(默认 mono)
|
||||
# 安装 skills 到当前项目(默认 multi;DWS_SKILL_MODE=mono 可切回)
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/`(全局,multi 为按产品平铺,mono 为 `dws/` 子目录);`install-skills.sh` 安装到 `./.agents/skills/`(当前项目)。
|
||||
>
|
||||
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
|
||||
|
||||
@@ -407,13 +409,18 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
# 交互式:提示选模式 + 目标 Agent
|
||||
dws skill setup
|
||||
|
||||
# 把 mono skill 铺到所有检测到的 Agent home(claude / cursor / codex / opencode / qoder)
|
||||
dws skill setup --mode mono --target all --yes
|
||||
# 先预览 mono setup 将备份和替换的精确目录
|
||||
dws skill setup --mode mono --target all --dry-run
|
||||
|
||||
# 只装到某一个 Agent home
|
||||
dws skill setup --mode multi --target cursor --yes
|
||||
# 交互执行并确认列出的目录
|
||||
dws skill setup --mode mono --target all
|
||||
|
||||
# 指定本地源目录(比如 fork 或正在改的版本)
|
||||
# 先预览,再交互确认装到某一个 Agent home
|
||||
dws skill setup --mode multi --target cursor --dry-run
|
||||
dws skill setup --mode multi --target cursor
|
||||
|
||||
# 指定本地源目录(比如 fork 或正在改的版本),先预览
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
```
|
||||
|
||||
@@ -422,7 +429,11 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
| `--mode` | `mono` \| `multi` | skill 布局,不指定则交互式询问 |
|
||||
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | 安装目标,`all` 表示铺到所有检测到的 Agent home |
|
||||
| `--source` | 路径 | 本地源目录(覆盖内置 skills) |
|
||||
| `--yes` | — | 跳过确认提示 |
|
||||
| `--yes` | — | 仅供脚本使用:跳过确认提示。删除操作仍会先备份到 `~/.dws/skill-backups/` |
|
||||
|
||||
> setup 命令可能移除对面模式残留(装 multi 删 `dws/`,装 mono 清理统一状态中登记或属于状态上线前精确官方名称集合的 multi Skill)以及不在 bundle 内的过期受管 Skill。DWS 在 `~/.dws/skills-state.json`(或 `$DWS_CONFIG_DIR/skills-state.json`)集中记录所有权、安装版本、来源和内容摘要。仅有 `dingtalk-*` 前缀不能触发清理,因此其他同前缀市场/用户 Skill 会保留。所有删除都会先列入确认预览,并备份到 `~/.dws/skill-backups/<时间戳>/`;备份失败的目录会保留原样、绝不删除。非交互环境应先用 `--dry-run` 核对输出,再由调用方显式决定是否使用仅供脚本的确认跳过参数。
|
||||
|
||||
multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权元数据写入 `~/.dws/skills-state.json`(或 `$DWS_CONFIG_DIR/skills-state.json`)。每次 upgrade 都会安装并覆盖该版本的全部预制 Skill;手工删除或通过 setup 排除预制 Skill 不会永久保留,下次 upgrade 会恢复。`dws upgrade --force` 还允许在没有新版本时重装当前 CLI 版本。
|
||||
|
||||
环境变量:`DWS_SKILL_MODE=mono|multi`(`install.sh` / `install.ps1` 也认)、`DWS_SKILL_SOURCE=<路径>`。
|
||||
|
||||
@@ -715,7 +726,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
|
||||
<summary>即将推出</summary>
|
||||
|
||||
- `conference`(视频会议)
|
||||
- 多 skill 模式(实验中)— 每产品一个独立 skill,位于 `skills/multi/`,通过 `dws skill setup --mode multi` 启用
|
||||
- 多 skill 模式(默认)— 每产品一个独立 skill,位于 `skills/multi/`,安装与升级默认启用;`dws skill setup --mode mono` 交互确认后可切回单 skill
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+570
-17
@@ -3,6 +3,7 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const childProcess = require("child_process");
|
||||
@@ -45,6 +46,58 @@ function ensureCleanDir(dir) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// backupStamp returns the UTC timestamp used for backup directory names,
|
||||
// matching the shell installers' `date -u +%Y%m%d-%H%M%S` layout.
|
||||
function backupStamp() {
|
||||
const d = new Date();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
|
||||
`-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
// backupAndRemoveSkillDir moves dir into <homeDir>/.dws/skill-backups/
|
||||
// <stamp>/<rel-or-basename> instead of destroying it (non-interactive
|
||||
// installs cannot confirm, so removals must stay reversible). Missing paths
|
||||
// are a no-op success. On any backup failure the directory is left in place
|
||||
// and false is returned so callers skip that target rather than silently
|
||||
// deleting data.
|
||||
function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.renameSync) {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||
return true;
|
||||
}
|
||||
const rel = path.relative(homeDir, dir);
|
||||
const name =
|
||||
rel && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel)
|
||||
? rel.split(path.sep).join("-")
|
||||
: path.basename(dir);
|
||||
const stamp = backupStamp();
|
||||
const backupRoot = path.join(homeDir, ".dws", "skill-backups");
|
||||
let targetRoot = path.join(backupRoot, stamp);
|
||||
let target = path.join(targetRoot, name);
|
||||
for (let i = 1; fs.existsSync(target); i++) {
|
||||
if (i > 1000) {
|
||||
console.warn(`⚠️ 备份目录冲突,保留原目录 ${dir}`);
|
||||
return false;
|
||||
}
|
||||
targetRoot = path.join(backupRoot, `${stamp}-${i}`);
|
||||
target = path.join(targetRoot, name);
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
renameFn(dir, target);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ 备份失败,保留原目录 ${dir}: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
if (backups) {
|
||||
backups.push({ original: dir, backup: target });
|
||||
}
|
||||
console.log(` × 已备份并移除 ${dir} → ${target}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function findBinary(root) {
|
||||
const entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
@@ -117,9 +170,86 @@ function copyChildren(srcDir, destDir) {
|
||||
}
|
||||
}
|
||||
|
||||
// publishCacheAtomically prepares a complete sibling tree before replacing a
|
||||
// cache. If copying or publishing fails, the previous cache stays available.
|
||||
// copyFn is injectable so the failure contract can be tested without relying
|
||||
// on platform-specific permission behavior.
|
||||
function publishCacheAtomically(sourceDir, cacheDir, copyFn = copyChildren) {
|
||||
const cacheParent = path.dirname(cacheDir);
|
||||
const cacheName = path.basename(cacheDir);
|
||||
fs.mkdirSync(cacheParent, { recursive: true });
|
||||
|
||||
const stagedDir = fs.mkdtempSync(path.join(cacheParent, `.${cacheName}.tmp-`));
|
||||
let rollbackDir = "";
|
||||
let published = false;
|
||||
try {
|
||||
copyFn(sourceDir, stagedDir);
|
||||
|
||||
if (fs.existsSync(cacheDir)) {
|
||||
rollbackDir = fs.mkdtempSync(path.join(cacheParent, `.${cacheName}.old-`));
|
||||
fs.rmSync(rollbackDir, { recursive: true, force: true });
|
||||
fs.renameSync(cacheDir, rollbackDir);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.renameSync(stagedDir, cacheDir);
|
||||
published = true;
|
||||
} catch (publishErr) {
|
||||
if (rollbackDir) {
|
||||
try {
|
||||
fs.renameSync(rollbackDir, cacheDir);
|
||||
rollbackDir = "";
|
||||
} catch (restoreErr) {
|
||||
throw new Error(
|
||||
`failed to publish cache ${cacheDir}: ${publishErr.message}; ` +
|
||||
`failed to restore previous cache from ${rollbackDir}: ${restoreErr.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw publishErr;
|
||||
}
|
||||
|
||||
if (rollbackDir) {
|
||||
try {
|
||||
fs.rmSync(rollbackDir, { recursive: true, force: true });
|
||||
} catch (cleanupErr) {
|
||||
console.warn(
|
||||
`⚠️ New cache is active, but old cache cleanup failed at ${rollbackDir}: ${cleanupErr.message}`,
|
||||
);
|
||||
}
|
||||
rollbackDir = "";
|
||||
}
|
||||
} finally {
|
||||
if (!published) {
|
||||
fs.rmSync(stagedDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function installSkillsToHomes(skillRoot) {
|
||||
const homeDir = os.homedir();
|
||||
const managedNames = readManagedSkillNames(homeDir);
|
||||
let installed = 0;
|
||||
let attempted = 0;
|
||||
let failed = 0;
|
||||
|
||||
const installToBase = (baseDir) => {
|
||||
const victims = [path.join(baseDir, "dws")];
|
||||
if (fs.existsSync(baseDir)) {
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
|
||||
victims.push(path.join(baseDir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
publishManagedMonoSkillSetAtomically(homeDir, skillRoot, baseDir, victims);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ 跳过 ${baseDir}(mono 集合发布失败,已回滚): ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
AGENT_DIRS.forEach((agentDir, index) => {
|
||||
const baseDir = path.join(homeDir, agentDir);
|
||||
@@ -127,37 +257,434 @@ function installSkillsToHomes(skillRoot) {
|
||||
if (index > 0 && !fs.existsSync(parentGate)) {
|
||||
return;
|
||||
}
|
||||
const destDir = path.join(baseDir, "dws");
|
||||
fs.rmSync(destDir, { recursive: true, force: true });
|
||||
copyChildren(skillRoot, destDir);
|
||||
installed += 1;
|
||||
attempted += 1;
|
||||
if (installToBase(baseDir)) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (installed === 0) {
|
||||
copyChildren(skillRoot, path.join(homeDir, ".agents", "skills", "dws"));
|
||||
if (attempted === 0) {
|
||||
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
if (installed === 0) {
|
||||
throw new Error("未安装任何 mono Skill:所有检测到的 Agent 目标均失败");
|
||||
}
|
||||
if (failed > 0) {
|
||||
throw new Error(`有 ${failed} 个 Agent 目标安装 mono Skill 失败`);
|
||||
}
|
||||
fs.rmSync(path.join(skillStateDir(homeDir), "skills-state.json"), { force: true });
|
||||
}
|
||||
|
||||
// multiTreeHasSkills mirrors multi_tree_has_skills in scripts/install.sh and
|
||||
// Test-MultiTreeHasSkills in scripts/install.ps1: true only when the multi
|
||||
// bundle carries at least one product skill (a subdir with SKILL.md). An
|
||||
// empty or corrupt multi/ tree must never select the multi branch nor refresh
|
||||
// the multi cache — installing it would wipe existing skills and lay down
|
||||
// nothing.
|
||||
function multiTreeHasSkills(dir) {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||
return false;
|
||||
}
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.some((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, "SKILL.md")));
|
||||
}
|
||||
|
||||
const MANAGED_SKILL_DIGEST_SCOPE = "skill-directory-v1";
|
||||
// Frozen exact names shipped before centralized ownership metadata. Retired
|
||||
// names stay here so old installs can be migrated without treating every
|
||||
// dingtalk-* directory as DWS-owned.
|
||||
const LEGACY_OFFICIAL_MULTI_SKILLS = new Set([
|
||||
"dingtalk-agoal", "dingtalk-aiapp", "dingtalk-aisearch", "dingtalk-aitable",
|
||||
"dingtalk-attendance", "dingtalk-calendar", "dingtalk-chat", "dingtalk-contact",
|
||||
"dingtalk-dev", "dingtalk-devapp", "dingtalk-devdoc", "dingtalk-ding",
|
||||
"dingtalk-doc", "dingtalk-drive", "dingtalk-event", "dingtalk-hrbrain",
|
||||
"dingtalk-live", "dingtalk-mail", "dingtalk-markdown", "dingtalk-minutes",
|
||||
"dingtalk-misc", "dingtalk-oa", "dingtalk-pat", "dingtalk-profile",
|
||||
"dingtalk-report", "dingtalk-shared", "dingtalk-sheet", "dingtalk-skill",
|
||||
"dingtalk-todo", "dingtalk-wiki", "dws-shared",
|
||||
]);
|
||||
|
||||
function skillStateDir(homeDir) {
|
||||
return (process.env.DWS_CONFIG_DIR || "").trim() || path.join(homeDir, ".dws");
|
||||
}
|
||||
|
||||
function readManagedSkillNames(homeDir) {
|
||||
try {
|
||||
const state = JSON.parse(fs.readFileSync(path.join(skillStateDir(homeDir), "skills-state.json"), "utf8"));
|
||||
return new Set((state.managed_skills || []).map((record) => record.name).filter(Boolean));
|
||||
} catch (_) {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function isManagedMultiSkillDir(dir, managedNames) {
|
||||
const name = path.basename(dir);
|
||||
return LEGACY_OFFICIAL_MULTI_SKILLS.has(name) || managedNames.has(name);
|
||||
}
|
||||
|
||||
function skillDirectoryDigest(dir) {
|
||||
const files = [];
|
||||
const visit = (current, prefix) => {
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(full, rel);
|
||||
} else {
|
||||
files.push({ rel, full });
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(dir, "");
|
||||
files.sort((a, b) => Buffer.from(a.rel).compare(Buffer.from(b.rel)));
|
||||
const hash = crypto.createHash("sha256");
|
||||
for (const file of files) {
|
||||
hash.update(file.rel, "utf8");
|
||||
hash.update(Buffer.from([0]));
|
||||
hash.update(fs.readFileSync(file.full));
|
||||
hash.update(Buffer.from([0]));
|
||||
}
|
||||
return `sha256:${hash.digest("hex")}`;
|
||||
}
|
||||
|
||||
// Publish a complete multi-skill set as one transaction. The entire new set
|
||||
// is staged before any Agent-visible directory moves. If a later backup or
|
||||
// publish fails, every partial publication is removed and all old directories
|
||||
// are restored from their exact backup paths.
|
||||
function publishManagedMultiSkillSetAtomically(
|
||||
homeDir,
|
||||
multiRoot,
|
||||
baseDir,
|
||||
skills,
|
||||
victims,
|
||||
options = {},
|
||||
) {
|
||||
const copyFn = options.copyFn || copyChildren;
|
||||
const renameFn = options.renameFn || fs.renameSync;
|
||||
const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-multi-set.tmp-"));
|
||||
const staged = [];
|
||||
const backups = [];
|
||||
const published = [];
|
||||
|
||||
const restore = () => {
|
||||
const restoreErrors = [];
|
||||
for (let i = published.length - 1; i >= 0; i -= 1) {
|
||||
try {
|
||||
removeFn(published[i]);
|
||||
} catch (err) {
|
||||
restoreErrors.push(`remove ${published[i]}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
for (let i = backups.length - 1; i >= 0; i -= 1) {
|
||||
const item = backups[i];
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(item.original), { recursive: true });
|
||||
renameFn(item.backup, item.original);
|
||||
} catch (err) {
|
||||
restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
if (restoreErrors.length > 0) {
|
||||
throw new Error(restoreErrors.join("; "));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
for (const name of skills) {
|
||||
const stagedDir = path.join(stageRoot, name);
|
||||
copyFn(path.join(multiRoot, name), stagedDir);
|
||||
staged.push({ staged: stagedDir, dest: path.join(baseDir, name) });
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
for (const victim of victims) {
|
||||
const normalized = path.resolve(victim);
|
||||
if (seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) {
|
||||
throw new Error(`failed to back up Skill directory ${victim}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of staged) {
|
||||
renameFn(item.staged, item.dest);
|
||||
published.push(item.dest);
|
||||
}
|
||||
} catch (err) {
|
||||
try {
|
||||
restore();
|
||||
} catch (restoreErr) {
|
||||
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
removeFn(stageRoot);
|
||||
}
|
||||
}
|
||||
|
||||
// Publish mono plus every mutually-exclusive managed multi victim as one
|
||||
// transaction. The complete dws/ tree is staged before any live directory is
|
||||
// moved; a later backup or publish failure restores the exact previous set.
|
||||
function publishManagedMonoSkillSetAtomically(
|
||||
homeDir,
|
||||
monoRoot,
|
||||
baseDir,
|
||||
victims,
|
||||
options = {},
|
||||
) {
|
||||
const copyFn = options.copyFn || copyChildren;
|
||||
const renameFn = options.renameFn || fs.renameSync;
|
||||
const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-mono-set.tmp-"));
|
||||
const stagedDir = path.join(stageRoot, "dws");
|
||||
const destDir = path.join(baseDir, "dws");
|
||||
const backups = [];
|
||||
const published = [];
|
||||
|
||||
const restore = () => {
|
||||
const restoreErrors = [];
|
||||
for (let i = published.length - 1; i >= 0; i -= 1) {
|
||||
try {
|
||||
removeFn(published[i]);
|
||||
} catch (err) {
|
||||
restoreErrors.push(`remove ${published[i]}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
for (let i = backups.length - 1; i >= 0; i -= 1) {
|
||||
const item = backups[i];
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(item.original), { recursive: true });
|
||||
renameFn(item.backup, item.original);
|
||||
} catch (err) {
|
||||
restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
if (restoreErrors.length > 0) {
|
||||
throw new Error(restoreErrors.join("; "));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
copyFn(monoRoot, stagedDir);
|
||||
|
||||
const seen = new Set();
|
||||
for (const victim of victims) {
|
||||
const normalized = path.resolve(victim);
|
||||
if (seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) {
|
||||
throw new Error(`failed to back up Skill directory ${victim}`);
|
||||
}
|
||||
}
|
||||
|
||||
published.push(destDir);
|
||||
renameFn(stagedDir, destDir);
|
||||
} catch (err) {
|
||||
try {
|
||||
restore();
|
||||
} catch (restoreErr) {
|
||||
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
removeFn(stageRoot);
|
||||
}
|
||||
}
|
||||
|
||||
function writeSkillsState(homeDir, multiRoot, skills) {
|
||||
const version = process.env.npm_package_version || process.env.DWS_PACKAGE_VERSION || "unknown";
|
||||
const managedSkills = [...skills].sort().map((name) => ({
|
||||
name,
|
||||
version,
|
||||
source: "npm-postinstall",
|
||||
digest: skillDirectoryDigest(path.join(multiRoot, name)),
|
||||
digest_scope: MANAGED_SKILL_DIGEST_SCOPE,
|
||||
}));
|
||||
const state = {
|
||||
version,
|
||||
official_skills: [...skills].sort(),
|
||||
updated_skills: [...skills].sort(),
|
||||
managed_skills: managedSkills,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
const stateDir = skillStateDir(homeDir);
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const stage = fs.mkdtempSync(path.join(stateDir, ".skills-state.tmp-"));
|
||||
const stagedFile = path.join(stage, "skills-state.json");
|
||||
const statePath = path.join(stateDir, "skills-state.json");
|
||||
const rollbackPath = path.join(stage, "skills-state.previous.json");
|
||||
let movedPrevious = false;
|
||||
let preserveRecovery = false;
|
||||
try {
|
||||
fs.writeFileSync(stagedFile, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
if (fs.existsSync(statePath)) {
|
||||
fs.renameSync(statePath, rollbackPath);
|
||||
movedPrevious = true;
|
||||
}
|
||||
try {
|
||||
fs.renameSync(stagedFile, statePath);
|
||||
} catch (err) {
|
||||
if (movedPrevious && !fs.existsSync(statePath)) {
|
||||
try {
|
||||
fs.renameSync(rollbackPath, statePath);
|
||||
movedPrevious = false;
|
||||
} catch (restoreErr) {
|
||||
preserveRecovery = true;
|
||||
throw new Error(
|
||||
`publish skills state failed: ${err.message}; restore also failed: ${restoreErr.message}; previous state retained at ${rollbackPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
if (!preserveRecovery) {
|
||||
fs.rmSync(stage, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// installMultiSkillsToHomes mirrors installSkillsToHomes for the multi bundle:
|
||||
// every product skill becomes a sibling directory of the agent home. Mutual
|
||||
// exclusion: the mono leftover (dws/) and stale, proven DWS-managed skills not
|
||||
// present in the new bundle are removed first.
|
||||
function installMultiSkillsToHomes(multiRoot) {
|
||||
const homeDir = os.homedir();
|
||||
const skills = fs
|
||||
.readdirSync(multiRoot, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && fs.existsSync(path.join(multiRoot, e.name, "SKILL.md")))
|
||||
.map((e) => e.name);
|
||||
if (skills.length === 0) {
|
||||
throw new Error(`no product skills found under ${multiRoot}`);
|
||||
}
|
||||
const skillSet = new Set(skills);
|
||||
const managedNames = readManagedSkillNames(homeDir);
|
||||
let installed = 0;
|
||||
let attempted = 0;
|
||||
let failed = 0;
|
||||
|
||||
const installToBase = (baseDir) => {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
const victims = [path.join(baseDir, "dws")];
|
||||
// Mutual exclusion: include the mono leftover and stale managed skills in
|
||||
// the same transaction as every replaced bundled skill.
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (
|
||||
entry.isDirectory() &&
|
||||
(LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) &&
|
||||
!skillSet.has(entry.name)
|
||||
) {
|
||||
victims.push(path.join(baseDir, entry.name));
|
||||
}
|
||||
}
|
||||
for (const name of skills) {
|
||||
victims.push(path.join(baseDir, name));
|
||||
}
|
||||
try {
|
||||
publishManagedMultiSkillSetAtomically(homeDir, multiRoot, baseDir, skills, victims);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ 跳过 ${baseDir}(multi 集合发布失败,已回滚): ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
AGENT_DIRS.forEach((agentDir, index) => {
|
||||
const baseDir = path.join(homeDir, agentDir);
|
||||
const parentGate = path.dirname(baseDir);
|
||||
if (index > 0 && !fs.existsSync(parentGate)) {
|
||||
return;
|
||||
}
|
||||
attempted += 1;
|
||||
if (installToBase(baseDir)) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (attempted === 0) {
|
||||
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
if (installed === 0) {
|
||||
throw new Error("未安装任何 multi Skill:所有检测到的 Agent 目标均失败");
|
||||
}
|
||||
if (failed > 0) {
|
||||
throw new Error(`有 ${failed} 个 Agent 目标安装 multi Skill 失败`);
|
||||
}
|
||||
writeSkillsState(homeDir, multiRoot, skills);
|
||||
}
|
||||
|
||||
// resolveSkillMode mirrors scripts/install.sh: DWS_SKILL_MODE (mono|multi)
|
||||
// wins; multi is the default. The --skill-mode flag accepts both the space
|
||||
// form (`--skill-mode mono`) and the equals form (`--skill-mode=mono`).
|
||||
function resolveSkillMode() {
|
||||
const raw = (process.env.DWS_SKILL_MODE || "").trim().toLowerCase();
|
||||
if (raw === "mono" || raw === "multi") {
|
||||
return raw;
|
||||
}
|
||||
if (raw !== "") {
|
||||
throw new Error(`invalid DWS_SKILL_MODE='${process.env.DWS_SKILL_MODE}'. Use 'mono' or 'multi'.`);
|
||||
}
|
||||
let fromFlag;
|
||||
const flagIndex = process.argv.indexOf("--skill-mode");
|
||||
if (flagIndex !== -1 && process.argv[flagIndex + 1]) {
|
||||
fromFlag = process.argv[flagIndex + 1];
|
||||
} else {
|
||||
const equalsArg = process.argv.find((arg) => arg.startsWith("--skill-mode="));
|
||||
if (equalsArg) {
|
||||
fromFlag = equalsArg.slice("--skill-mode=".length);
|
||||
}
|
||||
}
|
||||
if (fromFlag !== undefined) {
|
||||
const mode = fromFlag.trim().toLowerCase();
|
||||
if (mode === "mono" || mode === "multi") {
|
||||
return mode;
|
||||
}
|
||||
throw new Error(`invalid --skill-mode '${fromFlag}'. Use 'mono' or 'multi'.`);
|
||||
}
|
||||
return "multi";
|
||||
}
|
||||
|
||||
// cacheUserSkills copies the mono and multi trees out of the freshly extracted
|
||||
// dws-skills.zip into ~/.dws/skills/{mono,multi}/ so that `dws skill setup`
|
||||
// can fall back to a user-local cache when --source is not provided. mono is
|
||||
// already installed into agent homes by installSkillsToHomes; the cache is
|
||||
// purely a source-of-truth for the setup command.
|
||||
// can fall back to a user-local cache when --source is not provided. A cache
|
||||
// is only refreshed when the new bundle actually carries that tree — an
|
||||
// empty/corrupt multi/ (or a missing mono tree) must never wipe a previously
|
||||
// good cache.
|
||||
function cacheUserSkills(extractedSkillsRoot) {
|
||||
const cacheBase = path.join(os.homedir(), ".dws", "skills");
|
||||
|
||||
const monoSource = fs.existsSync(path.join(extractedSkillsRoot, "mono", "SKILL.md"))
|
||||
? path.join(extractedSkillsRoot, "mono")
|
||||
: extractedSkillsRoot;
|
||||
const monoCache = path.join(cacheBase, "mono");
|
||||
fs.rmSync(monoCache, { recursive: true, force: true });
|
||||
copyChildren(monoSource, monoCache);
|
||||
if (fs.existsSync(path.join(monoSource, "SKILL.md"))) {
|
||||
const monoCache = path.join(cacheBase, "mono");
|
||||
publishCacheAtomically(monoSource, monoCache);
|
||||
}
|
||||
|
||||
const multiSource = path.join(extractedSkillsRoot, "multi");
|
||||
if (fs.existsSync(multiSource) && fs.statSync(multiSource).isDirectory()) {
|
||||
if (multiTreeHasSkills(multiSource)) {
|
||||
const multiCache = path.join(cacheBase, "multi");
|
||||
fs.rmSync(multiCache, { recursive: true, force: true });
|
||||
copyChildren(multiSource, multiCache);
|
||||
publishCacheAtomically(multiSource, multiCache);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,8 +718,34 @@ function main() {
|
||||
const monoRoot = fs.existsSync(path.join(skillsStaging, "mono", "SKILL.md"))
|
||||
? path.join(skillsStaging, "mono")
|
||||
: skillsStaging;
|
||||
installSkillsToHomes(monoRoot);
|
||||
// A mono install requires an actual SKILL.md at the root of monoRoot. On a
|
||||
// multi-only zip monoRoot would degrade to the staging root and copy the
|
||||
// whole bundle (multi/ included) into a dws/ directory — skip instead.
|
||||
const monoHasSkill = fs.existsSync(path.join(monoRoot, "SKILL.md"));
|
||||
const multiRoot = path.join(skillsStaging, "multi");
|
||||
const skillMode = resolveSkillMode();
|
||||
if (skillMode === "multi" && multiTreeHasSkills(multiRoot)) {
|
||||
console.log(`Skill mode: multi — installing per-product skills`);
|
||||
installMultiSkillsToHomes(multiRoot);
|
||||
} else {
|
||||
if (skillMode === "multi") {
|
||||
console.log("multi skill tree not found or empty in bundle; falling back to mono.");
|
||||
}
|
||||
if (monoHasSkill) {
|
||||
installSkillsToHomes(monoRoot);
|
||||
} else {
|
||||
console.log("mono skill tree not found in bundle; skipping skill install.");
|
||||
}
|
||||
}
|
||||
cacheUserSkills(skillsStaging);
|
||||
}
|
||||
|
||||
main();
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
publishCacheAtomically,
|
||||
publishManagedMonoSkillSetAtomically,
|
||||
publishManagedMultiSkillSetAtomically,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# RFC:DWS 预制 Skill 安装、升级与模式迁移
|
||||
|
||||
| 字段 | 内容 |
|
||||
|---|---|
|
||||
| 状态 | Accepted / as implemented |
|
||||
| 生效范围 | DWS CLI、升级器、npm 与平台安装脚本 |
|
||||
| 事实源 | 本 RFC 与当前代码;两者冲突时以代码和测试为准 |
|
||||
| 关联合同 | [Skill 内容框架](skill-content-framework.md)、[Mono↔Multi 内容质检](skill-mono-multi-qa.md) |
|
||||
|
||||
## 1. 背景
|
||||
|
||||
DWS 同时通过 CLI、升级器、npm、Shell 和 PowerShell 分发预制 Skill。multi
|
||||
成为默认布局后,所有入口必须对安装集合、模式互斥、失败退出、缓存发布和目录
|
||||
所有权保持一致。此前分散的调研、迁移计划、阶段性 roadmap 和 rollout 文档容易
|
||||
相互冲突;本 RFC 将最终行为收敛为一个长期合同。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
- 新装与升级默认使用 multi 布局,mono 在兼容期内保留显式 opt-in。
|
||||
- 每次升级使用当前版本的官方清单全量覆盖预制 Skill。
|
||||
- 删除或替换任何目录前先创建可恢复备份,备份失败不修改该 Agent 目标。
|
||||
- 只清理能够证明由 DWS 管理的目录,不通过名称前缀推断所有权。
|
||||
- 所有安装入口对部分失败返回非零状态,不误报整体成功。
|
||||
- 安装预览、确认和实际执行使用同一份计划。
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
- 不建设独立的 `dws skill mode status|set|rollback` 产品面。
|
||||
- 不持久化用户对预制 Skill 的本地删除或排除意图。
|
||||
- 不提供跨所有 Agent 目标的事务式回滚。
|
||||
- 不把市场 Skill 纳入预制 Skill 的升级和清理范围。
|
||||
|
||||
## 3. 业内调研
|
||||
|
||||
对主流 CLI 与 Agent Skill 分发方式的公开实现进行归纳后,可以得到以下共性:
|
||||
|
||||
| 观察 | 对 DWS 的启示 |
|
||||
|---|---|
|
||||
| 多个产品能力通常以同级 Skill 目录安装,由 Agent 按目录发现 | multi 使用平铺的产品 Skill,并保留一个共享 Skill 承载公共协议 |
|
||||
| CLI 本体安装和 Agent Skill 安装是两个生命周期 | DWS 可以在 CLI 安装、setup 和 upgrade 中触发 Skill 同步,但二者的失败与状态必须分别报告 |
|
||||
| 生态安装器通常天然采用 multi,不提供 mono/multi 状态机 | DWS 的模式切换保持为重新执行 setup,不新增长期驻留的 mode lifecycle |
|
||||
| 市场 Skill 与 CLI 预制 Skill 可能落在同一 Agent 根目录 | 必须使用统一所有权元数据识别受管目录,名称前缀不能作为删除依据 |
|
||||
| 多 Skill 更新常以新清单刷新官方集合 | DWS 使用当前 bundle 官方清单全量覆盖,新增 Skill 自动加入,本地删除不视为持久化排除 |
|
||||
| 制品可能需要同时服务无运行时依赖、离线和多镜像环境 | DWS 保留 embed、zip 和平台安装脚本,不把单一生态包管理器设为唯一入口 |
|
||||
| 中断的复制和原地覆盖容易破坏最后一个可用版本 | 缓存与 Go upgrade 的 Agent 目标采用 staging publish;发布失败自动恢复该目标的完整旧集合 |
|
||||
| Agent 通常以 `SKILL.md` 为入口,其他文件按引用或工具规则按需读取 | 安装元数据使用不被内容引用的隐藏文件,并保证其内容不包含 Agent 指令 |
|
||||
|
||||
本节只保留可复用的工程结论,不记录具体产品、仓库、版本或逐项能力对照,也不构成
|
||||
DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约束和本 RFC 的行为合同为准。
|
||||
|
||||
## 4. 布局合同
|
||||
|
||||
| 模式 | Agent 目录布局 | 选择方式 |
|
||||
|---|---|---|
|
||||
| multi(默认) | `<agent-home>/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` |
|
||||
| mono(兼容) | `<agent-home>/dws/` | `dws skill setup --mode mono` 或安装器的 mono opt-in |
|
||||
|
||||
模式切换通过重新执行 setup 完成。安装 multi 前备份并移除 mono 的 `dws/`;安装
|
||||
mono 前只备份并移除能够证明由 DWS 管理的 multi 目录。两个方向都不提供隐式、
|
||||
不可恢复的删除。
|
||||
|
||||
## 5. 官方集合与升级策略
|
||||
|
||||
当前版本 bundle 中的 multi 目录清单是升级集合的唯一权威来源。普通 upgrade 和
|
||||
`--force` 都安装并覆盖该版本的全部官方预制 Skill:
|
||||
|
||||
- 本地删除的预制 Skill 会在下一次升级恢复;
|
||||
- setup 时通过 `--exclude` 暂时排除的 Skill 会在下一次升级恢复;
|
||||
- 新版本新增的官方 Skill 会自动安装;
|
||||
- 用户对预制 Skill 的本地修改会被官方版本覆盖;
|
||||
- `dingtalk-shared` 始终随官方集合安装。
|
||||
|
||||
`~/.dws/skills-state.json`(设置 `DWS_CONFIG_DIR` 时位于该目录)不参与安装集合
|
||||
求解,也不保存排除策略。它既记录结果快照,也集中记录 multi Skill 的所有权和
|
||||
provenance,供安全清理、诊断与后续迁移使用。
|
||||
|
||||
## 6. 目录所有权
|
||||
|
||||
每次 multi setup 或 upgrade 全部成功后,DWS 在统一的
|
||||
`~/.dws/skills-state.json` 中写入:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "v0.2.14",
|
||||
"official_skills": ["dingtalk-aitable"],
|
||||
"updated_skills": ["dingtalk-aitable"],
|
||||
"managed_skills": [
|
||||
{
|
||||
"name": "dingtalk-aitable",
|
||||
"version": "v0.2.14",
|
||||
"source": "dws-upgrade",
|
||||
"digest": "sha256:<64 个十六进制字符>",
|
||||
"digest_scope": "skill-directory-v1"
|
||||
}
|
||||
],
|
||||
"updated_at": "2026-08-11T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
每条 `managed_skills` 记录代表一个由 DWS 管理的官方 Skill。`version` 记录安装该
|
||||
副本的 DWS/发布包版本,`source` 记录安装入口,`digest` 是对 bundle 中 Skill 目录
|
||||
全部普通文件按相对路径排序后计算的内容摘要。摘要用于诊断和来源追踪,不作为后续
|
||||
升级的完整性门禁;用户修改 Skill 内容后,DWS 仍保有明确管理权并能在下一次升级时
|
||||
覆盖恢复。
|
||||
|
||||
清理 stale Skill 或切换到 mono 时,只接受以下所有权证据:
|
||||
|
||||
1. Skill 名称存在于统一状态的 `managed_skills` 中;
|
||||
2. 统一状态上线前曾发布过的官方 Skill 精确名称集合。
|
||||
|
||||
历史集合是冻结的迁移清单,包含 `dws-shared` 以及已退役、折叠或仍在发布的旧官方
|
||||
目录名。仅有 `dingtalk-*` 前缀不构成所有权证据。因此,市场或用户创建的
|
||||
`dingtalk-custom` 等非官方精确名称目录不会被迁走。
|
||||
|
||||
### 6.1 对 Agent 的影响
|
||||
|
||||
Skill 目录内不再放置 DWS 所有权文件,也不增加非通用 frontmatter 字段。支持的
|
||||
Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agent Skill 目录之外,
|
||||
不会成为提示词上下文或影响 Agent 行为。
|
||||
|
||||
## 7. Setup:Plan → Confirm → Execute
|
||||
|
||||
`dws skill setup` 分为三个阶段:
|
||||
|
||||
1. **Plan**:只读计算目标、安装集合以及所有待备份路径;
|
||||
2. **Confirm**:`--dry-run` 和交互确认渲染同一份计划;
|
||||
3. **Execute**:确认后严格执行计划中的备份和安装。
|
||||
|
||||
安全要求:
|
||||
|
||||
- 非交互环境未传 `--yes` 时拒绝执行;
|
||||
- 用户拒绝确认时必须零文件写入;
|
||||
- 备份失败时跳过整个 Agent 目标,不开始铺设相反布局;
|
||||
- 同一目标先完成所有必要备份,再复制新集合;
|
||||
- multi Skill 必须在同级 staging 中完成复制,再原子发布到正式目录;
|
||||
- 任意 `skipped > 0` 都返回非零退出码,并且不写入完整成功快照;
|
||||
- 一个 Agent 目标失败不阻止其他目标尝试,但最终结果仍为失败。
|
||||
|
||||
## 8. Upgrade 与恢复语义
|
||||
|
||||
升级器对每个 Agent 目标执行:
|
||||
|
||||
1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill;
|
||||
2. 在目标文件系统的 staging 中复制完整新集合;
|
||||
3. staging 全部成功后,才将旧集合移入备份目录;
|
||||
4. 逐项发布 staging;任一发布失败时删除已发布的新目录,并逆序恢复该目标的全部旧目录;
|
||||
5. 仅在没有目标失败且至少一个目标成功时更新状态快照。
|
||||
|
||||
Go upgrade 当前提供 **单 Agent 目标级事务恢复**:复制失败发生在旧目录移动前;
|
||||
备份中途失败会恢复此前已移动的目录;发布中途失败会恢复该目标的完整旧集合。不同
|
||||
Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功升级的其他目标,这与
|
||||
“不提供跨所有 Agent 目标的事务式回滚”非目标保持一致。
|
||||
|
||||
## 9. 备份合同
|
||||
|
||||
- 路径:`~/.dws/skill-backups/<UTC 时间戳>/...`;
|
||||
- 主要操作:同一文件系统内使用 rename 移动;
|
||||
- 失败语义:备份失败时原目录保持不变,目标安装失败;
|
||||
- 可见性:计划和执行日志显示原路径与备份路径;
|
||||
- 保留策略:自动修剪,仅保留最近 5 批。
|
||||
|
||||
备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行
|
||||
`dws skill setup --mode mono`。
|
||||
|
||||
## 10. 缓存与制品
|
||||
|
||||
发布制品和二进制内嵌内容同时携带 mono 与 multi 源树。`~/.dws/skills/` 只是
|
||||
setup 在未显式指定 `--source` 时的本地回退缓存。
|
||||
|
||||
缓存刷新必须采用同级 staging + publish:
|
||||
|
||||
1. 在 staging 中完整复制并验证新树;
|
||||
2. 发布前保留旧缓存;
|
||||
3. 通过 rename 发布新缓存;
|
||||
4. 复制或发布失败时保留或恢复旧缓存;
|
||||
5. 空、缺失或损坏的 bundle 不能擦除有效缓存。
|
||||
|
||||
## 11. 安装入口一致性
|
||||
|
||||
以下入口都遵守本 RFC:
|
||||
|
||||
| 入口 | 默认模式 | 失败合同 |
|
||||
|---|---|---|
|
||||
| `dws skill setup` | multi | 部分失败返回非零;不写完整成功状态 |
|
||||
| `dws upgrade` | bundle 含 multi 时安装 multi | 目标失败返回失败;下次全量重试 |
|
||||
| `scripts/install.sh` | multi | 任一检测到的目标失败则脚本非零 |
|
||||
| `scripts/install.ps1` | multi | 任一检测到的目标失败则脚本非零 |
|
||||
| `scripts/install-skills.sh` | multi | 任一检测到的目标失败则脚本非零 |
|
||||
| npm `install.js` | multi | 任一检测到的目标失败则 postinstall 失败 |
|
||||
|
||||
Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行相同流程。
|
||||
|
||||
## 12. 验收与回归门禁
|
||||
|
||||
合入和后续修改至少覆盖:
|
||||
|
||||
- mono → multi、multi → mono 互斥切换;
|
||||
- 状态上线前的官方 multi 目录切换 mono 时能够被精确迁移;
|
||||
- 未登记的同前缀市场/用户 Skill 在刷新和切换后仍存在;
|
||||
- 统一状态中登记的过期官方 Skill 被备份并移除;
|
||||
- 备份、复制、统一状态写入、缓存 publish 故障注入;
|
||||
- 非交互确认拒绝与显式 `--yes`;
|
||||
- 部分失败返回非零且不写错误状态快照;
|
||||
- 复制失败不留下 Agent 可见的残缺官方目录;
|
||||
- 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill;
|
||||
- Windows、macOS、Linux 的路径和覆盖率门禁;
|
||||
- npm、Shell、PowerShell 与包管理器安装冒烟。
|
||||
|
||||
## 13. 后续演进
|
||||
|
||||
- 收敛各安装入口中的 Agent home 清单,减少跨语言复制;
|
||||
- 如确有运维需求,可单独设计备份查看和显式恢复命令;
|
||||
- mono 的物理删除必须作为独立变更,在 multi 内容、安装入口和迁移回归稳定后推进;
|
||||
- `managed_skills` 字段若演进,必须同步更新所有安装入口和跨平台回归。
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
> 本分支权威合同:`skills/mono` / `skills/multi` 的**内容组织**与 zip 内容树形状。
|
||||
> 不做安装/升级行为约定。质检见 [skill-mono-multi-qa.md](skill-mono-multi-qa.md)。
|
||||
> 对齐调研:[skill-wukong-align-plan.md](skill-wukong-align-plan.md)。
|
||||
> 安装、升级与模式迁移见
|
||||
> [DWS 预制 Skill 安装、升级与模式迁移 RFC](rfc-skill-installation-and-upgrade.md)。
|
||||
|
||||
## 1. 两棵内容树
|
||||
|
||||
@@ -87,18 +88,7 @@ skills/mono/
|
||||
|
||||
质检可断言源树形状;**不**断言安装器默认解压哪棵。
|
||||
|
||||
## 6. 与悟空 `dingtalk-skills/` 对照(组织概念 only)
|
||||
|
||||
| 维度 | DWS `skills/multi` | 悟空 `dingtalk-skills/`(develop) |
|
||||
|---|---|---|
|
||||
| 布局 | flat `dingtalk-*` + `dingtalk-shared` | 同构 flat |
|
||||
| 集合 | 产品 skill + shared(含 event/profile/…;dev/skill 等长尾落在 misc) | 更小产品集(如 attendance/report 独立目录) |
|
||||
| 质检权威 | **mono 单 skill 树** | 不作为 DWS 覆盖基准 |
|
||||
| 不移植 | `_install.sh` / bundle / dual / Qwen overlay | — |
|
||||
|
||||
悟空独有命名(如 `dingtalk-attendance`)在 DWS 中由 `dingtalk-misc` 承接对应 mono `attendance*` / `report` / `oa` / `sheet` / `dev` 等面——见覆盖表。
|
||||
|
||||
## 7. 变更流程
|
||||
## 6. 变更流程
|
||||
|
||||
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
|
||||
2. 跑 `make skill-mono-multi-content`(该独立门禁不包含在默认 `make policy` 中)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
|
||||
> 执行:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)。
|
||||
> 安装、升级与模式迁移见
|
||||
> [DWS 预制 Skill 安装、升级与模式迁移 RFC](rfc-skill-installation-and-upgrade.md)。
|
||||
|
||||
## 1. 质检矩阵
|
||||
|
||||
@@ -70,7 +72,3 @@ paired_files:
|
||||
| X6 | SAFETY_PREAMBLE_INJECT 无注入器 | **done** | 标记已移除 |
|
||||
|
||||
产品面覆盖:见 YAML `coverage`——mono products 均有 multi 承接(misc 聚合 attendance/oa/sheet/…)。
|
||||
|
||||
## 4. 与悟空
|
||||
|
||||
借鉴 frontmatter / 断链 / requires 等**检查维度**;不运行悟空 bundle zip 校验脚本。覆盖权威始终是 DWS mono。
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
# DWS multi-skill **内容框架**对齐方案(相对 dws-wukong develop)
|
||||
|
||||
> 状态:**执行中** — Phase 1–3 已落地;M2/M3 已补;**M1 recovery 闭环已从 skill 删除(不做移植)**。
|
||||
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
|
||||
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
|
||||
> 门禁:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)
|
||||
>
|
||||
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
|
||||
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
|
||||
> 分支:`feat/multi-skill-framework-align`(自 `origin/main` @ `a37e6e68`)
|
||||
> **本分支范围:只做 skill 内容的这个框架**(目录布局、文档契约、共享内容约定、zip 内容树合同、**相对 mono 的内容质检**)。
|
||||
> **不做**安装/升级引擎、agent-home、脚本 skill-install 行为翻转。
|
||||
>
|
||||
> 对照仓:
|
||||
>
|
||||
> | 仓 | 路径 | 基线 |
|
||||
> |---|---|---|
|
||||
> | DWS OSS CLI(本工作树) | `dws-multi-skill-align` | `origin/main` |
|
||||
> | dws-wukong | `~/GolandProjects/open-source/dws-wukong` | `origin/develop` @ `ab76629a`(调研时) |
|
||||
> | 行为参考(**另一分支**) | `dws-skill-mode-migration` @ `402429ac`/`d5c8982c` | 安装默认 multi / upgrade 强制 multi —— **不在本分支排期** |
|
||||
> | 内容缺口留档(参考) | 同迁移分支 `docs/skill-capability-completion.md`(M1–M6 / X1 等) | **仅作质检目标线索**,非本分支权威 |
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
1. **本分支 = skill 内容框架 + 相对 mono 的内容质检**:固化 `skills/multi` 组织合同,并用 **mono 单 skill 布局作对照基准**做覆盖/结构/漂移门禁(文档 + CI 内容护栏)。
|
||||
2. **对齐悟空**:只取内容树组织概念;质检以 **DWS-native** 设计为主(已有 policy/测试可复用)。悟空 `validate-multiskill-bundle.py` 仅借鉴「frontmatter / 断链 / requires」类检查思路,**不**移植 bundle/安装校验。
|
||||
3. **安装/升级行为**与 `402429ac`/`d5c8982c` → **单独 follow-up 分支**,本方案只登记。
|
||||
4. 质检 **不改**默认安装哪棵树;只保证 multi 内容相对 mono **可解释、可覆盖、可回归**。
|
||||
|
||||
### 0.1 IN SCOPE
|
||||
|
||||
| 类别 | 包含 |
|
||||
|---|---|
|
||||
| 内容树结构 | `skills/mono/` 与 `skills/multi/<name>/` 目录合同 |
|
||||
| 单 skill 约定 | `SKILL.md` frontmatter / 契约块 / Golden Route;`references/`;可选 `scripts/` |
|
||||
| 共享内容 | `dingtalk-shared` 职责与被引用方式;与 mono 全局文映射(文档级) |
|
||||
| 命名与集合 | `dingtalk-*` + `dingtalk-shared`;相对悟空的共有/独有清单(文档) |
|
||||
| Zip **内容布局合同** | `mono/` / `multi/` / 根 mono 副本的内容含义与树形状;不改安装默认 |
|
||||
| **Mono↔multi 内容质检** | 覆盖、结构、漂移三类门禁;复用/扩展现有 policy 与测试;缺口修复属内容编辑(另批或同分支内容 Phase) |
|
||||
| 内容架构文档 | 本文件 + 可选短文(架构合同 + 质检矩阵) |
|
||||
|
||||
### 0.2 OUT OF SCOPE
|
||||
|
||||
| 类别 | 去向 |
|
||||
|---|---|
|
||||
| 安装默认 multi、upgrade always-multi | Follow-up 分支(`402429ac`/`d5c8982c`) |
|
||||
| `LocateSkillsRoot` / `skill_setup` / `paths.go` / `skillhome` / install 脚本行为 | 同上 |
|
||||
| 安装/运行时 manifest、state.json、mode 切换、telemetry header | 拒绝或行为分支 |
|
||||
| 悟空 `_install.sh` / dual / Qwen / RewindDesktop / pod | 拒绝 |
|
||||
| 非 skill 内容的 CLI 功能(schema/shortcut 代码等) | 拒绝 |
|
||||
| 把质检做成「改安装默认值」的后门 | 拒绝 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 内容现状盘点
|
||||
|
||||
### 1.1 DWS `skills/mono`(质检对照基准 · 单 skill)
|
||||
|
||||
```text
|
||||
skills/mono/
|
||||
├── SKILL.md
|
||||
├── references/
|
||||
│ ├── products/<area>.md|…/ # 产品能力面(质检「覆盖」主源)
|
||||
│ ├── error-codes.md、… # 全局协议(无 recovery 闭环)
|
||||
│ └── best_practices/…
|
||||
└── scripts/
|
||||
```
|
||||
|
||||
### 1.2 DWS `skills/multi`(内容主体)
|
||||
|
||||
```text
|
||||
skills/multi/
|
||||
├── dingtalk-shared/ # 跨产品契约 / routing / 全局协议应落点
|
||||
└── dingtalk-*/ # 19 产品 + 各 references、scripts
|
||||
```
|
||||
|
||||
仅 DWS 有(悟空无):dev, event, hrbrain, markdown, pat, profile, skill。
|
||||
|
||||
### 1.3 悟空 `dingtalk-skills/`(内容组织对照,非质检权威)
|
||||
|
||||
Flat `dingtalk-*` + `dingtalk-shared`;单 skill 骨架同构。**不作为 mono 覆盖基准**(集合更小、不同源)。
|
||||
|
||||
### 1.4 Zip 内容布局合同
|
||||
|
||||
| Zip 路径 | 内容含义 |
|
||||
|---|---|
|
||||
| `<root>/` | mono 副本(兼容) |
|
||||
| `<root>/mono/` | 显式 mono 内容源 |
|
||||
| `<root>/multi/` | 与 `skills/multi/` 同构 |
|
||||
|
||||
质检可断言「源树形状」;**不**断言安装面默认选哪棵。
|
||||
|
||||
### 1.5 现有 DWS skill 内容质检资产(复用清单)
|
||||
|
||||
| 资产 | 作用 | 与 mono↔multi 质检关系 |
|
||||
|---|---|---|
|
||||
| `scripts/policy/check-skill-commands.sh` + `skill-command-check/` | Skill 文内 `dws …` 命令路径存在性 | **复用**(命令真实性);非覆盖映射 |
|
||||
| `scripts/policy/check-skill-context-budget.sh` | chat/event/mono/`dingtalk-shared` 上下文预算与冷启动约束 | **复用**(结构/预算);可扩展 shared 引用规则 |
|
||||
| `scripts/policy/check-multi-im-skill-chain.sh` + `multi-im-skill-chain/` | IM 意图单默认路由、retired scripts、handoff | **复用**(chat/event 链);面窄 |
|
||||
| `test/unit/skill_docs_policy_test.go` | 退役命令、event 扁平输出契约等 | **复用**;可加 mono↔multi 断言 |
|
||||
| `test/unit/whiteboard_skill_docs_test.go` | mono/multi whiteboard recipes **字节一致** | **样板**:产品面「同源文件」门禁范式 |
|
||||
| `test/skill_static`(`-tags skill_verify`) | 文内命令 vs Cobra;multi 查 flag | **复用**(opt-in 深度);非 CI 默认全量时可保持 tags |
|
||||
| `test/skill_e2e` / `test/run_skill_tests.py` | 执行层 / 用例驱动 | **偏行为**;本分支质检默认不依赖 e2e |
|
||||
| `Makefile` → `policy` 含 context-budget、multi-im-skill-chain;`skill-command-integrity` 独立 | 已有 CI 钩子 | 新门禁优先挂同类 policy / `test/unit` |
|
||||
|
||||
**缺口(尚无的门禁)**:系统的「mono `references/products/*` → multi 目录/文」覆盖表;frontmatter 全集完备性;orphan scripts。全局协议中 **确认门禁 / Schema 教学已补**;**recovery 闭环已从 skill 移除(不再作为缺口)**。
|
||||
|
||||
### 1.6 悟空侧类比质检
|
||||
|
||||
| 悟空 | 说明 | 本分支 |
|
||||
|---|---|---|
|
||||
| `scripts/validate-multiskill-bundle.py` | 校验 **已打好的 bundle zip**:frontmatter keys/category、`requires`、markdown 断链、scenario 编排 | **Adapt 思路** → DWS 源树(`skills/multi` + 对照 mono),不跑 zip 安装语义 |
|
||||
| `sync-monolith-to-multiskill.py` | mono→multi 派生 | **不**作默认质检手段;DWS 直接维护 multi |
|
||||
|
||||
结论:**DWS-native mono↔multi 质检**;悟空仅参考检查维度。
|
||||
|
||||
---
|
||||
|
||||
## 2. Diff(内容组织 + 质检视角)
|
||||
|
||||
### 2.1 已同构
|
||||
|
||||
Flat `dingtalk-*` + `dingtalk-shared`;`SKILL.md` + `references/`(+ 可选 `scripts/`)。
|
||||
|
||||
### 2.2 分叉与已知内容风险(质检要盯的)
|
||||
|
||||
| 风险 ID | 现象(线索) | 质检类型 |
|
||||
|---|---|---|
|
||||
| **C-cov** | mono `products/*` 能力面在 multi 无对应 skill/reference,或未登记「有意省略」 | 覆盖 |
|
||||
| **C-struct** | multi 缺 frontmatter 字段、`references/`、`DWS_RUNTIME_CONTRACT`、对 `dingtalk-shared` 引用不一致 | 结构 |
|
||||
| **C-drift-global** | 曾关注 recovery / 确认 / Schema;现确认与 Schema 已在 `dingtalk-shared`,**recovery skill 文档已删除** | 漂移(协议) |
|
||||
| **C-drift-orphan** | multi(或 mono)scripts/refs 无文档引用;或 routing 指向无索引产品(留档 X1/M6) | 漂移(孤儿) |
|
||||
| **C-pair** | 应对齐的成对文件(如 whiteboard recipes)内容不一致 | 漂移(成对) |
|
||||
|
||||
### 2.3 Reject
|
||||
|
||||
悟空安装包校验整文件照搬、内容集 19→12 砍产品、安装行为门禁冒充内容质检。
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals / Non-goals
|
||||
|
||||
### 3.1 Goals
|
||||
|
||||
1. 固化 multi **内容目录合同**与 mono↔multi **映射说明**。
|
||||
2. 建立 **质检矩阵**(覆盖 / 结构 / 漂移)并以 mono 为对照基准;有意省略必须 reviewed 登记。
|
||||
3. **复用** §1.5 资产;新增门禁走 `scripts/policy` 或 `test/unit`,内容-only。
|
||||
4. (可选)纯内容元数据;**禁止**被安装引擎读取改行为。
|
||||
5. 质检失败 → 修 **内容**或更新「有意省略」表,不改 setup/upgrade。
|
||||
|
||||
### 3.2 Non-goals
|
||||
|
||||
安装/升级翻转;cherry-pick 行为提交;取消产品;悟空客户端;非 skill CLI 功能;用质检驱动默认 multi 安装。
|
||||
|
||||
---
|
||||
|
||||
## 4. 分期(内容框架 + 质检 · 均无安装引擎)
|
||||
|
||||
> 批准前 **零编码**(含不实现新 gates)。**已执行**:Phase 1–3 见文首状态。
|
||||
|
||||
### Phase 0 — 方案冻结(本文)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | 本文件;§7(含质检轨)勾选 |
|
||||
| **验收** | owner 重新批准 → ✅「现在开始执行」 |
|
||||
|
||||
### Phase 1 — Multi 内容目录合同 + 架构短文 ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | `skills/multi` 目录合同;与悟空内容树对照表;zip `multi/` 同构合同 |
|
||||
| **触达** | `docs/skill-content-framework.md` |
|
||||
| **验收** | 可指导「如何新增 dingtalk-* 内容目录」 |
|
||||
|
||||
### Phase 2 — Mono↔multi **内容质检规格**(矩阵 + 缺口基线) ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | 质检规格 + 覆盖/omit 机读表 + 缺口 disposition |
|
||||
| **触达** | `docs/skill-mono-multi-qa.md`、`skills/content-qa/mono-multi-coverage.yaml` |
|
||||
| **验收** | 矩阵可人工抽查;缺口均有 disposition |
|
||||
|
||||
### Phase 3 — 质检落地:CI 内容护栏(复用 + 新 gate) ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | G1–G4 自动门禁 |
|
||||
| **触达** | `test/unit/mono_multi_skill_content_test.go`、`scripts/policy/check-mono-multi-skill-content.sh`、`Makefile` |
|
||||
| **验收** | `make skill-mono-multi-content` 绿;已知缺口走 reviewed omit |
|
||||
|
||||
### Phase 4 — 可选:内容包元数据 + 缺口修复波次
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围 A** | 纯内容 layout/skill 列表元数据(人不读安装器) |
|
||||
| **范围 B** | 按 Phase 2 disposition **修内容**:确认 / Schema 已补;**recovery skill 文档已删除(wontfix 移植)**;orphan 脚本仍走 allowlist(M4 等) |
|
||||
| **验收** | 元数据不驱动安装;修复项关闭对应质检失败或转入 omit |
|
||||
|
||||
### 延期登记(非本分支)
|
||||
|
||||
| 主题 | 载体 |
|
||||
|---|---|
|
||||
| 默认 multi + upgrade always-multi | 行为分支 ← `402429ac`/`d5c8982c` |
|
||||
| skillhome / 安装面 bootstrap | 行为分支 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Port / Adapt / Reject
|
||||
|
||||
| 项 | 决策 | 说明 |
|
||||
|---|---|---|
|
||||
| flat + `dingtalk-shared` 内容模型 | **Port** | 已有;合同 + 质检加固 |
|
||||
| 悟空 bundle frontmatter/断链/requires 检查维度 | **Adapt** | 做成 DWS 源树门禁,不校验 bundle zip/安装 |
|
||||
| whiteboard 式 mono/multi 成对一致 | **Port(范式)** | 推广到 reviewed 文件对 |
|
||||
| `validate-multiskill-bundle.py` 整脚本 | **Reject** | 绑定悟空 zip/Qwen 语义 |
|
||||
| `_install.sh` / dual / overlay | **Reject** | 非内容 |
|
||||
| 行为 cherry-pick | **Defer** | 另分支 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 与 `402429ac` / `d5c8982c`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 本分支 cherry-pick? | **否** |
|
||||
| 质检是否替代行为翻转? | **否** |
|
||||
| 行为分支 | 另开;可与内容/质检并行 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 批准清单(请重新勾选)
|
||||
|
||||
**范围**
|
||||
|
||||
- [x] 本分支 = skill **内容**框架 + **mono↔multi 内容质检**(§0.1);无安装/升级引擎
|
||||
- [x] `402429ac`/`d5c8982c` 及 setup/paths/install 脚本行为 **不在本分支**
|
||||
- [x] 取消产品与悟空客户端链路仍拒绝
|
||||
|
||||
**内容框架 Phase**
|
||||
|
||||
- [x] **Phase 1**:multi 目录合同 + 悟空内容树对照短文
|
||||
|
||||
**质检轨 Phase**
|
||||
|
||||
- [x] **Phase 2**:质检矩阵 + mono↔multi 覆盖/缺口基线规格(先文档,可执行)
|
||||
- [x] **Phase 3**:CI 内容护栏(G1–G4)—— 本迭代做 / 拆 PR / 只要规格暂不落地
|
||||
- [x] 质检失败处置原则:修内容或 reviewed omit,**不**改安装默认
|
||||
|
||||
**可选**
|
||||
|
||||
- [ ] **Phase 4A** 纯内容元数据:做 / 不做 / 以后
|
||||
- [x] **Phase 4B** recovery skill 文档 **removed/wontfix**;确认/Schema 已补;剩余 orphan(M4 等)仍 defer / allowlist
|
||||
|
||||
**Follow-up 知悉**
|
||||
|
||||
- [ ] 安装默认 multi + upgrade always-multi → **另一分支**
|
||||
|
||||
---
|
||||
|
||||
## 8. 下一步
|
||||
|
||||
**Phase 1–3 已落地**(合同短文 + 质检规格 + `skills/content-qa` + CI 门禁)。
|
||||
Phase 4B:recovery 已删除(不做移植);确认/Schema 已补。剩余 defer:orphan scripts(M4 等)、LICENSE/NOTICE(M5)、Phase 4A 元数据。
|
||||
安装默认 multi 等行为仍走 **另一分支**。
|
||||
|
||||
---
|
||||
|
||||
*锚点:`skills/mono`、`skills/multi`、§1.5 policy/测试、wukong `dingtalk-skills/`(组织对照 only)。*
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -2033,6 +2034,10 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, readable, err := skillstate.Read(home)
|
||||
if err != nil || !readable || len(state.OfficialSkills) != 3 || len(state.UpdatedSkills) != 2 {
|
||||
t.Fatalf("setup state = %#v, readable=%v, err=%v", state, readable, err)
|
||||
}
|
||||
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--dry-run", "--exclude", "b"); err != nil || !strings.Contains(output, "DRY-RUN") {
|
||||
t.Fatalf("multi dry run = %q, %v", output, err)
|
||||
}
|
||||
@@ -2048,8 +2053,11 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
t.Fatalf("invalid setup %#v succeeded", args)
|
||||
}
|
||||
}
|
||||
if _, _, err := run("--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
|
||||
t.Fatalf("default mono setup: %v", err)
|
||||
if _, _, err := run("--mode", "mono", "--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
|
||||
t.Fatalf("mono setup: %v", err)
|
||||
}
|
||||
if output, _, err := run("--source", multi, "--target", "agents", "--yes", "--dry-run"); err != nil || !strings.Contains(output, "mode=multi") {
|
||||
t.Fatalf("default mode should be multi: %q, %v", output, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2082,7 +2090,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
if _, err := listMultiSkillNames(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("missing multi source succeeded")
|
||||
}
|
||||
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMono {
|
||||
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMulti {
|
||||
t.Fatalf("default setup mode = %q, %v", mode, err)
|
||||
}
|
||||
if _, err := resolveSkillSetupMode("bad", true, io.Discard); err == nil {
|
||||
@@ -2117,7 +2125,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
for _, tc := range []struct{ path, mode string }{{"", skillSetupModeMono}, {mono, skillSetupModeMono}, {filepath.Dir(multi), skillSetupModeMulti}, {root, "bad"}} {
|
||||
_ = isSkillSourceRoot(tc.path, tc.mode)
|
||||
}
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
setTestHome(t, t.TempDir())
|
||||
for _, tc := range []struct{ target, mode string }{{"agents", skillSetupModeMono}, {"agents", skillSetupModeMulti}, {"all", skillSetupModeMono}, {"missing", skillSetupModeMono}} {
|
||||
_, _ = resolveSkillSetupTargets(tc.target, tc.mode)
|
||||
}
|
||||
@@ -2125,8 +2133,8 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
_ = agentHomeForMode("base", skillSetupModeMulti)
|
||||
_ = detectExistingAgentHomes(t.TempDir(), skillSetupModeMono)
|
||||
for _, mode := range []string{skillSetupModeMono, skillSetupModeMulti, "bad"} {
|
||||
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all)
|
||||
_ = mutualExclusionVictims(root, mode)
|
||||
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all, false)
|
||||
_, _ = mutualExclusionVictims(root, mode)
|
||||
}
|
||||
if isCharDevice(nil) || isInteractiveTerminal() {
|
||||
t.Fatal("test process unexpectedly interactive")
|
||||
@@ -2134,17 +2142,17 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
|
||||
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
|
||||
_ = os.MkdirAll(filepath.Join(filepath.Dir(monoDest), "dingtalk-old"), 0o755)
|
||||
_ = mutualExclusionVictims(monoDest, skillSetupModeMono)
|
||||
_, _ = mutualExclusionVictims(monoDest, skillSetupModeMono)
|
||||
multiDest := filepath.Join(t.TempDir(), "agent")
|
||||
_ = os.MkdirAll(filepath.Join(multiDest, "dws"), 0o755)
|
||||
_ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
|
||||
_, _ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
|
||||
cleanupMutualExclusion(monoDest, skillSetupModeMono, io.Discard, io.Discard)
|
||||
cleanupMutualExclusion(multiDest, skillSetupModeMulti, io.Discard, io.Discard)
|
||||
|
||||
badParent := filepath.Join(t.TempDir(), "file")
|
||||
_ = os.WriteFile(badParent, []byte("x"), 0o600)
|
||||
_, _, _ = installSkillToHomes(root, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
|
||||
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
|
||||
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard, true)
|
||||
if err := copyDir(filepath.Join(root, "missing"), t.TempDir()); err == nil {
|
||||
t.Fatal("copy missing directory succeeded")
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
|
||||
// Actually install with the filtered+mandatory set and assert dingtalk-shared landed.
|
||||
dest := t.TempDir()
|
||||
var out, errOut bytes.Buffer
|
||||
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
|
||||
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut, true); err != nil {
|
||||
t.Fatalf("install: %v (%s)", err, errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dingtalk-shared", "SKILL.md")); err != nil {
|
||||
|
||||
@@ -79,13 +79,15 @@ func TestCrossPlatformCoveragePATRetryRemainingPureAndWaitCoverage(t *testing.T)
|
||||
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok {
|
||||
t.Fatalf("timed out authorization = %v, %v", ok, err)
|
||||
}
|
||||
patAuthorizationTimeout = 5 * time.Millisecond
|
||||
patAuthorizationTimeout = time.Second
|
||||
patAuthorizationPollInterval = time.Millisecond
|
||||
pollCtx, pollCancel := context.WithCancel(context.Background())
|
||||
patResolveAccessToken = func(context.Context, string, string) (string, error) {
|
||||
pollCancel()
|
||||
return "", authpkg.ErrTokenDataNotFound
|
||||
}
|
||||
out.Reset()
|
||||
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok || !strings.Contains(out.String(), "等待授权中") {
|
||||
if ok, err := WaitForPatAuthorization(pollCtx, "", &out); ok || !errors.Is(err, context.Canceled) || !strings.Contains(out.String(), "等待授权中") {
|
||||
t.Fatalf("invalid-token polling = %v, %v, output %q", ok, err, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
+724
-160
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,707 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func useManagedSkillNames(t *testing.T, names ...string) {
|
||||
t.Helper()
|
||||
records := make([]skillprovenance.Record, 0, len(names))
|
||||
for _, name := range names {
|
||||
records = append(records, skillprovenance.Record{Name: name})
|
||||
}
|
||||
testseam.Swap(t, &skillSetupReadState, func(string) (*skillstate.State, bool, error) {
|
||||
return &skillstate.State{ManagedSkills: records}, true, nil
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupConfirmPreviewsStaleSkills verifies the
|
||||
// confirmation prompt lists stale dingtalk-* / dws-shared directories that a
|
||||
// full (unfiltered) multi install will back up and remove, and that a
|
||||
// filtered install previews nothing extra.
|
||||
func TestCrossPlatformCoverageSkillSetupConfirmPreviewsStaleSkills(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupInteractive, func() bool { return false })
|
||||
|
||||
dest := filepath.Join(t.TempDir(), ".claude", "skills")
|
||||
stale := filepath.Join(dest, "dingtalk-old")
|
||||
if err := os.MkdirAll(stale, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(stale, "SKILL.md"), []byte("stale"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useManagedSkillNames(t, "dingtalk-old")
|
||||
|
||||
var out bytes.Buffer
|
||||
ok, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{dest}, []string{"dingtalk-chat"}, false)
|
||||
if err == nil || ok || !strings.Contains(err.Error(), "--yes") {
|
||||
t.Fatalf("confirmSkillSetup = (%v, %v), want non-interactive confirmation error", ok, err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "将备份并移除过期 skill") {
|
||||
t.Fatalf("full install preview must list stale skills, got %q", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), filepath.Join(dest, "dingtalk-old")) {
|
||||
t.Fatalf("preview must name the stale directory, got %q", out.String())
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
ok, err = confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{dest}, []string{"dingtalk-chat"}, true)
|
||||
if err == nil || ok {
|
||||
t.Fatalf("filtered confirmSkillSetup = (%v, %v), want non-interactive confirmation error", ok, err)
|
||||
}
|
||||
if strings.Contains(out.String(), "将备份并移除过期 skill") {
|
||||
t.Fatalf("filtered install must stay additive in the preview, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupUnifiedOwnership(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "dingtalk-custom")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if isManagedDWSMultiSkillDir(dir) {
|
||||
t.Fatal("an unregistered dingtalk-* directory must not be treated as DWS-owned")
|
||||
}
|
||||
managed := map[string]bool{"dingtalk-custom": true}
|
||||
if !isManagedDWSMultiSkillDir(dir, managed) {
|
||||
t.Fatal("unified metadata must prove ownership")
|
||||
}
|
||||
legacy := filepath.Join(t.TempDir(), legacySharedSkill)
|
||||
if !isManagedDWSMultiSkillDir(legacy) {
|
||||
t.Fatal("the exact legacy dws-shared name must remain managed")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePublishManagedSkillFailurePaths(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
|
||||
skillSrc := filepath.Join(src, "dingtalk-a")
|
||||
failure := errors.New("publish denied")
|
||||
|
||||
t.Run("mkdir", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupPublishTemp, func(string, string) (string, error) { return "", failure })
|
||||
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(t.TempDir(), "dingtalk-a"))
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "staging") {
|
||||
t.Fatalf("mkdir error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("copy", func(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return failure })
|
||||
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(parent, "dingtalk-a"))
|
||||
if !errors.Is(err, failure) {
|
||||
t.Fatalf("copy error = %v", err)
|
||||
}
|
||||
if entries, readErr := os.ReadDir(parent); readErr != nil || len(entries) != 0 {
|
||||
t.Fatalf("copy failure retained staging: %v, err=%v", entries, readErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rename", func(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
|
||||
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(parent, "dingtalk-a"))
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "发布 Skill") {
|
||||
t.Fatalf("rename error = %v", err)
|
||||
}
|
||||
if entries, readErr := os.ReadDir(parent); readErr != nil || len(entries) != 0 {
|
||||
t.Fatalf("rename failure retained staging: %v, err=%v", entries, readErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cleanup", func(t *testing.T) {
|
||||
renameErr := errors.New("rename denied")
|
||||
cleanupErr := errors.New("cleanup denied")
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return renameErr })
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
|
||||
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(t.TempDir(), "dingtalk-a"))
|
||||
if !errors.Is(err, renameErr) || !errors.Is(err, cleanupErr) {
|
||||
t.Fatalf("cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupCleanupHomeFailure verifies that
|
||||
// cleanupMutualExclusion keeps every victim in place with a warning when
|
||||
// $HOME cannot be resolved, instead of destroying anything.
|
||||
func TestCrossPlatformCoverageSkillSetupCleanupHomeFailure(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), ".agents", "skills")
|
||||
victim := filepath.Join(dest, "dws")
|
||||
if err := os.MkdirAll(victim, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(victim, "SKILL.md"), []byte("mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
homeErr := errors.New("home boom")
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", homeErr })
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut)
|
||||
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过删除") {
|
||||
t.Fatalf("expected HOME warning on errOut, got %q", errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(victim, "SKILL.md")); err != nil {
|
||||
t.Fatalf("victim must survive the HOME failure: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupBackupFailureSkipsWholeTarget(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
copyCalls := 0
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
|
||||
copyCalls++
|
||||
return nil
|
||||
})
|
||||
failure := errors.New("backup boom")
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
|
||||
if filepath.Base(dir) == "dws" {
|
||||
return "", failure
|
||||
}
|
||||
return "", nil
|
||||
})
|
||||
|
||||
dest := filepath.Join(home, ".agents", "skills")
|
||||
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, []string{"dingtalk-a", "dingtalk-shared"}, []string{dest}, &out, &errOut, false)
|
||||
if err != nil || installed != 0 || skipped != 2 {
|
||||
t.Fatalf("install = (%d, %d, %v), want (0, 2, nil)", installed, skipped, err)
|
||||
}
|
||||
if copyCalls != 2 {
|
||||
t.Fatalf("backup failure staged %d new Skills, want 2", copyCalls)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "跳过整个 Agent 目标") {
|
||||
t.Fatalf("missing whole-target warning: %q", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupCleanupMutualExclusionBackupFailure(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dest := filepath.Join(home, ".agents", "skills")
|
||||
victim := filepath.Join(dest, "dws")
|
||||
if err := os.MkdirAll(victim, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failure := errors.New("backup boom")
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
|
||||
if dir != victim {
|
||||
t.Fatalf("backup victim = %q, want %q", dir, victim)
|
||||
}
|
||||
return "", failure
|
||||
})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut)
|
||||
if !errors.Is(err, failure) {
|
||||
t.Fatalf("cleanup error = %v, want %v", err, failure)
|
||||
}
|
||||
if out.Len() != 0 || !strings.Contains(errOut.String(), "互斥清理失败") {
|
||||
t.Fatalf("cleanup output = %q / %q", out.String(), errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupMonoCleanupFailureSkipsWholeTarget(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
copyCalls := 0
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
|
||||
copyCalls++
|
||||
return nil
|
||||
})
|
||||
failure := errors.New("multi backup boom")
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
|
||||
if filepath.Base(dir) == "dingtalk-a" {
|
||||
return "", failure
|
||||
}
|
||||
return "", nil
|
||||
})
|
||||
|
||||
base := filepath.Join(home, ".agents", "skills")
|
||||
multi := filepath.Join(base, "dingtalk-a")
|
||||
if err := os.MkdirAll(multi, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useManagedSkillNames(t, filepath.Base(multi))
|
||||
monoSrc := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := installSkillToHomes(monoSrc, []string{filepath.Join(base, "dws")}, &out, &errOut)
|
||||
if err != nil || installed != 0 || skipped != 1 {
|
||||
t.Fatalf("install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
|
||||
}
|
||||
if copyCalls != 1 {
|
||||
t.Fatalf("multi cleanup failure staged mono %d times, want 1", copyCalls)
|
||||
}
|
||||
if _, err := os.Stat(multi); err != nil {
|
||||
t.Fatalf("multi leftover must survive backup failure: %v", err)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
|
||||
t.Fatalf("missing mono whole-target warning: %q", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupStaleBackupFailureSkipsWholeTarget(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
copyCalls := 0
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
|
||||
copyCalls++
|
||||
return nil
|
||||
})
|
||||
failure := errors.New("stale backup boom")
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
|
||||
if filepath.Base(dir) == "dingtalk-stale" {
|
||||
return "", failure
|
||||
}
|
||||
return "", nil
|
||||
})
|
||||
|
||||
dest := filepath.Join(home, ".agents", "skills")
|
||||
stale := filepath.Join(dest, "dingtalk-stale")
|
||||
if err := os.MkdirAll(stale, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useManagedSkillNames(t, filepath.Base(stale))
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, []string{"dingtalk-a", "dingtalk-shared"}, []string{dest}, &out, &errOut, false)
|
||||
if err != nil || installed != 0 || skipped != 2 {
|
||||
t.Fatalf("install = (%d, %d, %v), want (0, 2, nil)", installed, skipped, err)
|
||||
}
|
||||
if copyCalls != 2 {
|
||||
t.Fatalf("stale backup failure staged %d new Skills, want 2", copyCalls)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
|
||||
t.Fatalf("missing stale whole-target warning: %q", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupTransactionFailuresRestoreOldSet(t *testing.T) {
|
||||
for _, failureKind := range []string{"later_backup", "later_publish"} {
|
||||
failureKind := failureKind
|
||||
t.Run(failureKind, func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dest := filepath.Join(home, ".agents", "skills")
|
||||
first := filepath.Join(dest, "dingtalk-first")
|
||||
second := filepath.Join(dest, "dingtalk-second")
|
||||
for path, body := range map[string]string{
|
||||
filepath.Join(first, "SKILL.md"): "old first\n",
|
||||
filepath.Join(second, "SKILL.md"): "old second\n",
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-first", "dingtalk-second"})
|
||||
if err := os.WriteFile(filepath.Join(src, "dingtalk-first", "SKILL.md"), []byte("new first\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "dingtalk-second", "SKILL.md"), []byte("new second\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
failure := errors.New("injected " + failureKind + " failure")
|
||||
if failureKind == "later_backup" {
|
||||
originalBackup := skillSetupBackupAndRemove
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(homeDir, dir string) (string, error) {
|
||||
if dir == second {
|
||||
return "", failure
|
||||
}
|
||||
return originalBackup(homeDir, dir)
|
||||
})
|
||||
} else {
|
||||
originalRename := skillSetupPublishRename
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
|
||||
if newPath == second && strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
|
||||
return failure
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
})
|
||||
}
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(
|
||||
src,
|
||||
[]string{"dingtalk-first", "dingtalk-second"},
|
||||
[]string{dest},
|
||||
&out,
|
||||
&errOut,
|
||||
true,
|
||||
)
|
||||
if err != nil || installed != 0 || skipped != 2 {
|
||||
t.Fatalf("transaction failure = (%d, %d, %v), stderr=%s", installed, skipped, err, errOut.String())
|
||||
}
|
||||
for path, want := range map[string]string{
|
||||
filepath.Join(first, "SKILL.md"): "old first\n",
|
||||
filepath.Join(second, "SKILL.md"): "old second\n",
|
||||
} {
|
||||
got, readErr := os.ReadFile(path)
|
||||
if readErr != nil || string(got) != want {
|
||||
t.Fatalf("restored %s = %q, err=%v, want %q", path, got, readErr, want)
|
||||
}
|
||||
}
|
||||
entries, readErr := os.ReadDir(dest)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".dws-setup-set-") {
|
||||
t.Fatalf("transaction left staging directory %s", entry.Name())
|
||||
}
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "已执行回滚") || !strings.Contains(errOut.String(), failure.Error()) {
|
||||
t.Fatalf("transaction failure output = %q", errOut.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
|
||||
failure := errors.New("injected transaction failure")
|
||||
|
||||
t.Run("managed publish success", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
|
||||
dest := filepath.Join(t.TempDir(), "dingtalk-a")
|
||||
if err := publishDWSManagedSkillDir(filepath.Join(src, "dingtalk-a"), dest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "SKILL.md")); err != nil {
|
||||
t.Fatalf("published Skill missing: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("staging cleanup failure", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
|
||||
dest := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return failure })
|
||||
cleanupErr := errors.New("staging cleanup failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
|
||||
_, _, err := stageSkillSetupTarget(
|
||||
&skillSetupPlan{Mode: skillSetupModeMulti, Source: src, MultiSkillNames: []string{"dingtalk-a"}},
|
||||
skillSetupTargetPlan{Destination: dest},
|
||||
)
|
||||
if !errors.Is(err, failure) || !errors.Is(err, cleanupErr) {
|
||||
t.Fatalf("staging cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("staging directory failure", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
|
||||
dest := t.TempDir()
|
||||
originalMkdirAll := skillSetupMkdirAll
|
||||
testseam.Swap(t, &skillSetupMkdirAll, func(path string, mode os.FileMode) error {
|
||||
if filepath.Base(path) == "dingtalk-a" && strings.HasPrefix(filepath.Base(filepath.Dir(path)), ".dws-setup-set-") {
|
||||
return failure
|
||||
}
|
||||
return originalMkdirAll(path, mode)
|
||||
})
|
||||
_, _, err := stageSkillSetupTarget(
|
||||
&skillSetupPlan{Mode: skillSetupModeMulti, Source: src, MultiSkillNames: []string{"dingtalk-a"}},
|
||||
skillSetupTargetPlan{Destination: dest},
|
||||
)
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill staging 目录失败") {
|
||||
t.Fatalf("staging directory error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
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) {
|
||||
t.Fatalf("remove published error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("original still exists", func(t *testing.T) {
|
||||
original := t.TempDir()
|
||||
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: original, backup: "backup"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "恢复目标仍存在") {
|
||||
t.Fatalf("existing restore target error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("stat", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupStat, 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, &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 恢复目录失败") {
|
||||
t.Fatalf("restore mkdir error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("rename", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupStat, 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 })
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("backup rollback failure", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return "backup", nil
|
||||
}
|
||||
return "", failure
|
||||
})
|
||||
testseam.Swap(t, &skillSetupStat, 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 })
|
||||
_, 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)
|
||||
}
|
||||
})
|
||||
|
||||
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, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
|
||||
err := publishSkillSetupTarget(
|
||||
[]skillSetupStagedDir{{staged: "staged", dest: "dest"}},
|
||||
[]skillSetupBackedUpDir{{original: "dest", backup: "backup"}},
|
||||
)
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "回滚不完整") {
|
||||
t.Fatalf("publish rollback error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("execute cleanup errors", func(t *testing.T) {
|
||||
newPlan := func(t *testing.T) *skillSetupPlan {
|
||||
t.Helper()
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
|
||||
return &skillSetupPlan{
|
||||
Mode: skillSetupModeMulti,
|
||||
Source: src,
|
||||
MultiSkillNames: []string{"dingtalk-a"},
|
||||
Targets: []skillSetupTargetPlan{{Destination: t.TempDir()}},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("after backup failure", func(t *testing.T) {
|
||||
plan := newPlan(t)
|
||||
plan.Targets[0].Backups = []skillSetupBackup{{Path: filepath.Join(plan.Targets[0].Destination, "old")}}
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure })
|
||||
cleanupErr := errors.New("cleanup after backup failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
|
||||
var stderr bytes.Buffer
|
||||
_, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
|
||||
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
|
||||
t.Fatalf("backup cleanup = skipped %d, err %v, stderr %q", skipped, err, stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("after publish failure", func(t *testing.T) {
|
||||
plan := newPlan(t)
|
||||
originalRename := skillSetupPublishRename
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
|
||||
if strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
|
||||
return failure
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
})
|
||||
originalRemoveAll := skillSetupRemoveAll
|
||||
cleanupErr := errors.New("cleanup after publish failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(path string) error {
|
||||
if strings.HasPrefix(filepath.Base(path), ".dws-setup-set-") {
|
||||
return cleanupErr
|
||||
}
|
||||
return originalRemoveAll(path)
|
||||
})
|
||||
var stderr bytes.Buffer
|
||||
_, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
|
||||
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
|
||||
t.Fatalf("publish cleanup = skipped %d, err %v, stderr %q", skipped, err, stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("after success", func(t *testing.T) {
|
||||
plan := newPlan(t)
|
||||
originalRemoveAll := skillSetupRemoveAll
|
||||
cleanupErr := errors.New("cleanup after success")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(path string) error {
|
||||
if strings.HasPrefix(filepath.Base(path), ".dws-setup-set-") {
|
||||
return cleanupErr
|
||||
}
|
||||
return originalRemoveAll(path)
|
||||
})
|
||||
var stderr bytes.Buffer
|
||||
installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
|
||||
if err != nil || installed != 1 || skipped != 0 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
|
||||
t.Fatalf("success cleanup = installed %d, skipped %d, err %v, stderr %q", installed, skipped, err, stderr.String())
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupInstallHomeFailureSkips verifies both
|
||||
// install paths skip (never destroy) every target when $HOME cannot be
|
||||
// resolved for the pre-refresh backup.
|
||||
func TestCrossPlatformCoverageSkillSetupInstallHomeFailureSkips(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home boom") })
|
||||
|
||||
monoSrc := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
|
||||
if err := os.MkdirAll(monoDest, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(monoDest, "SKILL.md"), []byte("# old"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := installSkillToHomes(monoSrc, []string{monoDest}, &out, &errOut)
|
||||
if err != nil || installed != 0 || skipped != 1 {
|
||||
t.Fatalf("mono install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过刷新") {
|
||||
t.Fatalf("expected HOME skip warning, got %q", errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(monoDest, "SKILL.md")); err != nil {
|
||||
t.Fatalf("existing mono dir must be preserved: %v", err)
|
||||
}
|
||||
|
||||
multiSrc := writeMultiSkillSource(t, []string{"dingtalk-a"})
|
||||
multiDest := filepath.Join(t.TempDir(), ".claude", "skills")
|
||||
if err := os.MkdirAll(filepath.Join(multiDest, "dingtalk-a"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out.Reset()
|
||||
errOut.Reset()
|
||||
installed, skipped, err = installMultiSkillToHomes(multiSrc, []string{"dingtalk-a"}, []string{multiDest}, &out, &errOut, true)
|
||||
if err != nil || installed != 0 || skipped != 1 {
|
||||
t.Fatalf("multi install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过整个 Agent 目标") {
|
||||
t.Fatalf("expected multi HOME skip warning, got %q", errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(multiDest, "dingtalk-a")); err != nil {
|
||||
t.Fatalf("existing sub skill must be preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupRemoveStaleMultiSkillsEdges covers
|
||||
// removeStaleMultiSkills and its preview companion staleMultiSkillVictims:
|
||||
// scan failures, the HOME failure, backup failures, and the success path.
|
||||
func TestCrossPlatformCoverageSkillSetupRemoveStaleMultiSkillsEdges(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), ".cursor", "skills")
|
||||
keep := []string{"dingtalk-chat"}
|
||||
entries := map[string]bool{ // dir entries; README below is a plain file
|
||||
"dingtalk-chat": true, // kept (in bundle)
|
||||
"dingtalk-stale": true, // stale product skill
|
||||
"dws-shared": true, // legacy shared name is stale too
|
||||
"other-skill": true, // non-DWS, must survive
|
||||
}
|
||||
for name := range entries {
|
||||
if err := os.MkdirAll(filepath.Join(dest, name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
useManagedSkillNames(t, "dingtalk-stale")
|
||||
if err := os.WriteFile(filepath.Join(dest, "README"), []byte("file"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
|
||||
// Non-ENOENT scan failure warns; ENOENT is silent.
|
||||
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, errors.New("scan boom") })
|
||||
removeStaleMultiSkills(dest, keep, &out, &errOut)
|
||||
if !strings.Contains(errOut.String(), "过期 skill 扫描失败") {
|
||||
t.Fatalf("expected scan warning, got %q", errOut.String())
|
||||
}
|
||||
errOut.Reset()
|
||||
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, os.ErrNotExist })
|
||||
removeStaleMultiSkills(dest, keep, &out, &errOut)
|
||||
if errOut.Len() != 0 {
|
||||
t.Fatalf("ENOENT scan must be silent, got %q", errOut.String())
|
||||
}
|
||||
testseam.Swap(t, &skillSetupReadDir, os.ReadDir)
|
||||
|
||||
// The preview companion sees the same victims and skips files/kept/non-DWS.
|
||||
victims := staleMultiSkillVictims(dest, keep)
|
||||
wantVictims := []string{filepath.Join(dest, "dingtalk-stale"), filepath.Join(dest, "dws-shared")}
|
||||
if len(victims) != len(wantVictims) {
|
||||
t.Fatalf("staleMultiSkillVictims = %v, want %v", victims, wantVictims)
|
||||
}
|
||||
|
||||
// HOME failure keeps every stale directory with a warning.
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home boom") })
|
||||
errOut.Reset()
|
||||
removeStaleMultiSkills(dest, keep, &out, &errOut)
|
||||
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过删除") {
|
||||
t.Fatalf("expected HOME warning, got %q", errOut.String())
|
||||
}
|
||||
for name := range entries {
|
||||
if _, err := os.Stat(filepath.Join(dest, name)); err != nil {
|
||||
t.Fatalf("entry %s must survive the HOME failure: %v", name, err)
|
||||
}
|
||||
}
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
|
||||
|
||||
// Backup failure keeps the stale directory with a warning.
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", errors.New("backup boom") })
|
||||
errOut.Reset()
|
||||
removeStaleMultiSkills(dest, keep, &out, &errOut)
|
||||
if !strings.Contains(errOut.String(), "过期 skill 清理失败(保留原目录") {
|
||||
t.Fatalf("expected backup failure warning, got %q", errOut.String())
|
||||
}
|
||||
for _, stale := range wantVictims {
|
||||
if _, err := os.Stat(stale); err != nil {
|
||||
t.Fatalf("stale dir must survive the backup failure: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Success: both stale dirs are backed up and reported; the rest survives.
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(_, dir string) (string, error) { return filepath.Join(t.TempDir(), "backup"), nil })
|
||||
out.Reset()
|
||||
removeStaleMultiSkills(dest, keep, &out, &errOut)
|
||||
if count := strings.Count(out.String(), "已备份并清理过期 skill"); count != len(wantVictims) {
|
||||
t.Fatalf("expected %d stale cleanup lines, got %d (out=%q)", len(wantVictims), count, out.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "other-skill")); err != nil {
|
||||
t.Fatalf("non-DWS dir must survive: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dingtalk-chat")); err != nil {
|
||||
t.Fatalf("bundle skill must survive: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -47,18 +47,16 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
oldTargets := skillSetupResolveTargets
|
||||
oldList := skillSetupListMulti
|
||||
oldFilter := skillSetupFilterMulti
|
||||
oldConfirm := skillSetupConfirm
|
||||
oldMono := skillSetupInstallMono
|
||||
oldMulti := skillSetupInstallMulti
|
||||
oldConfirm := skillSetupConfirmPlan
|
||||
oldExecute := skillSetupExecutePlan
|
||||
t.Cleanup(func() {
|
||||
skillSetupResolveMode = oldMode
|
||||
skillSetupResolveSource = oldSource
|
||||
skillSetupResolveTargets = oldTargets
|
||||
skillSetupListMulti = oldList
|
||||
skillSetupFilterMulti = oldFilter
|
||||
skillSetupConfirm = oldConfirm
|
||||
skillSetupInstallMono = oldMono
|
||||
skillSetupInstallMulti = oldMulti
|
||||
skillSetupConfirmPlan = oldConfirm
|
||||
skillSetupExecutePlan = oldExecute
|
||||
})
|
||||
fail := errors.New("failure")
|
||||
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
|
||||
@@ -83,17 +81,17 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
}
|
||||
skillSetupFilterMulti = func(all, _, _ []string) ([]string, error) { return all, nil }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
_ = cmd.Root().PersistentFlags().Set("dry-run", "true")
|
||||
cmd.Flags().Bool("dry-run", true, "")
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, fail }
|
||||
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return false, fail }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("confirmation failure should propagate")
|
||||
}
|
||||
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, nil }
|
||||
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return false, nil }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -105,17 +103,17 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
t.Fatal("unknown resolved mode should fail")
|
||||
}
|
||||
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
|
||||
skillSetupInstallMono = func(string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
|
||||
skillSetupExecutePlan = func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("mono install failure should propagate")
|
||||
}
|
||||
skillSetupInstallMono = func(string, []string, io.Writer, io.Writer) (int, int, error) { return 1, 0, nil }
|
||||
skillSetupExecutePlan = func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) { return 1, 0, nil }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
|
||||
skillSetupExecutePlan = func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("multi install failure should propagate")
|
||||
@@ -123,6 +121,7 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
src := writeMultiSkillSource(t, []string{multiSharedSkill, "dingtalk-chat"})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
legacyPath := filepath.Join(home, legacyMultiSharedSkill)
|
||||
@@ -143,6 +142,7 @@ func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *
|
||||
[]string{home},
|
||||
&out,
|
||||
&errOut,
|
||||
true,
|
||||
)
|
||||
if err != nil || installed != 2 || skipped != 0 {
|
||||
t.Fatalf("install = %d/%d, err=%v, stderr=%s", installed, skipped, err, errOut.String())
|
||||
@@ -156,7 +156,7 @@ func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *
|
||||
if _, err := os.Stat(filepath.Join(customPath, "SKILL.md")); err != nil {
|
||||
t.Fatalf("unrelated custom skill changed: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "已清理已退役 Skill 残留") {
|
||||
if !strings.Contains(out.String(), "已备份并清理过期 skill") {
|
||||
t.Fatalf("legacy cleanup was not reported: %s", out.String())
|
||||
}
|
||||
|
||||
@@ -178,12 +178,17 @@ func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *
|
||||
[]string{failureHome},
|
||||
&failureOut,
|
||||
&failureErr,
|
||||
true,
|
||||
)
|
||||
if err != nil || installed != 0 || skipped != 1 {
|
||||
t.Fatalf("failed replacement = %d/%d, err=%v", installed, skipped, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(failureLegacy, "SKILL.md")); err != nil {
|
||||
t.Fatalf("failed replacement removed legacy shared skill: %v", err)
|
||||
got, readErr := os.ReadFile(filepath.Join(failureLegacy, "SKILL.md"))
|
||||
if readErr != nil || string(got) != "legacy\n" {
|
||||
t.Fatalf("failed replacement changed the live legacy copy: %q, err=%v", got, readErr)
|
||||
}
|
||||
if !strings.Contains(failureErr.String(), "Skill staging 失败,保留原集合") {
|
||||
t.Fatalf("failed replacement did not report preserved live set: %s", failureErr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -226,6 +231,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
oldReadDir, oldStat := skillSetupReadDir, skillSetupStat
|
||||
oldExecutable, oldGetwd, oldHome := skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir
|
||||
oldRemove, oldMkdir := skillSetupRemoveAll, skillSetupMkdirAll
|
||||
oldBackup := skillSetupBackupAndRemove
|
||||
oldCopyDir, oldWalk, oldRel := skillSetupCopyDir, skillSetupWalk, skillSetupRel
|
||||
oldMkdirTemp, oldRename := skillSetupMkdirTemp, skillSetupRename
|
||||
oldReadlink, oldOpen, oldOpenFile, oldCopy := skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy
|
||||
@@ -234,6 +240,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
|
||||
skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir = oldExecutable, oldGetwd, oldHome
|
||||
skillSetupRemoveAll, skillSetupMkdirAll = oldRemove, oldMkdir
|
||||
skillSetupBackupAndRemove = oldBackup
|
||||
skillSetupCopyDir, skillSetupWalk, skillSetupRel = oldCopyDir, oldWalk, oldRel
|
||||
skillSetupMkdirTemp, skillSetupRename = oldMkdirTemp, oldRename
|
||||
skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy = oldReadlink, oldOpen, oldOpenFile, oldCopy
|
||||
@@ -246,7 +253,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
t.Fatal("interactive mode failure should propagate")
|
||||
}
|
||||
skillSetupRunForm = func(*huh.Form) error { return nil }
|
||||
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMono {
|
||||
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMulti {
|
||||
t.Fatalf("interactive default choice = %q, %v", got, err)
|
||||
}
|
||||
|
||||
@@ -305,23 +312,24 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
|
||||
var out, errOut bytes.Buffer
|
||||
skillSetupRunForm = func(*huh.Form) error { return fail }
|
||||
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}); err == nil {
|
||||
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}, false); err == nil {
|
||||
t.Fatal("confirmation form failure should propagate")
|
||||
}
|
||||
skillSetupRunForm = func(*huh.Form) error { return nil }
|
||||
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil); err != nil || ok {
|
||||
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil, false); err != nil || ok {
|
||||
t.Fatalf("EOF confirmation = %v, %v", ok, err)
|
||||
}
|
||||
skillSetupRemoveAll = func(string) error { return fail }
|
||||
skillSetupUserHomeDir = func() (string, error) { return t.TempDir(), nil }
|
||||
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", fail }
|
||||
cleanupMutualExclusion(monoDest, skillSetupModeMono, &out, &errOut)
|
||||
|
||||
skillSetupCopyDir = func(string, string) error { return fail }
|
||||
skillSetupRemoveAll = func(string) error { return fail }
|
||||
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", fail }
|
||||
_, skipped, _ := installSkillToHomes("src", []string{"a"}, &out, &errOut)
|
||||
if skipped != 1 {
|
||||
t.Fatal("mono remove failure not skipped")
|
||||
t.Fatal("mono backup failure not skipped")
|
||||
}
|
||||
skillSetupRemoveAll = func(string) error { return nil }
|
||||
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", nil }
|
||||
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
|
||||
_, skipped, _ = installSkillToHomes("src", []string{"b"}, &out, &errOut)
|
||||
if skipped != 1 {
|
||||
@@ -334,18 +342,18 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
}
|
||||
|
||||
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
|
||||
if skipped != 2 {
|
||||
t.Fatal("multi mkdir failure count mismatch")
|
||||
}
|
||||
skillSetupMkdirAll = func(string, os.FileMode) error { return nil }
|
||||
skillSetupRemoveAll = func(string) error { return fail }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
|
||||
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", fail }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
|
||||
if skipped != 1 {
|
||||
t.Fatal("multi remove failure count mismatch")
|
||||
t.Fatal("multi backup failure count mismatch")
|
||||
}
|
||||
skillSetupRemoveAll = func(string) error { return nil }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
|
||||
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", nil }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
|
||||
if skipped != 1 {
|
||||
t.Fatal("multi copy failure count mismatch")
|
||||
}
|
||||
@@ -516,15 +524,15 @@ func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing
|
||||
})
|
||||
|
||||
t.Run("ordinary and prerequisite install errors", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
|
||||
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}, io.Discard, io.Discard); !errors.Is(err, fail) {
|
||||
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}, io.Discard, io.Discard); !errors.Is(err, fail) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/charmbracelet/huh"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dest := filepath.Join(home, ".claude", "skills")
|
||||
source := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
|
||||
for _, name := range []string{"dws", "dingtalk-a", "dingtalk-stale"} {
|
||||
if err := os.MkdirAll(filepath.Join(dest, name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
useManagedSkillNames(t, "dingtalk-stale")
|
||||
|
||||
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
|
||||
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return source, func() {}, nil })
|
||||
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{dest}, nil })
|
||||
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) {
|
||||
return []string{"dingtalk-a", "dingtalk-shared"}, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupInteractive, func() bool { return true })
|
||||
testseam.Swap(t, &skillSetupRunForm, func(*huh.Form) error { return nil })
|
||||
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error { return nil })
|
||||
|
||||
wantBackups := []string{
|
||||
filepath.Join(dest, "dingtalk-a"),
|
||||
filepath.Join(dest, "dingtalk-stale"),
|
||||
filepath.Join(dest, "dws"),
|
||||
}
|
||||
|
||||
// Dry-run must disclose every exact path and perform no backup or copy.
|
||||
backupCalls, copyCalls := []string{}, 0
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, path string) (string, error) {
|
||||
backupCalls = append(backupCalls, path)
|
||||
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)
|
||||
if err := dryRunCmd.Root().PersistentFlags().Set("dry-run", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := dryRunCmd.RunE(dryRunCmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range wantBackups {
|
||||
if strings.Count(dryRunOut.String(), path) != 1 {
|
||||
t.Fatalf("dry-run path %s count != 1:\n%s", path, dryRunOut.String())
|
||||
}
|
||||
}
|
||||
if len(backupCalls) != 0 || copyCalls != 0 {
|
||||
t.Fatalf("dry-run mutated backup=%v copy=%d", backupCalls, copyCalls)
|
||||
}
|
||||
|
||||
// The real confirmation renderer discloses the same paths. Its default
|
||||
// negative answer must leave backup and copy at zero calls.
|
||||
declineCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
|
||||
var declineOut bytes.Buffer
|
||||
declineCmd.SetOut(&declineOut)
|
||||
if err := declineCmd.RunE(declineCmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range wantBackups {
|
||||
if strings.Count(declineOut.String(), path) != 1 {
|
||||
t.Fatalf("confirmation path %s count != 1:\n%s", path, declineOut.String())
|
||||
}
|
||||
}
|
||||
if len(backupCalls) != 0 || copyCalls != 0 {
|
||||
t.Fatalf("declined confirmation mutated backup=%v copy=%d", backupCalls, copyCalls)
|
||||
}
|
||||
|
||||
// Explicit confirmation executes exactly the paths rendered from the plan.
|
||||
var confirmedPlan *skillSetupPlan
|
||||
testseam.Swap(t, &skillSetupConfirmPlan, func(out io.Writer, plan *skillSetupPlan) (bool, error) {
|
||||
confirmedPlan = plan
|
||||
renderSkillSetupPlan(out, plan)
|
||||
return true, nil
|
||||
})
|
||||
confirmCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
|
||||
if err := confirmCmd.RunE(confirmCmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var planned []string
|
||||
for _, target := range confirmedPlan.Targets {
|
||||
for _, backup := range target.Backups {
|
||||
planned = append(planned, backup.Path)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(planned, wantBackups) || !reflect.DeepEqual(backupCalls, wantBackups) {
|
||||
t.Fatalf("planned=%v executed=%v want=%v", planned, backupCalls, wantBackups)
|
||||
}
|
||||
if copyCalls != 2 {
|
||||
t.Fatalf("copy calls = %d, want 2", copyCalls)
|
||||
}
|
||||
|
||||
// A filtered multi plan replaces only selected same-name skills and leaves
|
||||
// unselected siblings out of the backup set.
|
||||
filtered, err := buildSkillSetupPlan(skillSetupModeMulti, source, []string{dest}, []string{"dingtalk-a"}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var filteredPaths []string
|
||||
for _, backup := range filtered.Targets[0].Backups {
|
||||
filteredPaths = append(filteredPaths, backup.Path)
|
||||
}
|
||||
if !reflect.DeepEqual(filteredPaths, []string{filepath.Join(dest, "dingtalk-a"), filepath.Join(dest, "dws")}) {
|
||||
t.Fatalf("filtered backups = %v", filteredPaths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupMonoPlanIncludesSameNameTarget(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), ".agents", "skills", "dws")
|
||||
if err := os.MkdirAll(dest, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{dest}, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.Targets) != 1 || len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != dest || plan.Targets[0].Backups[0].Reason != skillSetupBackupReplace {
|
||||
t.Fatalf("mono plan = %#v", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupPlanDeduplicatesAndFailsClosed(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), "skills")
|
||||
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// "dws" is synthetic but makes the mutual-exclusion target and selected
|
||||
// same-name target overlap, pinning path deduplication in the plan itself.
|
||||
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dws"}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != filepath.Join(dest, "dws") {
|
||||
t.Fatalf("deduplicated plan = %#v", plan)
|
||||
}
|
||||
|
||||
failure := errors.New("scan denied")
|
||||
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
|
||||
if err := os.MkdirAll(filepath.Dir(monoDest), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &skillSetupStat, 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, &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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupInstallWrappersFailOnPlanErrors(t *testing.T) {
|
||||
t.Run("multi mono-leftover stat failure", func(t *testing.T) {
|
||||
failure := errors.New("stat denied")
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) {
|
||||
return nil, failure
|
||||
})
|
||||
|
||||
installed, skipped, err := installMultiSkillToHomes(
|
||||
"source",
|
||||
[]string{"dingtalk-a"},
|
||||
[]string{filepath.Join(t.TempDir(), "skills")},
|
||||
io.Discard,
|
||||
io.Discard,
|
||||
true,
|
||||
)
|
||||
if installed != 0 || skipped != 1 || !errors.Is(err, failure) {
|
||||
t.Fatalf("installMultiSkillToHomes = (%d, %d, %v), want (0, 1, %v)", installed, skipped, err, failure)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mono multi-leftover scan failure", func(t *testing.T) {
|
||||
failure := errors.New("scan denied")
|
||||
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) {
|
||||
return nil, failure
|
||||
})
|
||||
|
||||
installed, skipped, err := installSkillToHomes(
|
||||
"source",
|
||||
[]string{filepath.Join(t.TempDir(), "skills", "dws")},
|
||||
io.Discard,
|
||||
io.Discard,
|
||||
)
|
||||
if installed != 0 || skipped != 1 || !errors.Is(err, failure) {
|
||||
t.Fatalf("installSkillToHomes = (%d, %d, %v), want (0, 1, %v)", installed, skipped, err, failure)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupMergedEventPlanEdges(t *testing.T) {
|
||||
t.Run("legacy shared stat failure", func(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), "skills")
|
||||
failure := errors.New("legacy stat denied")
|
||||
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
|
||||
if path == filepath.Join(dest, legacySharedSkill) {
|
||||
return nil, failure
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
})
|
||||
|
||||
_, err := buildSkillSetupPlan(
|
||||
skillSetupModeMulti,
|
||||
"source",
|
||||
[]string{dest},
|
||||
[]string{multiSharedSkill},
|
||||
true,
|
||||
)
|
||||
if !errors.Is(err, failure) {
|
||||
t.Fatalf("legacy shared stat error = %v, want %v", err, failure)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("migration plan retains unrelated backups", func(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), "skills")
|
||||
unrelated := filepath.Join(dest, "dws")
|
||||
plan := &skillSetupPlan{Targets: []skillSetupTargetPlan{
|
||||
{
|
||||
Destination: dest,
|
||||
Backups: []skillSetupBackup{
|
||||
{Path: filepath.Join(dest, multiEventSkill)},
|
||||
{Path: filepath.Join(dest, multiMiscSkill)},
|
||||
{Path: unrelated},
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
configureEventMiscMigrationPlan(plan, []string{dest}, true)
|
||||
if len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != unrelated {
|
||||
t.Fatalf("migration backups = %#v, want only %s", plan.Targets[0].Backups, unrelated)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no migration targets delegates filtered install", func(t *testing.T) {
|
||||
called := false
|
||||
testseam.Swap(t, &skillSetupInstallMulti, func(_ string, _ []string, _ []string, _ io.Writer, _ io.Writer, filtered bool) (int, int, error) {
|
||||
called = true
|
||||
if !filtered {
|
||||
t.Fatal("filtered flag was not forwarded")
|
||||
}
|
||||
return 1, 2, nil
|
||||
})
|
||||
|
||||
installed, skipped, err := installMultiSkillsWithEventMigration(
|
||||
"source", []string{"dingtalk-a"}, []string{"dest"}, nil, true, io.Discard, io.Discard,
|
||||
)
|
||||
if err != nil || installed != 1 || skipped != 2 || !called {
|
||||
t.Fatalf("delegated install = (%d, %d, %v), called=%v", installed, skipped, err, called)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("migration cleanup scan failure", func(t *testing.T) {
|
||||
failure := errors.New("cleanup stat denied")
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) {
|
||||
return nil, failure
|
||||
})
|
||||
dest := filepath.Join(t.TempDir(), "skills")
|
||||
|
||||
installed, skipped, err := installMultiSkillsWithEventMigration(
|
||||
"source",
|
||||
[]string{multiEventSkill, multiSharedSkill},
|
||||
[]string{dest},
|
||||
[]string{dest},
|
||||
true,
|
||||
io.Discard,
|
||||
io.Discard,
|
||||
)
|
||||
if installed != 0 || skipped != 2 || !errors.Is(err, failure) {
|
||||
t.Fatalf("cleanup scan failure = (%d, %d, %v), want (0, 2, %v)", installed, skipped, err, failure)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupEmptyCleanupPlansAreNoOps(t *testing.T) {
|
||||
dest := t.TempDir()
|
||||
var out, errOut bytes.Buffer
|
||||
if err := cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := removeStaleMultiSkills(dest, []string{"dingtalk-a"}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Len() != 0 || errOut.Len() != 0 {
|
||||
t.Fatalf("empty cleanup output = %q / %q", out.String(), errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupSameNameBackupFailureSkipsTarget(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dest := filepath.Join(home, ".agents", "skills")
|
||||
plan := &skillSetupPlan{
|
||||
Mode: skillSetupModeMulti,
|
||||
Source: "source",
|
||||
MultiSkillNames: []string{"dingtalk-a"},
|
||||
Targets: []skillSetupTargetPlan{{
|
||||
Destination: dest,
|
||||
Backups: []skillSetupBackup{{
|
||||
Path: filepath.Join(dest, "dingtalk-a"),
|
||||
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")
|
||||
})
|
||||
copyCalls := 0
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil })
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut)
|
||||
if err != nil || installed != 0 || skipped != 1 || copyCalls != 1 {
|
||||
t.Fatalf("same-name failure = (%d, %d, %v), copy=%d", installed, skipped, err, copyCalls)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
|
||||
t.Fatalf("same-name warning = %q", errOut.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupPersistsOfficialSnapshot(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
|
||||
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
|
||||
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{filepath.Join(home, "skills")}, nil })
|
||||
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) {
|
||||
return []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
|
||||
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
|
||||
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupReadState, func(string) (*skillstate.State, bool, error) {
|
||||
return &skillstate.State{ManagedSkills: []skillprovenance.Record{{Name: "dingtalk-existing"}}}, true, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupExecutePlan, func(plan *skillSetupPlan, _ io.Writer, _ io.Writer) (int, int, error) {
|
||||
if plan.Mode == skillSetupModeMono {
|
||||
return 1, 0, nil
|
||||
}
|
||||
return 2, 0, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupNow, func() time.Time {
|
||||
return time.Date(2026, 8, 10, 1, 2, 3, 0, time.UTC)
|
||||
})
|
||||
|
||||
var saved skillstate.State
|
||||
testseam.Swap(t, &skillSetupWriteState, func(_ string, state skillstate.State) error {
|
||||
saved = state
|
||||
return nil
|
||||
})
|
||||
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.Flags().Set("skill", "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(saved.OfficialSkills, []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}) ||
|
||||
!reflect.DeepEqual(saved.UpdatedSkills, []string{"dingtalk-shared", "dingtalk-a"}) ||
|
||||
!reflect.DeepEqual(skillprovenance.Names(saved.ManagedSkills), map[string]bool{"dingtalk-a": true, "dingtalk-existing": true, "dingtalk-shared": true}) {
|
||||
t.Fatalf("saved = %#v", saved)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error { return errors.New("denied") })
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "信息快照失败") {
|
||||
t.Fatalf("write-state error = %v", err)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &skillSetupRemoveState, func(string) error { return errors.New("denied") })
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "清理 multi") {
|
||||
t.Fatalf("remove-state error = %v", err)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &skillSetupRemoveState, func(string) error { return nil })
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("no home") })
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "无法解析 HOME") {
|
||||
t.Fatalf("home error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupFilteredUnreadableStateStopsBeforeInstall(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
|
||||
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
|
||||
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{filepath.Join(home, "skills")}, nil })
|
||||
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) { return []string{"dingtalk-a", "dingtalk-shared"}, nil })
|
||||
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
|
||||
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
|
||||
})
|
||||
stateErr := errors.New("state denied")
|
||||
testseam.Swap(t, &skillSetupReadState, func(string) (*skillstate.State, bool, error) { return nil, false, stateErr })
|
||||
executed := 0
|
||||
testseam.Swap(t, &skillSetupExecutePlan, func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) {
|
||||
executed++
|
||||
return 1, 0, nil
|
||||
})
|
||||
|
||||
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.Flags().Set("skill", "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := cmd.RunE(cmd, nil)
|
||||
if !errors.Is(err, stateErr) || executed != 0 {
|
||||
t.Fatalf("filtered setup = err %v, executed %d", err, executed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupProvenancePreflightFailures(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
filtered bool
|
||||
buildError error
|
||||
homeError error
|
||||
}{
|
||||
{name: "digest", buildError: errors.New("digest denied")},
|
||||
{name: "filtered home", filtered: true, homeError: errors.New("home denied")},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
|
||||
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
|
||||
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{filepath.Join(home, "skills")}, nil })
|
||||
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) { return []string{"dingtalk-a", "dingtalk-shared"}, nil })
|
||||
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
|
||||
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
|
||||
if tc.buildError != nil {
|
||||
return skillprovenance.Record{}, tc.buildError
|
||||
}
|
||||
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) {
|
||||
if tc.homeError != nil {
|
||||
return "", tc.homeError
|
||||
}
|
||||
return home, nil
|
||||
})
|
||||
executed := 0
|
||||
testseam.Swap(t, &skillSetupExecutePlan, func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) {
|
||||
executed++
|
||||
return 1, 0, nil
|
||||
})
|
||||
|
||||
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if tc.filtered {
|
||||
if err := cmd.Flags().Set("skill", "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
err := cmd.RunE(cmd, nil)
|
||||
if err == nil || executed != 0 {
|
||||
t.Fatalf("preflight = err %v, executed %d", err, executed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupPartialInstallDoesNotWriteState(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
|
||||
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
|
||||
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) {
|
||||
return []string{filepath.Join(home, "skills")}, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) {
|
||||
return []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
|
||||
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
|
||||
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupExecutePlan, func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) {
|
||||
return 2, 1, nil
|
||||
})
|
||||
|
||||
writes := 0
|
||||
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error {
|
||||
writes++
|
||||
return nil
|
||||
})
|
||||
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
err := cmd.RunE(cmd, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "Skill 安装不完整") || !strings.Contains(err.Error(), "skipped=1") {
|
||||
t.Fatalf("partial setup error = %v", err)
|
||||
}
|
||||
if writes != 0 {
|
||||
t.Fatalf("partial setup wrote %d complete state snapshot(s)", writes)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestSkillSetupCommandRegistered(t *testing.T) {
|
||||
@@ -25,6 +29,189 @@ func TestSkillSetupCommandRegistered(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupExamplesDoNotBypassConfirmation guards
|
||||
// the P1 review finding: copyable examples must preview or enter the normal
|
||||
// confirmation path, never carry the scripting-only confirmation bypass.
|
||||
func TestCrossPlatformCoverageSkillSetupExamplesDoNotBypassConfirmation(t *testing.T) {
|
||||
cmd := newSkillSetupCommand()
|
||||
var examples []string
|
||||
for _, line := range strings.Split(cmd.Example, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
examples = append(examples, line)
|
||||
if strings.Contains(line, "--yes") {
|
||||
t.Fatalf("skill setup example bypasses confirmation: %q", line)
|
||||
}
|
||||
}
|
||||
if len(examples) != 2 || !strings.Contains(examples[0], "--dry-run") || strings.Contains(examples[1], "--dry-run") {
|
||||
t.Fatalf("examples must show preview then interactive confirmation: %v", examples)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupHelpDescribesFullUpgradeRefresh(t *testing.T) {
|
||||
help := newSkillSetupCommand().Long
|
||||
for _, want := range []string{"每次 dws upgrade", "全量覆盖预制 skill", "本地删除", "会在升级时恢复"} {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("skill setup help missing full-refresh contract %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
for _, stale := range []string{"跳过本地已删除", "--force 恢复全量"} {
|
||||
if strings.Contains(help, stale) {
|
||||
t.Fatalf("skill setup help still advertises retired incremental behavior %q:\n%s", stale, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupDeclinedConfirmationNeverRemoves verifies
|
||||
// the destructive half of the setup contract: when the user declines the
|
||||
// confirmation prompt, nothing is installed and nothing is removed (neither
|
||||
// the opposite-mode leftovers nor stale skills). Confirming must then run the
|
||||
// exact cleanup previewed earlier: leftovers are backed up to
|
||||
// ~/.dws/skill-backups/ before they disappear.
|
||||
func TestCrossPlatformCoverageSkillSetupDeclinedConfirmationNeverRemoves(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
|
||||
multiSrc := writeMultiSkillSource(t, []string{"dingtalk-aitable", "dingtalk-calendar"})
|
||||
agentHome := filepath.Join(home, ".claude", "skills")
|
||||
|
||||
// Opposite-mode leftover (mono dws/) plus a stale multi skill the full
|
||||
// install would clean; both must survive a declined confirmation.
|
||||
for _, leftover := range []string{filepath.Join(agentHome, "dws"), filepath.Join(agentHome, "dingtalk-stale")} {
|
||||
if err := os.MkdirAll(leftover, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(leftover, "SKILL.md"), []byte("keep-me"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
useManagedSkillNames(t, "dingtalk-stale")
|
||||
|
||||
oldConfirm := skillSetupConfirmPlan
|
||||
t.Cleanup(func() { skillSetupConfirmPlan = oldConfirm })
|
||||
|
||||
// Declined confirmation: nothing may change on disk.
|
||||
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return false, nil }
|
||||
cmd := newSkillSetupCommand()
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{"--mode", "multi", "--target", "claude", "--source", multiSrc})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("declined setup should succeed as a no-op: %v (%s)", err, errOut.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "已取消") {
|
||||
t.Fatalf("expected cancellation notice, got %q", out.String())
|
||||
}
|
||||
for _, survivor := range []string{filepath.Join(agentHome, "dws"), filepath.Join(agentHome, "dingtalk-stale")} {
|
||||
if _, err := os.Stat(filepath.Join(survivor, "SKILL.md")); err != nil {
|
||||
t.Fatalf("declined confirmation removed %s: %v", survivor, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable")); !os.IsNotExist(err) {
|
||||
t.Fatalf("declined confirmation must not install either: %v", err)
|
||||
}
|
||||
|
||||
// Confirmed: the previewed victims are backed up + removed, bundle skills land.
|
||||
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return true, nil }
|
||||
out.Reset()
|
||||
errOut.Reset()
|
||||
cmd = newSkillSetupCommand()
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{"--mode", "multi", "--target", "claude", "--source", multiSrc})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("confirmed setup failed: %v (%s)", err, errOut.String())
|
||||
}
|
||||
for _, gone := range []string{filepath.Join(agentHome, "dws"), filepath.Join(agentHome, "dingtalk-stale")} {
|
||||
if _, err := os.Stat(gone); !os.IsNotExist(err) {
|
||||
t.Fatalf("confirmed setup should remove %s (stat err=%v)", gone, err)
|
||||
}
|
||||
}
|
||||
for _, n := range []string{"dingtalk-aitable", "dingtalk-calendar"} {
|
||||
if _, err := os.Stat(filepath.Join(agentHome, n, "SKILL.md")); err != nil {
|
||||
t.Fatalf("confirmed setup missing %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
// Every removal went through the reversible backup path, not a hard delete.
|
||||
backupRoot := filepath.Join(home, ".dws", "skill-backups")
|
||||
entries, err := os.ReadDir(backupRoot)
|
||||
if err != nil || len(entries) == 0 {
|
||||
t.Fatalf("confirmed setup must preserve victims under %s (entries=%v, err=%v)", backupRoot, entries, err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "已备份并清理对面模式残留") || !strings.Contains(out.String(), "已备份并清理过期 skill") {
|
||||
t.Fatalf("expected backup-and-remove log lines, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupNonInteractiveRequiresYes pins the real
|
||||
// non-TTY safety boundary: an explicit mode alone is not consent to move
|
||||
// directories. The same command with --yes performs the previewed backup and
|
||||
// installation.
|
||||
func TestCrossPlatformCoverageSkillSetupNonInteractiveRequiresYes(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
oldInteractive := skillSetupInteractive
|
||||
skillSetupInteractive = func() bool { return false }
|
||||
t.Cleanup(func() { skillSetupInteractive = oldInteractive })
|
||||
|
||||
multiSrc := writeMultiSkillSource(t, []string{"dingtalk-aitable"})
|
||||
agentHome := filepath.Join(home, ".claude", "skills")
|
||||
mono := filepath.Join(agentHome, "dws")
|
||||
if err := os.MkdirAll(mono, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("keep-me"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
run := func(extra ...string) error {
|
||||
cmd := newSkillSetupCommand()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
args := []string{"--mode", "multi", "--target", "claude", "--source", multiSrc}
|
||||
cmd.SetArgs(append(args, extra...))
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
if err := run(); err == nil || !strings.Contains(err.Error(), "--yes") {
|
||||
t.Fatalf("non-interactive setup error = %v, want explicit --yes requirement", err)
|
||||
}
|
||||
if data, err := os.ReadFile(filepath.Join(mono, "SKILL.md")); err != nil || string(data) != "keep-me" {
|
||||
t.Fatalf("unconfirmed setup changed mono (data=%q, err=%v)", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable")); !os.IsNotExist(err) {
|
||||
t.Fatalf("unconfirmed setup installed multi, stat err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".dws", "skill-backups")); !os.IsNotExist(err) {
|
||||
t.Fatalf("unconfirmed setup created backup state, stat err=%v", err)
|
||||
}
|
||||
|
||||
if err := run("--yes"); err != nil {
|
||||
t.Fatalf("explicitly confirmed setup failed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(mono); !os.IsNotExist(err) {
|
||||
t.Fatalf("confirmed setup kept mono, stat err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable", "SKILL.md")); err != nil {
|
||||
t.Fatalf("confirmed setup did not install multi: %v", err)
|
||||
}
|
||||
backupFound := false
|
||||
_ = filepath.Walk(filepath.Join(home, ".dws", "skill-backups"), func(path string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr == nil && info != nil && !info.IsDir() && info.Name() == "SKILL.md" {
|
||||
if data, readErr := os.ReadFile(path); readErr == nil && string(data) == "keep-me" {
|
||||
backupFound = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if !backupFound {
|
||||
t.Fatal("confirmed setup did not back up mono")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSetupModeFlagDirect(t *testing.T) {
|
||||
got, err := resolveSkillSetupMode("mono", true, &bytes.Buffer{})
|
||||
if err != nil || got != skillSetupModeMono {
|
||||
@@ -39,14 +226,14 @@ func TestResolveSkillSetupModeFlagDirect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSetupModeNonInteractiveDefaultsMono(t *testing.T) {
|
||||
func TestResolveSkillSetupModeNonInteractiveDefaultsMulti(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
got, err := resolveSkillSetupMode("", true, &buf)
|
||||
if err != nil || got != skillSetupModeMono {
|
||||
t.Fatalf("non-interactive empty mode should default to mono, got %q err=%v", got, err)
|
||||
if err != nil || got != skillSetupModeMulti {
|
||||
t.Fatalf("non-interactive empty mode should default to multi, got %q err=%v", got, err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "mono") {
|
||||
t.Fatalf("expected output to mention mono fallback, got %q", buf.String())
|
||||
if !strings.Contains(buf.String(), "multi") {
|
||||
t.Fatalf("expected output to mention multi fallback, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +330,7 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSkillToHomesEndToEnd(t *testing.T) {
|
||||
func TestCrossPlatformCoverageInstallSkillToHomesEndToEnd(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("# test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -205,7 +392,7 @@ func writeMultiSkillSource(t *testing.T, names []string) string {
|
||||
return root
|
||||
}
|
||||
|
||||
func TestInstallMultiSkillToHomes(t *testing.T) {
|
||||
func TestCrossPlatformCoverageInstallMultiSkillToHomes(t *testing.T) {
|
||||
names := []string{"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc"}
|
||||
src := writeMultiSkillSource(t, names)
|
||||
|
||||
@@ -221,7 +408,7 @@ func TestInstallMultiSkillToHomes(t *testing.T) {
|
||||
dst2 := filepath.Join(t.TempDir(), ".cursor", "skills")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, got, []string{dst1, dst2}, &stdout, &stderr)
|
||||
installed, skipped, err := installMultiSkillToHomes(src, got, []string{dst1, dst2}, &stdout, &stderr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("installMultiSkillToHomes err: %v", err)
|
||||
}
|
||||
@@ -245,12 +432,14 @@ func TestInstallMultiSkillToHomes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupMutualExclusion(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSkillSetupMutualExclusion(t *testing.T) {
|
||||
names := []string{"dingtalk-aitable", "dingtalk-calendar"}
|
||||
src := writeMultiSkillSource(t, names)
|
||||
|
||||
// Simulate a pre-existing mono install under <agent-home>/dws/
|
||||
agentHome := filepath.Join(t.TempDir(), ".claude", "skills")
|
||||
homeRoot := t.TempDir()
|
||||
setTestHome(t, homeRoot)
|
||||
agentHome := filepath.Join(homeRoot, ".claude", "skills")
|
||||
monoLeftover := filepath.Join(agentHome, "dws")
|
||||
if err := os.MkdirAll(filepath.Join(monoLeftover, "references"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -265,13 +454,16 @@ func TestSkillSetupMutualExclusion(t *testing.T) {
|
||||
}
|
||||
|
||||
// Confirm mutualExclusionVictims sees the leftover
|
||||
victims := mutualExclusionVictims(agentHome, skillSetupModeMulti)
|
||||
victims, vErr := mutualExclusionVictims(agentHome, skillSetupModeMulti)
|
||||
if vErr != nil {
|
||||
t.Fatalf("mutualExclusionVictims err: %v", vErr)
|
||||
}
|
||||
if len(victims) != 1 || victims[0] != monoLeftover {
|
||||
t.Fatalf("expected victims=[%s], got %v", monoLeftover, victims)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr)
|
||||
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
|
||||
}
|
||||
@@ -290,7 +482,7 @@ func TestSkillSetupMutualExclusion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// the cleanup line should appear in stdout (best-effort observability)
|
||||
if !strings.Contains(stdout.String(), "已清理对面模式残留") {
|
||||
if !strings.Contains(stdout.String(), "已备份并清理对面模式残留") {
|
||||
t.Fatalf("expected cleanup log line, got stdout=%q", stdout.String())
|
||||
}
|
||||
|
||||
@@ -318,11 +510,48 @@ func TestSkillSetupMutualExclusion(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(monoDest, "SKILL.md")); err != nil {
|
||||
t.Fatalf("mono SKILL.md missing: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "已清理对面模式残留") {
|
||||
if !strings.Contains(stdout.String(), "已备份并清理对面模式残留") {
|
||||
t.Fatalf("expected cleanup log line on mono install, got stdout=%q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupMonoPreservesUnregisteredDingtalkSkill(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
base := filepath.Join(home, ".agents", "skills")
|
||||
managed := filepath.Join(base, "dingtalk-managed-old")
|
||||
legacyOfficial := filepath.Join(base, "dingtalk-aitable")
|
||||
custom := filepath.Join(base, "dingtalk-custom")
|
||||
for _, dir := range []string{managed, legacyOfficial, custom} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(filepath.Base(dir)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
useManagedSkillNames(t, filepath.Base(managed))
|
||||
|
||||
monoSrc := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := installSkillToHomes(monoSrc, []string{filepath.Join(base, "dws")}, &out, &errOut)
|
||||
if err != nil || installed != 1 || skipped != 0 {
|
||||
t.Fatalf("mono install = (%d, %d, %v), stderr=%s", installed, skipped, err, errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(managed); !os.IsNotExist(err) {
|
||||
t.Fatalf("centrally managed DWS multi Skill must be removed during mono switch: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(legacyOfficial); !os.IsNotExist(err) {
|
||||
t.Fatalf("pre-state official multi Skill must be removed during mono switch: %v", err)
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(custom, "SKILL.md")); err != nil || string(got) != "dingtalk-custom" {
|
||||
t.Fatalf("unregistered market/user dingtalk-* Skill changed: data=%q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillSourceCandidatesIncludesUserCache verifies that the user-level
|
||||
// cache populated by install.sh / install.ps1 / npm install.js is part of the
|
||||
// fallback candidate list, so `dws skill setup` can find a source on a fresh
|
||||
@@ -464,10 +693,11 @@ func TestFilterMultiSkillNames(t *testing.T) {
|
||||
// TestSkillSetupMultiAdditivePreservesSiblings verifies the key UX promise of
|
||||
// `dws skill setup --mode multi -s aitable`: installing a subset must NOT
|
||||
// touch already-installed dingtalk-* siblings (additive semantics).
|
||||
func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc",
|
||||
})
|
||||
setTestHome(t, t.TempDir())
|
||||
agentHome := filepath.Join(t.TempDir(), ".claude", "skills")
|
||||
|
||||
// Pretend the user already installed two dingtalk-* skills earlier.
|
||||
@@ -493,7 +723,7 @@ func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, filtered, []string{agentHome}, &stdout, &stderr)
|
||||
installed, skipped, err := installMultiSkillToHomes(src, filtered, []string{agentHome}, &stdout, &stderr, true)
|
||||
if err != nil {
|
||||
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
|
||||
}
|
||||
@@ -561,6 +791,135 @@ func TestResolveSkillSetupSourceMultiFinds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupMultiFullInstallCleansStale verifies that a full (unfiltered)
|
||||
// multi install removes stale dingtalk-* / dws-shared directories that are no
|
||||
// longer part of the bundle, matching install.sh / install.js / upgrade paths.
|
||||
// The additive counterpart (filtered install) is covered by
|
||||
// TestCrossPlatformCoverageSkillSetupMultiAdditivePreservesSiblings.
|
||||
func TestCrossPlatformCoverageSkillSetupMultiFullInstallCleansStale(t *testing.T) {
|
||||
names := []string{"dingtalk-aitable"}
|
||||
src := writeMultiSkillSource(t, names)
|
||||
|
||||
homeRoot := t.TempDir()
|
||||
setTestHome(t, homeRoot)
|
||||
agentHome := filepath.Join(homeRoot, ".claude", "skills")
|
||||
// Stale multi skills absent from the bundle, plus a non-DWS dir that must survive.
|
||||
for _, n := range []string{"dingtalk-stale", "dws-shared", "other-skill"} {
|
||||
dir := filepath.Join(agentHome, n)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("OLD "+n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
useManagedSkillNames(t, "dingtalk-stale")
|
||||
custom := filepath.Join(agentHome, "dingtalk-custom")
|
||||
if err := os.MkdirAll(custom, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(custom, "SKILL.md"), []byte("market skill"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
|
||||
}
|
||||
if installed != 1 || skipped != 0 {
|
||||
t.Fatalf("expected installed=1 skipped=0, got %d/%d", installed, skipped)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable", "SKILL.md")); err != nil {
|
||||
t.Errorf("missing installed skill: %v", err)
|
||||
}
|
||||
for _, stale := range []string{"dingtalk-stale", "dws-shared"} {
|
||||
if _, err := os.Stat(filepath.Join(agentHome, stale)); !os.IsNotExist(err) {
|
||||
t.Errorf("stale %q should be removed by a full multi install (stat err=%v)", stale, err)
|
||||
}
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(agentHome, "other-skill", "SKILL.md"))
|
||||
if err != nil || !strings.HasPrefix(string(body), "OLD ") {
|
||||
t.Errorf("non-DWS dir must be preserved (body=%q, err=%v)", string(body), err)
|
||||
}
|
||||
if body, err := os.ReadFile(filepath.Join(custom, "SKILL.md")); err != nil || string(body) != "market skill" {
|
||||
t.Errorf("unregistered market/user dingtalk-* dir must survive (body=%q, err=%v)", string(body), err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "已备份并清理过期 skill") {
|
||||
t.Errorf("expected stale cleanup log line, got stdout=%q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillSetupMutualExclusionScanWarning verifies that a victim-scan failure
|
||||
// surfaces as an errOut warning instead of silently skipping cleanup.
|
||||
func TestCrossPlatformCoverageSkillSetupMutualExclusionScanWarning(t *testing.T) {
|
||||
oldReadDir := skillSetupReadDir
|
||||
t.Cleanup(func() { skillSetupReadDir = oldReadDir })
|
||||
scanFail := errors.New("scan boom")
|
||||
skillSetupReadDir = func(string) ([]os.DirEntry, error) { return nil, scanFail }
|
||||
|
||||
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
|
||||
if _, err := mutualExclusionVictims(monoDest, skillSetupModeMono); err == nil {
|
||||
t.Fatal("scan failure should surface as an error")
|
||||
}
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
cleanupMutualExclusion(monoDest, skillSetupModeMono, &out, &errOut)
|
||||
if !strings.Contains(errOut.String(), "互斥清理扫描失败") {
|
||||
t.Fatalf("expected scan warning on errOut, got %q", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunSkillSetupThreadsFilteredFlag verifies runSkillSetup tells
|
||||
// installMultiSkillToHomes whether -s/--skill or -x/--exclude was used, so a
|
||||
// full install cleans stale siblings while a filtered install stays additive.
|
||||
func TestRunSkillSetupThreadsFilteredFlag(t *testing.T) {
|
||||
oldMode, oldSource, oldTargets := skillSetupResolveMode, skillSetupResolveSource, skillSetupResolveTargets
|
||||
oldList, oldFilter, oldExecute := skillSetupListMulti, skillSetupFilterMulti, skillSetupExecutePlan
|
||||
t.Cleanup(func() {
|
||||
skillSetupResolveMode, skillSetupResolveSource, skillSetupResolveTargets = oldMode, oldSource, oldTargets
|
||||
skillSetupListMulti, skillSetupFilterMulti, skillSetupExecutePlan = oldList, oldFilter, oldExecute
|
||||
})
|
||||
|
||||
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
|
||||
skillSetupResolveSource = func(string, string) (string, func(), error) { return "source", func() {}, nil }
|
||||
skillSetupResolveTargets = func(string, string) ([]string, error) {
|
||||
return []string{filepath.Join(t.TempDir(), "dest")}, nil
|
||||
}
|
||||
skillSetupListMulti = func(string) ([]string, error) { return []string{"dingtalk-aitable", "dws-shared"}, nil }
|
||||
skillSetupFilterMulti = filterMultiSkillNames
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
|
||||
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error { return nil })
|
||||
var gotFiltered []bool
|
||||
skillSetupExecutePlan = func(plan *skillSetupPlan, _, _ io.Writer) (int, int, error) {
|
||||
gotFiltered = append(gotFiltered, plan.Filtered)
|
||||
return 1, 0, nil
|
||||
}
|
||||
|
||||
// Full install (no -s/-x): filtered must be false.
|
||||
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("full install run err: %v", err)
|
||||
}
|
||||
|
||||
// Filtered install: filtered must be true.
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.Flags().Set("skill", "aitable"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("filtered install run err: %v", err)
|
||||
}
|
||||
|
||||
if len(gotFiltered) != 2 || gotFiltered[0] != false || gotFiltered[1] != true {
|
||||
t.Fatalf("filtered flag threading = %v, want [false true]", gotFiltered)
|
||||
}
|
||||
}
|
||||
func executeMultiSkillSetupTest(t *testing.T, src string, dests []string, args ...string) (string, string, error) {
|
||||
t.Helper()
|
||||
originalTargets := skillSetupResolveTargets
|
||||
@@ -633,7 +992,7 @@ func assertNoEventMigrationStages(t *testing.T, agentHome string) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
|
||||
})
|
||||
@@ -653,13 +1012,10 @@ func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event")
|
||||
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, "迁移伴侣") || !strings.Contains(stdout, foldedHome) {
|
||||
t.Fatalf("confirmation output should expose folded misc migration: %s", stdout)
|
||||
}
|
||||
if !strings.Contains(stdout, "重新加载 Skills") {
|
||||
t.Fatalf("completion should tell the user to reload skills: %s", stdout)
|
||||
}
|
||||
@@ -703,7 +1059,7 @@ func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationDryRunAndExplicitExclude(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSkillSetupEventMigrationDryRunAndExplicitExclude(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
|
||||
})
|
||||
@@ -854,7 +1210,7 @@ func TestSkillSetupSelectiveEventPreservesFoldedMiscAfterPrimarySkip(t *testing.
|
||||
originalInstallMulti := skillSetupInstallMulti
|
||||
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
|
||||
calls := 0
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
|
||||
calls++
|
||||
if calls > 1 {
|
||||
t.Fatal("misc migration companion ran after a primary install skip")
|
||||
@@ -891,7 +1247,7 @@ func TestSkillSetupFreshTargetFailureDoesNotTouchFoldedPair(t *testing.T) {
|
||||
originalInstallMulti := skillSetupInstallMulti
|
||||
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
|
||||
calls := 0
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
|
||||
calls++
|
||||
if calls > 1 {
|
||||
t.Fatal("folded target prerequisites ran after fresh target failure")
|
||||
|
||||
@@ -57,9 +57,9 @@ var (
|
||||
downloadUpgradeProgress = upgrade.DownloadWithProgress
|
||||
extractUpgradeZip = upgrade.ExtractZip
|
||||
findExtractedBinary = upgrade.FindBinaryInDir
|
||||
locateUpgradeSkill = upgrade.LocateSkillMD
|
||||
locateUpgradeSkill = upgrade.LocateSkillsRoot
|
||||
replaceUpgradeSelf = upgrade.ReplaceSelf
|
||||
installUpgradeSkills = upgrade.UpgradeSkillLocations
|
||||
installUpgradeSkills = upgrade.UpgradeSkillLocationsWithOptions
|
||||
upgradeMkdirTemp = os.MkdirTemp
|
||||
upgradeRemoveAll = os.RemoveAll
|
||||
upgradeReadFile = os.ReadFile
|
||||
@@ -99,7 +99,8 @@ func newUpgradeCommand() *cobra.Command {
|
||||
Long: `检查并升级 DWS CLI 到最新版本。
|
||||
|
||||
自动下载匹配当前平台的二进制文件和技能包,通过 SHA256 校验后原子替换。
|
||||
升级前会自动备份当前版本,可通过 --rollback 回滚。`,
|
||||
升级前会自动备份当前版本,可通过 --rollback 回滚。
|
||||
每次升级都会按新版本官方清单全量覆盖预制 Skill;--force 仅额外允许重装当前版本。`,
|
||||
Example: ` dws upgrade # 交互式升级到最新版本
|
||||
dws upgrade --check # 仅检查是否有新版本
|
||||
dws upgrade --list # 列出最近版本
|
||||
@@ -107,6 +108,7 @@ func newUpgradeCommand() *cobra.Command {
|
||||
dws upgrade --beta # 升级到最新 beta 预发布版本
|
||||
dws upgrade --version v1.0.7 # 升级到指定正式版本
|
||||
dws upgrade --version v1.0.8-beta.1 # 升级到指定 beta 版本
|
||||
dws upgrade --force # 即使已是最新版本也重装当前版本
|
||||
dws upgrade --rollback # 回滚到上一版本
|
||||
dws upgrade --dry-run # 仅预览升级步骤,不实际执行
|
||||
dws upgrade -y # 跳过确认直接升级`,
|
||||
@@ -158,7 +160,7 @@ func newUpgradeCommand() *cobra.Command {
|
||||
cmd.Flags().StringVar(&flagVersion, "version", "", "升级到指定版本")
|
||||
cmd.Flags().BoolVar(&flagBeta, "beta", false, "使用最新 beta 预发布版本(默认使用正式 release)")
|
||||
cmd.Flags().BoolVar(&flagRollback, "rollback", false, "回滚到上一版本")
|
||||
cmd.Flags().BoolVar(&flagForce, "force", false, "强制重新安装当前版本")
|
||||
cmd.Flags().BoolVar(&flagForce, "force", false, "即使已是最新版本也强制重新安装当前版本")
|
||||
cmd.Flags().BoolVar(&flagSkipSkills, "skip-skills", false, "跳过技能包更新")
|
||||
|
||||
return cmd
|
||||
@@ -596,7 +598,9 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
}
|
||||
|
||||
if hasSkills {
|
||||
result, installErr := installUpgradeSkills(skillSrc)
|
||||
result, installErr := installUpgradeSkills(skillSrc, upgrade.SkillUpgradeOptions{
|
||||
Version: release.Version,
|
||||
})
|
||||
if installErr != nil {
|
||||
fmt.Printf(" %s\n", ugRed("✗"))
|
||||
return fmt.Errorf("技能包安装失败: %w", installErr)
|
||||
|
||||
@@ -285,7 +285,7 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
installUpgradeSkills = func(string) (*upgradepkg.SkillUpgradeResult, error) {
|
||||
installUpgradeSkills = func(string, upgradepkg.SkillUpgradeOptions) (*upgradepkg.SkillUpgradeResult, error) {
|
||||
if stage == "install" {
|
||||
return nil, fail
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"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/upgrade"
|
||||
)
|
||||
|
||||
// TestCrossPlatformCoverageUpgradeSkillLocationsMonoSeedMigratesToMulti is the fake-HOME E2E for
|
||||
// the 2026-08-05 owner decision: upgrade is not disk-sticky. Seeding a mono
|
||||
// layout then calling UpgradeSkillLocations with a multi bundle must install
|
||||
// product skills, remove dws/, and leave non-DWS dirs alone.
|
||||
func TestCrossPlatformCoverageUpgradeSkillLocationsMonoSeedMigratesToMulti(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
upgrade.SwapUserHomeDirForTest(t, func() (string, error) { return home, nil })
|
||||
|
||||
agentsBase := filepath.Join(home, ".agents", "skills")
|
||||
if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(agentsBase, "dws", "SKILL.md"), []byte("old mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(agentsBase, "other-skill"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(agentsBase, "other-skill", "SKILL.md"), []byte("not dws"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
extract := t.TempDir()
|
||||
multiRoot := filepath.Join(extract, "multi")
|
||||
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
|
||||
dir := filepath.Join(multiRoot, 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)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := upgrade.UpgradeSkillLocations(multiRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v", err)
|
||||
}
|
||||
if failed := result.Failed(); len(failed) != 0 {
|
||||
t.Fatalf("expected 0 failures, got %v", failed)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
|
||||
t.Fatalf("mono leftover dws/ must be gone, stat err=%v", err)
|
||||
}
|
||||
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
|
||||
t.Errorf("multi skill missing: %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
|
||||
t.Errorf("non-DWS dir should be preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpgradeSkillLocationsRealBundleMigratesMono runs the upgrade path against
|
||||
// the repository's actual multi bundle (skills/multi, post-#887 layout with
|
||||
// dingtalk-shared). Disk is seeded with a mono install plus the leftovers the
|
||||
// rename and stale releases leave behind: a pre-rename dws-shared directory
|
||||
// and a dingtalk-* skill no longer in the bundle. All three must be gone after
|
||||
// the upgrade, every bundle skill installed, non-DWS dirs untouched, and the
|
||||
// ~/.dws/skills/multi cache refreshed.
|
||||
func TestCrossPlatformCoverageUpgradeSkillLocationsRealBundleMigratesMono(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
upgrade.SwapUserHomeDirForTest(t, func() (string, error) { return home, nil })
|
||||
|
||||
bundle := filepath.Join("..", "..", "skills", "multi")
|
||||
entries, err := os.ReadDir(bundle)
|
||||
if err != nil {
|
||||
t.Fatalf("real multi bundle missing: %v", err)
|
||||
}
|
||||
var bundleNames []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
bundleNames = append(bundleNames, e.Name())
|
||||
}
|
||||
}
|
||||
if len(bundleNames) == 0 {
|
||||
t.Fatal("real multi bundle is empty")
|
||||
}
|
||||
|
||||
agentsBase := filepath.Join(home, ".agents", "skills")
|
||||
// Mono install plus pre-rename shared and a stale product skill.
|
||||
for _, name := range []string{"dws", "dws-shared", "dingtalk-stale", "other-skill"} {
|
||||
dir := filepath.Join(agentsBase, 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)
|
||||
}
|
||||
}
|
||||
if err := skillstate.Write(home, skillstate.State{ManagedSkills: []skillprovenance.Record{{Name: "dingtalk-stale"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
custom := filepath.Join(agentsBase, "dingtalk-custom")
|
||||
if err := os.MkdirAll(custom, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(custom, "SKILL.md"), []byte("market skill"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := upgrade.UpgradeSkillLocations(bundle)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v", err)
|
||||
}
|
||||
if failed := result.Failed(); len(failed) != 0 {
|
||||
t.Fatalf("expected 0 failures, got %v", failed)
|
||||
}
|
||||
|
||||
for _, stale := range []string{"dws", "dws-shared", "dingtalk-stale"} {
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, stale)); !os.IsNotExist(err) {
|
||||
t.Errorf("leftover %q must be removed by the upgrade, stat err=%v", stale, err)
|
||||
}
|
||||
}
|
||||
for _, name := range bundleNames {
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
|
||||
t.Errorf("bundle skill %q missing after upgrade: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
|
||||
t.Errorf("non-DWS dir should be preserved: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(custom, "SKILL.md")); err != nil {
|
||||
t.Errorf("unregistered market/user dingtalk-* dir should be preserved: %v", err)
|
||||
}
|
||||
// Shared skill must come from the renamed bundle dir, never the legacy name.
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "dingtalk-shared", "SKILL.md")); err != nil {
|
||||
t.Errorf("dingtalk-shared missing: %v", err)
|
||||
}
|
||||
// Cache refresh so `dws skill setup` fallbacks stay on the upgraded version.
|
||||
if _, err := os.Stat(filepath.Join(home, ".dws", "skills", "multi", "SKILL.md")); err != nil {
|
||||
// multi cache mirrors the bundle root, which has no top-level SKILL.md;
|
||||
// check a bundle skill inside the cache instead.
|
||||
if _, err2 := os.Stat(filepath.Join(home, ".dws", "skills", "multi", bundleNames[0], "SKILL.md")); err2 != nil {
|
||||
t.Errorf("multi cache not refreshed: %v / %v", err, err2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,27 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageUpgradeHelpDoesNotMakeFullSkillRefreshForceOnly(t *testing.T) {
|
||||
cmd := newUpgradeCommand()
|
||||
force := cmd.Flags().Lookup("force")
|
||||
if force == nil {
|
||||
t.Fatal("upgrade --force flag is missing")
|
||||
}
|
||||
for _, text := range []string{cmd.Example, force.Usage} {
|
||||
if strings.Contains(text, "恢复全部官方 Skill") {
|
||||
t.Fatalf("upgrade help still implies that only --force performs the full Skill refresh: %q", text)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(force.Usage, "已是最新版本") {
|
||||
t.Fatalf("upgrade --force help must explain its remaining purpose, got %q", force.Usage)
|
||||
}
|
||||
for _, want := range []string{"每次升级", "全量覆盖预制 Skill", "--force 仅额外允许重装当前版本"} {
|
||||
if !strings.Contains(cmd.Long, want) {
|
||||
t.Fatalf("upgrade help missing full-refresh contract %q: %s", want, cmd.Long)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- ensureV ---
|
||||
|
||||
func TestEnsureV(t *testing.T) {
|
||||
|
||||
@@ -1356,6 +1356,8 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
{"chat message edit", "conversation-id", "openConversationId", true, ""},
|
||||
{"chat message edit", "msg-id", "openMessageId", true, ""},
|
||||
{"chat message edit", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
|
||||
{"chat message send-card", "at-all", "atAll", false, ""},
|
||||
{"chat message send-card", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
|
||||
{"chat message update-text-emotion", "msg-id", "openMsgId", true, ""},
|
||||
{"chat message update-text-emotion", "old-emotion-id", "oldEmotionId", true, ""},
|
||||
{"chat category batch-info", "category-ids", "categoryIds", true, "array"},
|
||||
|
||||
@@ -5721,27 +5721,41 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
|
||||
Use: "send-card",
|
||||
Short: "创建并推送流式卡片",
|
||||
Long: `向群聊或单聊创建并推送流式卡片。群聊传 --group,单聊传 --receiver,二者互斥。
|
||||
群聊创建卡片时可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人。
|
||||
创建时无需传入卡片内容,后续通过 update-card 更新内容。
|
||||
|
||||
注意:send-card 必须和 update-card 搭配使用。发送卡片后,使用返回的 bizId 调用 update-card 更新内容,
|
||||
最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。
|
||||
flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。`,
|
||||
Example: ` dws chat message send-card --group <openConversationId>
|
||||
dws chat message send-card --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId>
|
||||
dws chat message send-card --group <openConversationId> --at-all
|
||||
dws chat message send-card --receiver <openDingTalkId>
|
||||
# 查询群 ID: dws chat search --query "群名"
|
||||
# 查询人员: dws contact user search --keyword "姓名" --format json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat")
|
||||
receiver, _ := cmd.Flags().GetString("receiver")
|
||||
atOpenDingTalkIDs := uniqueNonEmptyStrings(parseCSVValues(mustGetFlag(cmd, "at-open-dingtalk-ids")))
|
||||
atAll, _ := cmd.Flags().GetBool("at-all")
|
||||
if groupID == "" && receiver == "" {
|
||||
return fmt.Errorf("--group or --receiver is required")
|
||||
}
|
||||
if groupID != "" && receiver != "" {
|
||||
return fmt.Errorf("--group and --receiver are mutually exclusive")
|
||||
}
|
||||
if groupID == "" && (len(atOpenDingTalkIDs) > 0 || atAll) {
|
||||
return fmt.Errorf("--at-open-dingtalk-ids and --at-all are only supported with --group")
|
||||
}
|
||||
toolArgs := map[string]any{}
|
||||
if groupID != "" {
|
||||
toolArgs["openConversationId"] = groupID
|
||||
if len(atOpenDingTalkIDs) > 0 {
|
||||
toolArgs["atOpenDingTalkIds"] = atOpenDingTalkIDs
|
||||
}
|
||||
if atAll {
|
||||
toolArgs["atAll"] = true
|
||||
}
|
||||
}
|
||||
if receiver != "" {
|
||||
resolved, err := resolveOpenDingTalkID(cmd.Context(), receiver)
|
||||
@@ -5766,19 +5780,21 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
CLIPath: "chat message send-card",
|
||||
PrimaryCLIPath: "chat message send-card",
|
||||
},
|
||||
Description: "创建并向群聊或单聊发送互动卡片",
|
||||
Description: "创建并向群聊或单聊发送互动卡片;群聊创建时可 @成员或 @所有人",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "create_and_send_card"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "创建并向群聊或单聊发送互动卡片",
|
||||
UseWhen: []string{"需要卡片式交互且已准备接收会话或用户时"},
|
||||
AgentSummary: "创建并向群聊或单聊发送互动卡片;群聊创建时可 @成员或 @所有人",
|
||||
UseWhen: []string{"需要创建卡片且已准备接收会话或用户时;群聊创建可同时指定 @成员或 @所有人"},
|
||||
AvoidWhen: []string{"只发送普通文本时使用 send 或 send-by-bot"},
|
||||
Examples: []string{"dws chat message send-card --group <openConversationId>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"},
|
||||
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array"},
|
||||
{Name: "group", Property: "openConversationId"},
|
||||
{Name: "receiver", Property: "receiverOpenDingTalkId"},
|
||||
},
|
||||
@@ -5786,6 +5802,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
})
|
||||
chatMessageSendCardCmd.Flags().String("group", "", "群聊 openConversationId(群聊时必填,与 --receiver 互斥)")
|
||||
chatMessageSendCardCmd.Flags().String("receiver", "", "单聊接收者 openDingTalkId(单聊时必填,与 --group 互斥)")
|
||||
chatMessageSendCardCmd.Flags().String("at-open-dingtalk-ids", "", "群聊创建卡片时 @ 的 openDingTalkId 列表,逗号分隔(仅与 --group 一起使用)")
|
||||
chatMessageSendCardCmd.Flags().Bool("at-all", false, "群聊创建卡片时 @ 所有人(仅与 --group 一起使用)")
|
||||
|
||||
chatMessageUpdateCardCmd := &cobra.Command{
|
||||
Use: "update-card",
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("generic success is unverified", func(t *testing.T) {
|
||||
t.Run("success acknowledgement is verified", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true,"errorCode":null}`}}}
|
||||
err := runNativeCardUpdate(t, caller,
|
||||
"message", "update-card",
|
||||
@@ -85,9 +85,11 @@ func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T)
|
||||
"--content", "完成",
|
||||
"--flow-status", "3",
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" {
|
||||
t.Fatalf("error = %#v, want streaming_card_update_unverified", err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 || caller.tool != "update_streaming_card" {
|
||||
t.Fatalf("call = count:%d tool:%q", caller.calls, caller.tool)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -272,6 +273,52 @@ func TestCrossPlatformCoverageChatCreateAndMessageSendEdges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
|
||||
|
||||
t.Run("group forwards mention arguments", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runChatCoverageCommand(t, caller,
|
||||
"message", "send-card",
|
||||
"--group=cid",
|
||||
"--at-open-dingtalk-ids=D1,D2,D1",
|
||||
"--at-all",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"atOpenDingTalkIds": []string{"D1", "D2"},
|
||||
"atAll": true,
|
||||
}
|
||||
if caller.calls != 1 || caller.server != "im" || caller.tool != "create_and_send_card" || !reflect.DeepEqual(caller.args, want) {
|
||||
t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, want)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "member mention rejects direct message", args: []string{"--receiver=D1", "--at-open-dingtalk-ids=D2"}},
|
||||
{name: "at all rejects direct message", args: []string{"--receiver=D1", "--at-all"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...)
|
||||
if err == nil || !strings.Contains(err.Error(), "only supported with --group") {
|
||||
t.Fatalf("error = %v, want group-only mention validation", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("invalid direct-message mentions made %d tool calls", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
|
||||
@@ -15,8 +15,8 @@ const defaultTextInputLimit = int64(8 << 20)
|
||||
|
||||
var (
|
||||
readTextInputAll = io.ReadAll
|
||||
readTextInputStat = os.Stat
|
||||
readTextInputFile = os.ReadFile
|
||||
statTextInputPath = os.Stat
|
||||
openTextInputFile = os.Open
|
||||
readTextInputRel = filepath.Rel
|
||||
)
|
||||
|
||||
@@ -41,6 +41,9 @@ func ReadTextInput(spec string, stdin io.Reader, maxBytes int64) (string, error)
|
||||
return string(data), nil
|
||||
}
|
||||
if !strings.HasPrefix(spec, "@") {
|
||||
if int64(len(spec)) > maxBytes {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文本输入超过 %d 字节", maxBytes)
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
path := strings.TrimSpace(strings.TrimPrefix(spec, "@"))
|
||||
@@ -70,7 +73,22 @@ func ReadTextInput(spec string, stdin io.Reader, maxBytes int64) (string, error)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_UNSAFE: @file 解析后逃逸工作目录")
|
||||
}
|
||||
info, err := readTextInputStat(realPath)
|
||||
pathInfo, err := statTextInputPath(realPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
|
||||
}
|
||||
if !pathInfo.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_INVALID: @file 必须是普通文件")
|
||||
}
|
||||
if pathInfo.Size() > maxBytes {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文件大小 %d 超过 %d 字节", pathInfo.Size(), maxBytes)
|
||||
}
|
||||
file, err := openTextInputFile(realPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
|
||||
}
|
||||
@@ -80,9 +98,12 @@ func ReadTextInput(spec string, stdin io.Reader, maxBytes int64) (string, error)
|
||||
if info.Size() > maxBytes {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文件大小 %d 超过 %d 字节", info.Size(), maxBytes)
|
||||
}
|
||||
data, err := readTextInputFile(realPath)
|
||||
data, err := readTextInputAll(io.LimitReader(file, maxBytes+1))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文件超过 %d 字节", maxBytes)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageReadTextInputUsesOpenedDescriptorAndBoundedReadE2E(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
|
||||
t.Run("file grows after stat", func(t *testing.T) {
|
||||
path := filepath.Join(dir, "grow.txt")
|
||||
if err := os.WriteFile(path, []byte("ok"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &readTextInputAll, func(reader io.Reader) ([]byte, error) {
|
||||
file, openErr := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
|
||||
if openErr != nil {
|
||||
return nil, openErr
|
||||
}
|
||||
if _, writeErr := file.WriteString("-too-large"); writeErr != nil {
|
||||
_ = file.Close()
|
||||
return nil, writeErr
|
||||
}
|
||||
if closeErr := file.Close(); closeErr != nil {
|
||||
return nil, closeErr
|
||||
}
|
||||
return io.ReadAll(reader)
|
||||
})
|
||||
if _, err := ReadTextInput("@grow.txt", nil, 4); err == nil {
|
||||
t.Fatal("file growth bypassed size limit")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("path becomes directory before descriptor check", func(t *testing.T) {
|
||||
path := filepath.Join(dir, "becomes-directory.txt")
|
||||
if err := os.WriteFile(path, []byte("safe"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
|
||||
if removeErr := os.Remove(candidate); removeErr != nil {
|
||||
return nil, removeErr
|
||||
}
|
||||
if mkdirErr := os.Mkdir(candidate, 0o700); mkdirErr != nil {
|
||||
return nil, mkdirErr
|
||||
}
|
||||
return os.Open(candidate)
|
||||
})
|
||||
if _, err := ReadTextInput("@becomes-directory.txt", nil, 10); err == nil {
|
||||
t.Fatal("path type replacement bypassed descriptor validation")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file grows before descriptor check", func(t *testing.T) {
|
||||
path := filepath.Join(dir, "grows-before-stat.txt")
|
||||
if err := os.WriteFile(path, []byte("ok"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
|
||||
file, openErr := os.Open(candidate)
|
||||
if openErr != nil {
|
||||
return nil, openErr
|
||||
}
|
||||
writer, writeOpenErr := os.OpenFile(candidate, os.O_APPEND|os.O_WRONLY, 0)
|
||||
if writeOpenErr != nil {
|
||||
_ = file.Close()
|
||||
return nil, writeOpenErr
|
||||
}
|
||||
if _, writeErr := writer.WriteString("-too-large"); writeErr != nil {
|
||||
_ = writer.Close()
|
||||
_ = file.Close()
|
||||
return nil, writeErr
|
||||
}
|
||||
if closeErr := writer.Close(); closeErr != nil {
|
||||
_ = file.Close()
|
||||
return nil, closeErr
|
||||
}
|
||||
return file, nil
|
||||
})
|
||||
if _, err := ReadTextInput("@grows-before-stat.txt", nil, 4); err == nil {
|
||||
t.Fatal("pre-open growth bypassed descriptor size validation")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//go:build unix
|
||||
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageReadTextInputUsesOpenedDescriptorAfterPathReplacementE2E(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
|
||||
path := filepath.Join(dir, "input.txt")
|
||||
replacement := filepath.Join(dir, "replacement.txt")
|
||||
if err := os.WriteFile(path, []byte("safe"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(replacement, []byte("replacement-exceeds-limit"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
|
||||
file, openErr := os.Open(candidate)
|
||||
if openErr != nil {
|
||||
return nil, openErr
|
||||
}
|
||||
if renameErr := os.Rename(replacement, candidate); renameErr != nil {
|
||||
_ = file.Close()
|
||||
return nil, renameErr
|
||||
}
|
||||
return file, nil
|
||||
})
|
||||
got, err := ReadTextInput("@input.txt", nil, 10)
|
||||
if err != nil || got != "safe" {
|
||||
t.Fatalf("replacement read = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadTextInputRejectsUnconnectedFIFOWithoutBlockingE2E(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
|
||||
fifo := filepath.Join(dir, "input.fifo")
|
||||
if err := syscall.Mkfifo(fifo, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, readErr := ReadTextInput("@input.fifo", nil, 1024)
|
||||
result <- readErr
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-result:
|
||||
if err == nil || !strings.Contains(err.Error(), "LOCAL_INPUT_INVALID") {
|
||||
t.Fatalf("FIFO rejection error = %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("FIFO without a writer blocked text input validation")
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,9 @@ func TestCrossPlatformCoverageReadTextInputRejectsEscapeAndOversizeE2E(t *testin
|
||||
t.Fatalf("unsafe input accepted: %q", spec)
|
||||
}
|
||||
}
|
||||
if _, err := ReadTextInput("too-large", nil, 2); err == nil {
|
||||
t.Fatal("oversize literal accepted")
|
||||
}
|
||||
if _, err := ReadTextInput("-", strings.NewReader("too-large"), 2); err == nil {
|
||||
t.Fatal("oversize stdin accepted")
|
||||
}
|
||||
@@ -103,11 +106,23 @@ func TestCrossPlatformCoverageReadTextInputFailureBranchesE2E(t *testing.T) {
|
||||
"relative": func(t *testing.T) {
|
||||
testseam.Swap(t, &readTextInputRel, func(string, string) (string, error) { return "", errors.New("rel") })
|
||||
},
|
||||
"stat": func(t *testing.T) {
|
||||
testseam.Swap(t, &readTextInputStat, func(string) (os.FileInfo, error) { return nil, errors.New("stat") })
|
||||
"path stat": func(t *testing.T) {
|
||||
testseam.Swap(t, &statTextInputPath, func(string) (os.FileInfo, error) { return nil, errors.New("stat") })
|
||||
},
|
||||
"open": func(t *testing.T) {
|
||||
testseam.Swap(t, &openTextInputFile, func(string) (*os.File, error) { return nil, errors.New("open") })
|
||||
},
|
||||
"opened file stat": func(t *testing.T) {
|
||||
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
|
||||
file, openErr := os.Open(candidate)
|
||||
if openErr == nil {
|
||||
_ = file.Close()
|
||||
}
|
||||
return file, openErr
|
||||
})
|
||||
},
|
||||
"read file": func(t *testing.T) {
|
||||
testseam.Swap(t, &readTextInputFile, func(string) ([]byte, error) { return nil, errors.New("read file") })
|
||||
testseam.Swap(t, &readTextInputAll, func(io.Reader) ([]byte, error) { return nil, errors.New("read file") })
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
|
||||
@@ -1049,14 +1049,6 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
},
|
||||
wantError: "biz-preserved",
|
||||
},
|
||||
{
|
||||
name: "unverified update preserves id",
|
||||
fake: &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/create_and_send_card": `{"bizId":"biz-unverified"}`,
|
||||
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
|
||||
}},
|
||||
wantError: "biz-unverified",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
helpers.InitDeps(tc.fake)
|
||||
@@ -1074,6 +1066,27 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("success acknowledgement completes composite update", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/create_and_send_card": `{"bizId":"biz-acknowledged"}`,
|
||||
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send-card",
|
||||
"--group", "cid",
|
||||
"--content", "完成",
|
||||
"--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[1].tool != "update_streaming_card" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
})
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"--group", "cid", "--content", "x", "--flow-status", "6"},
|
||||
{"--group", "cid", "--flow-status", "2"},
|
||||
@@ -1091,7 +1104,7 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesUpdateCardRejectsFalseSuccess(t *testing.T) {
|
||||
func TestCrossPlatformCoverageMessagesUpdateCardVerifiesSuccess(t *testing.T) {
|
||||
t.Run("agent shortcut owns confirmation boundary", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/update_streaming_card": `{"result":{"bizId":"biz-confirm","updated":true}}`,
|
||||
@@ -1115,7 +1128,7 @@ func TestCrossPlatformCoverageMessagesUpdateCardRejectsFalseSuccess(t *testing.T
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("generic success is unverified", func(t *testing.T) {
|
||||
t.Run("success acknowledgement is verified", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
|
||||
}}
|
||||
@@ -1128,10 +1141,8 @@ func TestCrossPlatformCoverageMessagesUpdateCardRejectsFalseSuccess(t *testing.T
|
||||
"--flow-status", "3",
|
||||
"--yes",
|
||||
})
|
||||
err := root.Execute()
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" {
|
||||
t.Fatalf("error = %#v, want streaming_card_update_unverified", err)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "update_streaming_card" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
|
||||
@@ -59,9 +59,8 @@ func isCardBizIDPlaceholder(value string) bool {
|
||||
}
|
||||
|
||||
// VerifyStreamingCardUpdate requires affirmative evidence that the requested
|
||||
// write took effect. A transport-level {success:true,errorCode:null} only says
|
||||
// that the RPC returned normally and is deliberately not accepted as proof of
|
||||
// a card update.
|
||||
// write took effect. update_streaming_card may acknowledge an applied write
|
||||
// with success=true without returning an updated flag or affected count.
|
||||
func VerifyStreamingCardUpdate(requestedBizID string, response map[string]any) (string, error) {
|
||||
requestedBizID = strings.TrimSpace(requestedBizID)
|
||||
observation := cardUpdateObservation{bizIDs: map[string]struct{}{}}
|
||||
@@ -132,6 +131,23 @@ func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservati
|
||||
}
|
||||
}
|
||||
}
|
||||
errorCode, hasErrorCode := value["errorCode"]
|
||||
errorCodeEmpty := hasErrorCode && cardUpdateErrorCodeEmpty(errorCode)
|
||||
if hasErrorCode && !errorCodeEmpty {
|
||||
setNegativeCardUpdateEvidence(observation, "errorCode=non-empty")
|
||||
}
|
||||
if success, ok := value["success"].(bool); ok {
|
||||
if success {
|
||||
// Record success=true only when the same response envelope explicitly
|
||||
// includes its business-error field. A non-empty code is already
|
||||
// negative evidence above, so the two signals reject the conflict.
|
||||
if hasErrorCode {
|
||||
setPositiveCardUpdateEvidence(observation, "success=true")
|
||||
}
|
||||
} else {
|
||||
setNegativeCardUpdateEvidence(observation, "success=false")
|
||||
}
|
||||
}
|
||||
|
||||
// Only documented response envelopes are traversed. This prevents an
|
||||
// unrelated extension field containing "updated":true from proving the
|
||||
@@ -143,6 +159,14 @@ func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservati
|
||||
}
|
||||
}
|
||||
|
||||
func cardUpdateErrorCodeEmpty(value any) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
code, ok := value.(string)
|
||||
return ok && strings.TrimSpace(code) == ""
|
||||
}
|
||||
|
||||
func setPositiveCardUpdateEvidence(observation *cardUpdateObservation, evidence string) {
|
||||
if observation.positiveEvidence == "" {
|
||||
observation.positiveEvidence = evidence
|
||||
|
||||
@@ -62,7 +62,13 @@ func TestCrossPlatformCoverageVerifyStreamingCardUpdate(t *testing.T) {
|
||||
{name: "matching id", response: map[string]any{"result": map[string]any{"bizId": "biz-1", "applied": true}}, wantProof: "applied=true"},
|
||||
{name: "conflicting evidence", response: map[string]any{"updated": true, "applied": false}, wantErrIs: ErrCardUpdateUnverified},
|
||||
{name: "zero affected", response: map[string]any{"affectedCount": 0}, wantErrIs: ErrCardUpdateNotApplied},
|
||||
{name: "false success has no write proof", response: map[string]any{"success": true, "errorCode": nil}, wantErrIs: ErrCardUpdateUnverified},
|
||||
{name: "success acknowledgement", response: map[string]any{"success": true, "errorCode": nil}, wantProof: "success=true"},
|
||||
{name: "success acknowledgement with empty error code", response: map[string]any{"success": true, "errorCode": " "}, wantProof: "success=true"},
|
||||
{name: "success without explicit error code", response: map[string]any{"success": true}, wantErrIs: ErrCardUpdateUnverified},
|
||||
{name: "success conflicts with error code", response: map[string]any{"success": true, "errorCode": "InternalError"}, wantErrIs: ErrCardUpdateUnverified},
|
||||
{name: "success conflicts with numeric error code", response: map[string]any{"success": true, "errorCode": float64(500)}, wantErrIs: ErrCardUpdateUnverified},
|
||||
{name: "error code without success", response: map[string]any{"errorCode": "InternalError"}, wantErrIs: ErrCardUpdateNotApplied},
|
||||
{name: "failed acknowledgement", response: map[string]any{"success": false}, wantErrIs: ErrCardUpdateNotApplied},
|
||||
{name: "explicitly not updated", response: map[string]any{"result": map[string]any{"updated": false}}, wantErrIs: ErrCardUpdateNotApplied},
|
||||
{name: "mismatched id", response: map[string]any{"result": map[string]any{"bizId": "biz-2", "updated": true}}, wantErrIs: ErrCardUpdateBizIDDrift},
|
||||
{name: "unrelated extension ignored", response: map[string]any{"extension": map[string]any{"updated": true}}, wantErrIs: ErrCardUpdateUnverified},
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package skillprovenance builds the per-Skill provenance records stored in
|
||||
// the single ~/.dws/skills-state.json metadata file.
|
||||
package skillprovenance
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
DigestScope = "skill-directory-v1"
|
||||
|
||||
SourceSkillSetup = "dws-skill-setup"
|
||||
SourceUpgrade = "dws-upgrade"
|
||||
)
|
||||
|
||||
// Record identifies one DWS-managed Skill. Ownership is derived from these
|
||||
// records, never from files placed inside an Agent's Skill directory.
|
||||
type Record struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Source string `json:"source"`
|
||||
Digest string `json:"digest"`
|
||||
DigestScope string `json:"digest_scope"`
|
||||
}
|
||||
|
||||
var (
|
||||
provenanceWalkDir = filepath.WalkDir
|
||||
provenanceRelative = filepath.Rel
|
||||
provenanceReadFile = os.ReadFile
|
||||
)
|
||||
|
||||
// Build returns a deterministic provenance record for a Skill directory.
|
||||
func Build(name, dir, version, source string) (Record, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return Record{}, fmt.Errorf("provenance Skill name is empty")
|
||||
}
|
||||
digest, err := DigestDir(dir)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
version = strings.TrimSpace(version)
|
||||
if version == "" {
|
||||
version = "unknown"
|
||||
}
|
||||
source = strings.TrimSpace(source)
|
||||
if source == "" {
|
||||
return Record{}, fmt.Errorf("provenance source is empty")
|
||||
}
|
||||
return Record{
|
||||
Name: name,
|
||||
Version: version,
|
||||
Source: source,
|
||||
Digest: digest,
|
||||
DigestScope: DigestScope,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DigestDir hashes every regular file ordered by slash-normalized relative
|
||||
// path. Paths and contents are NUL-delimited.
|
||||
func DigestDir(root string) (string, error) {
|
||||
var paths []string
|
||||
err := provenanceWalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := provenanceRelative(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if !entry.Type().IsRegular() {
|
||||
return fmt.Errorf("unsupported non-regular Skill file: %s", path)
|
||||
}
|
||||
paths = append(paths, rel)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sort.Strings(paths)
|
||||
|
||||
hash := sha256.New()
|
||||
for _, rel := range paths {
|
||||
_, _ = hash.Write([]byte(rel))
|
||||
_, _ = hash.Write([]byte{0})
|
||||
content, err := provenanceReadFile(filepath.Join(root, filepath.FromSlash(rel)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, _ = hash.Write(content)
|
||||
_, _ = hash.Write([]byte{0})
|
||||
}
|
||||
return "sha256:" + hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// Merge replaces records with matching names and returns a name-sorted set.
|
||||
func Merge(existing, updates []Record) []Record {
|
||||
byName := make(map[string]Record, len(existing)+len(updates))
|
||||
for _, record := range existing {
|
||||
if strings.TrimSpace(record.Name) != "" {
|
||||
byName[record.Name] = record
|
||||
}
|
||||
}
|
||||
for _, record := range updates {
|
||||
if strings.TrimSpace(record.Name) != "" {
|
||||
byName[record.Name] = record
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(byName))
|
||||
for name := range byName {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
out := make([]Record, 0, len(names))
|
||||
for _, name := range names {
|
||||
out = append(out, byName[name])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func Names(records []Record) map[string]bool {
|
||||
names := make(map[string]bool, len(records))
|
||||
for _, record := range records {
|
||||
if record.Name != "" {
|
||||
names[record.Name] = true
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package skillprovenance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeDirEntry struct {
|
||||
name string
|
||||
mode fs.FileMode
|
||||
}
|
||||
|
||||
func (entry fakeDirEntry) Name() string { return entry.name }
|
||||
func (entry fakeDirEntry) IsDir() bool { return entry.mode.IsDir() }
|
||||
func (entry fakeDirEntry) Type() fs.FileMode { return entry.mode.Type() }
|
||||
func (entry fakeDirEntry) Info() (fs.FileInfo, error) { return nil, errors.New("unused") }
|
||||
|
||||
func swapProvenanceSeam[T any](t *testing.T, target *T, replacement T) {
|
||||
t.Helper()
|
||||
original := *target
|
||||
*target = replacement
|
||||
t.Cleanup(func() { *target = original })
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillProvenanceBuildDigestAndMerge(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "references"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("skill"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "references", "a.md"), []byte("ref"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record, err := Build("dingtalk-a", dir, "", SourceUpgrade)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.Version != "unknown" || record.Source != SourceUpgrade || record.DigestScope != DigestScope {
|
||||
t.Fatalf("record = %#v", record)
|
||||
}
|
||||
first := record.Digest
|
||||
if err := os.WriteFile(filepath.Join(dir, "references", "a.md"), []byte("changed"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := Build("dingtalk-a", dir, "v2", SourceSkillSetup)
|
||||
if err != nil || second.Digest == first {
|
||||
t.Fatalf("second = %#v, %v", second, err)
|
||||
}
|
||||
merged := Merge([]Record{record, {Name: "dingtalk-b"}}, []Record{second, {}})
|
||||
if !reflect.DeepEqual(merged, []Record{second, {Name: "dingtalk-b"}}) {
|
||||
t.Fatalf("merged = %#v", merged)
|
||||
}
|
||||
if got := Names(merged); !got["dingtalk-a"] || !got["dingtalk-b"] || len(got) != 2 {
|
||||
t.Fatalf("names = %#v", got)
|
||||
}
|
||||
if _, err := Build("", dir, "v1", "test"); err == nil {
|
||||
t.Fatal("empty name accepted")
|
||||
}
|
||||
if _, err := Build("dingtalk-a", dir, "v1", " "); err == nil {
|
||||
t.Fatal("empty source accepted")
|
||||
}
|
||||
if _, err := Build("dingtalk-a", filepath.Join(dir, "missing"), "v1", "test"); err == nil {
|
||||
t.Fatal("missing Skill directory accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillProvenanceDigestErrors(t *testing.T) {
|
||||
want := errors.New("injected")
|
||||
t.Run("walk", func(t *testing.T) {
|
||||
swapProvenanceSeam(t, &provenanceWalkDir, func(string, fs.WalkDirFunc) error { return want })
|
||||
if _, err := DigestDir(t.TempDir()); !errors.Is(err, want) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
t.Run("callback", func(t *testing.T) {
|
||||
swapProvenanceSeam(t, &provenanceWalkDir, func(root string, walk fs.WalkDirFunc) error { return walk(root, nil, want) })
|
||||
if _, err := DigestDir(t.TempDir()); !errors.Is(err, want) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
t.Run("relative", func(t *testing.T) {
|
||||
swapProvenanceSeam(t, &provenanceWalkDir, func(root string, walk fs.WalkDirFunc) error {
|
||||
return walk(filepath.Join(root, "file"), fakeDirEntry{name: "file"}, nil)
|
||||
})
|
||||
swapProvenanceSeam(t, &provenanceRelative, func(string, string) (string, error) { return "", want })
|
||||
if _, err := DigestDir(t.TempDir()); !errors.Is(err, want) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
t.Run("nonregular", func(t *testing.T) {
|
||||
swapProvenanceSeam(t, &provenanceWalkDir, func(root string, walk fs.WalkDirFunc) error {
|
||||
return walk(filepath.Join(root, "link"), fakeDirEntry{name: "link", mode: fs.ModeSymlink}, nil)
|
||||
})
|
||||
if _, err := DigestDir(t.TempDir()); err == nil {
|
||||
t.Fatal("nonregular accepted")
|
||||
}
|
||||
})
|
||||
t.Run("read", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("x"), 0o644)
|
||||
swapProvenanceSeam(t, &provenanceReadFile, func(string) ([]byte, error) { return nil, want })
|
||||
if _, err := DigestDir(dir); !errors.Is(err, want) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package skillstate persists the official Skill snapshot and centralized
|
||||
// ownership metadata written after setup and upgrade. Bundled skills are
|
||||
// always fully refreshed from the current release and local absence is not an
|
||||
// exclusion.
|
||||
package skillstate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
|
||||
)
|
||||
|
||||
const stateFile = "skills-state.json"
|
||||
|
||||
// legacyOfficialSkillNames is the frozen set of official multi-skill names
|
||||
// shipped before centralized ownership metadata. Exact names are safe
|
||||
// migration evidence; a dingtalk-* prefix is not.
|
||||
//
|
||||
// Keep retired names here permanently so an old installation can still be
|
||||
// migrated after that Skill has been folded into another bundle.
|
||||
var legacyOfficialSkillNames = map[string]struct{}{
|
||||
"dingtalk-agoal": {},
|
||||
"dingtalk-aiapp": {},
|
||||
"dingtalk-aisearch": {},
|
||||
"dingtalk-aitable": {},
|
||||
"dingtalk-attendance": {},
|
||||
"dingtalk-calendar": {},
|
||||
"dingtalk-chat": {},
|
||||
"dingtalk-contact": {},
|
||||
"dingtalk-dev": {},
|
||||
"dingtalk-devapp": {},
|
||||
"dingtalk-devdoc": {},
|
||||
"dingtalk-ding": {},
|
||||
"dingtalk-doc": {},
|
||||
"dingtalk-drive": {},
|
||||
"dingtalk-event": {},
|
||||
"dingtalk-hrbrain": {},
|
||||
"dingtalk-live": {},
|
||||
"dingtalk-mail": {},
|
||||
"dingtalk-markdown": {},
|
||||
"dingtalk-minutes": {},
|
||||
"dingtalk-misc": {},
|
||||
"dingtalk-oa": {},
|
||||
"dingtalk-pat": {},
|
||||
"dingtalk-profile": {},
|
||||
"dingtalk-report": {},
|
||||
"dingtalk-shared": {},
|
||||
"dingtalk-sheet": {},
|
||||
"dingtalk-skill": {},
|
||||
"dingtalk-todo": {},
|
||||
"dingtalk-wiki": {},
|
||||
"dws-shared": {},
|
||||
}
|
||||
|
||||
var (
|
||||
skillStateReadFile = os.ReadFile
|
||||
skillStateRemove = os.Remove
|
||||
)
|
||||
|
||||
type State struct {
|
||||
Version string `json:"version"`
|
||||
OfficialSkills []string `json:"official_skills"`
|
||||
UpdatedSkills []string `json:"updated_skills"`
|
||||
ManagedSkills []skillprovenance.Record `json:"managed_skills"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// IsLegacyOfficialSkillName reports whether name was an exact official
|
||||
// multi-skill directory name before centralized ownership metadata shipped.
|
||||
func IsLegacyOfficialSkillName(name string) bool {
|
||||
_, ok := legacyOfficialSkillNames[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func Path(home string) string {
|
||||
if configured := strings.TrimSpace(os.Getenv("DWS_CONFIG_DIR")); configured != "" {
|
||||
return filepath.Join(configured, stateFile)
|
||||
}
|
||||
return filepath.Join(home, ".dws", stateFile)
|
||||
}
|
||||
|
||||
func Read(home string) (*State, bool, error) {
|
||||
data, err := skillStateReadFile(Path(home))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
var state State
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, false, fmt.Errorf("skill 状态不可读: %w", err)
|
||||
}
|
||||
return &state, true, nil
|
||||
}
|
||||
|
||||
func Write(home string, state State) error {
|
||||
state.OfficialSkills = uniqueSorted(state.OfficialSkills)
|
||||
state.UpdatedSkills = uniqueSorted(state.UpdatedSkills)
|
||||
state.ManagedSkills = skillprovenance.Merge(nil, state.ManagedSkills)
|
||||
data, _ := json.MarshalIndent(state, "", " ")
|
||||
if err := helpers.AtomicWriteJSON(Path(home), append(data, '\n')); err != nil {
|
||||
return fmt.Errorf("保存 skill 状态失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ManagedSkillNames(state *State) map[string]bool {
|
||||
if state == nil {
|
||||
return map[string]bool{}
|
||||
}
|
||||
return skillprovenance.Names(state.ManagedSkills)
|
||||
}
|
||||
|
||||
func Remove(home string) error {
|
||||
if err := skillStateRemove(Path(home)); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("清理 skill 状态失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func uniqueSorted(values []string) []string {
|
||||
seen := make(map[string]bool, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package skillstate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSkillStateReadWriteRemoveAndErrors(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "")
|
||||
home := t.TempDir()
|
||||
if state, readable, err := Read(home); err != nil || readable || state != nil {
|
||||
t.Fatalf("missing = %#v, %v, %v", state, readable, err)
|
||||
}
|
||||
want := State{
|
||||
Version: "1.2.3",
|
||||
OfficialSkills: []string{"dingtalk-b", "dingtalk-a", "dingtalk-a"},
|
||||
UpdatedSkills: []string{"dingtalk-a"},
|
||||
ManagedSkills: []skillprovenance.Record{
|
||||
{Name: "dingtalk-b", Version: "old"},
|
||||
{Name: "dingtalk-a", Version: "1"},
|
||||
{Name: "dingtalk-b", Version: "2"},
|
||||
},
|
||||
}
|
||||
if err := Write(home, want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, readable, err := Read(home)
|
||||
if err != nil || !readable || !reflect.DeepEqual(got.OfficialSkills, []string{"dingtalk-a", "dingtalk-b"}) {
|
||||
t.Fatalf("round trip = %#v, %v, %v", got, readable, err)
|
||||
}
|
||||
if !reflect.DeepEqual(got.ManagedSkills, []skillprovenance.Record{{Name: "dingtalk-a", Version: "1"}, {Name: "dingtalk-b", Version: "2"}}) {
|
||||
t.Fatalf("managed skills = %#v", got.ManagedSkills)
|
||||
}
|
||||
if names := ManagedSkillNames(got); !reflect.DeepEqual(names, map[string]bool{"dingtalk-a": true, "dingtalk-b": true}) {
|
||||
t.Fatalf("managed names = %#v", names)
|
||||
}
|
||||
if names := ManagedSkillNames(nil); len(names) != 0 {
|
||||
t.Fatalf("nil managed names = %#v", names)
|
||||
}
|
||||
if err := Remove(home); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Remove(home); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
badHome := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Dir(Path(badHome)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(Path(badHome), []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := Read(badHome); err == nil || !strings.Contains(err.Error(), "不可读") {
|
||||
t.Fatalf("malformed = %v", err)
|
||||
}
|
||||
blocked := filepath.Join(t.TempDir(), "home-file")
|
||||
if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Write(blocked, State{}); err == nil {
|
||||
t.Fatal("blocked write succeeded")
|
||||
}
|
||||
failure := errors.New("denied")
|
||||
testseam.Swap(t, &skillStateReadFile, func(string) ([]byte, error) { return nil, failure })
|
||||
if _, _, err := Read(blocked); !errors.Is(err, failure) {
|
||||
t.Fatal("blocked read succeeded")
|
||||
}
|
||||
testseam.Swap(t, &skillStateRemove, func(string) error { return failure })
|
||||
if err := Remove(blocked); !errors.Is(err, failure) {
|
||||
t.Fatal("blocked remove succeeded")
|
||||
}
|
||||
configured := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", " "+configured+" ")
|
||||
if Path("ignored") != filepath.Join(configured, stateFile) {
|
||||
t.Fatal("configured path ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageIsLegacyOfficialSkillName(t *testing.T) {
|
||||
for _, name := range []string{"dingtalk-aitable", "dingtalk-devdoc", "dws-shared"} {
|
||||
if !IsLegacyOfficialSkillName(name) {
|
||||
t.Fatalf("historical official Skill %q not recognized", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"dingtalk-custom", "other-skill", ""} {
|
||||
if IsLegacyOfficialSkillName(name) {
|
||||
t.Fatalf("user Skill %q must not be treated as historical official", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// SwapUserHomeDirForTest swaps the home-dir seam used by UpgradeSkillLocations
|
||||
// and related upgrade path helpers. Restored via t.Cleanup; not safe with
|
||||
// t.Parallel.
|
||||
func SwapUserHomeDirForTest(t *testing.T, fn func() (string, error)) {
|
||||
t.Helper()
|
||||
testseam.Swap(t, &upgradeUserHomeDir, fn)
|
||||
}
|
||||
+671
-15
@@ -4,10 +4,17 @@
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
|
||||
)
|
||||
|
||||
// Permission constants following Unix best practices.
|
||||
@@ -50,13 +57,83 @@ var knownSkillDirs = []string{
|
||||
}
|
||||
|
||||
var (
|
||||
upgradeUserHomeDir = os.UserHomeDir
|
||||
upgradeExecutable = os.Executable
|
||||
upgradeEvalSymlinks = filepath.EvalSymlinks
|
||||
upgradeCopyDir = copyDir
|
||||
upgradeEnsureDir = ensureDir
|
||||
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
|
||||
)
|
||||
|
||||
// skillBackupSubdir is the user-level directory where skill directories are
|
||||
// preserved before a layout-changing install/upgrade removes them. Non-
|
||||
// interactive flows (install scripts, npm postinstall, `dws upgrade`) cannot
|
||||
// ask for confirmation, so deletions must stay reversible instead.
|
||||
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
|
||||
// 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).
|
||||
func backupAndRemoveSkillDir(homeDir, dir string) (string, error) {
|
||||
info, err := upgradeStat(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("检查技能目录失败 %s: %w", dir, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", nil
|
||||
}
|
||||
rel, relErr := filepath.Rel(homeDir, dir)
|
||||
if relErr != nil || rel == "." || strings.HasPrefix(rel, "..") {
|
||||
rel = filepath.Base(dir)
|
||||
}
|
||||
name := strings.NewReplacer(string(filepath.Separator), "-", "/", "-").Replace(rel)
|
||||
stamp := upgradeBackupStamp()
|
||||
backupRoot := filepath.Join(homeDir, skillBackupSubdir, stamp)
|
||||
target := filepath.Join(backupRoot, name)
|
||||
for i := 1; ; i++ {
|
||||
if _, err := os.Stat(target); os.IsNotExist(err) {
|
||||
break
|
||||
}
|
||||
backupRoot = filepath.Join(homeDir, skillBackupSubdir, fmt.Sprintf("%s-%d", stamp, i))
|
||||
target = filepath.Join(backupRoot, name)
|
||||
if i > 1000 {
|
||||
return "", fmt.Errorf("备份目录冲突无法解决: %s", target)
|
||||
}
|
||||
}
|
||||
if err := upgradeMkdirAll(backupRoot, dirPermShared); err != nil {
|
||||
return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err)
|
||||
}
|
||||
if err := upgradeRename(dir, target); err != nil {
|
||||
return "", fmt.Errorf("备份技能目录失败 %s: %w", dir, err)
|
||||
}
|
||||
// Keep the backup directory bounded; a prune failure must not fail the
|
||||
// install (the backup itself succeeded).
|
||||
_ = pruneSkillBackups(homeDir)
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// BackupAndRemoveSkillDir is the exported wrapper over
|
||||
// backupAndRemoveSkillDir for callers outside the upgrade package (the
|
||||
// skill-setup channel in internal/app).
|
||||
func BackupAndRemoveSkillDir(homeDir, dir string) (string, error) {
|
||||
return backupAndRemoveSkillDir(homeDir, dir)
|
||||
}
|
||||
|
||||
// skillDirBlacklist contains parent directories whose skills are managed by
|
||||
// external mechanisms (e.g. IDE extensions) and must NOT be touched by upgrade.
|
||||
var skillDirBlacklist = []string{
|
||||
@@ -107,22 +184,341 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult {
|
||||
return out
|
||||
}
|
||||
|
||||
// UpgradeSkillLocations installs skills from extractedDir into all locations
|
||||
// where they are currently installed or expected.
|
||||
// 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
|
||||
// resolve a release zip usually pass LocateSkillsRoot's result (multi/
|
||||
// preferred when present).
|
||||
//
|
||||
// Package-driven layout:
|
||||
// - release zip has multi/ → install and overwrite the complete official
|
||||
// bundle. Locally deleted bundled skills are restored on the next upgrade;
|
||||
// local absence is never treated as a persistent exclusion.
|
||||
// - dingtalk-shared is mandatory whenever it exists in the bundle.
|
||||
// - legacy zip with no multi tree → mono refresh path (unchanged fallback)
|
||||
//
|
||||
// Fresh install defaults to multi with opt-in mono via the interactive
|
||||
// `dws skill setup --mode mono` flow.
|
||||
//
|
||||
// Strategy (matches npm install.js installSkillsToHomes):
|
||||
// - ~/.agents/skills/dws/ is ALWAYS updated (primary install location)
|
||||
// - ~/.agents/skills/ is ALWAYS updated (primary install location)
|
||||
// - Other agent dirs (claude, cursor, ...) are updated only when the parent
|
||||
// directory exists (e.g. ~/.claude/ exists => user has Claude)
|
||||
// - ~/.real/ and other blacklisted paths are NEVER touched
|
||||
// - If no location was updated at all, fall back to ~/.agents/skills/dws/
|
||||
// - If no location was updated at all, fall back to ~/.agents/skills/
|
||||
//
|
||||
// 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
|
||||
// cannot be backed up is never removed and fails that home. Same-name bundle
|
||||
// skills are refreshed in place (verified DWS-managed overwrite). Caches
|
||||
// under ~/.dws/skills/{multi,mono} are refreshed best-effort.
|
||||
func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) {
|
||||
return UpgradeSkillLocationsWithOptions(extractedDir, SkillUpgradeOptions{})
|
||||
}
|
||||
|
||||
type SkillUpgradeOptions struct {
|
||||
Version string
|
||||
}
|
||||
|
||||
func UpgradeSkillLocationsWithOptions(extractedDir string, opts SkillUpgradeOptions) (*SkillUpgradeResult, error) {
|
||||
homeDir, err := upgradeUserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
multiRoot, skills := resolveMultiBundle(extractedDir)
|
||||
if len(skills) > 0 {
|
||||
official := append([]string(nil), skills...)
|
||||
managedSkills, provenanceErr := buildUpgradeProvenanceRecords(multiRoot, official, opts.Version)
|
||||
if provenanceErr != nil {
|
||||
return nil, fmt.Errorf("生成统一 Skill provenance 失败: %w", provenanceErr)
|
||||
}
|
||||
result, installErr := upgradeMultiSkillLocations(homeDir, multiRoot, official)
|
||||
if installErr != nil {
|
||||
return result, installErr
|
||||
}
|
||||
if len(result.Failed()) == 0 && len(result.Succeeded()) > 0 {
|
||||
state := skillstate.State{
|
||||
Version: opts.Version,
|
||||
OfficialSkills: official,
|
||||
UpdatedSkills: official,
|
||||
ManagedSkills: managedSkills,
|
||||
UpdatedAt: upgradeNow().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if writeErr := upgradeWriteSkillState(homeDir, state); writeErr != nil {
|
||||
return result, fmt.Errorf("skill 已同步但状态未写入: %w", writeErr)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
monoSrc := resolveMonoSkillSrc(extractedDir)
|
||||
if monoSrc != "" {
|
||||
return upgradeMonoSkillLocations(homeDir, monoSrc)
|
||||
}
|
||||
return nil, fmt.Errorf("升级包中找不到可安装的 skill 源")
|
||||
}
|
||||
|
||||
func buildUpgradeProvenanceRecords(root string, names []string, version string) ([]skillprovenance.Record, error) {
|
||||
records := make([]skillprovenance.Record, 0, len(names))
|
||||
for _, name := range names {
|
||||
record, err := upgradeBuildProvenance(name, filepath.Join(root, name), version, skillprovenance.SourceUpgrade)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// skillBackupKeep limits ~/.dws/skill-backups/ growth: only the newest
|
||||
// backups are kept.
|
||||
const skillBackupKeep = 5
|
||||
|
||||
// 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.
|
||||
func pruneSkillBackups(homeDir string) error {
|
||||
root := filepath.Join(homeDir, skillBackupSubdir)
|
||||
entries, err := upgradeReadDir(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
var firstErr error
|
||||
for len(names) > skillBackupKeep {
|
||||
old := filepath.Join(root, names[0])
|
||||
names = names[1:]
|
||||
if err := upgradeRemoveAll(old); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// resolveMultiBundle returns the multi skill root and its skill names when
|
||||
// extractedDir is itself a bundle, or when a multi/ child of the parent
|
||||
// extract root carries one (LocateSkillsRoot already prefers that child).
|
||||
func resolveMultiBundle(extractedDir string) (string, []string) {
|
||||
if skills := bundleSkillNames(extractedDir); len(skills) > 0 {
|
||||
return extractedDir, skills
|
||||
}
|
||||
child := filepath.Join(extractedDir, "multi")
|
||||
if skills := bundleSkillNames(child); len(skills) > 0 {
|
||||
return child, skills
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// resolveMonoSkillSrc finds a mono skill tree for the legacy mono-only
|
||||
// package fallback: the path itself, a sibling mono/ next to a multi root,
|
||||
// or the extract-root SKILL.md copy that release zips still ship.
|
||||
func resolveMonoSkillSrc(extractedDir string) string {
|
||||
if skillTreeHasRoot(extractedDir) {
|
||||
return extractedDir
|
||||
}
|
||||
sibling := filepath.Join(filepath.Dir(extractedDir), "mono")
|
||||
if skillTreeHasRoot(sibling) {
|
||||
return sibling
|
||||
}
|
||||
parent := filepath.Dir(extractedDir)
|
||||
if skillTreeHasRoot(parent) {
|
||||
return parent
|
||||
}
|
||||
child := filepath.Join(extractedDir, "mono")
|
||||
if skillTreeHasRoot(child) {
|
||||
return child
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type skillStageSpec struct {
|
||||
src string
|
||||
dest string
|
||||
}
|
||||
|
||||
type stagedSkillDir struct {
|
||||
staged string
|
||||
dest string
|
||||
}
|
||||
|
||||
type backedUpSkillDir struct {
|
||||
original string
|
||||
backup string
|
||||
}
|
||||
|
||||
// stageSkillSet builds a complete replacement next to its final destinations.
|
||||
// Nothing Agent-visible is changed until every copy succeeds.
|
||||
func stageSkillSet(destBase, prefix string, specs []skillStageSpec) (stageRoot string, staged []stagedSkillDir, err error) {
|
||||
if err := upgradeMkdirAll(destBase, dirPermShared); err != nil {
|
||||
return "", nil, fmt.Errorf("创建 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 {
|
||||
return
|
||||
}
|
||||
if cleanupErr := upgradeRemoveAll(stageRoot); cleanupErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
|
||||
}
|
||||
}()
|
||||
|
||||
staged = make([]stagedSkillDir, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
stageDir := filepath.Join(stageRoot, filepath.Base(spec.dest))
|
||||
if err := upgradeCopyDir(spec.src, stageDir); err != nil {
|
||||
return stageRoot, nil, fmt.Errorf("拷贝 Skill staging 失败 %s: %w", stageDir, err)
|
||||
}
|
||||
staged = append(staged, stagedSkillDir{staged: stageDir, dest: spec.dest})
|
||||
}
|
||||
return stageRoot, staged, nil
|
||||
}
|
||||
|
||||
func uniqueSkillDirs(paths []string) []string {
|
||||
seen := make(map[string]bool, len(paths))
|
||||
unique := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
path = filepath.Clean(path)
|
||||
if seen[path] {
|
||||
continue
|
||||
}
|
||||
seen[path] = true
|
||||
unique = append(unique, path)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
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 {
|
||||
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w", backup.original, err))
|
||||
}
|
||||
}
|
||||
return restoreErr
|
||||
}
|
||||
|
||||
// backupSkillSet moves every victim aside as one logical operation. If a
|
||||
// later backup fails, earlier moves are restored before the error is returned.
|
||||
func backupSkillSet(homeDir string, victims []string) ([]backedUpSkillDir, error) {
|
||||
backups := make([]backedUpSkillDir, 0, len(victims))
|
||||
for _, victim := range uniqueSkillDirs(victims) {
|
||||
backup, err := backupAndRemoveSkillDir(homeDir, victim)
|
||||
if err != nil {
|
||||
if restoreErr := restoreSkillSet(nil, backups); restoreErr != nil {
|
||||
return nil, errors.Join(err, fmt.Errorf("恢复已备份 Skill 失败: %w", restoreErr))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if backup != "" {
|
||||
backups = append(backups, backedUpSkillDir{original: victim, backup: backup})
|
||||
}
|
||||
}
|
||||
return backups, nil
|
||||
}
|
||||
|
||||
// publishStagedSkillSet switches a fully staged set into place. Any publish
|
||||
// failure removes the partial new set and restores every original directory.
|
||||
func publishStagedSkillSet(homeDir string, staged []stagedSkillDir, victims []string) error {
|
||||
backups, err := backupSkillSet(homeDir, victims)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
published := make([]string, 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)
|
||||
if restoreErr := restoreSkillSet(published, backups); restoreErr != nil {
|
||||
return errors.Join(publishErr, fmt.Errorf("恢复原 Skill 集合失败: %w", restoreErr))
|
||||
}
|
||||
return publishErr
|
||||
}
|
||||
published = append(published, skill.dest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func monoUpgradeVictims(baseDir, destDir string, managed ...map[string]bool) ([]string, error) {
|
||||
victims, err := managedMultiSkillVictims(baseDir, managed...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(victims, destDir), nil
|
||||
}
|
||||
|
||||
func multiUpgradeVictims(destBase string, skillSet map[string]bool, skills []string, managed ...map[string]bool) ([]string, error) {
|
||||
victims, err := oppositeModeSkillVictims(destBase, skillSet, managed...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, name := range skills {
|
||||
victims = append(victims, filepath.Join(destBase, name))
|
||||
}
|
||||
return victims, nil
|
||||
}
|
||||
|
||||
func publishMonoUpgradeTarget(homeDir, destBase, skillSrc string, managed ...map[string]bool) error {
|
||||
destDir := filepath.Join(destBase, "dws")
|
||||
victims, err := monoUpgradeVictims(destBase, destDir, managed...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stageRoot, staged, err := stageSkillSet(destBase, ".dws-upgrade-mono-", []skillStageSpec{{src: skillSrc, dest: destDir}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = upgradeRemoveAll(stageRoot) }()
|
||||
return publishStagedSkillSet(homeDir, staged, victims)
|
||||
}
|
||||
|
||||
func publishMultiUpgradeTarget(homeDir, destBase, multiRoot string, skills []string, skillSet map[string]bool, managed ...map[string]bool) error {
|
||||
victims, err := multiUpgradeVictims(destBase, skillSet, skills, managed...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
specs := make([]skillStageSpec, 0, len(skills))
|
||||
for _, name := range skills {
|
||||
specs = append(specs, skillStageSpec{
|
||||
src: filepath.Join(multiRoot, name),
|
||||
dest: filepath.Join(destBase, name),
|
||||
})
|
||||
}
|
||||
stageRoot, staged, err := stageSkillSet(destBase, ".dws-upgrade-multi-", specs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = upgradeRemoveAll(stageRoot) }()
|
||||
return publishStagedSkillSet(homeDir, staged, victims)
|
||||
}
|
||||
|
||||
// upgradeMonoSkillLocations is the legacy mono behavior: one dws/ directory
|
||||
// per agent home.
|
||||
func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, error) {
|
||||
result := &SkillUpgradeResult{}
|
||||
managedNames := readManagedSkillNames(homeDir)
|
||||
|
||||
for i, agentDir := range knownSkillDirs {
|
||||
destDir := filepath.Join(homeDir, agentDir, "dws")
|
||||
@@ -140,19 +536,21 @@ func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
os.RemoveAll(destDir)
|
||||
if err := upgradeCopyDir(extractedDir, destDir); err != nil {
|
||||
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})
|
||||
}
|
||||
|
||||
// Fallback: if nothing succeeded, force the primary location
|
||||
// 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 {
|
||||
dest := filepath.Join(homeDir, ".agents", "skills", "dws")
|
||||
os.MkdirAll(filepath.Dir(dest), dirPermShared)
|
||||
if err := upgradeCopyDir(extractedDir, dest); err != nil {
|
||||
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
|
||||
@@ -169,9 +567,267 @@ func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort: refresh the user-level mono cache so that
|
||||
// `dws skill setup --mode mono` fallbacks stay on the upgraded version
|
||||
// (symmetric with the multi cache refresh in upgradeMultiSkillLocations).
|
||||
_ = refreshSkillCache(homeDir, "mono", skillSrc)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// upgradeMultiSkillLocations installs every skill of the multi bundle into
|
||||
// each agent home as sibling directories and backs up + removes the
|
||||
// opposite-mode leftovers. A home is marked failed (and multi is NOT
|
||||
// installed into it) when leftover backup/removal fails, so mono and multi
|
||||
// never co-exist.
|
||||
func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*SkillUpgradeResult, error) {
|
||||
skillSet := make(map[string]bool, len(skills))
|
||||
for _, s := range skills {
|
||||
skillSet[s] = true
|
||||
}
|
||||
|
||||
result := &SkillUpgradeResult{}
|
||||
managedNames := readManagedSkillNames(homeDir)
|
||||
|
||||
for i, agentDir := range knownSkillDirs {
|
||||
destBase := filepath.Join(homeDir, agentDir)
|
||||
|
||||
if isBlacklisted(agentDir) {
|
||||
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted})
|
||||
continue
|
||||
}
|
||||
|
||||
if i > 0 {
|
||||
parentGate := filepath.Dir(destBase)
|
||||
if _, err := os.Stat(parentGate); os.IsNotExist(err) {
|
||||
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
|
||||
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})
|
||||
}
|
||||
|
||||
// Fallback: if nothing succeeded, force the primary location
|
||||
if len(result.Succeeded()) == 0 {
|
||||
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
|
||||
// trees, so when the sibling mono/ tree is present the mono cache is
|
||||
// refreshed as well.
|
||||
_ = refreshSkillCache(homeDir, "multi", multiRoot)
|
||||
if monoSrc := filepath.Join(filepath.Dir(multiRoot), "mono"); skillTreeHasRoot(monoSrc) {
|
||||
_ = refreshSkillCache(homeDir, "mono", monoSrc)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// refreshSkillCache mirrors src into ~/.dws/skills/<name>/ through a staged
|
||||
// sibling directory. The existing cache is moved aside only after the staged
|
||||
// copy is complete, and is restored if publishing the new cache fails.
|
||||
func refreshSkillCache(homeDir, name, src string) error {
|
||||
cacheDir := filepath.Join(homeDir, ".dws", "skills", name)
|
||||
cacheParent := filepath.Dir(cacheDir)
|
||||
if err := upgradeMkdirAll(cacheParent, dirPermShared); err != nil {
|
||||
return fmt.Errorf("创建 Skill 缓存目录失败 %s: %w", cacheParent, err)
|
||||
}
|
||||
|
||||
stagedDir, err := upgradeMkdirTemp(cacheParent, "."+name+".tmp-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 Skill 缓存临时目录失败 %s: %w", cacheParent, err)
|
||||
}
|
||||
stagedPublished := false
|
||||
defer func() {
|
||||
if !stagedPublished {
|
||||
_ = upgradeRemoveAll(stagedDir)
|
||||
}
|
||||
}()
|
||||
if err := upgradeCopyDir(src, stagedDir); err != nil {
|
||||
return fmt.Errorf("暂存 Skill 缓存失败 %s: %w", stagedDir, err)
|
||||
}
|
||||
|
||||
if _, err := upgradeStat(cacheDir); os.IsNotExist(err) {
|
||||
if err := upgradeRename(stagedDir, cacheDir); err != nil {
|
||||
return fmt.Errorf("发布 Skill 缓存失败 %s: %w", cacheDir, err)
|
||||
}
|
||||
stagedPublished = true
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("检查 Skill 缓存失败 %s: %w", cacheDir, err)
|
||||
}
|
||||
|
||||
rollbackDir, err := upgradeMkdirTemp(cacheParent, "."+name+".old-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 Skill 缓存回滚目录失败 %s: %w", cacheParent, err)
|
||||
}
|
||||
if err := upgradeRemoveAll(rollbackDir); err != nil {
|
||||
return fmt.Errorf("准备 Skill 缓存回滚目录失败 %s: %w", rollbackDir, err)
|
||||
}
|
||||
if err := upgradeRename(cacheDir, rollbackDir); err != nil {
|
||||
return fmt.Errorf("暂存原 Skill 缓存失败 %s: %w", cacheDir, err)
|
||||
}
|
||||
if err := upgradeRename(stagedDir, cacheDir); err != nil {
|
||||
if restoreErr := upgradeRename(rollbackDir, cacheDir); restoreErr != nil {
|
||||
return fmt.Errorf("发布 Skill 缓存失败 %s: %w(恢复原缓存也失败: %v)", cacheDir, err, restoreErr)
|
||||
}
|
||||
return fmt.Errorf("发布 Skill 缓存失败 %s: %w", cacheDir, err)
|
||||
}
|
||||
stagedPublished = true
|
||||
_ = upgradeRemoveAll(rollbackDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// skillTreeHasRoot reports whether dir carries a top-level SKILL.md.
|
||||
func skillTreeHasRoot(dir string) bool {
|
||||
info, err := os.Stat(filepath.Join(dir, "SKILL.md"))
|
||||
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 {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("读取技能目录失败 %s: %w", baseDir, err)
|
||||
}
|
||||
victims := make([]string, 0)
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) {
|
||||
continue
|
||||
}
|
||||
victims = append(victims, filepath.Join(baseDir, e.Name()))
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return victims, nil
|
||||
}
|
||||
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...) {
|
||||
continue
|
||||
}
|
||||
victims = append(victims, filepath.Join(destBase, e.Name()))
|
||||
}
|
||||
return victims, nil
|
||||
}
|
||||
|
||||
// isManagedMultiSkillDir accepts only centralized metadata or the frozen exact
|
||||
// official-name migration list. Files inside Skill directories are ignored.
|
||||
func isManagedMultiSkillDir(dir string, managed ...map[string]bool) bool {
|
||||
if skillstate.IsLegacyOfficialSkillName(filepath.Base(dir)) {
|
||||
return true
|
||||
}
|
||||
return len(managed) > 0 && managed[0][filepath.Base(dir)]
|
||||
}
|
||||
|
||||
func readManagedSkillNames(homeDir string) map[string]bool {
|
||||
state, readable, err := upgradeReadSkillState(homeDir)
|
||||
if err != nil || !readable {
|
||||
return map[string]bool{}
|
||||
}
|
||||
return skillstate.ManagedSkillNames(state)
|
||||
}
|
||||
|
||||
// bundleSkillNames returns the sorted names of subdirectories of dir that
|
||||
// contain a SKILL.md. It returns nil when dir itself carries a top-level
|
||||
// SKILL.md (mono layout) so callers can distinguish the two layouts.
|
||||
func bundleSkillNames(dir string) []string {
|
||||
if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err == nil {
|
||||
return nil
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, e.Name(), "SKILL.md")); err == nil {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// LocateSkillsRoot resolves the skill root inside an extracted dws-skills.zip,
|
||||
// preferring the multi bundle ({extractDir}/multi) over the legacy mono
|
||||
// layouts handled by LocateSkillMD.
|
||||
func LocateSkillsRoot(extractDir string) string {
|
||||
multiRoot := filepath.Join(extractDir, "multi")
|
||||
if skills := bundleSkillNames(multiRoot); len(skills) > 0 {
|
||||
return multiRoot
|
||||
}
|
||||
return LocateSkillMD(extractDir)
|
||||
}
|
||||
|
||||
// LocateSkillMD finds the directory containing SKILL.md in an extracted zip.
|
||||
// It handles both flat layouts (SKILL.md at root) and nested layouts (dws/SKILL.md).
|
||||
func LocateSkillMD(extractDir string) string {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,10 @@ func TestLocateSkillMD(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpgradeSkillLocations(t *testing.T) {
|
||||
// Fake HOME: the mono path refreshes ~/.dws/skills/mono best-effort, and
|
||||
// the test must never touch the real user cache.
|
||||
homeDir := withFakeHome(t)
|
||||
|
||||
skillSrc := t.TempDir()
|
||||
os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# test skill"), 0644)
|
||||
os.MkdirAll(filepath.Join(skillSrc, "references"), 0755)
|
||||
@@ -74,7 +78,6 @@ func TestUpgradeSkillLocations(t *testing.T) {
|
||||
t.Fatal("UpgradeSkillLocations() returned 0 succeeded locations")
|
||||
}
|
||||
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
primaryDest := filepath.Join(homeDir, ".agents", "skills", "dws")
|
||||
|
||||
found := false
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func writeSkillCacheFixture(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 readSkillCacheFixture(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(dir, "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read cache fixture: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageRefreshSkillCacheTransaction pins the cache refresh
|
||||
// transaction on every platform: copying is staged before the old cache moves,
|
||||
// and a publish failure restores the prior usable cache.
|
||||
func TestCrossPlatformCoverageRefreshSkillCacheTransaction(t *testing.T) {
|
||||
newSource := func(t *testing.T) string {
|
||||
t.Helper()
|
||||
src := t.TempDir()
|
||||
writeSkillCacheFixture(t, src, "new")
|
||||
return src
|
||||
}
|
||||
cachePath := func(home string) string {
|
||||
return filepath.Join(home, ".dws", "skills", "multi")
|
||||
}
|
||||
assertOldCache := func(t *testing.T, home string) {
|
||||
t.Helper()
|
||||
if got := readSkillCacheFixture(t, cachePath(home)); got != "old" {
|
||||
t.Fatalf("cache content = %q, want old", got)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("replace existing cache", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
writeSkillCacheFixture(t, cachePath(home), "old")
|
||||
if err := refreshSkillCache(home, "multi", newSource(t)); err != nil {
|
||||
t.Fatalf("refreshSkillCache() error = %v", err)
|
||||
}
|
||||
if got := readSkillCacheFixture(t, cachePath(home)); got != "new" {
|
||||
t.Fatalf("cache content = %q, want new", got)
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Dir(cachePath(home)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Name() != "multi" {
|
||||
t.Fatalf("cache parent entries = %v, want only multi", entries)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publish new cache", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
if err := refreshSkillCache(home, "multi", newSource(t)); err != nil {
|
||||
t.Fatalf("refreshSkillCache() error = %v", err)
|
||||
}
|
||||
if got := readSkillCacheFixture(t, cachePath(home)); got != "new" {
|
||||
t.Fatalf("cache content = %q, want new", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parent creation failure", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
failure := errors.New("mkdir denied")
|
||||
testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return failure })
|
||||
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
|
||||
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("staging creation failure preserves old cache", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
writeSkillCacheFixture(t, cachePath(home), "old")
|
||||
failure := errors.New("temp denied")
|
||||
testseam.Swap(t, &upgradeMkdirTemp, func(string, string) (string, error) { return "", failure })
|
||||
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
|
||||
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
|
||||
}
|
||||
assertOldCache(t, home)
|
||||
})
|
||||
|
||||
t.Run("copy failure preserves old cache", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
writeSkillCacheFixture(t, cachePath(home), "old")
|
||||
failure := errors.New("copy denied")
|
||||
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return failure })
|
||||
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
|
||||
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
|
||||
}
|
||||
assertOldCache(t, home)
|
||||
})
|
||||
|
||||
t.Run("cache stat failure preserves old cache", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
writeSkillCacheFixture(t, cachePath(home), "old")
|
||||
failure := errors.New("stat denied")
|
||||
testseam.Swap(t, &upgradeStat, func(string) (os.FileInfo, error) { return nil, failure })
|
||||
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
|
||||
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
|
||||
}
|
||||
assertOldCache(t, home)
|
||||
})
|
||||
|
||||
t.Run("new cache publish failure cleans staging", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
failure := errors.New("rename denied")
|
||||
testseam.Swap(t, &upgradeRename, func(string, string) error { return failure })
|
||||
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
|
||||
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Dir(cachePath(home)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("cache parent entries = %v, want empty", entries)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rollback staging creation failure preserves old cache", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
writeSkillCacheFixture(t, cachePath(home), "old")
|
||||
failure := errors.New("rollback temp denied")
|
||||
original := upgradeMkdirTemp
|
||||
calls := 0
|
||||
testseam.Swap(t, &upgradeMkdirTemp, func(dir, pattern string) (string, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return "", failure
|
||||
}
|
||||
return original(dir, pattern)
|
||||
})
|
||||
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
|
||||
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
|
||||
}
|
||||
assertOldCache(t, home)
|
||||
})
|
||||
|
||||
t.Run("rollback preparation failure preserves old cache", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
writeSkillCacheFixture(t, cachePath(home), "old")
|
||||
failure := errors.New("remove denied")
|
||||
testseam.Swap(t, &upgradeRemoveAll, func(string) error { return failure })
|
||||
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
|
||||
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
|
||||
}
|
||||
assertOldCache(t, home)
|
||||
})
|
||||
|
||||
t.Run("old cache move failure preserves old cache", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
writeSkillCacheFixture(t, cachePath(home), "old")
|
||||
failure := errors.New("old move denied")
|
||||
testseam.Swap(t, &upgradeRename, func(string, string) error { return failure })
|
||||
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
|
||||
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
|
||||
}
|
||||
assertOldCache(t, home)
|
||||
})
|
||||
|
||||
t.Run("publish failure restores old cache", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
writeSkillCacheFixture(t, cachePath(home), "old")
|
||||
failure := errors.New("publish denied")
|
||||
original := upgradeRename
|
||||
calls := 0
|
||||
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return failure
|
||||
}
|
||||
return original(src, dst)
|
||||
})
|
||||
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
|
||||
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
|
||||
}
|
||||
assertOldCache(t, home)
|
||||
})
|
||||
|
||||
t.Run("restore failure reports both errors and retains backup", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
writeSkillCacheFixture(t, cachePath(home), "old")
|
||||
publishFailure := errors.New("publish denied")
|
||||
restoreFailure := errors.New("restore denied")
|
||||
original := upgradeRename
|
||||
calls := 0
|
||||
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
|
||||
calls++
|
||||
switch calls {
|
||||
case 2:
|
||||
return publishFailure
|
||||
case 3:
|
||||
return restoreFailure
|
||||
default:
|
||||
return original(src, dst)
|
||||
}
|
||||
})
|
||||
err := refreshSkillCache(home, "multi", newSource(t))
|
||||
if !errors.Is(err, publishFailure) || !strings.Contains(err.Error(), restoreFailure.Error()) {
|
||||
t.Fatalf("refreshSkillCache() error = %v, want publish and restore errors", err)
|
||||
}
|
||||
matches, globErr := filepath.Glob(filepath.Join(filepath.Dir(cachePath(home)), ".multi.old-*", "SKILL.md"))
|
||||
if globErr != nil || len(matches) != 1 {
|
||||
t.Fatalf("rollback cache matches = %v, err = %v", matches, globErr)
|
||||
}
|
||||
if got := readSkillCacheFixture(t, filepath.Dir(matches[0])); got != "old" {
|
||||
t.Fatalf("rollback cache content = %q, want old", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("post publish cleanup is best effort", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
writeSkillCacheFixture(t, cachePath(home), "old")
|
||||
original := upgradeRemoveAll
|
||||
calls := 0
|
||||
testseam.Swap(t, &upgradeRemoveAll, func(path string) error {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return errors.New("cleanup denied")
|
||||
}
|
||||
return original(path)
|
||||
})
|
||||
if err := refreshSkillCache(home, "multi", newSource(t)); err != nil {
|
||||
t.Fatalf("refreshSkillCache() error = %v", err)
|
||||
}
|
||||
if got := readSkillCacheFixture(t, cachePath(home)); got != "new" {
|
||||
t.Fatalf("cache content = %q, want new", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func seedUpgradeSkill(t *testing.T, dir, content string, managed bool) {
|
||||
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)
|
||||
}
|
||||
_ = managed
|
||||
}
|
||||
|
||||
func assertUpgradeSkillContent(t *testing.T, dir, want string) {
|
||||
t.Helper()
|
||||
got, err := os.ReadFile(filepath.Join(dir, "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", dir, err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Fatalf("%s content = %q, want %q", dir, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoUpgradeStaging(t *testing.T, base string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".dws-upgrade-") {
|
||||
t.Fatalf("staging directory leaked after failed transaction: %s", filepath.Join(base, entry.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seedMultiUpgradeTarget(t *testing.T) (home, base, multiRoot string, skills []string, skillSet map[string]bool) {
|
||||
t.Helper()
|
||||
home = t.TempDir()
|
||||
base = filepath.Join(home, ".agents", "skills")
|
||||
seedUpgradeSkill(t, filepath.Join(base, "dws"), "old mono", false)
|
||||
seedUpgradeSkill(t, filepath.Join(base, "dingtalk-a"), "old a", true)
|
||||
seedUpgradeSkill(t, filepath.Join(base, "dingtalk-b"), "old b", true)
|
||||
seedUpgradeSkill(t, filepath.Join(base, "dingtalk-stale"), "old stale", true)
|
||||
multiRoot = writeMultiBundle(t, t.TempDir(), "dingtalk-a", "dingtalk-b")
|
||||
skills = []string{"dingtalk-a", "dingtalk-b"}
|
||||
skillSet = map[string]bool{"dingtalk-a": true, "dingtalk-b": true}
|
||||
useUpgradeManagedNames(t, "dingtalk-a", "dingtalk-b", "dingtalk-stale")
|
||||
return home, base, multiRoot, skills, skillSet
|
||||
}
|
||||
|
||||
func assertOriginalMultiUpgradeTarget(t *testing.T, base string) {
|
||||
t.Helper()
|
||||
assertUpgradeSkillContent(t, filepath.Join(base, "dws"), "old mono")
|
||||
assertUpgradeSkillContent(t, filepath.Join(base, "dingtalk-a"), "old a")
|
||||
assertUpgradeSkillContent(t, filepath.Join(base, "dingtalk-b"), "old b")
|
||||
assertUpgradeSkillContent(t, filepath.Join(base, "dingtalk-stale"), "old stale")
|
||||
assertNoUpgradeStaging(t, base)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMultiUpgradeTransactionPreservesOldSet(t *testing.T) {
|
||||
failure := errors.New("injected transaction failure")
|
||||
|
||||
t.Run("copy failure before backup", func(t *testing.T) {
|
||||
home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t)
|
||||
originalCopy := upgradeCopyDir
|
||||
testseam.Swap(t, &upgradeCopyDir, func(src, dest string) error {
|
||||
if filepath.Base(src) == "dingtalk-b" {
|
||||
return failure
|
||||
}
|
||||
return originalCopy(src, dest)
|
||||
})
|
||||
if err := publishMultiUpgradeTarget(home, base, multiRoot, skills, skillSet); !errors.Is(err, failure) {
|
||||
t.Fatalf("copy failure = %v, want injected failure", err)
|
||||
}
|
||||
assertOriginalMultiUpgradeTarget(t, base)
|
||||
})
|
||||
|
||||
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 {
|
||||
if src == filepath.Join(base, "dingtalk-b") {
|
||||
return failure
|
||||
}
|
||||
return originalRename(src, dest)
|
||||
})
|
||||
if err := publishMultiUpgradeTarget(home, base, multiRoot, skills, skillSet); !errors.Is(err, failure) {
|
||||
t.Fatalf("backup failure = %v, want injected failure", err)
|
||||
}
|
||||
assertOriginalMultiUpgradeTarget(t, base)
|
||||
})
|
||||
|
||||
t.Run("publish failure rolls back complete set", func(t *testing.T) {
|
||||
home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t)
|
||||
originalRename := upgradeRename
|
||||
failed := false
|
||||
testseam.Swap(t, &upgradeRename, func(src, dest string) error {
|
||||
if !failed && strings.Contains(src, ".dws-upgrade-multi-") && filepath.Base(dest) == "dingtalk-b" {
|
||||
failed = true
|
||||
return failure
|
||||
}
|
||||
return originalRename(src, dest)
|
||||
})
|
||||
if err := publishMultiUpgradeTarget(home, base, multiRoot, skills, skillSet); !errors.Is(err, failure) {
|
||||
t.Fatalf("publish failure = %v, want injected failure", err)
|
||||
}
|
||||
assertOriginalMultiUpgradeTarget(t, base)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMonoUpgradeTransactionPreservesOldSet(t *testing.T) {
|
||||
failure := errors.New("injected mono transaction failure")
|
||||
seed := func(t *testing.T) (home, base, monoRoot string) {
|
||||
t.Helper()
|
||||
home = t.TempDir()
|
||||
base = filepath.Join(home, ".agents", "skills")
|
||||
seedUpgradeSkill(t, filepath.Join(base, "dws"), "old mono", false)
|
||||
seedUpgradeSkill(t, filepath.Join(base, "dingtalk-a"), "old multi", true)
|
||||
useUpgradeManagedNames(t, "dingtalk-a")
|
||||
monoRoot = t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(monoRoot, "SKILL.md"), []byte("new mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return home, base, monoRoot
|
||||
}
|
||||
assertOld := func(t *testing.T, base string) {
|
||||
t.Helper()
|
||||
assertUpgradeSkillContent(t, filepath.Join(base, "dws"), "old mono")
|
||||
assertUpgradeSkillContent(t, filepath.Join(base, "dingtalk-a"), "old multi")
|
||||
assertNoUpgradeStaging(t, base)
|
||||
}
|
||||
|
||||
t.Run("copy failure before backup", func(t *testing.T) {
|
||||
home, base, monoRoot := seed(t)
|
||||
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return failure })
|
||||
if err := publishMonoUpgradeTarget(home, base, monoRoot); !errors.Is(err, failure) {
|
||||
t.Fatalf("copy failure = %v, want injected failure", err)
|
||||
}
|
||||
assertOld(t, base)
|
||||
})
|
||||
|
||||
t.Run("publish failure rolls back mono and multi", func(t *testing.T) {
|
||||
home, base, monoRoot := seed(t)
|
||||
originalRename := upgradeRename
|
||||
failed := false
|
||||
testseam.Swap(t, &upgradeRename, func(src, dest string) error {
|
||||
if !failed && strings.Contains(src, ".dws-upgrade-mono-") && filepath.Base(dest) == "dws" {
|
||||
failed = true
|
||||
return failure
|
||||
}
|
||||
return originalRename(src, dest)
|
||||
})
|
||||
if err := publishMonoUpgradeTarget(home, base, monoRoot); !errors.Is(err, failure) {
|
||||
t.Fatalf("publish failure = %v, want injected failure", err)
|
||||
}
|
||||
assertOld(t, base)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T) {
|
||||
failure := errors.New("injected failure")
|
||||
restoreFailure := errors.New("injected restore failure")
|
||||
|
||||
t.Run("stage setup and cleanup failures", func(t *testing.T) {
|
||||
destBase := filepath.Join(t.TempDir(), "skills")
|
||||
testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return failure })
|
||||
if _, _, err := stageSkillSet(destBase, ".stage-", nil); !errors.Is(err, failure) {
|
||||
t.Fatalf("stage parent failure = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stage temp failure", func(t *testing.T) {
|
||||
testseam.Swap(t, &upgradeMkdirTemp, func(string, string) (string, error) { return "", failure })
|
||||
if _, _, err := stageSkillSet(t.TempDir(), ".stage-", nil); !errors.Is(err, failure) {
|
||||
t.Fatalf("stage temp failure = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stage cleanup failure joins copy error", func(t *testing.T) {
|
||||
destBase := t.TempDir()
|
||||
src := t.TempDir()
|
||||
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return failure })
|
||||
testseam.Swap(t, &upgradeRemoveAll, func(string) error { return restoreFailure })
|
||||
_, _, err := stageSkillSet(destBase, ".stage-", []skillStageSpec{{src: src, dest: filepath.Join(destBase, "dws")}})
|
||||
if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) {
|
||||
t.Fatalf("joined stage cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate victims are removed once", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "dws")
|
||||
got := uniqueSkillDirs([]string{path, path})
|
||||
if len(got) != 1 || got[0] != path {
|
||||
t.Fatalf("uniqueSkillDirs = %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("restore reports removal and directory failures", func(t *testing.T) {
|
||||
testseam.Swap(t, &upgradeRemoveAll, func(string) error { return failure })
|
||||
testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return restoreFailure })
|
||||
err := restoreSkillSet(
|
||||
[]string{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) {
|
||||
t.Fatalf("restore removal/directory error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("restore reports rename failure", func(t *testing.T) {
|
||||
testseam.Swap(t, &upgradeRename, 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)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("backup failure joins restore failure", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
first := filepath.Join(home, "first")
|
||||
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 {
|
||||
switch {
|
||||
case src == second:
|
||||
return failure
|
||||
case strings.Contains(filepath.ToSlash(src), skillBackupSubdir):
|
||||
return restoreFailure
|
||||
default:
|
||||
return originalRename(src, dest)
|
||||
}
|
||||
})
|
||||
_, err := backupSkillSet(home, []string{first, second})
|
||||
if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) {
|
||||
t.Fatalf("backup/restore error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publish failure joins restore failure", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
old := filepath.Join(home, "skills", "dws")
|
||||
staged := filepath.Join(home, "skills", ".stage", "dws")
|
||||
seedUpgradeSkill(t, old, "old", false)
|
||||
seedUpgradeSkill(t, staged, "new", false)
|
||||
originalRename := upgradeRename
|
||||
testseam.Swap(t, &upgradeRename, func(src, dest string) error {
|
||||
switch {
|
||||
case src == staged:
|
||||
return failure
|
||||
case strings.Contains(filepath.ToSlash(src), skillBackupSubdir):
|
||||
return restoreFailure
|
||||
default:
|
||||
return originalRename(src, dest)
|
||||
}
|
||||
})
|
||||
err := publishStagedSkillSet(home, []stagedSkillDir{{staged: staged, dest: old}}, []string{old})
|
||||
if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) {
|
||||
t.Fatalf("publish/restore error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("victim scans fail closed", func(t *testing.T) {
|
||||
testseam.Swap(t, &upgradeReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
|
||||
base := t.TempDir()
|
||||
if _, err := monoUpgradeVictims(base, filepath.Join(base, "dws")); !errors.Is(err, failure) {
|
||||
t.Fatalf("mono victim scan error = %v", err)
|
||||
}
|
||||
if _, err := multiUpgradeVictims(base, map[string]bool{}, []string{"dingtalk-a"}); !errors.Is(err, failure) {
|
||||
t.Fatalf("multi victim scan error = %v", err)
|
||||
}
|
||||
if err := publishMultiUpgradeTarget(base, base, base, []string{"dingtalk-a"}, map[string]bool{}); !errors.Is(err, failure) {
|
||||
t.Fatalf("multi target victim scan error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package upgrade
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSkillUpgradeAlwaysRestoresOfficialBundle(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "")
|
||||
home := withFakeHome(t)
|
||||
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills"})
|
||||
testseam.Swap(t, &upgradeNow, func() time.Time { return time.Date(2026, 8, 10, 1, 2, 3, 0, time.UTC) })
|
||||
base := filepath.Join(home, ".agents", "skills")
|
||||
for _, name := range []string{"dingtalk-a", "dingtalk-shared"} {
|
||||
if err := os.MkdirAll(filepath.Join(base, name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(base, name, "SKILL.md"), []byte("old"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := skillstate.Write(home, skillstate.State{OfficialSkills: []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi := writeMultiBundle(t, t.TempDir(), "dingtalk-a", "dingtalk-b", "dingtalk-c", "dingtalk-shared")
|
||||
result, err := UpgradeSkillLocationsWithOptions(multi, SkillUpgradeOptions{Version: "1.1.0"})
|
||||
if err != nil || len(result.Failed()) != 0 {
|
||||
t.Fatalf("full refresh = %#v, %v", result, err)
|
||||
}
|
||||
for _, name := range []string{"dingtalk-a", "dingtalk-b", "dingtalk-c", "dingtalk-shared"} {
|
||||
if _, err := os.Stat(filepath.Join(base, name, "SKILL.md")); err != nil {
|
||||
t.Fatalf("official Skill %s was not restored: %v", name, err)
|
||||
}
|
||||
}
|
||||
state, readable, err := skillstate.Read(home)
|
||||
wantOfficial := []string{"dingtalk-a", "dingtalk-b", "dingtalk-c", "dingtalk-shared"}
|
||||
if err != nil || !readable || !reflect.DeepEqual(state.OfficialSkills, wantOfficial) || !reflect.DeepEqual(state.UpdatedSkills, wantOfficial) {
|
||||
t.Fatalf("state = %#v, %v, %v", state, readable, err)
|
||||
}
|
||||
if len(state.ManagedSkills) != len(wantOfficial) {
|
||||
t.Fatalf("managed provenance = %#v", state.ManagedSkills)
|
||||
}
|
||||
for _, provenance := range state.ManagedSkills {
|
||||
if provenance.Version != "1.1.0" || provenance.Source != skillprovenance.SourceUpgrade || !strings.HasPrefix(provenance.Digest, "sha256:") {
|
||||
t.Fatalf("provenance = %#v", provenance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFullSkillUpgradeIgnoresOldStateAndReportsWriteFailure(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "")
|
||||
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills"})
|
||||
multi := writeMultiBundle(t, t.TempDir(), "dingtalk-a", "dingtalk-b", "dingtalk-shared")
|
||||
home := withFakeHome(t)
|
||||
statePath := skillstate.Path(home)
|
||||
if err := os.MkdirAll(filepath.Dir(statePath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(statePath, []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := UpgradeSkillLocationsWithOptions(multi, SkillUpgradeOptions{})
|
||||
if err != nil || len(result.Succeeded()) != 1 {
|
||||
t.Fatalf("corrupt old state should not block full refresh = %#v, %v", result, err)
|
||||
}
|
||||
home2 := t.TempDir()
|
||||
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil })
|
||||
testseam.Swap(t, &upgradeWriteSkillState, func(string, skillstate.State) error { return errors.New("denied") })
|
||||
result, err = UpgradeSkillLocationsWithOptions(multi, SkillUpgradeOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "状态未写入") || len(result.Succeeded()) != 1 {
|
||||
t.Fatalf("write failure = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
+521
-36
@@ -13,6 +13,7 @@ set -eu
|
||||
# Environment variables (optional):
|
||||
# DWS_VERSION — release tag (default: latest)
|
||||
# DWS_SKILLS_ROOT — base path for agent dirs (default: $PWD)
|
||||
# DWS_SKILL_MODE — mono | multi (default: multi)
|
||||
# DWS_GITEE_REPO — "owner/repo" on Gitee; resolve version + assets via the
|
||||
# Gitee API instead of GitHub (China mirror)
|
||||
|
||||
@@ -25,6 +26,13 @@ VERSION="${DWS_VERSION:-latest}"
|
||||
SKILL_NAME="dws"
|
||||
ROOT="${DWS_SKILLS_ROOT:-$PWD}"
|
||||
DWS_CACHE_ROOT="${DWS_CACHE_ROOT:-$HOME/.dws}"
|
||||
DWS_STATE_ROOT="${DWS_CONFIG_DIR:-$DWS_CACHE_ROOT}"
|
||||
MANAGED_SKILL_DIGEST_SCOPE="skill-directory-v1"
|
||||
SKILL_MODE="$(printf '%s' "${DWS_SKILL_MODE:-multi}" | tr '[:upper:]' '[:lower:]')"
|
||||
case "$SKILL_MODE" in
|
||||
mono|multi) ;;
|
||||
*) printf '❌ Invalid DWS_SKILL_MODE=%s. Use mono or multi.\n' "${DWS_SKILL_MODE:-}" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -35,6 +43,227 @@ need_cmd() {
|
||||
fi
|
||||
}
|
||||
|
||||
# backup_and_remove_skill_dir <dir>
|
||||
# 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 backup failure
|
||||
# the directory is left in place and a non-zero status is returned so callers
|
||||
# skip that target rather than silently deleting data.
|
||||
DWS_LAST_SKILL_BACKUP=""
|
||||
backup_and_remove_skill_dir() {
|
||||
_bed_dir="$1"
|
||||
DWS_LAST_SKILL_BACKUP=""
|
||||
[ -d "$_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
|
||||
_bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name"
|
||||
_bed_i=$((_bed_i + 1))
|
||||
if [ "$_bed_i" -gt 1000 ]; then
|
||||
printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$_bed_dir"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
mkdir -p "$(dirname "$_bed_target")" 2>/dev/null || {
|
||||
printf ' ⚠️ 无法创建备份目录,保留原目录 %s\n' "$_bed_dir"
|
||||
return 1
|
||||
}
|
||||
if mv "$_bed_dir" "$_bed_target" 2>/dev/null; then
|
||||
DWS_LAST_SKILL_BACKUP="$_bed_target"
|
||||
printf ' × 已备份并移除 %s → %s\n' "$_bed_dir" "$_bed_target"
|
||||
return 0
|
||||
fi
|
||||
printf ' ⚠️ 备份失败,保留原目录 %s\n' "$_bed_dir"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 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() {
|
||||
_managed_dir="$1"
|
||||
_managed_name="$(basename "$_managed_dir")"
|
||||
is_legacy_official_multi_skill_name "$_managed_name" && return 0
|
||||
[ -f "$DWS_STATE_ROOT/skills-state.json" ] || return 1
|
||||
_managed_json_name="$(json_escape "$_managed_name")"
|
||||
_managed_compact='"name":"'"$_managed_json_name"'"'
|
||||
_managed_spaced='"name": "'"$_managed_json_name"'"'
|
||||
DWS_MANAGED_COMPACT="$_managed_compact" DWS_MANAGED_SPACED="$_managed_spaced" awk '
|
||||
/^[[:space:]]*"managed_skills"[[:space:]]*:[[:space:]]*\[[[:space:]]*$/ { inside = 1; next }
|
||||
inside && /^[[:space:]]*\][[:space:]]*,?[[:space:]]*$/ { closed = 1; exit }
|
||||
inside && (index($0, ENVIRON["DWS_MANAGED_COMPACT"]) || index($0, ENVIRON["DWS_MANAGED_SPACED"])) { found = 1 }
|
||||
END { exit !(closed && found) }
|
||||
' "$DWS_STATE_ROOT/skills-state.json"
|
||||
}
|
||||
|
||||
# Frozen exact names shipped before centralized ownership metadata. Never replace this
|
||||
# with a dingtalk-* prefix check: user/market Skills may use that prefix.
|
||||
is_legacy_official_multi_skill_name() {
|
||||
case "$1" in
|
||||
dingtalk-agoal|dingtalk-aiapp|dingtalk-aisearch|dingtalk-aitable|dingtalk-attendance|dingtalk-calendar|dingtalk-chat|dingtalk-contact|dingtalk-dev|dingtalk-devapp|dingtalk-devdoc|dingtalk-ding|dingtalk-doc|dingtalk-drive|dingtalk-event|dingtalk-hrbrain|dingtalk-live|dingtalk-mail|dingtalk-markdown|dingtalk-minutes|dingtalk-misc|dingtalk-oa|dingtalk-pat|dingtalk-profile|dingtalk-report|dingtalk-shared|dingtalk-sheet|dingtalk-skill|dingtalk-todo|dingtalk-wiki|dws-shared) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
sha256_stdin() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 | awk '{print $1}'
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
openssl dgst -sha256 | awk '{print $NF}'
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
digest_skill_dir() {
|
||||
_digest_dir="$1"
|
||||
_digest="$({
|
||||
find "$_digest_dir" -type f -print | LC_ALL=C sort | while IFS= read -r _digest_file; do
|
||||
_digest_rel="${_digest_file#"$_digest_dir"/}"
|
||||
printf '%s\0' "$_digest_rel"
|
||||
cat "$_digest_file"
|
||||
printf '\0'
|
||||
done
|
||||
} | sha256_stdin)" || return 1
|
||||
printf 'sha256:%s' "$_digest"
|
||||
}
|
||||
|
||||
write_skills_state() {
|
||||
_state_multi="$1"
|
||||
_state_source="$2"
|
||||
mkdir -p "$DWS_STATE_ROOT" || return 1
|
||||
_state_tmp="$(mktemp "$DWS_STATE_ROOT/.skills-state.XXXXXX")" || return 1
|
||||
_state_version="$(json_escape "$VERSION")"
|
||||
_state_names=""
|
||||
for _state_dir in "$_state_multi"/*/; do
|
||||
[ -f "${_state_dir}SKILL.md" ] || continue
|
||||
_state_names="${_state_names}$(basename "$_state_dir")\n"
|
||||
done
|
||||
{
|
||||
printf '{\n "version": "%s",\n' "$_state_version"
|
||||
for _state_field in official_skills updated_skills; do
|
||||
printf ' "%s": [' "$_state_field"
|
||||
_state_first=1
|
||||
printf '%b' "$_state_names" | LC_ALL=C sort | while IFS= read -r _state_name; do
|
||||
[ -n "$_state_name" ] || continue
|
||||
[ "$_state_first" -eq 1 ] || printf ', '
|
||||
printf '"%s"' "$(json_escape "$_state_name")"
|
||||
_state_first=0
|
||||
done
|
||||
printf '],\n'
|
||||
done
|
||||
printf ' "managed_skills": [\n'
|
||||
_state_first=1
|
||||
printf '%b' "$_state_names" | LC_ALL=C sort | while IFS= read -r _state_name; do
|
||||
[ -n "$_state_name" ] || continue
|
||||
_state_digest="$(digest_skill_dir "$_state_multi/$_state_name")" || exit 1
|
||||
[ "$_state_first" -eq 1 ] || printf ',\n'
|
||||
printf ' {"name":"%s","version":"%s","source":"%s","digest":"%s","digest_scope":"%s"}' "$(json_escape "$_state_name")" "$_state_version" "$_state_source" "$_state_digest" "$MANAGED_SKILL_DIGEST_SCOPE"
|
||||
_state_first=0
|
||||
done
|
||||
printf '\n ],\n "updated_at": "%s"\n}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
} > "$_state_tmp" || { rm -f "$_state_tmp"; return 1; }
|
||||
mv "$_state_tmp" "$DWS_STATE_ROOT/skills-state.json"
|
||||
}
|
||||
|
||||
# backup_and_record_skill_dir <victim> <manifest>
|
||||
# Records exact original/backup pairs so a multi-set transaction can restore
|
||||
# earlier moves when any later backup or publication fails.
|
||||
backup_and_record_skill_dir() {
|
||||
_bars_victim="$1"
|
||||
_bars_manifest="$2"
|
||||
backup_and_remove_skill_dir "$_bars_victim" || return 1
|
||||
if [ -n "$DWS_LAST_SKILL_BACKUP" ]; then
|
||||
if ! printf '%s\n%s\n' "$_bars_victim" "$DWS_LAST_SKILL_BACKUP" >> "$_bars_manifest"; then
|
||||
mv "$DWS_LAST_SKILL_BACKUP" "$_bars_victim" 2>/dev/null || printf ' ⚠️ 备份记录失败且无法自动恢复: %s(备份位于 %s)\n' "$_bars_victim" "$DWS_LAST_SKILL_BACKUP"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# restore_multi_skill_set <published-manifest> <backup-manifest>
|
||||
# Removes partial new publications, then restores every old directory from
|
||||
# its exact backup path. Paths containing newlines are outside the supported
|
||||
# installer path contract; spaces are preserved.
|
||||
restore_multi_skill_set() {
|
||||
_rms_published="$1"
|
||||
_rms_backups="$2"
|
||||
_rms_ok=1
|
||||
if [ -f "$_rms_published" ]; then
|
||||
while IFS= read -r _rms_dest; do
|
||||
[ -n "$_rms_dest" ] || continue
|
||||
rm -rf "$_rms_dest" || _rms_ok=0
|
||||
done < "$_rms_published"
|
||||
fi
|
||||
if [ -f "$_rms_backups" ]; then
|
||||
while IFS= read -r _rms_original && IFS= read -r _rms_backup; do
|
||||
[ -n "$_rms_backup" ] || continue
|
||||
if [ -e "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then
|
||||
printf ' ⚠️ 无法恢复原 Skill: %s(备份保留于 %s)\n' "$_rms_original" "$_rms_backup"
|
||||
_rms_ok=0
|
||||
fi
|
||||
done < "$_rms_backups"
|
||||
fi
|
||||
[ "$_rms_ok" -eq 1 ]
|
||||
}
|
||||
|
||||
# publish_skill_cache <source> <cache-dir>
|
||||
# Stages a complete sibling cache before publishing it. Any copy or publish
|
||||
# failure leaves the previous cache in place (or in the reported recovery dir
|
||||
# when even restoration fails).
|
||||
publish_skill_cache() {
|
||||
_psc_src="$1"
|
||||
_psc_cache="$2"
|
||||
_psc_parent="$(dirname "$_psc_cache")"
|
||||
_psc_name="$(basename "$_psc_cache")"
|
||||
_psc_stage=""
|
||||
_psc_old=""
|
||||
|
||||
mkdir -p "$_psc_parent" || return 1
|
||||
_psc_stage="$(mktemp -d "$_psc_parent/.${_psc_name}.tmp.XXXXXX")" || return 1
|
||||
if ! cp -R "$_psc_src/." "$_psc_stage/" 2>/dev/null && \
|
||||
! cp -r "$_psc_src/." "$_psc_stage/" 2>/dev/null; then
|
||||
rm -rf "$_psc_stage"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -e "$_psc_cache" ]; then
|
||||
_psc_old="$(mktemp -d "$_psc_parent/.${_psc_name}.old.XXXXXX")" || {
|
||||
rm -rf "$_psc_stage"
|
||||
return 1
|
||||
}
|
||||
rmdir "$_psc_old" || {
|
||||
rm -rf "$_psc_stage" "$_psc_old"
|
||||
return 1
|
||||
}
|
||||
if ! mv "$_psc_cache" "$_psc_old"; then
|
||||
rm -rf "$_psc_stage"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if mv "$_psc_stage" "$_psc_cache"; then
|
||||
if [ -n "$_psc_old" ] && ! rm -rf "$_psc_old"; then
|
||||
printf ' ⚠️ 新 Skill 缓存已生效,但旧缓存清理失败: %s\n' "$_psc_old"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
rm -rf "$_psc_stage"
|
||||
if [ -n "$_psc_old" ] && ! mv "$_psc_old" "$_psc_cache"; then
|
||||
printf ' ⚠️ Skill 缓存发布失败,原缓存保留在 %s\n' "$_psc_old"
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Fetch a Gitee API endpoint, retrying transient 502/503 from Gitee's gateway.
|
||||
gitee_api() {
|
||||
_url="$1"
|
||||
@@ -114,11 +343,17 @@ _copy_skill_summary() {
|
||||
_label="$3"
|
||||
|
||||
if [ -d "$_dest" ]; then
|
||||
rm -rf "$_dest"
|
||||
backup_and_remove_skill_dir "$_dest" || {
|
||||
printf ' ⚠️ 跳过 %s(保留原目录)\n' "$_dest"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
mkdir -p "$_dest"
|
||||
cp -R "$_src/"* "$_dest/" 2>/dev/null || cp -r "$_src/"* "$_dest/"
|
||||
mkdir -p "$_dest" || return 1
|
||||
if ! cp -R "$_src/"* "$_dest/" 2>/dev/null && ! cp -r "$_src/"* "$_dest/"; then
|
||||
printf ' ⚠️ Skill 复制失败,目标未计为安装成功: %s\n' "$_dest"
|
||||
return 1
|
||||
fi
|
||||
file_count="$(find "$_dest" -type f | wc -l | tr -d ' ')"
|
||||
|
||||
printf ' ✅ Skills → %s (%s files)\n' "$_label" "$file_count"
|
||||
@@ -131,11 +366,17 @@ _copy_skill() {
|
||||
_label="$3"
|
||||
|
||||
if [ -d "$_dest" ]; then
|
||||
rm -rf "$_dest"
|
||||
backup_and_remove_skill_dir "$_dest" || {
|
||||
printf ' ⚠️ 跳过 %s(保留原目录)\n' "$_dest"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
mkdir -p "$_dest"
|
||||
cp -R "$_src/"* "$_dest/" 2>/dev/null || cp -r "$_src/"* "$_dest/"
|
||||
mkdir -p "$_dest" || return 1
|
||||
if ! cp -R "$_src/"* "$_dest/" 2>/dev/null && ! cp -r "$_src/"* "$_dest/"; then
|
||||
printf ' ⚠️ Skill 复制失败,目标未计为安装成功: %s\n' "$_dest"
|
||||
return 1
|
||||
fi
|
||||
file_count="$(find "$_dest" -type f | wc -l | tr -d ' ')"
|
||||
|
||||
printf ' ✅ Skills → %s (%s files)\n' "$_label" "$file_count"
|
||||
@@ -151,11 +392,29 @@ _copy_skill() {
|
||||
done
|
||||
}
|
||||
|
||||
# Same semantics as build/npm/install.js installSkillsToHomes (root = DWS_SKILLS_ROOT or PWD).
|
||||
install_skills_to_root() {
|
||||
skill_src="$1"
|
||||
# multi_tree_has_skills returns 0 only when the given multi bundle directory
|
||||
# contains at least one product skill (a subdirectory with a SKILL.md). An
|
||||
# empty or corrupt multi/ tree must never select the multi branch: installing
|
||||
# it would delete existing dws/ + dingtalk-* skills and lay down nothing.
|
||||
# (Go bundleSkillNames and install.js multiTreeHasSkills guard the same way.)
|
||||
multi_tree_has_skills() {
|
||||
_dir="$1"
|
||||
[ -d "$_dir" ] || return 1
|
||||
for _sub in "$_dir"/*/; do
|
||||
if [ -f "${_sub}SKILL.md" ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# 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
|
||||
failed=0
|
||||
idx=0
|
||||
for agent_dir in \
|
||||
".agents/skills" \
|
||||
@@ -181,28 +440,242 @@ install_skills_to_root() {
|
||||
idx=$((idx + 1))
|
||||
continue
|
||||
fi
|
||||
dest="$base_dir/$SKILL_NAME"
|
||||
attempted=$((attempted + 1))
|
||||
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
|
||||
installed=$((installed + 1))
|
||||
else
|
||||
failed=$((failed + 1))
|
||||
printf ' ⚠️ 跳过 %s(备份或复制失败,未完成 multi 安装)\n' "$base_dir"
|
||||
fi
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
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 [ "$failed" -gt 0 ]; then
|
||||
printf ' ⚠️ 有 %s 个 Agent 目标安装失败\n' "$failed"
|
||||
return 1
|
||||
fi
|
||||
write_skills_state "$multi_src" "install-skills.sh" || return 1
|
||||
}
|
||||
|
||||
_install_multi_to_base() {
|
||||
_msrc="$1"
|
||||
_base="$2"
|
||||
_root="$3"
|
||||
_agent_dir="$4"
|
||||
|
||||
mkdir -p "$_base" || return 1
|
||||
|
||||
# Build the complete replacement set before moving any Agent-visible
|
||||
# directory. The manifests remain inside the private staging directory.
|
||||
_ms_stage="$(mktemp -d "$_base/.dws-multi-set.XXXXXX")" || return 1
|
||||
_ms_backups="$_ms_stage/.backups"
|
||||
_ms_published="$_ms_stage/.published"
|
||||
: > "$_ms_backups" || { rm -rf "$_ms_stage"; return 1; }
|
||||
: > "$_ms_published" || { rm -rf "$_ms_stage"; return 1; }
|
||||
for skill_dir in "$_msrc"/*/; do
|
||||
[ -f "${skill_dir}SKILL.md" ] || continue
|
||||
_name="$(basename "$skill_dir")"
|
||||
_ms_staged_skill="$_ms_stage/$_name"
|
||||
mkdir -p "$_ms_staged_skill" || { rm -rf "$_ms_stage"; return 1; }
|
||||
if ! cp -R "$skill_dir/." "$_ms_staged_skill/" 2>/dev/null && ! cp -r "$skill_dir/." "$_ms_staged_skill/"; then
|
||||
rm -rf "$_ms_stage"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Mutual exclusion: back up + remove the mono leftover.
|
||||
if ! backup_and_record_skill_dir "$_base/$SKILL_NAME" "$_ms_backups"; then
|
||||
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
|
||||
rm -rf "$_ms_stage"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Back up + remove stale, proven DWS-managed skills not in the new bundle.
|
||||
# Never infer ownership from the dingtalk-* prefix alone.
|
||||
for existing in "$_base"/*/; do
|
||||
[ -d "$existing" ] || continue
|
||||
_name="$(basename "$existing")"
|
||||
if is_managed_multi_skill_dir "$existing" && [ ! -f "$_msrc/$_name/SKILL.md" ]; then
|
||||
if ! backup_and_record_skill_dir "$existing" "$_ms_backups"; then
|
||||
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
|
||||
rm -rf "$_ms_stage"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ -d "$_base/dws-shared" ] && [ ! -f "$_msrc/dws-shared/SKILL.md" ]; then
|
||||
if ! backup_and_record_skill_dir "$_base/dws-shared" "$_ms_backups"; then
|
||||
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
|
||||
rm -rf "$_ms_stage"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Back up all replaced skills as one logical operation. Any failure restores
|
||||
# every earlier move before this target reports failure.
|
||||
for skill_dir in "$_msrc"/*/; do
|
||||
[ -f "${skill_dir}SKILL.md" ] || continue
|
||||
_name="$(basename "$skill_dir")"
|
||||
_dest="$_base/$_name"
|
||||
if ! backup_and_record_skill_dir "$_dest" "$_ms_backups"; then
|
||||
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
|
||||
rm -rf "$_ms_stage"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
_count=0
|
||||
for skill_dir in "$_msrc"/*/; do
|
||||
[ -f "${skill_dir}SKILL.md" ] || continue
|
||||
_name="$(basename "$skill_dir")"
|
||||
_dest="$_base/$_name"
|
||||
printf '%s\n' "$_dest" >> "$_ms_published" || {
|
||||
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
|
||||
rm -rf "$_ms_stage"
|
||||
return 1
|
||||
}
|
||||
if ! mv "$_ms_stage/$_name" "$_dest"; then
|
||||
printf ' ⚠️ multi Skill 集合发布失败,正在恢复原集合: %s\n' "$_dest"
|
||||
restore_multi_skill_set "$_ms_published" "$_ms_backups" || printf ' ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径\n'
|
||||
rm -rf "$_ms_stage"
|
||||
return 1
|
||||
fi
|
||||
_count=$((_count + 1))
|
||||
done
|
||||
rm -rf "$_ms_stage" || return 1
|
||||
|
||||
if [ "$_root" = "$HOME" ]; then
|
||||
_label="~/$_agent_dir/"
|
||||
else
|
||||
_label="$_root/$_agent_dir/"
|
||||
fi
|
||||
printf ' ✅ Skills → %s (%s product skills)\n' "$_label" "$_count"
|
||||
}
|
||||
|
||||
# Publish mono and all mutually-exclusive managed multi directories as one
|
||||
# transaction. The complete dws/ tree is staged before any visible directory
|
||||
# moves; any later backup or publish failure restores the exact old set.
|
||||
_install_mono_to_base() {
|
||||
_mono_src="$1"
|
||||
_mono_base="$2"
|
||||
_mono_label="$3"
|
||||
|
||||
mkdir -p "$_mono_base" || return 1
|
||||
_mono_stage="$(mktemp -d "$_mono_base/.dws-mono-set.XXXXXX")" || return 1
|
||||
_mono_backups="$_mono_stage/.backups"
|
||||
_mono_published="$_mono_stage/.published"
|
||||
: > "$_mono_backups" || { rm -rf "$_mono_stage"; return 1; }
|
||||
: > "$_mono_published" || { rm -rf "$_mono_stage"; return 1; }
|
||||
mkdir -p "$_mono_stage/$SKILL_NAME" || { rm -rf "$_mono_stage"; return 1; }
|
||||
if ! cp -R "$_mono_src/." "$_mono_stage/$SKILL_NAME/" 2>/dev/null && ! cp -r "$_mono_src/." "$_mono_stage/$SKILL_NAME/"; then
|
||||
rm -rf "$_mono_stage"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! backup_and_record_skill_dir "$_mono_base/$SKILL_NAME" "$_mono_backups"; then
|
||||
restore_multi_skill_set "$_mono_published" "$_mono_backups" || true
|
||||
rm -rf "$_mono_stage"
|
||||
return 1
|
||||
fi
|
||||
for existing in "$_mono_base"/*/; do
|
||||
[ -d "$existing" ] || continue
|
||||
is_managed_multi_skill_dir "$existing" || continue
|
||||
if ! backup_and_record_skill_dir "$existing" "$_mono_backups"; then
|
||||
restore_multi_skill_set "$_mono_published" "$_mono_backups" || true
|
||||
rm -rf "$_mono_stage"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
_mono_dest="$_mono_base/$SKILL_NAME"
|
||||
printf '%s\n' "$_mono_dest" >> "$_mono_published" || {
|
||||
restore_multi_skill_set "$_mono_published" "$_mono_backups" || true
|
||||
rm -rf "$_mono_stage"
|
||||
return 1
|
||||
}
|
||||
if ! mv "$_mono_stage/$SKILL_NAME" "$_mono_dest"; then
|
||||
printf ' ⚠️ mono Skill 集合发布失败,正在恢复原集合: %s\n' "$_mono_dest"
|
||||
restore_multi_skill_set "$_mono_published" "$_mono_backups" || printf ' ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径\n'
|
||||
rm -rf "$_mono_stage"
|
||||
return 1
|
||||
fi
|
||||
rm -rf "$_mono_stage" || return 1
|
||||
_mono_count="$(find "$_mono_dest" -type f | wc -l | tr -d ' ')"
|
||||
printf ' ✅ Skills → %s (%s files)\n' "$_mono_label" "$_mono_count"
|
||||
}
|
||||
|
||||
# Same semantics as build/npm/install.js installSkillsToHomes (root = DWS_SKILLS_ROOT or PWD).
|
||||
install_skills_to_root() {
|
||||
skill_src="$1"
|
||||
root="$2"
|
||||
installed=0
|
||||
attempted=0
|
||||
failed=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"
|
||||
do
|
||||
base_dir="$root/$agent_dir"
|
||||
parent_gate="$(dirname "$base_dir")"
|
||||
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
|
||||
idx=$((idx + 1))
|
||||
continue
|
||||
fi
|
||||
attempted=$((attempted + 1))
|
||||
if [ "$root" = "$HOME" ]; then
|
||||
label="~/$agent_dir/$SKILL_NAME"
|
||||
else
|
||||
label="$root/$agent_dir/$SKILL_NAME"
|
||||
fi
|
||||
if [ "$installed" -eq 0 ]; then
|
||||
_copy_skill "$skill_src" "$dest" "$label"
|
||||
if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then
|
||||
installed=$((installed + 1))
|
||||
else
|
||||
_copy_skill_summary "$skill_src" "$dest" "$label"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
installed=$((installed + 1))
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
if [ "$installed" -eq 0 ]; then
|
||||
if [ "$attempted" -eq 0 ]; then
|
||||
if [ "$root" = "$HOME" ]; then
|
||||
flabel="~/.agents/skills/$SKILL_NAME"
|
||||
else
|
||||
flabel="$root/.agents/skills/$SKILL_NAME"
|
||||
fi
|
||||
_copy_skill "$skill_src" "$root/.agents/skills/$SKILL_NAME" "$flabel"
|
||||
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 [ "$failed" -gt 0 ]; then
|
||||
printf ' ⚠️ 有 %s 个 Agent 目标安装 mono Skill 失败\n' "$failed"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$DWS_STATE_ROOT/skills-state.json"
|
||||
}
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
@@ -236,31 +709,43 @@ main() {
|
||||
SKILL_SRC="$TMPDIR_WORK/extracted/${SKILL_NAME}"
|
||||
fi
|
||||
|
||||
if [ ! -f "$SKILL_SRC/SKILL.md" ]; then
|
||||
printf ' ❌ Skill source not found in release asset\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
printf ' Installing under root: %s\n' "$ROOT"
|
||||
install_skills_to_root "$SKILL_SRC" "$ROOT"
|
||||
printf ' Installing under root: %s (mode: %s)\n' "$ROOT" "$SKILL_MODE"
|
||||
# Multi first: a release may ship only the multi/ tree without the root
|
||||
# mono copy, so the mono SKILL.md gate must never block a multi install.
|
||||
# An empty/corrupt multi/ tree (no */SKILL.md) falls back to mono with a
|
||||
# warning — installing it would wipe existing skills and lay down nothing.
|
||||
if [ "$SKILL_MODE" = "multi" ] && multi_tree_has_skills "$TMPDIR_WORK/extracted/multi"; then
|
||||
install_multi_skills_to_root "$TMPDIR_WORK/extracted/multi" "$ROOT"
|
||||
else
|
||||
if [ "$SKILL_MODE" = "multi" ]; then
|
||||
printf ' ⚠️ Multi skill tree not found or empty in release asset; falling back to mono.\n'
|
||||
fi
|
||||
if [ ! -f "$SKILL_SRC/SKILL.md" ]; then
|
||||
printf ' ❌ Skill source not found in release asset\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
install_skills_to_root "$SKILL_SRC" "$ROOT"
|
||||
fi
|
||||
|
||||
# Cache multi/ (and a mono copy) under ~/.dws/skills so that subsequent
|
||||
# `dws skill setup --mode multi|mono` invocations can find a source.
|
||||
if [ -d "$TMPDIR_WORK/extracted/multi" ]; then
|
||||
# `dws skill setup --mode multi|mono` invocations can find a source. An
|
||||
# empty/corrupt tree must never wipe a previously good cache.
|
||||
if multi_tree_has_skills "$TMPDIR_WORK/extracted/multi"; then
|
||||
cache_dir="${DWS_CACHE_ROOT}/skills/multi"
|
||||
rm -rf "$cache_dir"
|
||||
mkdir -p "$cache_dir"
|
||||
cp -R "$TMPDIR_WORK/extracted/multi/"* "$cache_dir/" 2>/dev/null || \
|
||||
cp -r "$TMPDIR_WORK/extracted/multi/"* "$cache_dir/" 2>/dev/null || true
|
||||
file_count="$(find "$cache_dir" -type f | wc -l | tr -d ' ')"
|
||||
printf ' ✅ Cached multi skills → %s (%s files)\n' "$cache_dir" "$file_count"
|
||||
if publish_skill_cache "$TMPDIR_WORK/extracted/multi" "$cache_dir"; then
|
||||
file_count="$(find "$cache_dir" -type f | wc -l | tr -d ' ')"
|
||||
printf ' ✅ Cached multi skills → %s (%s files)\n' "$cache_dir" "$file_count"
|
||||
else
|
||||
printf ' ⚠️ Multi Skill 缓存刷新失败,未覆盖原缓存: %s\n' "$cache_dir"
|
||||
fi
|
||||
fi
|
||||
if [ -f "$SKILL_SRC/SKILL.md" ]; then
|
||||
mono_cache="${DWS_CACHE_ROOT}/skills/mono"
|
||||
if ! publish_skill_cache "$SKILL_SRC" "$mono_cache"; then
|
||||
printf ' ⚠️ Mono Skill 缓存刷新失败,未覆盖原缓存: %s\n' "$mono_cache"
|
||||
fi
|
||||
fi
|
||||
mono_cache="${DWS_CACHE_ROOT}/skills/mono"
|
||||
rm -rf "$mono_cache"
|
||||
mkdir -p "$mono_cache"
|
||||
cp -R "$SKILL_SRC/"* "$mono_cache/" 2>/dev/null || \
|
||||
cp -r "$SKILL_SRC/"* "$mono_cache/" 2>/dev/null || true
|
||||
|
||||
printf '\n'
|
||||
printf ' 📖 Skill includes:\n'
|
||||
|
||||
+543
-77
@@ -17,7 +17,7 @@
|
||||
# DWS_ARCH — architecture override (amd64 or arm64)
|
||||
# DWS_NO_SKILLS — set to 1 to skip skills install
|
||||
# DWS_SKILLS_ONLY — set to 1 to install only skills
|
||||
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else mono)
|
||||
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else multi)
|
||||
# DWS_GITEE_REPO — "owner/repo" on Gitee; resolve version + assets via the
|
||||
# Gitee API instead of GitHub (China mirror)
|
||||
#
|
||||
@@ -39,6 +39,18 @@ $NoSkills = $env:DWS_NO_SKILLS -eq "1"
|
||||
$SkillsOnly = $env:DWS_SKILLS_ONLY -eq "1"
|
||||
$SkillName = "dws"
|
||||
$SkillMode = ""
|
||||
$SkillStateRoot = if ($env:DWS_CONFIG_DIR) { $env:DWS_CONFIG_DIR } else { Join-Path $HOME ".dws" }
|
||||
$ManagedSkillDigestScope = "skill-directory-v1"
|
||||
$LegacyOfficialMultiSkills = @(
|
||||
"dingtalk-agoal", "dingtalk-aiapp", "dingtalk-aisearch", "dingtalk-aitable",
|
||||
"dingtalk-attendance", "dingtalk-calendar", "dingtalk-chat", "dingtalk-contact",
|
||||
"dingtalk-dev", "dingtalk-devapp", "dingtalk-devdoc", "dingtalk-ding",
|
||||
"dingtalk-doc", "dingtalk-drive", "dingtalk-event", "dingtalk-hrbrain",
|
||||
"dingtalk-live", "dingtalk-mail", "dingtalk-markdown", "dingtalk-minutes",
|
||||
"dingtalk-misc", "dingtalk-oa", "dingtalk-pat", "dingtalk-profile",
|
||||
"dingtalk-report", "dingtalk-shared", "dingtalk-sheet", "dingtalk-skill",
|
||||
"dingtalk-todo", "dingtalk-wiki", "dws-shared"
|
||||
)
|
||||
|
||||
# Agent skill base directories (same order as build/npm/install.js AGENT_DIRS).
|
||||
$AgentDirs = @(
|
||||
@@ -73,6 +85,105 @@ function Write-Err {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# A dingtalk-* prefix alone is not ownership evidence: market/user skills may
|
||||
# use it too. Ownership comes from the centralized skills-state.json.
|
||||
function Test-ManagedMultiSkillDir {
|
||||
param([string]$Dir)
|
||||
$name = Split-Path $Dir -Leaf
|
||||
if ($LegacyOfficialMultiSkills -contains $name) { return $true }
|
||||
$statePath = Join-Path $SkillStateRoot "skills-state.json"
|
||||
if (!(Test-Path $statePath -PathType Leaf)) { return $false }
|
||||
try {
|
||||
$state = Get-Content -Path $statePath -Raw | ConvertFrom-Json -ErrorAction Stop
|
||||
return @($state.managed_skills | Where-Object { $_.name -eq $name }).Count -gt 0
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Get-SkillDirectoryDigest {
|
||||
param([string]$Dir)
|
||||
$root = [System.IO.Path]::GetFullPath($Dir).TrimEnd([char[]]@('\', '/'))
|
||||
$files = @(
|
||||
Get-ChildItem -Path $root -Recurse -File -Force |
|
||||
ForEach-Object {
|
||||
[pscustomobject]@{
|
||||
Relative = $_.FullName.Substring($root.Length).TrimStart([char[]]@('\', '/')).Replace('\', '/')
|
||||
FullName = $_.FullName
|
||||
}
|
||||
} |
|
||||
Sort-Object -Property Relative
|
||||
)
|
||||
$stream = [System.IO.MemoryStream]::new()
|
||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
foreach ($file in $files) {
|
||||
$pathBytes = [System.Text.Encoding]::UTF8.GetBytes($file.Relative)
|
||||
$stream.Write($pathBytes, 0, $pathBytes.Length)
|
||||
$stream.WriteByte(0)
|
||||
$content = [System.IO.File]::ReadAllBytes($file.FullName)
|
||||
$stream.Write($content, 0, $content.Length)
|
||||
$stream.WriteByte(0)
|
||||
}
|
||||
$hash = $sha.ComputeHash($stream.ToArray())
|
||||
return "sha256:" + ([System.BitConverter]::ToString($hash).Replace("-", "").ToLowerInvariant())
|
||||
} finally {
|
||||
$sha.Dispose()
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Write-SkillsState {
|
||||
param([string]$MultiSrc)
|
||||
$stateDir = $SkillStateRoot
|
||||
New-Item -ItemType Directory -Path $stateDir -Force | Out-Null
|
||||
$versionValue = if ([string]::IsNullOrWhiteSpace($Version)) { "unknown" } else { $Version }
|
||||
$skills = @(Get-ChildItem -Path $MultiSrc -Directory | Where-Object {
|
||||
Test-Path (Join-Path $_.FullName "SKILL.md")
|
||||
} | Sort-Object -Property Name)
|
||||
$names = @($skills | ForEach-Object { $_.Name })
|
||||
$managed = @($skills | ForEach-Object {
|
||||
[ordered]@{
|
||||
name = $_.Name
|
||||
version = $versionValue
|
||||
source = "install.ps1"
|
||||
digest = Get-SkillDirectoryDigest -Dir $_.FullName
|
||||
digest_scope = $ManagedSkillDigestScope
|
||||
}
|
||||
})
|
||||
$state = [ordered]@{
|
||||
version = $versionValue
|
||||
official_skills = $names
|
||||
updated_skills = $names
|
||||
managed_skills = $managed
|
||||
updated_at = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
}
|
||||
$statePath = Join-Path $stateDir "skills-state.json"
|
||||
$tempPath = Join-Path $stateDir (".skills-state-" + [guid]::NewGuid().ToString("N") + ".tmp")
|
||||
$backupPath = Join-Path $stateDir (".skills-state-" + [guid]::NewGuid().ToString("N") + ".previous")
|
||||
try {
|
||||
[System.IO.File]::WriteAllText($tempPath, (($state | ConvertTo-Json -Depth 5) + "`n"), [System.Text.UTF8Encoding]::new($false))
|
||||
if (Test-Path $statePath -PathType Leaf) {
|
||||
[System.IO.File]::Replace($tempPath, $statePath, $backupPath, $true)
|
||||
Remove-Item -LiteralPath $backupPath -Force -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
Move-Item -LiteralPath $tempPath -Destination $statePath
|
||||
}
|
||||
} finally {
|
||||
if (Test-Path $tempPath) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
}
|
||||
|
||||
# Central move seam for transactional Skill publication. Tests replace this
|
||||
# function to inject backup/publish failures without relying on ACL behavior.
|
||||
function Move-SkillPath {
|
||||
param(
|
||||
[string]$Source,
|
||||
[string]$Destination
|
||||
)
|
||||
Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop
|
||||
}
|
||||
|
||||
function Get-Arch {
|
||||
# Allow manual override via environment variable
|
||||
if ($env:DWS_ARCH) {
|
||||
@@ -230,12 +341,130 @@ function Copy-DirRecursive {
|
||||
return $count
|
||||
}
|
||||
|
||||
function Publish-SkillCache {
|
||||
param([string]$Source, [string]$CacheDir)
|
||||
|
||||
$cacheParent = Split-Path $CacheDir -Parent
|
||||
$cacheName = Split-Path $CacheDir -Leaf
|
||||
New-Item -ItemType Directory -Path $cacheParent -Force -ErrorAction Stop | Out-Null
|
||||
$stagedDir = Join-Path $cacheParent ".$cacheName.tmp-$([Guid]::NewGuid().ToString('N'))"
|
||||
$rollbackDir = ""
|
||||
$published = $false
|
||||
New-Item -ItemType Directory -Path $stagedDir -Force -ErrorAction Stop | Out-Null
|
||||
|
||||
try {
|
||||
$count = Copy-DirRecursive -Source $Source -Destination $stagedDir
|
||||
if (Test-Path $CacheDir) {
|
||||
$rollbackDir = Join-Path $cacheParent ".$cacheName.old-$([Guid]::NewGuid().ToString('N'))"
|
||||
Move-Item -Path $CacheDir -Destination $rollbackDir -ErrorAction Stop
|
||||
}
|
||||
try {
|
||||
Move-Item -Path $stagedDir -Destination $CacheDir -ErrorAction Stop
|
||||
$published = $true
|
||||
} catch {
|
||||
$publishError = $_
|
||||
if ($rollbackDir) {
|
||||
try {
|
||||
Move-Item -Path $rollbackDir -Destination $CacheDir -ErrorAction Stop
|
||||
$rollbackDir = ""
|
||||
} catch {
|
||||
throw "Skill 缓存发布失败: $publishError;原缓存恢复也失败,恢复目录: $rollbackDir;错误: $_"
|
||||
}
|
||||
}
|
||||
throw $publishError
|
||||
}
|
||||
if ($rollbackDir -and (Test-Path $rollbackDir)) {
|
||||
Remove-Item -Path $rollbackDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
if (Test-Path $rollbackDir) {
|
||||
Write-Say "⚠️ 新缓存已生效,但旧缓存清理失败: $rollbackDir"
|
||||
}
|
||||
$rollbackDir = ""
|
||||
}
|
||||
return $count
|
||||
} finally {
|
||||
if (!$published -and (Test-Path $stagedDir)) {
|
||||
Remove-Item -Path $stagedDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 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
|
||||
# backup failure the directory is left in place and $false is returned so
|
||||
# callers skip that target rather than silently deleting data.
|
||||
function Backup-SkillDir {
|
||||
param(
|
||||
[string]$Dir,
|
||||
[ref]$BackupPath
|
||||
)
|
||||
if ($null -ne $BackupPath) { $BackupPath.Value = "" }
|
||||
if (!(Test-Path $Dir -PathType Container)) { 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
|
||||
$i = 1
|
||||
while (Test-Path $target) {
|
||||
$target = Join-Path (Join-Path $backupRoot "$stamp-$i") $name
|
||||
$i++
|
||||
if ($i -gt 1000) {
|
||||
Write-Say "⚠️ 备份目录冲突,保留原目录 $Dir"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
try {
|
||||
New-Item -ItemType Directory -Path (Split-Path $target -Parent) -Force -ErrorAction Stop | Out-Null
|
||||
Move-SkillPath -Source $Dir -Destination $target
|
||||
} catch {
|
||||
Write-Say "⚠️ 备份失败,保留原目录 $Dir"
|
||||
return $false
|
||||
}
|
||||
if ($null -ne $BackupPath) { $BackupPath.Value = $target }
|
||||
Write-Say " × 已备份并移除 $Dir → $target"
|
||||
return $true
|
||||
}
|
||||
|
||||
function Restore-MultiSkillSet {
|
||||
param(
|
||||
[array]$Published,
|
||||
[array]$Backups
|
||||
)
|
||||
$ok = $true
|
||||
for ($i = $Published.Count - 1; $i -ge 0; $i--) {
|
||||
try {
|
||||
if (Test-Path $Published[$i]) {
|
||||
Remove-Item -LiteralPath $Published[$i] -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
} catch {
|
||||
Write-Say "⚠️ 无法移除失败发布目录 $($Published[$i]): $_"
|
||||
$ok = $false
|
||||
}
|
||||
}
|
||||
for ($i = $Backups.Count - 1; $i -ge 0; $i--) {
|
||||
$item = $Backups[$i]
|
||||
try {
|
||||
if (Test-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
|
||||
} catch {
|
||||
Write-Say "⚠️ 无法恢复原 Skill $($item.Original);备份保留于 $($item.Backup): $_"
|
||||
$ok = $false
|
||||
}
|
||||
}
|
||||
return $ok
|
||||
}
|
||||
|
||||
function Copy-SkillToDir {
|
||||
param([string]$SkillSrc, [string]$Dest, [string]$Label)
|
||||
|
||||
# Remove existing installation
|
||||
if (Test-Path $Dest) {
|
||||
Remove-Item -Path $Dest -Recurse -Force
|
||||
# Refreshing an existing skill: back it up first; on backup failure keep
|
||||
# the user's copy and skip this target.
|
||||
if (!(Backup-SkillDir -Dir $Dest)) {
|
||||
Write-Say "⚠️ 跳过 $Dest(保留原目录)"
|
||||
return $false
|
||||
}
|
||||
|
||||
$fileCount = Copy-DirRecursive -Source $SkillSrc -Destination $Dest
|
||||
@@ -250,17 +479,20 @@ function Copy-SkillToDir {
|
||||
Write-Say " 📄 $($_.Name)"
|
||||
}
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Copy-SkillToDirSummary {
|
||||
param([string]$SkillSrc, [string]$Dest, [string]$Label)
|
||||
|
||||
if (Test-Path $Dest) {
|
||||
Remove-Item -Path $Dest -Recurse -Force
|
||||
if (!(Backup-SkillDir -Dir $Dest)) {
|
||||
Write-Say "⚠️ 跳过 $Dest(保留原目录)"
|
||||
return $false
|
||||
}
|
||||
|
||||
$fileCount = Copy-DirRecursive -Source $SkillSrc -Destination $Dest
|
||||
Write-Say "✅ Skills → $Label ($fileCount files)"
|
||||
return $true
|
||||
}
|
||||
|
||||
function Resolve-SourceRoot {
|
||||
@@ -288,8 +520,8 @@ function Write-Banner {
|
||||
#
|
||||
# Priority (highest first):
|
||||
# 1. DWS_SKILL_MODE env var (mono | multi, case-insensitive)
|
||||
# 2. Interactive prompt when both stdin and stdout are TTYs (default: mono)
|
||||
# 3. Fallback: mono (non-TTY without env var, e.g. irm | iex)
|
||||
# 2. Interactive prompt when both stdin and stdout are TTYs (default: multi)
|
||||
# 3. Fallback: multi (non-TTY without env var, e.g. irm | iex)
|
||||
function Resolve-SkillMode {
|
||||
if ($env:DWS_SKILL_MODE) {
|
||||
$normalized = $env:DWS_SKILL_MODE.ToLower()
|
||||
@@ -311,33 +543,25 @@ function Resolve-SkillMode {
|
||||
if ($isInteractive) {
|
||||
Write-Host ""
|
||||
Write-Say "Select skill installation mode:"
|
||||
Write-Say " 1) mono — install one bundled dws skill (stable / recommended)"
|
||||
Write-Say " 2) multi — split each product into its own skill (run 'dws skill setup --mode multi' afterwards)"
|
||||
Write-Say " 1) multi (default) — split each product into its own skill (dingtalk-*)"
|
||||
Write-Say " 2) mono — install one bundled dws skill (legacy)"
|
||||
$choice = Read-Host " Choice [1]"
|
||||
switch ($choice) {
|
||||
"" { $script:SkillMode = "mono" }
|
||||
"1" { $script:SkillMode = "mono" }
|
||||
"mono" { $script:SkillMode = "mono" }
|
||||
"2" { $script:SkillMode = "multi" }
|
||||
"" { $script:SkillMode = "multi" }
|
||||
"1" { $script:SkillMode = "multi" }
|
||||
"multi" { $script:SkillMode = "multi" }
|
||||
"2" { $script:SkillMode = "mono" }
|
||||
"mono" { $script:SkillMode = "mono" }
|
||||
default {
|
||||
Write-Say "Unrecognized choice '$choice', defaulting to mono."
|
||||
$script:SkillMode = "mono"
|
||||
Write-Say "Unrecognized choice '$choice', defaulting to multi."
|
||||
$script:SkillMode = "multi"
|
||||
}
|
||||
}
|
||||
Write-Say "Skill mode: $SkillMode"
|
||||
return
|
||||
}
|
||||
|
||||
$script:SkillMode = "mono"
|
||||
}
|
||||
|
||||
function Write-MultiModeNotice {
|
||||
Write-Say ""
|
||||
Write-Say "Skill mode: multi — automatic skill install skipped."
|
||||
Write-Say " To install split skills, run:"
|
||||
Write-Say " $BinName skill setup --mode multi"
|
||||
Write-Say " (One skill per product family; requires the dws binary installed above.)"
|
||||
$script:SkillMode = "multi"
|
||||
}
|
||||
|
||||
# ── Install Binary ───────────────────────────────────────────────────────────
|
||||
@@ -422,17 +646,30 @@ function Install-SkillsLocal {
|
||||
$skillSrc = Join-Path (Join-Path $Root "skills") "mono"
|
||||
$multiSrc = Join-Path (Join-Path $Root "skills") "multi"
|
||||
|
||||
if (!(Test-Path $skillSrc)) {
|
||||
Write-Say "⚠️ Local skills directory not found: $skillSrc"
|
||||
Write-Say " Skipping skills installation."
|
||||
return
|
||||
if ($SkillMode -eq "multi" -and (Test-MultiTreeHasSkills $multiSrc)) {
|
||||
Write-Say ""
|
||||
Write-Say "📦 Installing agent skills (multi) from local source: $multiSrc"
|
||||
if (!(Install-MultiSkillsToHomes -MultiSrc $multiSrc -Root $HOME)) {
|
||||
throw "multi Skill installation failed"
|
||||
}
|
||||
} else {
|
||||
if ($SkillMode -eq "multi") {
|
||||
Write-Say "⚠️ multi skill tree not found or empty at $multiSrc; falling back to mono."
|
||||
}
|
||||
if (!(Test-Path $skillSrc)) {
|
||||
Write-Say "⚠️ Local skills directory not found: $skillSrc"
|
||||
Write-Say " Skipping skills installation."
|
||||
return
|
||||
}
|
||||
|
||||
Write-Say ""
|
||||
Write-Say "📦 Installing agent skills from local source: $skillSrc"
|
||||
|
||||
if (!(Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME)) {
|
||||
throw "mono Skill installation failed"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Say ""
|
||||
Write-Say "📦 Installing agent skills from local source: $skillSrc"
|
||||
|
||||
Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME
|
||||
|
||||
if (Test-Path $multiSrc) {
|
||||
Cache-MultiSkills -Source $multiSrc
|
||||
}
|
||||
@@ -445,28 +682,93 @@ function Install-SkillsLocal {
|
||||
function Cache-MultiSkills {
|
||||
param([string]$Source)
|
||||
|
||||
if (!(Test-Path $Source)) { return }
|
||||
# Never let an empty/corrupt multi\ tree wipe a previously good cache.
|
||||
if (!(Test-MultiTreeHasSkills $Source)) { return }
|
||||
|
||||
$cacheDir = Join-Path $HOME ".dws\skills\multi"
|
||||
if (Test-Path $cacheDir) {
|
||||
Remove-Item -Path $cacheDir -Recurse -Force
|
||||
try {
|
||||
$count = Publish-SkillCache -Source $Source -CacheDir $cacheDir
|
||||
Write-Say "✅ Cached multi skills → $cacheDir ($count files)"
|
||||
} catch {
|
||||
Write-Say "⚠️ Multi Skill 缓存刷新失败,未覆盖原缓存: $cacheDir ($_)"
|
||||
}
|
||||
New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
|
||||
$count = Copy-DirRecursive -Source $Source -Destination $cacheDir
|
||||
Write-Say "✅ Cached multi skills → $cacheDir ($count files)"
|
||||
}
|
||||
|
||||
function Cache-MonoSkills {
|
||||
param([string]$Source)
|
||||
|
||||
if (!(Test-Path $Source)) { return }
|
||||
# Only refresh when the new bundle actually carries a mono tree — a
|
||||
# multi-only bundle must never wipe a previously good mono cache.
|
||||
if (!(Test-Path (Join-Path $Source "SKILL.md"))) { return }
|
||||
|
||||
$cacheDir = Join-Path $HOME ".dws\skills\mono"
|
||||
if (Test-Path $cacheDir) {
|
||||
Remove-Item -Path $cacheDir -Recurse -Force
|
||||
try {
|
||||
Publish-SkillCache -Source $Source -CacheDir $cacheDir | Out-Null
|
||||
} catch {
|
||||
Write-Say "⚠️ Mono Skill 缓存刷新失败,未覆盖原缓存: $cacheDir ($_)"
|
||||
}
|
||||
New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
|
||||
Copy-DirRecursive -Source $Source -Destination $cacheDir | Out-Null
|
||||
}
|
||||
|
||||
function Install-MonoToBase {
|
||||
param(
|
||||
[string]$SkillSrc,
|
||||
[string]$BaseDir,
|
||||
[string]$Label
|
||||
)
|
||||
|
||||
if (!(Test-Path $BaseDir)) {
|
||||
New-Item -ItemType Directory -Path $BaseDir -Force | Out-Null
|
||||
}
|
||||
$stageRoot = Join-Path $BaseDir (".dws-mono-set-" + [guid]::NewGuid().ToString("N"))
|
||||
$stagedSkill = Join-Path $stageRoot $SkillName
|
||||
$dest = Join-Path $BaseDir $SkillName
|
||||
$backups = @()
|
||||
$published = @()
|
||||
try {
|
||||
# Stage the complete mono tree before moving any Agent-visible
|
||||
# directory, including every mutually-exclusive managed multi Skill.
|
||||
New-Item -ItemType Directory -Path $stageRoot -Force -ErrorAction Stop | Out-Null
|
||||
Copy-DirRecursive -Source $SkillSrc -Destination $stagedSkill | Out-Null
|
||||
|
||||
$victims = [System.Collections.Generic.List[string]]::new()
|
||||
$victims.Add($dest)
|
||||
foreach ($existing in Get-ChildItem -Path $BaseDir -Directory -ErrorAction SilentlyContinue) {
|
||||
if ($existing.FullName -eq $stageRoot) { continue }
|
||||
if (Test-ManagedMultiSkillDir -Dir $existing.FullName) {
|
||||
$victims.Add($existing.FullName)
|
||||
}
|
||||
}
|
||||
|
||||
$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($victim in $victims) {
|
||||
if (!$seen.Add($victim)) { continue }
|
||||
$backupPath = ""
|
||||
if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) {
|
||||
throw "Skill 备份失败: $victim"
|
||||
}
|
||||
if ($backupPath) {
|
||||
$backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath }
|
||||
}
|
||||
}
|
||||
|
||||
$published += $dest
|
||||
Move-SkillPath -Source $stagedSkill -Destination $dest
|
||||
} catch {
|
||||
$transactionError = $_
|
||||
if (!(Restore-MultiSkillSet -Published $published -Backups $backups)) {
|
||||
Write-Say "⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径"
|
||||
}
|
||||
Write-Say "⚠️ mono Skill 集合发布失败,目标已回滚: $BaseDir ($transactionError)"
|
||||
return $false
|
||||
} finally {
|
||||
if (Test-Path $stageRoot) {
|
||||
Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
$fileCount = (Get-ChildItem -Path $dest -Recurse -File).Count
|
||||
Write-Say "✅ Skills → $Label ($fileCount files)"
|
||||
return $true
|
||||
}
|
||||
|
||||
function Install-SkillsToHomes {
|
||||
@@ -476,6 +778,8 @@ function Install-SkillsToHomes {
|
||||
)
|
||||
|
||||
$installed = 0
|
||||
$attempted = 0
|
||||
$failed = 0
|
||||
for ($i = 0; $i -lt $AgentDirs.Count; $i++) {
|
||||
$agentDir = $AgentDirs[$i]
|
||||
$baseDir = Join-Path $Root $agentDir
|
||||
@@ -483,28 +787,187 @@ function Install-SkillsToHomes {
|
||||
if ($i -gt 0 -and !(Test-Path $parentGate)) {
|
||||
continue
|
||||
}
|
||||
$dest = Join-Path $baseDir $SkillName
|
||||
$attempted++
|
||||
if ($Root -eq $HOME) {
|
||||
$label = "~\$agentDir\$SkillName"
|
||||
} else {
|
||||
$label = Join-Path $Root (Join-Path $agentDir $SkillName)
|
||||
}
|
||||
if ($installed -eq 0) {
|
||||
Copy-SkillToDir -SkillSrc $SkillSrc -Dest $dest -Label $label
|
||||
$copied = Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label $label
|
||||
if ($copied) {
|
||||
$installed++
|
||||
} else {
|
||||
Copy-SkillToDirSummary -SkillSrc $SkillSrc -Dest $dest -Label $label
|
||||
$failed++
|
||||
}
|
||||
$installed++
|
||||
}
|
||||
if ($installed -eq 0) {
|
||||
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)
|
||||
}
|
||||
Copy-SkillToDir -SkillSrc $SkillSrc -Dest $fallback -Label $flabel
|
||||
if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir (Split-Path $fallback -Parent) -Label $flabel) {
|
||||
$installed++
|
||||
} else {
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
if ($installed -eq 0) {
|
||||
Write-Say "⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"
|
||||
return $false
|
||||
}
|
||||
if ($failed -gt 0) {
|
||||
Write-Say "⚠️ 有 $failed 个 Agent 目标安装 mono Skill 失败"
|
||||
return $false
|
||||
}
|
||||
Remove-Item -LiteralPath (Join-Path $SkillStateRoot "skills-state.json") -Force -ErrorAction SilentlyContinue
|
||||
return $true
|
||||
}
|
||||
|
||||
# Test-MultiTreeHasSkills returns $true only when the multi bundle directory
|
||||
# contains at least one product skill (a subdirectory with a SKILL.md). An
|
||||
# empty or corrupt multi\ tree must never select the multi branch: installing
|
||||
# it would delete existing dws\ + dingtalk-* skills and lay down nothing.
|
||||
function Test-MultiTreeHasSkills {
|
||||
param([string]$MultiSrc)
|
||||
if (!(Test-Path $MultiSrc)) { return $false }
|
||||
foreach ($dir in Get-ChildItem -Path $MultiSrc -Directory -ErrorAction SilentlyContinue) {
|
||||
if (Test-Path (Join-Path $dir.FullName "SKILL.md")) { return $true }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
# Install the multi skill bundle (one subdirectory per product skill) into all
|
||||
# agent homes as sibling directories, mirroring `dws skill setup --mode multi`.
|
||||
# Mutual exclusion: the mono leftover (<home>\dws) and stale DWS-managed Skills
|
||||
# not present in the new bundle are removed first.
|
||||
function Install-MultiSkillsToHomes {
|
||||
param(
|
||||
[string]$MultiSrc,
|
||||
[string]$Root = $HOME
|
||||
)
|
||||
|
||||
$installed = 0
|
||||
$attempted = 0
|
||||
$failed = 0
|
||||
for ($i = 0; $i -lt $AgentDirs.Count; $i++) {
|
||||
$agentDir = $AgentDirs[$i]
|
||||
$baseDir = Join-Path $Root $agentDir
|
||||
$parentGate = Split-Path $baseDir -Parent
|
||||
if ($i -gt 0 -and !(Test-Path $parentGate)) {
|
||||
continue
|
||||
}
|
||||
$attempted++
|
||||
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agentDir) {
|
||||
$installed++
|
||||
} else {
|
||||
Write-Say "⚠️ 跳过 $baseDir(备份失败,未安装 multi)"
|
||||
$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 ($installed -eq 0) {
|
||||
Write-Say "⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"
|
||||
return $false
|
||||
}
|
||||
if ($failed -gt 0) {
|
||||
Write-Say "⚠️ 有 $failed 个 Agent 目标安装 multi Skill 失败"
|
||||
return $false
|
||||
}
|
||||
Write-SkillsState -MultiSrc $MultiSrc
|
||||
return $true
|
||||
}
|
||||
|
||||
function Install-MultiToBase {
|
||||
param(
|
||||
[string]$MultiSrc,
|
||||
[string]$BaseDir,
|
||||
[string]$Root,
|
||||
[string]$AgentDir
|
||||
)
|
||||
|
||||
if (!(Test-Path $BaseDir)) {
|
||||
New-Item -ItemType Directory -Path $BaseDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$skillDirs = @(Get-ChildItem -Path $MultiSrc -Directory | Where-Object {
|
||||
Test-Path (Join-Path $_.FullName "SKILL.md")
|
||||
})
|
||||
$stageRoot = Join-Path $BaseDir (".dws-multi-set-" + [guid]::NewGuid().ToString("N"))
|
||||
$backups = @()
|
||||
$published = @()
|
||||
try {
|
||||
# Stage the complete replacement before moving any Agent-visible
|
||||
# directory. Copy failures therefore leave the old set untouched.
|
||||
New-Item -ItemType Directory -Path $stageRoot -Force -ErrorAction Stop | Out-Null
|
||||
foreach ($skillDir in $skillDirs) {
|
||||
Copy-DirRecursive -Source $skillDir.FullName -Destination (Join-Path $stageRoot $skillDir.Name) | Out-Null
|
||||
}
|
||||
|
||||
$victims = [System.Collections.Generic.List[string]]::new()
|
||||
$victims.Add((Join-Path $BaseDir $SkillName))
|
||||
|
||||
# Include stale, proven DWS-managed skills in the same transaction.
|
||||
foreach ($existing in Get-ChildItem -Path $BaseDir -Directory -ErrorAction SilentlyContinue) {
|
||||
if ($existing.FullName -eq $stageRoot) { continue }
|
||||
if ((Test-ManagedMultiSkillDir -Dir $existing.FullName) -and
|
||||
!(Test-Path (Join-Path (Join-Path $MultiSrc $existing.Name) "SKILL.md"))) {
|
||||
$victims.Add($existing.FullName)
|
||||
}
|
||||
}
|
||||
if (!(Test-Path (Join-Path (Join-Path $MultiSrc "dws-shared") "SKILL.md"))) {
|
||||
$victims.Add((Join-Path $BaseDir "dws-shared"))
|
||||
}
|
||||
foreach ($skillDir in $skillDirs) {
|
||||
$victims.Add((Join-Path $BaseDir $skillDir.Name))
|
||||
}
|
||||
|
||||
$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($victim in $victims) {
|
||||
if (!$seen.Add($victim)) { continue }
|
||||
$backupPath = ""
|
||||
if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) {
|
||||
throw "Skill 备份失败: $victim"
|
||||
}
|
||||
if ($backupPath) {
|
||||
$backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath }
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($skillDir in $skillDirs) {
|
||||
$dest = Join-Path $BaseDir $skillDir.Name
|
||||
$published += $dest
|
||||
Move-SkillPath -Source (Join-Path $stageRoot $skillDir.Name) -Destination $dest
|
||||
}
|
||||
} catch {
|
||||
$transactionError = $_
|
||||
if (!(Restore-MultiSkillSet -Published $published -Backups $backups)) {
|
||||
Write-Say "⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径"
|
||||
}
|
||||
Write-Say "⚠️ multi Skill 集合发布失败,目标已回滚: $BaseDir ($transactionError)"
|
||||
return $false
|
||||
} finally {
|
||||
if (Test-Path $stageRoot) {
|
||||
Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
$count = $skillDirs.Count
|
||||
|
||||
if ($Root -eq $HOME) {
|
||||
$label = "~\$AgentDir\"
|
||||
} else {
|
||||
$label = Join-Path $Root $AgentDir
|
||||
}
|
||||
Write-Say "✅ Skills → $label ($count product skills)"
|
||||
return $true
|
||||
}
|
||||
|
||||
# ── Install Binary from Source ───────────────────────────────────────────────
|
||||
@@ -575,22 +1038,37 @@ function Install-Skills {
|
||||
$skillSrc = Join-Path $extractRoot $SkillName
|
||||
}
|
||||
|
||||
if (!(Test-Path (Join-Path $skillSrc "SKILL.md"))) {
|
||||
Write-Say "⚠️ Skills not found in release asset. Trying local source..."
|
||||
$localRoot = Resolve-SourceRoot
|
||||
if ($localRoot) {
|
||||
Install-SkillsLocal -Root $localRoot
|
||||
# Multi first: a release may ship only the multi\ tree without the
|
||||
# root mono copy, so the mono SKILL.md gate must never block a multi
|
||||
# install. An empty/corrupt multi\ tree (no *\SKILL.md) falls back to
|
||||
# mono with a warning — installing it would wipe existing skills and
|
||||
# lay down nothing.
|
||||
$multiRoot = Join-Path $extractRoot "multi"
|
||||
if ($SkillMode -eq "multi" -and (Test-MultiTreeHasSkills $multiRoot)) {
|
||||
if (!(Install-MultiSkillsToHomes -MultiSrc $multiRoot -Root $HOME)) {
|
||||
throw "multi Skill installation failed"
|
||||
}
|
||||
} else {
|
||||
if ($SkillMode -eq "multi") {
|
||||
Write-Say "⚠️ multi skill tree not found or empty in release asset; falling back to mono."
|
||||
}
|
||||
if (!(Test-Path (Join-Path $skillSrc "SKILL.md"))) {
|
||||
Write-Say "⚠️ Skills not found in release asset. Trying local source..."
|
||||
$localRoot = Resolve-SourceRoot
|
||||
if ($localRoot) {
|
||||
Install-SkillsLocal -Root $localRoot
|
||||
return
|
||||
}
|
||||
Write-Say "⚠️ No local source found either. Skipping skills installation."
|
||||
return
|
||||
}
|
||||
Write-Say "⚠️ No local source found either. Skipping skills installation."
|
||||
return
|
||||
if (!(Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME)) {
|
||||
throw "mono Skill installation failed"
|
||||
}
|
||||
}
|
||||
|
||||
Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME
|
||||
|
||||
# Cache the multi/ tree (and a mono copy) under ~/.dws/skills so that
|
||||
# subsequent `dws skill setup --mode multi|mono` can find a source.
|
||||
$multiRoot = Join-Path $extractRoot "multi"
|
||||
if (Test-Path $multiRoot) {
|
||||
Cache-MultiSkills -Source $multiRoot
|
||||
}
|
||||
@@ -617,27 +1095,15 @@ if (!$NoSkills) {
|
||||
if ($SourceRoot -and !$SkillsOnly -and ($Version -eq "latest")) {
|
||||
Install-BinaryFromSource -Root $SourceRoot
|
||||
if (!$NoSkills) {
|
||||
if ($SkillMode -eq "multi") {
|
||||
Write-MultiModeNotice
|
||||
} else {
|
||||
Install-SkillsLocal -Root $SourceRoot
|
||||
}
|
||||
Install-SkillsLocal -Root $SourceRoot
|
||||
}
|
||||
} elseif ($SkillsOnly) {
|
||||
if ($SkillMode -eq "multi") {
|
||||
Write-MultiModeNotice
|
||||
} else {
|
||||
Install-Skills
|
||||
}
|
||||
Install-Skills
|
||||
} elseif ($NoSkills) {
|
||||
Install-Binary
|
||||
} else {
|
||||
Install-Binary
|
||||
if ($SkillMode -eq "multi") {
|
||||
Write-MultiModeNotice
|
||||
} else {
|
||||
Install-Skills
|
||||
}
|
||||
Install-Skills
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
|
||||
+558
-79
@@ -14,7 +14,7 @@
|
||||
# DWS_VERSION — version to install (default: latest)
|
||||
# DWS_NO_SKILLS — set to 1 to skip skills install
|
||||
# DWS_SKILLS_ONLY — set to 1 to install only skills (skip binary)
|
||||
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else mono)
|
||||
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else multi)
|
||||
# DWS_GITEE_REPO — "owner/repo" on Gitee; when set, version + assets resolve
|
||||
# via the Gitee API instead of GitHub (China mirror)
|
||||
#
|
||||
@@ -38,8 +38,10 @@ INSTALL_NAME="${DWS_INSTALL_NAME:-$BIN_NAME}"
|
||||
VERSION="${DWS_VERSION:-latest}"
|
||||
NO_SKILLS="${DWS_NO_SKILLS:-0}"
|
||||
SKILLS_ONLY="${DWS_SKILLS_ONLY:-0}"
|
||||
SKILL_STATE_ROOT="${DWS_CONFIG_DIR:-$HOME/.dws}"
|
||||
SKILL_NAME="dws"
|
||||
SKILL_MODE=""
|
||||
MANAGED_SKILL_DIGEST_SCOPE="skill-directory-v1"
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -59,6 +61,234 @@ need_cmd() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# backup_and_remove_skill_dir <dir>
|
||||
# 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 backup failure
|
||||
# the directory is left in place and a non-zero status is returned so callers
|
||||
# skip that target rather than silently deleting data.
|
||||
DWS_LAST_SKILL_BACKUP=""
|
||||
backup_and_remove_skill_dir() {
|
||||
_bed_dir="$1"
|
||||
DWS_LAST_SKILL_BACKUP=""
|
||||
[ -d "$_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
|
||||
_bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name"
|
||||
_bed_i=$((_bed_i + 1))
|
||||
if [ "$_bed_i" -gt 1000 ]; then
|
||||
say " ⚠️ 备份目录冲突,保留原目录 $_bed_dir"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
mkdir -p "$(dirname "$_bed_target")" 2>/dev/null || {
|
||||
say " ⚠️ 无法创建备份目录,保留原目录 $_bed_dir"
|
||||
return 1
|
||||
}
|
||||
if mv "$_bed_dir" "$_bed_target" 2>/dev/null; then
|
||||
DWS_LAST_SKILL_BACKUP="$_bed_target"
|
||||
say " × 已备份并移除 $_bed_dir → $_bed_target"
|
||||
return 0
|
||||
fi
|
||||
say " ⚠️ 备份失败,保留原目录 $_bed_dir"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 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() {
|
||||
_managed_dir="$1"
|
||||
_managed_name="$(basename "$_managed_dir")"
|
||||
is_legacy_official_multi_skill_name "$_managed_name" && return 0
|
||||
[ -f "$SKILL_STATE_ROOT/skills-state.json" ] || return 1
|
||||
_managed_json_name="$(json_escape "$_managed_name")"
|
||||
_managed_compact='"name":"'"$_managed_json_name"'"'
|
||||
_managed_spaced='"name": "'"$_managed_json_name"'"'
|
||||
DWS_MANAGED_COMPACT="$_managed_compact" DWS_MANAGED_SPACED="$_managed_spaced" awk '
|
||||
/^[[:space:]]*"managed_skills"[[:space:]]*:[[:space:]]*\[[[:space:]]*$/ { inside = 1; next }
|
||||
inside && /^[[:space:]]*\][[:space:]]*,?[[:space:]]*$/ { closed = 1; exit }
|
||||
inside && (index($0, ENVIRON["DWS_MANAGED_COMPACT"]) || index($0, ENVIRON["DWS_MANAGED_SPACED"])) { found = 1 }
|
||||
END { exit !(closed && found) }
|
||||
' "$SKILL_STATE_ROOT/skills-state.json"
|
||||
}
|
||||
|
||||
# Frozen exact names shipped before centralized ownership metadata. Never replace this
|
||||
# with a dingtalk-* prefix check: user/market Skills may use that prefix.
|
||||
is_legacy_official_multi_skill_name() {
|
||||
case "$1" in
|
||||
dingtalk-agoal|dingtalk-aiapp|dingtalk-aisearch|dingtalk-aitable|dingtalk-attendance|dingtalk-calendar|dingtalk-chat|dingtalk-contact|dingtalk-dev|dingtalk-devapp|dingtalk-devdoc|dingtalk-ding|dingtalk-doc|dingtalk-drive|dingtalk-event|dingtalk-hrbrain|dingtalk-live|dingtalk-mail|dingtalk-markdown|dingtalk-minutes|dingtalk-misc|dingtalk-oa|dingtalk-pat|dingtalk-profile|dingtalk-report|dingtalk-shared|dingtalk-sheet|dingtalk-skill|dingtalk-todo|dingtalk-wiki|dws-shared) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
sha256_stdin() {
|
||||
if need_cmd sha256sum; then
|
||||
sha256sum | awk '{print $1}'
|
||||
elif need_cmd shasum; then
|
||||
shasum -a 256 | awk '{print $1}'
|
||||
elif need_cmd openssl; then
|
||||
openssl dgst -sha256 | awk '{print $NF}'
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
digest_skill_dir() {
|
||||
_digest_dir="$1"
|
||||
_digest="$({
|
||||
find "$_digest_dir" -type f -print | LC_ALL=C sort | while IFS= read -r _digest_file; do
|
||||
_digest_rel="${_digest_file#"$_digest_dir"/}"
|
||||
printf '%s\0' "$_digest_rel"
|
||||
cat "$_digest_file"
|
||||
printf '\0'
|
||||
done
|
||||
} | sha256_stdin)" || return 1
|
||||
printf 'sha256:%s' "$_digest"
|
||||
}
|
||||
|
||||
write_skills_state() {
|
||||
_state_multi="$1"
|
||||
_state_source="$2"
|
||||
_state_root="$SKILL_STATE_ROOT"
|
||||
mkdir -p "$_state_root" || return 1
|
||||
_state_tmp="$(mktemp "$_state_root/.skills-state.XXXXXX")" || return 1
|
||||
_state_version="$(json_escape "$VERSION")"
|
||||
_state_names=""
|
||||
for _state_dir in "$_state_multi"/*/; do
|
||||
[ -f "${_state_dir}SKILL.md" ] || continue
|
||||
_state_name="$(basename "$_state_dir")"
|
||||
_state_names="${_state_names}${_state_name}\n"
|
||||
done
|
||||
{
|
||||
printf '{\n "version": "%s",\n' "$_state_version"
|
||||
printf ' "official_skills": ['
|
||||
_state_first=1
|
||||
printf '%b' "$_state_names" | LC_ALL=C sort | while IFS= read -r _state_name; do
|
||||
[ -n "$_state_name" ] || continue
|
||||
[ "$_state_first" -eq 1 ] || printf ', '
|
||||
printf '"%s"' "$(json_escape "$_state_name")"
|
||||
_state_first=0
|
||||
done
|
||||
printf '],\n "updated_skills": ['
|
||||
_state_first=1
|
||||
printf '%b' "$_state_names" | LC_ALL=C sort | while IFS= read -r _state_name; do
|
||||
[ -n "$_state_name" ] || continue
|
||||
[ "$_state_first" -eq 1 ] || printf ', '
|
||||
printf '"%s"' "$(json_escape "$_state_name")"
|
||||
_state_first=0
|
||||
done
|
||||
printf '],\n "managed_skills": [\n'
|
||||
_state_first=1
|
||||
printf '%b' "$_state_names" | LC_ALL=C sort | while IFS= read -r _state_name; do
|
||||
[ -n "$_state_name" ] || continue
|
||||
_state_digest="$(digest_skill_dir "$_state_multi/$_state_name")" || exit 1
|
||||
[ "$_state_first" -eq 1 ] || printf ',\n'
|
||||
printf ' {"name":"%s","version":"%s","source":"%s","digest":"%s","digest_scope":"%s"}' "$(json_escape "$_state_name")" "$_state_version" "$_state_source" "$_state_digest" "$MANAGED_SKILL_DIGEST_SCOPE"
|
||||
_state_first=0
|
||||
done
|
||||
printf '\n ],\n "updated_at": "%s"\n}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
} > "$_state_tmp" || { rm -f "$_state_tmp"; return 1; }
|
||||
mv "$_state_tmp" "$_state_root/skills-state.json"
|
||||
}
|
||||
|
||||
# backup_and_record_skill_dir <victim> <manifest>
|
||||
# Records exact original/backup pairs so a multi-set transaction can restore
|
||||
# earlier moves when any later backup or publication fails.
|
||||
backup_and_record_skill_dir() {
|
||||
_bars_victim="$1"
|
||||
_bars_manifest="$2"
|
||||
backup_and_remove_skill_dir "$_bars_victim" || return 1
|
||||
if [ -n "$DWS_LAST_SKILL_BACKUP" ]; then
|
||||
if ! printf '%s\n%s\n' "$_bars_victim" "$DWS_LAST_SKILL_BACKUP" >> "$_bars_manifest"; then
|
||||
mv "$DWS_LAST_SKILL_BACKUP" "$_bars_victim" 2>/dev/null || say " ⚠️ 备份记录失败且无法自动恢复: $_bars_victim(备份位于 $DWS_LAST_SKILL_BACKUP)"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# restore_multi_skill_set <published-manifest> <backup-manifest>
|
||||
# Removes partial new publications, then restores every old directory from
|
||||
# its exact backup path. Paths containing newlines are outside the supported
|
||||
# installer path contract; spaces are preserved.
|
||||
restore_multi_skill_set() {
|
||||
_rms_published="$1"
|
||||
_rms_backups="$2"
|
||||
_rms_ok=1
|
||||
if [ -f "$_rms_published" ]; then
|
||||
while IFS= read -r _rms_dest; do
|
||||
[ -n "$_rms_dest" ] || continue
|
||||
rm -rf "$_rms_dest" || _rms_ok=0
|
||||
done < "$_rms_published"
|
||||
fi
|
||||
if [ -f "$_rms_backups" ]; then
|
||||
while IFS= read -r _rms_original && IFS= read -r _rms_backup; do
|
||||
[ -n "$_rms_backup" ] || continue
|
||||
if [ -e "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then
|
||||
say " ⚠️ 无法恢复原 Skill: $_rms_original(备份保留于 $_rms_backup)"
|
||||
_rms_ok=0
|
||||
fi
|
||||
done < "$_rms_backups"
|
||||
fi
|
||||
[ "$_rms_ok" -eq 1 ]
|
||||
}
|
||||
|
||||
# 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
|
||||
# itself fails, the recovery directory is reported and left untouched.
|
||||
publish_skill_cache() {
|
||||
_psc_src="$1"
|
||||
_psc_cache="$2"
|
||||
_psc_parent="$(dirname "$_psc_cache")"
|
||||
_psc_name="$(basename "$_psc_cache")"
|
||||
_psc_stage=""
|
||||
_psc_old=""
|
||||
|
||||
mkdir -p "$_psc_parent" || return 1
|
||||
_psc_stage="$(mktemp -d "$_psc_parent/.${_psc_name}.tmp.XXXXXX")" || return 1
|
||||
if ! cp -R "$_psc_src/." "$_psc_stage/" 2>/dev/null && \
|
||||
! cp -r "$_psc_src/." "$_psc_stage/" 2>/dev/null; then
|
||||
rm -rf "$_psc_stage"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -e "$_psc_cache" ]; then
|
||||
_psc_old="$(mktemp -d "$_psc_parent/.${_psc_name}.old.XXXXXX")" || {
|
||||
rm -rf "$_psc_stage"
|
||||
return 1
|
||||
}
|
||||
rmdir "$_psc_old" || {
|
||||
rm -rf "$_psc_stage" "$_psc_old"
|
||||
return 1
|
||||
}
|
||||
if ! mv "$_psc_cache" "$_psc_old"; then
|
||||
rm -rf "$_psc_stage"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if mv "$_psc_stage" "$_psc_cache"; then
|
||||
if [ -n "$_psc_old" ] && ! rm -rf "$_psc_old"; then
|
||||
say " ⚠️ 新 Skill 缓存已生效,但旧缓存清理失败: $_psc_old"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
rm -rf "$_psc_stage"
|
||||
if [ -n "$_psc_old" ] && ! mv "$_psc_old" "$_psc_cache"; then
|
||||
say " ⚠️ Skill 缓存发布失败,原缓存保留在 $_psc_old"
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_source_root() {
|
||||
script_path="$0"
|
||||
if [ ! -f "$script_path" ]; then
|
||||
@@ -215,8 +445,8 @@ print_banner() {
|
||||
#
|
||||
# Priority (highest first):
|
||||
# 1. DWS_SKILL_MODE env var (mono | multi, case-insensitive)
|
||||
# 2. Interactive prompt when both stdin and stdout are TTYs (default: mono)
|
||||
# 3. Fallback: mono (non-TTY without env var, e.g. curl | sh)
|
||||
# 2. Interactive prompt when both stdin and stdout are TTYs (default: multi)
|
||||
# 3. Fallback: multi (non-TTY without env var, e.g. curl | sh)
|
||||
resolve_skill_mode() {
|
||||
if [ -n "${DWS_SKILL_MODE:-}" ]; then
|
||||
raw="$DWS_SKILL_MODE"
|
||||
@@ -237,31 +467,23 @@ resolve_skill_mode() {
|
||||
if [ -t 0 ] && [ -t 1 ]; then
|
||||
printf '\n'
|
||||
say "Select skill installation mode:"
|
||||
say " 1) mono — install one bundled dws skill (stable / recommended)"
|
||||
say " 2) multi — split each product into its own skill (run 'dws skill setup --mode multi' afterwards)"
|
||||
say " 1) multi (default) — split each product into its own skill (dingtalk-*)"
|
||||
say " 2) mono — install one bundled dws skill (legacy)"
|
||||
printf ' Choice [1]: '
|
||||
read choice || choice=""
|
||||
case "$choice" in
|
||||
""|1|mono) SKILL_MODE="mono" ;;
|
||||
2|multi) SKILL_MODE="multi" ;;
|
||||
""|1|multi) SKILL_MODE="multi" ;;
|
||||
2|mono) SKILL_MODE="mono" ;;
|
||||
*)
|
||||
say "Unrecognized choice '${choice}', defaulting to mono."
|
||||
SKILL_MODE="mono"
|
||||
say "Unrecognized choice '${choice}', defaulting to multi."
|
||||
SKILL_MODE="multi"
|
||||
;;
|
||||
esac
|
||||
say "Skill mode: ${SKILL_MODE}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
SKILL_MODE="mono"
|
||||
}
|
||||
|
||||
print_multi_mode_notice() {
|
||||
say ""
|
||||
say "Skill mode: multi — automatic skill install skipped."
|
||||
say " To install split skills, run:"
|
||||
say " ${BIN_NAME} skill setup --mode multi"
|
||||
say " (One skill per product family; requires the dws binary installed above.)"
|
||||
SKILL_MODE="multi"
|
||||
}
|
||||
|
||||
install_binary_from_source() {
|
||||
@@ -296,17 +518,26 @@ install_skills_local() {
|
||||
skill_src="${root}/skills/mono"
|
||||
multi_src="${root}/skills/multi"
|
||||
|
||||
if [ ! -d "$skill_src" ]; then
|
||||
say "⚠️ Local skills directory not found: ${skill_src}"
|
||||
say " Skipping skills installation."
|
||||
return 1
|
||||
if [ "$SKILL_MODE" = "multi" ] && multi_tree_has_skills "$multi_src"; then
|
||||
say ""
|
||||
say "📦 Installing agent skills (multi) from local source: ${multi_src}"
|
||||
install_multi_skills_to_homes "$multi_src"
|
||||
else
|
||||
if [ "$SKILL_MODE" = "multi" ]; then
|
||||
say "⚠️ Multi skill tree not found or empty at ${multi_src}; falling back to mono."
|
||||
fi
|
||||
if [ ! -d "$skill_src" ]; then
|
||||
say "⚠️ Local skills directory not found: ${skill_src}"
|
||||
say " Skipping skills installation."
|
||||
return 1
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "📦 Installing agent skills from local source: ${skill_src}"
|
||||
|
||||
install_skills_to_homes "$skill_src"
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "📦 Installing agent skills from local source: ${skill_src}"
|
||||
|
||||
install_skills_to_homes "$skill_src"
|
||||
|
||||
# Cache multi source for later `dws skill setup --mode multi`.
|
||||
if [ -d "$multi_src" ]; then
|
||||
cache_multi_skills "$multi_src"
|
||||
@@ -326,14 +557,14 @@ cache_multi_skills() {
|
||||
src="$1"
|
||||
cache_dir="${HOME}/.dws/skills/multi"
|
||||
|
||||
if [ ! -d "$src" ]; then
|
||||
# Never let an empty/corrupt multi/ tree wipe a previously good cache.
|
||||
multi_tree_has_skills "$src" || return 0
|
||||
|
||||
if ! publish_skill_cache "$src" "$cache_dir"; then
|
||||
say "⚠️ Multi Skill 缓存刷新失败,未覆盖原缓存: ${cache_dir}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
rm -rf "$cache_dir"
|
||||
mkdir -p "$cache_dir"
|
||||
cp -R "$src/"* "$cache_dir/" 2>/dev/null || cp -r "$src/"* "$cache_dir/" 2>/dev/null || true
|
||||
|
||||
file_count="$(find "$cache_dir" -type f | wc -l | tr -d ' ')"
|
||||
case "$cache_dir" in
|
||||
"$HOME"/*) label="~/${cache_dir#$HOME/}" ;;
|
||||
@@ -349,20 +580,78 @@ cache_mono_skills() {
|
||||
src="$1"
|
||||
cache_dir="${HOME}/.dws/skills/mono"
|
||||
|
||||
if [ ! -d "$src" ]; then
|
||||
# Only refresh when the new bundle actually carries a mono tree — a
|
||||
# multi-only bundle must never wipe a previously good mono cache.
|
||||
if [ ! -f "$src/SKILL.md" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
rm -rf "$cache_dir"
|
||||
mkdir -p "$cache_dir"
|
||||
cp -R "$src/"* "$cache_dir/" 2>/dev/null || cp -r "$src/"* "$cache_dir/" 2>/dev/null || true
|
||||
if ! publish_skill_cache "$src" "$cache_dir"; then
|
||||
say "⚠️ Mono Skill 缓存刷新失败,未覆盖原缓存: ${cache_dir}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Publish mono and all mutually-exclusive managed multi directories as one
|
||||
# transaction. The complete dws/ tree is staged before any visible directory
|
||||
# moves; any later backup or publish failure restores the exact old set.
|
||||
_install_mono_to_base() {
|
||||
_mono_src="$1"
|
||||
_mono_base="$2"
|
||||
_mono_label="$3"
|
||||
|
||||
mkdir -p "$_mono_base" || return 1
|
||||
_mono_stage="$(mktemp -d "$_mono_base/.dws-mono-set.XXXXXX")" || return 1
|
||||
_mono_backups="$_mono_stage/.backups"
|
||||
_mono_published="$_mono_stage/.published"
|
||||
: > "$_mono_backups" || { rm -rf "$_mono_stage"; return 1; }
|
||||
: > "$_mono_published" || { rm -rf "$_mono_stage"; return 1; }
|
||||
mkdir -p "$_mono_stage/$SKILL_NAME" || { rm -rf "$_mono_stage"; return 1; }
|
||||
if ! cp -R "$_mono_src/." "$_mono_stage/$SKILL_NAME/" 2>/dev/null && ! cp -r "$_mono_src/." "$_mono_stage/$SKILL_NAME/"; then
|
||||
rm -rf "$_mono_stage"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! backup_and_record_skill_dir "$_mono_base/$SKILL_NAME" "$_mono_backups"; then
|
||||
restore_multi_skill_set "$_mono_published" "$_mono_backups" || true
|
||||
rm -rf "$_mono_stage"
|
||||
return 1
|
||||
fi
|
||||
for existing in "$_mono_base"/*/; do
|
||||
[ -d "$existing" ] || continue
|
||||
is_managed_multi_skill_dir "$existing" || continue
|
||||
if ! backup_and_record_skill_dir "$existing" "$_mono_backups"; then
|
||||
restore_multi_skill_set "$_mono_published" "$_mono_backups" || true
|
||||
rm -rf "$_mono_stage"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
_mono_dest="$_mono_base/$SKILL_NAME"
|
||||
printf '%s\n' "$_mono_dest" >> "$_mono_published" || {
|
||||
restore_multi_skill_set "$_mono_published" "$_mono_backups" || true
|
||||
rm -rf "$_mono_stage"
|
||||
return 1
|
||||
}
|
||||
if ! mv "$_mono_stage/$SKILL_NAME" "$_mono_dest"; then
|
||||
say " ⚠️ mono Skill 集合发布失败,正在恢复原集合: $_mono_dest"
|
||||
restore_multi_skill_set "$_mono_published" "$_mono_backups" || say " ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径"
|
||||
rm -rf "$_mono_stage"
|
||||
return 1
|
||||
fi
|
||||
rm -rf "$_mono_stage" || return 1
|
||||
_mono_count="$(find "$_mono_dest" -type f | wc -l | tr -d ' ')"
|
||||
say "✅ Skills → ${_mono_label} (${_mono_count} files)"
|
||||
}
|
||||
|
||||
# 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`.
|
||||
install_skills_to_homes() {
|
||||
skill_src="$1"
|
||||
root="${HOME}"
|
||||
installed=0
|
||||
attempted=0
|
||||
failed=0
|
||||
idx=0
|
||||
for agent_dir in \
|
||||
".agents/skills" \
|
||||
@@ -388,7 +677,7 @@ install_skills_to_homes() {
|
||||
idx=$((idx + 1))
|
||||
continue
|
||||
fi
|
||||
dest="$base_dir/$SKILL_NAME"
|
||||
attempted=$((attempted + 1))
|
||||
case "$root" in
|
||||
"$HOME")
|
||||
label="~/$agent_dir/$SKILL_NAME"
|
||||
@@ -397,15 +686,14 @@ install_skills_to_homes() {
|
||||
label="$root/$agent_dir/$SKILL_NAME"
|
||||
;;
|
||||
esac
|
||||
if [ "$installed" -eq 0 ]; then
|
||||
_copy_skill "$skill_src" "$dest" "$label"
|
||||
if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then
|
||||
installed=$((installed + 1))
|
||||
else
|
||||
_copy_skill_summary "$skill_src" "$dest" "$label"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
installed=$((installed + 1))
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
if [ "$installed" -eq 0 ]; then
|
||||
if [ "$attempted" -eq 0 ]; then
|
||||
case "$root" in
|
||||
"$HOME")
|
||||
flabel="~/.agents/skills/$SKILL_NAME"
|
||||
@@ -414,8 +702,189 @@ install_skills_to_homes() {
|
||||
flabel="$root/.agents/skills/$SKILL_NAME"
|
||||
;;
|
||||
esac
|
||||
_copy_skill "$skill_src" "$root/.agents/skills/$SKILL_NAME" "$flabel"
|
||||
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 [ "$failed" -gt 0 ]; then
|
||||
say " ⚠️ 有 ${failed} 个 Agent 目标安装 mono Skill 失败"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$SKILL_STATE_ROOT/skills-state.json"
|
||||
}
|
||||
|
||||
# multi_tree_has_skills returns 0 only when the given multi bundle directory
|
||||
# contains at least one product skill (a subdirectory with a SKILL.md). An
|
||||
# empty or corrupt multi/ tree must never select the multi branch: installing
|
||||
# it would delete existing dws/ + dingtalk-* skills and lay down nothing.
|
||||
# (Go bundleSkillNames and install.js multiTreeHasSkills guard the same way.)
|
||||
multi_tree_has_skills() {
|
||||
_dir="$1"
|
||||
[ -d "$_dir" ] || return 1
|
||||
for _sub in "$_dir"/*/; do
|
||||
if [ -f "${_sub}SKILL.md" ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Install the multi skill bundle (one subdirectory per product skill) into all
|
||||
# agent homes as sibling directories, mirroring `dws skill setup --mode multi`.
|
||||
# Mutual exclusion: the mono leftover (<home>/dws) and stale DWS-managed Skills
|
||||
# not present in the new bundle are removed first.
|
||||
install_multi_skills_to_homes() {
|
||||
multi_src="$1"
|
||||
root="${HOME}"
|
||||
installed=0
|
||||
attempted=0
|
||||
failed=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"
|
||||
do
|
||||
base_dir="$root/$agent_dir"
|
||||
parent_gate="$(dirname "$base_dir")"
|
||||
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
|
||||
idx=$((idx + 1))
|
||||
continue
|
||||
fi
|
||||
attempted=$((attempted + 1))
|
||||
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
|
||||
installed=$((installed + 1))
|
||||
else
|
||||
failed=$((failed + 1))
|
||||
say " ⚠️ 跳过 ${base_dir}(备份或复制失败,未完成 multi 安装)"
|
||||
fi
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
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 [ "$failed" -gt 0 ]; then
|
||||
say " ⚠️ 有 ${failed} 个 Agent 目标安装失败"
|
||||
return 1
|
||||
fi
|
||||
write_skills_state "$multi_src" "install.sh" || return 1
|
||||
}
|
||||
|
||||
_install_multi_to_base() {
|
||||
_msrc="$1"
|
||||
_base="$2"
|
||||
_root="$3"
|
||||
_agent_dir="$4"
|
||||
|
||||
mkdir -p "$_base" || return 1
|
||||
|
||||
# Build the complete replacement set before moving any Agent-visible
|
||||
# directory. The manifests remain inside the private staging directory.
|
||||
_ms_stage="$(mktemp -d "$_base/.dws-multi-set.XXXXXX")" || return 1
|
||||
_ms_backups="$_ms_stage/.backups"
|
||||
_ms_published="$_ms_stage/.published"
|
||||
: > "$_ms_backups" || { rm -rf "$_ms_stage"; return 1; }
|
||||
: > "$_ms_published" || { rm -rf "$_ms_stage"; return 1; }
|
||||
for skill_dir in "$_msrc"/*/; do
|
||||
[ -f "${skill_dir}SKILL.md" ] || continue
|
||||
_name="$(basename "$skill_dir")"
|
||||
_ms_staged_skill="$_ms_stage/$_name"
|
||||
mkdir -p "$_ms_staged_skill" || { rm -rf "$_ms_stage"; return 1; }
|
||||
if ! cp -R "$skill_dir/." "$_ms_staged_skill/" 2>/dev/null && ! cp -r "$skill_dir/." "$_ms_staged_skill/"; then
|
||||
rm -rf "$_ms_stage"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Mutual exclusion: back up + remove the mono leftover.
|
||||
if ! backup_and_record_skill_dir "$_base/$SKILL_NAME" "$_ms_backups"; then
|
||||
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
|
||||
rm -rf "$_ms_stage"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Back up + remove stale, proven DWS-managed skills not in the new bundle.
|
||||
# Never infer ownership from the dingtalk-* prefix alone.
|
||||
for existing in "$_base"/*/; do
|
||||
[ -d "$existing" ] || continue
|
||||
_name="$(basename "$existing")"
|
||||
if is_managed_multi_skill_dir "$existing" && [ ! -f "$_msrc/$_name/SKILL.md" ]; then
|
||||
if ! backup_and_record_skill_dir "$existing" "$_ms_backups"; then
|
||||
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
|
||||
rm -rf "$_ms_stage"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ -d "$_base/dws-shared" ] && [ ! -f "$_msrc/dws-shared/SKILL.md" ]; then
|
||||
if ! backup_and_record_skill_dir "$_base/dws-shared" "$_ms_backups"; then
|
||||
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
|
||||
rm -rf "$_ms_stage"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Back up all replaced skills as one logical operation. Any failure restores
|
||||
# every earlier move before this target reports failure.
|
||||
for skill_dir in "$_msrc"/*/; do
|
||||
[ -f "${skill_dir}SKILL.md" ] || continue
|
||||
_name="$(basename "$skill_dir")"
|
||||
_dest="$_base/$_name"
|
||||
if ! backup_and_record_skill_dir "$_dest" "$_ms_backups"; then
|
||||
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
|
||||
rm -rf "$_ms_stage"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
_count=0
|
||||
for skill_dir in "$_msrc"/*/; do
|
||||
[ -f "${skill_dir}SKILL.md" ] || continue
|
||||
_name="$(basename "$skill_dir")"
|
||||
_dest="$_base/$_name"
|
||||
printf '%s\n' "$_dest" >> "$_ms_published" || {
|
||||
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
|
||||
rm -rf "$_ms_stage"
|
||||
return 1
|
||||
}
|
||||
if ! mv "$_ms_stage/$_name" "$_dest"; then
|
||||
say " ⚠️ multi Skill 集合发布失败,正在恢复原集合: $_dest"
|
||||
restore_multi_skill_set "$_ms_published" "$_ms_backups" || say " ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径"
|
||||
rm -rf "$_ms_stage"
|
||||
return 1
|
||||
fi
|
||||
_count=$((_count + 1))
|
||||
done
|
||||
rm -rf "$_ms_stage" || return 1
|
||||
|
||||
case "$_root" in
|
||||
"$HOME") _label="~/$_agent_dir/" ;;
|
||||
*) _label="$_root/$_agent_dir/" ;;
|
||||
esac
|
||||
say "✅ Skills → ${_label} (${_count} product skills)"
|
||||
}
|
||||
|
||||
# One-line summary copy (used for 2nd+ agent targets).
|
||||
@@ -425,11 +894,17 @@ _copy_skill_summary() {
|
||||
_label="$3"
|
||||
|
||||
if [ -d "$_dest" ]; then
|
||||
rm -rf "$_dest"
|
||||
backup_and_remove_skill_dir "$_dest" || {
|
||||
say " ⚠️ 跳过 ${_dest}(保留原目录)"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
mkdir -p "$_dest"
|
||||
cp -R "$_src/"* "$_dest/" 2>/dev/null || cp -r "$_src/"* "$_dest/"
|
||||
mkdir -p "$_dest" || return 1
|
||||
if ! cp -R "$_src/"* "$_dest/" 2>/dev/null && ! cp -r "$_src/"* "$_dest/"; then
|
||||
say " ⚠️ Skill 复制失败,目标未计为安装成功: $_dest"
|
||||
return 1
|
||||
fi
|
||||
file_count="$(find "$_dest" -type f | wc -l | tr -d ' ')"
|
||||
|
||||
say "✅ Skills → ${_label} (${file_count} files)"
|
||||
@@ -442,11 +917,17 @@ _copy_skill() {
|
||||
_label="$3"
|
||||
|
||||
if [ -d "$_dest" ]; then
|
||||
rm -rf "$_dest"
|
||||
backup_and_remove_skill_dir "$_dest" || {
|
||||
say " ⚠️ 跳过 ${_dest}(保留原目录)"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
mkdir -p "$_dest"
|
||||
cp -R "$_src/"* "$_dest/" 2>/dev/null || cp -r "$_src/"* "$_dest/"
|
||||
mkdir -p "$_dest" || return 1
|
||||
if ! cp -R "$_src/"* "$_dest/" 2>/dev/null && ! cp -r "$_src/"* "$_dest/"; then
|
||||
say " ⚠️ Skill 复制失败,目标未计为安装成功: $_dest"
|
||||
return 1
|
||||
fi
|
||||
file_count="$(find "$_dest" -type f | wc -l | tr -d ' ')"
|
||||
|
||||
say "✅ Skills → ${_label} (${file_count} files)"
|
||||
@@ -593,21 +1074,31 @@ install_skills() {
|
||||
elif [ -f "$extract_root/$SKILL_NAME/SKILL.md" ]; then
|
||||
skill_src="$extract_root/$SKILL_NAME"
|
||||
fi
|
||||
if [ ! -f "$skill_src/SKILL.md" ]; then
|
||||
say "⚠️ Skills not found in release asset. Trying local source..."
|
||||
rm -rf "$tmpdir_skills"
|
||||
local_root="$(resolve_source_root || true)"
|
||||
if [ -n "$local_root" ]; then
|
||||
install_skills_local "$local_root"
|
||||
return
|
||||
else
|
||||
say "⚠️ No local source checkout found either. Skipping skills installation."
|
||||
return
|
||||
# Multi first: a release may ship only the multi/ tree without the root
|
||||
# mono copy, so the mono SKILL.md gate must never block a multi install.
|
||||
# An empty/corrupt multi/ tree (no */SKILL.md) falls back to mono with a
|
||||
# warning — installing it would wipe existing skills and lay down nothing.
|
||||
if [ "$SKILL_MODE" = "multi" ] && multi_tree_has_skills "$extract_root/multi"; then
|
||||
install_multi_skills_to_homes "$extract_root/multi"
|
||||
else
|
||||
if [ "$SKILL_MODE" = "multi" ]; then
|
||||
say "⚠️ Multi skill tree not found or empty in release asset; falling back to mono."
|
||||
fi
|
||||
if [ ! -f "$skill_src/SKILL.md" ]; then
|
||||
say "⚠️ Skills not found in release asset. Trying local source..."
|
||||
rm -rf "$tmpdir_skills"
|
||||
local_root="$(resolve_source_root || true)"
|
||||
if [ -n "$local_root" ]; then
|
||||
install_skills_local "$local_root"
|
||||
return
|
||||
else
|
||||
say "⚠️ No local source checkout found either. Skipping skills installation."
|
||||
return
|
||||
fi
|
||||
fi
|
||||
install_skills_to_homes "$skill_src"
|
||||
fi
|
||||
|
||||
install_skills_to_homes "$skill_src"
|
||||
|
||||
# Cache the multi tree (if present in the release asset) so a later
|
||||
# `dws skill setup --mode multi` can find a source without re-downloading.
|
||||
if [ -d "$extract_root/multi" ]; then
|
||||
@@ -642,32 +1133,20 @@ main() {
|
||||
if [ -n "$source_root" ]; then
|
||||
install_binary_from_source "$source_root"
|
||||
if [ "$NO_SKILLS" != "1" ]; then
|
||||
if [ "$SKILL_MODE" = "multi" ]; then
|
||||
print_multi_mode_notice
|
||||
else
|
||||
install_skills_local "$source_root"
|
||||
fi
|
||||
install_skills_local "$source_root"
|
||||
fi
|
||||
elif [ "$SKILLS_ONLY" = "1" ]; then
|
||||
if [ "$SKILL_MODE" = "multi" ]; then
|
||||
print_multi_mode_notice
|
||||
local_root="$(resolve_source_root || true)"
|
||||
if [ -n "$local_root" ]; then
|
||||
install_skills_local "$local_root"
|
||||
else
|
||||
local_root="$(resolve_source_root || true)"
|
||||
if [ -n "$local_root" ]; then
|
||||
install_skills_local "$local_root"
|
||||
else
|
||||
install_skills
|
||||
fi
|
||||
install_skills
|
||||
fi
|
||||
elif [ "$NO_SKILLS" = "1" ]; then
|
||||
install_binary
|
||||
else
|
||||
install_binary
|
||||
if [ "$SKILL_MODE" = "multi" ]; then
|
||||
print_multi_mode_notice
|
||||
else
|
||||
install_skills
|
||||
fi
|
||||
install_skills
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
|
||||
@@ -71,23 +71,23 @@ HOME_AGENT_PARENTS="
|
||||
.openclaw
|
||||
.hermes
|
||||
"
|
||||
HOME_SKILL_TARGETS="
|
||||
.agents/skills/dws
|
||||
.claude/skills/dws
|
||||
.cursor/skills/dws
|
||||
.qoder/skills/dws
|
||||
.qoderwork/skills/dws
|
||||
.gemini/skills/dws
|
||||
.codex/skills/dws
|
||||
.github/skills/dws
|
||||
.windsurf/skills/dws
|
||||
.augment/skills/dws
|
||||
.cline/skills/dws
|
||||
.amp/skills/dws
|
||||
.kiro/skills/dws
|
||||
.trae/skills/dws
|
||||
.openclaw/skills/dws
|
||||
.hermes/skills/dws
|
||||
HOME_SKILL_BASES="
|
||||
.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
|
||||
"
|
||||
cleanup() {
|
||||
if [ "$RUN_BREW" -eq 1 ] && command -v brew >/dev/null 2>&1 && [ -n "${BREW_TAP_NAME:-}" ]; then
|
||||
@@ -109,8 +109,10 @@ seed_agent_homes() {
|
||||
|
||||
verify_skill_targets() {
|
||||
home_root="$1"
|
||||
for target in $HOME_SKILL_TARGETS; do
|
||||
need_file "$home_root/$target/SKILL.md"
|
||||
for base in $HOME_SKILL_BASES; do
|
||||
need_file "$home_root/$base/dingtalk-shared/SKILL.md"
|
||||
need_file "$home_root/$base/dingtalk-misc/SKILL.md"
|
||||
[ ! -e "$home_root/$base/dws" ] || err "unexpected mono Skill layout found in $home_root/$base/dws"
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
@@ -2386,7 +2386,7 @@ dws chat message send --group <openConversationId> --msg-type image --media-id "
|
||||
|
||||
#### 创建并推送流式卡片 — 向群聊或单聊发送流式卡片消息
|
||||
|
||||
群聊传 --group,单聊传 --receiver,二者互斥。
|
||||
群聊传 --group,单聊传 --receiver,二者互斥。群聊创建时可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人。
|
||||
|
||||
**注意:send-card 必须和 update-card 搭配使用。** 创建卡片时无需传入内容,后续通过 update-card 更新内容,最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。
|
||||
flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。
|
||||
@@ -2395,12 +2395,16 @@ Usage:
|
||||
dws chat message send-card [flags]
|
||||
Example:
|
||||
dws chat message send-card --group <openConversationId>
|
||||
dws chat message send-card --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId>
|
||||
dws chat message send-card --group <openConversationId> --at-all
|
||||
dws chat message send-card --receiver <openDingTalkId>
|
||||
# 查询群 ID: dws chat search --query "群名"
|
||||
# 查询人员: dws aisearch person --keyword "姓名" --dimension name
|
||||
Flags:
|
||||
--group string 群聊 openConversationId(群聊时必填,与 --receiver 互斥)
|
||||
--receiver string 单聊接收者 openDingTalkId(单聊时必填,与 --group 互斥)
|
||||
--at-all 群聊创建卡片时 @ 所有人(仅与 --group 一起使用)
|
||||
--at-open-dingtalk-ids string 群聊创建卡片时 @ 的 openDingTalkId 列表,逗号分隔(仅与 --group 一起使用)
|
||||
--group string 群聊 openConversationId(群聊时必填,与 --receiver 互斥)
|
||||
--receiver string 单聊接收者 openDingTalkId(单聊时必填,与 --group 互斥)
|
||||
```
|
||||
|
||||
#### 流式更新卡片内容 — 更新已发送的流式卡片内容
|
||||
@@ -2409,6 +2413,7 @@ Flags:
|
||||
flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。
|
||||
|
||||
**最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。**
|
||||
更新结果不确定时不要再次执行更新;保留返回结果并告知用户。
|
||||
```
|
||||
Usage:
|
||||
dws chat message update-card [flags]
|
||||
|
||||
@@ -13,8 +13,9 @@ Runtime 会唯一解析为 openDingTalkId;已有 openDingTalkId 时传
|
||||
或艾特占位符。
|
||||
- `--dry-run` 仍执行只读 userId 解析,只输出两步计划,不执行写入。
|
||||
|
||||
创建成功但自动更新失败时,错误会保留真实 `bizId`。不要重复创建;使用该 `bizId` 继续
|
||||
`+messages-update-card` 或人工处理。
|
||||
自动更新结果不确定时,不要再次更新或重复创建;保留返回结果并告知用户。若结果中已经
|
||||
包含 `openTaskId`,可以按用户需要查询一次投递状态;该查询只确认消息投递,不代表卡片
|
||||
正文已经更新成功。
|
||||
|
||||
当前内容仅为 streaming text,不接受 Lark Card JSON、组件树或按钮 callback。
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
| "搜索技能 / 找技能" | `dws skill search --query "<关键词>" [--source DingtalkMarket\|OrgInternal]` |
|
||||
| "下载技能包" | `dws skill get --skill-id <skillId>` |
|
||||
| "安装市场技能" | `dws skill install <skillId> <target>` |
|
||||
| "安装 DWS mono/multi skills" | `dws skill setup --mode <mono\|multi> --target <target> --yes` |
|
||||
| "安装 DWS mono/multi skills" | `dws skill setup --mode <mono\|multi> --target <target>` |
|
||||
|
||||
## 约束
|
||||
|
||||
@@ -73,20 +73,20 @@ Example:
|
||||
Usage:
|
||||
dws skill setup [flags]
|
||||
Example:
|
||||
dws skill setup --mode mono --yes
|
||||
dws skill setup --mode multi --target qoder --yes
|
||||
dws skill setup --mode multi -s aitable -s calendar --target qoder --yes
|
||||
dws skill setup --mode multi -x live -x devdoc --target qoder --yes
|
||||
dws skill setup --mode mono
|
||||
dws skill setup --mode multi --target qoder
|
||||
dws skill setup --mode multi -s aitable -s calendar --target qoder
|
||||
dws skill setup --mode multi -x live -x devdoc --target qoder
|
||||
Flags:
|
||||
--mode string mono | multi
|
||||
--target string 目标 Agent,默认 all
|
||||
--source string 显式 skill 源目录
|
||||
-s, --skill strings multi 模式只安装指定子 skill
|
||||
-x, --exclude strings multi 模式排除指定子 skill
|
||||
--yes 跳过确认
|
||||
--yes 仅脚本使用:跳过确认(删除仍先备份)
|
||||
```
|
||||
|
||||
`--skill` 与 `--exclude` 互斥。未指定 `--source` 时使用当前二进制内置的 skill 版本。
|
||||
`--skill` 与 `--exclude` 互斥。未指定 `--source` 时使用当前二进制内置的 skill 版本。setup 会清理对面模式残留与不在 bundle 内的过期 skill;这些目录在确认前逐条列出,删除前先备份到 `~/.dws/skill-backups/`,备份失败的目录保留原样。代用户执行时不要附加 `--yes` 绕过确认。
|
||||
|
||||
## 上下文传递
|
||||
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* install_js_smoke.mjs — smoke test for build/npm/install.js (npm postinstall).
|
||||
*
|
||||
* Runs the REAL build/npm/install.js against a staged fake package:
|
||||
*
|
||||
* <tmp>/pkg/
|
||||
* install.js (copied from build/npm/install.js)
|
||||
* assets/
|
||||
* dws-<os>-<arch>.tar.gz (dummy archive holding a fake `dws` binary)
|
||||
* dws-skills.zip (tiny release-layout fixture built on the fly,
|
||||
* NOT the real skills/ tree)
|
||||
*
|
||||
* Scenarios (each with an isolated fake HOME):
|
||||
* 1. multi install — dingtalk-* and dws-shared land as sibling
|
||||
* 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
|
||||
* crashing postinstall; a previously good multi
|
||||
* cache is NOT wiped.
|
||||
* 3. 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
|
||||
* staging root is NOT copied into a dws/ dir.
|
||||
* 5. multi backup failure — preserves mono, writes no multi skill, and
|
||||
* reports postinstall failure.
|
||||
* 6. 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
|
||||
* later backup or mono publish failure.
|
||||
*
|
||||
* Requirements: unix host with tar/zip/unzip on PATH (the same tools
|
||||
* install.js itself shells out to). Skips cleanly on win32.
|
||||
*
|
||||
* Usage (standalone; there is intentionally no Go test harness for the npm
|
||||
* installer — test/scripts/install_script_test.go only execs POSIX sh):
|
||||
*
|
||||
* node test/scripts/install_js_smoke.mjs # self-contained, <10s
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import childProcess from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
|
||||
const PLATFORM_MAP = {
|
||||
"darwin-x64": "dws-darwin-amd64.tar.gz",
|
||||
"darwin-arm64": "dws-darwin-arm64.tar.gz",
|
||||
"linux-x64": "dws-linux-amd64.tar.gz",
|
||||
"linux-arm64": "dws-linux-arm64.tar.gz",
|
||||
};
|
||||
|
||||
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 {
|
||||
publishCacheAtomically,
|
||||
publishManagedMonoSkillSetAtomically,
|
||||
publishManagedMultiSkillSetAtomically,
|
||||
} = require(installJsSource);
|
||||
const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`];
|
||||
|
||||
if (process.platform === "win32" || !assetName) {
|
||||
console.log(`SKIP: install.js smoke test needs a unix host with tar/zip/unzip (got ${process.platform}-${process.arch})`);
|
||||
process.exit(0);
|
||||
}
|
||||
for (const tool of ["tar", "zip", "unzip"]) {
|
||||
try {
|
||||
childProcess.execFileSync("sh", ["-c", `command -v ${tool}`], { stdio: "ignore" });
|
||||
} catch {
|
||||
console.log(`SKIP: required tool not on PATH: ${tool}`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
function sh(command, args, options = {}) {
|
||||
childProcess.execFileSync(command, args, { stdio: "ignore", ...options });
|
||||
}
|
||||
|
||||
function writeFile(filePath, content, mode = 0o644) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content, { mode });
|
||||
}
|
||||
|
||||
function writeManagedState(home, names) {
|
||||
const state = {
|
||||
version: "old",
|
||||
official_skills: names,
|
||||
updated_skills: names,
|
||||
managed_skills: names.map((name) => ({
|
||||
name,
|
||||
version: "old",
|
||||
source: "test",
|
||||
digest: `sha256:${"0".repeat(64)}`,
|
||||
digest_scope: "skill-directory-v1",
|
||||
})),
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
writeFile(path.join(home, ".dws", "skills-state.json"), `${JSON.stringify(state, null, 2)}\n`);
|
||||
}
|
||||
|
||||
// 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 }.
|
||||
function stagePkg(zipEntries, emptyDirs = []) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-smoke-"));
|
||||
const pkg = path.join(tmp, "pkg");
|
||||
const assets = path.join(pkg, "assets");
|
||||
fs.mkdirSync(assets, { recursive: true });
|
||||
fs.copyFileSync(installJsSource, path.join(pkg, "install.js"));
|
||||
|
||||
const binStage = path.join(tmp, "bin-stage");
|
||||
writeFile(path.join(binStage, "dws"), "#!/bin/sh\necho fake-dws\n", 0o755);
|
||||
sh("tar", ["-czf", path.join(assets, assetName), "-C", binStage, "."]);
|
||||
|
||||
const zipStage = path.join(tmp, "zip-stage");
|
||||
for (const [rel, content] of Object.entries(zipEntries)) {
|
||||
writeFile(path.join(zipStage, rel), content);
|
||||
}
|
||||
for (const dir of emptyDirs) {
|
||||
fs.mkdirSync(path.join(zipStage, dir), { recursive: true });
|
||||
}
|
||||
sh("zip", ["-qr", path.join(assets, "dws-skills.zip"), "."], { cwd: zipStage });
|
||||
|
||||
return { tmp, pkg, home: path.join(tmp, "home") };
|
||||
}
|
||||
|
||||
function runInstall(pkg, home, skillMode) {
|
||||
const env = { ...process.env, HOME: home };
|
||||
if (skillMode !== undefined) {
|
||||
env.DWS_SKILL_MODE = skillMode;
|
||||
} else {
|
||||
delete env.DWS_SKILL_MODE;
|
||||
}
|
||||
return childProcess.spawnSync(process.execPath, [path.join(pkg, "install.js")], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
const scenarios = [];
|
||||
function scenario(name, fn) {
|
||||
scenarios.push([name, fn]);
|
||||
}
|
||||
|
||||
scenario("multi install lays out sibling skills and caches", () => {
|
||||
const { tmp, pkg, home } = stagePkg({
|
||||
"SKILL.md": "# mono root copy\n",
|
||||
"mono/SKILL.md": "# mono fixture\n",
|
||||
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
|
||||
"multi/dingtalk-test/references/guide.md": "guide\n",
|
||||
"multi/dws-shared/SKILL.md": "# dws-shared\n",
|
||||
});
|
||||
try {
|
||||
// Pre-existing state the multi install must reconcile.
|
||||
writeFile(path.join(home, ".agents", "skills", "dws", "SKILL.md"), "old mono\n");
|
||||
writeFile(path.join(home, ".agents", "skills", "dingtalk-stale", "SKILL.md"), "stale\n");
|
||||
writeManagedState(home, ["dingtalk-stale"]);
|
||||
writeFile(path.join(home, ".agents", "skills", "dingtalk-custom", "SKILL.md"), "market skill\n");
|
||||
writeFile(path.join(home, ".agents", "skills", "other-skill", "SKILL.md"), "not dws\n");
|
||||
|
||||
const res = runInstall(pkg, home, undefined); // default mode = multi
|
||||
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
|
||||
assert.match(res.stdout, /Skill mode: multi/);
|
||||
|
||||
const base = path.join(home, ".agents", "skills");
|
||||
assert.ok(fs.existsSync(path.join(base, "dingtalk-test", "SKILL.md")), "dingtalk-test installed");
|
||||
assert.ok(fs.existsSync(path.join(base, "dingtalk-test", "references", "guide.md")), "references copied");
|
||||
assert.ok(fs.existsSync(path.join(base, "dws-shared", "SKILL.md")), "dws-shared installed");
|
||||
assert.ok(!fs.existsSync(path.join(base, "dws")), "mono leftover removed");
|
||||
assert.ok(!fs.existsSync(path.join(base, "dingtalk-stale")), "stale skill removed");
|
||||
assert.equal(fs.readFileSync(path.join(base, "dingtalk-custom", "SKILL.md"), "utf8"), "market skill\n", "unregistered dingtalk-* skill preserved");
|
||||
const state = JSON.parse(fs.readFileSync(path.join(home, ".dws", "skills-state.json"), "utf8"));
|
||||
const provenance = state.managed_skills.find((record) => record.name === "dingtalk-test");
|
||||
assert.equal(provenance.source, "npm-postinstall");
|
||||
assert.match(provenance.digest, /^sha256:[0-9a-f]{64}$/);
|
||||
assert.equal(provenance.digest_scope, "skill-directory-v1");
|
||||
assert.ok(fs.existsSync(path.join(base, "other-skill", "SKILL.md")), "non-DWS skill preserved");
|
||||
|
||||
assert.ok(fs.existsSync(path.join(home, ".dws", "skills", "multi", "dingtalk-test", "SKILL.md")), "multi cache filled");
|
||||
assert.equal(fs.readFileSync(path.join(home, ".dws", "skills", "mono", "SKILL.md"), "utf8"), "# mono fixture\n", "mono cache from mono/ tree");
|
||||
assert.ok(fs.existsSync(path.join(pkg, "vendor", "dws")), "binary installed into vendor/");
|
||||
} 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",
|
||||
"mono/SKILL.md": "# mono fixture\n",
|
||||
// Corrupt multi tree: a product subdir without SKILL.md.
|
||||
"multi/dingtalk-broken/references/guide.md": "orphan\n",
|
||||
});
|
||||
try {
|
||||
writeFile(path.join(home, ".agents", "skills", "dws", "SKILL.md"), "old mono\n");
|
||||
// A previously good multi cache must survive an empty/corrupt bundle.
|
||||
writeFile(path.join(home, ".dws", "skills", "multi", "dingtalk-good", "SKILL.md"), "good cache\n");
|
||||
|
||||
const res = runInstall(pkg, home, "multi");
|
||||
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
|
||||
assert.match(res.stdout, /falling back to mono/);
|
||||
|
||||
const base = path.join(home, ".agents", "skills");
|
||||
assert.equal(fs.readFileSync(path.join(base, "dws", "SKILL.md"), "utf8"), "# mono fixture\n", "mono installed from mono/ tree");
|
||||
assert.ok(!fs.existsSync(path.join(base, "dingtalk-broken")), "broken multi skill not installed");
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(home, ".dws", "skills", "multi", "dingtalk-good", "SKILL.md"), "utf8"),
|
||||
"good cache\n",
|
||||
"previously good multi cache must not be wiped",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
scenario("bogus DWS_SKILL_MODE fails fast with a clear error", () => {
|
||||
const { tmp, pkg, home } = stagePkg({
|
||||
"mono/SKILL.md": "# mono fixture\n",
|
||||
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
|
||||
});
|
||||
try {
|
||||
const res = runInstall(pkg, home, "bogus");
|
||||
assert.notEqual(res.status, 0, "bogus mode must exit non-zero");
|
||||
assert.match(res.stderr, /invalid DWS_SKILL_MODE/);
|
||||
assert.ok(!fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-test")), "nothing installed on mode error");
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
scenario("multi-only zip in mono mode skips skill install instead of copying staging root", () => {
|
||||
const { tmp, pkg, home } = stagePkg({
|
||||
// No root SKILL.md and no mono/ tree — a multi-only release layout.
|
||||
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
|
||||
"multi/dws-shared/SKILL.md": "# dws-shared\n",
|
||||
});
|
||||
try {
|
||||
const res = runInstall(pkg, home, "mono");
|
||||
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
|
||||
assert.match(res.stdout, /mono skill tree not found.*skipping skill install/s);
|
||||
const base = path.join(home, ".agents", "skills");
|
||||
assert.ok(!fs.existsSync(path.join(base, "dws")), "staging root must not be copied into dws/");
|
||||
assert.ok(!fs.existsSync(path.join(home, ".dws", "skills", "mono")), "mono cache not refreshed without a mono tree");
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
scenario("multi backup failure preserves mono and reports failure", () => {
|
||||
const { tmp, pkg, home } = stagePkg({
|
||||
"mono/SKILL.md": "# mono fixture\n",
|
||||
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
|
||||
"multi/dws-shared/SKILL.md": "# dws-shared\n",
|
||||
});
|
||||
try {
|
||||
const base = path.join(home, ".agents", "skills");
|
||||
writeFile(path.join(base, "dws", "SKILL.md"), "old mono\n");
|
||||
// Poison the backup root: mkdirSync(<file>/<stamp>) must fail.
|
||||
writeFile(path.join(home, ".dws", "skill-backups"), "not a directory\n");
|
||||
|
||||
const res = runInstall(pkg, home, "multi");
|
||||
assert.notEqual(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
|
||||
assert.match(res.stderr, /未安装任何 multi Skill/);
|
||||
assert.equal(fs.readFileSync(path.join(base, "dws", "SKILL.md"), "utf8"), "old mono\n");
|
||||
assert.ok(!fs.existsSync(path.join(base, "dingtalk-test")), "product skill not installed after cleanup failure");
|
||||
assert.ok(!fs.existsSync(path.join(base, "dws-shared")), "shared skill not installed after cleanup failure");
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
scenario("mono backup failure preserves multi and reports failure", () => {
|
||||
const { tmp, pkg, home } = stagePkg({
|
||||
"mono/SKILL.md": "# mono fixture\n",
|
||||
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
|
||||
});
|
||||
try {
|
||||
const base = path.join(home, ".agents", "skills");
|
||||
writeFile(path.join(base, "dingtalk-test", "SKILL.md"), "old multi\n");
|
||||
writeManagedState(home, ["dingtalk-test"]);
|
||||
writeFile(path.join(home, ".dws", "skill-backups"), "not a directory\n");
|
||||
|
||||
const res = runInstall(pkg, home, "mono");
|
||||
assert.notEqual(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
|
||||
assert.match(res.stderr, /未安装任何 mono Skill/);
|
||||
assert.equal(fs.readFileSync(path.join(base, "dingtalk-test", "SKILL.md"), "utf8"), "old multi\n");
|
||||
assert.ok(!fs.existsSync(path.join(base, "dws")), "mono not installed after cleanup failure");
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
scenario("mono switch migrates exact pre-state official skills", () => {
|
||||
const { tmp, pkg, home } = stagePkg({
|
||||
"mono/SKILL.md": "# mono fixture\n",
|
||||
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
|
||||
});
|
||||
try {
|
||||
const base = path.join(home, ".agents", "skills");
|
||||
writeFile(path.join(base, "dingtalk-aitable", "SKILL.md"), "legacy official\n");
|
||||
writeFile(path.join(base, "dingtalk-custom", "SKILL.md"), "market skill\n");
|
||||
writeManagedState(home, ["dingtalk-aitable"]);
|
||||
|
||||
const res = runInstall(pkg, home, "mono");
|
||||
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
|
||||
assert.ok(!fs.existsSync(path.join(base, "dingtalk-aitable")), "pre-state official skill removed");
|
||||
assert.equal(fs.readFileSync(path.join(base, "dingtalk-custom", "SKILL.md"), "utf8"), "market skill\n");
|
||||
assert.ok(fs.existsSync(path.join(base, "dws", "SKILL.md")), "mono installed");
|
||||
assert.ok(!fs.existsSync(path.join(home, ".dws", "skills-state.json")), "mono clears centralized multi state");
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
scenario("cache copy failure preserves the previous complete cache", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-cache-"));
|
||||
const source = path.join(tmp, "source");
|
||||
const cache = path.join(tmp, "skills", "multi");
|
||||
try {
|
||||
writeFile(path.join(source, "dingtalk-new", "SKILL.md"), "new cache\n");
|
||||
writeFile(path.join(cache, "dingtalk-old", "SKILL.md"), "old cache\n");
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
publishCacheAtomically(source, cache, (_src, staged) => {
|
||||
writeFile(path.join(staged, "partial", "SKILL.md"), "partial\n");
|
||||
throw new Error("injected cache copy failure");
|
||||
}),
|
||||
/injected cache copy failure/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(path.join(cache, "dingtalk-old", "SKILL.md"), "utf8"), "old cache\n");
|
||||
assert.ok(!fs.existsSync(path.join(cache, "dingtalk-new")), "failed refresh must not publish new cache");
|
||||
assert.ok(
|
||||
!fs.readdirSync(path.dirname(cache)).some((name) => name.startsWith(".multi.tmp-")),
|
||||
"failed refresh must clean its staging directory",
|
||||
);
|
||||
} 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");
|
||||
const source = path.join(tmp, "multi");
|
||||
const base = path.join(home, ".agents", "skills");
|
||||
const first = path.join(base, "dingtalk-first");
|
||||
const second = path.join(base, "dingtalk-second");
|
||||
try {
|
||||
writeFile(path.join(source, "dingtalk-first", "SKILL.md"), "new first\n");
|
||||
writeFile(path.join(source, "dingtalk-second", "SKILL.md"), "new second\n");
|
||||
writeFile(path.join(first, "SKILL.md"), "old first\n");
|
||||
writeFile(path.join(second, "SKILL.md"), "old second\n");
|
||||
|
||||
const originalRename = fs.renameSync;
|
||||
assert.throws(
|
||||
() =>
|
||||
publishManagedMultiSkillSetAtomically(
|
||||
home,
|
||||
source,
|
||||
base,
|
||||
["dingtalk-first", "dingtalk-second"],
|
||||
[first, second],
|
||||
{
|
||||
renameFn(src, dest) {
|
||||
if (src.includes(".dws-multi-set.tmp-") && path.basename(src) === "dingtalk-second") {
|
||||
throw new Error("injected second publish failure");
|
||||
}
|
||||
originalRename(src, dest);
|
||||
},
|
||||
},
|
||||
),
|
||||
/injected second publish failure/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(path.join(first, "SKILL.md"), "utf8"), "old first\n");
|
||||
assert.equal(fs.readFileSync(path.join(second, "SKILL.md"), "utf8"), "old second\n");
|
||||
assert.ok(
|
||||
!fs.readdirSync(base).some((name) => name.startsWith(".dws-multi-set.tmp-")),
|
||||
"failed publish must clean the multi-set staging directory",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
scenario("multi set backup failure restores earlier backups", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-multi-backup-"));
|
||||
const home = path.join(tmp, "home");
|
||||
const source = path.join(tmp, "multi");
|
||||
const base = path.join(home, ".agents", "skills");
|
||||
const first = path.join(base, "dingtalk-first");
|
||||
const second = path.join(base, "dingtalk-second");
|
||||
try {
|
||||
writeFile(path.join(source, "dingtalk-first", "SKILL.md"), "new first\n");
|
||||
writeFile(path.join(source, "dingtalk-second", "SKILL.md"), "new second\n");
|
||||
writeFile(path.join(first, "SKILL.md"), "old first\n");
|
||||
writeFile(path.join(second, "SKILL.md"), "old second\n");
|
||||
|
||||
const originalRename = fs.renameSync;
|
||||
assert.throws(
|
||||
() =>
|
||||
publishManagedMultiSkillSetAtomically(
|
||||
home,
|
||||
source,
|
||||
base,
|
||||
["dingtalk-first", "dingtalk-second"],
|
||||
[first, second],
|
||||
{
|
||||
renameFn(src, dest) {
|
||||
if (src === second) {
|
||||
throw new Error("injected second backup failure");
|
||||
}
|
||||
originalRename(src, dest);
|
||||
},
|
||||
},
|
||||
),
|
||||
/failed to back up Skill directory/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(path.join(first, "SKILL.md"), "utf8"), "old first\n");
|
||||
assert.equal(fs.readFileSync(path.join(second, "SKILL.md"), "utf8"), "old second\n");
|
||||
assert.ok(!fs.existsSync(path.join(base, "dingtalk-first", "new-only")));
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
for (const failureKind of ["backup", "publish"]) {
|
||||
scenario(`mono set ${failureKind} failure restores the complete previous set`, () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-mono-set-"));
|
||||
const home = path.join(tmp, "home");
|
||||
const source = path.join(tmp, "mono");
|
||||
const base = path.join(home, ".agents", "skills");
|
||||
const first = path.join(base, "dingtalk-first");
|
||||
const second = path.join(base, "dingtalk-second");
|
||||
const dest = path.join(base, "dws");
|
||||
try {
|
||||
writeFile(path.join(source, "SKILL.md"), "new mono\n");
|
||||
writeFile(path.join(first, "SKILL.md"), "old first\n");
|
||||
writeFile(path.join(second, "SKILL.md"), "old second\n");
|
||||
|
||||
const originalRename = fs.renameSync;
|
||||
assert.throws(
|
||||
() =>
|
||||
publishManagedMonoSkillSetAtomically(home, source, base, [dest, first, second], {
|
||||
renameFn(src, target) {
|
||||
if (failureKind === "backup" && src === second) {
|
||||
throw new Error("injected second backup failure");
|
||||
}
|
||||
if (
|
||||
failureKind === "publish" &&
|
||||
src.includes(".dws-mono-set.tmp-") &&
|
||||
path.basename(src) === "dws"
|
||||
) {
|
||||
throw new Error("injected mono publish failure");
|
||||
}
|
||||
originalRename(src, target);
|
||||
},
|
||||
}),
|
||||
/injected|failed to back up/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(path.join(first, "SKILL.md"), "utf8"), "old first\n");
|
||||
assert.equal(fs.readFileSync(path.join(second, "SKILL.md"), "utf8"), "old second\n");
|
||||
assert.ok(!fs.existsSync(dest), "failed mono transaction must not expose dws/");
|
||||
assert.ok(
|
||||
!fs.readdirSync(base).some((name) => name.startsWith(".dws-mono-set.tmp-")),
|
||||
"failed mono transaction must clean its staging directory",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, fn] of scenarios) {
|
||||
process.stdout.write(`• ${name} ... `);
|
||||
fn();
|
||||
process.stdout.write("ok\n");
|
||||
}
|
||||
console.log(`OK — ${scenarios.length} install.js smoke scenarios passed`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,22 +17,22 @@ import (
|
||||
)
|
||||
|
||||
var expectedPackagedSkillTargets = []string{
|
||||
".agents/skills/dws",
|
||||
".claude/skills/dws",
|
||||
".cursor/skills/dws",
|
||||
".qoder/skills/dws",
|
||||
".qoderwork/skills/dws",
|
||||
".gemini/skills/dws",
|
||||
".codex/skills/dws",
|
||||
".github/skills/dws",
|
||||
".windsurf/skills/dws",
|
||||
".augment/skills/dws",
|
||||
".cline/skills/dws",
|
||||
".amp/skills/dws",
|
||||
".kiro/skills/dws",
|
||||
".trae/skills/dws",
|
||||
".openclaw/skills/dws",
|
||||
".hermes/skills/dws",
|
||||
".agents/skills/dingtalk-shared",
|
||||
".claude/skills/dingtalk-shared",
|
||||
".cursor/skills/dingtalk-shared",
|
||||
".qoder/skills/dingtalk-shared",
|
||||
".qoderwork/skills/dingtalk-shared",
|
||||
".gemini/skills/dingtalk-shared",
|
||||
".codex/skills/dingtalk-shared",
|
||||
".github/skills/dingtalk-shared",
|
||||
".windsurf/skills/dingtalk-shared",
|
||||
".augment/skills/dingtalk-shared",
|
||||
".cline/skills/dingtalk-shared",
|
||||
".amp/skills/dingtalk-shared",
|
||||
".kiro/skills/dingtalk-shared",
|
||||
".trae/skills/dingtalk-shared",
|
||||
".openclaw/skills/dingtalk-shared",
|
||||
".hermes/skills/dingtalk-shared",
|
||||
}
|
||||
|
||||
var expectedReleaseAdmissionContexts = []string{
|
||||
@@ -68,6 +68,19 @@ func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) {
|
||||
if strings.Contains(script, `strings "$vendor_bin"`) || strings.Contains(script, `strings "$prefix/bin/dws"`) {
|
||||
t.Fatal("package-manager verifier still requires the version marker to occupy a strings(1) line")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"HOME_SKILL_BASES=",
|
||||
`$base/dingtalk-shared/SKILL.md`,
|
||||
`$base/dingtalk-misc/SKILL.md`,
|
||||
"unexpected mono Skill layout",
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Errorf("package-manager verifier is missing multi-layout contract %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(script, "HOME_SKILL_TARGETS=") {
|
||||
t.Fatal("package-manager verifier still declares the legacy mono target contract")
|
||||
}
|
||||
}
|
||||
|
||||
func seedDistArchive(t *testing.T, path string) {
|
||||
@@ -277,7 +290,7 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
|
||||
}
|
||||
npmInstallText := string(npmInstallData)
|
||||
for _, target := range expectedPackagedSkillTargets {
|
||||
agentDir := strings.TrimSuffix(target, "/dws")
|
||||
agentDir := strings.TrimSuffix(target, "/dingtalk-shared")
|
||||
if !strings.Contains(npmInstallText, agentDir) {
|
||||
t.Fatalf("npm install.js missing %q:\n%s", agentDir, npmInstallText)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user