The POSIX shell installers staged shared Skill links and published them with mv after an existence check; a file or symlink another process created at the destination between the check and the move was silently replaced, and the inode confirmation could not detect the loss. Publish by creating each link directly at its destination instead — symlink(2) refuses an occupied path with EEXIST, so the creation itself is the atomic no-replace check. A directory that appears at the destination turns ln -s into a container; the nested link is removed after an identity check and the transaction rolls back, leaving the foreign directory untouched. Applied to install.sh, install-skills.sh, install-event.sh, and install-devapp.sh. Also covers the remaining retraction branches of the Go shell-removal fallback so changed-code coverage is complete. Regression tests inject a concurrent occupant at the publish instant for regular-file and directory cases and assert the foreign object and its contents stay completely unchanged.
552 lines
22 KiB
Bash
Executable File
552 lines
22 KiB
Bash
Executable File
#!/bin/sh
|
|
# Copyright 2026 Alibaba Group
|
|
# Licensed under the Apache License, Version 2.0
|
|
#
|
|
# One-command installer for dws personal events.
|
|
# Downloads the official dws binary and installs:
|
|
# - multi skill: dingtalk-event (personal IM/OA event routing)
|
|
# - multi prerequisites: dingtalk-shared + clean dingtalk-misc
|
|
# - mono skill: dws
|
|
#
|
|
# Usage:
|
|
# curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
|
|
#
|
|
# Env (all optional):
|
|
# EVENT_REPO repo holding official releases (default: DingTalk-Real-AI/dingtalk-workspace-cli)
|
|
# EVENT_VERSION pin a release tag (default: latest stable release)
|
|
# DWS_VERSION alias for EVENT_VERSION when EVENT_VERSION is empty
|
|
# DWS_INSTALL_DIR binary dir (default: ~/.local/bin)
|
|
# DWS_NO_SKILLS set 1 to skip skill installation
|
|
# DWS_SKILLS_ONLY set 1 to install only skills, without touching the binary
|
|
set -eu
|
|
|
|
EVENT_REPO="${EVENT_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
|
|
EVENT_VERSION="${EVENT_VERSION:-${DWS_VERSION:-}}"
|
|
INSTALL_DIR="${DWS_INSTALL_DIR:-$HOME/.local/bin}"
|
|
NO_SKILLS="${DWS_NO_SKILLS:-0}"
|
|
SKILLS_ONLY="${DWS_SKILLS_ONLY:-0}"
|
|
BIN_NAME="dws"
|
|
EVENT_SKILL_NAME="dingtalk-event"
|
|
SHARED_SKILL_NAME="dingtalk-shared"
|
|
MISC_SKILL_NAME="dingtalk-misc"
|
|
MONO_SKILL_NAME="dws"
|
|
|
|
if [ "$EVENT_VERSION" = "latest" ]; then
|
|
EVENT_VERSION=""
|
|
fi
|
|
|
|
say() { printf ' %s\n' "$@"; }
|
|
err() { printf ' ERROR: %s\n' "$@" >&2; exit 1; }
|
|
need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; }
|
|
|
|
sha256_file() {
|
|
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
|
|
elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'
|
|
elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}'
|
|
else return 1
|
|
fi
|
|
}
|
|
|
|
verify_release_asset() {
|
|
name="$1"; file="$2"
|
|
checksums="$tmp/checksums.txt"
|
|
if [ ! -f "$checksums" ]; then
|
|
curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/checksums.txt" -o "$checksums" \
|
|
|| err "Could not download checksums.txt; refusing unverified release assets."
|
|
fi
|
|
expected="$(awk -v asset="$name" '$2 == asset {print $1; exit}' "$checksums")"
|
|
[ -n "$expected" ] || err "${name} is missing from checksums.txt."
|
|
actual="$(sha256_file "$file")" || err "Could not compute SHA256 for ${name}."
|
|
[ "$actual" = "$expected" ] || err "SHA256 checksum mismatch for ${name}."
|
|
say "SHA256 checksum verified: ${name}"
|
|
}
|
|
|
|
detect_os() {
|
|
case "$(uname -s)" in
|
|
Linux*) echo linux ;;
|
|
Darwin*) echo darwin ;;
|
|
MINGW*|MSYS*|CYGWIN*) echo windows ;;
|
|
*) err "Unsupported OS: $(uname -s). On native Windows use a PowerShell installer." ;;
|
|
esac
|
|
}
|
|
|
|
detect_arch() {
|
|
case "$(uname -m)" in
|
|
x86_64|amd64) echo amd64 ;;
|
|
arm64|aarch64) echo arm64 ;;
|
|
*) err "Unsupported architecture: $(uname -m)" ;;
|
|
esac
|
|
}
|
|
|
|
extract_zip() {
|
|
archive="$1"
|
|
dest="$2"
|
|
if command -v unzip >/dev/null 2>&1; then
|
|
unzip -q "$archive" -d "$dest"
|
|
return 0
|
|
fi
|
|
if command -v tar >/dev/null 2>&1 && tar -xf "$archive" -C "$dest" >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
err "Missing required command: unzip (or tar with zip support)"
|
|
}
|
|
|
|
resolve_event_version() {
|
|
[ -n "$EVENT_VERSION" ] && return 0
|
|
|
|
if command -v gh >/dev/null 2>&1; then
|
|
EVENT_VERSION="$(gh api "repos/${EVENT_REPO}/releases/latest" --jq '.tag_name' 2>/dev/null || true)"
|
|
[ -n "$EVENT_VERSION" ] && [ "$EVENT_VERSION" != "null" ] && return 0
|
|
EVENT_VERSION=""
|
|
fi
|
|
|
|
tmpfile="$(mktemp)"
|
|
http_code="$(curl -sSL -o "$tmpfile" -w '%{http_code}' "https://api.github.com/repos/${EVENT_REPO}/releases/latest" 2>/dev/null || echo "000")"
|
|
if [ "$http_code" = "403" ] || [ "$http_code" = "429" ]; then
|
|
rm -f "$tmpfile"
|
|
err "GitHub API rate limit hit (HTTP ${http_code}). Install gh or set EVENT_VERSION explicitly."
|
|
fi
|
|
EVENT_VERSION="$(grep -m1 '"tag_name"' "$tmpfile" | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/' || true)"
|
|
rm -f "$tmpfile"
|
|
[ -n "$EVENT_VERSION" ] || err "No stable release found on ${EVENT_REPO}. Set EVENT_VERSION to a published release tag."
|
|
}
|
|
|
|
copy_tree() {
|
|
src="$1"
|
|
dest="$2"
|
|
parent="$(dirname "$dest")"
|
|
mkdir -p "$parent" || return 1
|
|
stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1
|
|
if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi
|
|
backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; }
|
|
if mv "$stage" "$dest"; then return 0; fi
|
|
rm -rf "$stage"
|
|
if [ -n "$backup" ] && ! mv "$backup" "$dest"; then
|
|
printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# refresh_cache_tree <src> <dest>
|
|
# Same staged, atomic swap as copy_tree, but for the ephemeral ~/.dws/skills
|
|
# caches: those trees are byte-reproducible from the release asset, so archiving
|
|
# every stale generation into ~/.dws/skill-backups only grows that directory
|
|
# without bound. The previous cache is deleted, exactly like install-devapp.sh —
|
|
# but only after the replacement is fully staged and published.
|
|
refresh_cache_tree() {
|
|
src="$1"
|
|
dest="$2"
|
|
parent="$(dirname "$dest")"
|
|
mkdir -p "$parent" || return 1
|
|
stage="$(mktemp -d "$parent/.dws-cache.tmp.XXXXXX")" || return 1
|
|
if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi
|
|
old=""
|
|
if [ -e "$dest" ] || [ -L "$dest" ]; then
|
|
old="$(mktemp -d "$parent/.dws-cache.old.XXXXXX")" || { rm -rf "$stage"; return 1; }
|
|
rmdir "$old" || { rm -rf "$stage" "$old"; return 1; }
|
|
if ! mv "$dest" "$old"; then rm -rf "$stage"; return 1; fi
|
|
fi
|
|
if mv "$stage" "$dest"; then
|
|
if [ -n "$old" ] && ! rm -rf "$old"; then
|
|
printf ' ⚠️ 新缓存已生效,但旧缓存清理失败: %s\n' "$old" >&2
|
|
fi
|
|
return 0
|
|
fi
|
|
rm -rf "$stage"
|
|
if [ -n "$old" ] && ! mv "$old" "$dest"; then
|
|
printf ' ⚠️ 缓存刷新失败,原缓存保留在 %s\n' "$old" >&2
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
backup_skill_dir() {
|
|
victim="$1"
|
|
[ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; }
|
|
# Prune before creating this run's archive; every stamp this run has already
|
|
# created is registered, so pruning can only remove earlier-run batches.
|
|
prune_skill_backups
|
|
stamp="$(date -u +%Y%m%d-%H%M%S)"
|
|
name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')"
|
|
backup_root="$HOME/.dws/skill-backups/$stamp"
|
|
backup="$backup_root/$name"
|
|
i=0
|
|
while [ -e "$backup" ] || [ -L "$backup" ]; do
|
|
i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name"
|
|
# Same bail-out as install-skills.sh: never spin forever on a pathological
|
|
# backup root, report it and keep the original directory instead.
|
|
if [ "$i" -gt 1000 ]; then
|
|
printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$victim" >&2
|
|
return 1
|
|
fi
|
|
done
|
|
record_current_run_backup_stamp "$backup_root"
|
|
mkdir -p "$backup_root" || return 1
|
|
mv "$victim" "$backup" || return 1
|
|
printf '%s\n' "$backup"
|
|
}
|
|
|
|
# prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup
|
|
# directories from earlier runs under $HOME/.dws/skill-backups, matching Go's
|
|
# skillBackupKeep / pruneSkillBackups. Stamp directories created by the
|
|
# current process are never pruned (mirroring Go's run-root registry and
|
|
# install.js's currentRunBackupRoots), so a migration that retires more than
|
|
# SKILL_BACKUP_KEEP batches stays reversible. Glob order is lexicographic and
|
|
# the stamps are UTC YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort:
|
|
# a removal failure is reported and never fatal.
|
|
SKILL_BACKUP_KEEP=5
|
|
|
|
# Stamp directories created by this run, recorded by backup_skill_dir so
|
|
# pruning can never delete a backup the running migration may still need to
|
|
# roll back to.
|
|
CURRENT_RUN_BACKUP_STAMPS=""
|
|
|
|
record_current_run_backup_stamp() {
|
|
case " $CURRENT_RUN_BACKUP_STAMPS " in
|
|
*" $1 "*) return 0 ;;
|
|
esac
|
|
CURRENT_RUN_BACKUP_STAMPS="${CURRENT_RUN_BACKUP_STAMPS} $1"
|
|
}
|
|
|
|
# is_skill_backup_stamp accepts only directory names DWS itself writes: UTC
|
|
# YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry in
|
|
# the backup root is foreign (user data, unrelated tooling) and is preserved
|
|
# so pruning can never remove a directory it cannot prove DWS created.
|
|
is_skill_backup_stamp() {
|
|
case "$1" in
|
|
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
|
|
return 0 ;;
|
|
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*)
|
|
# base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip
|
|
# those 16 chars and require the remainder to be all digits.
|
|
_isbs_suffix="${1#????????????????}"
|
|
case "$_isbs_suffix" in
|
|
""|*[!0-9]*) return 1 ;;
|
|
esac
|
|
return 0 ;;
|
|
esac
|
|
return 1
|
|
}
|
|
|
|
prune_skill_backups() {
|
|
prune_root="$HOME/.dws/skill-backups"
|
|
[ -d "$prune_root" ] || return 0
|
|
prune_total=0
|
|
for prune_entry in "$prune_root"/*; do
|
|
[ -d "$prune_entry" ] || continue
|
|
is_skill_backup_stamp "${prune_entry##*/}" || continue
|
|
prune_total=$((prune_total + 1))
|
|
done
|
|
[ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0
|
|
prune_drop=$((prune_total - SKILL_BACKUP_KEEP))
|
|
for prune_entry in "$prune_root"/*; do
|
|
[ "$prune_drop" -gt 0 ] || break
|
|
[ -d "$prune_entry" ] || continue
|
|
is_skill_backup_stamp "${prune_entry##*/}" || continue
|
|
case " $CURRENT_RUN_BACKUP_STAMPS " in
|
|
*" $prune_entry "*) continue ;;
|
|
esac
|
|
rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2
|
|
prune_drop=$((prune_drop - 1))
|
|
done
|
|
}
|
|
|
|
same_physical_skill() {
|
|
[ -d "$1" ] && [ -d "$2" ] || return 1
|
|
left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1
|
|
right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1
|
|
[ "$left" = "$right" ]
|
|
}
|
|
|
|
is_cleanup_only_agent_dir() {
|
|
case "$1" in
|
|
.config/agents/skills|.gemini/antigravity/skills|.gemini/antigravity-cli/skills|.codex/skills|.cursor/skills|.deepagents/agent/skills|.firebender/skills|.gemini/skills|.copilot/skills|.config/opencode/skills|.github/skills|.windsurf/skills|.cline/skills|.amp/skills) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
agent_skill_dirs() {
|
|
printf '%s\n' \
|
|
.config/agents/skills .gemini/antigravity/skills .gemini/antigravity-cli/skills .codex/skills .cursor/skills .deepagents/agent/skills .firebender/skills .gemini/skills .copilot/skills .config/opencode/skills \
|
|
.aider-desk/skills .astrbot/data/skills .autohand/skills .augment/skills .bob/skills .claude/skills .openclaw/skills .codeartsdoer/skills .codebuddy/skills .codemaker/skills .codestudio/skills .commandcode/skills .continue/skills .snowflake/cortex/skills .config/crush/skills .config/devin/skills .factory/skills .forge/skills .config/goose/skills .grok/skills .hermes/skills .inferencesh/skills .jazz/skills .junie/skills .iflow/skills .kilocode/skills .config/kimchi/harness/skills .kiro/skills .kode/skills .lingma/skills .mcpjam/skills .minimax/skills .vibe/skills .moxby/skills .mux/skills .openhands/skills .ona/skills .pi/agent/skills .qoder/skills .qoder-cn/skills .qwen/skills .reasonix/skills .rovodev/skills .roo/skills .tabnine/agent/skills .terramind/skills .tinycloud/skills .trae/skills .trae-cn/skills .codeium/windsurf/skills .zcode/skills .zencoder/skills .neovate/skills .pochi/skills .adal/skills \
|
|
.qoderwork/skills .github/skills .windsurf/skills .cline/skills .amp/skills
|
|
}
|
|
|
|
resolve_agent_skill_base() {
|
|
agent_dir="$1"; base="$HOME/$agent_dir"
|
|
case "$agent_dir" in
|
|
.claude/skills) [ -n "${CLAUDE_CONFIG_DIR:-}" ] && base="$CLAUDE_CONFIG_DIR/skills" ;;
|
|
.codex/skills) [ -n "${CODEX_HOME:-}" ] && base="$CODEX_HOME/skills" ;;
|
|
.hermes/skills) [ -n "${HERMES_HOME:-}" ] && base="$HERMES_HOME/skills" ;;
|
|
.autohand/skills) [ -n "${AUTOHAND_HOME:-}" ] && base="$AUTOHAND_HOME/skills" ;;
|
|
.grok/skills) [ -n "${GROK_HOME:-}" ] && base="$GROK_HOME/skills" ;;
|
|
.vibe/skills) [ -n "${VIBE_HOME:-}" ] && base="$VIBE_HOME/skills" ;;
|
|
.openclaw/skills) for legacy in .openclaw .clawdbot .moltbot; do [ -d "$HOME/$legacy" ] && { base="$HOME/$legacy/skills"; break; }; done ;;
|
|
.config/*) base="${XDG_CONFIG_HOME:-$HOME/.config}/${agent_dir#.config/}" ;;
|
|
esac
|
|
printf '%s\n' "$base"
|
|
}
|
|
|
|
agent_skill_base_detected() {
|
|
agent_dir="$1"; base="$2"
|
|
case "$agent_dir" in
|
|
.config/kimchi/harness/skills|.tabnine/agent/skills) [ -d "$(dirname "$(dirname "$base")")" ] ;;
|
|
.zcode/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/ZCode.app" ] ;;
|
|
.minimax/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;;
|
|
*) [ -d "$(dirname "$base")" ] ;;
|
|
esac
|
|
}
|
|
|
|
link_or_copy_skill() {
|
|
canonical="$1"; src="$2"; dest="$3"
|
|
same_physical_skill "$dest" "$canonical" && return 0
|
|
parent="$(dirname "$dest")"; mkdir -p "$parent" || return 1
|
|
parent_real="$(CDPATH= cd -- "$parent" && pwd -P)" || return 1
|
|
target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1
|
|
relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j<nt?"/":""); if(out=="")out="."; print out }')"
|
|
stage="$(mktemp -d "$parent/.dws-link.tmp.XXXXXX")" || return 1
|
|
if ! ln -s "$relative" "$stage/skill" 2>/dev/null; then
|
|
rm -rf "$stage"
|
|
if copy_tree "$src" "$dest"; then
|
|
printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$dest" >&2
|
|
return 0
|
|
fi
|
|
return 1
|
|
fi
|
|
backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; }
|
|
# Create the link directly at the destination: symlink(2) refuses an
|
|
# occupied path with EEXIST, so publication itself is the atomic no-replace
|
|
# check (`mv` after the backup could still replace a file or symlink a
|
|
# concurrent writer created in between). A directory that slipped in turns
|
|
# `ln -s` into a container: remove exactly our nested link and roll back.
|
|
if ln -s "$relative" "$dest" 2>/dev/null && [ -L "$dest" ]; then
|
|
rm -rf "$stage"
|
|
return 0
|
|
fi
|
|
nested="$dest/${relative##*/}"
|
|
if [ -L "$nested" ] && [ "$(readlink "$nested" 2>/dev/null)" = "$relative" ]; then
|
|
rm -f "$nested" || true
|
|
fi
|
|
rm -rf "$stage"
|
|
if [ -n "$backup" ]; then
|
|
if [ -e "$dest" ] || [ -L "$dest" ]; then
|
|
printf ' ⚠️ %s 在发布期间被并发写入占用,原 Skill 保留于备份: %s\n' "$dest" "$backup" >&2
|
|
elif ! mv "$backup" "$dest"; then
|
|
printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2
|
|
fi
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
install_skill_to_homes() {
|
|
src="$1"
|
|
skill_name="$2"
|
|
canonical="$HOME/.agents/skills/$skill_name"
|
|
copy_tree "$src" "$canonical" || return 1
|
|
installed=1
|
|
failed=0
|
|
retire_failed=0
|
|
for agent_dir in $(agent_skill_dirs); do
|
|
base="$(resolve_agent_skill_base "$agent_dir")"
|
|
agent_skill_base_detected "$agent_dir" "$base" || continue
|
|
same_physical_skill "$base" "$HOME/.agents/skills" && continue
|
|
if is_cleanup_only_agent_dir "$agent_dir"; then
|
|
# Cleanup-only: universal Agents read the canonical store directly, so
|
|
# nothing is installed here. A retire failure only leaves an obsolete copy
|
|
# behind and must never fail an otherwise complete install.
|
|
if ! backup_skill_dir "$base/$skill_name" >/dev/null; then
|
|
printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): %s\n' "$base/$skill_name" >&2
|
|
retire_failed=$((retire_failed + 1))
|
|
fi
|
|
continue
|
|
fi
|
|
# Per-agent degrade like install.sh: a failed target is reported and
|
|
# skipped loudly instead of aborting the remaining agents mid-loop.
|
|
if ! link_or_copy_skill "$canonical" "$src" "$base/$skill_name"; then
|
|
printf ' ⚠️ Agent 目标安装失败,已跳过: %s\n' "$base/$skill_name" >&2
|
|
failed=$((failed + 1))
|
|
continue
|
|
fi
|
|
installed=$((installed + 1))
|
|
done
|
|
if [ "$retire_failed" -gt 0 ]; then
|
|
printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" >&2
|
|
fi
|
|
if [ "$failed" -gt 0 ]; then
|
|
printf ' ⚠️ 有 %s 个 Agent 目标安装 %s 失败\n' "$failed" "$skill_name" >&2
|
|
return 1
|
|
fi
|
|
printf '%s\n' "$installed"
|
|
}
|
|
|
|
find_multi_skill_src() {
|
|
bundle="$1"
|
|
skill_name="$2"
|
|
for c in \
|
|
"$bundle/multi/$skill_name" \
|
|
"$bundle/skills/multi/$skill_name" \
|
|
"$bundle/$skill_name"
|
|
do
|
|
if [ -f "$c/SKILL.md" ]; then
|
|
printf '%s\n' "$c"
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
find_mono_skill_src() {
|
|
bundle="$1"
|
|
for c in \
|
|
"$bundle/mono" \
|
|
"$bundle/skills/mono" \
|
|
"$bundle/$MONO_SKILL_NAME" \
|
|
"$bundle"
|
|
do
|
|
if [ -f "$c/SKILL.md" ]; then
|
|
printf '%s\n' "$c"
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
install_skills_from_bundle() {
|
|
bundle="$1"
|
|
|
|
# Resolve the complete, version-matched set before touching any installed or
|
|
# cached skill. A malformed release therefore cannot leave a half-migrated
|
|
# event/misc pair behind.
|
|
event_src="$(find_multi_skill_src "$bundle" "$EVENT_SKILL_NAME" || true)"
|
|
[ -n "$event_src" ] || err "${EVENT_SKILL_NAME} not found in dws-skills.zip"
|
|
shared_src="$(find_multi_skill_src "$bundle" "$SHARED_SKILL_NAME" || true)"
|
|
[ -n "$shared_src" ] || err "${SHARED_SKILL_NAME} not found in dws-skills.zip"
|
|
misc_src="$(find_multi_skill_src "$bundle" "$MISC_SKILL_NAME" || true)"
|
|
[ -n "$misc_src" ] || err "${MISC_SKILL_NAME} not found in dws-skills.zip"
|
|
mono_src="$(find_mono_skill_src "$bundle" || true)"
|
|
[ -n "$mono_src" ] || err "mono ${MONO_SKILL_NAME} skill not found in dws-skills.zip"
|
|
|
|
event_cache="$HOME/.dws/skills/multi/$EVENT_SKILL_NAME"
|
|
shared_cache="$HOME/.dws/skills/multi/$SHARED_SKILL_NAME"
|
|
misc_cache="$HOME/.dws/skills/multi/$MISC_SKILL_NAME"
|
|
refresh_cache_tree "$event_src" "$event_cache" || err "${EVENT_SKILL_NAME} 缓存刷新失败: ${event_cache}"
|
|
refresh_cache_tree "$shared_src" "$shared_cache" || err "${SHARED_SKILL_NAME} 缓存刷新失败: ${shared_cache}"
|
|
refresh_cache_tree "$misc_src" "$misc_cache" || err "${MISC_SKILL_NAME} 缓存刷新失败: ${misc_cache}"
|
|
|
|
mono_cache="$HOME/.dws/skills/mono"
|
|
refresh_cache_tree "$mono_src" "$mono_cache" || err "mono ${MONO_SKILL_NAME} 缓存刷新失败: ${mono_cache}"
|
|
|
|
event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")" \
|
|
|| err "${EVENT_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警"
|
|
shared_installed="$(install_skill_to_homes "$shared_src" "$SHARED_SKILL_NAME")" \
|
|
|| err "${SHARED_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警"
|
|
misc_installed="$(install_skill_to_homes "$misc_src" "$MISC_SKILL_NAME")" \
|
|
|| err "${MISC_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警"
|
|
mono_installed="$(install_skill_to_homes "$mono_src" "$MONO_SKILL_NAME")" \
|
|
|| err "mono ${MONO_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警"
|
|
|
|
say "Skill ${EVENT_SKILL_NAME} -> ${event_installed} agent dir(s)"
|
|
say "Skill ${SHARED_SKILL_NAME} -> ${shared_installed} agent dir(s)"
|
|
say "Skill ${MISC_SKILL_NAME} -> ${misc_installed} agent dir(s)"
|
|
say "Skill ${MONO_SKILL_NAME} -> ${mono_installed} agent dir(s)"
|
|
say "Cached ${EVENT_SKILL_NAME} -> ${event_cache}"
|
|
say "Cached ${SHARED_SKILL_NAME} -> ${shared_cache}"
|
|
say "Cached ${MISC_SKILL_NAME} -> ${misc_cache}"
|
|
say "Cached mono ${MONO_SKILL_NAME} -> ${mono_cache}"
|
|
}
|
|
|
|
install_binary() {
|
|
os="$(detect_os)"
|
|
arch="$(detect_arch)"
|
|
if [ "$os" = "windows" ]; then
|
|
asset="${BIN_NAME}-windows-${arch}.zip"
|
|
binname="${BIN_NAME}.exe"
|
|
else
|
|
asset="${BIN_NAME}-${os}-${arch}.tar.gz"
|
|
binname="${BIN_NAME}"
|
|
fi
|
|
|
|
say "Downloading ${asset} ..."
|
|
curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/${asset}" -o "$tmp/$asset" \
|
|
|| err "Binary download failed. Does release ${EVENT_VERSION} have ${asset}?"
|
|
verify_release_asset "$asset" "$tmp/$asset"
|
|
|
|
if [ "$os" = "windows" ]; then
|
|
extract_zip "$tmp/$asset" "$tmp/bin"
|
|
else
|
|
need_cmd tar
|
|
mkdir -p "$tmp/bin"
|
|
tar -xzf "$tmp/$asset" -C "$tmp/bin"
|
|
fi
|
|
|
|
found=""
|
|
for c in "$tmp/bin/$binname" "$tmp/bin/${BIN_NAME}-${os}-${arch}/$binname"; do
|
|
[ -f "$c" ] && found="$c" && break
|
|
done
|
|
if [ -z "$found" ]; then
|
|
found="$(find "$tmp/bin" -name "$binname" -type f | head -1 || true)"
|
|
fi
|
|
[ -n "$found" ] || err "${binname} not found inside ${asset}"
|
|
|
|
mkdir -p "$INSTALL_DIR"
|
|
cp "$found" "$INSTALL_DIR/$binname"
|
|
chmod +x "$INSTALL_DIR/$binname" 2>/dev/null || true
|
|
say "Binary -> ${INSTALL_DIR}/${binname}"
|
|
}
|
|
|
|
install_skills() {
|
|
say "Downloading dws-skills.zip ..."
|
|
curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/dws-skills.zip" -o "$tmp/dws-skills.zip" \
|
|
|| err "dws-skills.zip download failed for release ${EVENT_VERSION}"
|
|
verify_release_asset "dws-skills.zip" "$tmp/dws-skills.zip"
|
|
|
|
mkdir -p "$tmp/skills"
|
|
extract_zip "$tmp/dws-skills.zip" "$tmp/skills"
|
|
install_skills_from_bundle "$tmp/skills"
|
|
}
|
|
|
|
main() {
|
|
need_cmd curl
|
|
if [ "$NO_SKILLS" = "1" ] && [ "$SKILLS_ONLY" = "1" ]; then
|
|
err "DWS_NO_SKILLS=1 cannot be combined with DWS_SKILLS_ONLY=1"
|
|
fi
|
|
|
|
resolve_event_version
|
|
tmp="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp"' EXIT INT TERM
|
|
|
|
printf '\n'
|
|
say "dws event installer"
|
|
say "Repo: ${EVENT_REPO}"
|
|
say "Version: ${EVENT_VERSION}"
|
|
say "Install: ${INSTALL_DIR}"
|
|
printf '\n'
|
|
|
|
if [ "$SKILLS_ONLY" != "1" ]; then
|
|
install_binary
|
|
else
|
|
say "DWS_SKILLS_ONLY=1, skipping binary installation."
|
|
fi
|
|
|
|
if [ "$NO_SKILLS" != "1" ]; then
|
|
install_skills
|
|
else
|
|
say "DWS_NO_SKILLS=1, skipping skill installation."
|
|
fi
|
|
|
|
printf '\n'
|
|
say "Done. Verify with:"
|
|
if [ "$SKILLS_ONLY" != "1" ]; then
|
|
say " dws version"
|
|
fi
|
|
say " dws event schema user_im_message_receive_o2o"
|
|
say " dws event consume user_im_message_receive_o2o --user <userId> -f ndjson"
|
|
say ""
|
|
say "If an Agent session is already open, restart it or reload skills before testing event routing."
|
|
case ":$PATH:" in
|
|
*":$INSTALL_DIR:"*) ;;
|
|
*) [ "$SKILLS_ONLY" = "1" ] || say "Note: ${INSTALL_DIR} is not on PATH." ;;
|
|
esac
|
|
}
|
|
|
|
main
|