diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fe97a7..ab55b88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,15 @@ jobs: - name: Mount + headless-render smoke run: pnpm test:mount + # Aggregate double-mount regression: a fixture aggregate bundle mounts + # dsh-better-sidebar under its own entry id BEFORE the plugin's bundle + # row; the plugin must back off (no duplicate /sidebar/api, boot OK). + # Uses the pinned DSH CLI installed above. + - name: Aggregate double-mount regression + env: + DSH_CMD: dsh + run: bash scripts/e2e-aggregate-mount.sh + - name: Upload Playwright report on failure if: failure() uses: actions/upload-artifact@v4 diff --git a/README.md b/README.md index cf9227b..3b1fa80 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ dsh plugin --profile web add dsh-better-sidebar@latest | 报 `Ignored build scripts` | pnpm 11 拦截构建脚本。在 profile 目录(`~/.dsh/profiles/web`)跑 `pnpm approve-builds --all`。 | | 报 `minimum release age` / 版本不足 24h | 装的版本发布不足 24 小时。等 24h 或重跑一次(pnpm 会自动补 `minimumReleaseAgeExclude`)。 | | 报「找不到 profile 目录」 | 先跑一次 `dsh web`,让它初始化 `~/.dsh/profiles/web`。 | -| 页面出现**两个侧边栏** | 双挂载:`~/.dsh/profiles/web/cordis.patch.yml` 还留着旧的手动挂载行,删掉那段 `- insert: ... better-sidebar ...`。 | +| 页面出现**两个侧边栏** | 双挂载。旧的手动挂载行:`~/.dsh/profiles/web/cordis.patch.yml` 还留着 `- insert: ... better-sidebar ...`,删掉那段(同 id 重复挂载 loader 会直接报 `duplicate loader entry id`)。聚合包(如 `@linxin666/dsh-web-ui-all`)以**不同 id** 挂载本包时,0.13.x 起插件自身 bundle patch 会自动退让(检测到已有启用中的同包名挂载就不挂自己),无需手动处理;若仍双挂载,先确认聚合包的 bundle 顺序在 `dsh-better-sidebar` 之前。 | | Windows 下终端无法使用 | `node-pty` 依赖预编译二进制;若当前 Node 版本没有对应产物,需装编译工具链(VS Build Tools)。主流 Node 版本一般已有预编译。 | | 终端提示「node-pty 加载失败」 | `node-pty` 安装缺失/损坏(如 pnpm 拦截了构建脚本)。终端横幅会给出修复命令:复制到 DSH 所在环境的终端/cmd 执行(在 `~/.dsh/profiles/web` 下 `pnpm approve-builds --all && pnpm rebuild node-pty`),完成后重启 DSH 并点重试。插件与 DSH 核心使用同一 `node-pty@^1.1.0`,修复后两者同步恢复。 | | 提示 `dsh: command not found` | 先安装 DSH;或直接用 `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar@latest`。 | diff --git a/README_EN.md b/README_EN.md index fbe6b7d..51e39fc 100644 --- a/README_EN.md +++ b/README_EN.md @@ -128,7 +128,7 @@ or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.13.0"`) and | `Ignored build scripts` | pnpm 11 blocked build scripts. Run `pnpm approve-builds --all` in the profile directory (`~/.dsh/profiles/web`). | | `minimum release age` / version `< 24h` | The release is younger than 24 hours. Wait, or re-run once (pnpm auto-adds `minimumReleaseAgeExclude`). | | "profile directory not found" | Run `dsh web` once so it initializes `~/.dsh/profiles/web`. | -| Two sidebars on the page | Double-mount: `~/.dsh/profiles/web/cordis.patch.yml` still has the old hand-written `- insert: ... better-sidebar ...` line — delete it. | +| Two sidebars on the page | Double-mount. Old hand-written line: `~/.dsh/profiles/web/cordis.patch.yml` still has `- insert: ... better-sidebar ...` — delete it (a same-id duplicate mount makes the loader fail loudly with `duplicate loader entry id`). When an aggregate bundle (e.g. `@linxin666/dsh-web-ui-all`) mounts this package under a **different** id, the plugin's own bundle patch backs off automatically since 0.13.x (it detects an already-enabled mount of the same package name and does not mount itself) — no manual fix needed; if it still double-mounts, make sure the aggregate bundle precedes `dsh-better-sidebar` in `dsh.profile.bundles`. | | Terminal fails on Windows | `node-pty` relies on prebuilt binaries; if none match your Node version, install a build toolchain (VS Build Tools). Mainstream Node versions are usually covered. | | Terminal shows "node-pty failed to load" | The `node-pty` install is missing or broken (e.g. pnpm skipped its build script). The terminal banner shows a repair command — copy it into a terminal/cmd on the DSH machine and run it (in `~/.dsh/profiles/web`: `pnpm approve-builds --all && pnpm rebuild node-pty`), then restart DSH and click Retry. The plugin and DSH core share the same `node-pty@^1.1.0`, so the repair restores both. | | `dsh: command not found` | Install DSH first, or run `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar@latest`. | diff --git a/cordis.patch.yml b/cordis.patch.yml index 193675a..420076f 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -29,6 +29,21 @@ # shellArgs: # - --noprofile # - --no-rc +# +# Aggregate double-mount guard: aggregate bundles (e.g. @linxin666/dsh-web-ui-all) +# may already mount this package under their own entry id (e.g. +# `web-ui-better-sidebar`). Two mounts register /sidebar/api twice and fail the +# whole plugin tree at boot ("duplicate prefix route"). The `!!js` disabled +# expression backs THIS row off when another *enabled* entry already mounts +# `dsh-better-sidebar`; the aggregate instance then owns the sidebar. The +# loader evaluates it when this row is processed (entry-list order, only rows +# before this one are visible), so the aggregate bundle must precede this one +# in `dsh.profile.bundles` — the standard install order, since `dsh plugin add` +# appends new bundles last. The reverse order (this bundle before the +# aggregate) is a known limitation: the guard cannot see rows that come after +# it, and no runtime singleton guard is added. Same-id manual duplicates still +# fail loudly at the loader as before. - insert: - id: better-sidebar name: 'dsh-better-sidebar' + disabled: !!js "[...ctx.loader.entries()].some((e) => e.options.name === 'dsh-better-sidebar' && e.options.id !== 'better-sidebar' && !e.disabled)" diff --git a/scripts/e2e-aggregate-mount.sh b/scripts/e2e-aggregate-mount.sh new file mode 100755 index 0000000..12066dc --- /dev/null +++ b/scripts/e2e-aggregate-mount.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# ============================================================================= +# dsh-better-sidebar 聚合双挂载冒烟(CI + 本地): +# +# 场景:一个聚合 bundle(tests/fixtures/aggregate-better-sidebar)先以独立 +# 条目 id 挂载 dsh-better-sidebar,插件自身的 bundle patch 后到 —— 旧行为 +# 两个实例都注册 /sidebar/api,整个插件树启动失败(duplicate prefix +# route);新行为插件行检测到已有启用中的同包名挂载后自动 disabled,由 +# 聚合行接管 sidebar。 +# +# 流程: +# 1. 打包 fixture 聚合包; +# 2. 用官方 CLI 把 fixture 先装进全新 scratch profile,再把插件 tarball +# 装进同一 profile(复刻真实安装顺序:聚合先行、dsh plugin add 后到); +# 3. 启动真实 `dsh web`(keyless,--port 0); +# 4. 断言:日志出现启动 URL、无 "duplicate prefix route"、/sidebar/api +# 路由存活(POST 未知方法应返回 404 JSON 而非崩溃)。 +# +# 用法: +# bash scripts/e2e-aggregate-mount.sh +# +# 环境变量(均可省略): +# DSH_CMD dsh 命令;缺省 `npx -y --package @deepseek-ai/dsh dsh` +# (与 pm2 启动器同源,避免依赖可能失效的 PATH dsh) +# TARBALL 插件 tarball;缺省仓库根 dsh-better-sidebar-*.tgz(须已 pack) +# PORT 固定端口(默认 0 = OS 分配,从日志解析 URL) +# DSH_HOME_BASE scratch 根目录(默认系统临时目录)。脚本始终在其下新建 +# 本调用拥有的独立子目录,只写入/删除该子目录;调用方提供 +# 的目录本身(可能是真实 ~/.dsh)绝不写入或删除。 +# KEEP_HOME 非空时保留 scratch 目录(调试用) +# +# 退出码 = 0 通过;非 0 失败。服务器与 scratch 目录由 trap 兜底清理。 +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +FIXTURE_DIR="$ROOT/tests/fixtures/aggregate-better-sidebar" + +DSH_CMD="${DSH_CMD:-npx -y --package @deepseek-ai/dsh dsh}" +TARBALL="${TARBALL:-}" +PORT="${PORT:-0}" +KEEP_HOME="${KEEP_HOME:-}" + +say() { printf '\033[32m[e2e-aggregate-mount]\033[0m %s\n' "$*"; } +warn() { printf '\033[33m[e2e-aggregate-mount]\033[0m %s\n' "$*" >&2; } +die() { printf '\033[31m[e2e-aggregate-mount]\033[0m %s\n' "$*" >&2; exit 1; } + +command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js >= 20)" +command -v pnpm >/dev/null 2>&1 || die "未找到 pnpm(dsh plugin 转发给 pnpm)" +command -v npm >/dev/null 2>&1 || die "未找到 npm(打包 fixture 需要)" +command -v curl >/dev/null 2>&1 || die "未找到 curl" + +if [ -z "$TARBALL" ]; then + TARBALL="$(ls "$ROOT"/dsh-better-sidebar-*.tgz 2>/dev/null | head -1 || true)" +fi +[ -n "$TARBALL" ] && [ -f "$TARBALL" ] || die "未找到插件 tarball:先在本仓库跑 pnpm build && npm pack,或用 TARBALL 指定" + +# ── scratch home ──────────────────────────────────────────────────────────── +# 始终在本调用拥有的全新目录里运行:调用方给了 DSH_HOME_BASE(可能是真实 +# ~/.dsh)时,只在其下新建子目录并只删除该子目录;缺省时直接用系统临时 +# 目录。调用方提供的目录本身绝不写入或删除。 +DSH_HOME_BASE="${DSH_HOME_BASE:-}" +if [ -n "$DSH_HOME_BASE" ]; then + SCRATCH="$(mktemp -d "$DSH_HOME_BASE/dsh-e2e-agg.XXXXXX")" +else + SCRATCH="$(mktemp -d /tmp/dsh-e2e-agg.XXXXXX)" +fi +export DSH_HOME="$SCRATCH" +LOG_DIR="$DSH_HOME/logs"; mkdir -p "$LOG_DIR" +OUT_LOG="$LOG_DIR/dsh-web.out.log"; ERR_LOG="$LOG_DIR/dsh-web.err.log" +SERVER_PID="" +TMP_DIR="" +cleanup() { + if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then + rm -rf "$TMP_DIR" + fi + if [ -z "$KEEP_HOME" ] && [ -d "$SCRATCH" ]; then + rm -rf "$SCRATCH" + else + warn "KEEP_HOME 已设置,保留 $SCRATCH" + fi +} +trap cleanup EXIT + +# ── 打包 fixture 聚合包 ───────────────────────────────────────────────────── +TMP_DIR="$(mktemp -d)" +( cd "$FIXTURE_DIR" && npm pack --silent --pack-destination "$TMP_DIR" >/dev/null ) +FIXTURE_TGZ="$(ls "$TMP_DIR"/*.tgz 2>/dev/null | head -1 || true)" +[ -n "$FIXTURE_TGZ" ] || die "fixture 打包失败" + +# ── 引导 scratch profile(web 模板)──────────────────────────────────────── +# 先写 pnpm-workspace.yaml 的 allowBuilds / minimumReleaseAgeExclude,避免 +# pnpm 11 strict-dep-builds 拦截 node-pty/protobufjs——同 e2e-mount.sh。 +PROFILE_DIR="$DSH_HOME/profiles/web" +mkdir -p "$PROFILE_DIR" +cat > "$PROFILE_DIR/package.json" < "$PROFILE_DIR/cordis.patch.yml" +cat > "$PROFILE_DIR/pnpm-workspace.yaml" <<'EOF' +packages: + - . + +nodeLinker: hoisted +autoInstallPeers: false + +allowBuilds: + node-pty: true + protobufjs: true + +minimumReleaseAgeExclude: + - dsh-better-sidebar +EOF + +# ── 组装 profile:聚合先行,插件后到 ────────────────────────────────────── +say "安装 fixture 聚合包(先行)…" +$DSH_CMD plugin --profile web add "file:$FIXTURE_TGZ" +say "安装插件 tarball(后到)…" +$DSH_CMD plugin --profile web add "file:$TARBALL" + +# ── 启动真实 dsh web ─────────────────────────────────────────────────────── +say "启动 dsh web(--port ${PORT},日志 ${LOG_DIR})…" +$DSH_CMD web --port "$PORT" >"$OUT_LOG" 2>"$ERR_LOG" & +SERVER_PID=$! + +# 等待启动 URL 或进程退出(最多 120s) +URL="" +for _ in $(seq 1 120); do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then break; fi + URL="$(grep -oE 'http://127\.0\.0\.1:[0-9]+' "$OUT_LOG" | tail -1 || true)" + [ -n "$URL" ] && break + sleep 1 +done +if [ -z "$URL" ]; then + warn "out log 尾部:$(tail -5 "$OUT_LOG" 2>/dev/null || true)" + warn "err log 尾部:$(tail -5 "$ERR_LOG" 2>/dev/null || true)" + die "dsh web 未在 120s 内启动" +fi +say "已启动:$URL" + +# ── 断言 ──────────────────────────────────────────────────────────────────── +if grep -q "duplicate prefix route" "$ERR_LOG" "$OUT_LOG" 2>/dev/null; then + die "检测到 duplicate prefix route(双挂载未退让)" +fi + +# 真实方法探活:terminal.deps 是插件自己的 handler(writeOk → HTTP 200 + +# {"ok":true,...})。成功响应证明至少一个实例真正注册了 /sidebar/api 路由 +# ——generic missing-route 404 无法冒充(P2: Probe a real sidebar API method)。 +DEPS="$(curl -s -X POST "$URL/sidebar/api/terminal.deps" 2>/dev/null || true)" +if ! printf '%s' "$DEPS" | grep -q '"ok":true'; then + die "/sidebar/api/terminal.deps 未返回 ok:true(响应:$(printf '%s' "$DEPS" | head -c 200))" +fi +say "/sidebar/api/terminal.deps → ok:true(真实 handler 存活)" + +STATUS="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$URL/sidebar/api/__e2e_unknown__" 2>/dev/null || true)" +say "/sidebar/api POST 未知方法 → HTTP ${STATUS}(期望 404)" +[ "$STATUS" = "404" ] || die "/sidebar/api 未知方法未按预期返回 404(HTTP ${STATUS})" + +say "通过:聚合双挂载场景下插件自动退让,侧边栏由聚合行接管。" diff --git a/scripts/e2e-mount.sh b/scripts/e2e-mount.sh index ba12c7d..983d08b 100755 --- a/scripts/e2e-mount.sh +++ b/scripts/e2e-mount.sh @@ -16,7 +16,9 @@ # DSH_CMD dsh 命令;缺省 PATH 上的 `dsh`,回退 npx 拉官方包 # TARBALL 插件 tarball;缺省仓库根 dsh-better-sidebar-*.tgz(须已 pack) # PORT 固定端口(默认 0 = OS 分配,从日志解析 URL) -# DSH_HOME_BASE 覆盖 scratch 根目录(默认 mktemp -d) +# DSH_HOME_BASE 覆盖 scratch 根目录(默认系统临时目录)。脚本始终在其下 +# 新建本调用拥有的独立子目录,只写入/删除该子目录;调用方 +# 提供的目录本身(可能是真实 ~/.dsh)绝不写入或删除。 # KEEP_HOME 非空时保留 scratch home(调试用) # # 退出码 = playwright 的退出码;服务器与 scratch 目录由 trap 兜底清理。 @@ -57,8 +59,14 @@ fi TARBALL="$(cd "$(dirname "$TARBALL")" && pwd)/$(basename "$TARBALL")" say "tarball: $TARBALL" -# scratch home(每次全新,绝不触碰真实 ~/.dsh) -SCRATCH="${DSH_HOME_BASE:-$(mktemp -d /tmp/dsh-e2e-mount.XXXXXX)}" +# scratch home(每次全新,绝不触碰真实 ~/.dsh):调用方给了 DSH_HOME_BASE +# 时,只在其下新建本调用拥有的子目录并只删除该子目录;缺省时直接用系统 +# 临时目录。 +if [ -n "${DSH_HOME_BASE:-}" ]; then + SCRATCH="$(mktemp -d "$DSH_HOME_BASE/dsh-e2e-mount.XXXXXX")" +else + SCRATCH="$(mktemp -d /tmp/dsh-e2e-mount.XXXXXX)" +fi export DSH_HOME="$SCRATCH/home" WORKSPACE_DIR="$SCRATCH/workspace" LOG_DIR="$SCRATCH" diff --git a/tests/fixtures/aggregate-better-sidebar/cordis.patch.yml b/tests/fixtures/aggregate-better-sidebar/cordis.patch.yml new file mode 100644 index 0000000..8501355 --- /dev/null +++ b/tests/fixtures/aggregate-better-sidebar/cordis.patch.yml @@ -0,0 +1,7 @@ +# Fixture aggregate bundle: mounts dsh-better-sidebar under its own entry id, +# the way real aggregate bundles (e.g. @linxin666/dsh-web-ui-all) do. Used by +# scripts/e2e-aggregate-mount.sh to prove the plugin's own bundle patch backs +# off when another enabled entry already mounts the same package. +- insert: + - id: aggregate-better-sidebar + name: 'dsh-better-sidebar' diff --git a/tests/fixtures/aggregate-better-sidebar/lib/index.js b/tests/fixtures/aggregate-better-sidebar/lib/index.js new file mode 100644 index 0000000..c688ea6 --- /dev/null +++ b/tests/fixtures/aggregate-better-sidebar/lib/index.js @@ -0,0 +1,4 @@ +// No-op plugin body. This fixture only carries a patch layer (see +// cordis.patch.yml); no entry ever references this package by name, but the +// main file keeps npm pack's manifest valid. +export function apply() {} diff --git a/tests/fixtures/aggregate-better-sidebar/package.json b/tests/fixtures/aggregate-better-sidebar/package.json new file mode 100644 index 0000000..31c6ebd --- /dev/null +++ b/tests/fixtures/aggregate-better-sidebar/package.json @@ -0,0 +1,16 @@ +{ + "name": "fixture-aggregate-better-sidebar", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "lib/index.js", + "files": [ + "lib", + "cordis.patch.yml" + ], + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + } +}