A stamp-shaped directory name is not ownership proof: pruneSkillBackups counted and RemoveAll'd any 20260819-120000-shaped entry under ~/.dws/skill-backups, so a user or tool that created such a directory lost its contents once DWS held five backups, and the Go backup path (MkdirAll) adopted a same-named foreign root outright. The PowerShell installers already implemented the correct contract; every other surface now matches it. Go stamps a freshly created root with the exact marker bytes the install scripts write (.dws-skill-backup = "dws skill backup v1") before any payload moves in, claims the root with mkdir so an existing unproven root bumps to a collision suffix instead of being adopted, and prunes only roots whose marker verifies — unmarked or wrongly worded stamp-shaped directories are foreign data, preserved and never counted against the keep limit. The shell installers (install.sh, install-skills.sh, install-event.sh, install-devapp.sh) and the npm installer apply the same rule in their backup collision loops, with roots recorded as created by the running process exempt from marker re-verification so a mid-run marker permission failure still reuses this run's own root and keeps the sibling payload intact. Regression tests cover every surface: pruning an unmarked/wrongly marked stamp-shaped directory alongside marked ones, refusing to adopt a foreign root (payload moves to a suffixed root, foreign data and its nonexistent marker untouched), same-stamp reuse of a proven root, and marker-write failure cleaning the empty fresh root.
1907 lines
72 KiB
JavaScript
Executable File
1907 lines
72 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
||
|
||
"use strict";
|
||
|
||
const fs = require("fs");
|
||
const crypto = require("crypto");
|
||
const os = require("os");
|
||
const path = require("path");
|
||
const childProcess = require("child_process");
|
||
|
||
// Exact vercel-labs/skills registry at the reference revision: 76 Agent IDs,
|
||
// including canonical-direct and no-global-directory entries. The resolver
|
||
// below intentionally deduplicates shared effective roots only after this
|
||
// authoritative enumeration has been classified.
|
||
const UPSTREAM_AGENTS = [
|
||
["aider-desk", false, ".aider-desk/skills"],
|
||
["amp", true, ".config/agents/skills"],
|
||
["antigravity", true, ".gemini/antigravity/skills"],
|
||
["antigravity-cli", true, ".gemini/antigravity-cli/skills"],
|
||
["astrbot", false, ".astrbot/data/skills"],
|
||
["autohand-code", false, ".autohand/skills"],
|
||
["augment", false, ".augment/skills"], ["bob", false, ".bob/skills"],
|
||
["claude-code", false, ".claude/skills"], ["openclaw", false, ".openclaw/skills"],
|
||
["cline", true, ".agents/skills"], ["codearts-agent", false, ".codeartsdoer/skills"],
|
||
["codebuddy", false, ".codebuddy/skills"], ["codemaker", false, ".codemaker/skills"],
|
||
["codestudio", false, ".codestudio/skills"], ["codex", true, ".codex/skills"],
|
||
["command-code", false, ".commandcode/skills"], ["continue", false, ".continue/skills"],
|
||
["cortex", false, ".snowflake/cortex/skills"], ["crush", false, ".config/crush/skills"],
|
||
["cursor", true, ".cursor/skills"], ["deepagents", true, ".deepagents/agent/skills"],
|
||
["devin", false, ".config/devin/skills"], ["dexto", true, ".agents/skills"],
|
||
["droid", false, ".factory/skills"], ["eve", false, null],
|
||
["firebender", true, ".firebender/skills"], ["forgecode", false, ".forge/skills"],
|
||
["gemini-cli", true, ".gemini/skills"], ["github-copilot", true, ".copilot/skills"],
|
||
["goose", false, ".config/goose/skills"], ["grok", false, ".grok/skills"],
|
||
["hermes-agent", false, ".hermes/skills"], ["inference-sh", false, ".inferencesh/skills"],
|
||
["jazz", false, ".jazz/skills"], ["junie", false, ".junie/skills"],
|
||
["iflow-cli", false, ".iflow/skills"], ["kilo", false, ".kilocode/skills"],
|
||
["kimchi", false, ".config/kimchi/harness/skills"], ["kimi-code-cli", true, ".agents/skills"],
|
||
["kiro-cli", false, ".kiro/skills"], ["kode", false, ".kode/skills"],
|
||
["lingma", false, ".lingma/skills"], ["loaf", true, ".agents/skills"],
|
||
["mcpjam", false, ".mcpjam/skills"], ["minimax-code", false, ".minimax/skills"],
|
||
["mistral-vibe", false, ".vibe/skills"], ["moxby", false, ".moxby/skills"],
|
||
["mux", false, ".mux/skills"], ["opencode", true, ".config/opencode/skills"],
|
||
["openhands", false, ".openhands/skills"], ["ona", false, ".ona/skills"],
|
||
["pi", false, ".pi/agent/skills"], ["qoder", false, ".qoder/skills"],
|
||
["qoder-cn", false, ".qoder-cn/skills"], ["qwen-code", false, ".qwen/skills"],
|
||
["replit", true, ".config/agents/skills"], ["reasonix", false, ".reasonix/skills"],
|
||
["rovodev", false, ".rovodev/skills"], ["roo", false, ".roo/skills"],
|
||
["tabnine-cli", false, ".tabnine/agent/skills"], ["terramind", false, ".terramind/skills"],
|
||
["tinycloud", false, ".tinycloud/skills"], ["trae", false, ".trae/skills"],
|
||
["trae-cn", false, ".trae-cn/skills"], ["warp", true, ".agents/skills"],
|
||
["windsurf", false, ".codeium/windsurf/skills"], ["zed", true, ".agents/skills"],
|
||
["zcode", false, ".zcode/skills"], ["zencoder", false, ".zencoder/skills"],
|
||
["zenflow", false, ".zencoder/skills"], ["neovate", false, ".neovate/skills"],
|
||
["pochi", false, ".pochi/skills"], ["promptscript", true, null],
|
||
["adal", false, ".adal/skills"], ["universal", true, ".config/agents/skills"],
|
||
].map(([id, universal, agentDir]) => ({ id, universal, agentDir }));
|
||
|
||
// Paths emitted by beta.6 or older DWS installers that do not correspond to
|
||
// the upstream registry. They are cleanup-only migration targets.
|
||
const LEGACY_UNIVERSAL_AGENT_DIRS = [
|
||
".github/skills", ".windsurf/skills", ".cline/skills", ".amp/skills",
|
||
];
|
||
|
||
function openClawSkillsDir(homeDir) {
|
||
for (const name of [".openclaw", ".clawdbot", ".moltbot"]) {
|
||
const root = path.join(homeDir, name);
|
||
if (fs.existsSync(root)) return path.join(root, "skills");
|
||
}
|
||
return path.join(homeDir, ".openclaw", "skills");
|
||
}
|
||
|
||
function resolvedAgentTargets(homeDir) {
|
||
const xdgConfigHome = (process.env.XDG_CONFIG_HOME || "").trim() || path.join(homeDir, ".config");
|
||
const targets = [
|
||
...UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir && agentDir !== ".agents/skills"),
|
||
// DWS compatibility target; qoderwork is not in upstream's registry.
|
||
{ id: "dws-qoderwork", agentDir: ".qoderwork/skills", universal: false, extension: true },
|
||
...LEGACY_UNIVERSAL_AGENT_DIRS.map((agentDir) => ({ agentDir, universal: true, legacy: true })),
|
||
].map((target) => {
|
||
const { agentDir } = target;
|
||
let baseDir = path.join(homeDir, agentDir);
|
||
if (agentDir === ".claude/skills" && (process.env.CLAUDE_CONFIG_DIR || "").trim()) {
|
||
baseDir = path.join(process.env.CLAUDE_CONFIG_DIR.trim(), "skills");
|
||
} else if (agentDir === ".codex/skills" && (process.env.CODEX_HOME || "").trim()) {
|
||
baseDir = path.join(process.env.CODEX_HOME.trim(), "skills");
|
||
} else if (agentDir === ".hermes/skills" && (process.env.HERMES_HOME || "").trim()) {
|
||
baseDir = path.join(process.env.HERMES_HOME.trim(), "skills");
|
||
} else if (agentDir === ".autohand/skills" && (process.env.AUTOHAND_HOME || "").trim()) {
|
||
baseDir = path.join(process.env.AUTOHAND_HOME.trim(), "skills");
|
||
} else if (agentDir === ".grok/skills" && (process.env.GROK_HOME || "").trim()) {
|
||
baseDir = path.join(process.env.GROK_HOME.trim(), "skills");
|
||
} else if (agentDir === ".vibe/skills" && (process.env.VIBE_HOME || "").trim()) {
|
||
baseDir = path.join(process.env.VIBE_HOME.trim(), "skills");
|
||
} else if (agentDir === ".openclaw/skills") {
|
||
baseDir = openClawSkillsDir(homeDir);
|
||
} else if (agentDir.startsWith(".config/")) {
|
||
baseDir = path.join(xdgConfigHome, agentDir.slice(".config/".length));
|
||
}
|
||
return { ...target, baseDir };
|
||
});
|
||
const seen = new Set();
|
||
return targets.filter((target) => {
|
||
let key = path.resolve(target.baseDir);
|
||
if (process.platform === "win32") key = key.toLowerCase();
|
||
if (seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function agentTargetDetected(target) {
|
||
const parent = path.dirname(target.baseDir);
|
||
switch (target.id) {
|
||
case "kimchi":
|
||
case "tabnine-cli":
|
||
return fs.existsSync(path.dirname(parent));
|
||
case "zcode":
|
||
return fs.existsSync(parent) || fs.existsSync("/Applications/ZCode.app");
|
||
case "minimax-code":
|
||
return fs.existsSync(parent) || fs.existsSync("/Applications/MiniMax Code.app");
|
||
default:
|
||
return fs.existsSync(parent);
|
||
}
|
||
}
|
||
|
||
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",
|
||
"win32-x64": "dws-windows-amd64.zip",
|
||
"win32-arm64": "dws-windows-arm64.zip",
|
||
};
|
||
|
||
function run(command, args) {
|
||
childProcess.execFileSync(command, args, { stdio: "inherit" });
|
||
}
|
||
|
||
function ensureCleanDir(dir) {
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
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())}`
|
||
);
|
||
}
|
||
|
||
// SKILL_BACKUP_KEEP bounds ~/.dws/skill-backups/ growth: only the newest
|
||
// stamped backup directories are kept. Mirrors skillBackupKeep and
|
||
// pruneSkillBackups in internal/upgrade/paths.go so every installer surface
|
||
// applies the same retention.
|
||
const SKILL_BACKUP_KEEP = 5;
|
||
|
||
// Backup roots this process created are never pruned: an in-flight
|
||
// transaction still needs them to roll back.
|
||
const currentRunBackupRoots = new Set();
|
||
|
||
// Ownership marker: every stamp root DWS creates carries .dws-skill-backup
|
||
// with exactly these bytes — the same bytes internal/upgrade/paths.go,
|
||
// install.sh, and the PowerShell installers write. A stamp-shaped name alone
|
||
// is not ownership proof, so pruning only deletes directories whose marker
|
||
// content matches byte-for-byte (cross-surface exact-LF contract; a
|
||
// PowerShell writer's hypothetical CRLF is normalized away on that surface,
|
||
// while every writer here emits LF).
|
||
const SKILL_BACKUP_MARKER_FILE = ".dws-skill-backup";
|
||
const SKILL_BACKUP_MARKER_BODY = "dws skill backup v1\n";
|
||
|
||
// writeSkillBackupMarker stamps a freshly created stamp root as DWS-owned.
|
||
function writeSkillBackupMarker(root) {
|
||
fs.writeFileSync(path.join(root, SKILL_BACKUP_MARKER_FILE), SKILL_BACKUP_MARKER_BODY);
|
||
}
|
||
|
||
// skillBackupRootUsable reports whether a stamp root may be written into:
|
||
// either it does not exist yet (this transaction creates it fresh) or its
|
||
// ownership marker already verifies. A stamp-shaped directory created by
|
||
// anyone else must never be adopted — writing our marker into it would turn
|
||
// its foreign contents into prunable DWS backups.
|
||
function skillBackupRootUsable(root) {
|
||
if (!pathExistsLexicallySync(root)) return true;
|
||
// A root this very process created is ours by construction and stays
|
||
// reusable even when its marker cannot be re-verified mid-run (for
|
||
// example a permission failure after the first payload moved in).
|
||
if (currentRunBackupRoots.has(root)) return true;
|
||
return isProvenSkillBackupRoot(root);
|
||
}
|
||
|
||
// isProvenSkillBackupRoot reports whether a stamp root carries the ownership
|
||
// marker with the exact expected bytes; anything else is foreign data.
|
||
function isProvenSkillBackupRoot(root) {
|
||
try {
|
||
return fs.readFileSync(path.join(root, SKILL_BACKUP_MARKER_FILE), "utf8") === SKILL_BACKUP_MARKER_BODY;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// skillBackupStampPattern matches only directory names DWS itself writes:
|
||
// UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry
|
||
// in the backup root is foreign (user data, unrelated tooling) and must be
|
||
// left untouched, so pruning is restricted to names it can prove DWS created.
|
||
const skillBackupStampPattern = /^[0-9]{8}-[0-9]{6}(-[0-9]+)?$/;
|
||
|
||
function isSkillBackupStamp(name) {
|
||
return skillBackupStampPattern.test(name);
|
||
}
|
||
|
||
// pruneSkillBackups removes the oldest stamped backup directories once more
|
||
// than SKILL_BACKUP_KEEP remain. Only directories whose names match the DWS
|
||
// backup stamp format AND whose ownership marker verifies are candidates;
|
||
// unknown or unmarked directories are foreign data — preserved and never
|
||
// counted against SKILL_BACKUP_KEEP. Stamps sort lexicographically in
|
||
// chronological order (`YYYYmmdd-HHMMSS`), so name order is age order.
|
||
function pruneSkillBackups(homeDir) {
|
||
const root = path.join(homeDir, ".dws", "skill-backups");
|
||
let entries;
|
||
try {
|
||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||
} catch (err) {
|
||
if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) return;
|
||
throw err;
|
||
}
|
||
const names = entries
|
||
.filter((entry) => entry.isDirectory() && isSkillBackupStamp(entry.name))
|
||
.filter((entry) => isProvenSkillBackupRoot(path.join(root, entry.name)))
|
||
.map((entry) => entry.name)
|
||
.sort((a, b) => Buffer.from(a).compare(Buffer.from(b)));
|
||
let excess = names.length - SKILL_BACKUP_KEEP;
|
||
for (const name of names) {
|
||
if (excess <= 0) break;
|
||
const candidate = path.join(root, name);
|
||
if (currentRunBackupRoots.has(candidate)) continue;
|
||
fs.rmSync(candidate, { recursive: true, force: true });
|
||
excess -= 1;
|
||
}
|
||
}
|
||
|
||
function pathExistsLexicallySync(target) {
|
||
try {
|
||
fs.lstatSync(target);
|
||
return true;
|
||
} catch (err) {
|
||
if (err && err.code === "ENOENT") return false;
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
function copyPathLexicallySync(src, dest) {
|
||
const info = fs.lstatSync(src);
|
||
if (info.isDirectory()) {
|
||
fs.mkdirSync(dest, { mode: 0o700 });
|
||
for (const name of fs.readdirSync(src)) {
|
||
copyPathLexicallySync(path.join(src, name), path.join(dest, name));
|
||
}
|
||
fs.chmodSync(dest, info.mode & 0o777);
|
||
return;
|
||
}
|
||
if (info.isSymbolicLink()) {
|
||
fs.symlinkSync(fs.readlinkSync(src), dest);
|
||
return;
|
||
}
|
||
if (!info.isFile()) {
|
||
throw new Error(`unsupported special Skill path ${src} (mode=${info.mode.toString(8)})`);
|
||
}
|
||
fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL);
|
||
fs.chmodSync(dest, info.mode & 0o777);
|
||
}
|
||
|
||
function restoreDirectoryModesSync(modes, chmodFn) {
|
||
const failures = [];
|
||
for (let i = modes.length - 1; i >= 0; i -= 1) {
|
||
const item = modes[i];
|
||
try {
|
||
if (pathExistsLexicallySync(item.path)) chmodFn(item.path, item.mode);
|
||
} catch (err) {
|
||
failures.push(`restore source directory mode ${item.path}: ${err.message}`);
|
||
}
|
||
}
|
||
if (failures.length > 0) throw new Error(failures.join("; "));
|
||
}
|
||
|
||
function prepareTreeRemovalSync(root, chmodFn) {
|
||
const modes = [];
|
||
const visit = (target) => {
|
||
const info = fs.lstatSync(target);
|
||
if (!info.isDirectory()) return;
|
||
const mode = info.mode & 0o777;
|
||
if ((mode & 0o700) !== 0o700) {
|
||
chmodFn(target, mode | 0o700);
|
||
modes.push({ path: target, mode });
|
||
}
|
||
for (const name of fs.readdirSync(target)) visit(path.join(target, name));
|
||
};
|
||
try {
|
||
visit(root);
|
||
return modes;
|
||
} catch (err) {
|
||
try {
|
||
restoreDirectoryModesSync(modes, chmodFn);
|
||
} catch (restoreErr) {
|
||
throw new Error(`${err.message}; ${restoreErr.message}`);
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
function makeTreeWritableBestEffortSync(root) {
|
||
try {
|
||
const info = fs.lstatSync(root);
|
||
if (!info.isDirectory()) return;
|
||
try { fs.chmodSync(root, 0o700); } catch {}
|
||
let names = [];
|
||
try { names = fs.readdirSync(root); } catch {}
|
||
for (const name of names) makeTreeWritableBestEffortSync(path.join(root, name));
|
||
} catch {}
|
||
}
|
||
|
||
function removePublishedSourceSync(src, removeFn, chmodFn) {
|
||
const modes = prepareTreeRemovalSync(src, chmodFn);
|
||
try {
|
||
removeFn(src);
|
||
if (pathExistsLexicallySync(src)) throw new Error("source still exists after removal");
|
||
} catch (err) {
|
||
try {
|
||
restoreDirectoryModesSync(modes, chmodFn);
|
||
} catch (restoreErr) {
|
||
throw new Error(`${err.message}; ${restoreErr.message}`);
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
function fileDigestSync(file) {
|
||
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||
}
|
||
|
||
function verifyPathCopySync(src, dest) {
|
||
const srcInfo = fs.lstatSync(src);
|
||
const destInfo = fs.lstatSync(dest);
|
||
const srcType = srcInfo.mode & fs.constants.S_IFMT;
|
||
const destType = destInfo.mode & fs.constants.S_IFMT;
|
||
if (srcType !== destType) {
|
||
throw new Error(`Skill path type mismatch: ${src} != ${dest}`);
|
||
}
|
||
if (srcInfo.isDirectory()) {
|
||
if ((srcInfo.mode & 0o777) !== (destInfo.mode & 0o777)) {
|
||
throw new Error(`Skill directory mode mismatch: ${src} != ${dest}`);
|
||
}
|
||
const bytewise = (left, right) => Buffer.from(left).compare(Buffer.from(right));
|
||
const srcNames = fs.readdirSync(src).sort(bytewise);
|
||
const destNames = fs.readdirSync(dest).sort(bytewise);
|
||
if (srcNames.length !== destNames.length || srcNames.some((name, index) => name !== destNames[index])) {
|
||
throw new Error(`Skill directory entries mismatch: ${src} != ${dest}`);
|
||
}
|
||
for (const name of srcNames) {
|
||
verifyPathCopySync(path.join(src, name), path.join(dest, name));
|
||
}
|
||
return;
|
||
}
|
||
if (srcInfo.isSymbolicLink()) {
|
||
if (fs.readlinkSync(src) !== fs.readlinkSync(dest)) {
|
||
throw new Error(`Skill symlink target mismatch: ${src} != ${dest}`);
|
||
}
|
||
return;
|
||
}
|
||
if (!srcInfo.isFile()) {
|
||
throw new Error(`unsupported special Skill path ${src} (mode=${srcInfo.mode.toString(8)})`);
|
||
}
|
||
if ((srcInfo.mode & 0o777) !== (destInfo.mode & 0o777)) {
|
||
throw new Error(`Skill file mode mismatch: ${src} != ${dest}`);
|
||
}
|
||
if (srcInfo.size !== destInfo.size) {
|
||
throw new Error(`Skill file size mismatch: ${src} != ${dest}`);
|
||
}
|
||
if (fileDigestSync(src) !== fileDigestSync(dest)) {
|
||
throw new Error(`Skill file digest mismatch: ${src} != ${dest}`);
|
||
}
|
||
}
|
||
|
||
// removeClaimIfEmptySync deletes a claimed destination only while it holds no
|
||
// entries. A foreign entry that appeared after the claim must never be
|
||
// removed: the rollback has no ownership proof for it.
|
||
function removeClaimIfEmptySync(destination) {
|
||
try {
|
||
fs.rmdirSync(destination);
|
||
} catch (err) {
|
||
if (err && (err.code === "ENOTEMPTY" || err.code === "EEXIST" || err.code === "EPERM")) {
|
||
return;
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
function destinationExistsError(destination) {
|
||
return Object.assign(new Error(`Skill move destination already exists: ${destination}`), { code: "EEXIST" });
|
||
}
|
||
|
||
// moveChildIntoClaimNoClobberSync moves one staged child into the claimed
|
||
// destination through an atomic no-clobber primitive: directories recursively
|
||
// claim with mkdir, regular files publish via fs.linkSync, and symlinks are
|
||
// recreated with fs.symlinkSync. Every primitive fails with EEXIST when the
|
||
// destination entry was taken concurrently, so no step can replace a foreign
|
||
// object the way a plain rename would.
|
||
function moveChildIntoClaimNoClobberSync(source, destination, fns) {
|
||
const sourceStat = fs.lstatSync(source);
|
||
if (sourceStat.isDirectory() && !sourceStat.isSymbolicLink()) {
|
||
try {
|
||
fns.mkdirFn(destination);
|
||
} catch (err) {
|
||
if (err && err.code === "EEXIST") throw destinationExistsError(destination);
|
||
throw err;
|
||
}
|
||
moveChildrenIntoClaimSync(source, destination, sourceStat.mode & 0o777, fns);
|
||
// The recursive move leaves an emptied shell at the source. rmdirSync
|
||
// only deletes an empty directory, so clearing the shell keeps the
|
||
// single-move outcome the parent's rollback expects; a shell that gained
|
||
// a concurrent entry fails the removal and retracts the publication
|
||
// instead of silently discarding it.
|
||
try {
|
||
fs.rmdirSync(source);
|
||
} catch (err) {
|
||
const rollbackErr = moveChildrenBackOutOfClaimSync(source, destination, fs.readdirSync(destination), fns);
|
||
removeClaimIfEmptySync(destination);
|
||
throw new Error(
|
||
`Skill move state uncertain; source shell ${source} removal failed: ${err.message}` +
|
||
(rollbackErr ? `; rollback failed: ${rollbackErr.message}` : ""),
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
if (sourceStat.isSymbolicLink()) {
|
||
const target = fs.readlinkSync(source);
|
||
try {
|
||
fns.symlinkFn(target, destination);
|
||
} catch (err) {
|
||
if (err && err.code === "EEXIST") throw destinationExistsError(destination);
|
||
throw err;
|
||
}
|
||
try {
|
||
fs.unlinkSync(source);
|
||
} catch (err) {
|
||
let current = null;
|
||
try {
|
||
current = fs.readlinkSync(destination);
|
||
} catch (_) {
|
||
// Dest no longer carries our link; leave whatever replaced it.
|
||
}
|
||
if (current === target) fs.unlinkSync(destination);
|
||
throw err;
|
||
}
|
||
return;
|
||
}
|
||
if (sourceStat.isFile()) {
|
||
try {
|
||
fns.linkFn(source, destination);
|
||
} catch (err) {
|
||
if (err && err.code === "EEXIST") throw destinationExistsError(destination);
|
||
throw err;
|
||
}
|
||
try {
|
||
fs.unlinkSync(source);
|
||
} catch (err) {
|
||
try {
|
||
const destStat = fs.lstatSync(destination, { bigint: true });
|
||
if (destStat.ino === sourceStat.ino && destStat.dev === sourceStat.dev) {
|
||
fs.unlinkSync(destination);
|
||
}
|
||
} catch (_) {
|
||
// Best effort; the error below names both retained paths.
|
||
}
|
||
throw err;
|
||
}
|
||
return;
|
||
}
|
||
throw new Error(`unsupported Skill path type for no-replace move: ${source}`);
|
||
}
|
||
|
||
// moveChildrenBackOutOfClaimSync returns the already relocated children to
|
||
// the source with plain renames — every one of them is an object this
|
||
// transaction published, so a replacing rename is safe — and removes the
|
||
// claim only while it is empty.
|
||
function moveChildrenBackOutOfClaimSync(source, destination, moved, fns) {
|
||
let firstErr = null;
|
||
for (let i = moved.length - 1; i >= 0; i -= 1) {
|
||
try {
|
||
fns.renameFn(path.join(destination, moved[i]), path.join(source, moved[i]));
|
||
} catch (err) {
|
||
firstErr = firstErr || err;
|
||
}
|
||
}
|
||
try {
|
||
removeClaimIfEmptySync(destination);
|
||
} catch (err) {
|
||
firstErr = firstErr || err;
|
||
}
|
||
return firstErr;
|
||
}
|
||
|
||
// moveChildrenIntoClaimSync populates an already-claimed destination from the
|
||
// staged source. The claim must still be empty when it runs (a concurrent
|
||
// writer landing between the claim and here aborts the publication with the
|
||
// destination retained), every child moves through an atomic no-clobber
|
||
// primitive, and a live re-read catches a different-named foreign entry that
|
||
// landed mid-move. Any failure returns the moved children to the source and
|
||
// removes the claim only while it is empty, so foreign data always survives.
|
||
function moveChildrenIntoClaimSync(source, destination, sourceMode, fns) {
|
||
const claimEntries = fs.readdirSync(destination);
|
||
if (claimEntries.length > 0) {
|
||
throw destinationExistsError(destination);
|
||
}
|
||
const entries = fs.readdirSync(source);
|
||
const moved = [];
|
||
for (const name of entries) {
|
||
try {
|
||
moveChildIntoClaimNoClobberSync(path.join(source, name), path.join(destination, name), fns);
|
||
} catch (err) {
|
||
const rollbackErr = moveChildrenBackOutOfClaimSync(source, destination, moved, fns);
|
||
if (rollbackErr) {
|
||
throw new Error(`${err.message}; rollback failed: ${rollbackErr.message}`);
|
||
}
|
||
throw err;
|
||
}
|
||
moved.push(name);
|
||
}
|
||
const live = fs.readdirSync(destination);
|
||
if (live.length > entries.length) {
|
||
const abortErr = destinationExistsError(destination);
|
||
const rollbackErr = moveChildrenBackOutOfClaimSync(source, destination, moved, fns);
|
||
if (rollbackErr) {
|
||
throw new Error(`${abortErr.message}; rollback failed: ${rollbackErr.message}`);
|
||
}
|
||
throw abortErr;
|
||
}
|
||
fns.chmodFn(destination, sourceMode);
|
||
}
|
||
|
||
function defaultClaimFns(renameFn) {
|
||
return {
|
||
mkdirFn: (target) => fs.mkdirSync(target, { mode: 0o700 }),
|
||
renameFn: renameFn || fs.renameSync,
|
||
linkFn: fs.linkSync,
|
||
symlinkFn: fs.symlinkSync,
|
||
chmodFn: fs.chmodSync,
|
||
};
|
||
}
|
||
|
||
function renamePathNoReplaceSync(source, destination, renameFn = fs.renameSync) {
|
||
const sourceStat = fs.lstatSync(source);
|
||
if (sourceStat.isDirectory() && !sourceStat.isSymbolicLink()) {
|
||
const fns = defaultClaimFns(renameFn);
|
||
try {
|
||
fns.mkdirFn(destination);
|
||
} catch (err) {
|
||
if (err && err.code === "EEXIST") {
|
||
throw destinationExistsError(destination);
|
||
}
|
||
throw err;
|
||
}
|
||
moveChildrenIntoClaimSync(source, destination, sourceStat.mode & 0o777, fns);
|
||
try {
|
||
fs.rmdirSync(source);
|
||
} catch (err) {
|
||
const rollbackErr = moveChildrenBackOutOfClaimSync(source, destination, fs.readdirSync(destination), fns);
|
||
if (rollbackErr) {
|
||
throw new Error(`Skill move state uncertain; source ${source} and dest ${destination} retained: ${err.message}; retract failed: ${rollbackErr.message}`);
|
||
}
|
||
throw err;
|
||
}
|
||
return;
|
||
}
|
||
if (sourceStat.isSymbolicLink()) {
|
||
try {
|
||
fs.symlinkSync(fs.readlinkSync(source), destination);
|
||
} catch (err) {
|
||
if (err && err.code === "EEXIST") {
|
||
throw destinationExistsError(destination);
|
||
}
|
||
throw err;
|
||
}
|
||
try {
|
||
fs.unlinkSync(source);
|
||
} catch (err) {
|
||
try {
|
||
fs.unlinkSync(destination);
|
||
} catch (retractErr) {
|
||
throw new Error(`Skill move state uncertain; source ${source} and dest ${destination} retained: ${err.message}; retract failed: ${retractErr.message}`);
|
||
}
|
||
throw err;
|
||
}
|
||
return;
|
||
}
|
||
if (sourceStat.isFile()) {
|
||
try {
|
||
fs.linkSync(source, destination);
|
||
} catch (err) {
|
||
if (err && err.code === "EEXIST") {
|
||
throw destinationExistsError(destination);
|
||
}
|
||
throw err;
|
||
}
|
||
try {
|
||
fs.unlinkSync(source);
|
||
} catch (err) {
|
||
try {
|
||
const destStat = fs.lstatSync(destination);
|
||
if (destStat.ino === sourceStat.ino && destStat.dev === sourceStat.dev) {
|
||
fs.unlinkSync(destination);
|
||
}
|
||
} catch (retractErr) {
|
||
throw new Error(`Skill move state uncertain; source ${source} and dest ${destination} retained: ${err.message}; retract failed: ${retractErr.message}`);
|
||
}
|
||
throw err;
|
||
}
|
||
return;
|
||
}
|
||
throw new Error(`unsupported Skill path type for no-replace move: ${source}`);
|
||
}
|
||
|
||
function recordSkillPathPublicationSync(destination) {
|
||
const stat = fs.lstatSync(destination, { bigint: true });
|
||
return {
|
||
destination,
|
||
device: stat.dev.toString(),
|
||
inode: stat.ino.toString(),
|
||
fingerprint: skillPathFingerprint(destination),
|
||
};
|
||
}
|
||
|
||
function retractUnconfirmedSkillPublicationSync(src, dest, publication, cause, options = {}) {
|
||
try {
|
||
rollbackPublishedSkillPath(publication, {
|
||
quarantineRenameFn: options.quarantineRenameFn,
|
||
restoreRenameFn: options.restoreRenameFn || options.renameFn,
|
||
removeFn: options.removeFn,
|
||
});
|
||
} catch (retractErr) {
|
||
throw new Error(`Skill move state uncertain: ${cause.message}; failed to retract ${dest}: ${retractErr.message}; source ${src} and dest ${dest} retained`);
|
||
}
|
||
throw new Error(`Skill move failed, dest retracted, source retained (${src}): ${cause.message}`);
|
||
}
|
||
|
||
function movePathRecoverablySync(src, dest, options = {}) {
|
||
const renameFn = options.renameFn || ((source, target) => renamePathNoReplaceSync(source, target));
|
||
const copyFn = options.copyFn || copyPathLexicallySync;
|
||
const verifyFn = options.verifyFn || verifyPathCopySync;
|
||
const removeFn = options.removeFn || ((target) => fs.rmSync(target, { recursive: true, force: true }));
|
||
const mkdirTempFn = options.mkdirTempFn || fs.mkdtempSync;
|
||
const chmodFn = options.chmodFn || fs.chmodSync;
|
||
if (pathExistsLexicallySync(dest)) {
|
||
throw new Error(`move destination already exists: ${dest}`);
|
||
}
|
||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||
try {
|
||
renameFn(src, dest);
|
||
return;
|
||
} catch (err) {
|
||
if (!err || err.code !== "EXDEV") throw err;
|
||
}
|
||
|
||
const stageRoot = mkdirTempFn(path.join(path.dirname(dest), `.${path.basename(dest)}.cross-device-`));
|
||
const stage = path.join(stageRoot, "payload");
|
||
let stageCleaned = false;
|
||
let destOccupied = false;
|
||
try {
|
||
copyFn(src, stage);
|
||
verifyFn(src, stage);
|
||
const stageInfo = fs.lstatSync(stage);
|
||
const stageMode = stageInfo.mode & 0o777;
|
||
if (stageInfo.isDirectory()) chmodFn(stage, stageMode | 0o700);
|
||
// Dest publication is always no-replace. The injected renameFn is the
|
||
// same-volume EXDEV probe; using it here would re-open a replacing
|
||
// rename on Windows (MOVEFILE_REPLACE_EXISTING).
|
||
renamePathNoReplaceSync(stage, dest, renameFn);
|
||
destOccupied = true;
|
||
let publication;
|
||
try {
|
||
publication = recordSkillPathPublicationSync(dest);
|
||
} catch (recErr) {
|
||
if (!pathExistsLexicallySync(dest)) throw recErr;
|
||
throw new Error(`Skill move state uncertain: published dest ${dest} could not be recorded: ${recErr.message}; source ${src} and dest ${dest} retained`);
|
||
}
|
||
try {
|
||
if (stageInfo.isDirectory()) {
|
||
chmodFn(dest, stageMode);
|
||
publication = recordSkillPathPublicationSync(dest);
|
||
}
|
||
verifyFn(src, dest);
|
||
makeTreeWritableBestEffortSync(stageRoot);
|
||
removeFn(stageRoot);
|
||
stageCleaned = true;
|
||
} catch (postErr) {
|
||
retractUnconfirmedSkillPublicationSync(src, dest, publication, postErr, {
|
||
removeFn,
|
||
});
|
||
}
|
||
try {
|
||
removePublishedSourceSync(src, removeFn, chmodFn);
|
||
} catch (err) {
|
||
throw new Error(`verified target published but source removal failed; both copies retained (${src}, ${dest}): ${err.message}`);
|
||
}
|
||
if (pathExistsLexicallySync(src)) {
|
||
throw new Error(`verified target published but source still exists; both copies retained (${src}, ${dest})`);
|
||
}
|
||
} catch (err) {
|
||
if (!stageCleaned) {
|
||
try {
|
||
makeTreeWritableBestEffortSync(stageRoot);
|
||
removeFn(stageRoot);
|
||
stageCleaned = true;
|
||
} catch (cleanupErr) {
|
||
if (!destOccupied) {
|
||
throw new Error(`${err.message}; cross-device staging cleanup failed: ${cleanupErr.message}`);
|
||
}
|
||
}
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
function skillMoveOptions(options = {}) {
|
||
return {
|
||
renameFn: options.renameFn,
|
||
copyFn: options.backupCopyFn,
|
||
verifyFn: options.backupVerifyFn,
|
||
removeFn: options.backupRemoveFn,
|
||
mkdirTempFn: options.backupMkdirTempFn,
|
||
};
|
||
}
|
||
|
||
// 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 an error is returned so callers cannot silently delete data.
|
||
function backupAndRemoveSkillDir(homeDir, dir, backups = null, options = {}) {
|
||
try {
|
||
fs.lstatSync(dir);
|
||
} catch (err) {
|
||
if (err && err.code === "ENOENT") return "";
|
||
throw err;
|
||
}
|
||
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 = (options.backupStampFn || backupStamp)();
|
||
const backupRoot = path.join(homeDir, ".dws", "skill-backups");
|
||
let targetRoot = path.join(backupRoot, stamp);
|
||
let target = path.join(targetRoot, name);
|
||
// Bump not only when the payload path is taken but also when the stamp
|
||
// root exists without a verified ownership marker: a same-second foreign
|
||
// directory must never be stamped DWS-owned and made prunable. A
|
||
// marker-verified root from this run's same second stays reusable.
|
||
for (let i = 1; pathExistsLexicallySync(target) || !skillBackupRootUsable(targetRoot); i++) {
|
||
if (i > 1000) {
|
||
throw new Error(`backup directory collision limit exceeded; source retained: ${dir}`);
|
||
}
|
||
targetRoot = path.join(backupRoot, `${stamp}-${i}`);
|
||
target = path.join(targetRoot, name);
|
||
}
|
||
try {
|
||
fs.mkdirSync(targetRoot, { recursive: true });
|
||
// Stamp ownership immediately after creating the stamp root and before
|
||
// the skill directory moves into it, so an interrupted backup can never
|
||
// leave an unmarked (never-prunable) stamp behind.
|
||
writeSkillBackupMarker(targetRoot);
|
||
} catch (err) {
|
||
// Never leave an empty unowned stamp root behind. rmdirSync removes an
|
||
// empty root but deliberately fails on a non-empty one, so a pre-existing
|
||
// root holding foreign data is never destroyed.
|
||
try {
|
||
fs.rmdirSync(targetRoot);
|
||
} catch {
|
||
// Non-empty pre-existing root: foreign data stays.
|
||
}
|
||
console.warn(`⚠️ 备份失败,保留原目录 ${dir}: ${err.message}`);
|
||
throw new Error(`failed to back up Skill directory ${dir}: ${err.message}`);
|
||
}
|
||
try {
|
||
movePathRecoverablySync(dir, target, options);
|
||
} catch (err) {
|
||
console.warn(`⚠️ 备份失败,保留原目录 ${dir}: ${err.message}`);
|
||
throw new Error(`failed to back up Skill directory ${dir}: ${err.message}`);
|
||
}
|
||
if (backups) {
|
||
backups.push({ original: dir, backup: target });
|
||
}
|
||
currentRunBackupRoots.add(targetRoot);
|
||
// Keep the backup root bounded. The backup itself already succeeded, so a
|
||
// prune failure is only a warning (matches Go's best-effort prune call).
|
||
try {
|
||
pruneSkillBackups(homeDir);
|
||
} catch (err) {
|
||
console.warn(`⚠️ 旧备份清理失败(备份本身已成功): ${err.message}`);
|
||
}
|
||
console.log(` × 已备份并移除 ${dir} → ${target}`);
|
||
return target;
|
||
}
|
||
|
||
function findBinary(root) {
|
||
const entries = fs.readdirSync(root, { withFileTypes: true });
|
||
for (const entry of entries) {
|
||
const entryPath = path.join(root, entry.name);
|
||
if (entry.isDirectory()) {
|
||
const nested = findBinary(entryPath);
|
||
if (nested) {
|
||
return nested;
|
||
}
|
||
continue;
|
||
}
|
||
if (entry.name === "dws" || entry.name === "dws.exe") {
|
||
return entryPath;
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function extractArchive(archivePath, destDir) {
|
||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dws-npm-bin-"));
|
||
try {
|
||
if (archivePath.endsWith(".tar.gz")) {
|
||
run("tar", ["-xzf", archivePath, "-C", tmpDir]);
|
||
} else if (process.platform === "win32") {
|
||
run("powershell.exe", [
|
||
"-NoLogo",
|
||
"-NoProfile",
|
||
"-Command",
|
||
`Expand-Archive -Path '${archivePath.replace(/'/g, "''")}' -DestinationPath '${tmpDir.replace(/'/g, "''")}' -Force`,
|
||
]);
|
||
} else {
|
||
run("unzip", ["-q", archivePath, "-d", tmpDir]);
|
||
}
|
||
|
||
const binaryPath = findBinary(tmpDir);
|
||
if (!binaryPath) {
|
||
throw new Error(`dws binary not found in archive ${archivePath}`);
|
||
}
|
||
|
||
ensureCleanDir(destDir);
|
||
const targetName = process.platform === "win32" ? "dws.exe" : "dws";
|
||
const targetPath = path.join(destDir, targetName);
|
||
fs.copyFileSync(binaryPath, targetPath);
|
||
if (process.platform !== "win32") {
|
||
fs.chmodSync(targetPath, 0o755);
|
||
}
|
||
} finally {
|
||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
function extractSkills(zipPath, destDir) {
|
||
ensureCleanDir(destDir);
|
||
if (process.platform === "win32") {
|
||
run("powershell.exe", [
|
||
"-NoLogo",
|
||
"-NoProfile",
|
||
"-Command",
|
||
`Expand-Archive -Path '${zipPath.replace(/'/g, "''")}' -DestinationPath '${destDir.replace(/'/g, "''")}' -Force`,
|
||
]);
|
||
return;
|
||
}
|
||
run("unzip", ["-q", zipPath, "-d", destDir]);
|
||
}
|
||
|
||
function copyChildren(srcDir, destDir) {
|
||
fs.mkdirSync(destDir, { recursive: true });
|
||
for (const entry of fs.readdirSync(srcDir)) {
|
||
fs.cpSync(path.join(srcDir, entry), path.join(destDir, entry), { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
// 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() || entry.isSymbolicLink()) && 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;
|
||
};
|
||
|
||
const canonicalBase = path.join(homeDir, ".agents", "skills");
|
||
attempted += 1;
|
||
if (installToBase(canonicalBase)) {
|
||
installed += 1;
|
||
} else {
|
||
failed += 1;
|
||
}
|
||
|
||
if (installed > 0) {
|
||
for (const target of resolvedAgentTargets(homeDir)) {
|
||
const { agentDir, baseDir, universal } = target;
|
||
if (!agentTargetDetected(target) || samePhysicalDir(baseDir, canonicalBase)) continue;
|
||
attempted += 1;
|
||
if (universal) {
|
||
// Cleanup-only migration: universal Agents read the canonical store
|
||
// directly, so nothing is installed here. A retire failure leaves an
|
||
// obsolete copy in place — worth a warning, never a reason to fail an
|
||
// install whose canonical store and links all succeeded.
|
||
try {
|
||
retireManagedSkillRoot(homeDir, baseDir, managedNames);
|
||
} catch (err) {
|
||
console.warn(`⚠️ Agent Skill 旧副本迁移失败(不影响本次安装)${baseDir}: ${err.message}`);
|
||
}
|
||
continue;
|
||
}
|
||
const victims = [path.join(baseDir, "dws")];
|
||
if (fs.existsSync(baseDir)) {
|
||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||
if ((entry.isDirectory() || entry.isSymbolicLink()) && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
|
||
victims.push(path.join(baseDir, entry.name));
|
||
}
|
||
}
|
||
}
|
||
try {
|
||
publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, ["dws"], victims);
|
||
installed += 1;
|
||
} catch (linkErr) {
|
||
if (installToBase(baseDir)) {
|
||
console.log(`ℹ️ ${baseDir} 已自动使用兼容方式安装,可正常使用`);
|
||
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 });
|
||
console.log(`✅ DWS Skills 安装完成`);
|
||
console.log(` 统一安装位置:${canonicalBase}`);
|
||
console.log(` 已自动适配本机上检测到的 Agent`);
|
||
console.log(`ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效`);
|
||
}
|
||
|
||
// 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 retireManagedSkillRoot(homeDir, baseDir, managedNames, options = {}) {
|
||
const moveOptions = skillMoveOptions(options);
|
||
const victims = [path.join(baseDir, "dws")];
|
||
if (fs.existsSync(baseDir)) {
|
||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||
if ((entry.isDirectory() || entry.isSymbolicLink()) && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
|
||
victims.push(path.join(baseDir, entry.name));
|
||
}
|
||
}
|
||
}
|
||
const backups = [];
|
||
try {
|
||
for (const victim of victims) {
|
||
backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions);
|
||
}
|
||
} catch (err) {
|
||
const restoreErrors = [];
|
||
for (let i = backups.length - 1; i >= 0; i -= 1) {
|
||
try {
|
||
fs.mkdirSync(path.dirname(backups[i].original), { recursive: true });
|
||
movePathRecoverablySync(backups[i].backup, backups[i].original, moveOptions);
|
||
} catch (restoreErr) {
|
||
restoreErrors.push(`${backups[i].original}: ${restoreErr.message}`);
|
||
}
|
||
}
|
||
if (restoreErrors.length > 0) {
|
||
throw new Error(`${err.message}; Agent-root rollback failed: ${restoreErrors.join("; ")}`);
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
function samePhysicalDir(left, right) {
|
||
try {
|
||
const leftReal = fs.realpathSync(left);
|
||
const rightReal = fs.realpathSync(right);
|
||
return process.platform === "win32" ? leftReal.toLowerCase() === rightReal.toLowerCase() : leftReal === rightReal;
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function writeSkillFingerprintField(hash, value) {
|
||
const bytes = Buffer.from(String(value), "utf8");
|
||
hash.update(String(bytes.length), "utf8");
|
||
hash.update(":", "utf8");
|
||
hash.update(bytes);
|
||
}
|
||
|
||
function skillPathFingerprint(target) {
|
||
const hash = crypto.createHash("sha256");
|
||
const visit = (current) => {
|
||
const stat = fs.lstatSync(current, { bigint: true });
|
||
writeSkillFingerprintField(hash, Number(stat.mode & 0o170000n));
|
||
writeSkillFingerprintField(hash, Number(stat.mode & 0o777n));
|
||
if (stat.isDirectory()) {
|
||
const names = fs.readdirSync(current).sort((a, b) => Buffer.from(a).compare(Buffer.from(b)));
|
||
writeSkillFingerprintField(hash, names.length);
|
||
for (const name of names) {
|
||
writeSkillFingerprintField(hash, name);
|
||
visit(path.join(current, name));
|
||
}
|
||
return;
|
||
}
|
||
if (stat.isSymbolicLink()) {
|
||
writeSkillFingerprintField(hash, fs.readlinkSync(current));
|
||
return;
|
||
}
|
||
if (stat.isFile()) {
|
||
writeSkillFingerprintField(hash, stat.size.toString());
|
||
hash.update(fs.readFileSync(current));
|
||
return;
|
||
}
|
||
throw new Error(`unsupported Skill path type: ${current}`);
|
||
};
|
||
visit(target);
|
||
return hash.digest("hex");
|
||
}
|
||
|
||
function skillPublicationMatches(target, publication) {
|
||
const stat = fs.lstatSync(target, { bigint: true });
|
||
return stat.dev.toString() === publication.device
|
||
&& stat.ino.toString() === publication.inode
|
||
&& skillPathFingerprint(target) === publication.fingerprint;
|
||
}
|
||
|
||
function removeSkillPathLexically(target) {
|
||
const stat = fs.lstatSync(target);
|
||
if (stat.isDirectory() && !stat.isSymbolicLink()) {
|
||
fs.rmSync(target, { recursive: true, force: true });
|
||
} else {
|
||
fs.unlinkSync(target);
|
||
}
|
||
}
|
||
|
||
function skillPathExistsLexically(target) {
|
||
try {
|
||
fs.lstatSync(target);
|
||
return true;
|
||
} catch (err) {
|
||
if (err && err.code === "ENOENT") return false;
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
function rollbackPublishedSkillPath(publication, options = {}) {
|
||
// Prove ownership at dest first. Quarantining before that check would move
|
||
// a concurrent replacement off its original path; if the later match fails
|
||
// and restore cannot republish, user data stays hidden under .rollback-*.
|
||
// Quarantine itself is a same-inode rename — a no-replace mkdir-claim
|
||
// would mint a new identity and make this transaction's own dest look
|
||
// foreign. A post-quarantine mismatch is restored with no-replace.
|
||
const quarantineRenameFn = options.quarantineRenameFn || fs.renameSync;
|
||
const restoreRenameFn = options.restoreRenameFn || options.renameFn || ((source, target) => renamePathNoReplaceSync(source, target));
|
||
const removeFn = options.removeFn || removeSkillPathLexically;
|
||
const destination = publication.destination;
|
||
const quarantineRoot = fs.mkdtempSync(path.join(path.dirname(destination), `.${path.basename(destination)}.rollback-`));
|
||
const quarantine = path.join(quarantineRoot, "payload");
|
||
const cleanupRoot = () => {
|
||
try {
|
||
fs.rmSync(quarantineRoot, { recursive: true, force: true });
|
||
} catch (cleanupErr) {
|
||
throw new Error(`failed to clean Skill rollback quarantine ${quarantineRoot}: ${cleanupErr.message}`);
|
||
}
|
||
};
|
||
try {
|
||
if (!skillPublicationMatches(destination, publication)) {
|
||
cleanupRoot();
|
||
throw new Error(`refusing to delete non-transaction Skill ${destination}: concurrent object identity changed`);
|
||
}
|
||
} catch (err) {
|
||
if (err && err.code === "ENOENT") {
|
||
// ENOENT from the fingerprint walk means the destination itself is
|
||
// gone only when it no longer exists lexically; a concurrently removed
|
||
// CHILD produces the same error and must fall through to the refusal
|
||
// below instead of skipping rollback.
|
||
if (!pathExistsLexicallySync(destination)) {
|
||
cleanupRoot();
|
||
return;
|
||
}
|
||
}
|
||
if (String(err.message || "").startsWith("refusing to delete non-transaction Skill")
|
||
|| String(err.message || "").startsWith("failed to clean Skill rollback quarantine")) {
|
||
throw err;
|
||
}
|
||
cleanupRoot();
|
||
throw new Error(`refusing to delete unverifiable Skill ${destination}: ${err.message}`);
|
||
}
|
||
|
||
try {
|
||
quarantineRenameFn(destination, quarantine);
|
||
} catch (err) {
|
||
cleanupRoot();
|
||
if (err && err.code === "ENOENT") return;
|
||
throw new Error(`failed to quarantine published Skill ${destination}: ${err.message}`);
|
||
}
|
||
let owned = false;
|
||
try {
|
||
owned = skillPublicationMatches(quarantine, publication);
|
||
} catch (err) {
|
||
try {
|
||
restoreRenameFn(quarantine, destination);
|
||
} catch (restoreErr) {
|
||
throw new Error(`refusing to delete non-transaction Skill ${destination}: ${err.message}; concurrent object retained at ${quarantine}; restore failed: ${restoreErr.message}`);
|
||
}
|
||
cleanupRoot();
|
||
throw new Error(`refusing to delete non-transaction Skill ${destination}: ${err.message}`);
|
||
}
|
||
if (!owned) {
|
||
try {
|
||
restoreRenameFn(quarantine, destination);
|
||
} catch (restoreErr) {
|
||
throw new Error(`refusing to delete non-transaction Skill ${destination}: concurrent object identity changed; concurrent object retained at ${quarantine}; restore failed: ${restoreErr.message}`);
|
||
}
|
||
cleanupRoot();
|
||
throw new Error(`refusing to delete non-transaction Skill ${destination}: concurrent object identity changed`);
|
||
}
|
||
removeFn(quarantine);
|
||
cleanupRoot();
|
||
}
|
||
|
||
// Publish a staged Skill directory with a truly atomic no-replace claim.
|
||
// fs.mkdirSync fails with EEXIST when anything occupies the destination, so
|
||
// the claim itself is the existence check — there is no check-then-rename
|
||
// window a concurrent file, symlink, or empty directory could slip through
|
||
// (fs.renameSync replaces the destination outright on every platform,
|
||
// including Windows where libuv passes MOVEFILE_REPLACE_EXISTING). Children
|
||
// then move into the claimed directory one by one; each child rename targets
|
||
// a path inside a directory this transaction owns, so no step can replace a
|
||
// foreign object. The publication identity is the claim's: it is captured
|
||
// after the claim succeeds and re-verified by the caller once the move
|
||
// completes. On a failed child move the relocated children are moved back and
|
||
// only the (now empty) claim is removed, so the destination returns to the
|
||
// foreign object or to nothing — never to a partial publication.
|
||
function publishSkillDirNoReplace(staged, destination, options = {}) {
|
||
const fns = {
|
||
mkdirFn: options.publishMkdirFn || ((target) => fs.mkdirSync(target, { mode: 0o700 })),
|
||
renameFn: options.publishRenameFn || fs.renameSync,
|
||
linkFn: options.publishLinkFn || fs.linkSync,
|
||
symlinkFn: options.publishSymlinkFn || fs.symlinkSync,
|
||
chmodFn: options.publishChmodFn || fs.chmodSync,
|
||
};
|
||
const sourceStat = fs.lstatSync(staged);
|
||
try {
|
||
fns.mkdirFn(destination);
|
||
} catch (err) {
|
||
if (err && err.code === "EEXIST") {
|
||
throw Object.assign(new Error(`Skill publish destination already exists: ${destination}`), { code: "EEXIST" });
|
||
}
|
||
throw err;
|
||
}
|
||
const claimStat = fs.lstatSync(destination, { bigint: true });
|
||
const publication = {
|
||
destination,
|
||
device: claimStat.dev.toString(),
|
||
inode: claimStat.ino.toString(),
|
||
fingerprint: skillPathFingerprint(staged),
|
||
};
|
||
moveChildrenIntoClaimSync(staged, destination, sourceStat.mode & 0o7777, fns);
|
||
return publication;
|
||
}
|
||
|
||
// Publish a staged canonical link by creating the link directly at the
|
||
// destination. Link creation is the atomic no-replace primitive on every
|
||
// platform: the kernel refuses with EEXIST when anything — file, symlink, or
|
||
// directory — already occupies the path, and unlike a rename (which Windows
|
||
// performs with MOVEFILE_REPLACE_EXISTING) or `ln -P source target` (which
|
||
// links INTO a directory that appeared at the target) it has no
|
||
// check-then-publish window and never treats the destination as a container.
|
||
// Staging still proves beforehand that links can be created on this
|
||
// filesystem, before any victim moves. The publication identity is captured
|
||
// from the destination after creation — no primitive carries the staged
|
||
// inode across this publish — and confirmation re-reads the live path, so a
|
||
// concurrent replacement surfaces before the publication enters the rollback
|
||
// list instead of being recorded as owned.
|
||
function publishCanonicalLinkNoReplace(staged, destination, options = {}) {
|
||
const linkTarget = fs.readlinkSync(staged);
|
||
const linkType = process.platform === "win32" ? "junction" : "dir";
|
||
const symlinkFn = options.publishSymlinkFn || fs.symlinkSync;
|
||
try {
|
||
symlinkFn(linkTarget, destination, linkType);
|
||
} catch (err) {
|
||
if (err && err.code === "EEXIST") {
|
||
throw Object.assign(new Error(`Skill publish destination already exists: ${destination}`), { code: "EEXIST" });
|
||
}
|
||
throw err;
|
||
}
|
||
let publication;
|
||
try {
|
||
publication = recordSkillPathPublicationSync(destination);
|
||
} catch (recErr) {
|
||
if (!skillPathExistsLexically(destination)) throw recErr;
|
||
throw new Error(`Skill publish state uncertain: dest ${destination} occupied but unrecorded: ${recErr.message}`);
|
||
}
|
||
let destStat;
|
||
try {
|
||
destStat = fs.lstatSync(destination, { bigint: true });
|
||
} catch (err) {
|
||
throw new Error(`published Skill identity changed before confirmation: ${destination}: ${err.message}`);
|
||
}
|
||
if (!destStat.isSymbolicLink() || fs.readlinkSync(destination) !== linkTarget) {
|
||
// Dest is not the link this transaction created. Leave the occupant.
|
||
throw new Error(`published Skill identity changed before confirmation: ${destination}`);
|
||
}
|
||
const sameInode = publication
|
||
&& destStat.dev.toString() === publication.device
|
||
&& destStat.ino.toString() === publication.inode;
|
||
if (!publication || !sameInode || !skillPublicationMatches(destination, publication)) {
|
||
if (sameInode) {
|
||
try {
|
||
rollbackPublishedSkillPath(publication);
|
||
} catch (retractErr) {
|
||
throw new Error(`Skill publish state uncertain: published dest ${destination} confirmation failed; retract failed: ${retractErr.message}`);
|
||
}
|
||
}
|
||
throw new Error(`published Skill identity changed before confirmation: ${destination}`);
|
||
}
|
||
return publication;
|
||
}
|
||
|
||
function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims, options = {}) {
|
||
const moveOptions = skillMoveOptions(options);
|
||
const publishRemoveFn = options.publishRemoveFn || removeSkillPathLexically;
|
||
const rollbackRenameFn = options.rollbackRenameFn || ((source, target) => renamePathNoReplaceSync(source, target));
|
||
// Injectable so the Windows junction type and absolute link target can be
|
||
// asserted from a non-Windows host.
|
||
const symlinkFn = options.symlinkFn || fs.symlinkSync;
|
||
fs.mkdirSync(baseDir, { recursive: true });
|
||
const realBaseDir = fs.realpathSync(baseDir);
|
||
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-"));
|
||
const staged = [];
|
||
const backups = [];
|
||
const published = [];
|
||
const correctLinks = new Set();
|
||
const restore = () => {
|
||
const restoreErrors = [];
|
||
for (let i = published.length - 1; i >= 0; i -= 1) {
|
||
try {
|
||
rollbackPublishedSkillPath(published[i], { restoreRenameFn: rollbackRenameFn, removeFn: publishRemoveFn });
|
||
} catch (err) {
|
||
restoreErrors.push(`remove ${published[i].destination}: ${err.message}`);
|
||
}
|
||
}
|
||
for (let i = backups.length - 1; i >= 0; i -= 1) {
|
||
try {
|
||
fs.mkdirSync(path.dirname(backups[i].original), { recursive: true });
|
||
movePathRecoverablySync(backups[i].backup, backups[i].original, moveOptions);
|
||
} catch (err) {
|
||
restoreErrors.push(`restore ${backups[i].original} from ${backups[i].backup}: ${err.message}`);
|
||
}
|
||
}
|
||
if (restoreErrors.length > 0) {
|
||
throw new Error(restoreErrors.join("; "));
|
||
}
|
||
};
|
||
try {
|
||
for (const name of names) {
|
||
const target = path.join(canonicalBase, name);
|
||
const dest = path.join(baseDir, name);
|
||
if (samePhysicalDir(dest, target)) {
|
||
correctLinks.add(path.resolve(dest));
|
||
continue;
|
||
}
|
||
const stagedPath = path.join(stageRoot, name);
|
||
const realTarget = fs.realpathSync(target);
|
||
const linkTarget = process.platform === "win32" ? realTarget : path.relative(realBaseDir, realTarget);
|
||
symlinkFn(linkTarget, stagedPath, process.platform === "win32" ? "junction" : "dir");
|
||
staged.push({ staged: stagedPath, dest: path.join(baseDir, name) });
|
||
}
|
||
const seen = new Set();
|
||
for (const victim of victims) {
|
||
const normalized = path.resolve(victim);
|
||
if (seen.has(normalized) || correctLinks.has(normalized)) continue;
|
||
seen.add(normalized);
|
||
backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions);
|
||
}
|
||
for (const item of staged) {
|
||
const publication = publishCanonicalLinkNoReplace(item.staged, item.dest, options);
|
||
published.push(publication);
|
||
}
|
||
} catch (err) {
|
||
try {
|
||
restore();
|
||
} catch (restoreErr) {
|
||
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
|
||
}
|
||
throw err;
|
||
} finally {
|
||
fs.rmSync(stageRoot, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
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 removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
|
||
const rollbackRenameFn = options.rollbackRenameFn || ((source, target) => renamePathNoReplaceSync(source, target));
|
||
const moveOptions = skillMoveOptions(options);
|
||
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 {
|
||
rollbackPublishedSkillPath(published[i], { restoreRenameFn: rollbackRenameFn, removeFn });
|
||
} catch (err) {
|
||
restoreErrors.push(`remove ${published[i].destination}: ${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 });
|
||
movePathRecoverablySync(item.backup, item.original, moveOptions);
|
||
} 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);
|
||
backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions);
|
||
}
|
||
|
||
for (const item of staged) {
|
||
const publication = publishSkillDirNoReplace(item.staged, item.dest, options);
|
||
if (!skillPublicationMatches(item.dest, publication)) {
|
||
let live = null;
|
||
try {
|
||
live = fs.lstatSync(item.dest, { bigint: true });
|
||
} catch (_) {
|
||
live = null;
|
||
}
|
||
if (live && live.dev.toString() === publication.device && live.ino.toString() === publication.inode) {
|
||
try {
|
||
rollbackPublishedSkillPath(publication, { restoreRenameFn: rollbackRenameFn, removeFn });
|
||
} catch (retractErr) {
|
||
throw new Error(`Skill publish state uncertain: dest ${item.dest} confirmation failed; retract failed: ${retractErr.message}`);
|
||
}
|
||
}
|
||
throw new Error(`published Skill identity changed before confirmation: ${item.dest}`);
|
||
}
|
||
published.push(publication);
|
||
}
|
||
} 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 removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
|
||
const rollbackRenameFn = options.rollbackRenameFn || ((source, target) => renamePathNoReplaceSync(source, target));
|
||
const moveOptions = skillMoveOptions(options);
|
||
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 {
|
||
rollbackPublishedSkillPath(published[i], { restoreRenameFn: rollbackRenameFn, removeFn });
|
||
} catch (err) {
|
||
restoreErrors.push(`remove ${published[i].destination}: ${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 });
|
||
movePathRecoverablySync(item.backup, item.original, moveOptions);
|
||
} 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);
|
||
backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions);
|
||
}
|
||
|
||
const publication = publishSkillDirNoReplace(stagedDir, destDir, options);
|
||
if (!skillPublicationMatches(destDir, publication)) {
|
||
let live = null;
|
||
try {
|
||
live = fs.lstatSync(destDir, { bigint: true });
|
||
} catch (_) {
|
||
live = null;
|
||
}
|
||
if (live && live.dev.toString() === publication.device && live.ino.toString() === publication.inode) {
|
||
try {
|
||
rollbackPublishedSkillPath(publication, { restoreRenameFn: rollbackRenameFn, removeFn });
|
||
} catch (retractErr) {
|
||
throw new Error(`Skill publish state uncertain: dest ${destDir} confirmation failed; retract failed: ${retractErr.message}`);
|
||
}
|
||
}
|
||
throw new Error(`published Skill identity changed before confirmation: ${destDir}`);
|
||
}
|
||
published.push(publication);
|
||
} 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() || entry.isSymbolicLink()) &&
|
||
(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;
|
||
};
|
||
|
||
const canonicalBase = path.join(homeDir, ".agents", "skills");
|
||
attempted += 1;
|
||
if (installToBase(canonicalBase)) installed += 1;
|
||
else failed += 1;
|
||
|
||
if (installed > 0) {
|
||
for (const target of resolvedAgentTargets(homeDir)) {
|
||
const { agentDir, baseDir, universal } = target;
|
||
if (!agentTargetDetected(target) || samePhysicalDir(baseDir, canonicalBase)) continue;
|
||
attempted += 1;
|
||
if (universal) {
|
||
// Cleanup-only migration (see installSkillsToHomes): nothing is
|
||
// installed in a universal root, so a retire failure must not fail an
|
||
// otherwise complete install. Go records the same case as a failed
|
||
// directory yet still returns success for the run.
|
||
try {
|
||
retireManagedSkillRoot(homeDir, baseDir, managedNames);
|
||
} catch (err) {
|
||
console.warn(`⚠️ Agent Skill 旧副本迁移失败(不影响本次安装)${baseDir}: ${err.message}`);
|
||
}
|
||
continue;
|
||
}
|
||
const victims = [path.join(baseDir, "dws")];
|
||
if (fs.existsSync(baseDir)) {
|
||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||
if (
|
||
(entry.isDirectory() || entry.isSymbolicLink()) &&
|
||
(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 {
|
||
publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, skills, victims);
|
||
installed += 1;
|
||
} catch (linkErr) {
|
||
if (installToBase(baseDir)) {
|
||
console.log(`ℹ️ ${baseDir} 已自动使用兼容方式安装,可正常使用`);
|
||
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);
|
||
console.log(`✅ DWS Skills 安装完成`);
|
||
console.log(` 统一安装位置:${canonicalBase}`);
|
||
console.log(` 已自动适配本机上检测到的 Agent`);
|
||
console.log(`ℹ️ 下一步:请重启已打开的 Agent,使新 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. 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;
|
||
if (fs.existsSync(path.join(monoSource, "SKILL.md"))) {
|
||
const monoCache = path.join(cacheBase, "mono");
|
||
publishCacheAtomically(monoSource, monoCache);
|
||
}
|
||
|
||
const multiSource = path.join(extractedSkillsRoot, "multi");
|
||
if (multiTreeHasSkills(multiSource)) {
|
||
const multiCache = path.join(cacheBase, "multi");
|
||
publishCacheAtomically(multiSource, multiCache);
|
||
}
|
||
}
|
||
|
||
function main() {
|
||
const packageRoot = __dirname;
|
||
const assetsDir = path.join(packageRoot, "assets");
|
||
const vendorDir = path.join(packageRoot, "vendor");
|
||
// Extract dws-skills.zip into a staging directory so we can split mono/
|
||
// (installed to agent homes) from multi/ (cached for later setup use).
|
||
const skillsStaging = path.join(packageRoot, "share", "skills");
|
||
const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`];
|
||
if (!assetName) {
|
||
throw new Error(`unsupported platform: ${process.platform}/${process.arch}`);
|
||
}
|
||
|
||
const archivePath = path.join(assetsDir, assetName);
|
||
const skillsPath = path.join(assetsDir, "dws-skills.zip");
|
||
if (!fs.existsSync(archivePath)) {
|
||
throw new Error(`missing platform archive: ${archivePath}`);
|
||
}
|
||
if (!fs.existsSync(skillsPath)) {
|
||
throw new Error(`missing skills archive: ${skillsPath}`);
|
||
}
|
||
|
||
extractArchive(archivePath, vendorDir);
|
||
extractSkills(skillsPath, skillsStaging);
|
||
|
||
// For backward compatibility, the zip root carries a copy of mono content
|
||
// (SKILL.md + references/ + scripts/). Prefer the explicit mono/ subdir
|
||
// when present; fall back to the staging root otherwise.
|
||
const monoRoot = fs.existsSync(path.join(skillsStaging, "mono", "SKILL.md"))
|
||
? path.join(skillsStaging, "mono")
|
||
: skillsStaging;
|
||
// 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);
|
||
}
|
||
|
||
if (require.main === module) {
|
||
main();
|
||
}
|
||
|
||
module.exports = {
|
||
UPSTREAM_AGENTS,
|
||
resolvedAgentTargets,
|
||
agentTargetDetected,
|
||
isSkillBackupStamp,
|
||
pruneSkillBackups,
|
||
backupAndRemoveSkillDir,
|
||
copyPathLexicallySync,
|
||
movePathRecoverablySync,
|
||
publishCacheAtomically,
|
||
publishCanonicalLinksAtomically,
|
||
publishManagedMonoSkillSetAtomically,
|
||
publishManagedMultiSkillSetAtomically,
|
||
recordSkillPathPublicationSync,
|
||
rollbackPublishedSkillPath,
|
||
verifyPathCopySync,
|
||
};
|