|
| 1 | +#!/usr/bin/env bash |
| 2 | +# install-cli.sh — shadow-dev CLI 的拉取/更新/回滚安装器(供 shadow-dev-workflow 插件钩子与人工共用) |
| 3 | +# |
| 4 | +# 接缝契约(与 README「安装与分发」同源): |
| 5 | +# commands: install | update(=install) | rollback | status |
| 6 | +# options : --channel release|main --version vA.B.C --from <tarball|dir> |
| 7 | +# --prefix DIR(~/.local/share/shadow-dev-cli) --bin DIR(~/.local/bin) |
| 8 | +# --force --dry-run --json(单行机器输出) |
| 9 | +# exit : 0 成功/已最新 | 1 参数或冲突 | 2 网络/API | 3 产物自校验失败 |
| 10 | +# 布局 : $PREFIX/shadow-dev-cli-<ver>/{cli.mjs,lib,...};CURRENT/PREVIOUS 为版本指针文本文件; |
| 11 | +# $BIN/shadow-dev(.cmd) 为托管 shim(头标 managed-by,运行时读 CURRENT → 更新不动 shim) |
| 12 | +# 信任边界: HTTPS + GitHub 仓库;发布前自校验(node cli.mjs help --json 断言 ok)失败则指针不动 |
| 13 | +set -euo pipefail |
| 14 | + |
| 15 | +REPO_SLUG="stack-wuh/shadow-dev-cli" |
| 16 | +API="https://api.github.com/repos/$REPO_SLUG" |
| 17 | +WEB="https://github.com/$REPO_SLUG" |
| 18 | +MARK="managed-by: shadow-dev-cli-installer" |
| 19 | + |
| 20 | +CMD="install"; CHANNEL="release"; VERSION=""; FROM=""; FORCE=0; DRY=0; JSON=0 |
| 21 | +PREFIX="${SD_PREFIX:-$HOME/.local/share/shadow-dev-cli}" |
| 22 | +BIN="${SD_BIN:-$HOME/.local/bin}" |
| 23 | + |
| 24 | +json() { if [ "$JSON" -eq 1 ]; then printf '%s\n' "$1"; fi; } |
| 25 | +log() { if [ "$JSON" -ne 1 ]; then printf '%s\n' "$1"; fi; } |
| 26 | +die() { # die <exit> <error> <message> |
| 27 | + json "{\"ok\":false,\"error\":\"$2\",\"message\":\"$3\"}" |
| 28 | + printf '%s: %s\n' "$2" "$3" >&2 || true |
| 29 | + exit "$1" |
| 30 | +} |
| 31 | + |
| 32 | +while [ $# -gt 0 ]; do |
| 33 | + case "$1" in |
| 34 | + install|update|rollback|status) CMD="$1"; shift |
| 35 | + ;; |
| 36 | + --channel) [ $# -ge 2 ] || die 1 usage "--channel needs release|main"; CHANNEL="$2"; shift 2 |
| 37 | + ;; |
| 38 | + --version) [ $# -ge 2 ] || die 1 usage "--version needs vA.B.C"; VERSION="$2"; shift 2 |
| 39 | + ;; |
| 40 | + --from) [ $# -ge 2 ] || die 1 usage "--from needs <tarball|dir>"; FROM="$2"; shift 2 |
| 41 | + ;; |
| 42 | + --prefix) [ $# -ge 2 ] || die 1 usage "--prefix needs DIR"; PREFIX="$2"; shift 2 |
| 43 | + ;; |
| 44 | + --bin) [ $# -ge 2 ] || die 1 usage "--bin needs DIR"; BIN="$2"; shift 2 |
| 45 | + ;; |
| 46 | + --force) FORCE=1; shift ;; |
| 47 | + --dry-run) DRY=1; shift ;; |
| 48 | + --json) JSON=1; shift ;; |
| 49 | + *) die 1 usage "unknown argument: $1" ;; |
| 50 | + esac |
| 51 | +done |
| 52 | + |
| 53 | +command -v node >/dev/null || die 1 missing-node "node is required (>=20)" |
| 54 | +if [ "$CMD" = install ] || [ "$CMD" = update ]; then |
| 55 | + if [ -n "$FROM" ] && { [ -n "$VERSION" ] || [ "$CHANNEL" = main ]; }; then die 1 usage "--from conflicts with --version/--channel main"; fi |
| 56 | + if [ "$CHANNEL" = main ] && [ -n "$VERSION" ]; then die 1 usage "--version conflicts with --channel main"; fi |
| 57 | +fi |
| 58 | + |
| 59 | +# ---- 依赖与工具 ---- |
| 60 | +DL() { # DL <url> <out>:curl 优先,wget 兜底,带可选 token |
| 61 | + local hdr=() |
| 62 | + [ -n "${GITHUB_TOKEN:-}${GH_TOKEN:-}" ] && hdr=(-H "Authorization: Bearer ${GITHUB_TOKEN:-$GH_TOKEN}") |
| 63 | + if command -v curl >/dev/null; then |
| 64 | + curl -fsSL -H "Accept: application/vnd.github+json" -H "User-Agent: shadow-dev-installer" ${hdr[@]+"${hdr[@]}"} -o "$2" "$1" |
| 65 | + elif command -v wget >/dev/null; then |
| 66 | + wget -q ${hdr[@]+"${hdr[@]}"} -O "$2" "$1" |
| 67 | + else |
| 68 | + return 127 |
| 69 | + fi |
| 70 | +} |
| 71 | +verof() { node -pe "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).version" "$1/package.json"; } |
| 72 | + |
| 73 | +# ---- 锁(陈旧>10min 自动接管)---- |
| 74 | +LOCK="$PREFIX/.lock" |
| 75 | +mkdir -p "$PREFIX" |
| 76 | +if ! mkdir "$LOCK" 2>/dev/null; then |
| 77 | + if [ -n "$(find "$LOCK" -maxdepth 0 -mmin +10 2>/dev/null)" ]; then rm -rf "$LOCK"; mkdir "$LOCK" || die 1 busy "cannot acquire lock"; |
| 78 | + else die 1 busy "another install-cli run is in progress ($LOCK)"; fi |
| 79 | +fi |
| 80 | +trap 'rm -rf "$LOCK" 2>/dev/null || true' EXIT |
| 81 | + |
| 82 | +# ---- 离线命令先行:rollback/status 不触网、不解析远端 ---- |
| 83 | +CUR="$(cat "$PREFIX/CURRENT" 2>/dev/null || true)" |
| 84 | +if [ "$CMD" = rollback ]; then |
| 85 | + PREV="$(cat "$PREFIX/PREVIOUS" 2>/dev/null || true)" |
| 86 | + [ -n "$PREV" ] && [ -d "$PREFIX/shadow-dev-cli-$PREV" ] || die 1 no-rollback "no previous version available" |
| 87 | + printf '%s\n' "$CUR" > "$PREFIX/PREVIOUS"; printf '%s\n' "$PREV" > "$PREFIX/CURRENT" |
| 88 | + json "{\"ok\":true,\"action\":\"rollback\",\"current\":\"$PREV\",\"previous\":\"${CUR:-null}\"}" |
| 89 | + log "rolled back: $CUR -> $PREV" |
| 90 | + exit 0 |
| 91 | +fi |
| 92 | +if [ "$CMD" = status ]; then |
| 93 | + PRV="$(cat "$PREFIX/PREVIOUS" 2>/dev/null || true)" |
| 94 | + json "{\"ok\":true,\"current\":\"${CUR:-null}\",\"previous\":\"${PRV:-null}\"}" |
| 95 | + log "current=${CUR:-<none>} previous=${PRV:-<none>}" |
| 96 | + exit 0 |
| 97 | +fi |
| 98 | + |
| 99 | +# ---- 解析目标版本与物化产物(release/main/--from 三路同构:得到 WORK 目录 + VER)---- |
| 100 | +WORK=""; VER=""; TMP="" |
| 101 | +if [ -n "$FROM" ]; then |
| 102 | + if [ -d "$FROM" ]; then |
| 103 | + [ -f "$FROM/cli.mjs" ] && [ -f "$FROM/package.json" ] || die 3 artifact "--from dir lacks cli.mjs/package.json" |
| 104 | + WORK="$FROM" |
| 105 | + else |
| 106 | + command -v tar >/dev/null || die 1 missing-tar "tar is required for tarball install" |
| 107 | + TMP="$(mktemp -d)"; trap 'rm -rf "$LOCK" "$TMP" 2>/dev/null || true' EXIT |
| 108 | + tar -xzf "$FROM" -C "$TMP" |
| 109 | + WORK="$TMP/shadow-dev-cli" |
| 110 | + [ -f "$WORK/cli.mjs" ] && [ -f "$WORK/package.json" ] || die 3 artifact "tarball lacks shadow-dev-cli/cli.mjs (invalid artifact)" |
| 111 | + fi |
| 112 | + VER="$(verof "$WORK")" |
| 113 | +elif [ "$CHANNEL" = main ]; then |
| 114 | + command -v git >/dev/null || die 1 missing-git "git is required for --channel main" |
| 115 | + TMP="$(mktemp -d)"; trap 'rm -rf "$LOCK" "$TMP" 2>/dev/null || true' EXIT |
| 116 | + git clone --quiet --depth 1 "$WEB" "$TMP/clone" 2>/dev/null || die 2 network "git clone failed (check network/credentials)" |
| 117 | + SHA="$(git -C "$TMP/clone" rev-parse --short=8 HEAD)" |
| 118 | + WORK="$TMP/clone"; VER="$(verof "$WORK")-main.$SHA" |
| 119 | +else |
| 120 | + command -v tar >/dev/null || die 1 missing-tar "tar is required" |
| 121 | + API_PATH=$([ -n "$VERSION" ] && echo "/releases/tags/$VERSION" || echo "/releases/latest") |
| 122 | + TMP="$(mktemp -d)"; trap 'rm -rf "$LOCK" "$TMP" 2>/dev/null || true' EXIT |
| 123 | + DL "$API$API_PATH" "$TMP/rel.json" || die 2 network "GitHub API request failed ($API$API_PATH)" |
| 124 | + URL="$(node -pe ' |
| 125 | + const j = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")) |
| 126 | + const a = (j.assets || []).find(x => /^shadow-dev-cli-v[0-9][0-9.]*\.tar\.gz$/.test(x.name)) |
| 127 | + if (!a) process.exit(1) |
| 128 | + process.stdout.write(a.browser_download_url) |
| 129 | + ' "$TMP/rel.json")" || die 2 network "release asset not found" |
| 130 | + VER="$(node -pe 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).tag_name.replace(/^v/,"")' "$TMP/rel.json")" |
| 131 | + DL "$URL" "$TMP/artifact.tgz" || die 2 network "artifact download failed" |
| 132 | + tar -xzf "$TMP/artifact.tgz" -C "$TMP" |
| 133 | + WORK="$TMP/shadow-dev-cli" |
| 134 | + [ -f "$WORK/cli.mjs" ] && [ -f "$WORK/package.json" ] || die 3 artifact "extracted artifact lacks cli.mjs (invalid)" |
| 135 | +fi |
| 136 | +[ "$(verof "$WORK")" = "${VER%%-main.*}" ] || die 3 artifact "version mismatch between tag and package.json" |
| 137 | + |
| 138 | +if [ "$CUR" = "$VER" ] && [ -d "$PREFIX/shadow-dev-cli-$VER" ] && [ "$FORCE" -eq 0 ]; then |
| 139 | + json "{\"ok\":true,\"action\":\"none\",\"version\":\"$VER\"}"; exit 0 |
| 140 | +fi |
| 141 | + |
| 142 | +# ---- 冲突保护:非托管同名 shim 在任何写盘前失败退出 ---- |
| 143 | +shim_guard() { |
| 144 | + for f in "$BIN/shadow-dev" "$BIN/shadow-dev.cmd"; do |
| 145 | + [ -e "$f" ] || continue |
| 146 | + grep -q "$MARK" "$f" 2>/dev/null || die 1 unmanaged-shim "unmanaged file occupies shim path: $f — rename it or pass --bin elsewhere (never overwriting silently)" |
| 147 | + done |
| 148 | +} |
| 149 | +shim_guard |
| 150 | +if [ "$DRY" -eq 1 ]; then |
| 151 | + json "{\"ok\":true,\"action\":\"dry-run\",\"version\":\"$VER\",\"current\":\"${CUR:-null}\",\"prefix\":\"$PREFIX/shadow-dev-cli-$VER\"}" |
| 152 | + log "dry-run: would install $VER to $PREFIX/shadow-dev-cli-$VER and refresh shims in $BIN" |
| 153 | + exit 0 |
| 154 | +fi |
| 155 | + |
| 156 | +# ---- 物化 + 自校验(发布前,失败指针不动)---- |
| 157 | +DEST="$PREFIX/shadow-dev-cli-$VER" |
| 158 | +rm -rf "$DEST"; mkdir -p "$DEST" |
| 159 | +cp -r "$WORK/." "$DEST/" |
| 160 | +node "$DEST/cli.mjs" help --json 2>/dev/null | grep -q '"ok":true' || { rm -rf "$DEST"; die 3 selfcheck "installed cli.mjs failed 'help --json' smoke test"; } |
| 161 | + |
| 162 | +# ---- 原子发布:切指针,保留上一版供回滚,清理更旧版本 ---- |
| 163 | +[ -n "$CUR" ] && [ "$CUR" != "$VER" ] && printf '%s\n' "$CUR" > "$PREFIX/PREVIOUS" |
| 164 | +printf '%s\n' "$VER" > "$PREFIX/CURRENT" |
| 165 | +PRV="$(cat "$PREFIX/PREVIOUS" 2>/dev/null || true)" |
| 166 | +for d in "$PREFIX"/shadow-dev-cli-*; do |
| 167 | + [ -d "$d" ] || continue |
| 168 | + b="$(basename "$d")" |
| 169 | + [ "$b" = "shadow-dev-cli-$VER" ] && continue |
| 170 | + [ "$b" = "shadow-dev-cli-$PRV" ] && continue |
| 171 | + rm -rf "$d" |
| 172 | +done |
| 173 | + |
| 174 | +# ---- 托管 shim:运行时读 CURRENT,更新不再动 shim 文件 ---- |
| 175 | +mkdir -p "$BIN" |
| 176 | +{ |
| 177 | + echo '#!/bin/sh' |
| 178 | + echo "# $MARK v1 — generated file, regenerate via install-cli.sh, do not edit" |
| 179 | + echo "root='$PREFIX'" |
| 180 | + echo "v=\$(cat \"\$root/CURRENT\" 2>/dev/null)" |
| 181 | + echo "if [ -z \"\$v\" ]; then echo 'shadow-dev: not installed — run install-cli.sh install' >&2; exit 1; fi" |
| 182 | + echo "exec node \"\$root/shadow-dev-cli-\$v/cli.mjs\" \"\$@\"" |
| 183 | +} > "$BIN/shadow-dev" |
| 184 | +chmod +x "$BIN/shadow-dev" |
| 185 | +case "$(uname -s 2>/dev/null)" in |
| 186 | + MINGW*|MSYS*|CYGWIN*) |
| 187 | + WINROOT="$PREFIX" |
| 188 | + command -v cygpath >/dev/null && WINROOT="$(cygpath -w "$PREFIX")" |
| 189 | + { |
| 190 | + echo '@echo off' |
| 191 | + echo "rem $MARK v1 — generated file, regenerate via install-cli.sh, do not edit" |
| 192 | + echo "set \"ROOT=$WINROOT\"" |
| 193 | + echo 'set /p V=<"%ROOT%\CURRENT"' |
| 194 | + echo 'if "%V%"=="" (echo shadow-dev: not installed 1>&2 & exit /b 1)' |
| 195 | + echo 'node "%ROOT%\shadow-dev-cli-%V%\cli.mjs" %*' |
| 196 | + } > "$BIN/shadow-dev.cmd" |
| 197 | + ;; |
| 198 | +esac |
| 199 | + |
| 200 | +# ---- 安装后冒烟 + PATH 提示 ---- |
| 201 | +"$BIN/shadow-dev" help 2>/dev/null | grep -q '"ok":true' || die 3 selfcheck "installed shim failed smoke test" |
| 202 | +case ":$PATH:" in *":$BIN:"*) ;; *) log "warn: $BIN is not on PATH — add it (unix: export PATH=\"$BIN:\$PATH\"; windows setx PATH \"%PATH%;%USERPROFILE%\.local\\bin\")" ;; esac |
| 203 | + |
| 204 | +json "{\"ok\":true,\"action\":\"install\",\"version\":\"$VER\",\"previous\":\"${CUR:-null}\",\"prefix\":\"$PREFIX\",\"shim\":\"$BIN/shadow-dev\"}" |
| 205 | +log "shadow-dev $VER installed to $DEST (was: ${CUR:-none})" |
0 commit comments