@zzclub/pipeline 0.10.2 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -72
- package/package.json +1 -1
- package/src/adapter-types.ts +5 -0
- package/src/adapters/builtin-image-renderer.ts +7 -6
- package/src/adapters/builtin-markdown-renderer.ts +3 -0
- package/src/cli.ts +10 -4
- package/src/command-outcome.ts +50 -0
- package/src/commands/abandon.ts +13 -2
- package/src/commands/checkpoint.ts +3 -2
- package/src/commands/config.ts +47 -20
- package/src/commands/monitor.ts +21 -0
- package/src/commands/prepare-finalize.ts +2 -0
- package/src/commands/prepare.ts +3 -0
- package/src/commands/publish.ts +5 -2
- package/src/commands/render.ts +7 -3
- package/src/commands/republish.ts +4 -1
- package/src/commands/sync-blog.ts +4 -2
- package/src/commands/wechat-export.ts +6 -3
- package/src/config.ts +21 -2
- package/src/imgx/render-article.ts +8 -1
- package/src/imgx/render-card.ts +6 -1
- package/src/imgx/runtime.ts +1 -1
- package/src/logger.test.ts +28 -1
- package/src/logger.ts +39 -26
- package/src/monitor/client.ts +60 -0
- package/src/monitor/recorder.ts +142 -0
- package/src/monitor/runtime.ts +58 -0
- package/src/monitor/server.ts +170 -0
- package/src/monitor/store.ts +235 -0
- package/src/monitor/types.ts +77 -0
- package/src/monitor.test.ts +241 -0
- package/src/plugins.ts +4 -5
- package/src/providers/blog.ts +4 -1
- package/src/providers/cos.ts +3 -0
- package/src/providers/index.ts +7 -3
- package/src/providers/publish-core.ts +7 -1
- package/src/providers/wechat.ts +7 -2
- package/src/schema/config.account-name.test.ts +79 -0
- package/src/schema/config.ts +9 -3
- package/src/spawn.ts +4 -2
- package/src/state.ts +4 -0
- package/src/workflow.test.ts +3 -2
- package/src/commands/analytics.test.ts +0 -156
- package/src/commands/analytics.ts +0 -240
- package/src/commands/topic.test.ts +0 -146
- package/src/commands/topic.ts +0 -717
- package/src/db.test.ts +0 -47
- package/src/db.ts +0 -95
- package/src/schema/analytics.test.ts +0 -54
- package/src/schema/analytics.ts +0 -19
- package/src/schema/topic.test.ts +0 -69
- package/src/schema/topic.ts +0 -55
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
2
|
+
import { appendFile, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { defaultState, writeState } from "./state";
|
|
6
|
+
import { runWithCommandLog } from "./logger";
|
|
7
|
+
import { notifyProgress, reportLog, reportProgress } from "./monitor/recorder";
|
|
8
|
+
import { MonitorStore } from "./monitor/store";
|
|
9
|
+
import { serveMonitor } from "./monitor/server";
|
|
10
|
+
import { LOG_LIMIT } from "./monitor/runtime";
|
|
11
|
+
import type { MonitorDescriptor, MonitorSnapshot } from "./monitor/types";
|
|
12
|
+
|
|
13
|
+
let root = "";
|
|
14
|
+
let stop: (() => void) | undefined;
|
|
15
|
+
const previousEnv = { ...process.env };
|
|
16
|
+
const cli = resolve(import.meta.dir, "cli.ts");
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
root = await mkdtemp(join(tmpdir(), "zzp-monitor-test-"));
|
|
20
|
+
process.env.ZZHUB_PIPELINE_MONITOR_DIR = join(root, "monitor");
|
|
21
|
+
process.env.ZZHUB_PIPELINE_LOG_DIR = join(root, "logs");
|
|
22
|
+
process.env.ZZHUB_PIPELINE_CONFIG = join(root, "config.json");
|
|
23
|
+
process.env.ZZHUB_PIPELINE_MONITOR = "1";
|
|
24
|
+
process.env.NO_COLOR = "1";
|
|
25
|
+
await writeFile(process.env.ZZHUB_PIPELINE_CONFIG, "{}");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
stop?.();
|
|
30
|
+
stop = undefined;
|
|
31
|
+
for (const key of ["ZZHUB_PIPELINE_MONITOR_DIR", "ZZHUB_PIPELINE_LOG_DIR", "ZZHUB_PIPELINE_CONFIG", "ZZHUB_PIPELINE_MONITOR", "NO_COLOR"]) {
|
|
32
|
+
if (previousEnv[key] === undefined) delete process.env[key];
|
|
33
|
+
else process.env[key] = previousEnv[key];
|
|
34
|
+
}
|
|
35
|
+
await rm(root, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
async function run(args: string[]) {
|
|
39
|
+
const child = Bun.spawn([process.execPath, cli, ...args], { env: { ...process.env }, stdout: "pipe", stderr: "pipe" });
|
|
40
|
+
const [stdout, stderr, exitCode] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
|
|
41
|
+
return { stdout, stderr, exitCode };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function seed(workspace = root, runId = "same-run") {
|
|
45
|
+
const asset = join(workspace, "posts", "test");
|
|
46
|
+
await mkdir(asset, { recursive: true });
|
|
47
|
+
await writeFile(join(asset, "post.md"), "# 测试\n\n正文");
|
|
48
|
+
const state = defaultState();
|
|
49
|
+
state.run_id = runId;
|
|
50
|
+
state.workspace_root = workspace;
|
|
51
|
+
state.state_path = join(asset, "workflow-state.json");
|
|
52
|
+
state.asset_path = asset;
|
|
53
|
+
state.metadata = { ...state.metadata, title: "测试", slug: "test", date: "2026-09-06" };
|
|
54
|
+
state.content_review.status = "passed";
|
|
55
|
+
state.intent.requires.render = false;
|
|
56
|
+
state.intent.requires.publish = true;
|
|
57
|
+
state.route.primary = "blog";
|
|
58
|
+
state.phase.current = "publish";
|
|
59
|
+
state.phase.prepare.status = "done";
|
|
60
|
+
state.phase.render.status = "done";
|
|
61
|
+
await writeState(state.state_path, state);
|
|
62
|
+
return state;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function blogCommand(script: string) {
|
|
66
|
+
await writeFile(process.env.ZZHUB_PIPELINE_CONFIG!, JSON.stringify({ paths: { workspaceRoot: root, blogRoot: join(root, "blog") }, commands: { blogPublish: [process.execPath, "-e", script] } }));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function headers(descriptor: MonitorDescriptor) { return { Authorization: `Bearer ${descriptor.token}` }; }
|
|
70
|
+
|
|
71
|
+
test("CLI partial failures persist, exit 1, keep JSON stdout and retry only failed targets", async () => {
|
|
72
|
+
const state = await seed();
|
|
73
|
+
state.publish_targets = [{ route: "blog", account: "first" }, { route: "blog", account: "second" }];
|
|
74
|
+
await writeState(state.state_path!, state);
|
|
75
|
+
await blogCommand("const fs = require('fs'); console.log('child diagnostic'); if (fs.existsSync('attempt')) process.exit(7); fs.writeFileSync('attempt', 'ok');");
|
|
76
|
+
const failed = await run(["publish", "--state", state.state_path!]);
|
|
77
|
+
expect(failed.exitCode).toBe(1);
|
|
78
|
+
const result = JSON.parse(failed.stdout);
|
|
79
|
+
expect(result.publish_results.map((item: { status: string }) => item.status)).toEqual(["success", "failed"]);
|
|
80
|
+
expect(result.errors[0].error).toContain("exit=7");
|
|
81
|
+
expect(failed.stderr).toContain("child diagnostic");
|
|
82
|
+
expect(failed.stderr).toContain("PUBLISH_TARGET_FAILED");
|
|
83
|
+
const saved = JSON.parse(await readFile(state.state_path!, "utf8"));
|
|
84
|
+
expect(saved.mode).toBe("active");
|
|
85
|
+
expect(saved.phase.publish.status).toBe("pending");
|
|
86
|
+
const store = new MonitorStore("test");
|
|
87
|
+
await store.refresh();
|
|
88
|
+
expect(store.snapshot().executions[0]?.outcome?.status).toBe("partial_failure");
|
|
89
|
+
await blogCommand("require('fs').appendFileSync('retried', 'once');");
|
|
90
|
+
expect((await run(["publish", "--state", state.state_path!])).exitCode).toBe(0);
|
|
91
|
+
expect(await readFile(join(root, "blog", "retried"), "utf8")).toBe("once");
|
|
92
|
+
expect((await run(["publish", "--state", state.state_path!])).exitCode).toBe(0);
|
|
93
|
+
expect(await readFile(join(root, "blog", "retried"), "utf8")).toBe("once");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("checkpoint failure exits through common cleanup; status and dry-run still exit 0", async () => {
|
|
97
|
+
const state = await seed();
|
|
98
|
+
state.content_review.status = "unchecked";
|
|
99
|
+
await writeState(state.state_path!, state);
|
|
100
|
+
const checkpoint = await run(["checkpoint", "--state", state.state_path!]);
|
|
101
|
+
expect(checkpoint.exitCode).toBe(1);
|
|
102
|
+
expect(JSON.parse(checkpoint.stdout).validation.valid).toBe(false);
|
|
103
|
+
expect((await run(["status", "--state", state.state_path!])).exitCode).toBe(0);
|
|
104
|
+
state.content_review.status = "passed";
|
|
105
|
+
await writeState(state.state_path!, state);
|
|
106
|
+
await blogCommand("process.exit(9)");
|
|
107
|
+
expect((await run(["publish", "--state", state.state_path!, "--dry-run"])).exitCode).toBe(0);
|
|
108
|
+
const store = new MonitorStore("test");
|
|
109
|
+
await store.refresh();
|
|
110
|
+
expect(store.snapshot().executions.find((execution) => execution.command === "checkpoint")?.status).toBe("exited");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("20 parallel CLI calls preserve execution identities and separate identical run IDs across workspaces", async () => {
|
|
114
|
+
const a = await seed(join(root, "a"));
|
|
115
|
+
const b = await seed(join(root, "b"));
|
|
116
|
+
const results = await Promise.all(Array.from({ length: 20 }, (_, index) => run(["status", "--state", (index % 2 ? a : b).state_path!])));
|
|
117
|
+
expect(results.every((result) => result.exitCode === 0)).toBe(true);
|
|
118
|
+
const store = new MonitorStore("test");
|
|
119
|
+
await store.refresh();
|
|
120
|
+
expect(store.snapshot().executions).toHaveLength(20);
|
|
121
|
+
expect(store.snapshot().tasks).toHaveLength(2);
|
|
122
|
+
expect(store.snapshot({ workspace: a.workspace_root }).executions).toHaveLength(10);
|
|
123
|
+
expect(new Set(store.snapshot().executions.map((execution) => execution.id)).size).toBe(20);
|
|
124
|
+
}, 15_000);
|
|
125
|
+
|
|
126
|
+
test("recording failure and optional callback failure cannot change command results", async () => {
|
|
127
|
+
const file = join(root, "not-a-directory");
|
|
128
|
+
await writeFile(file, "x");
|
|
129
|
+
process.env.ZZHUB_PIPELINE_MONITOR_DIR = file;
|
|
130
|
+
const state = await seed();
|
|
131
|
+
expect((await run(["status", "--state", state.state_path!])).exitCode).toBe(0);
|
|
132
|
+
expect(() => notifyProgress(() => { throw new Error("observer unavailable"); }, { stage: "test" })).not.toThrow();
|
|
133
|
+
await expect(runWithCommandLog("render", [], async () => { throw new Error("original failure"); })).rejects.toThrow("original failure");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("logs redact secrets, preserve UTF-8 and retain terminal errors after truncation", async () => {
|
|
137
|
+
const message = "中文".repeat(2000);
|
|
138
|
+
await runWithCommandLog("render", ["--pat", "private-pat"], async () => {
|
|
139
|
+
reportLog("info", "Authorization: Bearer private-token ?access_token=another-secret");
|
|
140
|
+
for (let index = 0; index < 1100; index++) reportLog("debug", message);
|
|
141
|
+
reportProgress({ stage: "render.pages", current: 3, total: 3, unit: "pages" });
|
|
142
|
+
return { status: "failed", errors: [{ code: "RENDER_FAILED", message: "最终错误" }] };
|
|
143
|
+
});
|
|
144
|
+
const store = new MonitorStore("test");
|
|
145
|
+
await store.refresh();
|
|
146
|
+
const execution = store.snapshot().executions[0]!;
|
|
147
|
+
expect(execution.logs_truncated).toBe(true);
|
|
148
|
+
expect(store.eventsAfter("test:0")).toBeNull();
|
|
149
|
+
expect(execution.outcome?.errors?.[0]?.message).toBe("最终错误");
|
|
150
|
+
const logs = await store.logs(execution.id, 0, 30);
|
|
151
|
+
expect(JSON.stringify(logs)).not.toContain("private-token");
|
|
152
|
+
expect(JSON.stringify(logs)).not.toContain("another-secret");
|
|
153
|
+
expect(JSON.stringify(logs)).not.toContain("�");
|
|
154
|
+
const raw = await readFile(join(process.env.ZZHUB_PIPELINE_MONITOR_DIR!, "events", `${execution.id}.jsonl`), "utf8");
|
|
155
|
+
expect(raw).not.toContain("private-pat");
|
|
156
|
+
expect(Buffer.byteLength(raw)).toBeLessThan(LOG_LIMIT + 300_000);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("incomplete final records resume on the next read and state aliases keep one task", async () => {
|
|
160
|
+
await runWithCommandLog("prepare-finalize", [], async () => {
|
|
161
|
+
const state = await seed();
|
|
162
|
+
await writeState(join(root, "temporary-state.json"), state);
|
|
163
|
+
});
|
|
164
|
+
const eventDir = join(process.env.ZZHUB_PIPELINE_MONITOR_DIR!, "events");
|
|
165
|
+
const file = join(eventDir, (await readdir(eventDir))[0]!);
|
|
166
|
+
const raw = await readFile(file, "utf8");
|
|
167
|
+
const lines = raw.trimEnd().split("\n");
|
|
168
|
+
const final = lines.pop()!;
|
|
169
|
+
await writeFile(file, `${lines.join("\n")}\ninvalid-event\n${final.slice(0, -5)}`);
|
|
170
|
+
const store = new MonitorStore("test");
|
|
171
|
+
await store.refresh();
|
|
172
|
+
expect(store.snapshot().tasks).toHaveLength(1);
|
|
173
|
+
expect(store.snapshot().executions[0]?.outcome).toBeNull();
|
|
174
|
+
expect(store.snapshot().executions[0]?.status).not.toBe("interrupted");
|
|
175
|
+
await appendFile(file, `${final.slice(-5)}\n`);
|
|
176
|
+
await store.refresh();
|
|
177
|
+
expect(store.snapshot().executions[0]?.status).toBe("exited");
|
|
178
|
+
expect(store.snapshot().tasks[0]?.state_path).toContain("posts/test/workflow-state.json");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("HTTP/SSE support auth, shared subscriptions, cursor replay, resync and server restart", async () => {
|
|
182
|
+
const server = await serveMonitor();
|
|
183
|
+
expect(server).not.toBeNull();
|
|
184
|
+
stop = server!.stop;
|
|
185
|
+
const descriptor = server!.descriptor;
|
|
186
|
+
expect((await fetch(`${descriptor.url}/v1/snapshot`)).status).toBe(401);
|
|
187
|
+
expect((await fetch(`${descriptor.url}/v1/snapshot`, { headers: { ...headers(descriptor), Origin: "https://untrusted.example" } })).status).toBe(403);
|
|
188
|
+
const initial = await (await fetch(`${descriptor.url}/v1/snapshot`, { headers: headers(descriptor) })).json() as MonitorSnapshot;
|
|
189
|
+
const controllers = [new AbortController(), new AbortController()];
|
|
190
|
+
const streams = await Promise.all(controllers.map((controller) => fetch(`${descriptor.url}/v1/events?after=${encodeURIComponent(initial.cursor)}`, { headers: headers(descriptor), signal: controller.signal })));
|
|
191
|
+
const readers = streams.map((response) => response.body!.getReader());
|
|
192
|
+
await Promise.all(readers.map((reader) => reader.read()));
|
|
193
|
+
try {
|
|
194
|
+
await runWithCommandLog("render", [], async () => { reportProgress({ stage: "render.pages", current: 1, total: 2, unit: "pages" }); });
|
|
195
|
+
await fetch(`${descriptor.url}/v1/snapshot`, { headers: headers(descriptor) });
|
|
196
|
+
const events = await Promise.all(readers.map(async (reader) => new TextDecoder().decode((await reader.read()).value)));
|
|
197
|
+
expect(events.every((event) => event.includes("execution.updated"))).toBe(true);
|
|
198
|
+
const replay = await fetch(`${descriptor.url}/v1/events?after=${encodeURIComponent(initial.cursor)}`, { headers: headers(descriptor) });
|
|
199
|
+
const replayReader = replay.body!.getReader();
|
|
200
|
+
expect(new TextDecoder().decode((await replayReader.read()).value)).toContain("execution.updated");
|
|
201
|
+
await replayReader.cancel();
|
|
202
|
+
const stale = await fetch(`${descriptor.url}/v1/events?after=old-instance:1`, { headers: headers(descriptor) });
|
|
203
|
+
expect(await stale.text()).toContain("resync");
|
|
204
|
+
} finally {
|
|
205
|
+
controllers.forEach((controller) => controller.abort());
|
|
206
|
+
await Promise.all(readers.map((reader) => reader.cancel().catch(() => {})));
|
|
207
|
+
}
|
|
208
|
+
stop();
|
|
209
|
+
const restarted = await serveMonitor();
|
|
210
|
+
stop = restarted!.stop;
|
|
211
|
+
expect(restarted!.descriptor.instance_id).not.toBe(descriptor.instance_id);
|
|
212
|
+
const restored = await (await fetch(`${restarted!.descriptor.url}/v1/snapshot`, { headers: headers(restarted!.descriptor) })).json() as MonitorSnapshot;
|
|
213
|
+
expect(restored.executions).toHaveLength(1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("parallel monitor start calls reuse a single service and stop only that service", async () => {
|
|
217
|
+
const results = await Promise.all(Array.from({ length: 4 }, () => run(["monitor", "start"])));
|
|
218
|
+
const descriptors = results.map((result) => JSON.parse(result.stdout) as MonitorDescriptor);
|
|
219
|
+
try {
|
|
220
|
+
expect(new Set(descriptors.map((descriptor) => descriptor.instance_id)).size).toBe(1);
|
|
221
|
+
expect(results.every((result) => result.exitCode === 0)).toBe(true);
|
|
222
|
+
expect(await readdir(join(root, "logs")).catch(() => [])).toHaveLength(0);
|
|
223
|
+
} finally { await run(["monitor", "stop"]); }
|
|
224
|
+
}, 20_000);
|
|
225
|
+
|
|
226
|
+
test("a killed execution becomes interrupted without a heartbeat timeout", async () => {
|
|
227
|
+
const script = `import { runWithCommandLog } from ${JSON.stringify(resolve(import.meta.dir, "logger.ts"))}; await runWithCommandLog('render', [], async () => { await Bun.sleep(60000); });`;
|
|
228
|
+
const child = Bun.spawn([process.execPath, "-e", script], { env: { ...process.env }, stdout: "ignore", stderr: "ignore" });
|
|
229
|
+
try {
|
|
230
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
231
|
+
if ((await readdir(join(process.env.ZZHUB_PIPELINE_MONITOR_DIR!, "events")).catch(() => [])).length) break;
|
|
232
|
+
await Bun.sleep(20);
|
|
233
|
+
}
|
|
234
|
+
child.kill("SIGKILL");
|
|
235
|
+
await child.exited;
|
|
236
|
+
const store = new MonitorStore("test");
|
|
237
|
+
await store.refresh();
|
|
238
|
+
expect(store.snapshot().executions[0]?.status).toBe("interrupted");
|
|
239
|
+
expect(store.snapshot().executions[0]?.exit_code).toBeNull();
|
|
240
|
+
} finally { if (child.exitCode === null) { child.kill(); await child.exited; } }
|
|
241
|
+
});
|
package/src/plugins.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { abandon } from "./commands/abandon";
|
|
2
|
-
import { analytics } from "./commands/analytics";
|
|
3
2
|
import { attachBody } from "./commands/attach-body";
|
|
4
3
|
import { attachBodyImages } from "./commands/attach-body-images";
|
|
5
4
|
import { attachNewspicSpec } from "./commands/attach-newspic-spec";
|
|
@@ -23,17 +22,18 @@ import { review } from "./commands/review";
|
|
|
23
22
|
import { status } from "./commands/status";
|
|
24
23
|
import { syncBlog } from "./commands/sync-blog";
|
|
25
24
|
import { tasks } from "./commands/tasks";
|
|
26
|
-
import { topic } from "./commands/topic";
|
|
27
25
|
import { wechatExport } from "./commands/wechat-export";
|
|
28
26
|
import { wechatPreview } from "./commands/wechat-preview";
|
|
29
27
|
import { wxDrafts } from "./commands/wx-drafts";
|
|
30
28
|
import { wxDraftDelete } from "./commands/wx-draft-delete";
|
|
29
|
+
import type { CommandOutcome } from "./command-outcome";
|
|
30
|
+
import { monitor } from "./commands/monitor";
|
|
31
31
|
|
|
32
32
|
export interface CommandDefinition {
|
|
33
33
|
name: string;
|
|
34
34
|
summary: string;
|
|
35
35
|
plugin: string;
|
|
36
|
-
handler: (args: string[]) => Promise<void>;
|
|
36
|
+
handler: (args: string[]) => Promise<void | CommandOutcome>;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export interface CommandPlugin {
|
|
@@ -68,6 +68,7 @@ export function getCommandPlugins(): CommandPlugin[] {
|
|
|
68
68
|
{
|
|
69
69
|
name: "ops",
|
|
70
70
|
commands: [
|
|
71
|
+
{ name: "monitor", summary: "Manage local HTTP/SSE multi-task monitoring", plugin: "ops", handler: monitor },
|
|
71
72
|
{ name: "sync-blog", summary: "Copy post.md to blog repo, publish, and record result in state JSON", plugin: "ops", handler: syncBlog },
|
|
72
73
|
{ name: "republish", summary: "Publish completed task to additional accounts/platforms", plugin: "ops", handler: republish },
|
|
73
74
|
{ name: "imgx", summary: "Run bundled imgx renderer subcommands", plugin: "ops", handler: imgxCommand },
|
|
@@ -79,8 +80,6 @@ export function getCommandPlugins(): CommandPlugin[] {
|
|
|
79
80
|
{ name: "hermes-metrics", summary: "Show Hermes execution metrics per task", plugin: "ops", handler: hermesMetrics },
|
|
80
81
|
{ name: "wx-drafts", summary: "List or get drafts from WeChat draft box", plugin: "ops", handler: wxDrafts },
|
|
81
82
|
{ name: "wx-draft-delete", summary: "Delete a draft from WeChat draft box", plugin: "ops", handler: wxDraftDelete },
|
|
82
|
-
{ name: "topic", summary: "Manage topics (add, list, update, schedule, retro, abandon)", plugin: "ops", handler: topic },
|
|
83
|
-
{ name: "analytics", summary: "Record and analyze post-publish metrics", plugin: "ops", handler: analytics },
|
|
84
83
|
],
|
|
85
84
|
},
|
|
86
85
|
];
|
package/src/providers/blog.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "fs/promises";
|
|
|
2
2
|
import { dirname, isAbsolute, join, relative, resolve } from "path";
|
|
3
3
|
import { PipelineConfig, ResolvedWorkspacePaths } from "../config";
|
|
4
4
|
import { spawnSync } from "../spawn";
|
|
5
|
+
import { reportProgress } from "../monitor/recorder";
|
|
5
6
|
import { PublishResult, WorkflowState } from "../state";
|
|
6
7
|
import { stripFrontmatter } from "../text";
|
|
7
8
|
import { uploadFileToCos } from "./cos";
|
|
@@ -171,14 +172,16 @@ export async function publishBlogRoute({
|
|
|
171
172
|
|
|
172
173
|
await mkdir(blogPostDir, { recursive: true });
|
|
173
174
|
await writeFile(blogPostPath, blogContent, "utf-8");
|
|
175
|
+
reportProgress({ stage: "publish.script", message: "正在运行博客发布命令" });
|
|
174
176
|
const result = spawnSync(config.commands.blogPublish, { cwd: blogRoot });
|
|
177
|
+
reportProgress({ stage: "publish.script", message: result.exitCode === 0 ? "博客发布命令完成" : "博客发布命令失败", current: 1, total: 1, unit: "targets" });
|
|
175
178
|
|
|
176
179
|
if (result.exitCode !== 0) {
|
|
177
180
|
return {
|
|
178
181
|
route: "blog",
|
|
179
182
|
account: "default",
|
|
180
183
|
status: "failed",
|
|
181
|
-
detail: `blog publish command failed: ${config.commands.blogPublish.
|
|
184
|
+
detail: `blog publish command failed: ${config.commands.blogPublish[0]} (exit=${result.exitCode ?? "unknown"}, signal=${result.signal ?? "none"}${result.error ? `, cause=${result.error.message}` : ""})`,
|
|
182
185
|
published_at: null,
|
|
183
186
|
content_version: state.artifacts.content_version,
|
|
184
187
|
render_version: state.artifacts.render_version,
|
package/src/providers/cos.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "fs";
|
|
2
2
|
import { basename } from "path";
|
|
3
3
|
import COS from "cos-nodejs-sdk-v5";
|
|
4
|
+
import { reportProgress } from "../monitor/recorder";
|
|
4
5
|
|
|
5
6
|
const STS_PATH = "/api/v1/upload/cos";
|
|
6
7
|
const STS_TIMEOUT = 30000;
|
|
@@ -142,6 +143,7 @@ export async function uploadFileToCos(input: {
|
|
|
142
143
|
}
|
|
143
144
|
|
|
144
145
|
const sts = await requestSts(baseUrl, cosPat, filename, folder);
|
|
146
|
+
reportProgress({ stage: "upload.cos", message: "正在上传 COS 文件" });
|
|
145
147
|
const buffer = readFileSync(localPath);
|
|
146
148
|
|
|
147
149
|
const cos = new COS({
|
|
@@ -165,6 +167,7 @@ export async function uploadFileToCos(input: {
|
|
|
165
167
|
Key: sts.Key,
|
|
166
168
|
Body: buffer,
|
|
167
169
|
ContentType: contentType,
|
|
170
|
+
onProgress: ({ loaded, total }) => reportProgress({ stage: "upload.cos", current: loaded, total, unit: "bytes" }),
|
|
168
171
|
},
|
|
169
172
|
(err) => {
|
|
170
173
|
if (settled) return;
|
package/src/providers/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
prepareBodyForNewspic,
|
|
12
12
|
} from "../text";
|
|
13
13
|
import { resolveMarkdownRenderer } from "../adapter-loader";
|
|
14
|
+
import { reportProgress } from "../monitor/recorder";
|
|
14
15
|
import { publishBlogRoute } from "./blog";
|
|
15
16
|
import { createWechatDraft, createWechatNewspic, extractImageUrls, mergePhotoLists } from "./wechat";
|
|
16
17
|
|
|
@@ -80,6 +81,7 @@ async function publishWechatArticleRoute({
|
|
|
80
81
|
const markdownRenderer = await resolveMarkdownRenderer(config);
|
|
81
82
|
const wxAccount = config.wx.accounts[account] ?? config.wx.accounts[config.wx.defaultAccount];
|
|
82
83
|
const rendered = await markdownRenderer.render({
|
|
84
|
+
onProgress: reportProgress,
|
|
83
85
|
markdownPath: exportPostPath,
|
|
84
86
|
outPath: workspacePaths.zotepadExportHtml,
|
|
85
87
|
account,
|
|
@@ -93,7 +95,7 @@ async function publishWechatArticleRoute({
|
|
|
93
95
|
if (process.env.ZZHUB_WECHAT_PREVIEW_ON_PUBLISH !== "0") {
|
|
94
96
|
try {
|
|
95
97
|
const { registerPreviewEntry } = await import("../wechat-preview/server");
|
|
96
|
-
await registerPreviewEntry(
|
|
98
|
+
const registration = await registerPreviewEntry(
|
|
97
99
|
{
|
|
98
100
|
title: `[publish] ${state.metadata.title || "untitled"}`,
|
|
99
101
|
account,
|
|
@@ -110,8 +112,10 @@ async function publishWechatArticleRoute({
|
|
|
110
112
|
},
|
|
111
113
|
{ autoStart: process.env.ZZHUB_WECHAT_PREVIEW_AUTO_START !== "0" },
|
|
112
114
|
);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
if (!registration.ok) console.warn(`微信预览登记失败:${registration.error || "未知原因"}`);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
// 预览故障只记录警告,不改变发布结果。
|
|
118
|
+
console.warn("微信预览登记失败:", error);
|
|
115
119
|
}
|
|
116
120
|
}
|
|
117
121
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { PipelineConfig, ResolvedWorkspacePaths } from "../config";
|
|
8
8
|
import type { PublishResult, PublishTarget, WorkflowState } from "../state";
|
|
9
9
|
import { getPublishProvider, type PublishRouteContext } from "./index";
|
|
10
|
+
import { reportProgress } from "../monitor/recorder";
|
|
10
11
|
|
|
11
12
|
export interface PublishTargetError {
|
|
12
13
|
route: string;
|
|
@@ -102,6 +103,7 @@ export async function executePublishTargets(
|
|
|
102
103
|
const errors: PublishTargetError[] = [];
|
|
103
104
|
|
|
104
105
|
for (const target of filtered) {
|
|
106
|
+
reportProgress({ stage: "publish.targets", message: "正在发布", current: results.length, total: filtered.length, unit: "targets", route: target.route, account: target.account });
|
|
105
107
|
let result: PublishResult;
|
|
106
108
|
try {
|
|
107
109
|
const provider = getPublishProvider(target.route);
|
|
@@ -135,10 +137,14 @@ export async function executePublishTargets(
|
|
|
135
137
|
content_version: state.artifacts.content_version,
|
|
136
138
|
render_version: state.artifacts.render_version,
|
|
137
139
|
};
|
|
138
|
-
|
|
140
|
+
}
|
|
141
|
+
// provider 返回 failed 与抛出异常使用同一条错误汇总路径。
|
|
142
|
+
if (result.status === "failed") {
|
|
143
|
+
errors.push({ route: target.route, account: target.account, error: result.detail || "发布失败,provider 未提供原因" });
|
|
139
144
|
}
|
|
140
145
|
results.push(result);
|
|
141
146
|
await onResult?.(result);
|
|
147
|
+
reportProgress({ stage: "publish.targets", message: result.status === "failed" ? "目标发布失败" : "目标处理完成", current: results.length, total: filtered.length, unit: "targets", route: target.route, account: target.account });
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
return { results, errors };
|
package/src/providers/wechat.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "fs";
|
|
2
2
|
import { basename, extname } from "path";
|
|
3
3
|
import { PipelineConfig, WxAccountConfig } from "../config";
|
|
4
|
+
import { reportProgress } from "../monitor/recorder";
|
|
4
5
|
|
|
5
6
|
const TOKEN_PATH = "/api/v1/wx/cgi-bin/token";
|
|
6
7
|
const MATERIAL_PATH = "/api/v1/wx/cgi-bin/material/add_material";
|
|
@@ -161,8 +162,10 @@ export function normalizeAccountName(value: string): string {
|
|
|
161
162
|
return account;
|
|
162
163
|
}
|
|
163
164
|
|
|
164
|
-
|
|
165
|
+
/** Fill missing wx account fields with defaults. Preserves optional display `name`. */
|
|
166
|
+
export function fillWxAccountConfig(source?: WxAccountConfig): WxAccountConfig {
|
|
165
167
|
return {
|
|
168
|
+
name: source?.name ?? "",
|
|
166
169
|
pat: source?.pat ?? "",
|
|
167
170
|
appId: source?.appId ?? "",
|
|
168
171
|
appSecret: source?.appSecret ?? "",
|
|
@@ -540,6 +543,7 @@ async function uploadPhotos(
|
|
|
540
543
|
continue;
|
|
541
544
|
}
|
|
542
545
|
|
|
546
|
+
reportProgress({ stage: "publish.upload", message: "正在上传微信图片", current: index, total: photos.length, unit: "files" });
|
|
543
547
|
const payload = await resolvePhotoPayload(photoUrl, index);
|
|
544
548
|
const formData = new FormData();
|
|
545
549
|
formData.append("access_token", accessToken);
|
|
@@ -574,6 +578,7 @@ async function uploadPhotos(
|
|
|
574
578
|
index,
|
|
575
579
|
});
|
|
576
580
|
imageUrlMap[photoUrl] = wxUrl || photoUrl;
|
|
581
|
+
reportProgress({ stage: "publish.upload", current: index + 1, total: photos.length, unit: "files" });
|
|
577
582
|
}
|
|
578
583
|
|
|
579
584
|
return {
|
|
@@ -781,7 +786,7 @@ async function notifyNezusPublishResult(params: {
|
|
|
781
786
|
console.error(`[nezus] publish-result callback OK for note ${params.noteId}`);
|
|
782
787
|
} catch (err) {
|
|
783
788
|
// Non-fatal: the draft was created successfully even if the callback failed
|
|
784
|
-
console.
|
|
789
|
+
console.warn(`[nezus] publish-result callback failed for note ${params.noteId}:`, err);
|
|
785
790
|
}
|
|
786
791
|
}
|
|
787
792
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { normalizeConfig, SUGGESTED_ACCOUNT_NAMES } from "../config";
|
|
4
|
+
import { fillWxAccountConfig } from "../providers/wechat";
|
|
5
|
+
import { PipelineConfigSchema } from "./config";
|
|
6
|
+
|
|
7
|
+
describe("wx account display name", () => {
|
|
8
|
+
test("schema keeps name when provided", () => {
|
|
9
|
+
const parsed = PipelineConfigSchema.parse({
|
|
10
|
+
wx: {
|
|
11
|
+
accounts: {
|
|
12
|
+
default: {
|
|
13
|
+
name: "早早集市",
|
|
14
|
+
appId: "wx123",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
expect(parsed.wx.accounts.default?.name).toBe("早早集市");
|
|
20
|
+
expect(parsed.wx.accounts.default?.appId).toBe("wx123");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("schema defaults name to empty string when missing", () => {
|
|
24
|
+
const parsed = PipelineConfigSchema.parse({
|
|
25
|
+
wx: {
|
|
26
|
+
accounts: {
|
|
27
|
+
custom: {
|
|
28
|
+
appId: "wx999",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
// custom key has no soft-suggest unless normalizeConfig is used
|
|
34
|
+
expect(parsed.wx.accounts.custom?.name).toBe("");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("normalizeConfig soft-fills known keys only when name empty", () => {
|
|
38
|
+
const normalized = normalizeConfig({
|
|
39
|
+
wx: {
|
|
40
|
+
accounts: {
|
|
41
|
+
default: { appId: "wx1" },
|
|
42
|
+
ancientone: { name: "我自定义的小号", appId: "wx2" },
|
|
43
|
+
other: { appId: "wx3" },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
expect(normalized.wx.accounts.default?.name).toBe(SUGGESTED_ACCOUNT_NAMES.default);
|
|
48
|
+
expect(normalized.wx.accounts.ancientone?.name).toBe("我自定义的小号");
|
|
49
|
+
expect(normalized.wx.accounts.other?.name).toBe("");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("normalizeConfig does not override explicit empty-then-set name after trim", () => {
|
|
53
|
+
const normalized = normalizeConfig({
|
|
54
|
+
wx: {
|
|
55
|
+
accounts: {
|
|
56
|
+
default: { name: " 大号定制 ", appId: "wx1" },
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
expect(normalized.wx.accounts.default?.name).toBe("大号定制");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("fillWxAccountConfig preserves name", () => {
|
|
64
|
+
const filled = fillWxAccountConfig({
|
|
65
|
+
name: "大号",
|
|
66
|
+
pat: "p",
|
|
67
|
+
appId: "id",
|
|
68
|
+
appSecret: "s",
|
|
69
|
+
customCss: null,
|
|
70
|
+
theme: { editorVars: {}, exportTheme: {} },
|
|
71
|
+
});
|
|
72
|
+
expect(filled.name).toBe("大号");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("fillWxAccountConfig defaults name to empty", () => {
|
|
76
|
+
const filled = fillWxAccountConfig(undefined);
|
|
77
|
+
expect(filled.name).toBe("");
|
|
78
|
+
});
|
|
79
|
+
});
|
package/src/schema/config.ts
CHANGED
|
@@ -56,6 +56,12 @@ const WechatThemeOverridesSchema = withObjectDefault(
|
|
|
56
56
|
);
|
|
57
57
|
|
|
58
58
|
const WxAccountConfigSchema = z.object({
|
|
59
|
+
/**
|
|
60
|
+
* Optional Chinese display name for UIs (e.g. 大号(早早集市)).
|
|
61
|
+
* Config map key remains the stable account id (default / ancientone).
|
|
62
|
+
* Whitespace is trimmed in normalizeConfig when soft-filling / merging.
|
|
63
|
+
*/
|
|
64
|
+
name: z.string().default(""),
|
|
59
65
|
pat: z.string().default(""),
|
|
60
66
|
appId: z.string().default(""),
|
|
61
67
|
appSecret: z.string().default(""),
|
|
@@ -74,9 +80,9 @@ const WxConfigSchema = withObjectDefault(
|
|
|
74
80
|
z.number().default(30000),
|
|
75
81
|
),
|
|
76
82
|
defaultAccount: trimmedNonEmptyString("default"),
|
|
77
|
-
accounts: z
|
|
78
|
-
.
|
|
79
|
-
|
|
83
|
+
accounts: z.record(z.string(), WxAccountConfigSchema).default(() => ({
|
|
84
|
+
default: WxAccountConfigSchema.parse({}),
|
|
85
|
+
})),
|
|
80
86
|
}),
|
|
81
87
|
);
|
|
82
88
|
|
package/src/spawn.ts
CHANGED
|
@@ -42,6 +42,7 @@ export interface SpawnOptions {
|
|
|
42
42
|
|
|
43
43
|
export interface SpawnResult {
|
|
44
44
|
exitCode: number | null;
|
|
45
|
+
signal?: NodeJS.Signals | null;
|
|
45
46
|
error?: Error;
|
|
46
47
|
}
|
|
47
48
|
|
|
@@ -81,20 +82,21 @@ export function resolveBunBinary(): string {
|
|
|
81
82
|
function normalizeResult(result: SpawnSyncReturns<Buffer>): SpawnResult {
|
|
82
83
|
return {
|
|
83
84
|
exitCode: result.status,
|
|
85
|
+
signal: result.signal,
|
|
84
86
|
...(result.error ? { error: result.error } : {}),
|
|
85
87
|
};
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
/**
|
|
89
91
|
* Run a command synchronously with enriched PATH.
|
|
90
|
-
*
|
|
92
|
+
* 子脚本诊断输出统一写入 stderr,保留 CLI stdout 的 JSON 契约。
|
|
91
93
|
*
|
|
92
94
|
* Returns a normalized spawn result.
|
|
93
95
|
*/
|
|
94
96
|
export function spawnSync(cmd: string[], opts?: SpawnOptions): SpawnResult {
|
|
95
97
|
const [binary, ...args] = cmd;
|
|
96
98
|
const result = nodeSpawnSync(binary, args, {
|
|
97
|
-
stdio: "inherit",
|
|
99
|
+
stdio: ["inherit", 2, 2],
|
|
98
100
|
env: enrichedEnv(),
|
|
99
101
|
...(opts?.cwd ? { cwd: opts.cwd } : {}),
|
|
100
102
|
});
|
package/src/state.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { access, mkdir, open, readFile, rename, rm, stat, writeFile } from "fs/promises";
|
|
11
11
|
import { dirname, isAbsolute, resolve } from "path";
|
|
12
|
+
import { observeState } from "./monitor/recorder";
|
|
12
13
|
import {
|
|
13
14
|
WorkflowStateSchema,
|
|
14
15
|
NewspicRenderSpecSchema,
|
|
@@ -245,6 +246,8 @@ export async function readResolvedState(path: string): Promise<ResolvedState> {
|
|
|
245
246
|
state.state_path = currentPath;
|
|
246
247
|
}
|
|
247
248
|
|
|
249
|
+
observeState(state, currentPath);
|
|
250
|
+
|
|
248
251
|
return {
|
|
249
252
|
path: currentPath,
|
|
250
253
|
requested_path: requestedPath,
|
|
@@ -310,6 +313,7 @@ export async function writeState(
|
|
|
310
313
|
} finally {
|
|
311
314
|
await releaseLock();
|
|
312
315
|
}
|
|
316
|
+
observeState(validated, normalizedPath);
|
|
313
317
|
}
|
|
314
318
|
|
|
315
319
|
/**
|
package/src/workflow.test.ts
CHANGED
|
@@ -50,7 +50,7 @@ async function makeTempDir(prefix: string): Promise<string> {
|
|
|
50
50
|
return mkdtemp(join(tmpdir(), prefix));
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
async function captureJsonOutput<T>(fn: () => Promise<
|
|
53
|
+
async function captureJsonOutput<T>(fn: () => Promise<unknown>): Promise<T> {
|
|
54
54
|
const original = console.log;
|
|
55
55
|
const lines: string[] = [];
|
|
56
56
|
console.log = (...args: unknown[]) => {
|
|
@@ -66,7 +66,7 @@ async function captureJsonOutput<T>(fn: () => Promise<void>): Promise<T> {
|
|
|
66
66
|
return JSON.parse(payload) as T;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
async function captureTextOutput(fn: () => Promise<
|
|
69
|
+
async function captureTextOutput(fn: () => Promise<unknown>): Promise<string> {
|
|
70
70
|
const original = console.log;
|
|
71
71
|
const lines: string[] = [];
|
|
72
72
|
console.log = (...args: unknown[]) => {
|
|
@@ -1437,6 +1437,7 @@ describe("config", () => {
|
|
|
1437
1437
|
defaultAccount: "default",
|
|
1438
1438
|
accounts: {
|
|
1439
1439
|
default: {
|
|
1440
|
+
name: "",
|
|
1440
1441
|
pat: "",
|
|
1441
1442
|
appId: "",
|
|
1442
1443
|
appSecret: "",
|