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.
425 lines
18 KiB
Bash
Executable File
425 lines
18 KiB
Bash
Executable File
#!/bin/sh
|
|
# Copyright 2026 Alibaba Group
|
|
# Licensed under the Apache License, Version 2.0
|
|
#
|
|
# One-command installer for dws dev — pre-built binary, no build tools.
|
|
# Downloads the dev binary + dingtalk-misc skill (hosts open-platform app docs) from the DingTalk-Real-AI GitHub Releases.
|
|
# Requires only curl + tar (no go / make / git).
|
|
#
|
|
# Usage:
|
|
# curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
|
|
#
|
|
# Env (all optional):
|
|
# DEVAPP_REPO repo holding dev releases (default: DingTalk-Real-AI/dingtalk-workspace-cli)
|
|
# DEVAPP_VERSION pin a release tag (default: latest release)
|
|
# DWS_INSTALL_DIR binary dir (default: ~/.local/bin)
|
|
# DWS_NO_SKILLS set 1 to skip the dev skill
|
|
set -eu
|
|
|
|
DEVAPP_REPO="${DEVAPP_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
|
|
DEVAPP_VERSION="${DEVAPP_VERSION:-}"
|
|
INSTALL_DIR="${DWS_INSTALL_DIR:-$HOME/.local/bin}"
|
|
NO_SKILLS="${DWS_NO_SKILLS:-0}"
|
|
SKILL_NAME="dingtalk-misc"
|
|
|
|
say() { printf ' %s\n' "$@"; }
|
|
err() { printf ' ❌ %s\n' "$@" >&2; exit 1; }
|
|
need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; }
|
|
|
|
sha256_file() {
|
|
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
|
|
elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'
|
|
elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}'
|
|
else return 1
|
|
fi
|
|
}
|
|
|
|
verify_release_asset() {
|
|
name="$1"; file="$2"
|
|
checksums="$tmp/checksums.txt"
|
|
if [ ! -f "$checksums" ]; then
|
|
curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/checksums.txt" -o "$checksums" \
|
|
|| err "Could not download checksums.txt; refusing unverified release assets."
|
|
fi
|
|
expected="$(awk -v asset="$name" '$2 == asset {print $1; exit}' "$checksums")"
|
|
[ -n "$expected" ] || err "${name} is missing from checksums.txt."
|
|
actual="$(sha256_file "$file")" || err "Could not compute SHA256 for ${name}."
|
|
[ "$actual" = "$expected" ] || err "SHA256 checksum mismatch for ${name}."
|
|
say "✅ SHA256 checksum verified: ${name}"
|
|
}
|
|
|
|
copy_tree() {
|
|
src="$1"; dest="$2"; parent="$(dirname "$dest")"
|
|
mkdir -p "$parent" || return 1
|
|
stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1
|
|
if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi
|
|
backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; }
|
|
if mv "$stage" "$dest"; then return 0; fi
|
|
rm -rf "$stage"
|
|
if [ -n "$backup" ] && ! mv "$backup" "$dest"; then
|
|
printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# refresh_cache_tree <src> <dest>
|
|
# Ephemeral ~/.dws/skills cache refresh, identical to install-event.sh. The tree
|
|
# is byte-reproducible from the release asset, so it is never archived into
|
|
# ~/.dws/skill-backups; unlike the previous `rm -rf` + `cp` it only deletes the
|
|
# old cache after the replacement is fully staged, so an interrupted install can
|
|
# no longer leave a half-copied cache behind.
|
|
refresh_cache_tree() {
|
|
src="$1"; dest="$2"; parent="$(dirname "$dest")"
|
|
mkdir -p "$parent" || return 1
|
|
stage="$(mktemp -d "$parent/.dws-cache.tmp.XXXXXX")" || return 1
|
|
if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi
|
|
old=""
|
|
if [ -e "$dest" ] || [ -L "$dest" ]; then
|
|
old="$(mktemp -d "$parent/.dws-cache.old.XXXXXX")" || { rm -rf "$stage"; return 1; }
|
|
rmdir "$old" || { rm -rf "$stage" "$old"; return 1; }
|
|
if ! mv "$dest" "$old"; then rm -rf "$stage"; return 1; fi
|
|
fi
|
|
if mv "$stage" "$dest"; then
|
|
if [ -n "$old" ] && ! rm -rf "$old"; then
|
|
printf ' ⚠️ 新缓存已生效,但旧缓存清理失败: %s\n' "$old" >&2
|
|
fi
|
|
return 0
|
|
fi
|
|
rm -rf "$stage"
|
|
if [ -n "$old" ] && ! mv "$old" "$dest"; then
|
|
printf ' ⚠️ 缓存刷新失败,原缓存保留在 %s\n' "$old" >&2
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
backup_skill_dir() {
|
|
victim="$1"
|
|
[ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; }
|
|
# Prune before creating this run's archive; 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
|
|
}
|
|
|
|
need_cmd curl
|
|
|
|
detect_os() {
|
|
case "$(uname -s)" in
|
|
Linux*) echo linux ;;
|
|
Darwin*) echo darwin ;;
|
|
MINGW*|MSYS*|CYGWIN*) echo windows ;; # Git Bash / MSYS2 / Cygwin on Windows
|
|
*) err "Unsupported OS: $(uname -s). On native Windows use install-devapp.ps1." ;;
|
|
esac
|
|
}
|
|
|
|
detect_arch() {
|
|
case "$(uname -m)" in
|
|
x86_64|amd64) echo amd64 ;;
|
|
arm64|aarch64) echo arm64 ;;
|
|
*) err "Unsupported architecture: $(uname -m)" ;;
|
|
esac
|
|
}
|
|
|
|
# Read the releases list (newest first) and take the top tag, so this also works
|
|
# if a release is ever published as a prerelease (which /releases/latest skips).
|
|
# Prefer `gh` CLI (authenticated, 5 000 req/h) over raw curl (60 req/h, easily rate-limited).
|
|
resolve_version() {
|
|
[ -n "$DEVAPP_VERSION" ] && return 0
|
|
|
|
# Try gh CLI first (authenticated, much higher rate limit)
|
|
if command -v gh >/dev/null 2>&1; then
|
|
DEVAPP_VERSION="$(gh api "repos/${DEVAPP_REPO}/releases?per_page=1" --jq '.[0].tag_name' 2>/dev/null || true)"
|
|
[ -n "$DEVAPP_VERSION" ] && return 0
|
|
fi
|
|
|
|
# Fallback: unauthenticated curl (may be rate-limited)
|
|
_tmpfile="$(mktemp)"
|
|
_http_code="$(curl -sSL -o "$_tmpfile" -w '%{http_code}' "https://api.github.com/repos/${DEVAPP_REPO}/releases?per_page=1" 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 the GitHub CLI (gh) or set DEVAPP_VERSION explicitly."
|
|
fi
|
|
|
|
DEVAPP_VERSION="$(grep -m1 '"tag_name"' "$_tmpfile" | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')"
|
|
rm -f "$_tmpfile"
|
|
[ -n "$DEVAPP_VERSION" ] || err "No release found on ${DEVAPP_REPO}. Set DEVAPP_VERSION to a published release tag."
|
|
}
|
|
|
|
install_skill() {
|
|
bundle="$1" # extracted skills bundle dir
|
|
src=""
|
|
for c in "$bundle/multi/$SKILL_NAME" "$bundle/skills/multi/$SKILL_NAME" "$bundle/$SKILL_NAME"; do
|
|
[ -f "$c/SKILL.md" ] && src="$c" && break
|
|
done
|
|
[ -n "$src" ] || { say " (dingtalk-misc not found in skills bundle; skipped)"; return 0; }
|
|
|
|
# cache so `dws skill setup --mode multi` can find a source later
|
|
cache="$HOME/.dws/skills/multi/$SKILL_NAME"
|
|
refresh_cache_tree "$src" "$cache" || { printf ' ⚠️ Skill 缓存刷新失败: %s\n' "$cache" >&2; return 1; }
|
|
|
|
canonical="$HOME/.agents/skills/$SKILL_NAME"
|
|
copy_tree "$src" "$canonical" || return 1
|
|
installed=1
|
|
failed=0
|
|
retire_failed=0
|
|
for agent_dir in $(agent_skill_dirs); do
|
|
base="$(resolve_agent_skill_base "$agent_dir")"
|
|
agent_skill_base_detected "$agent_dir" "$base" || continue
|
|
same_physical_skill "$base" "$HOME/.agents/skills" && continue
|
|
if is_cleanup_only_agent_dir "$agent_dir"; then
|
|
# Cleanup-only: universal Agents read the canonical store directly, so
|
|
# nothing is installed here. A retire failure only leaves an obsolete copy
|
|
# behind and must never fail an otherwise complete install.
|
|
if ! backup_skill_dir "$base/$SKILL_NAME" >/dev/null; then
|
|
printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): %s\n' "$base/$SKILL_NAME" >&2
|
|
retire_failed=$((retire_failed + 1))
|
|
fi
|
|
continue
|
|
fi
|
|
# Per-agent degrade like install.sh: a failed target is reported and
|
|
# skipped loudly instead of aborting the remaining agents mid-loop.
|
|
if ! link_or_copy_skill "$canonical" "$src" "$base/$SKILL_NAME"; then
|
|
printf ' ⚠️ Agent 目标安装失败,已跳过: %s\n' "$base/$SKILL_NAME" >&2
|
|
failed=$((failed + 1))
|
|
continue
|
|
fi
|
|
installed=$((installed + 1))
|
|
done
|
|
if [ "$retire_failed" -gt 0 ]; then
|
|
printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" >&2
|
|
fi
|
|
if [ "$failed" -gt 0 ]; then
|
|
printf ' ⚠️ 有 %s 个 Agent 目标安装 %s 失败\n' "$failed" "$SKILL_NAME" >&2
|
|
return 1
|
|
fi
|
|
say "✅ Skill dingtalk-misc → ${installed} agent dir(s)"
|
|
}
|
|
|
|
main() {
|
|
resolve_version
|
|
os="$(detect_os)"; arch="$(detect_arch)"
|
|
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT INT TERM
|
|
|
|
printf '\n'
|
|
say "dws dev installer (pre-built binary)"
|
|
say "Repo: ${DEVAPP_REPO}"
|
|
say "Version: ${DEVAPP_VERSION}"
|
|
say "Target: ${os}/${arch}"
|
|
printf '\n'
|
|
|
|
# 1) binary (already ad-hoc signed by CI; copy does not break the signature)
|
|
if [ "$os" = "windows" ]; then
|
|
asset="dws-windows-${arch}.zip"; binname="dws.exe"
|
|
else
|
|
asset="dws-${os}-${arch}.tar.gz"; binname="dws"
|
|
fi
|
|
say "⬇ Downloading ${asset} ..."
|
|
curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/${asset}" -o "$tmp/$asset" \
|
|
|| err "Binary download failed — does release ${DEVAPP_VERSION} have ${asset}?"
|
|
verify_release_asset "$asset" "$tmp/$asset"
|
|
if [ "$os" = "windows" ]; then
|
|
need_cmd unzip; unzip -q "$tmp/$asset" -d "$tmp"
|
|
else
|
|
need_cmd tar; tar -xzf "$tmp/$asset" -C "$tmp"
|
|
fi
|
|
[ -f "$tmp/$binname" ] || err "${binname} not found inside ${asset}"
|
|
mkdir -p "$INSTALL_DIR"
|
|
cp "$tmp/$binname" "$INSTALL_DIR/$binname"; chmod +x "$INSTALL_DIR/$binname" 2>/dev/null || true
|
|
say "✅ Binary → ${INSTALL_DIR}/${binname}"
|
|
|
|
# 2) dev skill from the release's skills bundle
|
|
if [ "$NO_SKILLS" != "1" ]; then
|
|
if curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/dws-skills.zip" -o "$tmp/skills.zip" 2>/dev/null; then
|
|
verify_release_asset "dws-skills.zip" "$tmp/skills.zip"
|
|
mkdir -p "$tmp/sk"
|
|
if command -v unzip >/dev/null 2>&1; then unzip -q "$tmp/skills.zip" -d "$tmp/sk"; else tar -xf "$tmp/skills.zip" -C "$tmp/sk"; fi
|
|
say ""
|
|
install_skill "$tmp/sk" || err "dingtalk-misc Skill 安装失败,详见上方告警"
|
|
else
|
|
say " (no dws-skills.zip in release ${DEVAPP_VERSION}; skill skipped)"
|
|
fi
|
|
fi
|
|
|
|
printf '\n'
|
|
say "🎉 Done. Next steps:"
|
|
say " dws version"
|
|
say " dws auth login"
|
|
say " dws dev --help --format json"
|
|
say " dws dev app list --format json"
|
|
printf '\n'
|
|
case ":$PATH:" in
|
|
*":$INSTALL_DIR:"*) ;;
|
|
*) say "Note: ${INSTALL_DIR} is not on \$PATH — add it so 'dws' is found." ;;
|
|
esac
|
|
}
|
|
|
|
main
|