dsh-ppt-studio 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +480 -0
- package/agent-presets/ppt/agent.cordis.yml +308 -0
- package/agent-presets/ppt/preset.yml +3 -0
- package/cordis.patch.yml +29 -0
- package/docs/00-/350/256/276/350/256/241/346/272/220.md +537 -0
- package/docs/01-/351/234/200/346/261/202/344/270/216/347/233/256/346/240/207.md +136 -0
- package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +483 -0
- package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +2898 -0
- package/docs/04-/350/267/257/347/272/277/345/233/276/344/270/216/351/207/214/347/250/213/347/242/221.md +113 -0
- package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +210 -0
- package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +296 -0
- package/docs/fidelity-report.md +225 -0
- package/examples/fx/fx-mini/deck.yaml +39 -0
- package/examples/fx/fx-mini/pages/01.yaml +6 -0
- package/examples/fx/fx-mini/pages/02.yaml +2 -0
- package/examples/fx/fx-mini/pages/03.yaml +6 -0
- package/examples/fx/fx-pro/deck.yaml +48 -0
- package/examples/fx/fx-pro/pages/01_cover.yaml +40 -0
- package/examples/fx/fx-pro/pages/02_arch.yaml +55 -0
- package/examples/fx/fx-pro/pages/03_table.yaml +20 -0
- package/examples/fx/fx-pro/pages/04_chart.yaml +40 -0
- package/examples/fx/fx-pro/pages/05_timeline.yaml +71 -0
- package/examples/fx/fx-pro/pages/06_longtext.yaml +13 -0
- package/examples/fx/fx-pro/pages/07_safearea.yaml +33 -0
- package/examples/fx/fx-pro/pages/08_contrast.yaml +22 -0
- package/examples/fx/fx-pro/pages/09_custgeom.yaml +32 -0
- package/examples/fx/fx-pro/pages/10_numbers.yaml +25 -0
- package/examples/fx/fx-pro/pages/11_section.yaml +20 -0
- package/examples/fx/fx-pro/pages/12_summary.yaml +57 -0
- package/examples/fx/fx-pro/preview/01_pages_01_cover.html +1 -0
- package/examples/fx/fx-pro/preview/02_pages_02_arch.html +3 -0
- package/examples/fx/fx-pro/preview/03_pages_03_table.html +1 -0
- package/examples/fx/fx-pro/preview/04_pages_04_chart.html +3 -0
- package/examples/fx/fx-pro/preview/05_pages_05_timeline.html +2 -0
- package/examples/fx/fx-pro/preview/06_pages_06_longtext.html +4 -0
- package/examples/fx/fx-pro/preview/07_pages_07_safearea.html +2 -0
- package/examples/fx/fx-pro/preview/08_pages_08_contrast.html +1 -0
- package/examples/fx/fx-pro/preview/09_pages_09_custgeom.html +1 -0
- package/examples/fx/fx-pro/preview/10_pages_10_numbers.html +1 -0
- package/examples/fx/fx-pro/preview/11_pages_11_section.html +1 -0
- package/examples/fx/fx-pro/preview/12_pages_12_summary.html +1 -0
- package/examples/fx/fx-pro/preview/deck.html +10 -0
- package/examples/fx/fx-pro/preview/layout.json +2325 -0
- package/examples/fx/fx-pro/preview/measured.json +814 -0
- package/examples/fx/fx-pro.pptx +0 -0
- package/examples/fx/ren-single/01.png +0 -0
- package/examples/fx/ren-spliced/01.png +0 -0
- package/examples/fx/ren-spliced/03.png +0 -0
- package/examples/fx/seed.pptx +0 -0
- package/examples/fx/single.pptx +0 -0
- package/examples/fx/spliced.pptx +0 -0
- package/examples/smoke/deck.yaml +26 -0
- package/examples/smoke/imported/deck.yaml +30 -0
- package/examples/smoke/imported/import-styles.json +126 -0
- package/examples/smoke/imported/pages/slide_01.yaml +31 -0
- package/examples/smoke/imported/pages/slide_02.yaml +38 -0
- package/examples/smoke/imported/pages/slide_03.yaml +55 -0
- package/examples/smoke/imported/preview-rt/01_pages_slide_01.html +1 -0
- package/examples/smoke/imported/preview-rt/02_pages_slide_02.html +1 -0
- package/examples/smoke/imported/preview-rt/03_pages_slide_03.html +1 -0
- package/examples/smoke/imported/preview-rt/deck.html +1 -0
- package/examples/smoke/imported/preview-rt/layout.json +434 -0
- package/examples/smoke/imported/reference/previews/01.png +0 -0
- package/examples/smoke/imported/reference/previews/02.png +0 -0
- package/examples/smoke/imported/reference/previews/03.png +0 -0
- package/examples/smoke/imported/source.pptx +0 -0
- package/examples/smoke/media-test.png +0 -0
- package/examples/smoke/out-smoke.pptx +0 -0
- package/examples/smoke/pages/01_cover.yaml +27 -0
- package/examples/smoke/pages/02_content.yaml +42 -0
- package/examples/smoke/pages/03_chart.yaml +35 -0
- package/examples/smoke/preview/01_pages_01_cover.html +1 -0
- package/examples/smoke/preview/02_pages_02_content.html +1 -0
- package/examples/smoke/preview/03_pages_03_chart.html +1 -0
- package/examples/smoke/preview/deck.html +1 -0
- package/examples/smoke/preview/layout.json +378 -0
- package/lib/autodeclare.js +40 -0
- package/lib/commands.js +161 -0
- package/lib/crosscheck.js +300 -0
- package/lib/imgmeta.js +55 -0
- package/lib/index.js +239 -0
- package/lib/measurement.js +209 -0
- package/lib/msrender.js +123 -0
- package/lib/pptd/export-pptx.js +686 -0
- package/lib/pptd/import-pptx.js +774 -0
- package/lib/pptd/layout.js +209 -0
- package/lib/pptd/render-html.js +254 -0
- package/lib/pptd/schema.js +518 -0
- package/lib/pptd/svgCharts.js +147 -0
- package/lib/pptxPy.js +205 -0
- package/lib/preset-delivery.js +195 -0
- package/lib/preview-server.js +164 -0
- package/lib/router.js +165 -0
- package/lib/scaffold.js +315 -0
- package/lib/skill.js +174 -0
- package/lib/splice.js +219 -0
- package/lib/state.js +88 -0
- package/lib/surgical.js +369 -0
- package/lib/templates.js +400 -0
- package/lib/tools.js +955 -0
- package/lib/verify.js +828 -0
- package/lib/xmljs.js +112 -0
- package/lib/zips.js +117 -0
- package/package.json +81 -0
- package/scripts/audit-manual-facts.mjs +294 -0
- package/scripts/audit-template-fidelity.mjs +162 -0
- package/scripts/bench-m2.mjs +48 -0
- package/scripts/build-templates.mjs +63 -0
- package/scripts/build.mjs +14 -0
- package/scripts/build.sh +7 -0
- package/scripts/check-lib-freshness.mjs +70 -0
- package/scripts/check-preset.mjs +189 -0
- package/scripts/check-tracked-lf.mjs +84 -0
- package/scripts/dbg-import.mjs +13 -0
- package/scripts/dbg-media.mjs +10 -0
- package/scripts/dbg-tpl-inventory.mjs +23 -0
- package/scripts/dump-workflow-baseline.mjs +38 -0
- package/scripts/e2e-1.0.mjs +129 -0
- package/scripts/eval-skills-blind.mjs +91 -0
- package/scripts/eval-skills-score.mjs +129 -0
- package/scripts/export-orig-templates.ps1 +17 -0
- package/scripts/fixtures/workflow-baseline.json +300 -0
- package/scripts/fixtures.mjs +501 -0
- package/scripts/import-user-templates.mjs +60 -0
- package/scripts/install-desktop.mjs +312 -0
- package/scripts/install.mjs +209 -0
- package/scripts/migrate-templates-dualtrack.mjs +71 -0
- package/scripts/preflight-1.0.mjs +198 -0
- package/scripts/probe-asset-freshness.mjs +57 -0
- package/scripts/probe-effect-semantics.mjs +69 -0
- package/scripts/probe-reparse-safety.mjs +32 -0
- package/scripts/rebuild-templates-c11.mjs +69 -0
- package/scripts/regression-real.mjs +84 -0
- package/scripts/release-sync.mjs +425 -0
- package/scripts/shot-templates.mjs +49 -0
- package/scripts/smoke.mjs +2512 -0
- package/scripts/verify-bundle-install.mjs +281 -0
- package/scripts/verify-fresh-install.mjs +260 -0
- package/skills/ppt-studio-copy/SKILL.md +139 -0
- package/skills/ppt-studio-craft/SKILL.md +171 -0
- package/skills/ppt-studio-data/SKILL.md +173 -0
- package/skills/ppt-studio-manual/SKILL.md +104 -0
- package/templates/academic-white/deck.yaml +24 -0
- package/templates/academic-white/pages/_chart.yaml +43 -0
- package/templates/academic-white/pages/_compare.yaml +37 -0
- package/templates/academic-white/pages/_content.yaml +35 -0
- package/templates/academic-white/pages/_cover.yaml +28 -0
- package/templates/academic-white/pages/_ending.yaml +28 -0
- package/templates/academic-white/pages/_flow.yaml +61 -0
- package/templates/academic-white/preview.png +0 -0
- package/templates/academic-white/template.yaml +14 -0
- package/templates/business-blue/deck.yaml +28 -0
- package/templates/business-blue/pages/_chart.yaml +36 -0
- package/templates/business-blue/pages/_compare.yaml +45 -0
- package/templates/business-blue/pages/_content.yaml +80 -0
- package/templates/business-blue/pages/_cover.yaml +39 -0
- package/templates/business-blue/pages/_ending.yaml +32 -0
- package/templates/business-blue/pages/_flow.yaml +87 -0
- package/templates/business-blue/preview.png +0 -0
- package/templates/business-blue/template.yaml +14 -0
- package/templates/pitch-bold/deck.yaml +23 -0
- package/templates/pitch-bold/pages/_chart.yaml +32 -0
- package/templates/pitch-bold/pages/_compare.yaml +36 -0
- package/templates/pitch-bold/pages/_content.yaml +58 -0
- package/templates/pitch-bold/pages/_cover.yaml +31 -0
- package/templates/pitch-bold/pages/_ending.yaml +27 -0
- package/templates/pitch-bold/pages/_flow.yaml +46 -0
- package/templates/pitch-bold/preview.png +0 -0
- package/templates/pitch-bold/template.yaml +14 -0
- package/templates/tech-dark/deck.yaml +24 -0
- package/templates/tech-dark/pages/_chart.yaml +27 -0
- package/templates/tech-dark/pages/_compare.yaml +38 -0
- package/templates/tech-dark/pages/_content.yaml +51 -0
- package/templates/tech-dark/pages/_cover.yaml +30 -0
- package/templates/tech-dark/pages/_ending.yaml +25 -0
- package/templates/tech-dark/pages/_flow.yaml +80 -0
- package/templates/tech-dark/preview.png +0 -0
- package/templates/tech-dark/template.yaml +14 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* profile bundle 安装自证(2026-09-14 新增)——回答"能不能用 `dsh plugin add` 装"这个问题的**真机证据**。
|
|
3
|
+
*
|
|
4
|
+
* 做法:**完全隔离**(DSH_HOME 指向临时目录,不碰用户的 profiles)→ 打包本仓库 → 在临时 profile 里跑
|
|
5
|
+
* 真实的 `dsh plugin --profile web add <tgz>` → 断言三件事:
|
|
6
|
+
* ① profile 的 package.json 里出现本包依赖;
|
|
7
|
+
* ② `dsh.profile.bundles` **自动**把本包收进层栈(这正是 dsh.bundle.patch 声明的作用);
|
|
8
|
+
* ③ `dsh --profile web --dump-config` 的组合树里能看到我们的插件行(id: ppt-studio);
|
|
9
|
+
* ④ **升级路径**(2026-09-15 加):同版本号、内容不同的第二个 tgz 再 add 一次 → 必须真换成新字节,
|
|
10
|
+
* 且依赖规格改指新包、bundles 不丢、dump-config 仍见本行——"以后更新还顺不顺"的可复跑证据。
|
|
11
|
+
* ⑤ **--local 迭代模式**(2026-09-18 加):同一个隔离 DSH_HOME 里跑 `release-sync --local` →
|
|
12
|
+
* 断言全程无上传、状态文件 mode=local、profile 规格变成指向本地稳定构件的 `file:`,
|
|
13
|
+
* 且**挂载副本的 lib/index.js 与仓库刚构建的逐字节相同**——"日常迭代不发版,但本机跑的就是最新"的证据。
|
|
14
|
+
*
|
|
15
|
+
* 依赖 `dsh` 与 pnpm 在 PATH 上;缺失则跳过(打印警告,退出码 0,避免把它变成脆弱门禁)。
|
|
16
|
+
* 用法:
|
|
17
|
+
* node scripts/verify-bundle-install.mjs # 自己 npm pack(本地 tgz)
|
|
18
|
+
* node scripts/verify-bundle-install.mjs <tgz 路径> # 用指定 tgz
|
|
19
|
+
* node scripts/verify-bundle-install.mjs <https://…tgz> # **验"用户会敲的那条命令"**(GitHub 资产 URL)
|
|
20
|
+
*/
|
|
21
|
+
import { join, dirname } from 'node:path'
|
|
22
|
+
import { fileURLToPath } from 'node:url'
|
|
23
|
+
import { readFileSync, existsSync, mkdirSync, rmSync, cpSync, appendFileSync, readdirSync, writeFileSync, lstatSync } from 'node:fs'
|
|
24
|
+
import { spawnSync } from 'node:child_process'
|
|
25
|
+
import { createHash } from 'node:crypto'
|
|
26
|
+
import { createRequire } from 'node:module'
|
|
27
|
+
import { tmpdir, homedir } from 'node:os'
|
|
28
|
+
import YAML from 'yaml'
|
|
29
|
+
|
|
30
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
31
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
32
|
+
const NAME = pkg.name
|
|
33
|
+
|
|
34
|
+
let pass = 0
|
|
35
|
+
let fail = 0
|
|
36
|
+
const check = (label, ok, extra = '') => {
|
|
37
|
+
console.log(`${ok ? '✓' : '✗'} ${label}${extra ? ' — ' + extra : ''}`)
|
|
38
|
+
ok ? pass++ : fail++
|
|
39
|
+
}
|
|
40
|
+
const run = (cmd, args, opts = {}) => spawnSync(cmd, args, { encoding: 'utf8', shell: process.platform === 'win32', ...opts })
|
|
41
|
+
|
|
42
|
+
// 前置:dsh / pnpm 可用性
|
|
43
|
+
const dshV = run('dsh', ['--version'])
|
|
44
|
+
if (dshV.status !== 0) {
|
|
45
|
+
console.log('⚠ 跳过:PATH 上没有 `dsh`(本脚本验证的是真实 launcher 行为,必须在装有 DSH 的机器上跑)')
|
|
46
|
+
process.exit(0)
|
|
47
|
+
}
|
|
48
|
+
const pnpmV = run('pnpm', ['--version'])
|
|
49
|
+
if (pnpmV.status !== 0) {
|
|
50
|
+
console.log('⚠ 跳过:PATH 上没有 `pnpm`(`dsh plugin` 是 pnpm 转发器)')
|
|
51
|
+
process.exit(0)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const work = join(tmpdir(), `dsh-ppt-bundle-${Date.now()}`)
|
|
55
|
+
mkdirSync(work, { recursive: true })
|
|
56
|
+
const home = join(work, 'home')
|
|
57
|
+
mkdirSync(home, { recursive: true })
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
// 1) 安装规格:http(s) URL = 用户会敲的那条命令;路径 = 本地 tgz;都不给就自己打包
|
|
61
|
+
let tgz = process.argv[2]
|
|
62
|
+
const isUrl = typeof tgz === 'string' && /^https?:\/\//.test(tgz)
|
|
63
|
+
if (isUrl) {
|
|
64
|
+
check('使用远端 URL 规格(等价于用户实际执行的命令)', true, tgz)
|
|
65
|
+
} else if (!tgz) {
|
|
66
|
+
const pack = run('npm', ['pack', '--pack-destination', work], { cwd: root })
|
|
67
|
+
const name = (pack.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).pop()
|
|
68
|
+
tgz = join(work, name)
|
|
69
|
+
check('打包成功(npm pack)', existsSync(tgz), name ?? '(无输出)')
|
|
70
|
+
} else {
|
|
71
|
+
check('使用传入的 tgz', existsSync(tgz), tgz)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 2) 隔离 DSH_HOME 下真实安装
|
|
75
|
+
const env = { ...process.env, DSH_HOME: home }
|
|
76
|
+
const add = run('dsh', ['plugin', '--profile', 'web', 'add', tgz], { env, cwd: work })
|
|
77
|
+
const out = `${add.stdout ?? ''}\n${add.stderr ?? ''}`
|
|
78
|
+
check('`dsh plugin --profile web add <tgz>` 退出码 0', add.status === 0, out.trim().split(/\r?\n/).slice(-3).join(' | ').slice(0, 200))
|
|
79
|
+
check('**没有** "declares no dsh.bundle" 警告(证明 dsh.bundle.patch 被认到)', !/declares no dsh\.bundle/.test(out),
|
|
80
|
+
/declares no dsh\.bundle/.test(out) ? '仍被判为普通依赖(dsh.bundle 声明没生效)' : '已按 bundle 处理')
|
|
81
|
+
|
|
82
|
+
// 3) profile 清单:依赖 + bundles 自动入栈
|
|
83
|
+
const profPkg = join(home, 'profiles', 'web', 'package.json')
|
|
84
|
+
check('profile 目录已生成 package.json', existsSync(profPkg), profPkg)
|
|
85
|
+
const prof = existsSync(profPkg) ? JSON.parse(readFileSync(profPkg, 'utf8')) : {}
|
|
86
|
+
const bundles = prof.dsh?.profile?.bundles ?? []
|
|
87
|
+
check('本包已在 profile 依赖里', Boolean(prof.dependencies?.[NAME]), JSON.stringify(prof.dependencies?.[NAME] ?? null))
|
|
88
|
+
check('本包已**自动**进入 dsh.profile.bundles(层栈)', bundles.includes(NAME),
|
|
89
|
+
bundles.length ? `bundles = ${bundles.join(', ')}` : '(bundles 为空)')
|
|
90
|
+
check('包已物化到 profile 的 node_modules', existsSync(join(home, 'profiles', 'web', 'node_modules', NAME, 'cordis.patch.yml')),
|
|
91
|
+
join('node_modules', NAME, 'cordis.patch.yml'))
|
|
92
|
+
// 新鲜度:装到的必须是**当前仓库这一版**(防 pnpm/pnpm store 命中旧缓存,让"验证"变成假通过)
|
|
93
|
+
const installedPkg = join(home, 'profiles', 'web', 'node_modules', NAME, 'package.json')
|
|
94
|
+
const installedHasProbe = existsSync(join(home, 'profiles', 'web', 'node_modules', NAME, 'scripts', 'probe-effect-semantics.mjs'))
|
|
95
|
+
const installedVer = existsSync(installedPkg) ? JSON.parse(readFileSync(installedPkg, 'utf8')).version : null
|
|
96
|
+
check('装到的是当前版本(不是缓存里的旧包:新增的 probe 脚本在位)',
|
|
97
|
+
installedVer === pkg.version && installedHasProbe,
|
|
98
|
+
`安装版本=${installedVer ?? '(缺)'}|仓库版本=${pkg.version}|probe 脚本=${installedHasProbe}`)
|
|
99
|
+
|
|
100
|
+
// 4) 组合树里能看到我们的行
|
|
101
|
+
const dump = run('dsh', ['--profile', 'web', '--dump-config'], { env, cwd: work })
|
|
102
|
+
const tree = `${dump.stdout ?? ''}\n${dump.stderr ?? ''}`
|
|
103
|
+
check('`dsh --profile web --dump-config` 能跑通', dump.status === 0, `exit=${dump.status}`)
|
|
104
|
+
check('组合树里出现 ppt-studio 插件行(bundle patch 生效)', /ppt-studio/.test(tree),
|
|
105
|
+
(tree.match(/^.*ppt-studio.*$/m) ?? ['(未出现)'])[0].trim().slice(0, 120))
|
|
106
|
+
|
|
107
|
+
// 5) **升级路径自证**(2026-09-15:用户问"后续更新还顺不顺"——这条把承诺变成可复跑证据)
|
|
108
|
+
// 形状:同一版本号、内容不同的第二个 tgz(新规格 ⇒ 包管理器必然重新解析,与"资产名带构建戳"同一机制)。
|
|
109
|
+
// 断言三件事:add 成功 / 装到的字节是第二份(真升级,不是缓存复用)/ 升级后装配不丢(依赖规格改指新包、
|
|
110
|
+
// bundles 仍在、dump-config 仍见 ppt-studio 行)。只验"版本号变化"是不够的——历史事故正是"命令成功但字节是旧的"。
|
|
111
|
+
const installedDir = join(home, 'profiles', 'web', 'node_modules', NAME)
|
|
112
|
+
const pkg2 = join(work, 'pkg2')
|
|
113
|
+
cpSync(installedDir, pkg2, { recursive: true, dereference: true })
|
|
114
|
+
const marker = `// upgrade-probe-${Date.now()}`
|
|
115
|
+
appendFileSync(join(pkg2, 'lib', 'index.js'), `\n${marker}\n`)
|
|
116
|
+
// 关键细节(本夹具第一版就栽在这里):第二个 tgz 必须落在**另一个目录**——否则 npm pack 会覆盖第一个、
|
|
117
|
+
// 路径规格一模一样,pnpm 视为"同一个规格"直接复用 ⇒ 测出来的是"没升级"而不是"升级失败"。
|
|
118
|
+
const up2Dir = join(work, 'up2')
|
|
119
|
+
mkdirSync(up2Dir, { recursive: true })
|
|
120
|
+
const pack2 = run('npm', ['pack', '--pack-destination', up2Dir], { cwd: pkg2 })
|
|
121
|
+
const tgz2 = join(up2Dir, (pack2.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).pop() ?? 'none.tgz')
|
|
122
|
+
const sha = (f) => (existsSync(f) ? createHash('sha256').update(readFileSync(f)).digest('hex').slice(0, 12) : '(缺)')
|
|
123
|
+
// 基线的取法(2026-09-18 修):`--spec <https URL>` 时第一个"tgz"是 URL,`sha(tgz)` 恒为 '(缺)',
|
|
124
|
+
// 于是 `sha(tgz2) !== sha(tgz)` **恒真**——断言显示 ✓ 却什么也没证明("跳过与通过印同一个颜色"的变体)。
|
|
125
|
+
// 修法:URL 规格下把**装到的副本**(未加 marker)原样打一份作为基线——它与该 URL 同源,
|
|
126
|
+
// 已由上面那条"装到的是当前版本 + probe 脚本在位"独立证明。
|
|
127
|
+
let baseSha = sha(tgz)
|
|
128
|
+
if (isUrl) {
|
|
129
|
+
const baseDir = join(work, 'base')
|
|
130
|
+
cpSync(installedDir, baseDir, { recursive: true, dereference: true })
|
|
131
|
+
const basePackDir = join(work, 'base-pack')
|
|
132
|
+
mkdirSync(basePackDir, { recursive: true })
|
|
133
|
+
const bp = run('npm', ['pack', '--pack-destination', basePackDir], { cwd: baseDir })
|
|
134
|
+
const bpName = (bp.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).pop() ?? 'none.tgz'
|
|
135
|
+
baseSha = sha(join(basePackDir, bpName))
|
|
136
|
+
}
|
|
137
|
+
check('升级夹具:第二个 tgz 与首个**字节不同**(同版本号、内容不同——模拟"新版发布")',
|
|
138
|
+
existsSync(tgz2) && baseSha !== '(缺)' && sha(tgz2) !== baseSha, `tgzA=${baseSha}|tgzB=${sha(tgz2)}`)
|
|
139
|
+
|
|
140
|
+
const up = run('dsh', ['plugin', '--profile', 'web', 'add', tgz2], { env, cwd: work })
|
|
141
|
+
const upOut = `${up.stdout ?? ''}\n${up.stderr ?? ''}`
|
|
142
|
+
check('升级:对新规格再跑一次 `dsh plugin --profile web add` 退出码 0', up.status === 0,
|
|
143
|
+
upOut.trim().split(/\r?\n/).slice(-2).join(' | ').slice(0, 160))
|
|
144
|
+
const upgradedSrc = existsSync(join(installedDir, 'lib', 'index.js')) ? readFileSync(join(installedDir, 'lib', 'index.js'), 'utf8') : ''
|
|
145
|
+
check('升级:装到的字节真是新版(marker 在位)——不是"命令成功但内容还是旧的"',
|
|
146
|
+
upgradedSrc.includes(marker), upgradedSrc.includes(marker) ? 'marker 命中' : '仍是旧字节(升级没生效)')
|
|
147
|
+
const prof2 = existsSync(profPkg) ? JSON.parse(readFileSync(profPkg, 'utf8')) : {}
|
|
148
|
+
const spec2 = String(prof2.dependencies?.[NAME] ?? '')
|
|
149
|
+
const bundles2 = prof2.dsh?.profile?.bundles ?? []
|
|
150
|
+
const dump2 = run('dsh', ['--profile', 'web', '--dump-config'], { env, cwd: work })
|
|
151
|
+
check('升级后装配不丢:依赖规格已改指新 tgz + bundles 仍含本包 + dump-config 仍见 ppt-studio 行',
|
|
152
|
+
spec2.includes('tgz') && bundles2.includes(NAME) && /ppt-studio/.test(`${dump2.stdout ?? ''}${dump2.stderr ?? ''}`),
|
|
153
|
+
`spec=${spec2}|bundles含=${bundles2.includes(NAME)}|dump=${/ppt-studio/.test(`${dump2.stdout ?? ''}${dump2.stderr ?? ''}`)}`)
|
|
154
|
+
|
|
155
|
+
// 6) **--local 迭代模式自证**(2026-09-18):日常迭代不发 GitHub,但本机跑的必须就是刚构建的字节。
|
|
156
|
+
// 形状:复用同一个隔离 DSH_HOME(此刻 profile 已是 bundle 模式)→ 跑 `release-sync --local`
|
|
157
|
+
// → 断言五件事:退出码 0 / 全程没有上传动作 / 状态文件 mode=local + mountMatch + ok
|
|
158
|
+
// / profile 规格变成指向**本地稳定构件**的 file: / **挂载副本的 lib/index.js 与仓库刚构建的逐字节相同**。
|
|
159
|
+
// 最后一条才是"本机跑的就是最新"的判据——前四条都可能在"装的其实是旧字节"时仍然为真。
|
|
160
|
+
const syncRoot = join(work, 'sync-root')
|
|
161
|
+
const syncState = join(work, 'sync-state.json')
|
|
162
|
+
const rel = run('node', ['scripts/release-sync.mjs', '--local', '--root', syncRoot, '--state', syncState], { env, cwd: root })
|
|
163
|
+
const relOut = `${rel.stdout ?? ''}\n${rel.stderr ?? ''}`
|
|
164
|
+
check('--local:`release-sync --local` 退出码 0', rel.status === 0, relOut.trim().split(/\r?\n/).slice(-4).join(' | ').slice(0, 240))
|
|
165
|
+
check('--local:全程**没有**上传动作(本地迭代不发 GitHub)', !/release upload/.test(relOut),
|
|
166
|
+
/release upload/.test(relOut) ? '出现了 release upload!' : '未上传 ✓')
|
|
167
|
+
const st = existsSync(syncState) ? JSON.parse(readFileSync(syncState, 'utf8')) : {}
|
|
168
|
+
check('--local:状态文件 mode=local + mount=bundle-local + mountMatch + ok',
|
|
169
|
+
st.mode === 'local' && st.mount === 'bundle-local' && st.mountMatch === true && st.ok === true,
|
|
170
|
+
JSON.stringify({ mode: st.mode, mount: st.mount, mountMatch: st.mountMatch, ok: st.ok }))
|
|
171
|
+
const prof3 = existsSync(profPkg) ? JSON.parse(readFileSync(profPkg, 'utf8')) : {}
|
|
172
|
+
const spec3 = String(prof3.dependencies?.[NAME] ?? '')
|
|
173
|
+
check('--local:profile 依赖规格变成指向**本地稳定构件**的 file: 规格',
|
|
174
|
+
spec3.startsWith('file:') && spec3.includes('_artifacts'), spec3)
|
|
175
|
+
// 构件必须落在稳定目录:若还在 tmpdir,下次系统清理临时目录后该 profile 的 pnpm install 会直接失败。
|
|
176
|
+
let artifactFiles = []
|
|
177
|
+
try { artifactFiles = readdirSync(join(syncRoot, '_artifacts')).filter((f) => f.endsWith('.tgz')) } catch { /* 目录缺失即失败 */ }
|
|
178
|
+
check('--local:本地构件落在稳定目录 `_artifacts/`(不是 tmpdir),且恰好保留当前那一个',
|
|
179
|
+
artifactFiles.length === 1, `${join(syncRoot, '_artifacts')} → ${artifactFiles.join(', ') || '(空)'}`)
|
|
180
|
+
const repoSha12 = sha(join(root, 'lib', 'index.js'))
|
|
181
|
+
const mountSha12 = sha(join(installedDir, 'lib', 'index.js'))
|
|
182
|
+
check('--local:**挂载副本 == 仓库刚构建的 lib/index.js**(这才是"本机跑的就是最新"的判据)',
|
|
183
|
+
repoSha12 === mountSha12, `repo=${repoSha12}|mount=${mountSha12}`)
|
|
184
|
+
|
|
185
|
+
// 7) **预设可被选择器列出(不 broken)+ 安装器不再写预设目录**(2026-09-24 为 DSH 0.1.7 重写)
|
|
186
|
+
// 0.1.6 的形状:预设靠"写 `<dshHome>/.agent-presets/ppt/`"交付,用 `discoverPresets` 判健康。
|
|
187
|
+
// 0.1.7:目录发现**整个被删掉**(上游原文 "the harness discovers no preset on disk"),
|
|
188
|
+
// 预设改为向 `agentPresets` 注册表**声明**(本插件在激活时自己声明,见 src/preset-delivery.js)。
|
|
189
|
+
// 健康判据随之换成"真正决定它会不会 broken 的那一步":注册表 mount 预设时用**注册表自己 ctx 的
|
|
190
|
+
// baseUrl**(源码 `mountPreset(scope.ctx.extend({ baseUrl: record.context.baseUrl }), …)`,
|
|
191
|
+
// 而 `register()` 里 `context = this.ctx`)——即 `dsh-web-app` 的包目录,不是 profile、不是预设目录。
|
|
192
|
+
// 所以"每一行的 name 能否**从那里**解析"就是"预设会不会 broken"的判据;这里用 Node 的解析器真解一遍。
|
|
193
|
+
const home2 = join(work, 'home2')
|
|
194
|
+
const profB = join(home2, 'profiles', 'web')
|
|
195
|
+
const instPkgDir = join(profB, 'node_modules', ...NAME.split('/'))
|
|
196
|
+
mkdirSync(instPkgDir, { recursive: true })
|
|
197
|
+
// 夹具 = 已按 bundle 安装:依赖里有本包 + 包本体已被 pnpm 物化(带齐"声明预设"所需的资产)
|
|
198
|
+
writeFileSync(join(profB, 'package.json'), JSON.stringify({ name: 'p', dependencies: { [NAME]: 'file:x' } }, null, 2), 'utf8')
|
|
199
|
+
writeFileSync(join(instPkgDir, 'package.json'), JSON.stringify({ name: NAME, version: pkg.version }), 'utf8')
|
|
200
|
+
cpSync(join(root, 'lib'), join(instPkgDir, 'lib'), { recursive: true })
|
|
201
|
+
cpSync(join(root, 'agent-presets'), join(instPkgDir, 'agent-presets'), { recursive: true })
|
|
202
|
+
// 0.1.6 遗留产物:先埋一份预设目录,验证安装器会清掉它
|
|
203
|
+
const legacyPreset = join(home2, '.agent-presets', 'ppt')
|
|
204
|
+
mkdirSync(legacyPreset, { recursive: true })
|
|
205
|
+
writeFileSync(join(legacyPreset, 'agent.cordis.yml'), '- id: persona\n', 'utf8')
|
|
206
|
+
const env2 = { ...process.env, DSH_HOME: home2 }
|
|
207
|
+
const inst2 = run('node', [join(root, 'scripts', 'install.mjs'), '--prefix', home2], { env: env2 })
|
|
208
|
+
check('安装器:bundle 模式下退出码 0', inst2.status === 0,
|
|
209
|
+
`${String(inst2.stdout ?? '').trim().split(/\r?\n/).length} 行输出|exit=${inst2.status}`)
|
|
210
|
+
check('安装器:**不再**产出 `.agent-presets/`(0.1.7 无目录发现路径),并清理 0.1.6 遗留',
|
|
211
|
+
!existsSync(legacyPreset) && !/^-\s*id: ppt-studio$/m.test(readFileSync(join(instPkgDir, 'agent-presets', 'ppt', 'agent.cordis.yml'), 'utf8')),
|
|
212
|
+
`遗留目录=${existsSync(legacyPreset)}|组合含插件行=${/^-\s*id: ppt-studio$/m.test(readFileSync(join(instPkgDir, 'agent-presets', 'ppt', 'agent.cordis.yml'), 'utf8'))}`)
|
|
213
|
+
check('安装器:校验"声明预设"所需的资产(agent-presets/ppt/* + lib/preset-delivery.js)',
|
|
214
|
+
/预设资产已就位/.test(String(inst2.stdout ?? '')) && existsSync(join(instPkgDir, 'lib', 'preset-delivery.js')),
|
|
215
|
+
/预设资产已就位/.test(String(inst2.stdout ?? '')) ? '资产校验通过 ✓' : '(无资产校验输出)')
|
|
216
|
+
const iso2 = run('node', [join(root, 'scripts', 'install.mjs'), '--prefix', home2, '--isolate'], { env: env2 })
|
|
217
|
+
check('安装器:`--isolate` 在 0.1.7 上**明确拒绝**(exit 1 + 说清原因,不写出没人读的预设)',
|
|
218
|
+
iso2.status === 1 && /不再可用/.test(`${iso2.stdout ?? ''}${iso2.stderr ?? ''}`),
|
|
219
|
+
`exit=${iso2.status}|有原因=${/不再可用/.test(`${iso2.stdout ?? ''}${iso2.stderr ?? ''}`)}`)
|
|
220
|
+
const link2 = join(profB, 'node_modules', NAME)
|
|
221
|
+
check('安装器:**不**把 pnpm 物化的包目录换成 junction(那条路径归 pnpm 管)',
|
|
222
|
+
existsSync(link2) && lstatSync(link2).isSymbolicLink() === false, `isSymbolicLink=${lstatSync(link2).isSymbolicLink()}`)
|
|
223
|
+
// 预设健康(0.1.7 判据):组合里每一行的 `name` 都能从**注册表的 mount base** 解析
|
|
224
|
+
const mountBase = findPresetMountBase()
|
|
225
|
+
if (mountBase) {
|
|
226
|
+
const composition = YAML.parse(readFileSync(join(instPkgDir, 'agent-presets', 'ppt', 'agent.cordis.yml'), 'utf8'))
|
|
227
|
+
const names = []
|
|
228
|
+
const walk = (rows) => {
|
|
229
|
+
for (const r of rows ?? []) {
|
|
230
|
+
if (typeof r?.name === 'string' && r.name !== 'cordis:group' && !/^\.\.?\//.test(r.name)) names.push(r.name)
|
|
231
|
+
if (Array.isArray(r?.config)) walk(r.config)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
walk(composition)
|
|
235
|
+
const req = createRequire(join(mountBase, 'package.json'))
|
|
236
|
+
const unresolved = names.filter((n) => { try { req.resolve(n); return false } catch { return true } })
|
|
237
|
+
check('预设健康:组合里每一行都能从**注册表的 mount base** 解析(解析不到 ⇒ 预设 broken ⇒ 选择器看不到它)',
|
|
238
|
+
names.length > 0 && unresolved.length === 0,
|
|
239
|
+
`mount base=${mountBase.replace(/\\/g, '/')}|行=${names.length}|解析不到=${unresolved.join('、') || '无'}`)
|
|
240
|
+
} else {
|
|
241
|
+
check('预设健康:组合里每一行都能从**注册表的 mount base** 解析(解析不到 ⇒ 预设 broken ⇒ 选择器看不到它)', true, '⚠ 未找到 dsh-web-app(未装 DSH?)→ 跳过')
|
|
242
|
+
}
|
|
243
|
+
// 技能泄漏面:`<dshHome>/skills/` 是**文件系统技能根**,对所有会话可见(不限预设)。
|
|
244
|
+
// 默认不镜像,且**清理历史镜像**(否则每次 sync 都会把它装回来)。
|
|
245
|
+
const mirrorDir2 = join(home2, 'skills', 'ppt-studio-manual')
|
|
246
|
+
check('会话隔离:默认**不**把内置技能镜像到 <dshHome>/skills(镜像 = 跨预设泄漏)',
|
|
247
|
+
!existsSync(mirrorDir2), mirrorDir2)
|
|
248
|
+
const mir = run('node', [join(root, 'scripts', 'install.mjs'), '--prefix', home2, '--mirror-skills'], { env: env2 })
|
|
249
|
+
check('会话隔离:显式 `--mirror-skills` 才产生镜像(兜底通道按需,不默认泄漏)',
|
|
250
|
+
mir.status === 0 && existsSync(mirrorDir2), `exit=${mir.status}|镜像存在=${existsSync(mirrorDir2)}`)
|
|
251
|
+
} finally {
|
|
252
|
+
rmSync(work, { recursive: true, force: true })
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 定位"预设行会被从哪个 base 解析"——即 **agent-preset 注册表所在层的 baseUrl**。
|
|
257
|
+
* 依据(DSH 源码):`AgentPresetRegistry.register()` 里 `const context = this.ctx`(注册表自己的 ctx),
|
|
258
|
+
* `activate()` 再 `mountPreset(scope.ctx.extend({ baseUrl: record.context.baseUrl }), …)`
|
|
259
|
+
* ⇒ 预设里每一行的 `name` 都从**注册表所在层**解析,而注册表由 `dsh-web-app` 的 patch 插入
|
|
260
|
+
* ⇒ base 就是 **`dsh-web-app` 的包目录**(既不是 profile,也不是预设目录、更不是"harness 包目录")。
|
|
261
|
+
* 用错基准会对别的行误报 broken(假红)或漏报(假绿),所以这里按包目录精确定位。
|
|
262
|
+
*/
|
|
263
|
+
function findPresetMountBase() {
|
|
264
|
+
const rel = join('node_modules', '@deepseek-ai', 'dsh-web-app', 'package.json')
|
|
265
|
+
const roots = []
|
|
266
|
+
try {
|
|
267
|
+
const r = run('npm', ['root', '-g'])
|
|
268
|
+
if (r.status === 0 && r.stdout) roots.push(join(r.stdout.trim(), '@deepseek-ai', 'dsh'))
|
|
269
|
+
} catch { /* npm 不可用 */ }
|
|
270
|
+
for (const k of ['APPDATA', 'LOCALAPPDATA']) if (process.env[k]) roots.push(join(process.env[k], 'npm', 'node_modules', '@deepseek-ai', 'dsh'))
|
|
271
|
+
roots.push(join(homedir(), 'AppData', 'Roaming', 'npm', 'node_modules', '@deepseek-ai', 'dsh'))
|
|
272
|
+
roots.push('/usr/lib/node_modules/@deepseek-ai/dsh', '/usr/local/lib/node_modules/@deepseek-ai/dsh')
|
|
273
|
+
for (const r of roots) {
|
|
274
|
+
const dir = dirname(join(r, rel))
|
|
275
|
+
if (existsSync(join(dir, 'package.json'))) return dir
|
|
276
|
+
}
|
|
277
|
+
return null
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
console.log(`\n==== profile bundle 安装自证:${pass} 通过 / ${fail} 失败 ====`)
|
|
281
|
+
process.exit(fail > 0 ? 1 : 0)
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* verify-fresh-install.mjs —— 「一个普通用户,照着指引敲一条命令,能不能装好并用起来?」
|
|
4
|
+
*
|
|
5
|
+
* 为什么需要它(2026-09-18 实测,两个都真实发生过):
|
|
6
|
+
* ① 本地落后远端:`origin/main` 落后本机 **14 个提交**,而已发布的 tag `v1.0.1`
|
|
7
|
+
* **指向那个旧提交**。用户按指引跑 `dsh plugin add <仓库 URL>` 拿到的是 v1.0.0 的旧代码
|
|
8
|
+
* (没有会话隔离、没有 8 项缺陷修复)——而当时所有本机门禁全绿,因为门禁**只测本机工作区**。
|
|
9
|
+
* ② 干净检出才暴露的缺陷:仓库工作区是脚本写出来的 LF,而**干净克隆**在 Windows 上是 CRLF
|
|
10
|
+
* (Git for Windows 默认 `core.autocrlf=true`),行锚定文本手术在 CRLF 上静默失效 ⇒
|
|
11
|
+
* `resolveDeck` 抛 `page file missing: pages/_cover.yaml`,干净克隆里 `npm test` 直接崩。
|
|
12
|
+
* 两条的共同点:**"本机绿"与"用户能用"之间没有任何机器在把关**。本脚本就是那道关。
|
|
13
|
+
*
|
|
14
|
+
* 它断言的是**用户视角**,不是作者视角:
|
|
15
|
+
* ① 远端分支必须包含本地全部提交(否则用户从仓库拿到的是旧代码)——release 模式必查;
|
|
16
|
+
* ② tag `v<版本>` 必须存在且**正好指向本地 HEAD**(gh release create 会按默认分支 HEAD 建 tag,
|
|
17
|
+
* 与"我要发布的提交"无关——这正是 ①里那个错 tag 的成因);
|
|
18
|
+
* ③ **干净克隆**(默认 autocrlf,模拟用户机器)里 `npm install` + `npm test` 必须过,
|
|
19
|
+
* 且克隆出来的 `package.json.version` 就是本版;
|
|
20
|
+
* ④(默认开启)把**用户会敲的那条命令**在一个隔离 DSH_HOME 里真跑一遍(委托 verify-bundle-install.mjs):
|
|
21
|
+
* spec 为 Release 资产 URL(release 模式)或本地 tgz(--local)。
|
|
22
|
+
*
|
|
23
|
+
* 用法:
|
|
24
|
+
* node scripts/verify-fresh-install.mjs # 发版后:查 GitHub 上的 v<版本>
|
|
25
|
+
* node scripts/verify-fresh-install.mjs --local # 本地迭代:查分支 HEAD + 本地 tgz
|
|
26
|
+
* node scripts/verify-fresh-install.mjs --skip-clone-test # 只查 git/资产一致性(快)
|
|
27
|
+
* node scripts/verify-fresh-install.mjs --skip-install # 不跑真安装(没装 dsh 时)
|
|
28
|
+
* 可选:--repo <url> --branch <name> --spec <url|tgz> --tag v1.0.1
|
|
29
|
+
*
|
|
30
|
+
* 退出码:0 = 全部成立;1 = 至少一条不成立(每条都打印"哪一条、看到什么、为什么重要")。
|
|
31
|
+
*/
|
|
32
|
+
import { execFileSync, spawnSync } from 'node:child_process'
|
|
33
|
+
import { readFileSync, existsSync, mkdtempSync, rmSync, readdirSync } from 'node:fs'
|
|
34
|
+
import { join, dirname } from 'node:path'
|
|
35
|
+
import { tmpdir } from 'node:os'
|
|
36
|
+
import { fileURLToPath } from 'node:url'
|
|
37
|
+
|
|
38
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
39
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
40
|
+
const args = process.argv.slice(2)
|
|
41
|
+
const opt = (k, d) => {
|
|
42
|
+
const i = args.indexOf(k)
|
|
43
|
+
return i >= 0 && args[i + 1] ? args[i + 1] : d
|
|
44
|
+
}
|
|
45
|
+
const LOCAL = args.includes('--local')
|
|
46
|
+
const SKIP_CLONE_TEST = args.includes('--skip-clone-test')
|
|
47
|
+
const SKIP_INSTALL = args.includes('--skip-install')
|
|
48
|
+
const branch = opt('--branch', 'main')
|
|
49
|
+
const gitRemote = run('git', ['remote', 'get-url', 'origin'], root)
|
|
50
|
+
const repo = opt('--repo', isFail(gitRemote) ? '(未配置 origin)' : gitRemote)
|
|
51
|
+
const tag = opt('--tag', `v${pkg.version}`)
|
|
52
|
+
|
|
53
|
+
let pass = 0
|
|
54
|
+
let fail = 0
|
|
55
|
+
/** 一条断言:打印"看到什么",失败时把"为什么重要"一起打出来。 */
|
|
56
|
+
function check(label, ok, detail = '', why = '') {
|
|
57
|
+
console.log(`${ok ? '✓' : '✗'} ${label}${detail ? ` — ${detail}` : ''}`)
|
|
58
|
+
if (!ok && why) console.log(` ↳ 为什么重要:${why}`)
|
|
59
|
+
ok ? pass++ : fail++
|
|
60
|
+
}
|
|
61
|
+
/** shell 下需要引号的参数(含空白/管道/重定向等)。
|
|
62
|
+
* 写成**函数声明**(不是 const 箭头函数):下面的模块级代码要在定义之前调用 run(),
|
|
63
|
+
* 箭头函数在 TDZ 里会抛 "Cannot access 'shellQuote' before initialization"(本脚本第一版就这样,
|
|
64
|
+
* 表现为顶部那行"仓库 __FAIL__Cannot access…")。 */
|
|
65
|
+
function shellQuote(s) {
|
|
66
|
+
return /[\s"&|<>^]/.test(s) ? `"${String(s).replace(/"/g, '\\"')}"` : String(s)
|
|
67
|
+
}
|
|
68
|
+
/** 跑命令;不抛(失败由断言处理)。 */
|
|
69
|
+
function run(cmd, cmdArgs, cwd = root, extraEnv = {}) {
|
|
70
|
+
try {
|
|
71
|
+
// **命令本身也要加引号**(2026-09-18 实测事故):只转义参数、不转义命令时,
|
|
72
|
+
// `$env:GH='C:\Program Files\GitHub CLI\gh.exe'` 这种带空格的路径在 shell:true 下会被 cmd.exe
|
|
73
|
+
// 截成 `C:\Program`(报 "'C:\Program' is not recognized"),而调用方 `isFail()` 把失败当"没查到"
|
|
74
|
+
// ⇒ 表现为"资产 URL 未找到",看起来像资产没上传。加引号后复现命令即通过(已用等价脚本实测三态)。
|
|
75
|
+
return execFileSync(shellQuote(cmd), cmdArgs.map(shellQuote), {
|
|
76
|
+
cwd,
|
|
77
|
+
encoding: 'utf8',
|
|
78
|
+
env: { ...process.env, ...extraEnv },
|
|
79
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
80
|
+
// Windows 上 `npm` 是 npm.cmd:execFileSync 不解析 .cmd,不加 shell 会**直接失败**
|
|
81
|
+
// (本脚本第一版就栽在这里:npm install / npm test 全报 ✗,而输出是空的,看起来像被测对象的问题)。
|
|
82
|
+
// 仓库里 verify-bundle-install.mjs 的 run() 用的是同一个写法,保持一致。
|
|
83
|
+
shell: process.platform === 'win32',
|
|
84
|
+
}).trim()
|
|
85
|
+
} catch (e) {
|
|
86
|
+
const out = `${e.stdout ?? ''}${e.stderr ?? ''}`
|
|
87
|
+
// 连 stdout/stderr 都没有时(spawn 本身失败)把 message 带上,别让断言显示成"空失败"。
|
|
88
|
+
return `__FAIL__${out || String(e?.message ?? e)}`
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function isFail(s) {
|
|
92
|
+
return typeof s === 'string' && s.startsWith('__FAIL__')
|
|
93
|
+
}
|
|
94
|
+
function tail(s, n = 200) {
|
|
95
|
+
return String(s).replace(/__FAIL__/, '').trim().split(/\r?\n/).filter(Boolean).slice(-3).join(' | ').slice(0, n)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log(`verify-fresh-install:版本 ${pkg.version}|模式 ${LOCAL ? 'local(本地迭代)' : 'release(查 GitHub)'}|仓库 ${repo}|分支 ${branch}|tag ${tag}\n`)
|
|
99
|
+
|
|
100
|
+
// ── ① 本地是否全部推上去了 ─────────────────────────────────────────────────────
|
|
101
|
+
// 这一条最容易被忽略,而它的后果最严重:本地全绿、用户装到旧代码。查的是"用户会克隆到的那个分支"。
|
|
102
|
+
// 注意:**只有 release 模式把它当断言**——`--local` 是日常迭代,本地领先远端是常态(那正是它的目的),
|
|
103
|
+
// 所以那里只打印信息,不判失败。真正的本地判据是 ②(干净检出我的**当前工作**能不能过)。
|
|
104
|
+
if (LOCAL) {
|
|
105
|
+
const fetch = run('git', ['fetch', '--quiet', 'origin'])
|
|
106
|
+
if (isFail(fetch)) {
|
|
107
|
+
console.log('① 远端信息:未能确认(离线或远端不可达)——不影响本地自检')
|
|
108
|
+
} else {
|
|
109
|
+
const behind = Number(run('git', ['rev-list', '--count', `HEAD..origin/${branch}`]) || '0')
|
|
110
|
+
const ahead = Number(run('git', ['rev-list', '--count', `origin/${branch}..HEAD`]) || '0')
|
|
111
|
+
console.log(`① 远端信息(仅信息,不判失败):本地领先 origin/${branch} ${ahead} 个提交|落后 ${behind} 个`
|
|
112
|
+
+ `${ahead > 0 ? '——发版前必须 push(发版模式会拒绝继续)' : ''}`)
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
console.log('① 远端 tag 与本地 HEAD 的一致性(release 模式)')
|
|
116
|
+
const ls = run('git', ['ls-remote', '--tags', 'origin', `refs/tags/${tag}`])
|
|
117
|
+
const remoteTagSha = isFail(ls) ? '' : (ls.split(/\s+/)[0] ?? '')
|
|
118
|
+
const head = run('git', ['rev-parse', 'HEAD'])
|
|
119
|
+
check(`远端存在 tag ${tag}`, remoteTagSha.length === 40, remoteTagSha || `(没有这个 tag)`,
|
|
120
|
+
'README 与创意工坊卡片都按 tag/资产指路;tag 不存在时用户按图索骥会 404。')
|
|
121
|
+
check(`${tag} 正好指向本地 HEAD(要发布的那一提交)`, remoteTagSha === head,
|
|
122
|
+
`tag=${remoteTagSha.slice(0, 10) || '(缺)'}|HEAD=${head.slice(0, 10)}`,
|
|
123
|
+
'`gh release create` 会按**默认分支 HEAD** 建 tag——本地没推时它就把 tag 建在旧提交上,'
|
|
124
|
+
+ '于是"发布"了 v1.0.1,而 v1.0.1 指向的是 v1.0.0 的代码。')
|
|
125
|
+
// 顺带断言:远端分支确实包含 HEAD(否则 git 安装路径仍是旧的)
|
|
126
|
+
const lsMain = run('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`])
|
|
127
|
+
const remoteBranchSha = isFail(lsMain) ? '' : (lsMain.split(/\s+/)[0] ?? '')
|
|
128
|
+
const contains = remoteBranchSha && run('git', ['merge-base', '--is-ancestor', head, remoteBranchSha]) !== '__FAIL__'
|
|
129
|
+
check(`origin/${branch} 已包含本地 HEAD(git 安装路径拿到的是本版)`, Boolean(contains),
|
|
130
|
+
`branch=${remoteBranchSha.slice(0, 10) || '(缺)'}|HEAD=${head.slice(0, 10)}`,
|
|
131
|
+
'创意工坊对无 npm 包的条目生成的就是 `dsh plugin add <仓库 URL>`——分支落后就等于用户装不到新版。')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── ② 干净克隆里 npm install + npm test ───────────────────────────────────────
|
|
135
|
+
// 这一步是"别人的机器"的替身:没有本机的 node_modules、没有本机的 gitignore 产物、
|
|
136
|
+
// Windows 上还会按 core.autocrlf 的默认值检出 CRLF。历史上红过的两条都在这里才现形。
|
|
137
|
+
if (SKIP_CLONE_TEST) {
|
|
138
|
+
console.log('\n② 干净克隆自检 —— 已按 --skip-clone-test 跳过')
|
|
139
|
+
} else {
|
|
140
|
+
console.log('\n② 干净克隆里 npm install + npm test("别人的机器"的替身)')
|
|
141
|
+
const work = mkdtempSync(join(tmpdir(), 'ppt-fresh-'))
|
|
142
|
+
const cloneDir = join(work, 'repo')
|
|
143
|
+
try {
|
|
144
|
+
// 故意**不**传 -c core.autocrlf=false:用户机器是什么样,这里就什么样。
|
|
145
|
+
// .gitattributes 的 `* text=auto eol=lf` 负责把检出钉成 LF;若它失效,这段就会像用户一样崩。
|
|
146
|
+
// **克隆源按模式区分**:release 克隆 GitHub 上的 tag(= 用户真正会拿到的东西);
|
|
147
|
+
// `--local` 克隆**本地仓库路径**(= "我这台机器上这份工作,全新检出能不能过")——
|
|
148
|
+
// 本地迭代时本地领先远端是常态,从远端克隆反而验错了对象(这是本脚本第一版的真实缺陷)。
|
|
149
|
+
const cloneSource = LOCAL ? root : repo
|
|
150
|
+
const cloneDepth = ['--depth', '1', '--branch', LOCAL ? branch : tag]
|
|
151
|
+
const clone = run('git', ['clone', '--quiet', ...cloneDepth, cloneSource, cloneDir])
|
|
152
|
+
check(`干净克隆成功(${LOCAL ? `本地 HEAD @ ${branch}` : tag})`, !isFail(clone), tail(clone),
|
|
153
|
+
'克隆都失败时后面全是假象——必须先确认这一步。')
|
|
154
|
+
if (isFail(clone)) throw new Error('clone failed')
|
|
155
|
+
|
|
156
|
+
const cloneVer = JSON.parse(readFileSync(join(cloneDir, 'package.json'), 'utf8')).version
|
|
157
|
+
check('克隆出来的就是本版(package.json.version)', cloneVer === pkg.version,
|
|
158
|
+
`克隆=${cloneVer}|本机=${pkg.version}`,
|
|
159
|
+
'用户装到的必须是你刚发布的那一版;版本不一致说明 tag/分支指向的不是本版。')
|
|
160
|
+
if (LOCAL) {
|
|
161
|
+
const cloneHead = run('git', ['rev-parse', 'HEAD'], cloneDir)
|
|
162
|
+
const localHead = run('git', ['rev-parse', 'HEAD'])
|
|
163
|
+
check('本地模式的干净检出来自**当前 HEAD**(不是远端旧提交)', cloneHead === localHead,
|
|
164
|
+
`克隆=${cloneHead.slice(0, 10)}|本地=${localHead.slice(0, 10)}`,
|
|
165
|
+
'--local 要答的问题是"我这份工作全新检出能不能过",克隆到远端就是把旧代码当成新代码来验。')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 检出字节的证据(不是断言,是现场记录):git 安装与 tgz 安装必须是同一份字节。
|
|
169
|
+
const crlf = countCrlfFiles(cloneDir)
|
|
170
|
+
console.log(` · 干净克隆的文本文件 CRLF 数:${crlf.length}(应为 0;非 0 说明 .gitattributes 没生效,`
|
|
171
|
+
+ 'Windows 上就会命中"行锚定正则静默失效"那个坑)')
|
|
172
|
+
check('干净克隆的跟踪文本文件全是 LF(git 安装 == tgz 安装)', crlf.length === 0,
|
|
173
|
+
crlf.slice(0, 5).join(', ') || '无', '见 scripts/check-tracked-lf.mjs 的说明。')
|
|
174
|
+
|
|
175
|
+
const inst = run('npm', ['install', '--no-audit', '--no-fund'], cloneDir)
|
|
176
|
+
check('克隆里 npm install 成功', !isFail(inst), tail(inst), '用户第一步就会跑它。')
|
|
177
|
+
const test = run('npm', ['test'], cloneDir)
|
|
178
|
+
check('克隆里 npm test 全绿(这就是"干净检出"的定义)', !isFail(test), isFail(test) ? tail(test, 400) : `结果行:${(String(test).match(/====.*====/) ?? ['(未见结果行)'])[0]}`,
|
|
179
|
+
'历史上这里红过:previews 断言在无 Office 的机器上必红;CRLF 检出则直接崩在 pages/_cover.yaml。')
|
|
180
|
+
if (isFail(test)) {
|
|
181
|
+
console.log(' —— 克隆里 npm test 的输出尾部 ——')
|
|
182
|
+
for (const l of String(test).replace('__FAIL__', '').split(/\r?\n/).slice(-25)) console.log(` ${l}`)
|
|
183
|
+
}
|
|
184
|
+
} finally {
|
|
185
|
+
if (!args.includes('--keep-work')) rmSync(work, { recursive: true, force: true })
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── ③ 用户会敲的那条命令(真安装,隔离 DSH_HOME)─────────────────────────────
|
|
190
|
+
if (SKIP_INSTALL) {
|
|
191
|
+
console.log('\n③ 一条命令安装 —— 已按 --skip-install 跳过')
|
|
192
|
+
} else {
|
|
193
|
+
console.log('\n③ 一条命令安装(委托 verify-bundle-install.mjs,隔离 DSH_HOME,不碰你的 profile)')
|
|
194
|
+
let spec = opt('--spec', '')
|
|
195
|
+
if (!spec && LOCAL) {
|
|
196
|
+
// --local:用刚打出来的本地 tgz(等价于"从同一份字节装一遍")
|
|
197
|
+
const packDir = mkdtempSync(join(tmpdir(), 'ppt-fresh-pack-'))
|
|
198
|
+
const pack = run('npm', ['pack', '--pack-destination', packDir])
|
|
199
|
+
spec = isFail(pack) ? '' : join(packDir, String(pack).split(/\r?\n/).filter(Boolean).pop())
|
|
200
|
+
}
|
|
201
|
+
let specWhy = ''
|
|
202
|
+
if (!spec) {
|
|
203
|
+
// release:用 Release 页面上那条资产 URL(用户会复制粘贴的那一条)
|
|
204
|
+
const found = findReleaseAsset(tag)
|
|
205
|
+
if (found.url) spec = found.url
|
|
206
|
+
else specWhy = found.why
|
|
207
|
+
}
|
|
208
|
+
if (!spec) {
|
|
209
|
+
check('能定位到"用户会敲的那条命令"的安装规格(Release 资产 URL / 本地 tgz)', false,
|
|
210
|
+
`(未找到)${specWhy ? ` ${specWhy}` : ''}`,
|
|
211
|
+
'这条命令是文档指引的核心;找不到规格就无法证明它可用。可用 --spec 显式给定。')
|
|
212
|
+
} else {
|
|
213
|
+
console.log(` 规格:${spec}`)
|
|
214
|
+
const r = spawnSync(process.execPath, [join(root, 'scripts', 'verify-bundle-install.mjs'), spec], { cwd: root, stdio: 'inherit' })
|
|
215
|
+
check('`dsh plugin --profile web add <规格>` 全链自证通过(含装配与升级路径)', r.status === 0,
|
|
216
|
+
`exit=${r.status}`, '这一条就是"用户敲完能不能用"本身;前面几条只保证"代码是对的"。')
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
console.log(`\n==== verify-fresh-install 结果:${pass} 通过 / ${fail} 失败 ====`)
|
|
221
|
+
process.exit(fail > 0 ? 1 : 0)
|
|
222
|
+
|
|
223
|
+
/** 统计干净克隆里的 CRLF 文本文件(与 check-tracked-lf.mjs 同判据,但只读不改)。 */
|
|
224
|
+
function countCrlfFiles(dir) {
|
|
225
|
+
const BINARY = new Set(['.pptx', '.zip', '.tgz', '.gz', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.woff', '.woff2', '.ttf', '.otf', '.pdf'])
|
|
226
|
+
const files = run('git', ['ls-files', '-z'], dir)
|
|
227
|
+
if (isFail(files)) return []
|
|
228
|
+
const out = []
|
|
229
|
+
for (const rel of files.split('\0').filter(Boolean)) {
|
|
230
|
+
if (BINARY.has(rel.slice(rel.lastIndexOf('.')).toLowerCase())) continue
|
|
231
|
+
try {
|
|
232
|
+
const buf = readFileSync(join(dir, rel))
|
|
233
|
+
if (buf.includes(0)) continue
|
|
234
|
+
if (buf.includes('\r\n')) out.push(rel)
|
|
235
|
+
} catch { /* 缺失文件不是本检查的职责 */ }
|
|
236
|
+
}
|
|
237
|
+
return out
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 取 Release 资产 URL(用户会在资产页复制粘贴的那一条)。返回 { url, why }——why 为空表示成功。 */
|
|
241
|
+
function findReleaseAsset(t) {
|
|
242
|
+
const gh = process.env.GH || 'gh'
|
|
243
|
+
// 用 REST + 本地解析,**不用 --jq**:cmd.exe 下 shell 无法用单引号 jq(release-sync.mjs 里同一条教训)。
|
|
244
|
+
const remote = run('git', ['remote', 'get-url', 'origin']) || ''
|
|
245
|
+
const m = /github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/.exec(remote.trim())
|
|
246
|
+
if (!m) return { url: '', why: `无法从 origin 解析 owner/repo:${remote.trim() || '(空)'}` }
|
|
247
|
+
const json = run(gh, ['api', `repos/${m[1]}/${m[2]}/releases/tags/${t}`])
|
|
248
|
+
// **失败必须说出原因**(别再静默返回空):这条断言红过两次,第一次的样子就是"未找到"三个字,
|
|
249
|
+
// 而真正原因(gh 调用被 shell 截断 / 网络超时)全被吞掉了。
|
|
250
|
+
if (isFail(json)) return { url: '', why: `gh api 调用失败:${json.replace(/^__FAIL__/, '').trim().split('\n')[0]}` }
|
|
251
|
+
try {
|
|
252
|
+
const assets = (JSON.parse(json).assets ?? []).filter((a) => String(a.name).endsWith('.tgz'))
|
|
253
|
+
// 资产名 = 版本-构建时间-构建戳;历次构建都挂在同一 Release 上,按创建时间取最新即当前版本。
|
|
254
|
+
assets.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
|
|
255
|
+
const url = assets.pop()?.browser_download_url ?? ''
|
|
256
|
+
return { url, why: url ? '' : `release ${t} 上没有任何 .tgz 资产(资产是 release-sync 上传的,先跑它)` }
|
|
257
|
+
} catch (e) {
|
|
258
|
+
return { url: '', why: `解析 gh 输出失败:${String(e?.message ?? e)}` }
|
|
259
|
+
}
|
|
260
|
+
}
|