@zzclub/pipeline 0.10.1 → 0.10.2
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 +43 -1
- package/package.json +2 -1
- package/src/adapter-types.ts +21 -0
- package/src/adapters/builtin-markdown-renderer.ts +7 -0
- package/src/cli.ts +5 -1
- package/src/commands/ingest-handoff.ts +1 -21
- package/src/commands/prepare-finalize.ts +2 -1
- package/src/commands/prepare.ts +22 -14
- package/src/commands/publish.ts +2 -3
- package/src/commands/reconcile.ts +3 -5
- package/src/commands/render.ts +2 -1
- package/src/commands/republish.ts +2 -3
- package/src/commands/reset.ts +9 -1
- package/src/commands/review.ts +3 -2
- package/src/commands/sync-blog.ts +2 -1
- package/src/commands/wechat-export.ts +134 -12
- package/src/commands/wechat-preview.ts +160 -0
- package/src/imgx/runtime.ts +31 -3
- package/src/logger.test.ts +56 -0
- package/src/logger.ts +178 -0
- package/src/plugins.ts +2 -0
- package/src/providers/blog.ts +1 -2
- package/src/providers/index.ts +29 -1
- package/src/schema/config.ts +2 -0
- package/src/state.ts +24 -0
- package/src/task-manager.test.ts +57 -0
- package/src/task-manager.ts +23 -5
- package/src/wechat-preview/assets/browser-dist/editor-export.js +129 -73
- package/src/wechat-preview/browser/editor-export.css +5 -366
- package/src/wechat-preview/browser/editor-export.ts +7 -3
- package/src/wechat-preview/bundle-stale.test.ts +52 -0
- package/src/wechat-preview/index.ts +390 -83
- package/src/wechat-preview/server/client.ts +164 -0
- package/src/wechat-preview/server/http.ts +229 -0
- package/src/wechat-preview/server/index.ts +33 -0
- package/src/wechat-preview/server/local-file.test.ts +52 -0
- package/src/wechat-preview/server/local-file.ts +116 -0
- package/src/wechat-preview/server/paths.ts +80 -0
- package/src/wechat-preview/server/registry.test.ts +60 -0
- package/src/wechat-preview/server/registry.ts +118 -0
- package/src/wechat-preview/server/singleton.ts +118 -0
- package/src/wechat-preview/server/types.ts +61 -0
- package/src/wechat-preview/server/ui.ts +324 -0
- package/src/wechat-preview/themes.ts +8 -0
- package/src/wechat-preview/wechat-renderer.links.test.ts +44 -0
- package/src/wechat-preview/wechat-renderer.test.ts +2 -0
- package/src/wechat-preview/wechat-renderer.ts +52 -6
- package/src/wechat-preview.test.ts +14 -7
- package/src/workflow-materials.test.ts +52 -1
- package/src/workflow-materials.ts +12 -0
- package/src/workflow.test.ts +61 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { flagArg, optionalArg, parseArgs } from "../args";
|
|
3
|
+
import { printResult } from "../output";
|
|
4
|
+
import {
|
|
5
|
+
clearPreviewEntries,
|
|
6
|
+
getPreviewServerStatus,
|
|
7
|
+
listPreviewEntries,
|
|
8
|
+
resolvePreviewHost,
|
|
9
|
+
resolvePreviewPort,
|
|
10
|
+
startPreviewServer,
|
|
11
|
+
} from "../wechat-preview/server";
|
|
12
|
+
|
|
13
|
+
async function openUrl(url: string): Promise<void> {
|
|
14
|
+
const opener =
|
|
15
|
+
process.platform === "darwin"
|
|
16
|
+
? "open"
|
|
17
|
+
: process.platform === "win32"
|
|
18
|
+
? "cmd"
|
|
19
|
+
: "xdg-open";
|
|
20
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
21
|
+
try {
|
|
22
|
+
spawn(opener, args, { detached: true, stdio: "ignore" }).unref();
|
|
23
|
+
} catch {
|
|
24
|
+
// non-fatal
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function printHelp(): void {
|
|
29
|
+
console.log(`
|
|
30
|
+
Usage: zzhub-pipeline wechat-preview <subcommand> [options]
|
|
31
|
+
|
|
32
|
+
Subcommands:
|
|
33
|
+
serve Start the singleton local preview server (or print existing URL)
|
|
34
|
+
status Show whether the preview server is running
|
|
35
|
+
open Open the dashboard in a browser
|
|
36
|
+
list List registered preview entries
|
|
37
|
+
clear Clear all preview entries
|
|
38
|
+
|
|
39
|
+
serve options:
|
|
40
|
+
--host Bind host (default: 127.0.0.1, env ZZHUB_WECHAT_PREVIEW_HOST)
|
|
41
|
+
--port Bind port (default: 18765, env ZZHUB_WECHAT_PREVIEW_PORT)
|
|
42
|
+
--open Open the dashboard after start
|
|
43
|
+
--force Fail if a server is already running (default: reuse existing)
|
|
44
|
+
|
|
45
|
+
Environment:
|
|
46
|
+
ZZHUB_WECHAT_PREVIEW_DIR Data/lock directory override
|
|
47
|
+
`.trim());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function wechatPreview(args: string[]): Promise<void> {
|
|
51
|
+
const sub = args[0];
|
|
52
|
+
const rest = args.slice(1);
|
|
53
|
+
const parsed = parseArgs(rest);
|
|
54
|
+
|
|
55
|
+
if (!sub || sub === "help" || sub === "--help" || parsed.help) {
|
|
56
|
+
printHelp();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (sub === "serve") {
|
|
61
|
+
const host = optionalArg(parsed, "host");
|
|
62
|
+
const portRaw = optionalArg(parsed, "port");
|
|
63
|
+
const port = portRaw ? Number(portRaw) : undefined;
|
|
64
|
+
const open = flagArg(parsed, "open");
|
|
65
|
+
const force = flagArg(parsed, "force");
|
|
66
|
+
|
|
67
|
+
const result = await startPreviewServer({
|
|
68
|
+
host,
|
|
69
|
+
port: port !== undefined && Number.isFinite(port) ? port : undefined,
|
|
70
|
+
open,
|
|
71
|
+
reuseExisting: !force,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (open) {
|
|
75
|
+
await openUrl(result.url);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
printResult({
|
|
79
|
+
action: "serve",
|
|
80
|
+
url: result.url,
|
|
81
|
+
host: result.host,
|
|
82
|
+
port: result.port,
|
|
83
|
+
reused: result.reused,
|
|
84
|
+
message: result.reused
|
|
85
|
+
? `Already running at ${result.url}`
|
|
86
|
+
: `Listening on ${result.url}`,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Keep process alive when we own the server
|
|
90
|
+
if (!result.reused) {
|
|
91
|
+
await new Promise<void>(() => {
|
|
92
|
+
// blocked until SIGINT/SIGTERM
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (sub === "status") {
|
|
99
|
+
const host = optionalArg(parsed, "host");
|
|
100
|
+
const portRaw = optionalArg(parsed, "port");
|
|
101
|
+
const port = portRaw ? Number(portRaw) : undefined;
|
|
102
|
+
const status = await getPreviewServerStatus({
|
|
103
|
+
host: host ?? resolvePreviewHost(),
|
|
104
|
+
port: port !== undefined && Number.isFinite(port) ? port : resolvePreviewPort(),
|
|
105
|
+
});
|
|
106
|
+
printResult({ action: "status", ...status });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (sub === "open") {
|
|
111
|
+
const status = await getPreviewServerStatus();
|
|
112
|
+
if (!status.running || !status.url) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
"WeChat preview server is not running. Start with: wechat-preview serve",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
await openUrl(status.url);
|
|
118
|
+
printResult({ action: "open", url: status.url });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (sub === "list") {
|
|
123
|
+
const status = await getPreviewServerStatus();
|
|
124
|
+
if (status.running) {
|
|
125
|
+
// Prefer live server list
|
|
126
|
+
try {
|
|
127
|
+
const res = await fetch(`${status.url}/api/entries`);
|
|
128
|
+
if (res.ok) {
|
|
129
|
+
printResult({ action: "list", entries: await res.json() });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// fall through to disk
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
printResult({ action: "list", entries: listPreviewEntries(), source: "disk" });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (sub === "clear") {
|
|
141
|
+
const status = await getPreviewServerStatus();
|
|
142
|
+
if (status.running && status.url) {
|
|
143
|
+
try {
|
|
144
|
+
const res = await fetch(`${status.url}/api/entries`, { method: "DELETE" });
|
|
145
|
+
if (res.ok) {
|
|
146
|
+
const body = (await res.json()) as { cleared?: number };
|
|
147
|
+
printResult({ action: "clear", cleared: body.cleared ?? 0, via: "server" });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// fall through
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const cleared = clearPreviewEntries();
|
|
155
|
+
printResult({ action: "clear", cleared, via: "disk" });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
throw new Error(`Unknown wechat-preview subcommand: ${sub}`);
|
|
160
|
+
}
|
package/src/imgx/runtime.ts
CHANGED
|
@@ -304,10 +304,30 @@ export function screenshotHtml(options: {
|
|
|
304
304
|
rmSync(tempShotDir, { recursive: true, force: true });
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
export class ChromeDumpError extends Error {
|
|
308
|
+
readonly stderr: string;
|
|
309
|
+
readonly tempHtmlPath?: string;
|
|
310
|
+
readonly exitCode: number | null;
|
|
311
|
+
|
|
312
|
+
constructor(message: string, options: {
|
|
313
|
+
stderr: string;
|
|
314
|
+
tempHtmlPath?: string;
|
|
315
|
+
exitCode: number | null;
|
|
316
|
+
}) {
|
|
317
|
+
super(message);
|
|
318
|
+
this.name = "ChromeDumpError";
|
|
319
|
+
this.stderr = options.stderr;
|
|
320
|
+
this.tempHtmlPath = options.tempHtmlPath;
|
|
321
|
+
this.exitCode = options.exitCode;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
307
325
|
export function dumpHtmlDom(options: {
|
|
308
326
|
chromePath: string;
|
|
309
327
|
html: string;
|
|
310
328
|
virtualTimeBudgetMs?: number;
|
|
329
|
+
/** When true, leave the temp shell HTML on disk if Chrome fails (caller must clean up). */
|
|
330
|
+
keepTempOnError?: boolean;
|
|
311
331
|
}): string {
|
|
312
332
|
const tempHtml = writeTempHtml(injectStaticRenderStyle(options.html));
|
|
313
333
|
const fileUrl = pathToFileURL(tempHtml).href;
|
|
@@ -329,12 +349,20 @@ export function dumpHtmlDom(options: {
|
|
|
329
349
|
stdout: "pipe",
|
|
330
350
|
stderr: "pipe",
|
|
331
351
|
});
|
|
332
|
-
cleanupTempFile(tempHtml);
|
|
333
352
|
if (result.exitCode !== 0) {
|
|
334
353
|
const stderr = new TextDecoder().decode(result.stderr);
|
|
335
|
-
|
|
354
|
+
if (!options.keepTempOnError) {
|
|
355
|
+
cleanupTempFile(tempHtml);
|
|
356
|
+
}
|
|
357
|
+
throw new ChromeDumpError(`Chrome dump-dom failed (exit ${result.exitCode}):\n${stderr}`, {
|
|
358
|
+
stderr,
|
|
359
|
+
tempHtmlPath: options.keepTempOnError ? tempHtml : undefined,
|
|
360
|
+
exitCode: result.exitCode,
|
|
361
|
+
});
|
|
336
362
|
}
|
|
337
|
-
|
|
363
|
+
const dom = new TextDecoder().decode(result.stdout);
|
|
364
|
+
cleanupTempFile(tempHtml);
|
|
365
|
+
return dom;
|
|
338
366
|
}
|
|
339
367
|
|
|
340
368
|
export function cropTop(options: {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { getDailyLogPath, getLogDir, runWithCommandLog } from "./logger";
|
|
6
|
+
|
|
7
|
+
describe("pipeline daily logger", () => {
|
|
8
|
+
const previousLogDir = process.env.ZZHUB_PIPELINE_LOG_DIR;
|
|
9
|
+
let tempDir: string | null = null;
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
if (previousLogDir === undefined) {
|
|
13
|
+
delete process.env.ZZHUB_PIPELINE_LOG_DIR;
|
|
14
|
+
} else {
|
|
15
|
+
process.env.ZZHUB_PIPELINE_LOG_DIR = previousLogDir;
|
|
16
|
+
}
|
|
17
|
+
if (tempDir) {
|
|
18
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
19
|
+
tempDir = null;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("writes command start/ok markers and console output to the daily log", async () => {
|
|
24
|
+
tempDir = mkdtempSync(join(tmpdir(), "zzhub-pipeline-log-"));
|
|
25
|
+
process.env.ZZHUB_PIPELINE_LOG_DIR = tempDir;
|
|
26
|
+
|
|
27
|
+
await runWithCommandLog("status", ["--state", "/tmp/x.json"], async () => {
|
|
28
|
+
console.log("hello-from-status");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const logPath = getDailyLogPath();
|
|
32
|
+
expect(getLogDir()).toBe(tempDir);
|
|
33
|
+
expect(existsSync(logPath)).toBe(true);
|
|
34
|
+
const body = readFileSync(logPath, "utf-8");
|
|
35
|
+
expect(body).toContain("=== START command=status ===");
|
|
36
|
+
expect(body).toContain("=== OK command=status");
|
|
37
|
+
expect(body).toContain("[console.log] hello-from-status");
|
|
38
|
+
expect(body).toContain(`"--state"`);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("records failure stack and rethrows", async () => {
|
|
42
|
+
tempDir = mkdtempSync(join(tmpdir(), "zzhub-pipeline-log-"));
|
|
43
|
+
process.env.ZZHUB_PIPELINE_LOG_DIR = tempDir;
|
|
44
|
+
|
|
45
|
+
await expect(
|
|
46
|
+
runWithCommandLog("publish", [], async () => {
|
|
47
|
+
throw new Error("boom-upload");
|
|
48
|
+
}),
|
|
49
|
+
).rejects.toThrow("boom-upload");
|
|
50
|
+
|
|
51
|
+
const body = readFileSync(getDailyLogPath(), "utf-8");
|
|
52
|
+
expect(body).toContain("=== FAIL command=publish");
|
|
53
|
+
expect(body).toContain("Error: boom-upload");
|
|
54
|
+
expect(body).toContain("boom-upload");
|
|
55
|
+
});
|
|
56
|
+
});
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daily rolling file logger for zzhub-pipeline CLI commands.
|
|
3
|
+
*
|
|
4
|
+
* Layout (platform-aware, same base as wechat-preview config dir):
|
|
5
|
+
* macOS: ~/Library/Application Support/zzhub-pipeline/logs/pipeline-YYYY-MM-DD.log
|
|
6
|
+
* Linux: ~/.config/zzhub-pipeline/logs/pipeline-YYYY-MM-DD.log
|
|
7
|
+
* Windows: %APPDATA%/zzhub-pipeline/logs/pipeline-YYYY-MM-DD.log
|
|
8
|
+
*
|
|
9
|
+
* Override with ZZHUB_PIPELINE_LOG_DIR.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from "node:fs";
|
|
13
|
+
import { homedir, platform } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
const APP_DIR = "zzhub-pipeline";
|
|
17
|
+
const LOG_DIR_NAME = "logs";
|
|
18
|
+
const LOG_PREFIX = "pipeline-";
|
|
19
|
+
const LOG_SUFFIX = ".log";
|
|
20
|
+
/** Keep about two weeks of daily files. */
|
|
21
|
+
const LOG_RETENTION_DAYS = 14;
|
|
22
|
+
|
|
23
|
+
function getBaseConfigDir(): string {
|
|
24
|
+
const home = homedir();
|
|
25
|
+
const currentPlatform = platform();
|
|
26
|
+
|
|
27
|
+
if (currentPlatform === "win32") {
|
|
28
|
+
const appData = process.env.APPDATA;
|
|
29
|
+
if (appData) return join(appData, APP_DIR);
|
|
30
|
+
return join(home, "AppData", "Roaming", APP_DIR);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (currentPlatform === "darwin") {
|
|
34
|
+
return join(home, "Library", "Application Support", APP_DIR);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const xdgConfigHome = process.env.XDG_CONFIG_HOME;
|
|
38
|
+
if (xdgConfigHome && xdgConfigHome.trim().length > 0) {
|
|
39
|
+
return join(xdgConfigHome, APP_DIR);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return join(home, ".config", APP_DIR);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getLogDir(): string {
|
|
46
|
+
const override = process.env.ZZHUB_PIPELINE_LOG_DIR?.trim();
|
|
47
|
+
if (override) return override;
|
|
48
|
+
return join(getBaseConfigDir(), LOG_DIR_NAME);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function formatDateKey(date: Date = new Date()): string {
|
|
52
|
+
const y = date.getFullYear();
|
|
53
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
54
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
55
|
+
return `${y}-${m}-${d}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getDailyLogPath(date: Date = new Date()): string {
|
|
59
|
+
return join(getLogDir(), `${LOG_PREFIX}${formatDateKey(date)}${LOG_SUFFIX}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ensureLogDir(): string {
|
|
63
|
+
const dir = getLogDir();
|
|
64
|
+
if (!existsSync(dir)) {
|
|
65
|
+
mkdirSync(dir, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
return dir;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function pruneOldLogs(dir: string): void {
|
|
71
|
+
try {
|
|
72
|
+
const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
73
|
+
for (const name of readdirSync(dir)) {
|
|
74
|
+
if (!name.startsWith(LOG_PREFIX) || !name.endsWith(LOG_SUFFIX)) continue;
|
|
75
|
+
const key = name.slice(LOG_PREFIX.length, -LOG_SUFFIX.length);
|
|
76
|
+
// YYYY-MM-DD
|
|
77
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) continue;
|
|
78
|
+
const stamp = Date.parse(`${key}T00:00:00`);
|
|
79
|
+
if (!Number.isFinite(stamp) || stamp >= cutoff) continue;
|
|
80
|
+
unlinkSync(join(dir, name));
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// pruning is best-effort
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function appendLogLine(message: string, date: Date = new Date()): void {
|
|
88
|
+
try {
|
|
89
|
+
const dir = ensureLogDir();
|
|
90
|
+
pruneOldLogs(dir);
|
|
91
|
+
const path = getDailyLogPath(date);
|
|
92
|
+
const line = message.endsWith("\n") ? message : `${message}\n`;
|
|
93
|
+
appendFileSync(path, line, "utf-8");
|
|
94
|
+
} catch {
|
|
95
|
+
// never break CLI for logging failures
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function formatError(err: unknown): string {
|
|
100
|
+
if (err instanceof Error) {
|
|
101
|
+
return `${err.name}: ${err.message}${err.stack ? `\n${err.stack}` : ""}`;
|
|
102
|
+
}
|
|
103
|
+
return String(err);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type ConsoleMethod = "log" | "info" | "warn" | "error" | "debug";
|
|
107
|
+
|
|
108
|
+
function installConsoleTee(write: (chunk: string) => void): () => void {
|
|
109
|
+
const methods: ConsoleMethod[] = ["log", "info", "warn", "error", "debug"];
|
|
110
|
+
const originals = new Map<ConsoleMethod, (...args: unknown[]) => void>();
|
|
111
|
+
|
|
112
|
+
for (const method of methods) {
|
|
113
|
+
const original = console[method].bind(console);
|
|
114
|
+
originals.set(method, original);
|
|
115
|
+
console[method] = (...args: unknown[]) => {
|
|
116
|
+
original(...args);
|
|
117
|
+
try {
|
|
118
|
+
const text = args
|
|
119
|
+
.map((arg) => {
|
|
120
|
+
if (typeof arg === "string") return arg;
|
|
121
|
+
try {
|
|
122
|
+
return JSON.stringify(arg);
|
|
123
|
+
} catch {
|
|
124
|
+
return String(arg);
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
.join(" ");
|
|
128
|
+
write(`[console.${method}] ${text}`);
|
|
129
|
+
} catch {
|
|
130
|
+
// ignore
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return () => {
|
|
136
|
+
for (const method of methods) {
|
|
137
|
+
const original = originals.get(method);
|
|
138
|
+
if (original) console[method] = original as typeof console.log;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Run a CLI command with daily-file logging.
|
|
145
|
+
* Captures console output, start/end markers, duration, and full error stacks.
|
|
146
|
+
*/
|
|
147
|
+
export async function runWithCommandLog(
|
|
148
|
+
command: string,
|
|
149
|
+
args: string[],
|
|
150
|
+
handler: () => Promise<void>,
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
const startedAt = new Date();
|
|
153
|
+
const startedMs = Date.now();
|
|
154
|
+
const logPath = getDailyLogPath(startedAt);
|
|
155
|
+
const write = (message: string) => {
|
|
156
|
+
appendLogLine(`[${new Date().toISOString()}] ${message}`, startedAt);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
write(`=== START command=${command} ===`);
|
|
160
|
+
write(`log_file=${logPath}`);
|
|
161
|
+
write(`cwd=${process.cwd()}`);
|
|
162
|
+
write(`argv=${JSON.stringify(args)}`);
|
|
163
|
+
if (process.env.ZZHUB_PIPELINE_ROOT) {
|
|
164
|
+
write(`ZZHUB_PIPELINE_ROOT=${process.env.ZZHUB_PIPELINE_ROOT}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const restoreConsole = installConsoleTee(write);
|
|
168
|
+
try {
|
|
169
|
+
await handler();
|
|
170
|
+
write(`=== OK command=${command} duration_ms=${Date.now() - startedMs} ===`);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
write(`=== FAIL command=${command} duration_ms=${Date.now() - startedMs} ===`);
|
|
173
|
+
write(formatError(err));
|
|
174
|
+
throw err;
|
|
175
|
+
} finally {
|
|
176
|
+
restoreConsole();
|
|
177
|
+
}
|
|
178
|
+
}
|
package/src/plugins.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { syncBlog } from "./commands/sync-blog";
|
|
|
25
25
|
import { tasks } from "./commands/tasks";
|
|
26
26
|
import { topic } from "./commands/topic";
|
|
27
27
|
import { wechatExport } from "./commands/wechat-export";
|
|
28
|
+
import { wechatPreview } from "./commands/wechat-preview";
|
|
28
29
|
import { wxDrafts } from "./commands/wx-drafts";
|
|
29
30
|
import { wxDraftDelete } from "./commands/wx-draft-delete";
|
|
30
31
|
|
|
@@ -71,6 +72,7 @@ export function getCommandPlugins(): CommandPlugin[] {
|
|
|
71
72
|
{ name: "republish", summary: "Publish completed task to additional accounts/platforms", plugin: "ops", handler: republish },
|
|
72
73
|
{ name: "imgx", summary: "Run bundled imgx renderer subcommands", plugin: "ops", handler: imgxCommand },
|
|
73
74
|
{ name: "wechat-export", summary: "Render markdown to WeChat HTML with bundled preview styles", plugin: "ops", handler: wechatExport },
|
|
75
|
+
{ name: "wechat-preview", summary: "Local singleton server to preview exported WeChat HTML", plugin: "ops", handler: wechatPreview },
|
|
74
76
|
{ name: "cos-upload", summary: "Upload a local image to configured COS CDN", plugin: "ops", handler: cosUpload },
|
|
75
77
|
{ name: "config", summary: "Read or update pipeline config", plugin: "ops", handler: configCommand },
|
|
76
78
|
{ name: "doctor", summary: "Inspect resolved paths and provider health", plugin: "ops", handler: doctor },
|
package/src/providers/blog.ts
CHANGED
|
@@ -141,8 +141,7 @@ export async function publishBlogRoute({
|
|
|
141
141
|
lines.push(` - ${tag}`);
|
|
142
142
|
}
|
|
143
143
|
}
|
|
144
|
-
|
|
145
|
-
lines.push(`author: ${author}`);
|
|
144
|
+
lines.push("author: Kairos");
|
|
146
145
|
lines.push("---");
|
|
147
146
|
|
|
148
147
|
const bodyWithFrontmatter = `${lines.join("\n")}\n\n${stripFrontmatter(postContent)}`;
|
package/src/providers/index.ts
CHANGED
|
@@ -79,7 +79,7 @@ async function publishWechatArticleRoute({
|
|
|
79
79
|
try {
|
|
80
80
|
const markdownRenderer = await resolveMarkdownRenderer(config);
|
|
81
81
|
const wxAccount = config.wx.accounts[account] ?? config.wx.accounts[config.wx.defaultAccount];
|
|
82
|
-
const
|
|
82
|
+
const rendered = await markdownRenderer.render({
|
|
83
83
|
markdownPath: exportPostPath,
|
|
84
84
|
outPath: workspacePaths.zotepadExportHtml,
|
|
85
85
|
account,
|
|
@@ -87,6 +87,34 @@ async function publishWechatArticleRoute({
|
|
|
87
87
|
customCss: resolveConfigRelativePath(wxAccount?.customCss),
|
|
88
88
|
themeOverrides: wxAccount?.theme,
|
|
89
89
|
});
|
|
90
|
+
const { html } = rendered;
|
|
91
|
+
|
|
92
|
+
// Register with local preview server unless explicitly disabled.
|
|
93
|
+
if (process.env.ZZHUB_WECHAT_PREVIEW_ON_PUBLISH !== "0") {
|
|
94
|
+
try {
|
|
95
|
+
const { registerPreviewEntry } = await import("../wechat-preview/server");
|
|
96
|
+
await registerPreviewEntry(
|
|
97
|
+
{
|
|
98
|
+
title: `[publish] ${state.metadata.title || "untitled"}`,
|
|
99
|
+
account,
|
|
100
|
+
status: "success",
|
|
101
|
+
duration_ms: rendered.durationMs ?? 0,
|
|
102
|
+
markdown_path: exportPostPath,
|
|
103
|
+
html_path: workspacePaths.zotepadExportHtml,
|
|
104
|
+
preview_style: rendered.previewStyle,
|
|
105
|
+
html,
|
|
106
|
+
debug: {
|
|
107
|
+
bundle_rebuilt: rendered.bundleRebuilt,
|
|
108
|
+
bundle_stale: rendered.bundleStale,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{ autoStart: process.env.ZZHUB_WECHAT_PREVIEW_AUTO_START !== "0" },
|
|
112
|
+
);
|
|
113
|
+
} catch {
|
|
114
|
+
// preview registration must never block publish
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
90
118
|
const response = await createWechatDraft({
|
|
91
119
|
account,
|
|
92
120
|
title: state.metadata.title,
|
package/src/schema/config.ts
CHANGED
|
@@ -39,6 +39,8 @@ const WechatExportThemeOverridesSchema = z.object({
|
|
|
39
39
|
fontFamily: z.string().optional(),
|
|
40
40
|
bodyColor: z.string().optional(),
|
|
41
41
|
mutedColor: z.string().optional(),
|
|
42
|
+
h2Color: z.string().optional(),
|
|
43
|
+
h3Color: z.string().optional(),
|
|
42
44
|
primaryColor: z.string().optional(),
|
|
43
45
|
dividerColor: z.string().optional(),
|
|
44
46
|
blockquoteBorderColor: z.string().optional(),
|
package/src/state.ts
CHANGED
|
@@ -370,6 +370,30 @@ export function reenterPublish(state: WorkflowState): void {
|
|
|
370
370
|
state.redo_hint = null;
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
+
/**
|
|
374
|
+
* Reset every derived artifact when the core inputs (body, title, route, intent,
|
|
375
|
+
* explicit constraints) have changed. The run identity (run_id/workspace_root/
|
|
376
|
+
* state_path) and the current title are preserved; everything produced by
|
|
377
|
+
* prepare/render/publish is wiped so the workflow re-runs from prepare.
|
|
378
|
+
*
|
|
379
|
+
* Used by ingest-handoff resume when it detects an upstream input change.
|
|
380
|
+
*/
|
|
381
|
+
export function resetDerivedState(state: WorkflowState): void {
|
|
382
|
+
state.mode = "active";
|
|
383
|
+
state.phase = defaultPhase();
|
|
384
|
+
state.asset_path = "";
|
|
385
|
+
state.formatted_body_path = null;
|
|
386
|
+
state.metadata = {
|
|
387
|
+
...defaultMetadata(),
|
|
388
|
+
title: state.metadata.title,
|
|
389
|
+
};
|
|
390
|
+
state.artifacts = defaultArtifacts();
|
|
391
|
+
state.images = defaultImages();
|
|
392
|
+
state.publish = defaultPublish();
|
|
393
|
+
state.content_review = defaultContentReview();
|
|
394
|
+
state.redo_hint = null;
|
|
395
|
+
}
|
|
396
|
+
|
|
373
397
|
// ── Run ID ────────────────────────────────────────────────────────
|
|
374
398
|
|
|
375
399
|
/**
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, writeFile } from "fs/promises";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
|
|
6
|
+
import { defaultState, writeState } from "./state";
|
|
7
|
+
import { loadTaskState } from "./task-manager";
|
|
8
|
+
|
|
9
|
+
const TEST_CONFIG_PATH = join(
|
|
10
|
+
tmpdir(),
|
|
11
|
+
`zzhub-pipeline-test-config-loadtask-${process.pid}.json`,
|
|
12
|
+
);
|
|
13
|
+
process.env.ZZHUB_PIPELINE_CONFIG = TEST_CONFIG_PATH;
|
|
14
|
+
|
|
15
|
+
async function makeTempDir(prefix: string): Promise<string> {
|
|
16
|
+
return mkdtemp(join(tmpdir(), prefix));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
afterAll(async () => {
|
|
20
|
+
await writeFile(TEST_CONFIG_PATH, "{}", "utf-8").catch(() => undefined);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("loadTaskState", () => {
|
|
24
|
+
test("returns a reconciled view when body_inputs drift from the body on disk", async () => {
|
|
25
|
+
const workspace = await makeTempDir("zzhub-loadtask-");
|
|
26
|
+
const statePath = join(workspace, "workflow-state.json");
|
|
27
|
+
const bodyPath = join(workspace, "post.md");
|
|
28
|
+
|
|
29
|
+
const state = defaultState();
|
|
30
|
+
state.run_id = "run-loadtask";
|
|
31
|
+
state.workspace_root = workspace;
|
|
32
|
+
state.state_path = statePath;
|
|
33
|
+
state.source_body_path = bodyPath;
|
|
34
|
+
state.route.primary = "wechat-article";
|
|
35
|
+
state.intent.content_form = "article";
|
|
36
|
+
state.metadata.title = "T";
|
|
37
|
+
state.metadata.slug = "t";
|
|
38
|
+
state.metadata.date = "2026-04-10";
|
|
39
|
+
state.phase.prepare.status = "done";
|
|
40
|
+
// State claims one illustration input is pending, but the rewritten body
|
|
41
|
+
// no longer contains any markers.
|
|
42
|
+
state.images.body_inputs = {
|
|
43
|
+
scope: "article",
|
|
44
|
+
expected: 1,
|
|
45
|
+
received: [],
|
|
46
|
+
status: "pending",
|
|
47
|
+
layout: "staggered",
|
|
48
|
+
};
|
|
49
|
+
await writeFile(bodyPath, "# T\n\nBody without any markers", "utf-8");
|
|
50
|
+
await writeState(statePath, state);
|
|
51
|
+
|
|
52
|
+
const resolved = await loadTaskState(statePath);
|
|
53
|
+
|
|
54
|
+
expect(resolved.state.images.body_inputs.status).toBe("none");
|
|
55
|
+
expect(resolved.state.images.body_inputs.expected).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
});
|
package/src/task-manager.ts
CHANGED
|
@@ -5,12 +5,26 @@ import { loadConfig, resolveWorkspacePaths, resolveWorkspaceRoot } from "./confi
|
|
|
5
5
|
import {
|
|
6
6
|
readResolvedState,
|
|
7
7
|
type PhaseName,
|
|
8
|
+
type ResolvedState,
|
|
8
9
|
type ValidationError,
|
|
9
10
|
validateForPhase,
|
|
10
11
|
type WorkflowState,
|
|
11
12
|
} from "./state";
|
|
12
13
|
import { reconcileStateArtifacts } from "./workflow-materials";
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Load workflow state and apply in-memory artifact reconciliation so every
|
|
17
|
+
* command sees the same reconciled view of body_inputs/render_assets that
|
|
18
|
+
* `status` produces. Use this instead of `readResolvedState` in commands that
|
|
19
|
+
* read state and then make a decision based on it. `writeState`'s own
|
|
20
|
+
* optimistic re-read deliberately stays un-reconciled.
|
|
21
|
+
*/
|
|
22
|
+
export async function loadTaskState(path: string): Promise<ResolvedState> {
|
|
23
|
+
const resolved = await readResolvedState(path);
|
|
24
|
+
await reconcileStateArtifacts(resolved.state);
|
|
25
|
+
return resolved;
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
export type TaskFileSource = "run" | "canonical";
|
|
15
29
|
|
|
16
30
|
export interface TaskGap {
|
|
@@ -491,14 +505,18 @@ function buildTaskStatus(summary: TaskSummary, state: WorkflowState): TaskStatus
|
|
|
491
505
|
},
|
|
492
506
|
};
|
|
493
507
|
} else {
|
|
508
|
+
// Reached only when prepare/render/publish all report done (or aren't
|
|
509
|
+
// required) yet mode/phase hasn't advanced to done — an inconsistent state.
|
|
510
|
+
// Re-running prepare here would reenterPrepare and wipe render_assets,
|
|
511
|
+
// forcing a wasteful re-render. Route to a read-only checkpoint instead.
|
|
494
512
|
nextAction = {
|
|
495
|
-
action: "
|
|
496
|
-
reason: "
|
|
497
|
-
executor: "
|
|
498
|
-
command: `zzhub-pipeline
|
|
513
|
+
action: "checkpoint",
|
|
514
|
+
reason: "Phases report done but the task is not marked complete; inspect state before mutating it.",
|
|
515
|
+
executor: "repair",
|
|
516
|
+
command: `zzhub-pipeline checkpoint --state "${summary.state_path}"`,
|
|
499
517
|
params: {
|
|
500
518
|
state_path: summary.state_path,
|
|
501
|
-
on_complete: "
|
|
519
|
+
on_complete: "Inspect the checkpoint output, then reset or repair the inconsistent phase before continuing.",
|
|
502
520
|
},
|
|
503
521
|
};
|
|
504
522
|
}
|