@zzclub/pipeline 0.9.0 → 0.10.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/package.json +1 -1
- package/src/adapter-loader.ts +6 -1
- package/src/commands/analytics.test.ts +3 -2
- package/src/commands/analytics.ts +21 -4
- package/src/commands/attach-body-images.ts +23 -3
- package/src/commands/attach-body.ts +12 -3
- package/src/commands/attach-newspic-spec.ts +14 -3
- package/src/commands/config.ts +1 -0
- package/src/commands/ingest-handoff.ts +12 -6
- package/src/commands/init.ts +20 -36
- package/src/commands/prepare-finalize.ts +75 -43
- package/src/commands/prepare.ts +44 -17
- package/src/commands/publish.ts +63 -29
- package/src/commands/reconcile.ts +5 -3
- package/src/commands/render.ts +50 -9
- package/src/commands/republish.ts +73 -30
- package/src/commands/reset.test.ts +43 -0
- package/src/commands/reset.ts +25 -32
- package/src/commands/review.ts +5 -3
- package/src/commands/sync-blog.ts +20 -3
- package/src/config.ts +0 -0
- package/src/providers/blog.test.ts +37 -1
- package/src/providers/blog.ts +51 -13
- package/src/providers/cos.ts +23 -19
- package/src/providers/index.ts +28 -22
- package/src/providers/publish-core.ts +47 -26
- package/src/providers/wechat.test.ts +55 -0
- package/src/providers/wechat.ts +126 -14
- package/src/publish-targets.ts +143 -0
- package/src/schema/state.ts +1 -0
- package/src/state.test.ts +151 -0
- package/src/state.ts +267 -11
- package/src/task-manager.ts +62 -29
- package/src/workflow-materials.test.ts +46 -4
- package/src/workflow-materials.ts +41 -7
- package/src/workflow.test.ts +295 -23
package/package.json
CHANGED
package/src/adapter-loader.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { PipelineConfig } from "./schema/config";
|
|
10
|
+
import { isAbsolute, resolve } from "path";
|
|
11
|
+
import { pathToFileURL } from "url";
|
|
10
12
|
import type {
|
|
11
13
|
ImageRenderPlugin,
|
|
12
14
|
MarkdownRenderPlugin,
|
|
@@ -49,7 +51,10 @@ function validateMarkdownRenderPlugin(plugin: unknown, specifier: string): Markd
|
|
|
49
51
|
|
|
50
52
|
async function importPlugin(specifier: string): Promise<unknown> {
|
|
51
53
|
try {
|
|
52
|
-
const
|
|
54
|
+
const importSpecifier = specifier.startsWith(".") || isAbsolute(specifier)
|
|
55
|
+
? pathToFileURL(resolve(specifier)).href
|
|
56
|
+
: specifier;
|
|
57
|
+
const mod = await import(importSpecifier);
|
|
53
58
|
// Support both default exports and named `plugin` exports
|
|
54
59
|
return mod.default ?? mod.plugin ?? mod;
|
|
55
60
|
} catch (err) {
|
|
@@ -123,11 +123,12 @@ describe("analytics commands", () => {
|
|
|
123
123
|
|
|
124
124
|
test("filters by days", async () => {
|
|
125
125
|
const statePath = join(workspace, "state.json");
|
|
126
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
126
127
|
await writeFile(statePath, JSON.stringify({
|
|
127
128
|
run_id: "run_001",
|
|
128
|
-
metadata: { title: "Article", date:
|
|
129
|
+
metadata: { title: "Article", date: today },
|
|
129
130
|
route: { primary: "wechat-article" },
|
|
130
|
-
publish: { results: { wechat: { status: "success", published_at:
|
|
131
|
+
publish: { results: { wechat: { status: "success", published_at: `${today}T10:00:00Z` } } },
|
|
131
132
|
}));
|
|
132
133
|
|
|
133
134
|
await recordAnalytics({ statePath, reads: 1000 });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile } from "fs/promises";
|
|
2
|
-
import { dirname } from "path";
|
|
2
|
+
import { dirname, isAbsolute, resolve } from "path";
|
|
3
3
|
import { ensureDb } from "../db";
|
|
4
4
|
import { AnalyticsSchema, type Analytics } from "../schema/analytics";
|
|
5
5
|
import { parseArgs, requireArg, optionalArg } from "../args";
|
|
@@ -26,10 +26,27 @@ function resolveWorkspace(statePath: string): string {
|
|
|
26
26
|
export async function recordAnalytics(
|
|
27
27
|
input: RecordAnalyticsInput,
|
|
28
28
|
): Promise<Analytics> {
|
|
29
|
-
const
|
|
30
|
-
|
|
29
|
+
const requestedPath = resolve(input.statePath);
|
|
30
|
+
let statePath = requestedPath;
|
|
31
|
+
let state = JSON.parse(await readFile(statePath, "utf-8"));
|
|
32
|
+
if (typeof state.state_path === "string" && state.state_path.trim()) {
|
|
33
|
+
const candidatePath = isAbsolute(state.state_path)
|
|
34
|
+
? resolve(state.state_path)
|
|
35
|
+
: resolve(dirname(statePath), state.state_path);
|
|
36
|
+
if (candidatePath !== statePath) {
|
|
37
|
+
try {
|
|
38
|
+
state = JSON.parse(await readFile(candidatePath, "utf-8"));
|
|
39
|
+
statePath = candidatePath;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
const code = error instanceof Error && "code" in error
|
|
42
|
+
? String((error as NodeJS.ErrnoException).code)
|
|
43
|
+
: "";
|
|
44
|
+
if (code !== "ENOENT") throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
31
48
|
|
|
32
|
-
const workspace = resolveWorkspace(
|
|
49
|
+
const workspace = state.workspace_root || resolveWorkspace(statePath);
|
|
33
50
|
const db = ensureDb(workspace);
|
|
34
51
|
|
|
35
52
|
try {
|
|
@@ -2,7 +2,13 @@ import { readFile } from "fs/promises";
|
|
|
2
2
|
|
|
3
3
|
import { parseArgs, optionalArg, requireArg } from "../args";
|
|
4
4
|
import { printResult, renderTaskShape } from "../output";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
readResolvedState,
|
|
7
|
+
reenterPublish,
|
|
8
|
+
reenterRender,
|
|
9
|
+
writeState,
|
|
10
|
+
type BodyInputReceived,
|
|
11
|
+
} from "../state";
|
|
6
12
|
import { getTaskByStatePath } from "../task-manager";
|
|
7
13
|
import { reconcileStateArtifacts } from "../workflow-materials";
|
|
8
14
|
|
|
@@ -52,12 +58,15 @@ Options:
|
|
|
52
58
|
return;
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
const
|
|
61
|
+
const requestedStatePath = requireArg(parsed, "state", "state JSON path");
|
|
56
62
|
const imagesFile = requireArg(parsed, "images-file", "JSON file with marker/path data");
|
|
57
63
|
const scope = optionalArg(parsed, "scope");
|
|
58
64
|
const layout = optionalArg(parsed, "layout");
|
|
59
65
|
|
|
60
|
-
const
|
|
66
|
+
const resolved = await readResolvedState(requestedStatePath);
|
|
67
|
+
const statePath = resolved.path;
|
|
68
|
+
const state = resolved.state;
|
|
69
|
+
const prepareWasDone = state.phase.prepare.status === "done";
|
|
61
70
|
const raw = JSON.parse(await readFile(imagesFile, "utf-8")) as unknown;
|
|
62
71
|
const incoming = parseReceivedImages(raw);
|
|
63
72
|
const merged = new Map(state.images.body_inputs.received.map((item) => [item.marker, item.path]));
|
|
@@ -74,6 +83,17 @@ Options:
|
|
|
74
83
|
}
|
|
75
84
|
|
|
76
85
|
await reconcileStateArtifacts(state);
|
|
86
|
+
if (prepareWasDone && state.images.body_inputs.scope === "newspic-longform") {
|
|
87
|
+
reenterRender(state);
|
|
88
|
+
} else if (
|
|
89
|
+
prepareWasDone &&
|
|
90
|
+
state.images.body_inputs.scope === "article" &&
|
|
91
|
+
(state.phase.current === "publish" ||
|
|
92
|
+
state.phase.current === "done" ||
|
|
93
|
+
state.phase.publish.status === "done")
|
|
94
|
+
) {
|
|
95
|
+
reenterPublish(state);
|
|
96
|
+
}
|
|
77
97
|
await writeState(statePath, state);
|
|
78
98
|
|
|
79
99
|
const task = await getTaskByStatePath(statePath);
|
|
@@ -4,7 +4,7 @@ import { extname, join, resolve } from "path";
|
|
|
4
4
|
import { parseArgs, optionalArg, requireArg } from "../args";
|
|
5
5
|
import { printResult, renderTaskShape } from "../output";
|
|
6
6
|
import { resolveWorkspacePaths } from "../config";
|
|
7
|
-
import {
|
|
7
|
+
import { readResolvedState, reenterPrepare, writeState } from "../state";
|
|
8
8
|
import { getTaskByStatePath } from "../task-manager";
|
|
9
9
|
import { reconcileStateArtifacts } from "../workflow-materials";
|
|
10
10
|
|
|
@@ -48,7 +48,7 @@ Options:
|
|
|
48
48
|
return;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
const
|
|
51
|
+
const requestedStatePath = requireArg(parsed, "state", "state JSON path");
|
|
52
52
|
const bodyPath = optionalArg(parsed, "body");
|
|
53
53
|
const bodyText = optionalArg(parsed, "body-text");
|
|
54
54
|
if (!bodyPath && bodyText === undefined) {
|
|
@@ -58,13 +58,22 @@ Options:
|
|
|
58
58
|
throw new Error("Use either --body or --body-text, not both");
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
const
|
|
61
|
+
const resolved = await readResolvedState(requestedStatePath);
|
|
62
|
+
const statePath = resolved.path;
|
|
63
|
+
const state = resolved.state;
|
|
62
64
|
state.source_body_path = await stageManagedBodyFile(
|
|
63
65
|
state.workspace_root,
|
|
64
66
|
state.run_id,
|
|
65
67
|
bodyPath ?? null,
|
|
66
68
|
bodyText ?? null,
|
|
67
69
|
);
|
|
70
|
+
reenterPrepare(state, {
|
|
71
|
+
clearFormattedBody: true,
|
|
72
|
+
resetReview: true,
|
|
73
|
+
});
|
|
74
|
+
if (state.handoff.review_policy === "trust_user") {
|
|
75
|
+
state.content_review = { status: "passed", feedback: null };
|
|
76
|
+
}
|
|
68
77
|
await reconcileStateArtifacts(state);
|
|
69
78
|
await writeState(statePath, state);
|
|
70
79
|
|
|
@@ -2,7 +2,12 @@ import { readFile } from "fs/promises";
|
|
|
2
2
|
|
|
3
3
|
import { parseArgs, requireArg } from "../args";
|
|
4
4
|
import { printResult, renderTaskShape } from "../output";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
normalizeNewspicRenderSpec,
|
|
7
|
+
readResolvedState,
|
|
8
|
+
reenterRender,
|
|
9
|
+
writeState,
|
|
10
|
+
} from "../state";
|
|
6
11
|
import { getTaskByStatePath } from "../task-manager";
|
|
7
12
|
import { reconcileStateArtifacts } from "../workflow-materials";
|
|
8
13
|
|
|
@@ -20,13 +25,19 @@ Options:
|
|
|
20
25
|
return;
|
|
21
26
|
}
|
|
22
27
|
|
|
23
|
-
const
|
|
28
|
+
const requestedStatePath = requireArg(parsed, "state", "state JSON path");
|
|
24
29
|
const specPath = requireArg(parsed, "file", "newspic render spec file");
|
|
25
30
|
|
|
26
|
-
const
|
|
31
|
+
const resolved = await readResolvedState(requestedStatePath);
|
|
32
|
+
const statePath = resolved.path;
|
|
33
|
+
const state = resolved.state;
|
|
34
|
+
const prepareWasDone = state.phase.prepare.status === "done";
|
|
27
35
|
const spec = JSON.parse(await readFile(specPath, "utf-8")) as unknown;
|
|
28
36
|
state.intent.newspic_render = normalizeNewspicRenderSpec(spec);
|
|
29
37
|
await reconcileStateArtifacts(state);
|
|
38
|
+
if (prepareWasDone) {
|
|
39
|
+
reenterRender(state);
|
|
40
|
+
}
|
|
30
41
|
await writeState(statePath, state);
|
|
31
42
|
|
|
32
43
|
const task = await getTaskByStatePath(statePath);
|
package/src/commands/config.ts
CHANGED
|
@@ -67,6 +67,7 @@ Options:
|
|
|
67
67
|
wx: mergedWx,
|
|
68
68
|
cos: { ...config.cos, ...(importedObj.cos ?? {}) },
|
|
69
69
|
plugins: { ...config.plugins, ...(importedObj.plugins ?? {}) },
|
|
70
|
+
imgx: { ...config.imgx, ...(importedObj.imgx ?? {}) },
|
|
70
71
|
};
|
|
71
72
|
// Validate through Zod schema — strips unknown fields, applies defaults
|
|
72
73
|
const merged = PipelineConfigSchema.parse(raw);
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
defaultState,
|
|
16
16
|
generateRunId,
|
|
17
17
|
getRunStatePath,
|
|
18
|
-
|
|
18
|
+
readResolvedState,
|
|
19
19
|
writeState,
|
|
20
20
|
type ContentForm,
|
|
21
21
|
type HandoffAuthoringPolicy,
|
|
@@ -473,10 +473,16 @@ Options:
|
|
|
473
473
|
handoff.mode === "resume"
|
|
474
474
|
? await resolveExistingStatePath(workspace, handoff)
|
|
475
475
|
: getRunStatePath(workspace, generateRunId());
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
476
|
+
const resolvedExisting = handoff.mode === "resume"
|
|
477
|
+
? await readResolvedState(statePath)
|
|
478
|
+
: null;
|
|
479
|
+
const effectiveStatePath = resolvedExisting?.path ?? statePath;
|
|
480
|
+
const state = resolvedExisting?.state ??
|
|
481
|
+
buildNewState(
|
|
482
|
+
workspace,
|
|
483
|
+
effectiveStatePath,
|
|
484
|
+
effectiveStatePath.split("/").pop()?.replace(/\.json$/, "") ?? generateRunId(),
|
|
485
|
+
);
|
|
480
486
|
|
|
481
487
|
if (handoff.mode === "new" && !state.run_id) {
|
|
482
488
|
state.run_id = generateRunId();
|
|
@@ -484,7 +490,7 @@ Options:
|
|
|
484
490
|
}
|
|
485
491
|
|
|
486
492
|
await applyHandoffToState(state, handoff);
|
|
487
|
-
await writeState(state.state_path, state);
|
|
493
|
+
await writeState(state.state_path || effectiveStatePath, state);
|
|
488
494
|
|
|
489
495
|
const task = await getTaskByStatePath(state.state_path);
|
|
490
496
|
printResult({
|
package/src/commands/init.ts
CHANGED
|
@@ -28,17 +28,19 @@ import { parseArgs, requireArg, optionalArg, flagArg } from "../args";
|
|
|
28
28
|
import { printResult, renderInit } from "../output";
|
|
29
29
|
import { loadConfig, resolveWorkspaceRoot } from "../config";
|
|
30
30
|
import { resolveFullRoute } from "../routes";
|
|
31
|
+
import {
|
|
32
|
+
parseAccountName,
|
|
33
|
+
parseContentForm,
|
|
34
|
+
parseContentOrigin,
|
|
35
|
+
parsePublishTargets,
|
|
36
|
+
parseTaskKind,
|
|
37
|
+
} from "../publish-targets";
|
|
31
38
|
import {
|
|
32
39
|
defaultState,
|
|
33
40
|
generateRunId,
|
|
34
41
|
getRunStatePath,
|
|
35
42
|
normalizeNewspicRenderSpec,
|
|
36
43
|
writeState,
|
|
37
|
-
type ContentForm,
|
|
38
|
-
type ContentOrigin,
|
|
39
|
-
type PublishTarget,
|
|
40
|
-
type Target,
|
|
41
|
-
type TaskKind,
|
|
42
44
|
} from "../state";
|
|
43
45
|
|
|
44
46
|
export async function init(args: string[]): Promise<void> {
|
|
@@ -67,10 +69,10 @@ Options:
|
|
|
67
69
|
return;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
|
-
const taskKind = requireArg(parsed, "task-kind", "task kind")
|
|
71
|
-
const contentForm = requireArg(parsed, "content-form", "content form")
|
|
72
|
+
const taskKind = parseTaskKind(requireArg(parsed, "task-kind", "task kind"));
|
|
73
|
+
const contentForm = parseContentForm(requireArg(parsed, "content-form", "content form"));
|
|
72
74
|
const targetsRaw = requireArg(parsed, "targets", "publish targets");
|
|
73
|
-
const contentOrigin = requireArg(parsed, "content-origin", "content origin")
|
|
75
|
+
const contentOrigin = parseContentOrigin(requireArg(parsed, "content-origin", "content origin"));
|
|
74
76
|
const intentText = optionalArg(parsed, "intent-text") ?? "";
|
|
75
77
|
const accountOverride = optionalArg(parsed, "account");
|
|
76
78
|
const styleHint = optionalArg(parsed, "style-hint") ?? null;
|
|
@@ -80,31 +82,13 @@ Options:
|
|
|
80
82
|
const config = loadConfig();
|
|
81
83
|
const workspace = resolveWorkspaceRoot(optionalArg(parsed, "workspace"), config);
|
|
82
84
|
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const publishTargets
|
|
89
|
-
const
|
|
90
|
-
const defaultAccount = accountOverride || "default";
|
|
91
|
-
const mappedTargets: Target[] = [];
|
|
92
|
-
|
|
93
|
-
for (const part of targetParts) {
|
|
94
|
-
const atIdx = part.indexOf("@");
|
|
95
|
-
const route = atIdx !== -1 ? part.slice(0, atIdx).trim() : part;
|
|
96
|
-
const account = atIdx !== -1 ? part.slice(atIdx + 1).trim() : defaultAccount;
|
|
97
|
-
publishTargets.push({ route: route as any, account });
|
|
98
|
-
// Map RoutePrimary to Target enum: wechat-* → "wechat", blog → "blog"
|
|
99
|
-
mappedTargets.push(route.startsWith("wechat") ? "wechat" : route as Target);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Single target: leave publish_targets empty (backward compat)
|
|
103
|
-
// Multi-target: populate publish_targets
|
|
104
|
-
const isMultiTarget = publishTargets.length > 1 || targetsRaw.includes("@");
|
|
105
|
-
|
|
106
|
-
// Use mapped targets (Target enum) for intent, not raw RoutePrimary values
|
|
107
|
-
const intentTargets = mappedTargets.length > 0 ? mappedTargets : targets;
|
|
85
|
+
const defaultAccount = parseAccountName(accountOverride ?? "default");
|
|
86
|
+
const parsedTargets = parsePublishTargets(targetsRaw, {
|
|
87
|
+
contentForm,
|
|
88
|
+
defaultAccount,
|
|
89
|
+
});
|
|
90
|
+
const publishTargets = parsedTargets.targets;
|
|
91
|
+
const intentTargets = parsedTargets.intentTargets;
|
|
108
92
|
|
|
109
93
|
const runId = generateRunId();
|
|
110
94
|
const statePath = getRunStatePath(workspace, runId);
|
|
@@ -121,16 +105,16 @@ Options:
|
|
|
121
105
|
state.state_path = statePath;
|
|
122
106
|
state.mode = "active";
|
|
123
107
|
state.route = resolveFullRoute(intentText, {
|
|
124
|
-
account: accountOverride,
|
|
108
|
+
account: accountOverride ? defaultAccount : undefined,
|
|
125
109
|
contentForm,
|
|
126
110
|
targets: intentTargets,
|
|
127
111
|
});
|
|
128
112
|
|
|
129
113
|
// Set publish_targets if multi-target
|
|
130
|
-
if (
|
|
114
|
+
if (parsedTargets.explicit && publishTargets.length > 0) {
|
|
131
115
|
state.publish_targets = publishTargets;
|
|
132
116
|
// First target becomes primary route
|
|
133
|
-
state.route.primary = publishTargets[0].route
|
|
117
|
+
state.route.primary = publishTargets[0].route;
|
|
134
118
|
state.route.account = publishTargets[0].account;
|
|
135
119
|
}
|
|
136
120
|
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* Output: asset_path, canonical state_path
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { copyFile, readFile, writeFile, mkdir, rename, rm } from "fs/promises";
|
|
19
19
|
import { basename, dirname, isAbsolute, join, resolve } from "path";
|
|
20
20
|
import { parseArgs, requireArg, optionalArg } from "../args";
|
|
21
21
|
import { printResult, renderPrepareFinalize } from "../output";
|
|
@@ -26,7 +26,8 @@ import {
|
|
|
26
26
|
resolveWorkspaceRoot,
|
|
27
27
|
} from "../config";
|
|
28
28
|
import {
|
|
29
|
-
|
|
29
|
+
acquireStateOperationLock,
|
|
30
|
+
readResolvedState,
|
|
30
31
|
writeState,
|
|
31
32
|
getCanonicalStatePath,
|
|
32
33
|
} from "../state";
|
|
@@ -38,15 +39,6 @@ import {
|
|
|
38
39
|
removePageMarkers,
|
|
39
40
|
} from "../text";
|
|
40
41
|
|
|
41
|
-
async function pathExists(path: string): Promise<boolean> {
|
|
42
|
-
try {
|
|
43
|
-
await access(path);
|
|
44
|
-
return true;
|
|
45
|
-
} catch {
|
|
46
|
-
return false;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
42
|
function isExternalAssetRef(value: string): boolean {
|
|
51
43
|
return /^(https?:|data:|blob:|\/\/)/i.test(value);
|
|
52
44
|
}
|
|
@@ -140,13 +132,16 @@ async function materializeInlineAssets(options: {
|
|
|
140
132
|
};
|
|
141
133
|
|
|
142
134
|
let rewritten = options.body.replace(
|
|
143
|
-
/!\[([^\]]*)\]\((
|
|
144
|
-
(match, alt,
|
|
145
|
-
const targetRelativePath = registerAsset(String(
|
|
135
|
+
/!\[([^\]]*)\]\((?:<([^>]+)>|([^\s)]+))(\s+(?:"[^"]*"|'[^']*'))?\)/g,
|
|
136
|
+
(match, alt, angleSrc, bareSrc, titleSuffix) => {
|
|
137
|
+
const targetRelativePath = registerAsset(String(angleSrc ?? bareSrc ?? ""));
|
|
146
138
|
if (!targetRelativePath) {
|
|
147
139
|
return match;
|
|
148
140
|
}
|
|
149
|
-
|
|
141
|
+
const rewrittenSource = angleSrc
|
|
142
|
+
? `<${formatRelativeAssetRef(targetRelativePath)}>`
|
|
143
|
+
: formatRelativeAssetRef(targetRelativePath);
|
|
144
|
+
return ``;
|
|
150
145
|
},
|
|
151
146
|
);
|
|
152
147
|
|
|
@@ -193,11 +188,16 @@ Options:
|
|
|
193
188
|
return;
|
|
194
189
|
}
|
|
195
190
|
|
|
196
|
-
const
|
|
191
|
+
const requestedStatePath = requireArg(parsed, "state", "state JSON path");
|
|
197
192
|
const explicitBodyPath = optionalArg(parsed, "body");
|
|
198
193
|
const workspaceOverride = optionalArg(parsed, "workspace");
|
|
199
194
|
|
|
200
|
-
const
|
|
195
|
+
const initialResolved = await readResolvedState(requestedStatePath);
|
|
196
|
+
const releaseOperationLock = await acquireStateOperationLock(initialResolved.path);
|
|
197
|
+
try {
|
|
198
|
+
const resolved = await readResolvedState(initialResolved.path);
|
|
199
|
+
const statePath = resolved.path;
|
|
200
|
+
const state = resolved.state;
|
|
201
201
|
if (state.content_review.status !== "passed") {
|
|
202
202
|
throw new Error(
|
|
203
203
|
`content_review must be passed before prepare-finalize (current: ${state.content_review.status})`,
|
|
@@ -219,6 +219,18 @@ Options:
|
|
|
219
219
|
const config = loadConfig();
|
|
220
220
|
const workspace = resolveWorkspaceRoot(workspaceOverride ?? state.workspace_root, config);
|
|
221
221
|
const workspacePaths = resolveWorkspacePaths(workspace, config);
|
|
222
|
+
state.workspace_root = workspace;
|
|
223
|
+
|
|
224
|
+
const missingMetadata = [
|
|
225
|
+
["metadata.title", state.metadata.title],
|
|
226
|
+
["metadata.slug", state.metadata.slug],
|
|
227
|
+
["metadata.date", state.metadata.date],
|
|
228
|
+
].filter(([, value]) => !value);
|
|
229
|
+
if (missingMetadata.length > 0) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`prepare-finalize requires complete metadata: ${missingMetadata.map(([field]) => field).join(", ")}`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
222
234
|
|
|
223
235
|
// ── Step 1: Channel route 2nd pass — highlight_words ──
|
|
224
236
|
// If highlight_words were already set explicitly via `prepare --highlight-words`,
|
|
@@ -240,45 +252,52 @@ Options:
|
|
|
240
252
|
const baseTitle = state.metadata.title;
|
|
241
253
|
let nextSlug = baseSlug;
|
|
242
254
|
let nextTitle = baseTitle;
|
|
243
|
-
|
|
255
|
+
const baseRelativePath = renderPostsRelativePath(config, {
|
|
244
256
|
date: state.metadata.date,
|
|
245
257
|
slug: nextSlug,
|
|
246
258
|
title: nextTitle,
|
|
247
259
|
});
|
|
248
|
-
let
|
|
249
|
-
let suffix = 2;
|
|
260
|
+
let suffix = 1;
|
|
250
261
|
|
|
251
|
-
while (
|
|
252
|
-
if (patternUsesSlug) {
|
|
262
|
+
while (true) {
|
|
263
|
+
if (suffix > 1 && patternUsesSlug) {
|
|
253
264
|
nextSlug = `${baseSlug}-v${suffix}`;
|
|
254
|
-
|
|
255
|
-
date: state.metadata.date,
|
|
256
|
-
slug: nextSlug,
|
|
257
|
-
title: baseTitle,
|
|
258
|
-
});
|
|
259
|
-
} else if (patternUsesTitle) {
|
|
265
|
+
} else if (suffix > 1 && patternUsesTitle) {
|
|
260
266
|
nextTitle = `${baseTitle}-v${suffix}`;
|
|
261
|
-
relativePath = renderPostsRelativePath(config, {
|
|
262
|
-
date: state.metadata.date,
|
|
263
|
-
slug: baseSlug,
|
|
264
|
-
title: nextTitle,
|
|
265
|
-
});
|
|
266
|
-
} else {
|
|
267
|
-
relativePath = appendSuffixToRelativePath(relativePath, suffix);
|
|
268
267
|
}
|
|
269
|
-
|
|
270
|
-
|
|
268
|
+
const relativePath = suffix === 1
|
|
269
|
+
? baseRelativePath
|
|
270
|
+
: patternUsesSlug || patternUsesTitle
|
|
271
|
+
? renderPostsRelativePath(config, {
|
|
272
|
+
date: state.metadata.date,
|
|
273
|
+
slug: nextSlug,
|
|
274
|
+
title: nextTitle,
|
|
275
|
+
})
|
|
276
|
+
: appendSuffixToRelativePath(baseRelativePath, suffix);
|
|
277
|
+
const candidatePath = join(workspacePaths.postsRoot, relativePath);
|
|
278
|
+
await mkdir(dirname(candidatePath), { recursive: true });
|
|
279
|
+
try {
|
|
280
|
+
await mkdir(candidatePath);
|
|
281
|
+
assetPath = candidatePath;
|
|
282
|
+
break;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
const code = error instanceof Error && "code" in error
|
|
285
|
+
? String((error as NodeJS.ErrnoException).code)
|
|
286
|
+
: "";
|
|
287
|
+
if (code !== "EEXIST") {
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
suffix += 1;
|
|
291
|
+
}
|
|
271
292
|
}
|
|
272
293
|
|
|
273
294
|
if (patternUsesSlug && nextSlug !== state.metadata.slug) {
|
|
274
295
|
state.metadata.slug = nextSlug;
|
|
275
296
|
}
|
|
276
|
-
|
|
297
|
+
} else {
|
|
298
|
+
await mkdir(assetPath, { recursive: true });
|
|
277
299
|
}
|
|
278
300
|
|
|
279
|
-
// Create directories
|
|
280
|
-
await mkdir(assetPath, { recursive: true });
|
|
281
|
-
|
|
282
301
|
const bodySourceDir = dirname(state.source_body_path ?? bodyPath);
|
|
283
302
|
body = await materializeInlineAssets({
|
|
284
303
|
body,
|
|
@@ -312,7 +331,14 @@ Options:
|
|
|
312
331
|
// Write post.md
|
|
313
332
|
const postContent = `${frontmatter}\n\n${removePageMarkers(body)}`;
|
|
314
333
|
const postPath = join(assetPath, "post.md");
|
|
315
|
-
|
|
334
|
+
const postTempPath = `${postPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
335
|
+
try {
|
|
336
|
+
await writeFile(postTempPath, postContent, "utf-8");
|
|
337
|
+
await rename(postTempPath, postPath);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
await rm(postTempPath, { force: true }).catch(() => undefined);
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
316
342
|
|
|
317
343
|
// Update state
|
|
318
344
|
state.asset_path = assetPath;
|
|
@@ -322,6 +348,7 @@ Options:
|
|
|
322
348
|
state.phase.prepare = { status: "done", error: null };
|
|
323
349
|
state.phase.current = state.intent.requires.render ? "render" :
|
|
324
350
|
state.intent.requires.publish ? "publish" : "done";
|
|
351
|
+
state.mode = state.phase.current === "done" ? "done" : "active";
|
|
325
352
|
|
|
326
353
|
// Clear redo_hint — prepare sub-sequence is complete
|
|
327
354
|
state.redo_hint = null;
|
|
@@ -333,7 +360,9 @@ Options:
|
|
|
333
360
|
await writeState(state.state_path, state);
|
|
334
361
|
|
|
335
362
|
// Also update the temp run state to point to canonical
|
|
336
|
-
|
|
363
|
+
if (resolve(statePath) !== resolve(state.state_path)) {
|
|
364
|
+
await writeState(statePath, state);
|
|
365
|
+
}
|
|
337
366
|
|
|
338
367
|
// Output summary
|
|
339
368
|
const output = {
|
|
@@ -345,4 +374,7 @@ Options:
|
|
|
345
374
|
content_version: state.artifacts.content_version,
|
|
346
375
|
};
|
|
347
376
|
printResult(output, renderPrepareFinalize);
|
|
377
|
+
} finally {
|
|
378
|
+
await releaseOperationLock();
|
|
379
|
+
}
|
|
348
380
|
}
|