A universal Agent whose obsolete private copy cannot be retired installs nothing there, yet every entry point counted that retirement failure as an install failure — aborting `npm install`, `dws skill setup`, and the shell installers even when the canonical store and all links published correctly, and skipping the skills-state write. Route retirement failures to a separate warning path across all surfaces (Go upgrade + skill setup, npm, PowerShell, install.sh, install-skills.sh, install-event.sh, install-devapp.sh). Also: - Add a checked-rename fallback for filesystems that reject the atomic no-replace flag (NFS, FUSE, overlayfs); the no-clobber contract is kept and the previously unsupported platforms build and work. - PowerShell multi-mode links only bundle skills, never the shared canonical store, so third-party/user skills are no longer fanned into every Agent root. - Prune ~/.dws/skill-backups to the newest 5 on every surface; encode HOME-relative backup names on PowerShell to preserve origin. - Add simulated-win32 junction coverage and rewrite the tautological no-replace test; remove dead code whose tests gave false coverage. - Soften the overstated Windows ownership-proof comment (NTFS tunneling).
386 lines
14 KiB
Bash
Executable File
386 lines
14 KiB
Bash
Executable File
#!/bin/sh
|
|
set -eu
|
|
|
|
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
|
DIST_DIR="${DWS_PACKAGE_DIST_DIR:-$ROOT/dist}"
|
|
FORMULA_PATH="$DIST_DIR/homebrew/dingtalk-workspace-cli-local.rb"
|
|
NPM_STAGE_DIR="$DIST_DIR/npm/dingtalk-workspace-cli"
|
|
RUN_NPM=1
|
|
RUN_BREW=1
|
|
EXPECTED_VERSION=""
|
|
VERIFY_SKILL_TARGETS=1
|
|
|
|
say() {
|
|
printf '%s\n' "$*"
|
|
}
|
|
|
|
err() {
|
|
printf 'error: %s\n' "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
need_cmd() {
|
|
command -v "$1" >/dev/null 2>&1 || err "missing required command: $1"
|
|
}
|
|
|
|
need_file() {
|
|
[ -f "$1" ] || err "required file not found: $1"
|
|
}
|
|
|
|
usage() {
|
|
printf '%s\n' "usage: $0 [--npm-only|--brew-only] [--expected-version <vX.Y.Z>] [--skip-skill-targets]" >&2
|
|
}
|
|
|
|
mode_seen=0
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
--npm-only)
|
|
[ "$mode_seen" -eq 0 ] || { usage; exit 2; }
|
|
RUN_NPM=1; RUN_BREW=0; mode_seen=1; shift
|
|
;;
|
|
--brew-only)
|
|
[ "$mode_seen" -eq 0 ] || { usage; exit 2; }
|
|
RUN_NPM=0; RUN_BREW=1; mode_seen=1; shift
|
|
;;
|
|
--expected-version)
|
|
[ "$#" -ge 2 ] || { usage; exit 2; }
|
|
EXPECTED_VERSION="${2#v}"
|
|
shift 2
|
|
;;
|
|
--skip-skill-targets) VERIFY_SKILL_TARGETS=0; shift ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) usage; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-package-verify-XXXXXX")"
|
|
# vercel-labs/skills agents.ts (c6f69c6), exactly 76 upstream IDs.
|
|
# Format: id|universal(1/0)|effective default global root; '-' has no global root.
|
|
UPSTREAM_AGENT_REGISTRY='aider-desk|0|.aider-desk/skills
|
|
amp|1|.config/agents/skills
|
|
antigravity|1|.gemini/antigravity/skills
|
|
antigravity-cli|1|.gemini/antigravity-cli/skills
|
|
astrbot|0|.astrbot/data/skills
|
|
autohand-code|0|.autohand/skills
|
|
augment|0|.augment/skills
|
|
bob|0|.bob/skills
|
|
claude-code|0|.claude/skills
|
|
openclaw|0|.openclaw/skills
|
|
cline|1|.agents/skills
|
|
codearts-agent|0|.codeartsdoer/skills
|
|
codebuddy|0|.codebuddy/skills
|
|
codemaker|0|.codemaker/skills
|
|
codestudio|0|.codestudio/skills
|
|
codex|1|.codex/skills
|
|
command-code|0|.commandcode/skills
|
|
continue|0|.continue/skills
|
|
cortex|0|.snowflake/cortex/skills
|
|
crush|0|.config/crush/skills
|
|
cursor|1|.cursor/skills
|
|
deepagents|1|.deepagents/agent/skills
|
|
devin|0|.config/devin/skills
|
|
dexto|1|.agents/skills
|
|
droid|0|.factory/skills
|
|
eve|0|-
|
|
firebender|1|.firebender/skills
|
|
forgecode|0|.forge/skills
|
|
gemini-cli|1|.gemini/skills
|
|
github-copilot|1|.copilot/skills
|
|
goose|0|.config/goose/skills
|
|
grok|0|.grok/skills
|
|
hermes-agent|0|.hermes/skills
|
|
inference-sh|0|.inferencesh/skills
|
|
jazz|0|.jazz/skills
|
|
junie|0|.junie/skills
|
|
iflow-cli|0|.iflow/skills
|
|
kilo|0|.kilocode/skills
|
|
kimchi|0|.config/kimchi/harness/skills
|
|
kimi-code-cli|1|.agents/skills
|
|
kiro-cli|0|.kiro/skills
|
|
kode|0|.kode/skills
|
|
lingma|0|.lingma/skills
|
|
loaf|1|.agents/skills
|
|
mcpjam|0|.mcpjam/skills
|
|
minimax-code|0|.minimax/skills
|
|
mistral-vibe|0|.vibe/skills
|
|
moxby|0|.moxby/skills
|
|
mux|0|.mux/skills
|
|
opencode|1|.config/opencode/skills
|
|
openhands|0|.openhands/skills
|
|
ona|0|.ona/skills
|
|
pi|0|.pi/agent/skills
|
|
qoder|0|.qoder/skills
|
|
qoder-cn|0|.qoder-cn/skills
|
|
qwen-code|0|.qwen/skills
|
|
replit|1|.config/agents/skills
|
|
reasonix|0|.reasonix/skills
|
|
rovodev|0|.rovodev/skills
|
|
roo|0|.roo/skills
|
|
tabnine-cli|0|.tabnine/agent/skills
|
|
terramind|0|.terramind/skills
|
|
tinycloud|0|.tinycloud/skills
|
|
trae|0|.trae/skills
|
|
trae-cn|0|.trae-cn/skills
|
|
warp|1|.agents/skills
|
|
windsurf|0|.codeium/windsurf/skills
|
|
zed|1|.agents/skills
|
|
zcode|0|.zcode/skills
|
|
zencoder|0|.zencoder/skills
|
|
zenflow|0|.zencoder/skills
|
|
neovate|0|.neovate/skills
|
|
pochi|0|.pochi/skills
|
|
promptscript|1|-
|
|
adal|0|.adal/skills
|
|
universal|1|.config/agents/skills'
|
|
|
|
# DWS-only compatibility and old wrong global paths. Qoderwork remains a
|
|
# non-universal install target; the other entries verify migration cleanup.
|
|
LEGACY_AGENT_CLEANUP_REGISTRY='dws-qoderwork|0|.qoderwork/skills
|
|
dws-legacy-github|1|.github/skills
|
|
dws-legacy-amp|1|.amp/skills
|
|
dws-legacy-cline|1|.cline/skills
|
|
dws-legacy-windsurf|1|.windsurf/skills'
|
|
cleanup() {
|
|
if [ "$RUN_BREW" -eq 1 ] && command -v brew >/dev/null 2>&1 && [ -n "${BREW_TAP_NAME:-}" ]; then
|
|
HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
|
brew uninstall --force dingtalk-workspace-cli-local >/dev/null 2>&1 || true
|
|
HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
|
brew untap --force "$BREW_TAP_NAME" >/dev/null 2>&1 || true
|
|
fi
|
|
rm -rf "$TMP_ROOT"
|
|
}
|
|
trap cleanup EXIT INT TERM
|
|
|
|
seed_specific_agent_homes() {
|
|
home_root="$1"
|
|
seen=''
|
|
for row in $UPSTREAM_AGENT_REGISTRY $LEGACY_AGENT_CLEANUP_REGISTRY; do
|
|
id=${row%%|*}; rest=${row#*|}; path=${rest#*|}
|
|
[ "$path" != '-' ] || continue
|
|
base=$(resolve_agent_base "$home_root" "$id" "$path")
|
|
[ -n "$base" ] || continue
|
|
key=$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')
|
|
printf '%s\n' "$seen" | grep -Fqx "$key" && continue
|
|
seen="${seen}${key}
|
|
"
|
|
mkdir -p "${base%/skills}"
|
|
done
|
|
}
|
|
|
|
resolve_agent_base() {
|
|
home_root="$1"; id="$2"; default_path="$3"
|
|
case "$id" in
|
|
autohand-code) printf '%s\n' "${AUTOHAND_HOME:-$home_root/.autohand}/skills" ;;
|
|
claude-code) printf '%s\n' "${CLAUDE_CONFIG_DIR:-$home_root/.claude}/skills" ;;
|
|
codex) printf '%s\n' "${CODEX_HOME:-$home_root/.codex}/skills" ;;
|
|
grok) printf '%s\n' "${GROK_HOME:-$home_root/.grok}/skills" ;;
|
|
hermes-agent) printf '%s\n' "${HERMES_HOME:-$home_root/.hermes}/skills" ;;
|
|
mistral-vibe) printf '%s\n' "${VIBE_HOME:-$home_root/.vibe}/skills" ;;
|
|
amp|replit|universal) printf '%s\n' "${XDG_CONFIG_HOME:-$home_root/.config}/agents/skills" ;;
|
|
crush|devin|goose|kimchi|opencode) printf '%s\n' "${XDG_CONFIG_HOME:-$home_root/.config}/${default_path#.config/}" ;;
|
|
openclaw)
|
|
for alias in .openclaw .clawdbot .moltbot; do
|
|
if [ -d "$home_root/$alias" ]; then printf '%s\n' "$home_root/$alias/skills"; return; fi
|
|
done
|
|
printf '%s\n' "$home_root/.openclaw/skills"
|
|
;;
|
|
*) printf '%s\n' "$home_root/$default_path" ;;
|
|
esac
|
|
}
|
|
|
|
# Package verification must never inherit the runner's real Agent roots. In
|
|
# particular, hosted Linux runners can define XDG_CONFIG_HOME globally; two
|
|
# parallel verification jobs would then publish links into the same directory
|
|
# and make one another's links appear broken. Keep every supported override
|
|
# inside the scenario-specific temporary HOME instead.
|
|
with_isolated_agent_env() (
|
|
isolated_home=$1
|
|
shift
|
|
HOME="$isolated_home"
|
|
AUTOHAND_HOME="$isolated_home/.autohand"
|
|
CLAUDE_CONFIG_DIR="$isolated_home/.claude"
|
|
CODEX_HOME="$isolated_home/.codex"
|
|
GROK_HOME="$isolated_home/.grok"
|
|
HERMES_HOME="$isolated_home/.hermes"
|
|
VIBE_HOME="$isolated_home/.vibe"
|
|
XDG_CONFIG_HOME="$isolated_home/.config"
|
|
export HOME AUTOHAND_HOME CLAUDE_CONFIG_DIR CODEX_HOME GROK_HOME HERMES_HOME VIBE_HOME XDG_CONFIG_HOME
|
|
"$@"
|
|
)
|
|
|
|
verify_skill_base() {
|
|
home_root="$1"
|
|
base="$2"
|
|
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"
|
|
}
|
|
|
|
verify_compatible_skill_base() {
|
|
home_root="$1"; base="$2"; canonical="$home_root/.agents/skills"
|
|
for skill_md in "$canonical"/*/SKILL.md; do
|
|
[ -f "$skill_md" ] || continue
|
|
name=$(basename "$(dirname "$skill_md")")
|
|
target="$base/$name"
|
|
if [ -L "$target" ]; then
|
|
link_target="$(readlink "$target")" || err "could not read canonical Skill link at $target"
|
|
# Relative link targets are the headline invariant of the canonical store:
|
|
# an absolute target breaks as soon as the home directory moves or the
|
|
# store is relocated, so reject it even though it resolves today.
|
|
case "$link_target" in
|
|
/*) err "canonical Skill link must be relative at $target (target=$link_target)" ;;
|
|
esac
|
|
linked_real="$(CDPATH= cd -- "$target" 2>/dev/null && pwd -P)" || err "broken canonical Skill link at $target"
|
|
canonical_real="$(CDPATH= cd -- "$canonical/$name" && pwd -P)" || err "canonical Skill missing at $canonical/$name"
|
|
[ "$linked_real" = "$canonical_real" ] || err "Skill link does not resolve to canonical source at $target"
|
|
elif [ -d "$target" ]; then
|
|
diff -qr "$canonical/$name" "$target" >/dev/null || err "Skill copy fallback differs from canonical source at $target"
|
|
else
|
|
err "canonical Skill compatibility target missing at $target"
|
|
fi
|
|
done
|
|
[ ! -e "$base/dws" ] && [ ! -L "$base/dws" ] || err "unexpected mono Skill layout found in $base/dws"
|
|
}
|
|
|
|
# Universal Agents read the canonical store directly, so they must own no copy
|
|
# of ANY canonical Skill. Iterate the real store instead of a hardcoded name
|
|
# list: a hardcoded list silently passed leftover copies of every other
|
|
# dingtalk-* Skill from earlier betas.
|
|
verify_universal_skill_base() {
|
|
home_root="$1"; base="$2"; canonical="$home_root/.agents/skills"
|
|
for skill_md in "$canonical"/*/SKILL.md; do
|
|
[ -f "$skill_md" ] || continue
|
|
name=$(basename "$(dirname "$skill_md")")
|
|
{ [ ! -e "$base/$name" ] && [ ! -L "$base/$name" ]; } || \
|
|
err "unexpected duplicate Skill found in universal Agent root $base/$name"
|
|
done
|
|
# The mono layout is absent from a multi canonical store, so gate it directly.
|
|
{ [ ! -e "$base/dws" ] && [ ! -L "$base/dws" ]; } || \
|
|
err "unexpected duplicate Skill found in universal Agent root $base/dws"
|
|
}
|
|
|
|
verify_skill_targets() {
|
|
home_root="$1"
|
|
# The universal-convention canonical store is always present, regardless of
|
|
# which concrete Agent homes existed at install time.
|
|
verify_skill_base "$home_root" ".agents/skills"
|
|
seen=''
|
|
for row in $UPSTREAM_AGENT_REGISTRY $LEGACY_AGENT_CLEANUP_REGISTRY; do
|
|
id=${row%%|*}; rest=${row#*|}; universal=${rest%%|*}; path=${rest#*|}
|
|
[ "$path" != '-' ] || continue
|
|
base=$(resolve_agent_base "$home_root" "$id" "$path")
|
|
[ -n "$base" ] || continue
|
|
key=$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')
|
|
printf '%s\n' "$seen" | grep -Fqx "$key" && continue
|
|
seen="${seen}${key}
|
|
"
|
|
[ "$base" != "$home_root/.agents/skills" ] || continue
|
|
[ -d "${base%/skills}" ] || continue
|
|
if [ "$universal" -eq 1 ]; then
|
|
verify_universal_skill_base "$home_root" "$base"
|
|
continue
|
|
fi
|
|
verify_compatible_skill_base "$home_root" "$base"
|
|
done
|
|
}
|
|
|
|
verify_npm_install() {
|
|
tarball_path="$1"
|
|
scenario="$2"
|
|
npm_home="$TMP_ROOT/npm-home-$scenario"
|
|
npm_prefix="$TMP_ROOT/npm-prefix-$scenario"
|
|
npm_cache="$TMP_ROOT/npm-cache-$scenario"
|
|
mkdir -p "$npm_home" "$npm_prefix" "$npm_cache"
|
|
if [ "$scenario" = "specific-agent-roots" ]; then
|
|
with_isolated_agent_env "$npm_home" seed_specific_agent_homes "$npm_home"
|
|
fi
|
|
|
|
say "==> verifying npm package install ($scenario)"
|
|
with_isolated_agent_env "$npm_home" env npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
|
|
npm install -g "$tarball_path" >/dev/null
|
|
|
|
[ -x "$npm_prefix/bin/dws" ] || err "npm install did not expose dws in $npm_prefix/bin"
|
|
with_isolated_agent_env "$npm_home" "$npm_prefix/bin/dws" --help >/dev/null
|
|
if [ -n "$EXPECTED_VERSION" ]; then
|
|
vendor_bin="$npm_prefix/lib/node_modules/dingtalk-workspace-cli/vendor/dws"
|
|
need_file "$vendor_bin"
|
|
LC_ALL=C grep -aFq "v$EXPECTED_VERSION" "$vendor_bin" || \
|
|
err "npm-installed binary does not contain expected version marker v$EXPECTED_VERSION"
|
|
EXPECTED_VERSION="$EXPECTED_VERSION" node -e '
|
|
const pkg = require(process.argv[1]);
|
|
if (pkg.version !== process.env.EXPECTED_VERSION) process.exit(1);
|
|
' "$NPM_STAGE_DIR/package.json" || err "npm package.json version mismatch"
|
|
fi
|
|
[ "$VERIFY_SKILL_TARGETS" -eq 0 ] || with_isolated_agent_env "$npm_home" verify_skill_targets "$npm_home"
|
|
|
|
with_isolated_agent_env "$npm_home" env npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
|
|
npm uninstall -g dingtalk-workspace-cli >/dev/null
|
|
}
|
|
|
|
verify_npm() {
|
|
need_cmd npm
|
|
need_cmd node
|
|
need_cmd tar
|
|
need_cmd unzip
|
|
need_cmd diff
|
|
need_file "$NPM_STAGE_DIR/package.json"
|
|
|
|
tarball_name="$(
|
|
cd "$NPM_STAGE_DIR"
|
|
HOME="$TMP_ROOT/npm-pack-home" npm_config_cache="$TMP_ROOT/npm-pack-cache" npm pack --silent
|
|
)"
|
|
tarball_path="$NPM_STAGE_DIR/$tarball_name"
|
|
need_file "$tarball_path"
|
|
|
|
verify_npm_install "$tarball_path" "specific-agent-roots"
|
|
verify_npm_install "$tarball_path" "generic-fallback"
|
|
|
|
rm -f "$tarball_path"
|
|
}
|
|
|
|
verify_brew() {
|
|
need_cmd brew
|
|
need_file "$FORMULA_PATH"
|
|
|
|
brew_home="$TMP_ROOT/brew-home"
|
|
mkdir -p "$brew_home"
|
|
BREW_TAP_NAME="local/dws-package-verify-$$"
|
|
|
|
say "==> verifying Homebrew formula install"
|
|
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
|
brew uninstall --force dingtalk-workspace-cli-local >/dev/null 2>&1 || true
|
|
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
|
brew untap --force "$BREW_TAP_NAME" >/dev/null 2>&1 || true
|
|
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
|
brew tap-new --no-git "$BREW_TAP_NAME" >/dev/null
|
|
|
|
tap_repo="$(
|
|
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
|
brew --repository "$BREW_TAP_NAME"
|
|
)"
|
|
mkdir -p "$tap_repo/Formula"
|
|
cp "$FORMULA_PATH" "$tap_repo/Formula/dingtalk-workspace-cli-local.rb"
|
|
|
|
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
|
brew install -y "$BREW_TAP_NAME/dingtalk-workspace-cli-local" >/dev/null
|
|
|
|
prefix="$(
|
|
HOME="$brew_home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
|
|
brew --prefix dingtalk-workspace-cli-local
|
|
)"
|
|
[ -x "$prefix/bin/dws" ] || err "brew install did not create $prefix/bin/dws"
|
|
"$prefix/bin/dws" --help >/dev/null
|
|
if [ -n "$EXPECTED_VERSION" ]; then
|
|
LC_ALL=C grep -aFq "v$EXPECTED_VERSION" "$prefix/bin/dws" || \
|
|
err "Homebrew-installed binary does not contain expected version marker v$EXPECTED_VERSION"
|
|
fi
|
|
need_file "$prefix/share/dingtalk-workspace-cli-local/skills/dws/SKILL.md"
|
|
}
|
|
|
|
need_file "$DIST_DIR/dws-skills.zip"
|
|
|
|
[ "$RUN_NPM" -eq 0 ] || verify_npm
|
|
[ "$RUN_BREW" -eq 0 ] || verify_brew
|
|
|
|
say "Package-manager verification complete."
|