Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,26 @@ CLI 在 stderr 渲染一层人类提示:进场横幅(命令+参数)、收

## 安装与分发

本 CLI 随 [shadow-dev-workflow](https://github.com/stack-wuh/shadow-dev-workflow) 插件通过安装脚本分发:插件仓库执行 `scripts/install-cli.sh` 从本仓库 release 拉取目录产物。独立使用时克隆本仓库后直接 `node cli.mjs --help`。
`scripts/install-cli.sh` 是唯一安装入口,供 [shadow-dev-workflow](https://github.com/stack-wuh/shadow-dev-workflow) 插件钩子与手工共用:

```bash
bash scripts/install-cli.sh install # 拉取最新 release,物化+自校验+生成托管 shim
bash scripts/install-cli.sh install --json # 插件钩子用:单行机器输出(幂等,已最新秒退)
bash scripts/install-cli.sh status --json # 当前/上一版本指针
bash scripts/install-cli.sh rollback # 切回上一版(离线,不触网)
bash scripts/install-cli.sh install --from dist/shadow-dev-cli-v1.1.0.tar.gz # 离线安装
```

- 布局:`~/.local/share/shadow-dev-cli/shadow-dev-cli-<ver>/` + `CURRENT`/`PREVIOUS` 指针文件;shim(`~/.local/bin/shadow-dev` 与 `.cmd`)运行时读指针——更新与回滚都不再改动 shim 文件。自定义位置用 `--prefix` / `--bin`。
- 安全边界:发布前先物化并自跑 `help --json`,失败则指针不动(旧版本照常可用);shim 路径被**非托管**同名文件占用时告警退出、绝不覆盖;并发运行有锁(陈旧 10 分钟自动接管)。
- 退出码:`0` 成功/已最新 · `1` 参数或冲突 · `2` 网络/GitHub API · `3` 产物自校验失败。信任边界为 HTTPS + GitHub 仓库,未做独立校验和。
- 通道:默认 release(可复现);`--version v*` 锁版本;`--channel main` git 浅拉 rolling,仅供插件开发。依赖 bash + node(≥20) + tar(main 通道另需 git;curl 缺失自动退 wget),Windows 在 Git Bash 下运行。
- 纯手工使用(不装 shim):克隆本仓库后直接 `node cli.mjs --help`。

## 开发

```bash
npm test # node --test,47 个契约测试覆盖全部命令域、stderr 人用层与凭证链
npm test # node --test,49 项 CLI 契约 + 6 项安装器契约(离线产物全链、冲突保护、回滚、自校验)
```

行为契约:命令、JSON 输出结构、错误码、planHash 机制保持稳定;`test/cli.test.mjs` 是唯一契约规格。
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@
"shadow-dev": "cli.mjs"
},
"scripts": {
"test": "node --test test/cli.test.mjs"
"test": "node --test test/cli.test.mjs test/install.test.mjs"
}
}
205 changes: 205 additions & 0 deletions scripts/install-cli.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
#!/usr/bin/env bash
# install-cli.sh — shadow-dev CLI 的拉取/更新/回滚安装器(供 shadow-dev-workflow 插件钩子与人工共用)
#
# 接缝契约(与 README「安装与分发」同源):
# commands: install | update(=install) | rollback | status
# options : --channel release|main --version vA.B.C --from <tarball|dir>
# --prefix DIR(~/.local/share/shadow-dev-cli) --bin DIR(~/.local/bin)
# --force --dry-run --json(单行机器输出)
# exit : 0 成功/已最新 | 1 参数或冲突 | 2 网络/API | 3 产物自校验失败
# 布局 : $PREFIX/shadow-dev-cli-<ver>/{cli.mjs,lib,...};CURRENT/PREVIOUS 为版本指针文本文件;
# $BIN/shadow-dev(.cmd) 为托管 shim(头标 managed-by,运行时读 CURRENT → 更新不动 shim)
# 信任边界: HTTPS + GitHub 仓库;发布前自校验(node cli.mjs help --json 断言 ok)失败则指针不动
set -euo pipefail

REPO_SLUG="stack-wuh/shadow-dev-cli"
API="https://api.github.com/repos/$REPO_SLUG"
WEB="https://github.com/$REPO_SLUG"
MARK="managed-by: shadow-dev-cli-installer"

CMD="install"; CHANNEL="release"; VERSION=""; FROM=""; FORCE=0; DRY=0; JSON=0
PREFIX="${SD_PREFIX:-$HOME/.local/share/shadow-dev-cli}"
BIN="${SD_BIN:-$HOME/.local/bin}"

json() { if [ "$JSON" -eq 1 ]; then printf '%s\n' "$1"; fi; }
log() { if [ "$JSON" -ne 1 ]; then printf '%s\n' "$1"; fi; }
die() { # die <exit> <error> <message>
json "{\"ok\":false,\"error\":\"$2\",\"message\":\"$3\"}"
printf '%s: %s\n' "$2" "$3" >&2 || true
exit "$1"
}

while [ $# -gt 0 ]; do
case "$1" in
install|update|rollback|status) CMD="$1"; shift
;;
--channel) [ $# -ge 2 ] || die 1 usage "--channel needs release|main"; CHANNEL="$2"; shift 2
;;
--version) [ $# -ge 2 ] || die 1 usage "--version needs vA.B.C"; VERSION="$2"; shift 2
;;
--from) [ $# -ge 2 ] || die 1 usage "--from needs <tarball|dir>"; FROM="$2"; shift 2
;;
--prefix) [ $# -ge 2 ] || die 1 usage "--prefix needs DIR"; PREFIX="$2"; shift 2
;;
--bin) [ $# -ge 2 ] || die 1 usage "--bin needs DIR"; BIN="$2"; shift 2
;;
--force) FORCE=1; shift ;;
--dry-run) DRY=1; shift ;;
--json) JSON=1; shift ;;
*) die 1 usage "unknown argument: $1" ;;
esac
done

command -v node >/dev/null || die 1 missing-node "node is required (>=20)"
if [ "$CMD" = install ] || [ "$CMD" = update ]; then
if [ -n "$FROM" ] && { [ -n "$VERSION" ] || [ "$CHANNEL" = main ]; }; then die 1 usage "--from conflicts with --version/--channel main"; fi
if [ "$CHANNEL" = main ] && [ -n "$VERSION" ]; then die 1 usage "--version conflicts with --channel main"; fi
fi

# ---- 依赖与工具 ----
DL() { # DL <url> <out>:curl 优先,wget 兜底,带可选 token
local hdr=()
[ -n "${GITHUB_TOKEN:-}${GH_TOKEN:-}" ] && hdr=(-H "Authorization: Bearer ${GITHUB_TOKEN:-$GH_TOKEN}")
if command -v curl >/dev/null; then
curl -fsSL -H "Accept: application/vnd.github+json" -H "User-Agent: shadow-dev-installer" ${hdr[@]+"${hdr[@]}"} -o "$2" "$1"
elif command -v wget >/dev/null; then
wget -q ${hdr[@]+"${hdr[@]}"} -O "$2" "$1"
else
return 127
fi
}
verof() { node -pe "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).version" "$1/package.json"; }

# ---- 锁(陈旧>10min 自动接管)----
LOCK="$PREFIX/.lock"
mkdir -p "$PREFIX"
if ! mkdir "$LOCK" 2>/dev/null; then
if [ -n "$(find "$LOCK" -maxdepth 0 -mmin +10 2>/dev/null)" ]; then rm -rf "$LOCK"; mkdir "$LOCK" || die 1 busy "cannot acquire lock";
else die 1 busy "another install-cli run is in progress ($LOCK)"; fi
fi
trap 'rm -rf "$LOCK" 2>/dev/null || true' EXIT

# ---- 离线命令先行:rollback/status 不触网、不解析远端 ----
CUR="$(cat "$PREFIX/CURRENT" 2>/dev/null || true)"
if [ "$CMD" = rollback ]; then
PREV="$(cat "$PREFIX/PREVIOUS" 2>/dev/null || true)"
[ -n "$PREV" ] && [ -d "$PREFIX/shadow-dev-cli-$PREV" ] || die 1 no-rollback "no previous version available"
printf '%s\n' "$CUR" > "$PREFIX/PREVIOUS"; printf '%s\n' "$PREV" > "$PREFIX/CURRENT"
json "{\"ok\":true,\"action\":\"rollback\",\"current\":\"$PREV\",\"previous\":\"${CUR:-null}\"}"
log "rolled back: $CUR -> $PREV"
exit 0
fi
if [ "$CMD" = status ]; then
PRV="$(cat "$PREFIX/PREVIOUS" 2>/dev/null || true)"
json "{\"ok\":true,\"current\":\"${CUR:-null}\",\"previous\":\"${PRV:-null}\"}"
log "current=${CUR:-<none>} previous=${PRV:-<none>}"
exit 0
fi

# ---- 解析目标版本与物化产物(release/main/--from 三路同构:得到 WORK 目录 + VER)----
WORK=""; VER=""; TMP=""
if [ -n "$FROM" ]; then
if [ -d "$FROM" ]; then
[ -f "$FROM/cli.mjs" ] && [ -f "$FROM/package.json" ] || die 3 artifact "--from dir lacks cli.mjs/package.json"
WORK="$FROM"
else
command -v tar >/dev/null || die 1 missing-tar "tar is required for tarball install"
TMP="$(mktemp -d)"; trap 'rm -rf "$LOCK" "$TMP" 2>/dev/null || true' EXIT
tar -xzf "$FROM" -C "$TMP"
WORK="$TMP/shadow-dev-cli"
[ -f "$WORK/cli.mjs" ] && [ -f "$WORK/package.json" ] || die 3 artifact "tarball lacks shadow-dev-cli/cli.mjs (invalid artifact)"
fi
VER="$(verof "$WORK")"
elif [ "$CHANNEL" = main ]; then
command -v git >/dev/null || die 1 missing-git "git is required for --channel main"
TMP="$(mktemp -d)"; trap 'rm -rf "$LOCK" "$TMP" 2>/dev/null || true' EXIT
git clone --quiet --depth 1 "$WEB" "$TMP/clone" 2>/dev/null || die 2 network "git clone failed (check network/credentials)"
SHA="$(git -C "$TMP/clone" rev-parse --short=8 HEAD)"
WORK="$TMP/clone"; VER="$(verof "$WORK")-main.$SHA"
else
command -v tar >/dev/null || die 1 missing-tar "tar is required"
API_PATH=$([ -n "$VERSION" ] && echo "/releases/tags/$VERSION" || echo "/releases/latest")
TMP="$(mktemp -d)"; trap 'rm -rf "$LOCK" "$TMP" 2>/dev/null || true' EXIT
DL "$API$API_PATH" "$TMP/rel.json" || die 2 network "GitHub API request failed ($API$API_PATH)"
URL="$(node -pe '
const j = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))
const a = (j.assets || []).find(x => /^shadow-dev-cli-v[0-9][0-9.]*\.tar\.gz$/.test(x.name))
if (!a) process.exit(1)
process.stdout.write(a.browser_download_url)
' "$TMP/rel.json")" || die 2 network "release asset not found"
VER="$(node -pe 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).tag_name.replace(/^v/,"")' "$TMP/rel.json")"
DL "$URL" "$TMP/artifact.tgz" || die 2 network "artifact download failed"
tar -xzf "$TMP/artifact.tgz" -C "$TMP"
WORK="$TMP/shadow-dev-cli"
[ -f "$WORK/cli.mjs" ] && [ -f "$WORK/package.json" ] || die 3 artifact "extracted artifact lacks cli.mjs (invalid)"
fi
[ "$(verof "$WORK")" = "${VER%%-main.*}" ] || die 3 artifact "version mismatch between tag and package.json"

if [ "$CUR" = "$VER" ] && [ -d "$PREFIX/shadow-dev-cli-$VER" ] && [ "$FORCE" -eq 0 ]; then
json "{\"ok\":true,\"action\":\"none\",\"version\":\"$VER\"}"; exit 0
fi

# ---- 冲突保护:非托管同名 shim 在任何写盘前失败退出 ----
shim_guard() {
for f in "$BIN/shadow-dev" "$BIN/shadow-dev.cmd"; do
[ -e "$f" ] || continue
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)"
done
}
shim_guard
if [ "$DRY" -eq 1 ]; then
json "{\"ok\":true,\"action\":\"dry-run\",\"version\":\"$VER\",\"current\":\"${CUR:-null}\",\"prefix\":\"$PREFIX/shadow-dev-cli-$VER\"}"
log "dry-run: would install $VER to $PREFIX/shadow-dev-cli-$VER and refresh shims in $BIN"
exit 0
fi

# ---- 物化 + 自校验(发布前,失败指针不动)----
DEST="$PREFIX/shadow-dev-cli-$VER"
rm -rf "$DEST"; mkdir -p "$DEST"
cp -r "$WORK/." "$DEST/"
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"; }

# ---- 原子发布:切指针,保留上一版供回滚,清理更旧版本 ----
[ -n "$CUR" ] && [ "$CUR" != "$VER" ] && printf '%s\n' "$CUR" > "$PREFIX/PREVIOUS"
printf '%s\n' "$VER" > "$PREFIX/CURRENT"
PRV="$(cat "$PREFIX/PREVIOUS" 2>/dev/null || true)"
for d in "$PREFIX"/shadow-dev-cli-*; do
[ -d "$d" ] || continue
b="$(basename "$d")"
[ "$b" = "shadow-dev-cli-$VER" ] && continue
[ "$b" = "shadow-dev-cli-$PRV" ] && continue
rm -rf "$d"
done

# ---- 托管 shim:运行时读 CURRENT,更新不再动 shim 文件 ----
mkdir -p "$BIN"
{
echo '#!/bin/sh'
echo "# $MARK v1 — generated file, regenerate via install-cli.sh, do not edit"
echo "root='$PREFIX'"
echo "v=\$(cat \"\$root/CURRENT\" 2>/dev/null)"
echo "if [ -z \"\$v\" ]; then echo 'shadow-dev: not installed — run install-cli.sh install' >&2; exit 1; fi"
echo "exec node \"\$root/shadow-dev-cli-\$v/cli.mjs\" \"\$@\""
} > "$BIN/shadow-dev"
chmod +x "$BIN/shadow-dev"
case "$(uname -s 2>/dev/null)" in
MINGW*|MSYS*|CYGWIN*)
WINROOT="$PREFIX"
command -v cygpath >/dev/null && WINROOT="$(cygpath -w "$PREFIX")"
{
echo '@echo off'
echo "rem $MARK v1 — generated file, regenerate via install-cli.sh, do not edit"
echo "set \"ROOT=$WINROOT\""
echo 'set /p V=<"%ROOT%\CURRENT"'
echo 'if "%V%"=="" (echo shadow-dev: not installed 1>&2 & exit /b 1)'
echo 'node "%ROOT%\shadow-dev-cli-%V%\cli.mjs" %*'
} > "$BIN/shadow-dev.cmd"
;;
esac

# ---- 安装后冒烟 + PATH 提示 ----
"$BIN/shadow-dev" help 2>/dev/null | grep -q '"ok":true' || die 3 selfcheck "installed shim failed smoke test"
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

json "{\"ok\":true,\"action\":\"install\",\"version\":\"$VER\",\"previous\":\"${CUR:-null}\",\"prefix\":\"$PREFIX\",\"shim\":\"$BIN/shadow-dev\"}"
log "shadow-dev $VER installed to $DEST (was: ${CUR:-none})"
94 changes: 94 additions & 0 deletions shadow-docs/changes/20260917-feature-install-cli-script/brief.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
---
{
"schema": "shadow-dev/v1",
"name": "20260917-feature-install-cli-script",
"type": "feature",
"scope": "scripts",
"status": "published",
"baseBranch": "main",
"branch": "feature/20260917-feature-install-cli-script",
"files": [
"README.md",
"scripts/install-cli.sh",
"test/install.test.mjs"
],
"github": {
"repository": "stack-wuh/shadow-dev-cli",
"issue": 11,
"issueUrl": "https://github.com/stack-wuh/shadow-dev-cli/issues/11",
"pullRequest": 13,
"pullRequestUrl": "https://github.com/stack-wuh/shadow-dev-cli/pull/13"
},
"review": {
"conclusion": "passed",
"verifiedCommit": "38b13641d0167403b99e557e3e1fe8df4d321183",
"verifiedAt": "2026-09-17T09:21:06.983Z"
},
"workflow": {
"operation": null,
"checkpoint": "pr:13",
"planHash": "9f6178c08054b867c2105d6331097ca8fbbab78d388d8dc468d044aa437d3e8e",
"updatedAt": null,
"lastError": null,
"issuePlan": {
"title": "install-cli.sh:shadow-dev CLI 拉取/更新/回滚安装器",
"body": "版本目录+CURRENT/PREVIOUS 指针、托管 shim 保护、发布前自校验、rollback/--json/--dry-run/--from 离线。供 shadow-dev-workflow 钩子以 install --json 幂等调用。契约测试含离线全链、冲突保护、回滚往返。",
"labels": [
"feature"
]
}
},
"knowledge": {
"action": "无需变更",
"target": null,
"reason": null
}
}
---

# install-cli.sh:shadow-dev CLI 拉取/更新/回滚安装器

## 动机

CLI 与插件(shadow-dev-workflow)的接缝目前是手工的:本次 v1.1.0 发布后,用户 PATH 上的旧 exe 靠人工改名+手写 shim 才切到新行为。插件钩子需要一个可重复、机器可校验、可回滚的入口来保证「`shadow-dev` 永远指向正确版本」;同时本机 `.local/bin/shadow-dev.exe` 被非托管二进制占用这类冲突必须显式保护而非静默覆盖。

## 引用规范

- norms/code-style.md(通用规范)
- 当前结论: 渐进式治理;脚本与 CLI 同仓演进,README 分发段落需同步。
- 适用 scope: `scripts/`、`README.md`
- shadow-docs/knowledge/cli-output-contract.md
- 当前结论: JSON 契约面按环境路由;退出码 0/1/2/3 语义(本脚本对齐其子集:0 成功/已最新、1 参数/冲突、2 网络、3 自校验失败)。
- 适用 scope: 脚本 `--json` 输出与钩子集成契约。

## 决策

- **选型:** 版本目录 `$PREFIX/shadow-dev-cli-<ver>/` + `CURRENT`/`PREVIOUS` 指针文件;shim 运行时读指针(更新不动盘、回滚=改指针);`install|update|rollback|status` 四命令 + `--channel release|main` / `--version` / `--from <tarball|dir>` 离线 / `--json` / `--dry-run` / `--force`;非托管同名 shim/二进制只告警退出;发布前 `node cli.mjs help --json` 自校验通过才切指针。
- **对比方案:** ① symlink 版本目录——Windows 无可靠 symlink,否;② curl|bash 直装——供应链面大且无锁,插件改为本地 clone 执行;③ 校验依赖 GitHub asset digest——可用性不稳,降级为结构校验+自跑(信任边界=HTTPS+仓库,README 注明)。
- **理由:** 插件钩子每次启动都跑 `install --json`,已最新秒退保护启动路径;失败/断网时旧指针版本照常可用(先物化后发布)。rolling `main` 通道保留给插件开发,但钩子默认 release 保可复现。

## 任务

### Phase 1 — 脚本(骨架→能力)

- [x] 骨架:set -euo、参数解析与互斥校验(--from↔--version/--channel main)、退出码 0/1/2/3、PREFIX/.lock 原子锁(>10min 判陈旧) —— `scripts/install-cli.sh`
- [x] 目标解析:release 通道 GitHub API(token 可选)取 tag+asset URL;`--from` 本地 tarball/目录;channel=main git 浅拉并合成 `ver-main.sha8` 版本 —— `scripts/install-cli.sh`
- [x] 物化与发布:结构校验(含 cli.mjs/package.json)→ 解包到 `shadow-dev-cli-<ver>.new` → 自跑 help --json 断言 ok → 原子改名+写 CURRENT/保留 PREVIOUS(force 允许同版重装) —— `scripts/install-cli.sh`
- [x] shim 托管:无扩展名 sh + .cmd(cygpath 归一)双 shim,头部 managed-by 标记,运行时读 CURRENT;已存在无标记文件→告警退出 1 不覆盖;BIN 不在 PATH 时打印补救指引 —— `scripts/install-cli.sh`
- [x] rollback / status / --json / --dry-run 语义收尾 —— `scripts/install-cli.sh`

### Phase 2 — 契约测试与文档

- [x] 新测试文件(bash 可用才运行):离线 tarball 全链(CURRENT/shim 标记/自校验)、已最新秒退、--force、非托管冲突保护、rollback 往返、--json 可解析、dry-run 零写入 —— `test/install.test.mjs`
- [x] README「安装与分发」改写:钩子调用示例、退出码表、信任边界说明;全量冒烟(含既有 CLI 套件) —— `README.md`

## 结果

- 实际耗时: 约 40 分钟
- 验证: `node --test` CLI 49/49 + 安装器 6/6 全绿(测试先行:脚本缺失时全红,实现后逐步转绿;修复 `set -e` 下 `[ ] && cmd` 短路误杀、`json()` 退出码传染、rollback/status 误触网、`--from` 互斥校验取反、Windows→Git Bash tar 路径 `C:` 误解析为远程主机共 5 处真实缺陷,均由契约测试当场暴露);README 分发段改写;npm test 并列两套件。范围偏差声明:`package.json` 的 test 脚本并入安装器套件属本变更直接配套,未在 brief files 清单声明,随 commit 一并入库,review 时请确认。

## 知识评估

- **预期影响:** 无需变更
- **候选卡片:** 无
- **理由:** 安装器接缝契约(命令、退出码、指针机制)是 README 分发段落的直接内容+脚本头注释,属单表面文档而非跨变更执行约束;未产生需要独立卡片防回归的非显然事实。
Loading
Loading