@zzclub/pipeline 0.10.0 → 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 +113 -8
- package/package.json +4 -4
- 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 +30 -19
- 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.test.ts +41 -0
- package/src/commands/wechat-export.ts +156 -20
- package/src/commands/wechat-preview.ts +160 -0
- package/src/config.ts +12 -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 +35 -3
- package/src/publish-targets.ts +4 -5
- package/src/routes.ts +9 -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/.vite/manifest.json +1 -302
- package/src/wechat-preview/assets/browser-dist/editor-export.js +500 -958
- package/src/wechat-preview/browser/assets.d.ts +9 -0
- package/src/wechat-preview/browser/editor-export.css +5 -326
- package/src/wechat-preview/browser/editor-export.ts +30 -65
- package/src/wechat-preview/bundle-stale.test.ts +52 -0
- package/src/wechat-preview/fixtures/all-nodes.md +68 -0
- package/src/wechat-preview/index.test.ts +17 -0
- package/src/wechat-preview/index.ts +424 -180
- 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 +58 -45
- package/src/wechat-preview/wechat-export.integration.test.ts +94 -0
- package/src/wechat-preview/wechat-renderer.links.test.ts +44 -0
- package/src/wechat-preview/wechat-renderer.test.ts +50 -0
- package/src/wechat-preview/wechat-renderer.ts +698 -0
- package/src/wechat-preview.test.ts +25 -41
- package/src/workflow-materials.test.ts +52 -1
- package/src/workflow-materials.ts +12 -0
- package/src/workflow.test.ts +105 -5
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/src/wechat-preview/assets/browser-dist/assets/style-vhHYSP8T.css +0 -1
- package/src/wechat-preview/assets/fonts/SweiCurveLegCJKsc-Regular.woff +0 -0
- package/src/wechat-preview/wechat-formatter.test.ts +0 -287
- package/src/wechat-preview/wechat-formatter.ts +0 -1531
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { flagArg, optionalArg, parseArgs, requireArg } from "../args";
|
|
2
4
|
import { printResult, renderWechatExport } from "../output";
|
|
3
|
-
import { loadConfig } from "../config";
|
|
5
|
+
import { loadConfig, resolveConfigRelativePath } from "../config";
|
|
4
6
|
import { resolveMarkdownRenderer } from "../adapter-loader";
|
|
7
|
+
import { WechatExportError } from "../wechat-preview";
|
|
8
|
+
import { registerPreviewEntry } from "../wechat-preview/server";
|
|
5
9
|
|
|
6
10
|
const CSS_DEMO = `
|
|
7
|
-
Custom CSS is
|
|
8
|
-
|
|
9
|
-
rendering pipeline captures computed styles from the DOM — your CSS
|
|
10
|
-
cascades naturally and the final inline-styled HTML will reflect it.
|
|
11
|
+
Custom CSS is cascaded after the built-in theme and converted to conservative
|
|
12
|
+
inline styles. Use semantic selectors or stable data-wechat-node hooks.
|
|
11
13
|
|
|
12
14
|
Example custom.css:
|
|
13
15
|
|
|
@@ -26,6 +28,32 @@ Example custom.css:
|
|
|
26
28
|
}
|
|
27
29
|
`.trim();
|
|
28
30
|
|
|
31
|
+
export function resolveWechatExportCustomCss(
|
|
32
|
+
cliCustomCss: string | undefined,
|
|
33
|
+
accountCustomCss: string | null | undefined,
|
|
34
|
+
cwd: string = process.cwd(),
|
|
35
|
+
configPath?: string,
|
|
36
|
+
): string | null {
|
|
37
|
+
return cliCustomCss
|
|
38
|
+
? resolve(cwd, cliCustomCss)
|
|
39
|
+
: resolveConfigRelativePath(accountCustomCss, configPath);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function openUrl(url: string): Promise<void> {
|
|
43
|
+
const opener =
|
|
44
|
+
process.platform === "darwin"
|
|
45
|
+
? "open"
|
|
46
|
+
: process.platform === "win32"
|
|
47
|
+
? "cmd"
|
|
48
|
+
: "xdg-open";
|
|
49
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
50
|
+
try {
|
|
51
|
+
spawn(opener, args, { detached: true, stdio: "ignore" }).unref();
|
|
52
|
+
} catch {
|
|
53
|
+
// non-fatal
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
29
57
|
export async function wechatExport(args: string[]): Promise<void> {
|
|
30
58
|
const parsed = parseArgs(args);
|
|
31
59
|
|
|
@@ -38,8 +66,14 @@ Options:
|
|
|
38
66
|
--out Path to exported html file (required)
|
|
39
67
|
--account Account/theme key (optional; default: default)
|
|
40
68
|
--title Page title for the render shell (optional)
|
|
41
|
-
--preview-shell-out Path to
|
|
42
|
-
--custom-css
|
|
69
|
+
--preview-shell-out Path to exact final-HTML preview (optional)
|
|
70
|
+
--custom-css CSS override path; replaces account customCss (optional)
|
|
71
|
+
--timeout-ms Chrome virtual-time-budget in ms (default: 15000)
|
|
72
|
+
--debug-dir Write intermediate artifacts for debugging (optional)
|
|
73
|
+
--preview Register result with local preview server (default: true)
|
|
74
|
+
--no-preview Do not register with preview server
|
|
75
|
+
--preview-auto-start Auto-start preview server if needed (default: true)
|
|
76
|
+
--open Open preview URL in browser after export
|
|
43
77
|
|
|
44
78
|
${CSS_DEMO}
|
|
45
79
|
`.trim());
|
|
@@ -51,20 +85,122 @@ ${CSS_DEMO}
|
|
|
51
85
|
const account = optionalArg(parsed, "account") ?? "default";
|
|
52
86
|
const title = optionalArg(parsed, "title");
|
|
53
87
|
const previewShellOutPath = optionalArg(parsed, "preview-shell-out");
|
|
54
|
-
const
|
|
88
|
+
const cliCustomCss = optionalArg(parsed, "custom-css");
|
|
89
|
+
const timeoutRaw = optionalArg(parsed, "timeout-ms");
|
|
90
|
+
const timeoutMs = timeoutRaw ? Number(timeoutRaw) : undefined;
|
|
91
|
+
const debugDir = optionalArg(parsed, "debug-dir");
|
|
92
|
+
const noPreview = flagArg(parsed, "no-preview");
|
|
93
|
+
// Default true unless --no-preview or --preview=false
|
|
94
|
+
const previewExplicit = parsed.preview;
|
|
95
|
+
const preview =
|
|
96
|
+
!noPreview &&
|
|
97
|
+
previewExplicit !== false &&
|
|
98
|
+
previewExplicit !== "false";
|
|
99
|
+
const previewAutoStartExplicit = parsed["preview-auto-start"];
|
|
100
|
+
const previewAutoStart =
|
|
101
|
+
previewAutoStartExplicit !== false &&
|
|
102
|
+
previewAutoStartExplicit !== "false";
|
|
103
|
+
const open = flagArg(parsed, "open");
|
|
55
104
|
|
|
56
105
|
const config = loadConfig();
|
|
57
106
|
const wxAccount = config.wx.accounts[account] ?? config.wx.accounts[config.wx.defaultAccount];
|
|
107
|
+
const customCss = resolveWechatExportCustomCss(
|
|
108
|
+
cliCustomCss,
|
|
109
|
+
wxAccount?.customCss,
|
|
110
|
+
);
|
|
58
111
|
const markdownRenderer = await resolveMarkdownRenderer(config);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
112
|
+
|
|
113
|
+
const displayTitle = title ?? "Wechat Preview Export";
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const result = await markdownRenderer.render({
|
|
117
|
+
markdownPath,
|
|
118
|
+
outPath,
|
|
119
|
+
account,
|
|
120
|
+
title,
|
|
121
|
+
previewShellOutPath,
|
|
122
|
+
customCss,
|
|
123
|
+
themeOverrides: wxAccount?.theme,
|
|
124
|
+
timeoutMs: timeoutMs !== undefined && Number.isFinite(timeoutMs) ? timeoutMs : undefined,
|
|
125
|
+
debugDir: debugDir ? resolve(process.cwd(), debugDir) : undefined,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
let preview_url: string | undefined;
|
|
129
|
+
let preview_dashboard_url: string | undefined;
|
|
130
|
+
let preview_register_error: string | undefined;
|
|
131
|
+
|
|
132
|
+
if (preview) {
|
|
133
|
+
const reg = await registerPreviewEntry(
|
|
134
|
+
{
|
|
135
|
+
title: displayTitle,
|
|
136
|
+
account: result.account,
|
|
137
|
+
status: "success",
|
|
138
|
+
duration_ms: result.durationMs ?? 0,
|
|
139
|
+
markdown_path: resolve(process.cwd(), markdownPath),
|
|
140
|
+
html_path: result.htmlPath,
|
|
141
|
+
preview_style: result.previewStyle,
|
|
142
|
+
html: result.html,
|
|
143
|
+
debug: {
|
|
144
|
+
bundle_rebuilt: result.bundleRebuilt,
|
|
145
|
+
bundle_stale: result.bundleStale,
|
|
146
|
+
debug_dir: result.debugDir,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{ autoStart: previewAutoStart },
|
|
150
|
+
);
|
|
151
|
+
if (reg.ok) {
|
|
152
|
+
preview_url = reg.preview_url;
|
|
153
|
+
preview_dashboard_url = reg.dashboard_url;
|
|
154
|
+
if (open && preview_url) await openUrl(preview_url);
|
|
155
|
+
} else {
|
|
156
|
+
preview_register_error = reg.error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Keep semanticHtml out of default CLI output (large); it still lands in --debug-dir.
|
|
161
|
+
const { semanticHtml: _semanticHtml, ...resultRest } = result;
|
|
162
|
+
printResult(
|
|
163
|
+
{
|
|
164
|
+
...resultRest,
|
|
165
|
+
preview_url,
|
|
166
|
+
preview_dashboard_url,
|
|
167
|
+
preview_register_error,
|
|
168
|
+
},
|
|
169
|
+
renderWechatExport,
|
|
170
|
+
);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (preview && error instanceof WechatExportError) {
|
|
173
|
+
const reg = await registerPreviewEntry(
|
|
174
|
+
{
|
|
175
|
+
title: error.title ?? displayTitle,
|
|
176
|
+
account: error.account,
|
|
177
|
+
status: "failed",
|
|
178
|
+
duration_ms: error.durationMs,
|
|
179
|
+
markdown_path: error.markdownPath,
|
|
180
|
+
error: error.message,
|
|
181
|
+
error_kind: error.kind,
|
|
182
|
+
debug: error.debug,
|
|
183
|
+
},
|
|
184
|
+
{ autoStart: previewAutoStart },
|
|
185
|
+
);
|
|
186
|
+
if (reg.ok) {
|
|
187
|
+
printResult(
|
|
188
|
+
{
|
|
189
|
+
ok: false,
|
|
190
|
+
error: error.message,
|
|
191
|
+
error_kind: error.kind,
|
|
192
|
+
duration_ms: error.durationMs,
|
|
193
|
+
debug: error.debug,
|
|
194
|
+
preview_url: reg.preview_url,
|
|
195
|
+
preview_dashboard_url: reg.dashboard_url,
|
|
196
|
+
},
|
|
197
|
+
renderWechatExport,
|
|
198
|
+
);
|
|
199
|
+
if (open && reg.preview_url) await openUrl(reg.preview_url);
|
|
200
|
+
process.exitCode = 1;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
70
206
|
}
|
|
@@ -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/config.ts
CHANGED
|
@@ -85,6 +85,18 @@ export function getPipelineConfigPath(): string {
|
|
|
85
85
|
return join(getBaseConfigDir(PIPELINE_CONFIG_DIR), PIPELINE_CONFIG_FILE);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/** Resolve a file path stored in config relative to the config file directory. */
|
|
89
|
+
export function resolveConfigRelativePath(
|
|
90
|
+
value: string | null | undefined,
|
|
91
|
+
configPath: string = getPipelineConfigPath(),
|
|
92
|
+
): string | null {
|
|
93
|
+
const trimmed = value?.trim();
|
|
94
|
+
if (!trimmed) return null;
|
|
95
|
+
return isAbsolute(trimmed)
|
|
96
|
+
? resolve(trimmed)
|
|
97
|
+
: resolve(dirname(configPath), trimmed);
|
|
98
|
+
}
|
|
99
|
+
|
|
88
100
|
export function getLegacyZCliConfigPath(): string {
|
|
89
101
|
return join(getBaseConfigDir(LEGACY_ZCLI_CONFIG_DIR), PIPELINE_CONFIG_FILE);
|
|
90
102
|
}
|
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 },
|