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.
Files changed (179) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +480 -0
  3. package/agent-presets/ppt/agent.cordis.yml +308 -0
  4. package/agent-presets/ppt/preset.yml +3 -0
  5. package/cordis.patch.yml +29 -0
  6. package/docs/00-/350/256/276/350/256/241/346/272/220.md +537 -0
  7. package/docs/01-/351/234/200/346/261/202/344/270/216/347/233/256/346/240/207.md +136 -0
  8. package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +483 -0
  9. package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +2898 -0
  10. 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
  11. package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +210 -0
  12. package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +296 -0
  13. package/docs/fidelity-report.md +225 -0
  14. package/examples/fx/fx-mini/deck.yaml +39 -0
  15. package/examples/fx/fx-mini/pages/01.yaml +6 -0
  16. package/examples/fx/fx-mini/pages/02.yaml +2 -0
  17. package/examples/fx/fx-mini/pages/03.yaml +6 -0
  18. package/examples/fx/fx-pro/deck.yaml +48 -0
  19. package/examples/fx/fx-pro/pages/01_cover.yaml +40 -0
  20. package/examples/fx/fx-pro/pages/02_arch.yaml +55 -0
  21. package/examples/fx/fx-pro/pages/03_table.yaml +20 -0
  22. package/examples/fx/fx-pro/pages/04_chart.yaml +40 -0
  23. package/examples/fx/fx-pro/pages/05_timeline.yaml +71 -0
  24. package/examples/fx/fx-pro/pages/06_longtext.yaml +13 -0
  25. package/examples/fx/fx-pro/pages/07_safearea.yaml +33 -0
  26. package/examples/fx/fx-pro/pages/08_contrast.yaml +22 -0
  27. package/examples/fx/fx-pro/pages/09_custgeom.yaml +32 -0
  28. package/examples/fx/fx-pro/pages/10_numbers.yaml +25 -0
  29. package/examples/fx/fx-pro/pages/11_section.yaml +20 -0
  30. package/examples/fx/fx-pro/pages/12_summary.yaml +57 -0
  31. package/examples/fx/fx-pro/preview/01_pages_01_cover.html +1 -0
  32. package/examples/fx/fx-pro/preview/02_pages_02_arch.html +3 -0
  33. package/examples/fx/fx-pro/preview/03_pages_03_table.html +1 -0
  34. package/examples/fx/fx-pro/preview/04_pages_04_chart.html +3 -0
  35. package/examples/fx/fx-pro/preview/05_pages_05_timeline.html +2 -0
  36. package/examples/fx/fx-pro/preview/06_pages_06_longtext.html +4 -0
  37. package/examples/fx/fx-pro/preview/07_pages_07_safearea.html +2 -0
  38. package/examples/fx/fx-pro/preview/08_pages_08_contrast.html +1 -0
  39. package/examples/fx/fx-pro/preview/09_pages_09_custgeom.html +1 -0
  40. package/examples/fx/fx-pro/preview/10_pages_10_numbers.html +1 -0
  41. package/examples/fx/fx-pro/preview/11_pages_11_section.html +1 -0
  42. package/examples/fx/fx-pro/preview/12_pages_12_summary.html +1 -0
  43. package/examples/fx/fx-pro/preview/deck.html +10 -0
  44. package/examples/fx/fx-pro/preview/layout.json +2325 -0
  45. package/examples/fx/fx-pro/preview/measured.json +814 -0
  46. package/examples/fx/fx-pro.pptx +0 -0
  47. package/examples/fx/ren-single/01.png +0 -0
  48. package/examples/fx/ren-spliced/01.png +0 -0
  49. package/examples/fx/ren-spliced/03.png +0 -0
  50. package/examples/fx/seed.pptx +0 -0
  51. package/examples/fx/single.pptx +0 -0
  52. package/examples/fx/spliced.pptx +0 -0
  53. package/examples/smoke/deck.yaml +26 -0
  54. package/examples/smoke/imported/deck.yaml +30 -0
  55. package/examples/smoke/imported/import-styles.json +126 -0
  56. package/examples/smoke/imported/pages/slide_01.yaml +31 -0
  57. package/examples/smoke/imported/pages/slide_02.yaml +38 -0
  58. package/examples/smoke/imported/pages/slide_03.yaml +55 -0
  59. package/examples/smoke/imported/preview-rt/01_pages_slide_01.html +1 -0
  60. package/examples/smoke/imported/preview-rt/02_pages_slide_02.html +1 -0
  61. package/examples/smoke/imported/preview-rt/03_pages_slide_03.html +1 -0
  62. package/examples/smoke/imported/preview-rt/deck.html +1 -0
  63. package/examples/smoke/imported/preview-rt/layout.json +434 -0
  64. package/examples/smoke/imported/reference/previews/01.png +0 -0
  65. package/examples/smoke/imported/reference/previews/02.png +0 -0
  66. package/examples/smoke/imported/reference/previews/03.png +0 -0
  67. package/examples/smoke/imported/source.pptx +0 -0
  68. package/examples/smoke/media-test.png +0 -0
  69. package/examples/smoke/out-smoke.pptx +0 -0
  70. package/examples/smoke/pages/01_cover.yaml +27 -0
  71. package/examples/smoke/pages/02_content.yaml +42 -0
  72. package/examples/smoke/pages/03_chart.yaml +35 -0
  73. package/examples/smoke/preview/01_pages_01_cover.html +1 -0
  74. package/examples/smoke/preview/02_pages_02_content.html +1 -0
  75. package/examples/smoke/preview/03_pages_03_chart.html +1 -0
  76. package/examples/smoke/preview/deck.html +1 -0
  77. package/examples/smoke/preview/layout.json +378 -0
  78. package/lib/autodeclare.js +40 -0
  79. package/lib/commands.js +161 -0
  80. package/lib/crosscheck.js +300 -0
  81. package/lib/imgmeta.js +55 -0
  82. package/lib/index.js +239 -0
  83. package/lib/measurement.js +209 -0
  84. package/lib/msrender.js +123 -0
  85. package/lib/pptd/export-pptx.js +686 -0
  86. package/lib/pptd/import-pptx.js +774 -0
  87. package/lib/pptd/layout.js +209 -0
  88. package/lib/pptd/render-html.js +254 -0
  89. package/lib/pptd/schema.js +518 -0
  90. package/lib/pptd/svgCharts.js +147 -0
  91. package/lib/pptxPy.js +205 -0
  92. package/lib/preset-delivery.js +195 -0
  93. package/lib/preview-server.js +164 -0
  94. package/lib/router.js +165 -0
  95. package/lib/scaffold.js +315 -0
  96. package/lib/skill.js +174 -0
  97. package/lib/splice.js +219 -0
  98. package/lib/state.js +88 -0
  99. package/lib/surgical.js +369 -0
  100. package/lib/templates.js +400 -0
  101. package/lib/tools.js +955 -0
  102. package/lib/verify.js +828 -0
  103. package/lib/xmljs.js +112 -0
  104. package/lib/zips.js +117 -0
  105. package/package.json +81 -0
  106. package/scripts/audit-manual-facts.mjs +294 -0
  107. package/scripts/audit-template-fidelity.mjs +162 -0
  108. package/scripts/bench-m2.mjs +48 -0
  109. package/scripts/build-templates.mjs +63 -0
  110. package/scripts/build.mjs +14 -0
  111. package/scripts/build.sh +7 -0
  112. package/scripts/check-lib-freshness.mjs +70 -0
  113. package/scripts/check-preset.mjs +189 -0
  114. package/scripts/check-tracked-lf.mjs +84 -0
  115. package/scripts/dbg-import.mjs +13 -0
  116. package/scripts/dbg-media.mjs +10 -0
  117. package/scripts/dbg-tpl-inventory.mjs +23 -0
  118. package/scripts/dump-workflow-baseline.mjs +38 -0
  119. package/scripts/e2e-1.0.mjs +129 -0
  120. package/scripts/eval-skills-blind.mjs +91 -0
  121. package/scripts/eval-skills-score.mjs +129 -0
  122. package/scripts/export-orig-templates.ps1 +17 -0
  123. package/scripts/fixtures/workflow-baseline.json +300 -0
  124. package/scripts/fixtures.mjs +501 -0
  125. package/scripts/import-user-templates.mjs +60 -0
  126. package/scripts/install-desktop.mjs +312 -0
  127. package/scripts/install.mjs +209 -0
  128. package/scripts/migrate-templates-dualtrack.mjs +71 -0
  129. package/scripts/preflight-1.0.mjs +198 -0
  130. package/scripts/probe-asset-freshness.mjs +57 -0
  131. package/scripts/probe-effect-semantics.mjs +69 -0
  132. package/scripts/probe-reparse-safety.mjs +32 -0
  133. package/scripts/rebuild-templates-c11.mjs +69 -0
  134. package/scripts/regression-real.mjs +84 -0
  135. package/scripts/release-sync.mjs +425 -0
  136. package/scripts/shot-templates.mjs +49 -0
  137. package/scripts/smoke.mjs +2512 -0
  138. package/scripts/verify-bundle-install.mjs +281 -0
  139. package/scripts/verify-fresh-install.mjs +260 -0
  140. package/skills/ppt-studio-copy/SKILL.md +139 -0
  141. package/skills/ppt-studio-craft/SKILL.md +171 -0
  142. package/skills/ppt-studio-data/SKILL.md +173 -0
  143. package/skills/ppt-studio-manual/SKILL.md +104 -0
  144. package/templates/academic-white/deck.yaml +24 -0
  145. package/templates/academic-white/pages/_chart.yaml +43 -0
  146. package/templates/academic-white/pages/_compare.yaml +37 -0
  147. package/templates/academic-white/pages/_content.yaml +35 -0
  148. package/templates/academic-white/pages/_cover.yaml +28 -0
  149. package/templates/academic-white/pages/_ending.yaml +28 -0
  150. package/templates/academic-white/pages/_flow.yaml +61 -0
  151. package/templates/academic-white/preview.png +0 -0
  152. package/templates/academic-white/template.yaml +14 -0
  153. package/templates/business-blue/deck.yaml +28 -0
  154. package/templates/business-blue/pages/_chart.yaml +36 -0
  155. package/templates/business-blue/pages/_compare.yaml +45 -0
  156. package/templates/business-blue/pages/_content.yaml +80 -0
  157. package/templates/business-blue/pages/_cover.yaml +39 -0
  158. package/templates/business-blue/pages/_ending.yaml +32 -0
  159. package/templates/business-blue/pages/_flow.yaml +87 -0
  160. package/templates/business-blue/preview.png +0 -0
  161. package/templates/business-blue/template.yaml +14 -0
  162. package/templates/pitch-bold/deck.yaml +23 -0
  163. package/templates/pitch-bold/pages/_chart.yaml +32 -0
  164. package/templates/pitch-bold/pages/_compare.yaml +36 -0
  165. package/templates/pitch-bold/pages/_content.yaml +58 -0
  166. package/templates/pitch-bold/pages/_cover.yaml +31 -0
  167. package/templates/pitch-bold/pages/_ending.yaml +27 -0
  168. package/templates/pitch-bold/pages/_flow.yaml +46 -0
  169. package/templates/pitch-bold/preview.png +0 -0
  170. package/templates/pitch-bold/template.yaml +14 -0
  171. package/templates/tech-dark/deck.yaml +24 -0
  172. package/templates/tech-dark/pages/_chart.yaml +27 -0
  173. package/templates/tech-dark/pages/_compare.yaml +38 -0
  174. package/templates/tech-dark/pages/_content.yaml +51 -0
  175. package/templates/tech-dark/pages/_cover.yaml +30 -0
  176. package/templates/tech-dark/pages/_ending.yaml +25 -0
  177. package/templates/tech-dark/pages/_flow.yaml +80 -0
  178. package/templates/tech-dark/preview.png +0 -0
  179. package/templates/tech-dark/template.yaml +14 -0
@@ -0,0 +1,425 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * release-sync.mjs —— 一条命令完成"构建 + 本机同步",两种模式:
4
+ *
5
+ * **--local(日常迭代,推荐)**:不发 GitHub,本机跑的就是刚构建的字节。
6
+ * ① build → ② pack(带构建戳)→ ③ **跳过 GitHub 比对/上传** → ④ 同一 tgz 部署本机安装根 + 重跑 install.mjs
7
+ * → ⑤ 用**本地 tgz 的 `file:` 规格**把同一份字节装进 profile → ⑥ 终验 + 写 .sync-state.json
8
+ *
9
+ * **发版(默认)**:机器 == GitHub 字节级一致。
10
+ * ⓪b **git 前置**(2026-09-18 加):本地必须全部推上去了,且 tag 必须**正好指向 HEAD**——
11
+ * 否则用户从仓库 URL 装到的是旧代码(v1.0.1 就是这么发的:`gh release create` 按默认分支 HEAD
12
+ * 把 tag 建在了旧提交上,而资产 sha 校验全绿)。
13
+ * ① build → ② pack → ③ sha256 比对/上传(--clobber,幂等)→ ④ 同一 tgz 部署本机安装根
14
+ * → ⑤ 按 **GitHub 资产 URL** 重装 profile → ⑥ 终验三方一致 + 写 .sync-state.json
15
+ * → ⑦ **发版终验**:`verify-fresh-install`(干净克隆 + 一条命令安装,"用户能不能用"的机器判据;
16
+ * `--skip-fresh` 可跳过,但状态文件里会留 `freshInstall: null`,不伪装成验过)。
17
+ *
18
+ * 为什么 --local 用 `file:` 规格,而不是切回 junction/preset 装法:
19
+ * junction 会**新增一条挂载路径**——"我现在改的是哪份代码"于是有两种答案,答错**不报错**(这才是真正的风险,
20
+ * 不是"记不记得住")。`file:` 仍然只有一条路径(profile bundle 行),只把"从哪取字节"从远端资产换成本地 tgz。
21
+ * 仓库自带 `verify-bundle-install.mjs` 已自证这条机制:**同版本号、不同路径的 tgz ⇒ 装到的就是新字节**
22
+ * (pnpm 按规格解析,所以构建戳让规格必变 ⇒ 必然重取)。
23
+ *
24
+ * 关于"本地永不落后于 GitHub"(用户纪律):**它不会自动成立**——有人在 GitHub 上直接改、或换机器提交,
25
+ * 本地就落在后面,而"在最新版本上迭代"这个前提破了以后**没有任何症状**,直到发版时才发现分叉。
26
+ * 所以 --local 每次都先 `git fetch` 比对 HEAD 与 origin/<branch>:behind>0 → 直接失败(`--allow-behind` 可绕过);
27
+ * fetch 失败(离线)→ 明确标注"未能确认"并继续(本地迭代本来就可能无网,不能因此把它变成脆弱门禁)。
28
+ *
29
+ * 用法:
30
+ * node scripts/release-sync.mjs --local # 日常迭代(不发 GitHub)
31
+ * node scripts/release-sync.mjs # 发版
32
+ * node scripts/release-sync.mjs --local --allow-behind # 明知落后仍要构建(自担)
33
+ * node scripts/release-sync.mjs --skip-fresh # 发版但跳过"用户视角"终验(留痕:freshInstall=null)
34
+ * 可选:[--tag v1.0.0] [--root D:\plugins] [--profile web] [--no-upload] [--state <路径>]
35
+ *
36
+ * 回退到 GitHub 版本:`dsh plugin --profile web add <Releases 页面的资产 URL>`(发版模式做的就是这件事)。
37
+ */
38
+ import { execSync, spawnSync } from 'node:child_process'
39
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, statSync, copyFileSync, readdirSync } from 'node:fs'
40
+ import { createHash } from 'node:crypto'
41
+ import { join, dirname, resolve } from 'node:path'
42
+ import { homedir } from 'node:os'
43
+ import { fileURLToPath } from 'node:url'
44
+ import { tmpdir } from 'node:os'
45
+
46
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
47
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
48
+ const GH = process.env.GH || 'C:\\Program Files\\GitHub CLI\\gh.exe'
49
+ const args = process.argv.slice(2)
50
+ const opt = (k, d) => {
51
+ const i = args.indexOf(k)
52
+ return i >= 0 && args[i + 1] ? args[i + 1] : d
53
+ }
54
+ const tag = opt('--tag', 'v' + pkg.version)
55
+ const deployRoot = resolve(opt('--root', 'D:\\plugins'))
56
+ const noUpload = args.includes('--no-upload')
57
+ const local = args.includes('--local')
58
+ const allowBehind = args.includes('--allow-behind')
59
+ // 发版终验可以显式跳过(离线、CI 里只想要资产一致性时)——但**跳过必须留痕**:
60
+ // 状态文件里 freshInstall=null 而不是 true,免得"没验"被读成"验过了"。
61
+ const skipFresh = args.includes('--skip-fresh')
62
+ // --state 只为可测性存在:让隔离环境里跑的 release-sync 不要把 .sync-state.json 写进真仓库。
63
+ const stateFile = resolve(opt('--state', join(root, '.sync-state.json')))
64
+
65
+ const sha = (buf) => createHash('sha256').update(buf).digest('hex')
66
+ const run = (cmdStr) => execSync(cmdStr, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
67
+
68
+ // ── ⓪ 本地是否落后于远端(用户纪律:"每次迭代都在最新版本上进行")────────────────────
69
+ // 为什么必须机器查:这条纪律**不会自动成立**。有人在 GitHub 上直接改、或换机器提交,本地就落在后面;
70
+ // 而"在最新版本上迭代"这个前提一旦破了,**没有任何症状**,直到发版时才发现分叉。
71
+ // 离线(fetch 失败)不算失败——本地迭代本来就可能无网;但必须**明确标注"未能确认"**,不能假装查过了。
72
+ function gitFreshness() {
73
+ try {
74
+ const branch = run('git rev-parse --abbrev-ref HEAD').trim()
75
+ if (!branch || branch === 'HEAD') return { ok: true, note: '游离 HEAD——跳过落后检查' }
76
+ run('git fetch --quiet origin')
77
+ const behind = Number(run(`git rev-list --count HEAD..origin/${branch}`).trim())
78
+ const ahead = Number(run(`git rev-list --count origin/${branch}..HEAD`).trim())
79
+ const dirty = run('git status --porcelain').trim().split('\n').filter(Boolean).length
80
+ return { ok: behind === 0, behind, ahead, dirty, branch }
81
+ } catch (e) {
82
+ return { ok: true, uncertain: true, note: `无法比对远端(离线或 fetch 失败)——**未能确认**本地是否落后:${String(e?.message ?? e).slice(0, 120)}` }
83
+ }
84
+ }
85
+
86
+ if (local) {
87
+ const g = gitFreshness()
88
+ if (g.note) {
89
+ console.log(`⓪ git:${g.note}`)
90
+ } else {
91
+ console.log(`⓪ git:${g.branch} 落后 origin/${g.branch} ${g.behind} 个提交|本地未发布 ${g.ahead} 个|未提交改动 ${g.dirty} 个文件`)
92
+ if (!g.ok && !allowBehind) {
93
+ console.error(`✗ 本地落后 origin/${g.branch} **${g.behind} 个提交**——按"每次迭代都在最新版本上进行"的纪律,`
94
+ + `先 \`git pull\` 再跑;确要在旧基线上迭代请显式加 --allow-behind(后果自担:发版时会出现分叉)`)
95
+ process.exit(1)
96
+ }
97
+ if (!g.ok) console.log(`⚠ --allow-behind:明知落后 ${g.behind} 个提交仍继续——本轮基线**不是最新**`)
98
+ }
99
+ }
100
+
101
+ // ── ⓪b 发版模式的前置:**本地全部推上去了吗?tag 指向的是这一提交吗?**────────────────
102
+ // 为什么必须在这里拦(2026-09-18 实测事故):发布 v1.0.1 时本地有 14 个提交没推,`gh release create`
103
+ // 于是**按默认分支 HEAD** 把 tag 建在旧提交上——"发布成功",而 GitHub 上是 v1.0.0 的代码:
104
+ // 用户照创意工坊卡片跑 `dsh plugin add <仓库 URL>` 拿到的是旧版(没有会话隔离、没有 8 项修复)。
105
+ // 更糟的是 ①②③④⑤⑥ 全都照常通过——它们只比对**资产** sha,一步都没看 git。
106
+ // 旧的 `gitFreshness()` 只在 --local 分支里跑,发版分支完全没有 git 检查,所以这个洞一直存在。
107
+ if (!local) {
108
+ const branch = run('git rev-parse --abbrev-ref HEAD').trim()
109
+ let ahead = null
110
+ let behind = null
111
+ try {
112
+ run('git fetch --quiet origin')
113
+ ahead = Number(run(`git rev-list --count origin/${branch}..HEAD`).trim())
114
+ behind = Number(run(`git rev-list --count HEAD..origin/${branch}`).trim())
115
+ } catch {
116
+ console.log('⓪b git:无法比对远端(离线或 fetch 失败)——**未能确认**本地是否落后/未推送')
117
+ }
118
+ if (ahead !== null) {
119
+ console.log(`⓪b git:${branch} 未推送 ${ahead} 个提交|落后 ${behind} 个`)
120
+ if (ahead > 0) {
121
+ console.error(`✗ 本地有 **${ahead} 个提交没推**——用户从仓库 URL 安装拿到的是 origin 上的旧代码,`
122
+ + `而资产是本地的字节:两条安装路径从此分叉,且**没有任何症状**。请先 \`git push origin ${branch}\`:`)
123
+ for (const l of run(`git log --oneline origin/${branch}..HEAD`).split('\n').filter(Boolean).slice(0, 10)) {
124
+ console.error(` · ${l}`)
125
+ }
126
+ process.exit(1)
127
+ }
128
+ if (behind > 0) {
129
+ console.error(`✗ 本地落后 origin/${branch} ${behind} 个提交——发版等于把别人的提交顶掉。先 \`git pull\`。`)
130
+ process.exit(1)
131
+ }
132
+ }
133
+ // tag 必须**正好指向 HEAD**:`gh release create` 不指定 --target 时会按默认分支 HEAD 建 tag,
134
+ // 与"我要发布的提交"无关。这条把"发布目标 == 发布内容"钉死。
135
+ const head = run('git rev-parse HEAD').trim()
136
+ // 【2026-09-18 实测】这一行此前没有兜底:github.com 抖一下(HTTPS 21s 超时)就**崩栈**——
137
+ // 上一行 ⓪b 刚打印"未能确认(离线或 fetch 失败)",下一行就把 Node 堆栈糊在屏幕上,
138
+ // 既看不出该干什么,也没留下"这次没验成"的记录。网络类失败必须**失败得可读**:
139
+ // tag 与 HEAD 的一致性没法确认 ⇒ 拒绝继续(发布目标可能是错的,比"没发布"更糟)。
140
+ let lsTag = ''
141
+ try {
142
+ lsTag = run(`git ls-remote --tags origin refs/tags/${tag}`).trim()
143
+ } catch (e) {
144
+ const detail = String(e?.stderr ?? e?.stdout ?? e?.message ?? e).trim().split('\n').find((l) => l.trim()) ?? '(无输出)'
145
+ console.error(`✗ ⓪b git:**无法查询远端 tag ${tag}**(网络不可达或代理拦截)——本次未能确认"发布目标 == 发布内容",拒绝继续。\n`
146
+ + ` 原因:${detail}\n`
147
+ + ' 处理:网络恢复后重跑 `npm run sync`(已上传资产会被 --clobber 幂等覆盖;不会产生重复发布)。')
148
+ process.exit(1)
149
+ }
150
+ const remoteTagSha = lsTag ? lsTag.split(/\s+/)[0] : ''
151
+ if (!remoteTagSha) {
152
+ console.error(`✗ 远端还没有 tag ${tag}——` + '`gh release create` 不指定 --target 会**按默认分支 HEAD** 建 tag,'
153
+ + `本地没推时它就把 tag 建在旧提交上。请显式指定目标提交:\n`
154
+ + ` gh release create ${tag} --target ${head} --title "..." --notes-file ...\n`
155
+ + ` 然后重跑本命令(本命令负责上传/校验资产)。`)
156
+ process.exit(1)
157
+ }
158
+ if (remoteTagSha !== head) {
159
+ console.error(`✗ tag ${tag} 指向 ${remoteTagSha.slice(0, 10)},而本地 HEAD 是 ${head.slice(0, 10)}——`
160
+ + `"发布目标"与"发布内容"不是同一个提交(这正是 v1.0.1 那次的事故形状)。修正:\n`
161
+ + ` git push --force origin refs/tags/${tag} # 或删掉重建:gh release delete ${tag} 后按上面那条重建`)
162
+ process.exit(1)
163
+ }
164
+ console.log(`⓪b git:tag ${tag} == HEAD(${head.slice(0, 10)})✓ 分支已推 ✓`)
165
+ }
166
+
167
+ // ① build
168
+ console.log('① build ...')
169
+ run('node scripts/build.mjs')
170
+ console.log(' done')
171
+
172
+ // ② pack(**每次构建带构建戳 + 构建时间**)
173
+ // 为什么必须换名:2026-09-15 实测——同 tag 同 URL 用 --clobber 覆盖内容后,
174
+ // `dsh plugin add <同一 URL>` **不会重新下载**(pnpm 按 URL 规格复用旧副本,--force 也没绕过;
175
+ // 同时用独立探针 GET 该 URL 证明 URL 本身已提供新字节 → 是包管理器侧的复用)。
176
+ // 后果:用户重跑同一条命令"升级"会拿到旧版本。改法:文件名带本题 tgz 的 sha256 前 8 位 ⇒ URL 变化 ⇒ 必然重取。
177
+ // 为什么还要带**构建时间**:Release 页面会同时挂着历次构建的资产(有意不删——老 URL 得留给已装用户重装),
178
+ // 而 sha 前 8 位看不出新旧,用户"从页面复制那条 URL"时无从判断哪条是当前版本(实测踩到:页面上 4 条)。
179
+ // 加上日期时间后,肉眼/字典序取最新即当前版本;sha 段仍负责"内容变了 URL 必变"。
180
+ const now = new Date()
181
+ const pad = (n) => String(n).padStart(2, '0')
182
+ const buildStamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`
183
+ const packDir = join(tmpdir(), 'pptsync-' + Date.now())
184
+ mkdirSync(packDir, { recursive: true })
185
+ const tgzOutput = run(`npm pack --pack-destination "${packDir}"`).split('\n').pop()
186
+ const tgzPath = join(packDir, tgzOutput)
187
+ const tgzSha = sha(readFileSync(tgzPath))
188
+ const stampedName = tgzOutput.replace(/\.tgz$/, `-${buildStamp}-${tgzSha.slice(0, 8)}.tgz`)
189
+ const stampedPath = join(packDir, stampedName)
190
+ copyFileSync(tgzPath, stampedPath)
191
+ // --local 模式的**安装规格就是这个文件路径**,所以它必须落在**稳定目录**(不能留在 tmpdir):
192
+ // 否则系统清理临时目录后,profile 里记的 `file:` 规格指向一个不存在的文件 → 该 profile 的 pnpm install 直接失败,
193
+ // 而且失败发生在**下一次**安装时(远离本次构建),极难归因。
194
+ const artifactDir = join(deployRoot, '_artifacts')
195
+ mkdirSync(artifactDir, { recursive: true })
196
+ const artifactPath = join(artifactDir, stampedName)
197
+ copyFileSync(tgzPath, artifactPath)
198
+ console.log(`② packed ${tgzOutput} (${Math.round(statSync(tgzPath).size / 1024 / 1024 * 10) / 10}MB)`)
199
+ console.log(` 资产名(构建时间 + 构建戳:取最新一条即当前版本,且内容变了规格必变):${stampedName}\n local sha256=${tgzSha}`)
200
+ console.log(` 本地构件(--local 的安装规格):${artifactPath}`)
201
+
202
+ // ③④ 远程 digest 比对/上传(--local 整段跳过:本地迭代不发 GitHub)
203
+ const remoteDigest = () => {
204
+ try {
205
+ // REST 输出 JSON 后本地解析(cmd.exe 下 shell 无法用单引号 jq)
206
+ const j = JSON.parse(run(`"${GH}" api repos/zbsph/dsh-ppt-studio/releases/tags/${tag}`))
207
+ const a = (j.assets ?? []).find((x) => x.name === stampedName)
208
+ return a?.digest ? a.digest.replace(/^sha256:/, '') : ''
209
+ } catch { return '' }
210
+ }
211
+ let remote = ''
212
+ if (local) {
213
+ console.log('③④ --local:**跳过 GitHub 比对/上传**(本地迭代模式;发版时跑不带 --local 的那条)')
214
+ } else {
215
+ remote = remoteDigest()
216
+ console.log(`③ remote asset digest=${remote || '(未读取到,尝试同步)'}`)
217
+ if (remote !== tgzSha) {
218
+ if (noUpload) {
219
+ console.error(`✗ 本地与远程不一致且 --no-upload:本地 ${tgzSha} ≠ 远程 ${remote || '无'}——请先上传`)
220
+ process.exit(1)
221
+ }
222
+ console.log(`④ 上传(${stampedName})...`)
223
+ run(`"${GH}" release upload ${tag} "${stampedPath}" --clobber`)
224
+ for (let i = 0; i < 6; i++) {
225
+ const until = new Date(Date.now() + 4000)
226
+ while (new Date() < until) {} // 等 CDN 生效(无 sleep 依赖)
227
+ remote = remoteDigest()
228
+ if (remote === tgzSha) break
229
+ }
230
+ if (remote !== tgzSha) {
231
+ console.error(`✗ 上传后 digest 未收敛:远程 ${remote} ≠ 本地 ${tgzSha}——稍后重试或人工核查`)
232
+ process.exit(1)
233
+ }
234
+ console.log(` uploaded ✓ remote=${remote}`)
235
+ } else {
236
+ console.log('④ 远程已一致(跳过上传)')
237
+ }
238
+ }
239
+
240
+ // ⑤ 用同一 tgz 部署本机安装根(保持"下载 → 解压 → 安装"的产物完全同源)
241
+ console.log(`⑤ 本机部署(${deployRoot}\\package,源=同一 tgz)...`)
242
+ rmSync(join(deployRoot, 'package'), { recursive: true, force: true })
243
+ mkdirSync(deployRoot, { recursive: true })
244
+ run(`tar -xzf "${tgzPath}" -C "${deployRoot}"`)
245
+ console.log(' extracted')
246
+ const installOut = run(`node "${join(deployRoot, 'package', 'scripts', 'install.mjs')}"`)
247
+ console.log(installOut.split('\n').slice(0, 6).join('\n'))
248
+
249
+ // ⑤b bundle 模式:本机**挂载源**是 pnpm 的快照(`dsh plugin add <URL>`),不再是部署副本被 junction 指向。
250
+ // 所以发布后必须用**同一个 GitHub 资产 URL** 重新 add,才能让"本机跑的 == GitHub 上的"在这条路径上继续成立;
251
+ // 非 bundle 模式(preset/junction 装法)挂载源就是刚部署的副本,不需要这一步。
252
+ const dshHome = resolve(process.env.DSH_HOME || join(homedir(), '.dsh'))
253
+ const profileName = opt('--profile', 'web')
254
+ const profilePkgFile = join(dshHome, 'profiles', profileName, 'package.json')
255
+ let bundleMode = false
256
+ try {
257
+ const pp = JSON.parse(readFileSync(profilePkgFile, 'utf8').replace(/^\uFEFF/, ''))
258
+ bundleMode = Boolean(pp.dependencies?.[pkg.name]) || (pp.dsh?.profile?.bundles ?? []).includes(pkg.name)
259
+ } catch { /* 无 profile → 非 bundle */ }
260
+ let assetUrl = null
261
+ let mountMatch = null
262
+ if (bundleMode) {
263
+ const repoUrl = run('git remote get-url origin').trim().replace(/\.git$/, '').replace(/^git@github\.com:/, 'https://github.com/')
264
+ assetUrl = local ? null : `${repoUrl}/releases/download/${tag}/${stampedName}`
265
+ // --local 的安装规格 = 稳定构件路径(`dsh plugin add <路径>` 会被 pnpm 记成 `file:...`)。
266
+ const installSpec = local ? artifactPath : assetUrl
267
+ const installedPkgDir = join(dshHome, 'profiles', profileName, 'node_modules', ...pkg.name.split('/'))
268
+ // 构建戳文件名 ⇒ 规格每次都不同 ⇒ 包管理器必然重新解析(不必再赌 --force);
269
+ // 仍然**用内容比对自证**(部署副本 vs 挂载副本),不一致就改走 remove+add,最后报警并给手工命令。
270
+ const fileSha = (p) => (existsSync(p) ? sha(readFileSync(p)) : null)
271
+ const probeRels = [join('lib', 'index.js'), join('package.json'), 'cordis.patch.yml']
272
+ const contentMatch = () => probeRels.every((rel) => {
273
+ const a = fileSha(join(deployRoot, 'package', rel))
274
+ const b = fileSha(join(installedPkgDir, rel))
275
+ return a && b && a === b
276
+ })
277
+ console.log(local
278
+ ? `⑤b bundle 模式:按**本地 tgz 的 file: 规格**重装 profile(挂载源 = 本地刚构建的字节,不经 GitHub)...\n ${installSpec}`
279
+ : `⑤b bundle 模式:按 GitHub 资产 URL 重新安装到 profile(挂载源 = pnpm 快照)...\n ${installSpec}`)
280
+ try {
281
+ run(`dsh plugin --profile ${profileName} add "${installSpec}"`)
282
+ if (!contentMatch()) {
283
+ console.log(' 内容与部署副本不一致 → remove + add 强制重取...')
284
+ try { run(`dsh plugin --profile ${profileName} remove ${pkg.name}`) } catch { /* 可能本就不在 */ }
285
+ run(`dsh plugin --profile ${profileName} add "${installSpec}"`)
286
+ }
287
+ mountMatch = contentMatch()
288
+ console.log(mountMatch
289
+ ? ' 已重新 add ✓ 且**挂载副本与部署副本逐文件同源**(lib/index.js · package.json · cordis.patch.yml)'
290
+ : ' ⚠ 挂载副本与部署副本仍不一致——手工执行:dsh plugin --profile ' + profileName + ' remove ' + pkg.name + ' && dsh plugin --profile ' + profileName + ' add "' + installSpec + '"')
291
+ } catch (e) {
292
+ mountMatch = false
293
+ console.log(` ⚠ 重新 add 失败(不影响部署副本与字节级校验):${String(e.stdout ?? e.message ?? e).slice(0, 200)}`)
294
+ console.log(' 手动执行:dsh plugin --profile ' + profileName + ' add "' + installSpec + '"')
295
+ }
296
+ } else {
297
+ // 非 bundle 模式(**历史遗留**:0.1.6 时代的"预设行 + 预设内 junction"):挂载源 = 那条 junction → 部署副本。
298
+ // 判据同样是"逐文件 sha256",只是要比的是穿过 junction 读到的字节。
299
+ // 【2026-09-18 修】此前这一支不设 mountMatch(恒为 null),而 ⑥ 的 LOCAL 判据要求 `mountMatch === true`
300
+ // ⇒ 在本模式下 `--local` **恒定报 ❌**。写 `--local` 时只考虑了 bundle 模式,这是本模式的实际缺陷。
301
+ // 【2026-09-24 注】DSH 0.1.7 起 `--isolate` 被安装器**明确拒绝**(目录发现已删除,写出来也不会被读),
302
+ // 所以这一支只服务"升级前就已装成孤岛的老机器",不再给出"重跑 --isolate"这种现在会失败的建议。
303
+ const fileSha2 = (p) => (existsSync(p) ? sha(readFileSync(p)) : null)
304
+ const probeRels2 = [join('lib', 'index.js'), join('package.json'), 'cordis.patch.yml']
305
+ const candidates = [
306
+ { dir: join(dshHome, '.agent-presets', 'ppt', 'plugin'), label: '预设内 junction(0.1.6 时代的 --isolate)' },
307
+ { dir: join(dshHome, 'profiles', profileName, 'node_modules', ...pkg.name.split('/')), label: 'profile junction(旧装法)' },
308
+ ]
309
+ const hit = candidates.find((c) => existsSync(c.dir))
310
+ if (hit) {
311
+ mountMatch = probeRels2.every((rel) => {
312
+ const a = fileSha2(join(deployRoot, 'package', rel))
313
+ const b = fileSha2(join(hit.dir, rel))
314
+ return a && b && a === b
315
+ })
316
+ console.log(`⑤b 非 bundle 模式(历史遗留装法):挂载源 = 刚部署的副本(${hit.label})\n **穿过 junction** 逐文件 sha256 自证:${mountMatch ? '与部署副本同源 ✓' : '与部署副本不一致 ⚠'}(lib/index.js · package.json · cordis.patch.yml)`)
317
+ if (!mountMatch) console.log(` ⚠ 不一致 → 建议**改回默认装法**(DSH 0.1.7 上 --isolate 已不可用):dsh plugin --profile ${profileName} add "${installSpec}",然后 node "${join(deployRoot, 'package', 'scripts', 'install.mjs')}"`)
318
+ } else {
319
+ mountMatch = false
320
+ console.log(`⑤b 非 bundle 模式(历史遗留装法):**未找到挂载链接**(预设内 ${join(dshHome, '.agent-presets', 'ppt', 'plugin')} / profile ${join(dshHome, 'profiles', profileName, 'node_modules', ...pkg.name.split('/'))} 都不存在)——请改用默认装法:dsh plugin --profile ${profileName} add "${installSpec}",然后 node "${join(deployRoot, 'package', 'scripts', 'install.mjs')}"`)
321
+ }
322
+ }
323
+
324
+ // ⑥ 终验
325
+ const localSha = sha(readFileSync(tgzPath))
326
+ const artifactSha = sha(readFileSync(artifactPath))
327
+ const deployOk = existsSync(join(deployRoot, 'package', 'lib', 'index.js'))
328
+ const prev = (() => { try { return JSON.parse(readFileSync(stateFile, 'utf8')) } catch { return {} } })()
329
+ let remoteFinal = null
330
+ let ok
331
+ let verdict
332
+ if (local) {
333
+ // 本地模式的判据**不是**"== GitHub",而是"**本机跑的 == 刚构建的字节**":
334
+ // mountMatch 已在 ⑤b 用逐文件 sha256 自证——两种挂载模式都要证(bundle=pnpm 快照;
335
+ // 非 bundle=穿过 junction 读部署副本)。两种模式下它都必须为 true,否则这里报 ❌。
336
+ // ——本地模式**故意**放弃"机器 == GitHub"这条不变量,换取不发版也能迭代;
337
+ // 代价是发版前两者可以不同,所以发版模式(默认)仍做三向校验,状态文件用 mode 区分。
338
+ ok = mountMatch === true && deployOk && artifactSha === localSha
339
+ verdict = ok
340
+ ? `✅ LOCAL ✓ 本机跑的 == 本地刚构建的字节(${bundleMode ? 'bundle' : 'preset-junction'} 挂载,本轮未发 GitHub)`
341
+ : `❌ LOCAL 失败(挂载模式 ${bundleMode ? 'bundle' : 'preset-junction'}:mountMatch=${mountMatch}|部署副本=${deployOk}|构件一致=${artifactSha === localSha})`
342
+ } else {
343
+ remoteFinal = remoteDigest()
344
+ ok = localSha === remoteFinal && deployOk
345
+ verdict = ok ? '✅ SYNC ✓ 机器 == GitHub(字节级)' : '❌ SYNC 失败'
346
+ }
347
+
348
+ // 清理旧的本地构件:构建戳让每个构建都产一个新文件(每个约 30MB),不清理会一直堆。
349
+ // **只在已确认"挂载源就是本次构件"时才清理**(local && mountMatch),并且永远保留本次构件——
350
+ // 它正是 profile 当前 `file:` 规格指向的文件,删掉它下次 pnpm install 会直接失败。
351
+ let pruned = 0
352
+ if (local && mountMatch === true) {
353
+ try {
354
+ for (const f of readdirSync(artifactDir)) {
355
+ if (!f.endsWith('.tgz')) continue
356
+ const p = join(artifactDir, f)
357
+ if (p === artifactPath) continue
358
+ try { rmSync(p, { force: true }); pruned++ } catch { /* 被占用就算了,不值得让同步失败 */ }
359
+ }
360
+ } catch { /* 目录读不了就算了 */ }
361
+ }
362
+
363
+ // ⑦ 发版终验:**用户视角**的"一条命令装好并用起来"(2026-09-18 新增)
364
+ // 为什么放在最后而不是最前:前面 ①~⑥ 证明的是"我构建的字节与上传的资产一致",这一条证明的是
365
+ // **用户能不能照指引装好并用起来**——两件事事实上不同(v1.0.1 那次:资产 sha 完全一致,而 GitHub 上的
366
+ // 仓库/tag 指的是旧代码;干净克隆里 npm test 还会因 CRLF 检出直接崩)。
367
+ // 为什么失败只报 ❌ 不回滚:资产已经上传了,静默"回滚"会让 Release 页面与本地状态更难对齐;
368
+ // 正确做法是把判据写进状态文件并**让退出码为 1**,让"没验过"和"验过且通过"永远可区分。
369
+ let freshInstall = null
370
+ if (local) {
371
+ console.log('\n⑦ 本地模式:跳过发版终验(用户视角的安装自证只在发版时跑)')
372
+ } else if (skipFresh) {
373
+ console.log('\n⑦ ⚠ 已按 --skip-fresh 跳过发版终验——"用户能不能一条命令装好"**本轮未证明**(状态文件里 freshInstall=null)')
374
+ } else if (!ok) {
375
+ console.log('\n⑦ 资产一致性未通过,跳过发版终验(先修上面的 ❌)')
376
+ } else {
377
+ console.log('\n⑦ 发版终验:干净克隆 + 一条命令安装(verify-fresh-install)...\n')
378
+ const r = spawnSync(process.execPath, [join(root, 'scripts', 'verify-fresh-install.mjs')], { cwd: root, stdio: 'inherit' })
379
+ freshInstall = r.status === 0
380
+ if (!freshInstall) {
381
+ console.log('\n✗ 发版终验失败:资产已上传且字节一致,但"用户照指引能不能装好并用起来"**没被证明**——')
382
+ console.log(' 逐条看上面每个 ✗(每条都写明"看到什么 / 为什么重要")。修完重跑本命令即可。')
383
+ }
384
+ }
385
+ const okFinal = ok && freshInstall !== false
386
+
387
+ const state = {
388
+ version: pkg.version,
389
+ tag,
390
+ mode: local ? 'local' : 'release',
391
+ assetSha: localSha,
392
+ // 本地模式保留**上一次发版时**的 remoteSha(信息用途:本机比 GitHub 新多少),
393
+ // 绝不把"未上传"伪装成"已同步"——这正是本地模式唯一新增的风险,必须让它可读。
394
+ remoteSha: local ? (prev.remoteSha ?? null) : remoteFinal,
395
+ lastReleaseAt: local ? (prev.lastReleaseAt ?? prev.at ?? null) : new Date().toISOString(),
396
+ mount: bundleMode ? (local ? 'bundle-local' : 'bundle') : 'preset-junction',
397
+ mountMatch,
398
+ assetUrl,
399
+ // 本地构件 = **本次构建**的稳定路径(两种模式 ② 都产出它)。此前 release 模式沿用 `prev.localTgz`,
400
+ // 于是状态文件里会留着一个**上一版甚至上一版之前**的 tgz 路径(v1.0.3 实测:文件已不存在),
401
+ // 读状态的人会以为"本机就是从这个构件装的"。状态文件必须只说当下真实的事。
402
+ localTgz: artifactPath,
403
+ // 三态:true=验过且通过|false=验过且失败|null=没验(--local / --skip-fresh)
404
+ freshInstall,
405
+ at: new Date().toISOString(),
406
+ ok: okFinal,
407
+ }
408
+ writeFileSync(stateFile, JSON.stringify(state, null, 2))
409
+ console.log(`\n${verdict}:${localSha}`)
410
+ if (local) {
411
+ console.log(` 安装规格:file:${artifactPath}`)
412
+ console.log(` GitHub 侧:**本轮未上传**(remoteSha ${state.remoteSha ? '= ' + state.remoteSha + '(上一次发版的值)' : '未知'})`)
413
+ if (pruned) console.log(` 已清理 ${pruned} 个旧本地构件`)
414
+ console.log(' 生效:**重启 dsh web**(profile bundle 挂载不热更)')
415
+ } else {
416
+ console.log(remoteFinal ? ' remote: ' + remoteFinal : ' remote: 无法确认')
417
+ console.log(freshInstall === true
418
+ ? ' 用户视角终验:✅ 干净克隆 npm test 全绿 + 一条命令安装自证通过(freshInstall=true)'
419
+ : freshInstall === false
420
+ ? ' 用户视角终验:❌ **未通过**(freshInstall=false)——见上面 ⑦'
421
+ : ' 用户视角终验:⚠ **本轮未跑**(freshInstall=null)——"能不能装好"未证明')
422
+ }
423
+ console.log(` .sync-state.json → ${stateFile}`)
424
+ rmSync(packDir, { recursive: true, force: true })
425
+ process.exit(okFinal ? 0 : 1)
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 导入版模板逐页截图(保真对照用):render 模板工程 → Edge 逐页 1920×1080 PNG。
3
+ * 输出 fidelity/imp/<id>/NN.png;渲染临时目录 _imp-tmp 用后即删。
4
+ * 用法:node scripts/shot-templates.mjs [id...](缺省 = **模板库里现有的全部模板**)
5
+ * 【2026-09-18】此前默认硬编码 4 套"导入版"模板 id;那 4 套已从随包发行物移除
6
+ * (46.2 MB → 0.13 MB,见 docs/03 的 1.0.4 记录),硬编码 id 会立刻失效 ⇒ 改为动态列举。
7
+ */
8
+ import { join, dirname } from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { existsSync, readdirSync } from 'node:fs'
11
+ import { spawn } from 'node:child_process'
12
+ import { rm, mkdir } from 'node:fs/promises'
13
+ import { resolveDeck } from '../lib/pptd/schema.js'
14
+ import { renderDeck } from '../lib/pptd/render-html.js'
15
+ import { TEMPLATES_DIR, templateWorkspace } from '../lib/templates.js'
16
+
17
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
18
+ const OUT = join(process.env.FIDELITY_OUT ?? join(process.env.USERPROFILE ?? '', '.dsh', 'ppt-studio', 'fidelity'), 'imp')
19
+ const edge = ['C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
20
+ 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
21
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
22
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'].find((p) => existsSync(p))
23
+
24
+ const ids = process.argv.slice(2).length
25
+ ? process.argv.slice(2)
26
+ : (existsSync(TEMPLATES_DIR) ? readdirSync(TEMPLATES_DIR, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name) : [])
27
+ for (const id of ids) {
28
+ const t = await templateWorkspace(id)
29
+ const dir = t.dir
30
+ const ctx = await resolveDeck(dir)
31
+ const r = await renderDeck(ctx, { out: '_imp-tmp' })
32
+ const outDir = join(OUT, id)
33
+ await mkdir(outDir, { recursive: true })
34
+ let ok = 0
35
+ for (let i = 0; i < r.htmlFiles.length; i++) {
36
+ const url = 'file:///' + join(dir, '_imp-tmp', r.htmlFiles[i]).replace(/\\/g, '/')
37
+ const outPng = join(outDir, `${String(i + 1).padStart(2, '0')}.png`)
38
+ await new Promise((resolve, reject) => {
39
+ const child = spawn(edge, ['--headless=new', '--disable-gpu', '--no-first-run', '--hide-scrollbars', '--force-device-scale-factor=2',
40
+ `--window-size=${ctx.size.width},${ctx.size.height}`, `--screenshot=${outPng}`, url], { stdio: 'ignore', windowsHide: true })
41
+ child.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`Edge ${code}`)))
42
+ child.on('error', reject)
43
+ })
44
+ ok++
45
+ }
46
+ await rm(join(dir, '_imp-tmp'), { recursive: true, force: true })
47
+ console.log(`${id}: ${ok} png → ${outDir}`)
48
+ }
49
+ console.log('DONE')