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
120 changes: 80 additions & 40 deletions scripts/install-cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,25 @@
# install-cli.sh — shadow-dev CLI 的拉取/更新/回滚安装器(供 shadow-dev-workflow 插件钩子与人工共用)
#
# 接缝契约(与 README「安装与分发」同源):
# commands: install | update(=install) | rollback | status
# commands: install | update(=install) | rollback | status | link <path> | unlink
# 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 产物自校验失败
# 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)失败则指针不动
# $PREFIX/LINK 为开发直通指针(存在即 shim 最高优先,指向含 cli.mjs 的仓库目录绝对路径);
# $BIN/shadow-dev(.cmd) 为托管 shim(头标 managed-by,运行时 LINK→CURRENT 两段解析 → 更新不动 shim)
# 双轨语义: release 轨(插件钩子/物化/回滚)与 link 轨(装一次、代码即改即生效)互不覆盖;unlink 回 release 轨
# 信任边界: release 轨 HTTPS + GitHub 仓库;link 轨目标为用户显式给出的本机目录,落指针前同样冒烟;
# 发布前自校验(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
CMD="install"; CHANNEL="release"; VERSION=""; FROM=""; LINK_TARGET=""; FORCE=0; DRY=0; JSON=0
PREFIX="${SD_PREFIX:-$HOME/.local/share/shadow-dev-cli}"
BIN="${SD_BIN:-$HOME/.local/bin}"

Expand All @@ -31,7 +34,7 @@ die() { # die <exit> <error> <message>

while [ $# -gt 0 ]; do
case "$1" in
install|update|rollback|status) CMD="$1"; shift
install|update|rollback|status|link|unlink) CMD="$1"; shift
;;
--channel) [ $# -ge 2 ] || die 1 usage "--channel needs release|main"; CHANNEL="$2"; shift 2
;;
Expand All @@ -46,7 +49,7 @@ while [ $# -gt 0 ]; do
--force) FORCE=1; shift ;;
--dry-run) DRY=1; shift ;;
--json) JSON=1; shift ;;
*) die 1 usage "unknown argument: $1" ;;
*) if [ "$CMD" = link ] && [ -z "$LINK_TARGET" ]; then LINK_TARGET="$1"; shift; else die 1 usage "unknown argument: $1"; fi ;;
esac
done

Expand All @@ -70,6 +73,47 @@ DL() { # DL <url> <out>:curl 优先,wget 兜底,带可选 token
}
verof() { node -pe "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).version" "$1/package.json"; }

# ---- 托管 shim:运行时 LINK→CURRENT 两段解析;任何更新/切换都不动 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
}
gen_shims() {
mkdir -p "$BIN"
{
echo '#!/bin/sh'
echo "# $MARK v2 — generated file, regenerate via install-cli.sh, do not edit"
echo "root='$PREFIX'"
echo 'if [ -f "$root/LINK" ]; then exec node "$(cat "$root/LINK")/cli.mjs" "$@"; fi'
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"
if command -v cygpath >/dev/null; then WINROOT="$(cygpath -w "$PREFIX")"; fi
{
echo '@echo off'
echo "rem $MARK v2 — generated file, regenerate via install-cli.sh, do not edit"
echo "set \"ROOT=$WINROOT\""
echo 'set "L="'
echo 'if exist "%ROOT%\LINK" set /p L=<"%ROOT%\LINK"'
echo 'if not defined L goto materialized'
echo 'node "%L%\cli.mjs" %*'
echo 'exit /b %errorlevel%'
echo ':materialized'
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
}

# ---- 锁(陈旧>10min 自动接管)----
LOCK="$PREFIX/.lock"
mkdir -p "$PREFIX"
Expand All @@ -91,8 +135,33 @@ if [ "$CMD" = rollback ]; then
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>}"
LNK="$(cat "$PREFIX/LINK" 2>/dev/null || true)"
json "{\"ok\":true,\"current\":\"${CUR:-null}\",\"previous\":\"${PRV:-null}\",\"linked\":\"$(printf '%s' "${LNK:-null}" | sed 's/\\/\\\\/g')\"}"
log "current=${CUR:-<none>} previous=${PRV:-<none>} linked=${LNK:-<none>}"
exit 0
fi

# ---- link 轨:shim 一次性映射到含 cli.mjs 的仓库目录,代码即改即生效;不触网 ----
if [ "$CMD" = link ]; then
[ -n "$LINK_TARGET" ] || die 1 usage "link needs <path>"
[ -d "$LINK_TARGET" ] || die 1 bad-target "link target is not a directory: $LINK_TARGET"
{ [ -f "$LINK_TARGET/cli.mjs" ] && [ -f "$LINK_TARGET/package.json" ]; } || die 3 artifact "link target lacks cli.mjs/package.json: $LINK_TARGET"
node "$LINK_TARGET/cli.mjs" help --json 2>/dev/null | grep -q '"ok":true' || die 3 selfcheck "link target failed 'help --json' smoke test"
LINKV="$LINK_TARGET"
if command -v cygpath >/dev/null; then LINKV="$(cygpath -w "$LINK_TARGET")"; fi
shim_guard
printf '%s\n' "$LINKV" > "$PREFIX/LINK"
gen_shims
"$BIN/shadow-dev" help 2>/dev/null | grep -q '"ok":true' || die 3 selfcheck "linked shim failed smoke test"
json "{\"ok\":true,\"action\":\"link\",\"linked\":\"$(printf '%s' "$LINKV" | sed 's/\\/\\\\/g')\",\"shim\":\"$(printf '%s' "$BIN/shadow-dev" | sed 's/\\/\\\\/g')\"}"
log "shadow-dev linked to $LINKV (takes priority over the release track; run unlink to restore)"
exit 0
fi
if [ "$CMD" = unlink ]; then
[ -f "$PREFIX/LINK" ] || die 1 no-link "no LINK pointer to remove (not linked)"
rm -f "$PREFIX/LINK"
json '{"ok":true,"action":"unlink"}'
log "unlinked; shims fall back to the release track (CURRENT)"
exit 0
fi

Expand Down Expand Up @@ -141,12 +210,6 @@ if [ "$CUR" = "$VER" ] && [ -d "$PREFIX/shadow-dev-cli-$VER" ] && [ "$FORCE" -eq
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\"}"
Expand All @@ -172,31 +235,8 @@ for d in "$PREFIX"/shadow-dev-cli-*; do
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
# ---- 托管 shim:LINK→CURRENT 两段解析(生成逻辑与 link 共用)----
gen_shims

# ---- 安装后冒烟 + PATH 提示 ----
"$BIN/shadow-dev" help 2>/dev/null | grep -q '"ok":true' || die 3 selfcheck "installed shim failed smoke test"
Expand Down
89 changes: 89 additions & 0 deletions shadow-docs/changes/20260917-feature-install-link-mode/brief.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
{
"schema": "shadow-dev/v1",
"name": "20260917-feature-install-link-mode",
"type": "feature",
"scope": "scripts/install-cli.sh",
"status": "branched",
"baseBranch": "main",
"branch": "feature/20260917-feature-install-link-mode",
"files": [],
"github": {
"repository": "stack-wuh/shadow-dev-cli",
"issue": 20,
"issueUrl": "https://github.com/stack-wuh/shadow-dev-cli/issues/20",
"pullRequest": null,
"pullRequestUrl": null
},
"review": {
"conclusion": "pending",
"verifiedCommit": null,
"verifiedAt": null
},
"workflow": {
"operation": null,
"checkpoint": "issue:20",
"planHash": "be18f297a3af19ccd6be47a0f8f2e15d38815773a58b477b7222feed9baee39d",
"updatedAt": null,
"lastError": null,
"issuePlan": {
"title": "[feature] install-cli.sh 新增 link 模式:shim 一次性映射到仓库真实地址",
"titleRaw": null,
"supplement": "",
"body": "## 动机\n现有安装模型是\"release 物化\":每次代码更新必须发布新 GitHub Release 再重跑 install。实际痛点:`change list` 等能力已合并进 main,但装的 v1.1.0 没有该命令,用户终端报\"没有这个指令\";而 CLI 开发者本人每次改 `cli.mjs`/`lib/` 都要走发版链路才能生效。需要一条\"装一次、映射指向真实仓库目录、代码即改即生效\"的开发直通入口,同时插件钩子依赖的 release 链路原封不动。\n\n## 引用规范\n- norms/code-style.md\n - 当前结论: 渐进式治理(link 是新增模式,不重构 release 路径);同一字段不复用双语义\n - 适用 scope: scripts/install-cli.sh\n- norms/tdd-verification.md\n - 当前结论: 先写失败契约测试再实现(test/install.test.mjs 已有 6 项安装器契约先例)\n - 适用 scope: test/install.test.mjs\n- 文件头接缝契约(install-cli.sh:3-12,README「安装与分发」同源)\n - 当前结论: commands/options/exit/layout/信任边界是对外契约,新增子命令必须同步登记两处\n - 适用 scope: scripts/install-cli.sh, README.md\n\n## 决策\n- **选型:** 方案 A——独立 `LINK` 指针文件 + shim 两段解析。`link <path>` 校验目标(`cli.mjs`+`package.json` 存在、`help --json` 冒烟)通过后把绝对路径写入 `$PREFIX/LINK`;shim 运行时优先读 LINK,命中则 `node <LINK>/cli.mjs`,否则按 CURRENT 走物化版本。`unlink` 删除 LINK(回 release 轨)。`status` 输出增加 `linked` 字段。冲突保护复用 `shim_guard`。\n- **对比方案:** B 复用 CURRENT 存绝对路径——值域重载(版本号 OR 路径),所有解析方需适配,违背不复用语义原则;C 生成内嵌路径的 shim——relink 必须重写 shim 文件,违背\"更新不动 shim\"既有原则且 status 不可读。\n- **理由:** 双指针各有唯一语义:LINK=开发直通(存在即最高优先),CURRENT/PREVIOUS=release 物化;双轨切换各一条命令;信任边界不扩——link 目标由用户显式给出本机路径,不引入下载面;rollback/status 现有语义不破坏(status 仅 additive 字段)。\n- **非目标:** 不新增 PowerShell profile 入口(现有 `.cmd` 托管 shim 已覆盖 PowerShell/cmd,\"ps1 入口\"是用户对形态的类比);不改插件钩子契约;不在本次实现\"link 目标的自动 git pull 同步\"(仓库主人自己拉代码)。\n\n## 任务\n### Phase 1(TDD:红 → 绿)\n\n- [ ] task-1 — `test/install.test.mjs` — 新增失败契约用例:①`link` 校验失败(缺 cli.mjs / 冒烟不过)不落指针、exit 3/1;②`link` 成功后 shim 解析走 LINK 目标(以目标仓库版本输出为证)、`status` 含 `linked`;③`unlink` 后 shim 回退 CURRENT 物化版本;跑一遍确认红\n- [ ] task-2 — `scripts/install-cli.sh` — 实现 `link <path>`/`unlink` 子命令与 `$PREFIX/LINK` 指针;LINK 校验与自校验复用 `verof`/`help --json` 冒烟;`status` JSON additive 输出 `linked`;shim(sh 与 .cmd 两模板)改为 LINK 优先两段解析;文件头接缝契约注释同步更新\n- [ ] task-3 — `README.md` — 「安装与分发」命令块补 `link`/`unlink` 用法与双轨说明(更新免重装的开发者直通语义)\n\n### Phase 2(知识治理)\n\n- [ ] task-4 — `shadow-docs/knowledge/install-distribution.md`、`shadow-docs/menu.md` — 新增 active 卡片:安装/分发域(指针文件集、shim 托管协议、release/link 双轨语义、信任边界、接缝契约与测试对应关系),menu 追加路由;source 指向本 brief 与两个已归档安装 brief\n- [ ] task-5 — 本机真实验证 — `bash scripts/install-cli.sh link D:/works/shadow-dev-cli` 后终端 `shadow-dev change list --archived` 立即可用;改一行代码再跑确认即时生效;`unlink` 后回 v1.1.0 行为(记录到结果字段)\n\n完整 brief:shadow-docs/changes/20260917-feature-install-link-mode/brief.md\n\n<!-- shadow-dev:issue-metadata {\"name\":\"20260917-feature-install-link-mode\",\"type\":\"feature\",\"scope\":\"scripts/install-cli.sh\",\"status\":\"branched\",\"branch\":\"feature/20260917-feature-install-link-mode\",\"baseBranch\":\"main\",\"briefPath\":\"shadow-docs/changes/20260917-feature-install-link-mode/brief.md\",\"cliVersion\":\"1.2.0\",\"prUrl\":null,\"issueNumber\":null} -->\n",
"labels": [
"feature"
]
}
}
}
---

# install-cli.sh 新增 link 模式:shim 一次性映射到仓库真实地址

## 动机

现有安装模型是"release 物化":每次代码更新必须发布新 GitHub Release 再重跑 install。实际痛点:`change list` 等能力已合并进 main,但装的 v1.1.0 没有该命令,用户终端报"没有这个指令";而 CLI 开发者本人每次改 `cli.mjs`/`lib/` 都要走发版链路才能生效。需要一条"装一次、映射指向真实仓库目录、代码即改即生效"的开发直通入口,同时插件钩子依赖的 release 链路原封不动。

## 引用规范

- norms/code-style.md
- 当前结论: 渐进式治理(link 是新增模式,不重构 release 路径);同一字段不复用双语义
- 适用 scope: scripts/install-cli.sh
- norms/tdd-verification.md
- 当前结论: 先写失败契约测试再实现(test/install.test.mjs 已有 6 项安装器契约先例)
- 适用 scope: test/install.test.mjs
- 文件头接缝契约(install-cli.sh:3-12,README「安装与分发」同源)
- 当前结论: commands/options/exit/layout/信任边界是对外契约,新增子命令必须同步登记两处
- 适用 scope: scripts/install-cli.sh, README.md

## 决策

- **选型:** 方案 A——独立 `LINK` 指针文件 + shim 两段解析。`link <path>` 校验目标(`cli.mjs`+`package.json` 存在、`help --json` 冒烟)通过后把绝对路径写入 `$PREFIX/LINK`;shim 运行时优先读 LINK,命中则 `node <LINK>/cli.mjs`,否则按 CURRENT 走物化版本。`unlink` 删除 LINK(回 release 轨)。`status` 输出增加 `linked` 字段。冲突保护复用 `shim_guard`。
- **对比方案:** B 复用 CURRENT 存绝对路径——值域重载(版本号 OR 路径),所有解析方需适配,违背不复用语义原则;C 生成内嵌路径的 shim——relink 必须重写 shim 文件,违背"更新不动 shim"既有原则且 status 不可读。
- **理由:** 双指针各有唯一语义:LINK=开发直通(存在即最高优先),CURRENT/PREVIOUS=release 物化;双轨切换各一条命令;信任边界不扩——link 目标由用户显式给出本机路径,不引入下载面;rollback/status 现有语义不破坏(status 仅 additive 字段)。
- **非目标:** 不新增 PowerShell profile 入口(现有 `.cmd` 托管 shim 已覆盖 PowerShell/cmd,"ps1 入口"是用户对形态的类比);不改插件钩子契约;不在本次实现"link 目标的自动 git pull 同步"(仓库主人自己拉代码)。

## 任务

### Phase 1(TDD:红 → 绿)

- [x] task-1 — `test/install.test.mjs` — 新增失败契约用例:①`link` 校验失败(缺 cli.mjs / 冒烟不过)不落指针、exit 3/1;②`link` 成功后 shim 解析走 LINK 目标(以目标仓库版本输出为证)、`status` 含 `linked`;③`unlink` 后 shim 回退 CURRENT 物化版本;跑一遍确认红
- [x] task-2 — `scripts/install-cli.sh` — 实现 `link <path>`/`unlink` 子命令与 `$PREFIX/LINK` 指针;LINK 校验与自校验复用 `verof`/`help --json` 冒烟;`status` JSON additive 输出 `linked`;shim(sh 与 .cmd 两模板)改为 LINK 优先两段解析;文件头接缝契约注释同步更新
- [x] task-3 — `README.md` — 「安装与分发」命令块补 `link`/`unlink` 用法与双轨说明(更新免重装的开发者直通语义)

### Phase 2(知识治理)

- [x] task-4 — `shadow-docs/knowledge/install-distribution.md`、`shadow-docs/menu.md` — 新增 active 卡片:安装/分发域(指针文件集、shim 托管协议、release/link 双轨语义、信任边界、接缝契约与测试对应关系),menu 追加路由;source 指向本 brief 与两个已归档安装 brief
- [x] task-5 — 本机真实验证 — `bash scripts/install-cli.sh link D:/works/shadow-dev-cli` 后终端 `shadow-dev change list --archived` 立即可用;改一行代码再跑确认即时生效;`unlink` 后回 v1.1.0 行为(记录到结果字段)

## 结果

- 实际耗时: —
- 验证: —

## 知识评估

- **预期影响:** 新增
- **候选卡片:** shadow-docs/knowledge/install-distribution.md
- **理由:** 安装/分发域在 knowledge 无任何 active 卡片与 menu 路由(本次查询确认的治理缺口),而该域已有跨仓接缝契约(插件钩子)与双轨模型这类长期事实,值得沉淀;不更新 cli-output-contract(那是 CLI 运行时输出面,不含安装器)
Loading
Loading