#!/usr/bin/env bash # ============================================================================= # v-plugin.sh —— S0 尺子②:插件四判据(装配 / 运行 / 取数 / 回调) # 规格来源:交付物/功能打包到基础插件-分步实施方案-20260926.md §3.10.1(尺子表)· §3.10.2 S0-3 # ----------------------------------------------------------------------------- # 约定 # 1. **退出码即结论**:rc=0 绿 / rc=1 红 / rc=3 **未取到**(⛔ 3 不是绿 —— 取不到就如实报,⛔ 不折算成"已知") # 2. **只许测活体**:装配/运行 取真实 profile 与真实 boot 日志;取数/回调 **真实 import 并真实执行 apply()** # —— ⛔ 不用 dump-config 类"不 boot 就出结果"的路子;⛔ 不用静态 grep 冒充运行结论 # 3. 落点:脚本在 交付物/验证脚本/;临时 harness 落 tmp/p39/ # 用法 # bash v-plugin.sh 装配|运行|取数|回调 [--target N] [--home H] [--profile-json F] [--log F] [--entry F] # 英文别名:assembly|run|fetch|callback # 靶子:默认 @dsh-client/portal-first-screen(**已有插件**,已装在开发态桌面 profile) # ----------------------------------------------------------------------------- # 复核命令 # 装配: cat $HOME/profiles/desktop/package.json ;node createRequire().resolve(target) # 运行: ls $HOME/logs/startup-*.log ;grep -n <最新启动日志> # 取数: node tmp/p39/_probe.mjs fetch # 回调: node tmp/p39/_probe.mjs callback # ============================================================================= set -u SELF_DIR="$(cd "$(dirname "$0")" && pwd -W)" [ -d "$SELF_DIR" ] || SELF_DIR="$(cd "$(dirname "$0")" && pwd)" WS="$(cd "$SELF_DIR/../.." && pwd -W)" [ -d "$WS" ] || WS="$(cd "$SELF_DIR/../.." && pwd)" OUT="${P39_OUT:-$WS/tmp/p39}" PTRACE="$OUT/_probe.mjs" NODE="${NODE:-E:/ProgramData/.workbuddy/binaries/node/versions/22.22.2-3/node.exe}" PY="${PY:-E:/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe}" DSH_DESKTOP_HOME="${DSH_DESKTOP_HOME:-E:/github/dsh-desktop-0.1.7rc2/apps/desktop/.desktop-build/development/home-adapt}" TARGET="@dsh-client/portal-first-screen" PROFILE_JSON="" LOG_FILE="" ENTRY="" FACE_RAW="${1:-}" [ -z "$FACE_RAW" ] && { echo "用法: v-plugin.sh <装配|运行|取数|回调> [--target N] [--home H]..."; exit 2; } shift || true while [ $# -gt 0 ]; do case "$1" in --target) TARGET="$2"; shift 2 ;; --home) DSH_DESKTOP_HOME="$2"; shift 2 ;; --profile-json) PROFILE_JSON="$2"; shift 2 ;; --log) LOG_FILE="$2"; shift 2 ;; --entry) ENTRY="$2"; shift 2 ;; *) echo "⛔ 未知参数:$1"; exit 2 ;; esac done case "$FACE_RAW" in 装配|assembly|a) FACE=装配 ;; 运行|run|r) FACE=运行 ;; 取数|fetch|f) FACE=取数 ;; 回调|callback|c) FACE=回调 ;; *) echo "⛔ 未知判据面:$FACE_RAW(只许 装配|运行|取数|回调)"; exit 2 ;; esac [ -z "$PROFILE_JSON" ] && PROFILE_JSON="$DSH_DESKTOP_HOME/profiles/desktop/package.json" PROFILE_DIR="$(dirname "$PROFILE_JSON")" mkdir -p "$OUT" green() { echo "✅ 绿(rc=0)|判据【$FACE】:$*"; exit 0; } red() { echo "❌ 红(rc=1)|判据【$FACE】:$*"; exit 1; } na() { echo "⚠️ 未取到(rc=3)|判据【$FACE】:$*"; exit 3; } resolve_entry() { "$NODE" -e 'const{createRequire}=require("module");const r=createRequire(process.argv[1]);process.stdout.write(r.resolve(process.argv[2]))' "$PROFILE_JSON" "$TARGET" 2>/dev/null } case "$FACE" in # --------------------------------------------------------------------------- 装配) [ -f "$PROFILE_JSON" ] || na "profile 不存在:$PROFILE_JSON" echo "── 【装配】靶子=$TARGET profile=$PROFILE_JSON" PY_OUT="$("$PY" - "$PROFILE_JSON" "$TARGET" <<'PYEOF' 2>/dev/null import json, sys pj, target = sys.argv[1], sys.argv[2] d = json.load(open(pj, encoding='utf-8')) b = ((d.get('dsh') or {}).get('profile') or {}).get('bundles') or [] print("IN_BUNDLES=%s" % ("yes" if target in b else "no")) print("BUNDLE_COUNT=%d" % len(b)) PYEOF )" echo " ① 声明在 profile bundles:$(printf '%s\n' "$PY_OUT" | sed -n 's/^IN_BUNDLES=//p')(该 profile 共 $(printf '%s\n' "$PY_OUT" | sed -n 's/^BUNDLE_COUNT=//p') 个 bundle)" printf '%s\n' "$PY_OUT" | grep -q '^IN_BUNDLES=yes' || red "① 靶子不在 dsh.profile.bundles 里 ⇒ 装配前提不成立" RESOLVED="$(resolve_entry)" if [ -z "$RESOLVED" ] || [ ! -f "$RESOLVED" ]; then red "② 从 profile 目录解析不到靶子入口(createRequire.resolve 失败或文件不存在):'$RESOLVED'" fi echo " ② 自 profile 目录解析成功:$RESOLVED" PKGJSON="$("$NODE" -e 'const{createRequire}=require("module");const r=createRequire(process.argv[1]);process.stdout.write(r.resolve(process.argv[2]+"/package.json"))' "$PROFILE_JSON" "$TARGET" 2>/dev/null)" [ -f "$PKGJSON" ] || red "③ 解析不到靶子 package.json" PKGDIR="$(dirname "$PKGJSON")" PATCH="$(sed -n 's/.*"patch"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PKGJSON" | head -1)" if [ -n "$PATCH" ]; then [ -f "$PKGDIR/${PATCH#./}" ] || red "③ 声明了装载补丁 $PATCH 但文件不存在:$PKGDIR/${PATCH#./}" echo " ③ 装载补丁在位:${PATCH#./}" else echo " ③ ⚠️ 该包未声明 dsh.bundle.patch(非 profile 装载形态)" fi echo " 入口文件:$RESOLVED ($(find "$RESOLVED" -type f -printf '%s\n' 2>/dev/null || wc -c < "$RESOLVED") B)" green "三条装配前提(bundles 声明 / 可解析 / 产物在位)全部成立" ;; # --------------------------------------------------------------------------- 运行) HOME_DIR="$DSH_DESKTOP_HOME" if [ -z "$LOG_FILE" ]; then LOG_FILE="$(ls -1t "$HOME_DIR/logs/"startup-*.log 2>/dev/null | head -1)" fi [ -n "$LOG_FILE" ] && [ -f "$LOG_FILE" ] || na "无启动日志可取($HOME_DIR/logs/startup-*.log 不存在)⇒ 运行态证据只能取活体 boot,本机当前无新 boot 记录" echo "── 【运行】靶子=$TARGET 日志=$LOG_FILE(mtime $(date -r "$LOG_FILE" -Iseconds 2>/dev/null || echo '?'))" HITS="$(grep -n "$TARGET" "$LOG_FILE" 2>/dev/null | head -5)" FAILBLOCK="$(grep -c 'Failed plugins' "$LOG_FILE" 2>/dev/null || echo 0)" if [ -n "$HITS" ]; then echo " 日志中命中靶子的失败条目:" printf '%s\n' "$HITS" | sed 's/^/ /' | cut -c1-160 red "靶子出现在启动失败的 Failed plugins 列表里 ⇒ 运行面红" fi echo " 日志中无靶子的失败条目(该日志 'Failed plugins' 出现 $FAILBLOCK 次)" echo ' ⚠️ 证据强度:**无失败条目**(弱证据,⛔ 不是「已挂载」的证明)' echo " ⚠️ 已知坑:**启动日志只记失败条目**,插件自己的 logger.info 不在其中 ⇒「成功挂载」只能靠 RPC/浏览器级" green "靶子未出现于启动失败面(本面为**失败面判据**:出现即红;不出现 ≠ 已挂载)" ;; # --------------------------------------------------------------------------- 取数|回调) if [ -z "$ENTRY" ]; then ENTRY="$(resolve_entry)" [ -n "$ENTRY" ] && [ -f "$ENTRY" ] || na "解析不到靶子入口 ⇒ 无法真实 import" fi [ -f "$ENTRY" ] || na "入口文件不存在:$ENTRY" cat > "$PTRACE" <<'JSEOF' import { pathToFileURL } from 'node:url'; const face = process.argv[2]; const entry = process.argv[3]; const calls = []; function summarize(a) { if (a === null) return 'null'; const t = typeof a; if (t === 'string') return a.length > 70 ? JSON.stringify(a.slice(0, 70) + '…') : JSON.stringify(a); if (t === 'function') return ''; if (t === 'number' || t === 'boolean' || t === 'undefined') return String(a); if (t === 'object') { if (typeof a.path === 'string') return JSON.stringify({ path: a.path, methods: a.methods === undefined ? null : a.methods }); try { const s = JSON.stringify(a); return s && s.length > 70 ? s.slice(0, 70) + '…' : String(s); } catch (e) { return ''; } } return String(a); } function mkSpy(path, depth) { const fn = function () {}; return new Proxy(fn, { get(t, k) { if (typeof k === 'symbol') return undefined; if (k === 'then') return undefined; if (depth > 5) return undefined; return mkSpy(path + '.' + String(k), depth + 1); }, apply(t, th, args) { calls.push({ call: path, args: args.map(summarize) }); return mkSpy(path + '()', depth + 1); }, }); } const out = (o) => process.stdout.write(JSON.stringify(o, null, 1) + '\n'); let mod; try { mod = await import(pathToFileURL(entry).href); } catch (e) { out({ face, entry, verdict: 'UNAVAILABLE', reason: 'import failed: ' + (e && e.message) }); process.exit(3); } if (face === 'fetch') { const checks = { '导出 apply 是函数': typeof mod.apply === 'function', '导出 inject 是数组': Array.isArray(mod.inject), 'inject 含 connection(E1 路由能挂的前提)': Array.isArray(mod.inject) && mod.inject.includes('connection'), '导出 name 是非空字符串': typeof mod.name === 'string' && mod.name.length > 0, }; const bad = Object.entries(checks).filter(([, v]) => !v).map(([k]) => k); out({ face, entry, exportsOnModule: Object.keys(mod), checks, verdict: bad.length ? 'RED' : 'GREEN' }); process.exit(bad.length ? 1 : 0); } const ctx = mkSpy('ctx', 0); let applyErr = null; try { const p = Promise.resolve().then(() => mod.apply(ctx)); const to = new Promise((_, rej) => setTimeout(() => rej(new Error('timeout 8000ms')), 8000)); await Promise.race([p, to]); } catch (e) { applyErr = (e && e.message) || String(e); } const onCalls = calls.filter((c) => c.call === 'ctx.on'); const routeCalls = calls.filter((c) => c.call === 'ctx.connection.fetch.register'); const checks = { 'apply 可调用且未抛错': applyErr === null, '注册了回调(ctx.on 至少一次)': onCalls.length >= 1, }; const bad = Object.entries(checks).filter(([, v]) => !v).map(([k]) => k); out({ face, entry, applyError: applyErr, callCount: calls.length, onCalls, routeCalls, checks, verdict: bad.length ? 'RED' : 'GREEN' }); process.exit(bad.length ? 1 : 0); JSEOF echo "── 【$FACE】靶子=$TARGET entry=$ENTRY" if [ "$FACE" = "取数" ]; then echo ' ⚠️ 证据强度:**真实 import + 真实模块求值**(产物被真的执行了)⇒ 对「包产物能不能被宿主加载器吃下」这一面成立;' echo ' ⛔ 本面**不含端到端 RPC 取数**(本机当前无活体实例/RPC 靶子)⇒ 端到端取数须等 S2 后在实例内补测' else echo ' ⚠️ 证据强度:**真实调用 apply(ctx)**(spy ctx 记录真实副作用;apply 抛错即红)' fi ROUT="$("$NODE" "$PTRACE" "$([ "$FACE" = "取数" ] && echo fetch || echo callback)" "$ENTRY" 2>&1)"; RC=$? printf '%s\n' "$ROUT" | sed 's/^/ /' | cut -c1-200 case "$RC" in 0) green "靶子真实产物满足本面判据(原始输出见上,退出码 rc=0)" ;; 3) na "真实 import 未成功 ⇒ 本面未取到(⛔ 不是绿):$(printf '%s' "$ROUT" | tr '\n' ' ' | head -c 200)" ;; *) red "本面判据不成立(rc=$RC,原始输出见上)" ;; esac ;; esac