@zzclub/pipeline 0.11.0 → 0.13.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 (73) hide show
  1. package/README.md +10 -1
  2. package/package.json +2 -2
  3. package/src/adapter-types.ts +8 -0
  4. package/src/adapters/builtin-image-renderer.test.ts +32 -0
  5. package/src/adapters/builtin-image-renderer.ts +42 -73
  6. package/src/adapters/builtin-markdown-renderer.ts +3 -0
  7. package/src/cli.ts +10 -4
  8. package/src/command-outcome.ts +50 -0
  9. package/src/commands/abandon.ts +13 -2
  10. package/src/commands/checkpoint.ts +3 -2
  11. package/src/commands/config.ts +14 -1
  12. package/src/commands/imgx.ts +3 -3
  13. package/src/commands/ingest-handoff.ts +15 -0
  14. package/src/commands/init.ts +5 -0
  15. package/src/commands/monitor.ts +21 -0
  16. package/src/commands/prepare-finalize.ts +2 -0
  17. package/src/commands/prepare.ts +3 -0
  18. package/src/commands/publish.ts +5 -2
  19. package/src/commands/render-theme.test.ts +115 -0
  20. package/src/commands/render.ts +34 -12
  21. package/src/commands/republish.ts +4 -1
  22. package/src/commands/sync-blog.ts +4 -2
  23. package/src/commands/wechat-export.ts +6 -3
  24. package/src/config.ts +5 -0
  25. package/src/cover-theme-presets.json +59 -0
  26. package/src/imgx/assets/browser/cover.css +25 -0
  27. package/src/imgx/assets/browser/cover.js +40 -0
  28. package/src/imgx/assets/templates/longform-3-4.html +14 -36
  29. package/src/imgx/assets/templates/poster-3-4.html +15 -394
  30. package/src/imgx/assets/templates/wechat-cover-split.html +15 -334
  31. package/src/imgx/chrome-render.test.ts +38 -0
  32. package/src/imgx/chrome-render.ts +110 -0
  33. package/src/imgx/cover.test.ts +80 -0
  34. package/src/imgx/cover.ts +130 -0
  35. package/src/imgx/inline-text.ts +56 -0
  36. package/src/imgx/longform-theme.ts +74 -114
  37. package/src/imgx/obstacle-flow.test.ts +52 -0
  38. package/src/imgx/obstacle-flow.ts +177 -0
  39. package/src/imgx/pretext-adapter.ts +3 -0
  40. package/src/imgx/pretext-runtime.ts +0 -1
  41. package/src/imgx/render-article.test.ts +26 -1
  42. package/src/imgx/render-article.ts +168 -378
  43. package/src/imgx/render-card.ts +68 -203
  44. package/src/imgx/runtime.ts +1 -91
  45. package/src/logger.test.ts +28 -1
  46. package/src/logger.ts +39 -26
  47. package/src/monitor/client.ts +60 -0
  48. package/src/monitor/recorder.ts +142 -0
  49. package/src/monitor/runtime.ts +58 -0
  50. package/src/monitor/server.ts +170 -0
  51. package/src/monitor/store.ts +235 -0
  52. package/src/monitor/types.ts +77 -0
  53. package/src/monitor.test.ts +241 -0
  54. package/src/plugins.ts +4 -1
  55. package/src/providers/blog.ts +4 -1
  56. package/src/providers/cos.ts +3 -0
  57. package/src/providers/index.ts +7 -3
  58. package/src/providers/publish-core.ts +7 -1
  59. package/src/providers/wechat.ts +4 -1
  60. package/src/render-branding.test.ts +26 -0
  61. package/src/render-branding.ts +24 -0
  62. package/src/runtime-paths.ts +12 -51
  63. package/src/schema/config.ts +15 -0
  64. package/src/schema/cover-theme.test.ts +54 -0
  65. package/src/schema/cover-theme.ts +164 -0
  66. package/src/schema/state.ts +2 -0
  67. package/src/spawn.ts +4 -2
  68. package/src/state.ts +4 -0
  69. package/src/text.test.ts +7 -7
  70. package/src/text.ts +2 -27
  71. package/src/workflow.test.ts +4 -3
  72. package/src/imgx/assets/browser/obstacle-flow.d.ts +0 -27
  73. package/src/imgx/assets/browser/obstacle-flow.js +0 -326
package/README.md CHANGED
@@ -747,6 +747,8 @@ bun run src/cli.ts init ... --note-id NOTE_ID
747
747
 
748
748
  ## imgx 渲染子系统
749
749
 
750
+ 封面支持 `render.cover` 配置、十套主题、背景图与透明边框,以及供 App 使用的 JSON Schema 和预览输出。详见 [封面主题与图片排版](docs/render-themes.md),可直接导入 [配置示例](examples/cover-themes.json)。
751
+
750
752
  - 基于 Chrome headless 和 `@napi-rs/canvas`
751
753
  - 模板包括 `longform-3-4`(newspic 长文)、`poster-3-4`(单页卡片)和 `wechat-cover-split`(文章封面)
752
754
  - 主题包括 `paper-sage`(默认账号)和 `linen-news`(`ancientone` 账号)
@@ -853,7 +855,7 @@ bun install --global .
853
855
  运行时依赖检查:
854
856
  - `@napi-rs/canvas` — 图片渲染需要,懒加载,缺失时提示安装命令
855
857
  - Chrome — HTML 导出需要,缺失时提示安装命令
856
- - CJK 字体 — npm 模式下自动从 CDN 下载到 `~/.config/zzhub-pipeline/fonts/`
858
+ - CJK 字体 — 随 CLI 附带,生成图片时按需读取本地文件,不触发网络下载;封面也支持本机字体名称
857
859
 
858
860
  ## 发布提供者
859
861
 
@@ -866,6 +868,13 @@ bun install --global .
866
868
 
867
869
  Markdown → WeChat HTML 转换通过插件系统完成(默认使用内置 `builtin-wechat-preview` 适配器)。
868
870
 
871
+ ## 本机多任务监控
872
+
873
+ `zzp monitor start` 按需启动共享 HTTP/SSE 服务,`monitor status` 查看状态,`monitor stop` 只停止监控。
874
+ CLI 独立记录执行和错误,GUI 可同时订阅多个任务。新版业务失败会退出 1,调用方仍应读取失败响应和最新任务状态。
875
+
876
+ 详见 [监控接口与退出码迁移](docs/monitor.md)。
877
+
869
878
  ## 测试与验证
870
879
 
871
880
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zzclub/pipeline",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "zzp": "./src/cli.ts",
@@ -24,7 +24,7 @@
24
24
  "release:major": "changelogen --release --major --push"
25
25
  },
26
26
  "dependencies": {
27
- "@chenglou/pretext": "^0.0.5",
27
+ "@chenglou/pretext": "0.0.8",
28
28
  "@zzclub/milkdown-article-style": "0.1.1",
29
29
  "image-size": "^2.0.2",
30
30
  "pngjs": "^7.0.0",
@@ -9,6 +9,8 @@
9
9
  */
10
10
 
11
11
  import type { RenderAsset, RoutePrimary, WorkflowState } from "./state";
12
+ import type { MonitorProgress } from "./monitor/types";
13
+ import type { ResolvedCoverTheme } from "./schema/cover-theme";
12
14
 
13
15
  // ── Doctor check ──────────────────────────────────────────────────
14
16
 
@@ -26,6 +28,10 @@ export interface PipelinePluginDoctorCheck {
26
28
  * to runRenderArticleCli and runRenderCardCli via CLI argv.
27
29
  */
28
30
  export interface ImageRenderInput {
31
+ /** 已解析的封面主题;旧插件可忽略。 */
32
+ coverTheme?: ResolvedCoverTheme;
33
+ /** 可选进度通知,旧插件可忽略。 */
34
+ onProgress?: (progress: MonitorProgress) => void;
29
35
  /** Workflow state providing context (route, metadata, etc.) */
30
36
  state: WorkflowState;
31
37
 
@@ -117,6 +123,8 @@ export interface ImageRenderPlugin {
117
123
  * Maps to ExportMarkdownToWechatHtmlInput from wechat-preview.
118
124
  */
119
125
  export interface MarkdownRenderInput {
126
+ /** 可选进度通知,旧插件可忽略。 */
127
+ onProgress?: (progress: MonitorProgress) => void;
120
128
  /** Path to the markdown source file */
121
129
  markdownPath: string;
122
130
 
@@ -0,0 +1,32 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { defaultState, normalizeNewspicRenderSpec } from "../state";
6
+ import { builtinImageRenderer } from "./builtin-image-renderer";
7
+
8
+ test("multi mode without page specs renders body pages and excludes stale files", async () => {
9
+ const workspace = await mkdtemp(join(tmpdir(), "zzhub-auto-pages-"));
10
+ const previous = process.env.TEST_RENDER_CARD_STUB;
11
+ process.env.TEST_RENDER_CARD_STUB = "1";
12
+ try {
13
+ const outputDir = join(workspace, "images");
14
+ await mkdir(outputDir);
15
+ await writeFile(join(outputDir, "article-99.png"), "previous output");
16
+ const state = defaultState();
17
+ state.run_id = "auto-pages";
18
+ state.workspace_root = workspace;
19
+ state.route.primary = "wechat-newspic";
20
+ state.intent.newspic_render = normalizeNewspicRenderSpec({ pagination_mode: "multi", min_pages: 2, target_fill_ratio: 0.8 });
21
+ const result = await builtinImageRenderer.render({ state, route: "wechat-newspic", outputDir, title: "完整标题:副标题", bodyText: "正文应该舒适易读,不需要手工指定每一页。".repeat(24) });
22
+ expect(result.pageCount).toBeGreaterThanOrEqual(2);
23
+ expect(result.assets.filter(asset => asset.kind === "page")).toHaveLength(result.pageCount);
24
+ expect(result.assets.every(asset => !asset.path.endsWith("article-99.png"))).toBe(true);
25
+ expect((await readFile(result.assets.find(asset => asset.kind === "page")!.path)).subarray(1, 4).toString()).toBe("PNG");
26
+ expect(await readFile(join(outputDir, "article-99.png"), "utf8")).toBe("previous output");
27
+ } finally {
28
+ if (previous === undefined) delete process.env.TEST_RENDER_CARD_STUB;
29
+ else process.env.TEST_RENDER_CARD_STUB = previous;
30
+ await rm(workspace, { recursive: true, force: true });
31
+ }
32
+ }, 30_000);
@@ -5,7 +5,7 @@
5
5
  * when no user-provided plugin is configured.
6
6
  */
7
7
 
8
- import { mkdir, readdir, rm, rmdir, writeFile } from "fs/promises";
8
+ import { mkdir, rm, rmdir, writeFile } from "fs/promises";
9
9
  import { join } from "path";
10
10
 
11
11
  import { runRenderArticleCli, runRenderCardCli } from "../imgx";
@@ -17,13 +17,15 @@ import {
17
17
  generateCoverTitle,
18
18
  } from "../text";
19
19
  import { getLongformTheme } from "../routes";
20
- import { resolveWorkspacePaths } from "../config";
20
+ import { loadConfig, resolveWorkspacePaths } from "../config";
21
+ import { resolveRenderBranding } from "../render-branding";
21
22
  import type {
22
23
  ImageRenderPlugin,
23
24
  ImageRenderInput,
24
25
  ImageRenderOutput,
25
26
  PipelinePluginDoctorCheck,
26
27
  } from "../adapter-types";
28
+ import { normalizeNewspicRenderSpec } from "../state";
27
29
  import type {
28
30
  NewspicRenderSpec,
29
31
  RenderAsset,
@@ -32,20 +34,6 @@ import type {
32
34
 
33
35
  // ── Helpers ──────────────────────────────────────────────────────
34
36
 
35
- function appendPosterVisualArgs(
36
- cmdParts: string[],
37
- visualParams: AccountVisualParams | null,
38
- highlightWords: string[],
39
- ): void {
40
- if (visualParams) {
41
- cmdParts.push("--highlight", visualParams.highlight);
42
- cmdParts.push("--bg", visualParams.bg);
43
- cmdParts.push("--footer", visualParams.footer);
44
- cmdParts.push("--fallback-icon", visualParams.fallback_icon);
45
- }
46
- cmdParts.push("--highlight-words", highlightWords.join(","));
47
- }
48
-
49
37
  type ResolvedPageImageSpecFile = {
50
38
  default_image_layout: string;
51
39
  target_fill_ratio: number;
@@ -138,29 +126,19 @@ async function renderCover(
138
126
  outputDir: string,
139
127
  visualParams: AccountVisualParams | null,
140
128
  highlightWords: string[],
129
+ onProgress?: ImageRenderInput["onProgress"],
130
+ coverTheme?: ImageRenderInput["coverTheme"],
141
131
  ): Promise<RenderAsset> {
142
132
  const coverOut = join(outputDir, "cover.png");
143
133
  const route = template === "wechat-cover-split" ? "wechat-article" : "wechat-newspic";
144
-
145
- if (template === "wechat-cover-split") {
146
- const cmdParts = ["--template", "wechat-cover-split", "--text", title];
147
- if (visualParams) {
148
- cmdParts.push("--highlight", visualParams.highlight);
149
- cmdParts.push("--bg", visualParams.bg);
150
- cmdParts.push("--footer", visualParams.footer);
151
- cmdParts.push("--fallback-icon", visualParams.fallback_icon);
152
- }
153
- cmdParts.push("--highlight-words", highlightWords.join(","));
154
- cmdParts.push("--out", coverOut);
155
- runRenderCardCli(cmdParts);
156
- } else {
157
- const cmdParts = ["--template", "poster-3-4", "--text", title];
158
- appendPosterVisualArgs(cmdParts, visualParams, highlightWords);
159
- cmdParts.push("--out", coverOut);
160
- runRenderCardCli(cmdParts);
134
+ const args = ["--template", template, "--text", title, "--out", coverOut, "--highlight-words", highlightWords.join(",")];
135
+ if (visualParams) {
136
+ args.push("--footer", visualParams.footer, "--fallback-icon", visualParams.fallback_icon);
137
+ // 旧直接调用仍支持账号配色;已选主题使用自己的颜色。
138
+ if (!coverTheme) args.push("--bg", visualParams.bg, "--highlight", visualParams.highlight);
161
139
  }
162
-
163
- return { kind: "cover", route: route as RenderAsset["route"], path: coverOut };
140
+ await runRenderCardCli(args, onProgress, coverTheme);
141
+ return { kind: "cover", route, path: coverOut };
164
142
  }
165
143
 
166
144
  async function renderLongformPages(
@@ -196,6 +174,7 @@ async function renderLongformPages(
196
174
  "--text-file", tempBodyPath,
197
175
  "--out-dir", outputDir,
198
176
  "--theme", theme,
177
+ "--target-fill-ratio", String(newspicRenderSpec.target_fill_ratio),
199
178
  ];
200
179
 
201
180
  if (newspicRenderSpec.min_pages > 1) {
@@ -204,10 +183,9 @@ async function renderLongformPages(
204
183
  if (newspicRenderSpec.max_pages > 0) {
205
184
  pageParts.push("--max-pages", String(newspicRenderSpec.max_pages));
206
185
  }
186
+ if (newspicRenderSpec.require_image_every_page) pageParts.push("--require-image-every-page");
207
187
 
208
- if (vp?.footer) {
209
- pageParts.push("--footer", vp.footer);
210
- }
188
+ if (vp) pageParts.push("--footer", vp.footer, "--icon", vp.fallback_icon);
211
189
 
212
190
  const highlightWords = input.state.route.highlight_words ?? [];
213
191
  if (highlightWords.length > 0) {
@@ -231,7 +209,7 @@ async function renderLongformPages(
231
209
 
232
210
  let result: RenderArticleResult;
233
211
  try {
234
- result = runRenderArticleCli(pageParts);
212
+ result = await runRenderArticleCli(pageParts, input.onProgress);
235
213
  } finally {
236
214
  if (!keepTempFiles) {
237
215
  await rm(tempDir, { recursive: true, force: true });
@@ -244,20 +222,13 @@ async function renderLongformPages(
244
222
  throw new Error(`newspic render constraints not satisfied: ${errors.join("; ")}`);
245
223
  }
246
224
 
247
- // Discover generated page files
248
- const files = await readdir(outputDir);
249
- const pageFiles = files.filter((f) => /^article-\d+\.png$/.test(f)).sort();
250
-
251
- const assets: RenderAsset[] = [];
252
- for (let i = 0; i < pageFiles.length; i++) {
253
- assets.push({
254
- kind: "page",
255
- route: "wechat-newspic",
256
- index: i + 1,
257
- path: join(outputDir, pageFiles[i]),
258
- });
259
- }
260
-
225
+ // 仅返回本次实际生成的页,目录中的旧页不参与结果。
226
+ const assets: RenderAsset[] = result.pages.map(page => ({
227
+ kind: "page",
228
+ route: "wechat-newspic",
229
+ index: page.page,
230
+ path: join(outputDir, "article-" + String(page.page).padStart(2, "0") + ".png"),
231
+ }));
261
232
  return { assets, pageCount: result.pageCount, pages: result.pages };
262
233
  }
263
234
 
@@ -265,7 +236,7 @@ async function renderLongformPages(
265
236
 
266
237
  export const builtinImageRenderer: ImageRenderPlugin = {
267
238
  name: "builtin-imgx",
268
- version: "1.0.0",
239
+ version: "1.1.0",
269
240
 
270
241
  async doctor(): Promise<PipelinePluginDoctorCheck[]> {
271
242
  const checks: PipelinePluginDoctorCheck[] = [];
@@ -296,20 +267,19 @@ export const builtinImageRenderer: ImageRenderPlugin = {
296
267
  // Normalize visual params: adapter input uses camelCase, state uses snake_case
297
268
  const adapterVp = input.accountVisualParams;
298
269
  const stateVp = state.route.account_visual_params;
299
- const vp: AccountVisualParams | null = adapterVp
300
- ? {
301
- footer: adapterVp.footer ?? "",
302
- bg: adapterVp.bg ?? "",
303
- highlight: adapterVp.highlight ?? "",
304
- fallback_icon: adapterVp.fallbackIcon ?? "",
305
- }
306
- : stateVp;
270
+ const branding = resolveRenderBranding(loadConfig(), state.route.account);
271
+ const vp: AccountVisualParams = {
272
+ footer: adapterVp?.footer ?? branding.footerText,
273
+ bg: adapterVp?.bg ?? stateVp?.bg ?? "",
274
+ highlight: adapterVp?.highlight ?? stateVp?.highlight ?? "",
275
+ fallback_icon: adapterVp?.fallbackIcon ?? branding.logo,
276
+ };
307
277
 
308
278
  await mkdir(outputDir, { recursive: true });
309
279
 
310
280
  if (route === "wechat-article") {
311
281
  // Article: cover only (wechat-cover-split)
312
- const cover = await renderCover("wechat-cover-split", title, outputDir, vp, highlightWords);
282
+ const cover = await renderCover("wechat-cover-split", title, outputDir, vp, highlightWords, input.onProgress, input.coverTheme);
313
283
  return { assets: [cover], pageCount: 1, pages: [{ page: 1, imageCount: 0, imageSources: [] }] };
314
284
  }
315
285
 
@@ -318,20 +288,19 @@ export const builtinImageRenderer: ImageRenderPlugin = {
318
288
  }
319
289
 
320
290
  // wechat-newspic: determine short vs long
291
+ const savedSpec = normalizeNewspicRenderSpec(state.intent.newspic_render);
321
292
  const newspicRenderSpec: NewspicRenderSpec = {
322
- pagination_mode: input.pageSpecs && input.pageSpecs.length > 0 ? "multi" : "single",
323
- min_pages: input.minPages ?? 1,
324
- max_pages: input.maxPages ?? 0,
325
- require_image_every_page: false,
326
- default_image_layout: "staggered",
327
- target_fill_ratio: 0.8,
328
- page_specs: (input.pageSpecs ?? []).map((ps) => ({
293
+ ...savedSpec,
294
+ pagination_mode: input.template === "longform-3-4" ? "multi" : input.template === "poster-3-4" ? "single" : savedSpec.pagination_mode,
295
+ min_pages: input.minPages ?? savedSpec.min_pages,
296
+ max_pages: input.maxPages ?? savedSpec.max_pages,
297
+ page_specs: input.pageSpecs?.map(ps => ({
329
298
  page: ps.page,
330
299
  image_markers: ps.imageMarkers ?? [],
331
300
  image_layout: ps.imageLayout ?? null,
332
301
  target_fill_ratio: ps.targetFillRatio ?? null,
333
302
  note: ps.note ?? null,
334
- })),
303
+ })) ?? savedSpec.page_specs,
335
304
  };
336
305
 
337
306
  const isLong = newspicRenderSpec.pagination_mode === "multi";
@@ -339,13 +308,13 @@ export const builtinImageRenderer: ImageRenderPlugin = {
339
308
  if (!isLong) {
340
309
  // Short: single poster cover
341
310
  const coverTitle = generateCoverTitle(title);
342
- const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords);
311
+ const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords, input.onProgress, input.coverTheme);
343
312
  return { assets: [cover], pageCount: 1, pages: [{ page: 1, imageCount: 0, imageSources: [] }] };
344
313
  }
345
314
 
346
315
  // Long: cover + article pages
347
316
  const coverTitle = generateCoverTitle(title);
348
- const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords);
317
+ const cover = await renderCover("poster-3-4", coverTitle, outputDir, vp, highlightWords, input.onProgress, input.coverTheme);
349
318
 
350
319
  const bodyImages = input.bodyImages ?? [];
351
320
  const pageResult = await renderLongformPages(input, outputDir, newspicRenderSpec, bodyImages, vp);
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { exportMarkdownToWechatHtml } from "../wechat-preview";
9
9
  import { findChrome } from "../imgx/runtime";
10
+ import { notifyProgress } from "../monitor/recorder";
10
11
  import type {
11
12
  MarkdownRenderPlugin,
12
13
  MarkdownRenderInput,
@@ -43,6 +44,7 @@ export const builtinMarkdownRenderer: MarkdownRenderPlugin = {
43
44
  },
44
45
 
45
46
  async render(input: MarkdownRenderInput): Promise<MarkdownRenderOutput> {
47
+ notifyProgress(input.onProgress, { stage: "render.html", message: "正在导出微信 HTML" });
46
48
  const result = await exportMarkdownToWechatHtml({
47
49
  markdownPath: input.markdownPath,
48
50
  outPath: input.outPath,
@@ -54,6 +56,7 @@ export const builtinMarkdownRenderer: MarkdownRenderPlugin = {
54
56
  timeoutMs: input.timeoutMs,
55
57
  debugDir: input.debugDir ?? undefined,
56
58
  });
59
+ notifyProgress(input.onProgress, { stage: "render.html", message: "微信 HTML 导出完成", current: 1, total: 1, unit: "files" });
57
60
 
58
61
  return {
59
62
  html: result.html,
package/src/cli.ts CHANGED
@@ -5,6 +5,7 @@ import { dirname, join } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
  import { getDailyLogPath, runWithCommandLog } from "./logger";
7
7
  import { formatUsage, getCommandRegistry } from "./plugins";
8
+ import { outcomeExitCode } from "./command-outcome";
8
9
 
9
10
  const COMMANDS = getCommandRegistry();
10
11
 
@@ -37,14 +38,19 @@ async function main() {
37
38
  }
38
39
 
39
40
  try {
40
- await runWithCommandLog(cmd, args.slice(1), async () => {
41
- await command.handler(args.slice(1));
42
- });
41
+ // monitor 输出包含本机认证令牌,不能经过日志复制或监控自身。
42
+ const outcome = cmd === "monitor"
43
+ ? await command.handler(args.slice(1))
44
+ : await runWithCommandLog(cmd, args.slice(1), () => command.handler(args.slice(1)));
45
+ if (outcome) {
46
+ process.exitCode = outcomeExitCode(outcome);
47
+ for (const error of outcome.errors ?? []) console.error(`[zzhub-pipeline ${cmd}] ${error.code}: ${error.message}`);
48
+ }
43
49
  } catch (err) {
44
50
  const msg = err instanceof Error ? err.message : String(err);
45
51
  console.error(`[zzhub-pipeline ${cmd}] Error: ${msg}`);
46
52
  console.error(`[zzhub-pipeline ${cmd}] Full log: ${getDailyLogPath()}`);
47
- process.exit(1);
53
+ process.exitCode = 1;
48
54
  }
49
55
  }
50
56
 
@@ -0,0 +1,50 @@
1
+ import type { PublishResult } from "./state";
2
+
3
+ /** 同一次执行共用的业务结果,独立于工作流是否完成。 */
4
+ export interface CommandOutcome {
5
+ status: "success" | "skipped" | "waiting" | "partial_failure" | "failed";
6
+ errors?: CommandError[];
7
+ }
8
+
9
+ /** 可供终端和监控展示的错误,不推测可重试性。 */
10
+ export interface CommandError {
11
+ code: string;
12
+ message: string;
13
+ stage?: string;
14
+ route?: string;
15
+ account?: string;
16
+ cause?: string;
17
+ }
18
+
19
+ /** 保留已有错误码和堆栈,未知异常使用统一兜底。 */
20
+ export function commandError(error: unknown, code = "COMMAND_FAILED"): CommandError {
21
+ return {
22
+ code: error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : code,
23
+ message: error instanceof Error ? error.message : String(error),
24
+ ...(error instanceof Error && error.stack ? { cause: error.stack } : {}),
25
+ };
26
+ }
27
+
28
+ /** 出口只使用 0/1;等待输入和幂等跳过都是正常完成命令。 */
29
+ export function outcomeExitCode(outcome: CommandOutcome): number {
30
+ return outcome.status === "failed" || outcome.status === "partial_failure" ? 1 : 0;
31
+ }
32
+
33
+ /** 仅汇总本次请求目标,避免历史失败污染新的追加发布。 */
34
+ export function publishOutcome(results: PublishResult[], skipped = false): CommandOutcome {
35
+ const failures = results.filter((result) => result.status === "failed");
36
+ if (failures.length) {
37
+ return {
38
+ status: results.some((result) => result.status === "success") ? "partial_failure" : "failed",
39
+ errors: failures.map((result) => ({
40
+ code: "PUBLISH_TARGET_FAILED",
41
+ message: result.detail || "发布失败,provider 未提供原因",
42
+ stage: "publish",
43
+ route: result.route,
44
+ account: result.account,
45
+ })),
46
+ };
47
+ }
48
+ if (results.some((result) => result.status === "handoff")) return { status: "waiting" };
49
+ return { status: skipped || !results.length || results.every((result) => result.status === "skipped") ? "skipped" : "success" };
50
+ }
@@ -17,6 +17,7 @@ import { parseArgs, optionalArg } from "../args";
17
17
  import { printResult, renderAbandon } from "../output";
18
18
  import { filterActiveTasks, getTaskByStatePath, listTasks, type ListedTask } from "../task-manager";
19
19
  import { updateState } from "../state";
20
+ import type { CommandOutcome } from "../command-outcome";
20
21
 
21
22
  // ── Result type ───────────────────────────────────────────────────────────────
22
23
 
@@ -146,7 +147,7 @@ async function runInteractive(items: ListedTask[]): Promise<ListedTask[]> {
146
147
 
147
148
  // ── Command entry point ───────────────────────────────────────────────────────
148
149
 
149
- export async function abandon(args: string[]): Promise<void> {
150
+ export async function abandon(args: string[]): Promise<void | CommandOutcome> {
150
151
  const parsed = parseArgs(args);
151
152
 
152
153
  if (parsed.help) {
@@ -184,7 +185,7 @@ Options:
184
185
  }
185
186
  const result = await abandonTask(task);
186
187
  printResult([result], renderAbandon);
187
- return;
188
+ return abandonOutcome([result]);
188
189
  }
189
190
 
190
191
  // ── Interactive: TTY checkbox ─────────────────────────────────────────────
@@ -199,4 +200,14 @@ Options:
199
200
 
200
201
  const results = await Promise.all(chosen.map(abandonTask));
201
202
  printResult(results, renderAbandon);
203
+ return abandonOutcome(results);
204
+ }
205
+
206
+ /** 批量操作保留成功项,失败项决定本次执行结果。 */
207
+ function abandonOutcome(results: AbandonResult[]): CommandOutcome {
208
+ const failed = results.filter((result) => !result.ok);
209
+ return {
210
+ status: failed.length ? (failed.length < results.length ? "partial_failure" : "failed") : "success",
211
+ errors: failed.map((result) => ({ code: "ABANDON_FAILED", message: result.error || "放弃任务失败" })),
212
+ };
202
213
  }
@@ -15,8 +15,9 @@ import { getTaskByStatePath } from "../task-manager";
15
15
  import { parseTaskViewMode, renderTaskStatusMarkdown } from "../task-views";
16
16
  import type { PhaseName } from "../state";
17
17
  import { validateForPhase } from "../state";
18
+ import type { CommandOutcome } from "../command-outcome";
18
19
 
19
- export async function checkpoint(args: string[]): Promise<void> {
20
+ export async function checkpoint(args: string[]): Promise<void | CommandOutcome> {
20
21
  const parsed = parseArgs(args);
21
22
 
22
23
  if (parsed.help) {
@@ -61,6 +62,6 @@ Options:
61
62
  }
62
63
 
63
64
  if (errors.length > 0) {
64
- process.exit(1);
65
+ return { status: "failed", errors: errors.map((error) => ({ code: "CHECKPOINT_FAILED", message: `${error.field}: ${error.message}`, stage: phase })) };
65
66
  }
66
67
  }
@@ -1,4 +1,7 @@
1
1
  import { readFileSync } from "fs";
2
+ import { z } from "zod";
3
+ import { CoverConfigSchema, mergeCoverSettings } from "../schema/cover-theme";
4
+ import { RenderBrandingSchema, RenderConfigSchema } from "../schema/config";
2
5
  import { parseArgs, optionalArg, flagArg } from "../args";
3
6
  import { printResult, renderConfig } from "../output";
4
7
  import {
@@ -29,6 +32,7 @@ Options:
29
32
  --json Force JSON output for scalar reads
30
33
  --export Print full config as JSON (secrets redacted; use --raw to show all)
31
34
  --import Path to a JSON file to merge into current config
35
+ --schema Print JSON Schema (--key render | render.cover | render.branding)
32
36
  --raw Show secrets unredacted (use with --export)
33
37
 
34
38
  Examples:
@@ -39,6 +43,14 @@ Examples:
39
43
  return;
40
44
  }
41
45
 
46
+ // schema 查询不依赖配置文件是否可用,便于 App 修复配置。
47
+ if (flagArg(parsed, "schema")) {
48
+ const schemas: Record<string, z.ZodType> = { render: RenderConfigSchema, "render.cover": CoverConfigSchema, "render.branding": RenderBrandingSchema };
49
+ const schema = schemas[optionalArg(parsed, "key") ?? ""];
50
+ if (!schema) throw new Error("--schema requires --key render | render.cover | render.branding");
51
+ printResult(z.toJSONSchema(schema), data => JSON.stringify(data, null, 2));
52
+ return;
53
+ }
42
54
  const config = loadConfig();
43
55
 
44
56
  // --export
@@ -95,6 +107,7 @@ Examples:
95
107
  ...(isPlainObject(importedObj.plugins) ? importedObj.plugins : {}),
96
108
  },
97
109
  imgx: { ...config.imgx, ...(isPlainObject(importedObj.imgx) ? importedObj.imgx : {}) },
110
+ render: mergeCoverSettings(config.render, importedObj.render ?? {}),
98
111
  };
99
112
  // Soft-fill known account display names + Zod defaults / strip unknowns
100
113
  const merged = normalizeConfig(raw);
@@ -118,7 +131,7 @@ Examples:
118
131
  const currentValue = getConfigValue(config, key);
119
132
  const redactedValue = redactConfigValue(key, currentValue);
120
133
  if (forceJson || typeof currentValue !== "string") {
121
- printResult({ key, value: redactedValue }, renderConfig);
134
+ printResult({ key, value: redactedValue }, forceJson ? data => JSON.stringify(data, null, 2) : renderConfig);
122
135
  return;
123
136
  }
124
137
  printResult(String(redactedValue), renderConfig);
@@ -6,7 +6,7 @@ import {
6
6
  } from "../imgx";
7
7
  import { ensureFonts } from "../runtime-paths";
8
8
 
9
- type ImgxHandler = (args: string[]) => void;
9
+ type ImgxHandler = (args: string[]) => unknown;
10
10
 
11
11
  const IMGX_COMMANDS: Record<string, ImgxHandler> = {
12
12
  "render-article": runRenderArticleCli,
@@ -28,9 +28,9 @@ export async function imgxCommand(args: string[]): Promise<void> {
28
28
  throw new Error(`Unknown imgx subcommand: ${subcommand}`);
29
29
  }
30
30
 
31
- await ensureFonts();
31
+ if (subcommand !== "render-card" && subcommand !== "render-article") await ensureFonts();
32
32
  try {
33
- handler(args.slice(1));
33
+ await handler(args.slice(1));
34
34
  } catch (err) {
35
35
  const msg = err instanceof Error ? err.message : String(err);
36
36
  if (msg.includes("@napi-rs/canvas") || msg.includes("Cannot find package")) {
@@ -5,6 +5,7 @@ import { parseArgs, optionalArg, requireArg } from "../args";
5
5
  import { loadConfig, resolveWorkspacePaths, resolveWorkspaceRoot } from "../config";
6
6
  import { printResult } from "../output";
7
7
  import { resolveFullRoute } from "../routes";
8
+ import { resolveCoverTheme } from "../schema/cover-theme";
8
9
  import {
9
10
  defaultContentReview,
10
11
  defaultState,
@@ -12,6 +13,7 @@ import {
12
13
  getRunStatePath,
13
14
  readResolvedState,
14
15
  resetDerivedState,
16
+ reenterRender,
15
17
  writeState,
16
18
  type ContentForm,
17
19
  type HandoffAuthoringPolicy,
@@ -23,6 +25,7 @@ import {
23
25
  import { findTask, getTaskByStatePath } from "../task-manager";
24
26
 
25
27
  interface PublishHandoffInput {
28
+ cover_theme?: string | null;
26
29
  content_form: ContentForm;
27
30
  body_path: string;
28
31
  target_account: string;
@@ -34,6 +37,7 @@ interface PublishHandoffInput {
34
37
  type WorkflowHandoffMode = "new" | "resume";
35
38
 
36
39
  interface WorkflowHandoffInput {
40
+ cover_theme?: string | null;
37
41
  mode?: WorkflowHandoffMode;
38
42
  state_path?: string;
39
43
  run_id?: string;
@@ -50,6 +54,7 @@ interface WorkflowHandoffInput {
50
54
  }
51
55
 
52
56
  interface ResolvedWorkflowHandoff {
57
+ cover_theme?: string | null;
53
58
  source: "publish_handoff" | "workflow_handoff";
54
59
  mode: WorkflowHandoffMode;
55
60
  state_path?: string;
@@ -162,6 +167,7 @@ function parsePublishHandoff(raw: unknown): ResolvedWorkflowHandoff {
162
167
 
163
168
  return {
164
169
  source: "publish_handoff",
170
+ cover_theme: input.cover_theme === null ? null : cleanString(input.cover_theme, "publish_handoff.cover_theme"),
165
171
  mode: "new",
166
172
  content_form: contentForm,
167
173
  body_path: bodyPath,
@@ -193,6 +199,7 @@ function parseWorkflowHandoff(raw: unknown): ResolvedWorkflowHandoff {
193
199
  : "new");
194
200
  const handoff: ResolvedWorkflowHandoff = {
195
201
  source: "workflow_handoff",
202
+ cover_theme: input.cover_theme === null ? null : cleanString(input.cover_theme, "workflow_handoff.cover_theme"),
196
203
  mode,
197
204
  state_path: cleanString(input.state_path, "workflow_handoff.state_path"),
198
205
  run_id: cleanString(input.run_id, "workflow_handoff.run_id"),
@@ -319,6 +326,11 @@ async function applyHandoffToState(
319
326
  ): Promise<void> {
320
327
  const targets: Target[] = state.intent.targets.length > 0 ? state.intent.targets : ["wechat"];
321
328
  let restartFromPrepare = false;
329
+ const themeChanged = handoff.cover_theme !== undefined && handoff.cover_theme !== state.intent.cover_theme;
330
+ if (themeChanged) {
331
+ resolveCoverTheme(loadConfig().render.cover, "poster-3-4", handoff.target_account ?? state.route.account, handoff.cover_theme);
332
+ state.intent.cover_theme = handoff.cover_theme ?? null;
333
+ }
322
334
 
323
335
  const nextContentForm = handoff.content_form ?? state.intent.content_form;
324
336
  if (handoff.content_form && handoff.content_form !== state.intent.content_form) {
@@ -409,6 +421,9 @@ async function applyHandoffToState(
409
421
 
410
422
  if (restartFromPrepare) {
411
423
  resetDerivedState(state);
424
+ } else if (themeChanged && state.asset_path && state.content_review.status === "passed") {
425
+ // 仅更换主题时保留正文、审核和 prepare 产物。
426
+ reenterRender(state);
412
427
  }
413
428
 
414
429
  if (state.handoff.review_policy === "trust_user" && state.source_body_path) {