From 38b13641d0167403b99e557e3e1fe8df4d321183 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 17:19:59 +0800 Subject: [PATCH 1/2] feat(scripts): install-cli.sh pull/update/rollback installer for plugin hook and manual use --- README.md | 18 +- package.json | 2 +- scripts/install-cli.sh | 205 ++++++++++++++++++ .../brief.md | 89 ++++++++ test/install.test.mjs | 120 ++++++++++ 5 files changed, 431 insertions(+), 3 deletions(-) create mode 100644 scripts/install-cli.sh create mode 100644 shadow-docs/changes/20260917-feature-install-cli-script/brief.md create mode 100644 test/install.test.mjs diff --git a/README.md b/README.md index 51b0830..a3518d5 100644 --- a/README.md +++ b/README.md @@ -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-/` + `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` 是唯一契约规格。 diff --git a/package.json b/package.json index 94d0aee..6eeac16 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh new file mode 100644 index 0000000..48485cf --- /dev/null +++ b/scripts/install-cli.sh @@ -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 +# --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-/{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 + 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 "; 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 :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:-} previous=${PRV:-}" + 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})" diff --git a/shadow-docs/changes/20260917-feature-install-cli-script/brief.md b/shadow-docs/changes/20260917-feature-install-cli-script/brief.md new file mode 100644 index 0000000..895086a --- /dev/null +++ b/shadow-docs/changes/20260917-feature-install-cli-script/brief.md @@ -0,0 +1,89 @@ +--- +{ + "schema": "shadow-dev/v1", + "name": "20260917-feature-install-cli-script", + "type": "feature", + "scope": "scripts", + "status": "branched", + "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": null, + "pullRequestUrl": null + }, + "review": { + "conclusion": "pending", + "verifiedCommit": null, + "verifiedAt": null + }, + "workflow": { + "operation": null, + "checkpoint": "issue:11", + "planHash": "248575703efb32d8cd57ac78c2ad9e831885fc87659389c1a98b984233cd7d64", + "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" + ] + } + } +} +--- + +# 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-/` + `CURRENT`/`PREVIOUS` 指针文件;shim 运行时读指针(更新不动盘、回滚=改指针);`install|update|rollback|status` 四命令 + `--channel release|main` / `--version` / `--from ` 离线 / `--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-.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 分发段落的直接内容+脚本头注释,属单表面文档而非跨变更执行约束;未产生需要独立卡片防回归的非显然事实。 diff --git a/test/install.test.mjs b/test/install.test.mjs new file mode 100644 index 0000000..b2e6ca6 --- /dev/null +++ b/test/install.test.mjs @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { platform, tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +const REPO = fileURLToPath(new URL('..', import.meta.url)) +const SH = join(REPO, 'scripts', 'install-cli.sh') +const hasBash = spawnSync('bash', ['--version'], { encoding: 'utf8' }).status === 0 +const skip = !hasBash && { skip: 'bash unavailable' } + +// Git Bash 的 tar/bash 会把 "C:\..." 误读为“远程主机:路径”;传给子进程前统一转正斜杠 POSIX 形态 +const toUnix = platform() === 'win32' ? (p) => { + const r = spawnSync('cygpath', ['-u', p], { encoding: 'utf8' }) + return r.status === 0 ? r.stdout.trim() : p +} : (p) => p + +function run(args, env = {}) { + return spawnSync('bash', [SH, ...args], { cwd: REPO, encoding: 'utf8', env: { ...process.env, ...env } }) +} + +function makeTarball(version) { + const dir = mkdtempSync(join(tmpdir(), 'sd-art-')) + const app = join(dir, 'shadow-dev-cli') + mkdirSync(app, { recursive: true }) + writeFileSync(join(app, 'cli.mjs'), `console.log(JSON.stringify({ ok: true, command: 'help', data: { help: 'repo inspect' } }))\n`) + writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'shadow-dev-cli', version })) + writeFileSync(join(app, 'README.md'), '# fake artifact\n') + const tgz = join(dir, `shadow-dev-cli-v${version}.tar.gz`) + const r = spawnSync('tar', ['-czf', toUnix(tgz), '-C', toUnix(dir), 'shadow-dev-cli'], { encoding: 'utf8' }) + assert.equal(r.status, 0, r.stderr) + return tgz +} + +function env2() { + const base = mkdtempSync(join(tmpdir(), 'sd-home-')) + const prefix = join(base, 'share'), bin = join(base, 'bin') + mkdirSync(bin, { recursive: true }) + return { base, prefix, bin } +} + +test('offline install: pointer, managed shims, installed shim runs', skip, () => { + const { prefix, bin } = env2() + const tgz = makeTarball('9.9.9') + const r = run(['install', '--from', toUnix(tgz), '--prefix', toUnix(prefix), '--bin', toUnix(bin), '--json']) + assert.equal(r.status, 0, r.stderr) + const out = JSON.parse(r.stdout) + assert.equal(out.ok, true) + assert.equal(out.version, '9.9.9') + assert.equal(out.action, 'install') + assert.equal(readFileSync(join(prefix, 'CURRENT'), 'utf8').trim(), '9.9.9') + const shim = readFileSync(join(bin, 'shadow-dev'), 'utf8') + assert.match(shim, /managed-by: shadow-dev-cli-installer/) + assert.ok(existsSync(join(bin, 'shadow-dev.cmd'))) + const viaShim = spawnSync('bash', [toUnix(join(bin, 'shadow-dev')), 'help'], { encoding: 'utf8' }) + assert.equal(viaShim.status, 0, viaShim.stderr) + assert.match(viaShim.stdout, /"ok":true/) +}) + +test('second install is a no-op; --force reinstalls', skip, () => { + const { prefix, bin } = env2() + const tgz = makeTarball('9.9.9') + run(['install', '--from', toUnix(tgz), '--prefix', toUnix(prefix), '--bin', toUnix(bin)]) + const again = JSON.parse(run(['install', '--from', toUnix(tgz), '--prefix', toUnix(prefix), '--bin', toUnix(bin), '--json']).stdout) + assert.equal(again.action, 'none') + const forced = JSON.parse(run(['install', '--from', toUnix(tgz), '--prefix', toUnix(prefix), '--bin', toUnix(bin), '--force', '--json']).stdout) + assert.equal(forced.action, 'install') +}) + +test('unmanaged shim is protected and aborts before any pointer write', skip, () => { + const { prefix, bin } = env2() + const tgz = makeTarball('9.9.9') + writeFileSync(join(bin, 'shadow-dev'), '#!/bin/sh\necho sentinel\n') + const r = run(['install', '--from', toUnix(tgz), '--prefix', toUnix(prefix), '--bin', toUnix(bin)]) + assert.equal(r.status, 1) + assert.match(r.stderr + r.stdout, /unmanaged/i) + assert.equal(readFileSync(join(bin, 'shadow-dev'), 'utf8'), '#!/bin/sh\necho sentinel\n') + assert.ok(!existsSync(join(prefix, 'CURRENT')), 'conflict must fail before publishing') +}) + +test('rollback roundtrip; rollback without previous exits 1', skip, () => { + const { prefix, bin } = env2() + run(['install', '--from', toUnix(makeTarball('9.9.9')), '--prefix', toUnix(prefix), '--bin', toUnix(bin)]) + run(['install', '--from', toUnix(makeTarball('9.9.10')), '--prefix', toUnix(prefix), '--bin', toUnix(bin)]) + assert.equal(readFileSync(join(prefix, 'CURRENT'), 'utf8').trim(), '9.9.10') + const rb = JSON.parse(run(['rollback', '--prefix', toUnix(prefix), '--bin', toUnix(bin), '--json']).stdout) + assert.equal(rb.ok, true) + assert.equal(readFileSync(join(prefix, 'CURRENT'), 'utf8').trim(), '9.9.9') + writeFileSync(join(prefix, 'PREVIOUS'), '') + const none = run(['rollback', '--prefix', toUnix(prefix), '--bin', toUnix(bin)]) + assert.equal(none.status, 1) +}) + +test('artifact without cli.mjs fails selfcheck and leaves pointer untouched', skip, () => { + const { prefix, bin } = env2() + const dir = mkdtempSync(join(tmpdir(), 'sd-bad-')) + mkdirSync(join(dir, 'shadow-dev-cli')) + writeFileSync(join(dir, 'shadow-dev-cli', 'package.json'), '{"name":"shadow-dev-cli","version":"0.0.1"}') + writeFileSync(join(dir, 'shadow-dev-cli', 'README.md'), '# nope\n') + const tgz = join(dir, 'bad.tar.gz') + spawnSync('tar', ['-czf', toUnix(tgz), '-C', toUnix(dir), 'shadow-dev-cli']) + const r = run(['install', '--from', toUnix(tgz), '--prefix', toUnix(prefix), '--bin', toUnix(bin)]) + assert.equal(r.status, 3) + assert.ok(!existsSync(join(prefix, 'CURRENT'))) +}) + +test('dry-run writes nothing; status reflects installed version; bad args rejected', skip, () => { + const { prefix, bin } = env2() + const tgz = makeTarball('9.9.9') + const dry = run(['install', '--from', toUnix(tgz), '--prefix', toUnix(prefix), '--bin', toUnix(bin), '--dry-run', '--json']) + assert.equal(dry.status, 0, dry.stderr) + assert.equal(JSON.parse(dry.stdout).action, 'dry-run') + assert.ok(!existsSync(join(prefix, 'CURRENT'))) + run(['install', '--from', toUnix(tgz), '--prefix', toUnix(prefix), '--bin', toUnix(bin)]) + const st = JSON.parse(run(['status', '--prefix', toUnix(prefix), '--bin', toUnix(bin), '--json']).stdout) + assert.equal(st.current, '9.9.9') + assert.equal(run(['install', '--from', toUnix(tgz), '--version', 'v1.0.0', '--prefix', toUnix(prefix)]).status, 1) +}) From afc35875554da9652fb56bfd0f1efe4ca410cd71 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 17:26:06 +0800 Subject: [PATCH 2/2] chore(shadow-docs): #11 review passed + PR #13 state --- .../brief.md | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/shadow-docs/changes/20260917-feature-install-cli-script/brief.md b/shadow-docs/changes/20260917-feature-install-cli-script/brief.md index 895086a..15f9b79 100644 --- a/shadow-docs/changes/20260917-feature-install-cli-script/brief.md +++ b/shadow-docs/changes/20260917-feature-install-cli-script/brief.md @@ -4,7 +4,7 @@ "name": "20260917-feature-install-cli-script", "type": "feature", "scope": "scripts", - "status": "branched", + "status": "published", "baseBranch": "main", "branch": "feature/20260917-feature-install-cli-script", "files": [ @@ -16,18 +16,18 @@ "repository": "stack-wuh/shadow-dev-cli", "issue": 11, "issueUrl": "https://github.com/stack-wuh/shadow-dev-cli/issues/11", - "pullRequest": null, - "pullRequestUrl": null + "pullRequest": 13, + "pullRequestUrl": "https://github.com/stack-wuh/shadow-dev-cli/pull/13" }, "review": { - "conclusion": "pending", - "verifiedCommit": null, - "verifiedAt": null + "conclusion": "passed", + "verifiedCommit": "38b13641d0167403b99e557e3e1fe8df4d321183", + "verifiedAt": "2026-09-17T09:21:06.983Z" }, "workflow": { "operation": null, - "checkpoint": "issue:11", - "planHash": "248575703efb32d8cd57ac78c2ad9e831885fc87659389c1a98b984233cd7d64", + "checkpoint": "pr:13", + "planHash": "9f6178c08054b867c2105d6331097ca8fbbab78d388d8dc468d044aa437d3e8e", "updatedAt": null, "lastError": null, "issuePlan": { @@ -37,6 +37,11 @@ "feature" ] } + }, + "knowledge": { + "action": "无需变更", + "target": null, + "reason": null } } ---