Skip to content

Commit 12f9bff

Browse files
committed
fix(pipeline): clean partial install writes
1 parent ae46f5f commit 12f9bff

2 files changed

Lines changed: 105 additions & 2 deletions

File tree

src/cli/commands/pipeline-command.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2518,6 +2518,98 @@ describe("pipeline install command", () => {
25182518
);
25192519
});
25202520

2521+
it("cleans a partial steps.yaml when initial scaffold creation fails", async () => {
2522+
const stepsPath = "/repo/.poe-code/pipeline/steps.yaml";
2523+
const fs = createMemFs();
2524+
const originalWriteFile = fs.writeFile.bind(fs);
2525+
vi.spyOn(fs, "writeFile").mockImplementation(async (filePath, data, options) => {
2526+
if (String(filePath) === stepsPath) {
2527+
await originalWriteFile(filePath, "partial steps\n", options);
2528+
throw new Error("injected partial steps write failure");
2529+
}
2530+
await originalWriteFile(filePath, data, options);
2531+
});
2532+
const container = createCliContainer({
2533+
fs,
2534+
prompts: vi.fn().mockResolvedValue({}),
2535+
env: { cwd, homeDir },
2536+
logger: () => {}
2537+
});
2538+
const program = createBaseProgram();
2539+
registerPipelineCommand(program, container);
2540+
2541+
await expect(
2542+
program.parseAsync([
2543+
"node",
2544+
"cli",
2545+
"pipeline",
2546+
"install",
2547+
"--agent",
2548+
"claude-code",
2549+
"--local"
2550+
])
2551+
).rejects.toThrow("injected partial steps write failure");
2552+
2553+
await expect(fs.readFile(stepsPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
2554+
await expect(
2555+
fs.readFile("/repo/.claude/skills/poe-code-pipeline-plan/SKILL.md", "utf8")
2556+
).rejects.toThrow();
2557+
await expect(fs.stat("/repo/.poe-code/pipeline/plans")).rejects.toThrow();
2558+
});
2559+
2560+
it("cleans a partial forced steps temp file and restores the prior steps.yaml", async () => {
2561+
const stepsPath = "/repo/.poe-code/pipeline/steps.yaml";
2562+
const fs = createMemFs({
2563+
[stepsPath]: "EXISTING_STEPS"
2564+
});
2565+
const originalWriteFile = fs.writeFile.bind(fs);
2566+
let temporaryPath: string | undefined;
2567+
vi.spyOn(fs, "writeFile").mockImplementation(async (filePath, data, options) => {
2568+
const filePathText = String(filePath);
2569+
if (
2570+
temporaryPath === undefined &&
2571+
filePathText.startsWith(`${stepsPath}.${process.pid}.`) &&
2572+
filePathText.endsWith(".tmp")
2573+
) {
2574+
temporaryPath = filePathText;
2575+
await originalWriteFile(filePath, "partial forced steps\n", options);
2576+
throw new Error("injected forced steps write failure");
2577+
}
2578+
await originalWriteFile(filePath, data, options);
2579+
});
2580+
const container = createCliContainer({
2581+
fs,
2582+
prompts: vi.fn().mockResolvedValue({}),
2583+
env: { cwd, homeDir },
2584+
logger: () => {}
2585+
});
2586+
const program = createBaseProgram();
2587+
registerPipelineCommand(program, container);
2588+
2589+
await expect(
2590+
program.parseAsync([
2591+
"node",
2592+
"cli",
2593+
"pipeline",
2594+
"install",
2595+
"--agent",
2596+
"claude-code",
2597+
"--local",
2598+
"--force"
2599+
])
2600+
).rejects.toThrow("injected forced steps write failure");
2601+
2602+
expect(temporaryPath).toBeDefined();
2603+
await expect(fs.readFile(stepsPath, "utf8")).resolves.toBe("EXISTING_STEPS");
2604+
await expect(fs.readFile(temporaryPath as string, "utf8")).rejects.toMatchObject({
2605+
code: "ENOENT"
2606+
});
2607+
await expect(
2608+
fs.readFile("/repo/.claude/skills/poe-code-pipeline-plan/SKILL.md", "utf8")
2609+
).rejects.toThrow();
2610+
await expect(fs.stat("/repo/.poe-code/pipeline/plans")).rejects.toThrow();
2611+
});
2612+
25212613
it("does not install the skill or plans directory when steps creation fails", async () => {
25222614
const fs = createMemFs();
25232615
const container = createCliContainer({

src/cli/commands/pipeline.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -815,7 +815,14 @@ async function writePipelineTextFile(
815815
options: { exclusive: boolean }
816816
): Promise<void> {
817817
if (options.exclusive) {
818-
await fs.writeFile(filePath, content, { encoding: "utf8", flag: "wx" });
818+
try {
819+
await fs.writeFile(filePath, content, { encoding: "utf8", flag: "wx" });
820+
} catch (error) {
821+
if (!isAlreadyExists(error)) {
822+
await fs.unlink(filePath).catch(() => undefined);
823+
}
824+
throw error;
825+
}
819826
return;
820827
}
821828

@@ -826,13 +833,17 @@ async function writePipelineTextFile(
826833
temporaryCreated = true;
827834
await fs.rename(temporaryPath, filePath);
828835
} catch (error) {
829-
if (temporaryCreated) {
836+
if (temporaryCreated || !isAlreadyExists(error)) {
830837
await fs.unlink(temporaryPath).catch(() => undefined);
831838
}
832839
throw error;
833840
}
834841
}
835842

843+
function isAlreadyExists(error: unknown): boolean {
844+
return error instanceof Error && "code" in error && error.code === "EEXIST";
845+
}
846+
836847
export function registerPipelineCommand(program: Command, container: CliContainer): void {
837848
const pipeline = program
838849
.command("pipeline")

0 commit comments

Comments
 (0)