@zzclub/pipeline 0.4.0 → 0.9.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 (48) hide show
  1. package/README.md +102 -3
  2. package/package.json +1 -1
  3. package/src/adapter-types.ts +6 -0
  4. package/src/adapters/builtin-image-renderer.ts +7 -1
  5. package/src/adapters/builtin-markdown-renderer.ts +1 -0
  6. package/src/commands/analytics.test.ts +155 -0
  7. package/src/commands/analytics.ts +223 -0
  8. package/src/commands/init.ts +42 -3
  9. package/src/commands/publish.ts +44 -67
  10. package/src/commands/republish.ts +146 -0
  11. package/src/commands/sync-blog.ts +19 -3
  12. package/src/commands/topic.test.ts +146 -0
  13. package/src/commands/topic.ts +717 -0
  14. package/src/commands/wechat-export.ts +2 -0
  15. package/src/commands/wx-draft-delete.ts +36 -0
  16. package/src/commands/wx-drafts.ts +47 -0
  17. package/src/config.ts +0 -0
  18. package/src/db.test.ts +47 -0
  19. package/src/db.ts +95 -0
  20. package/src/imgx/assets/icons/ancientone-logo.png +0 -0
  21. package/src/imgx/assets/templates/wechat-cover-split.html +1 -1
  22. package/src/imgx/render-card.ts +1 -5
  23. package/src/plugins.ts +11 -1
  24. package/src/providers/blog.ts +9 -3
  25. package/src/providers/index.test.ts +1 -1
  26. package/src/providers/index.ts +29 -5
  27. package/src/providers/publish-core.ts +124 -0
  28. package/src/providers/wechat.ts +260 -28
  29. package/src/routes.ts +1 -1
  30. package/src/schema/analytics.test.ts +54 -0
  31. package/src/schema/analytics.ts +19 -0
  32. package/src/schema/config.ts +25 -1
  33. package/src/schema/state.ts +10 -0
  34. package/src/schema/topic.test.ts +69 -0
  35. package/src/schema/topic.ts +55 -0
  36. package/src/state.ts +1 -0
  37. package/src/text.test.ts +11 -6
  38. package/src/text.ts +27 -9
  39. package/src/wechat-preview/assets/browser-dist/.vite/manifest.json +2 -7
  40. package/src/wechat-preview/assets/browser-dist/assets/{style-BYNXnUvc.css → style-vhHYSP8T.css} +1 -1
  41. package/src/wechat-preview/assets/browser-dist/editor-export.js +27 -27
  42. package/src/wechat-preview/browser/editor-export.css +248 -159
  43. package/src/wechat-preview/index.ts +28 -11
  44. package/src/wechat-preview/themes.ts +70 -24
  45. package/src/wechat-preview/wechat-formatter.test.ts +0 -1
  46. package/src/wechat-preview/wechat-formatter.ts +48 -23
  47. package/src/workflow.test.ts +294 -1
  48. package/src/wechat-preview/assets/browser-dist/assets/SweiCurveLegCJKsc-Regular-B3Ut5bwH.woff +0 -0
@@ -73,6 +73,16 @@ const THEMES: Record<string, WechatPreviewTheme> = {
73
73
  "--muted": "210 14% 96%",
74
74
  "--muted-foreground": "215 8% 42%",
75
75
  "--border": "220 10% 86%",
76
+ "--brand": "#ca6093",
77
+ "--brand-soft": "#d4789e",
78
+ "--brand-bg": "rgba(202, 96, 147, 0.08)",
79
+ "--brand-bg-deep": "rgba(202, 96, 147, 0.14)",
80
+ "--text": "#202124",
81
+ "--text-soft": "#5f6368",
82
+ "--text-mid": "#3c4043",
83
+ "--divider": "#dadce0",
84
+ "--bg-page": "#ffffff",
85
+ "--bg-warm": "#f8f9fa",
76
86
  },
77
87
  exportTheme: {
78
88
  containerStyle: buildContainerStyle({
@@ -89,9 +99,9 @@ const THEMES: Record<string, WechatPreviewTheme> = {
89
99
  "'SweiCurveLeg', 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
90
100
  bodyColor: "#202124",
91
101
  mutedColor: "#5f6368",
92
- primaryColor: "#111111",
102
+ primaryColor: "#ca6093",
93
103
  dividerColor: "#dadce0",
94
- blockquoteBorderColor: "#3c4043",
104
+ blockquoteBorderColor: "#ca6093",
95
105
  bodyLineHeight: "1.92",
96
106
  bodyLetterSpacing: "0.03em",
97
107
  },
@@ -101,39 +111,75 @@ const THEMES: Record<string, WechatPreviewTheme> = {
101
111
  account: "ancientone",
102
112
  editorVars: {
103
113
  ...BASE_EDITOR_VARS,
104
- "--primary": "330 50% 58%",
105
- "--ring": "330 50% 58%",
106
- "--accent": "330 35% 96%",
107
- "--accent-foreground": "334 28% 22%",
108
- "--muted": "336 38% 97%",
109
- "--border": "334 26% 88%",
114
+ "--primary": "337 38% 58%",
115
+ "--ring": "337 38% 58%",
116
+ "--accent": "337 30% 96%",
117
+ "--accent-foreground": "337 28% 22%",
118
+ "--muted": "338 25% 97%",
119
+ "--border": "337 20% 88%",
120
+ "--brand": "#ca6093",
121
+ "--brand-soft": "#d4789e",
122
+ "--brand-bg": "rgba(202, 96, 147, 0.08)",
123
+ "--brand-bg-deep": "rgba(202, 96, 147, 0.14)",
124
+ "--text": "#2c2c2c",
125
+ "--text-soft": "#8c8c8c",
126
+ "--text-mid": "#5c5c5c",
127
+ "--divider": "#d8d0d3",
128
+ "--bg-page": "#fefefe",
129
+ "--bg-warm": "#faf8f9",
110
130
  },
111
131
  exportTheme: {
112
132
  containerStyle: buildContainerStyle({
113
- background: "#fffafb",
114
- color: "#4a3a44",
133
+ background: "#fefefe",
134
+ color: "#2c2c2c",
115
135
  fontFamily:
116
- "'SweiCurveLeg', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
117
- lineHeight: "1.94",
118
- letterSpacing: "0.035em",
136
+ "'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', -apple-system, 'SF Pro Text', 'Noto Sans SC', sans-serif",
137
+ lineHeight: "2.0",
138
+ letterSpacing: "0.015em",
119
139
  }),
120
140
  footerText: "公众号 · 古一软件",
121
- footerStyle: buildFooterStyle("#b08a9d"),
141
+ footerStyle: buildFooterStyle("#8c8c8c"),
122
142
  fontFamily:
123
- "'SweiCurveLeg', 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
124
- bodyColor: "#4a3a44",
125
- mutedColor: "#7a6271",
126
- primaryColor: "#b35d85",
127
- dividerColor: "#ead5df",
128
- blockquoteBorderColor: "#d6a8bc",
129
- bodyLineHeight: "1.94",
130
- bodyLetterSpacing: "0.035em",
143
+ "'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', -apple-system, 'SF Pro Text', 'Noto Sans SC', sans-serif",
144
+ bodyColor: "#2c2c2c",
145
+ mutedColor: "#8c8c8c",
146
+ primaryColor: "#ca6093",
147
+ dividerColor: "#d8d0d3",
148
+ blockquoteBorderColor: "#ca6093",
149
+ bodyLineHeight: "2.0",
150
+ bodyLetterSpacing: "0.015em",
131
151
  },
132
152
  },
133
153
  };
134
154
 
135
- export function getWechatPreviewTheme(account: string): WechatPreviewTheme {
136
- return THEMES[account] ?? THEMES.default;
155
+ function definedValues<T extends Record<string, unknown>>(obj: T): Partial<T> {
156
+ const result: Record<string, unknown> = {};
157
+ for (const key of Object.keys(obj)) {
158
+ if (obj[key] !== undefined) {
159
+ result[key] = obj[key];
160
+ }
161
+ }
162
+ return result as Partial<T>;
163
+ }
164
+
165
+ export function getWechatPreviewTheme(
166
+ account: string,
167
+ overrides?: {
168
+ editorVars?: Record<string, string>;
169
+ exportTheme?: Partial<WechatExportTheme>;
170
+ },
171
+ ): WechatPreviewTheme {
172
+ const base = THEMES[account] ?? THEMES.default;
173
+ if (!overrides) return base;
174
+
175
+ const hasEditorVars = overrides.editorVars && Object.keys(overrides.editorVars).length > 0;
176
+ const hasExportTheme = overrides.exportTheme && Object.keys(definedValues(overrides.exportTheme)).length > 0;
177
+
178
+ return {
179
+ ...base,
180
+ editorVars: hasEditorVars ? { ...base.editorVars, ...overrides.editorVars } : base.editorVars,
181
+ exportTheme: hasExportTheme ? { ...base.exportTheme, ...definedValues(overrides.exportTheme!) } : base.exportTheme,
182
+ };
137
183
  }
138
184
 
139
185
  export function getWechatPreviewStyleName(account: string): string {
@@ -137,7 +137,6 @@ describe("normalizeHrStyles", () => {
137
137
  const input = '<hr style="border: 1px solid red">';
138
138
  const result = normalizeHrStyles(input);
139
139
  expect(result).toContain("border: none");
140
- expect(result).toContain("border-top: 1px solid #dadce0");
141
140
  expect(result).toContain("height: 0");
142
141
  });
143
142
  });
@@ -276,7 +276,6 @@ export function normalizeHrStyles(html: string): string {
276
276
  'background-color': 'transparent',
277
277
  'background-image': 'none',
278
278
  'border': 'none',
279
- 'border-top': '1px solid #dadce0',
280
279
  'overflow-x': 'visible',
281
280
  'font-size': '0',
282
281
  'line-height': '0',
@@ -486,32 +485,40 @@ function normalizeTypography(
486
485
  if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(originalTagName)) {
487
486
  clearTextFrame()
488
487
  setStyle(styles, 'font-family', fontFamily)
489
- setStyle(styles, 'font-weight', originalTagName === 'h3' ? '600' : '700')
490
- setStyle(styles, 'color', originalTagName === 'h1' ? bodyColor : primaryColor)
491
488
 
492
489
  if (originalTagName === 'h1') {
493
- setStyle(styles, 'font-size', '2.1em')
494
- setStyle(styles, 'line-height', '1.32')
495
- setStyle(styles, 'letter-spacing', '-0.02em')
496
- setStyle(styles, 'margin', '1.6em 0 0.7em')
490
+ setStyle(styles, 'font-weight', '700')
491
+ setStyle(styles, 'color', bodyColor)
492
+ setStyle(styles, 'font-size', '1.625rem')
493
+ setStyle(styles, 'line-height', '1.3')
494
+ setStyle(styles, 'letter-spacing', '-0.01em')
495
+ setStyle(styles, 'margin', '3em 0 1em')
497
496
  }
498
497
  else if (originalTagName === 'h2') {
499
- setStyle(styles, 'font-size', '18px')
500
- setStyle(styles, 'line-height', '1.4')
501
- setStyle(styles, 'letter-spacing', '-0.01em')
502
- setStyle(styles, 'margin', '1.45em 0 0.62em')
498
+ setStyle(styles, 'font-weight', '700')
499
+ setStyle(styles, 'color', primaryColor)
500
+ setStyle(styles, 'font-size', '1.25rem')
501
+ setStyle(styles, 'line-height', '1.35')
502
+ setStyle(styles, 'letter-spacing', '0.02em')
503
+ setStyle(styles, 'margin', '2.4em 0 0.9em')
503
504
  }
504
505
  else if (originalTagName === 'h3') {
505
- setStyle(styles, 'font-size', '1.14em')
506
- setStyle(styles, 'line-height', '1.58')
507
- setStyle(styles, 'letter-spacing', '0.01em')
508
- setStyle(styles, 'margin', '1.28em 0 0.52em')
506
+ setStyle(styles, 'font-weight', '600')
507
+ setStyle(styles, 'color', bodyColor)
508
+ setStyle(styles, 'font-size', '1.125rem')
509
+ setStyle(styles, 'line-height', '1.45')
510
+ setStyle(styles, 'letter-spacing', '0.015em')
511
+ setStyle(styles, 'margin', '2em 0 0.65em')
512
+ setStyle(styles, 'padding-bottom', '0.5em')
513
+ setStyle(styles, 'border-bottom', `1px solid ${dividerColor}`)
509
514
  }
510
515
  else {
511
- setStyle(styles, 'font-size', '1em')
512
- setStyle(styles, 'line-height', '1.65')
516
+ setStyle(styles, 'font-weight', '600')
517
+ setStyle(styles, 'color', bodyColor)
518
+ setStyle(styles, 'font-size', '1rem')
519
+ setStyle(styles, 'line-height', '1.5')
513
520
  setStyle(styles, 'letter-spacing', '0.02em')
514
- setStyle(styles, 'margin', '1.15em 0 0.45em')
521
+ setStyle(styles, 'margin', '1.25em 0 0.5em')
515
522
  }
516
523
  return
517
524
  }
@@ -557,6 +564,17 @@ function normalizeTypography(
557
564
  return
558
565
  }
559
566
 
567
+ if (outTagName === 'figcaption') {
568
+ clearTextFrame()
569
+ setStyle(styles, 'font-family', fontFamily)
570
+ setStyle(styles, 'font-size', '12px')
571
+ setStyle(styles, 'line-height', '1.6')
572
+ setStyle(styles, 'color', mutedColor)
573
+ setStyle(styles, 'text-align', 'center')
574
+ setStyle(styles, 'margin', '8px 0 0 0')
575
+ return
576
+ }
577
+
560
578
  if (outTagName === 'span' || outTagName === 'strong' || outTagName === 'em' || outTagName === 'b' || outTagName === 'i' || outTagName === 'u' || outTagName === 's' || outTagName === 'del') {
561
579
  clearTextFrame()
562
580
  setStyle(styles, 'font-family', fontFamily)
@@ -662,13 +680,20 @@ function getOneDomCssStyle(
662
680
  const textParent = node.parentElement
663
681
  const shouldPreserveWhitespace = isInCodeBlock || hasBlockCodeAncestor(textParent)
664
682
  // 在代码块内,保留所有空格和换行
665
- // 将所有空格转换为 &nbsp; 以防止被 HTML 合并
683
+ // 先转义 HTML 特殊字符,防止代码块内的 <img>, ![ ]( ) 等被下游正则误匹配
666
684
  if (shouldPreserveWhitespace && text) {
667
685
  return text
668
- .replace(/ /g, '\u00A0') // 所有空格转为 &nbsp;
669
- .replace(/\t/g, '\u00A0\u00A0\u00A0\u00A0') // 制表符转为 4 个 &nbsp;
686
+ .replace(/&/g, '&amp;')
687
+ .replace(/</g, '&lt;')
688
+ .replace(/>/g, '&gt;')
689
+ .replace(/ /g, '\u00A0')
690
+ .replace(/\t/g, '\u00A0\u00A0\u00A0\u00A0')
670
691
  }
671
692
  return text
693
+ .replace(/&/g, '&amp;')
694
+ .replace(/</g, '&lt;')
695
+ .replace(/>/g, '&gt;')
696
+
672
697
  }
673
698
 
674
699
  // 注释节点
@@ -874,8 +899,8 @@ function getOneDomCssStyle(
874
899
  const val = (el as HTMLInputElement).value
875
900
  if (!val)
876
901
  return ''
877
- // 返回居中的 figcaption
878
- return `<figcaption style="margin-top: 6px; text-align: center; color: #888; font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">${val}</figcaption>`
902
+ // 返回居中的 figcaption(小号文字,无装饰符号)
903
+ return `<figcaption style="margin-top: 8px; text-align: center; color: #8c8c8c; font-size: 12px; line-height: 1.6;">${val}</figcaption>`
879
904
  }
880
905
  return ''
881
906
  }
@@ -14,6 +14,7 @@ import { prepare } from "./commands/prepare";
14
14
  import { prepareFinalize } from "./commands/prepare-finalize";
15
15
  import { render } from "./commands/render";
16
16
  import { publish } from "./commands/publish";
17
+ import { republish } from "./commands/republish";
17
18
  import { reconcile } from "./commands/reconcile";
18
19
  import { review } from "./commands/review";
19
20
  import { status } from "./commands/status";
@@ -22,8 +23,11 @@ import { tasks } from "./commands/tasks";
22
23
  import { renderPostsRelativePath, resolveWorkspacePaths, resolveWorkspaceRoot } from "./config";
23
24
  import { resolveFullRoute } from "./routes";
24
25
  import { resolveAuthoring } from "./profiles";
26
+ import { WorkflowStateSchema } from "./schema/state";
25
27
  import { defaultState, readState, validateForPhase, writeState } from "./state";
26
28
  import { getTaskByStatePath } from "./task-manager";
29
+ import { dedupeTargets, filterIdempotent } from "./providers/publish-core";
30
+ import { getPublishProvider, listPublishProviders } from "./providers";
27
31
  import { stripLeadingH1, stripLeadingTitleHeading } from "./text";
28
32
 
29
33
  const TEST_CONFIG_PATH = join(
@@ -1211,6 +1215,7 @@ describe("config", () => {
1211
1215
  appId: "",
1212
1216
  appSecret: "",
1213
1217
  customCss: null,
1218
+ theme: { editorVars: {}, exportTheme: {} },
1214
1219
  },
1215
1220
  },
1216
1221
  },
@@ -1336,7 +1341,7 @@ describe("publish validation", () => {
1336
1341
  expect(updated.phase.current).toBe("publish");
1337
1342
  expect(updated.phase.publish.status).toBe("pending");
1338
1343
  expect(updated.publish.results[0]?.status).toBe("failed");
1339
- expect(updated.publish.results[0]?.detail).toContain("Unsupported workflow publish route: blog");
1344
+ expect(updated.publish.results[0]?.detail).toContain("blog publish command failed");
1340
1345
 
1341
1346
  const task = await getTaskByStatePath(statePath);
1342
1347
  expect(task.blockers).toEqual([]);
@@ -2085,3 +2090,291 @@ describe("newspic_render default behavior", () => {
2085
2090
  expect(finalState.intent.newspic_render?.pagination_mode ?? "single").toBe("single");
2086
2091
  });
2087
2092
  });
2093
+
2094
+ describe("publish_targets and multi-account", () => {
2095
+ test("PublishResult schema accepts account field with default", () => {
2096
+ const state = WorkflowStateSchema.parse({});
2097
+ // publish.results should accept items with account field
2098
+ const result = {
2099
+ route: "wechat-article" as const,
2100
+ account: "ancientone",
2101
+ status: "success" as const,
2102
+ detail: null,
2103
+ published_at: "2026-06-14T10:00:00Z",
2104
+ content_version: 1,
2105
+ render_version: 1,
2106
+ };
2107
+ state.publish.results.push(result);
2108
+ const parsed = WorkflowStateSchema.parse(state);
2109
+ expect(parsed.publish.results[0].account).toBe("ancientone");
2110
+ });
2111
+
2112
+ test("PublishResult defaults account to 'default' when missing", () => {
2113
+ const result = {
2114
+ route: "wechat-article",
2115
+ status: "success",
2116
+ detail: null,
2117
+ published_at: null,
2118
+ content_version: 0,
2119
+ render_version: 0,
2120
+ };
2121
+ const state = WorkflowStateSchema.parse({
2122
+ publish: { results: [result] },
2123
+ });
2124
+ expect(state.publish.results[0].account).toBe("default");
2125
+ });
2126
+
2127
+ test("publish_targets field defaults to empty array", () => {
2128
+ const state = WorkflowStateSchema.parse({});
2129
+ expect(state.publish_targets).toEqual([]);
2130
+ });
2131
+
2132
+ test("publish_targets accepts array of {route, account}", () => {
2133
+ const state = WorkflowStateSchema.parse({
2134
+ publish_targets: [
2135
+ { route: "wechat-article", account: "default" },
2136
+ { route: "wechat-article", account: "ancientone" },
2137
+ { route: "blog", account: "default" },
2138
+ ],
2139
+ });
2140
+ expect(state.publish_targets).toHaveLength(3);
2141
+ expect(state.publish_targets[1].account).toBe("ancientone");
2142
+ });
2143
+
2144
+ test("publish_targets account defaults to 'default' when omitted", () => {
2145
+ const state = WorkflowStateSchema.parse({
2146
+ publish_targets: [{ route: "blog" }],
2147
+ });
2148
+ expect(state.publish_targets[0].account).toBe("default");
2149
+ });
2150
+ });
2151
+
2152
+ describe("provider accountOverride", () => {
2153
+ test("PublishRouteContext accepts accountOverride", () => {
2154
+ // Type-level test: this should compile
2155
+ const ctx: import("./providers").PublishRouteContext = {
2156
+ state: {} as any,
2157
+ dryRun: true,
2158
+ config: {} as any,
2159
+ workspacePaths: {} as any,
2160
+ accountOverride: "ancientone",
2161
+ };
2162
+ expect(ctx.accountOverride).toBe("ancientone");
2163
+ });
2164
+ });
2165
+
2166
+ describe("PUBLISH_PROVIDERS", () => {
2167
+ test("blog is registered as publish provider", () => {
2168
+ const provider = getPublishProvider("blog");
2169
+ expect(provider).toBeDefined();
2170
+ expect(typeof provider).toBe("function");
2171
+ });
2172
+
2173
+ test("listPublishProviders includes blog", () => {
2174
+ const providers = listPublishProviders();
2175
+ expect(providers).toContain("blog");
2176
+ });
2177
+ });
2178
+
2179
+ describe("executePublishTargets helpers", () => {
2180
+ test("dedupeTargets deduplicates by route+account", () => {
2181
+ const targets = [
2182
+ { route: "wechat-article" as const, account: "default" },
2183
+ { route: "wechat-article" as const, account: "default" }, // duplicate
2184
+ { route: "blog" as const, account: "default" },
2185
+ ];
2186
+ const deduped = dedupeTargets(targets);
2187
+ expect(deduped).toHaveLength(2);
2188
+ });
2189
+
2190
+ test("filterIdempotent filters out already-published targets", () => {
2191
+ const targets = [
2192
+ { route: "wechat-article" as const, account: "default" },
2193
+ { route: "blog" as const, account: "default" },
2194
+ ];
2195
+ const existingResults = [
2196
+ {
2197
+ route: "wechat-article" as const,
2198
+ account: "default",
2199
+ status: "success" as const,
2200
+ detail: null,
2201
+ published_at: "2026-06-14T10:00:00Z",
2202
+ content_version: 1,
2203
+ render_version: 1,
2204
+ },
2205
+ ];
2206
+ const filtered = filterIdempotent(targets, existingResults, 1, 1);
2207
+ expect(filtered).toHaveLength(1);
2208
+ expect(filtered[0].route).toBe("blog");
2209
+ });
2210
+ });
2211
+
2212
+ describe("publish command with publish_targets", () => {
2213
+ test("publish iterates publish_targets when present", async () => {
2214
+ const tmpDir = await makeTempDir("zzhub-test-publish-targets-");
2215
+ const statePath = join(tmpDir, "state.json");
2216
+ const state = defaultState();
2217
+ state.run_id = "test-run";
2218
+ state.workspace_root = tmpDir;
2219
+ state.state_path = statePath;
2220
+ state.asset_path = tmpDir;
2221
+ state.route.primary = "wechat-article";
2222
+ state.route.account = "default";
2223
+ state.publish_targets = [
2224
+ { route: "wechat-article", account: "default" },
2225
+ { route: "wechat-newspic", account: "ancientone" },
2226
+ ];
2227
+ state.metadata.title = "Test";
2228
+ state.metadata.slug = "test";
2229
+ state.metadata.date = "2026-06-14";
2230
+ state.content_review.status = "passed";
2231
+ // post.md needed by wechat-newspic provider (read before dry-run check)
2232
+ await writeFile(join(tmpDir, "post.md"), "正文", "utf-8");
2233
+ await writeState(statePath, state);
2234
+
2235
+ await publish(["--state", statePath, "--dry-run"]);
2236
+
2237
+ const finalState = await readState(statePath);
2238
+ expect(finalState.publish.results).toHaveLength(2);
2239
+ expect(finalState.publish.results[0].account).toBe("default");
2240
+ expect(finalState.publish.results[0].status).toBe("skipped");
2241
+ expect(finalState.publish.results[1].account).toBe("ancientone");
2242
+ expect(finalState.publish.results[1].status).toBe("skipped");
2243
+ });
2244
+ });
2245
+
2246
+ describe("init with multi-target --targets", () => {
2247
+ test("single target leaves publish_targets empty", async () => {
2248
+ const tmpDir = await makeTempDir("zzhub-test-init-single-");
2249
+ const output = await captureJsonOutput<any>(() =>
2250
+ init([
2251
+ "--workspace", tmpDir,
2252
+ "--task-kind", "publish",
2253
+ "--content-form", "article",
2254
+ "--targets", "wechat-article",
2255
+ "--content-origin", "user",
2256
+ "--account", "default",
2257
+ ]),
2258
+ );
2259
+ const state = await readState(output.state_path);
2260
+ expect(state.publish_targets).toEqual([]);
2261
+ expect(state.route.primary).toBe("wechat-article");
2262
+ expect(state.route.account).toBe("default");
2263
+ });
2264
+
2265
+ test("multi-target populates publish_targets", async () => {
2266
+ const tmpDir = await makeTempDir("zzhub-test-init-multi-");
2267
+ const output = await captureJsonOutput<any>(() =>
2268
+ init([
2269
+ "--workspace", tmpDir,
2270
+ "--task-kind", "publish",
2271
+ "--content-form", "article",
2272
+ "--targets", "wechat-article@default,wechat-article@ancientone,blog@default",
2273
+ "--content-origin", "user",
2274
+ ]),
2275
+ );
2276
+ const state = await readState(output.state_path);
2277
+ expect(state.publish_targets).toHaveLength(3);
2278
+ expect(state.publish_targets[0]).toEqual({ route: "wechat-article", account: "default" });
2279
+ expect(state.publish_targets[1]).toEqual({ route: "wechat-article", account: "ancientone" });
2280
+ expect(state.publish_targets[2]).toEqual({ route: "blog", account: "default" });
2281
+ expect(state.route.primary).toBe("wechat-article");
2282
+ expect(state.route.account).toBe("default");
2283
+ });
2284
+
2285
+ test("--targets without @ uses --account value", async () => {
2286
+ const tmpDir = await makeTempDir("zzhub-test-init-no-at-");
2287
+ const output = await captureJsonOutput<any>(() =>
2288
+ init([
2289
+ "--workspace", tmpDir,
2290
+ "--task-kind", "publish",
2291
+ "--content-form", "article",
2292
+ "--targets", "wechat-article,blog",
2293
+ "--content-origin", "user",
2294
+ "--account", "ancientone",
2295
+ ]),
2296
+ );
2297
+ const state = await readState(output.state_path);
2298
+ expect(state.publish_targets).toHaveLength(2);
2299
+ expect(state.publish_targets[0].account).toBe("ancientone");
2300
+ expect(state.publish_targets[1].account).toBe("ancientone");
2301
+ });
2302
+ });
2303
+
2304
+ describe("republish command", () => {
2305
+ test("republish adds targets and executes", async () => {
2306
+ const tmpDir = await makeTempDir("zzhub-test-republish-");
2307
+ const statePath = join(tmpDir, "state.json");
2308
+ const state = defaultState();
2309
+ state.run_id = "test-run";
2310
+ state.workspace_root = tmpDir;
2311
+ state.asset_path = tmpDir;
2312
+ state.route.primary = "wechat-article";
2313
+ state.route.account = "default";
2314
+ state.metadata.title = "Test";
2315
+ state.metadata.slug = "test";
2316
+ state.metadata.date = "2026-06-14";
2317
+ state.content_review.status = "passed";
2318
+ state.mode = "done";
2319
+ state.publish.results = [
2320
+ {
2321
+ route: "wechat-article",
2322
+ account: "default",
2323
+ status: "success",
2324
+ detail: null,
2325
+ published_at: "2026-06-14T10:00:00Z",
2326
+ content_version: 1,
2327
+ render_version: 1,
2328
+ },
2329
+ ];
2330
+ await writeState(statePath, state);
2331
+
2332
+ // Republish to another account with dry-run
2333
+ await republish(["--state", statePath, "--account", "ancientone", "--dry-run"]);
2334
+
2335
+ const finalState = await readState(statePath);
2336
+ expect(finalState.publish_targets).toContainEqual({
2337
+ route: "wechat-article",
2338
+ account: "ancientone",
2339
+ });
2340
+ expect(finalState.publish.results).toHaveLength(2);
2341
+ expect(finalState.mode).toBe("done");
2342
+ });
2343
+
2344
+ test("republish skips idempotent targets", async () => {
2345
+ const tmpDir = await makeTempDir("zzhub-test-republish-idem-");
2346
+ const statePath = join(tmpDir, "state.json");
2347
+ const state = defaultState();
2348
+ state.run_id = "test-run";
2349
+ state.workspace_root = tmpDir;
2350
+ state.asset_path = tmpDir;
2351
+ state.route.primary = "wechat-article";
2352
+ state.route.account = "default";
2353
+ state.metadata.title = "Test";
2354
+ state.metadata.slug = "test";
2355
+ state.metadata.date = "2026-06-14";
2356
+ state.content_review.status = "passed";
2357
+ state.mode = "done";
2358
+ state.artifacts.content_version = 1;
2359
+ state.artifacts.render_version = 1;
2360
+ state.publish.results = [
2361
+ {
2362
+ route: "wechat-article",
2363
+ account: "default",
2364
+ status: "success",
2365
+ detail: null,
2366
+ published_at: "2026-06-14T10:00:00Z",
2367
+ content_version: 1,
2368
+ render_version: 1,
2369
+ },
2370
+ ];
2371
+ await writeState(statePath, state);
2372
+
2373
+ // Try to republish same target - should skip
2374
+ await republish(["--state", statePath, "--account", "default", "--dry-run"]);
2375
+
2376
+ const finalState = await readState(statePath);
2377
+ // Should still have only 1 result (skipped duplicate)
2378
+ expect(finalState.publish.results).toHaveLength(1);
2379
+ });
2380
+ });