From 9c7d0cec70212adac472cee5da20150fc55d40ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E7=BA=A202?= <596540@ky-tech.com.cn> Date: Thu, 17 Sep 2026 21:47:00 +0800 Subject: [PATCH] feat(installer): link mode maps shim to live repo dir with LINK-priority resolution, dual-track coexists with release --- scripts/install-cli.sh | 120 ++++++++++++------ .../brief.md | 89 +++++++++++++ shadow-docs/knowledge/install-distribution.md | 39 ++++++ shadow-docs/menu.md | 1 + test/install.test.mjs | 48 +++++++ 5 files changed, 257 insertions(+), 40 deletions(-) create mode 100644 shadow-docs/changes/20260917-feature-install-link-mode/brief.md create mode 100644 shadow-docs/knowledge/install-distribution.md diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index e18d22f..28ee289 100644 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -2,14 +2,17 @@ # install-cli.sh — shadow-dev CLI 的拉取/更新/回滚安装器(供 shadow-dev-workflow 插件钩子与人工共用) # # 接缝契约(与 README「安装与分发」同源): -# commands: install | update(=install) | rollback | status +# commands: install | update(=install) | rollback | status | link | unlink # options : --channel release|main --version vA.B.C --from # --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-/{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" @@ -17,7 +20,7 @@ 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}" @@ -31,7 +34,7 @@ die() { # die 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 ;; @@ -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 @@ -70,6 +73,47 @@ DL() { # DL :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" @@ -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:-} previous=${PRV:-}" + 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:-} previous=${PRV:-} linked=${LNK:-}" + exit 0 +fi + +# ---- link 轨:shim 一次性映射到含 cli.mjs 的仓库目录,代码即改即生效;不触网 ---- +if [ "$CMD" = link ]; then + [ -n "$LINK_TARGET" ] || die 1 usage "link needs " + [ -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 @@ -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\"}" @@ -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" diff --git a/shadow-docs/changes/20260917-feature-install-link-mode/brief.md b/shadow-docs/changes/20260917-feature-install-link-mode/brief.md new file mode 100644 index 0000000..43f6748 --- /dev/null +++ b/shadow-docs/changes/20260917-feature-install-link-mode/brief.md @@ -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 ` 校验目标(`cli.mjs`+`package.json` 存在、`help --json` 冒烟)通过后把绝对路径写入 `$PREFIX/LINK`;shim 运行时优先读 LINK,命中则 `node /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 `/`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\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 ` 校验目标(`cli.mjs`+`package.json` 存在、`help --json` 冒烟)通过后把绝对路径写入 `$PREFIX/LINK`;shim 运行时优先读 LINK,命中则 `node /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 `/`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 运行时输出面,不含安装器) diff --git a/shadow-docs/knowledge/install-distribution.md b/shadow-docs/knowledge/install-distribution.md new file mode 100644 index 0000000..3caa7e3 --- /dev/null +++ b/shadow-docs/knowledge/install-distribution.md @@ -0,0 +1,39 @@ +--- +title: shadow-dev CLI 安装与分发模型 +domain: install-distribution +keywords: [安装, installer, shim, 指针文件, LINK, CURRENT, 双轨, 回滚, 插件钩子, tarball, Git Bash, PowerShell] +scope: [scripts/install-cli.sh, test/install.test.mjs] +status: active +source: + - changes/20260917-feature-install-cli-script/brief.md + - changes/20260917-fix-installer-url-taint/brief.md + - changes/20260917-feature-install-link-mode/brief.md +verified: 2026-09-17 +--- + +# shadow-dev CLI 安装与分发模型 + +## 当前结论 + +`scripts/install-cli.sh` 是唯一安装入口,维护**双轨**:release 物化轨(拉 GitHub Release tarball → 校验 → 物化到 `$PREFIX/shadow-dev-cli-/` → 切 `CURRENT` 指针,`PREVIOUS` 供回滚)与 link 直通轨(`link ` 校验目标目录后把其绝对路径写入 `$PREFIX/LINK`,代码即改即生效,面向 CLI 开发者本人)。`$BIN/shadow-dev`(.sh/.cmd) 是托管 shim,运行时按 `LINK → CURRENT` 两段解析——任何更新、回滚、双轨切换都不改 shim 文件本体。`install --json` 单行输出是 shadow-dev-workflow 插件钩子的跨仓接缝契约。 + +## 执行约束 + +- 命令面(install|update|rollback|status|link|unlink)、options、退出码(0/1/2/3)与布局在 install-cli.sh 文件头接缝注释、README「安装与分发」两处同源登记,改行为必须同步改注释。 +- 指针落盘前置校验不可绕过:物化轨 `node cli.mjs help --json` 冒烟不过则 `CURRENT` 不动;link 轨目标缺 `cli.mjs`/`package.json` 或冒烟不过则 `LINK` 不写(artifact/selfcheck 走退出码 3,参数/冲突走 1)。 +- shim 路径被非托管同名文件占用时 `shim_guard` 在任何写盘前失败退出,绝不静默覆盖;link 与 install 共用同一 guard 与 `gen_shims`。 +- `LINK` 与 `CURRENT` 语义互斥不复用:`install` 永不写/删 `LINK`,`unlink` 只删 `LINK`;status 的 `linked` 为 additive 字段。JSON 输出中的 Windows 路径必须转义反斜杠(`sed 's/\\/\\\\/g'`)。 +- Windows 上 `LINK` 落盘存 `cygpath -w` 的 Windows 形态(`.cmd` shim 用 `set /p` 直读,POSIX 形态 node 打不开);shim `.cmd` 用 goto 两段分支而非括号块(括号块内 `%errorlevel%` 提前展开会吞掉真实退出码)。 +- 信任边界分轨表述:release 轨 HTTPS + GitHub 仓库(无独立校验和),link 轨目标是用户显式给出的本机目录——引入 link 不扩大下载面,也不得把 link 目标喂给任何网络请求。 + +## 适用边界 + +适用于 `scripts/install-cli.sh` 及其托管 shim/指针的全部行为变更。不适用于 CLI 运行时输出面(见 cli-output-contract)与 GitHub release 的发布打包(`scripts/pack.mjs` 产物布局另论)。 + +## 验证方式 + +`node --test test/install.test.mjs` 全绿即契约成立(8 用例:离线物化+shim 运行、幂等/--force、非托管 shim 保护、回滚往返、坏产物、link 映射/优先/回退、link 校验与 guard、dry-run+status+参数拒绝)。手工复验:`bash scripts/install-cli.sh link <本仓库>` 后 `shadow-dev change list --archived` 立即可用且 `install --json` 后仍走 link(LINK 优先);`unlink` 后回物化版本;`status --json` 的 `linked`/`current` 如实反映。 + +## 关联知识 + +- [CLI 双通道输出契约](cli-output-contract.md) diff --git a/shadow-docs/menu.md b/shadow-docs/menu.md index def0c75..b63d16b 100644 --- a/shadow-docs/menu.md +++ b/shadow-docs/menu.md @@ -9,3 +9,4 @@ | brief 读写 | brief frontmatter 行尾 CRLF autocrlf BRIEF_FRONTMATTER_REQUIRED planHash | knowledge/brief-frontmatter-crlf.md | | CLI 输出面 | stdout stderr JSON 契约 语言 i18n 本地化 nextStep help 提示 QUIET --full 概览 摘要 减噪 TTY --json SHADOW_DEV_JSON | knowledge/cli-output-contract.md | | plan/execute 凭证 | planHash hash 漂移 norm changedFiles 脏工作区 porcelain trim 凭证链 | knowledge/plan-credential-chain.md | +| 安装与分发 | install 安装器 shim 指针文件 LINK CURRENT 双轨 回滚 插件钩子 tarball Git Bash PowerShell | knowledge/install-distribution.md | diff --git a/test/install.test.mjs b/test/install.test.mjs index b2e6ca6..4731b1a 100644 --- a/test/install.test.mjs +++ b/test/install.test.mjs @@ -106,6 +106,54 @@ test('artifact without cli.mjs fails selfcheck and leaves pointer untouched', sk assert.ok(!existsSync(join(prefix, 'CURRENT'))) }) +test('link maps shim to a live repo dir, wins over release, unlink restores materialized track', skip, () => { + const { prefix, bin } = env2() + const fake = mkdtempSync(join(tmpdir(), 'sd-repo-')) + writeFileSync(join(fake, 'cli.mjs'), 'console.log(JSON.stringify({ok:true,command:"help",data:{help:"LINKED"}}))\n') + writeFileSync(join(fake, 'package.json'), '{"name":"shadow-dev-cli","version":"8.8.8"}') + const linked = run(['link', toUnix(fake), '--prefix', toUnix(prefix), '--bin', toUnix(bin), '--json']) + assert.equal(linked.status, 0, linked.stderr) + assert.equal(JSON.parse(linked.stdout).action, 'link') + const viaShim = spawnSync('bash', [toUnix(join(bin, 'shadow-dev')), 'help'], { encoding: 'utf8' }) + assert.equal(viaShim.status, 0, viaShim.stderr) + assert.match(viaShim.stdout, /LINKED/, 'shim must resolve LINK target') + // release 安装不改变 LINK:双轨并存,LINK 优先 + const inst = run(['install', '--from', toUnix(makeTarball('9.9.9')), '--prefix', toUnix(prefix), '--bin', toUnix(bin), '--json']) + assert.equal(inst.status, 0, inst.stderr) + const still = spawnSync('bash', [toUnix(join(bin, 'shadow-dev')), 'help'], { encoding: 'utf8' }) + assert.match(still.stdout, /LINKED/, 'LINK keeps priority over a later release install') + const st = JSON.parse(run(['status', '--prefix', toUnix(prefix), '--bin', toUnix(bin), '--json']).stdout) + assert.equal(st.current, '9.9.9') + assert.ok(st.linked, 'status must expose the linked target') + const un = run(['unlink', '--prefix', toUnix(prefix), '--bin', toUnix(bin), '--json']) + assert.equal(un.status, 0, un.stderr) + const back = spawnSync('bash', [toUnix(join(bin, 'shadow-dev')), 'help'], { encoding: 'utf8' }) + assert.match(back.stdout, /repo inspect/, 'after unlink the shim falls back to the materialized CURRENT') + assert.equal(run(['unlink', '--prefix', toUnix(prefix), '--bin', toUnix(bin)]).status, 1, 'unlink without LINK fails') +}) + +test('link rejects invalid targets and unmanaged shims without writing any pointer', skip, () => { + const { prefix, bin } = env2() + assert.equal(run(['link', '--prefix', toUnix(prefix), '--bin', toUnix(bin)]).status, 1, 'link needs a target') + const empty = mkdtempSync(join(tmpdir(), 'sd-notrepo-')) + assert.equal(run(['link', toUnix(empty), '--prefix', toUnix(prefix), '--bin', toUnix(bin)]).status, 3, 'missing cli.mjs is an artifact failure') + assert.ok(!existsSync(join(prefix, 'LINK')), 'failed link must not write the pointer') + const broken = mkdtempSync(join(tmpdir(), 'sd-smoke-')) + writeFileSync(join(broken, 'cli.mjs'), 'process.exit(7)\n') + writeFileSync(join(broken, 'package.json'), '{"name":"shadow-dev-cli","version":"0.0.0"}') + assert.equal(run(['link', toUnix(broken), '--prefix', toUnix(prefix), '--bin', toUnix(bin)]).status, 3, 'smoke failure is a selfcheck failure') + assert.ok(!existsSync(join(prefix, 'LINK'))) + const good = mkdtempSync(join(tmpdir(), 'sd-repo2-')) + writeFileSync(join(good, 'cli.mjs'), 'console.log(JSON.stringify({ok:true,command:"help",data:{help:"LINKED"}}))\n') + writeFileSync(join(good, 'package.json'), '{"name":"shadow-dev-cli","version":"8.8.8"}') + writeFileSync(join(bin, 'shadow-dev'), '#!/bin/sh\necho sentinel\n') + const guard = run(['link', toUnix(good), '--prefix', toUnix(prefix), '--bin', toUnix(bin)]) + assert.equal(guard.status, 1) + assert.match(guard.stderr + guard.stdout, /unmanaged/i) + assert.ok(!existsSync(join(prefix, 'LINK')), 'shim conflict must fail before writing LINK') + assert.equal(readFileSync(join(bin, 'shadow-dev'), 'utf8'), '#!/bin/sh\necho sentinel\n') +}) + test('dry-run writes nothing; status reflects installed version; bad args rejected', skip, () => { const { prefix, bin } = env2() const tgz = makeTarball('9.9.9')