diff --git a/packages/cli/src/timeline/a2Commands.ts b/packages/cli/src/timeline/a2Commands.ts index 06e8021834..939fe3b3a0 100644 --- a/packages/cli/src/timeline/a2Commands.ts +++ b/packages/cli/src/timeline/a2Commands.ts @@ -5,7 +5,7 @@ import { } from "@hyperframes/studio-server"; import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import { join, relative } from "node:path"; import { describeProject, type ProjectTimeline, type TimelineRow } from "./describeProject.js"; import { resolveRef } from "./resolveRef.js"; import { parseTimeExpression } from "./timeExpr.js"; @@ -309,8 +309,25 @@ function readUndoInput(input: string): UndoInput { return { ok: true, receipts: values, batch: Array.isArray(value) }; } +function refuseUndoError(error: unknown, projectDir: string, json: boolean): void { + if (isRecord(error) && error.code === "ENOENT" && typeof error.path === "string") + return refuse( + "timeline undo", + { + reason: `undo file is missing: ${relative(projectDir, error.path).split("\\").join("/")}`, + fix: "restore the missing target or backup file before retrying undo", + }, + json, + ); + if (isFileChanged(error)) + return refusal(error.message, "read the latest timeline before retrying undo", json); + throw error; +} + export async function runUndo(args: Record): Promise { - const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined); + const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined, { + requireIndex: false, + }); const json = args.json === true; const input = typeof args.receipt === "string" ? args.receipt : positional(args)[1]; if (!input) @@ -321,23 +338,21 @@ export async function runUndo(args: Record): Promise { ); const undo = readUndoInput(input); if (!undo.ok) return refuse("timeline undo", undo, json); - const inputs = undo.receipts.map((receipt) => { - const target = join(project.dir, receipt.file); - return { - sourceFile: receipt.file, - absPath: target, - before: readFileSync(target, "utf-8"), - after: readFileSync(join(project.dir, receipt.backupPath), "utf-8"), - expectedVersion: receipt.version, - }; - }); let receipts: AppliedFileMutation[]; try { + const inputs = undo.receipts.map((receipt) => { + const target = join(project.dir, receipt.file); + return { + sourceFile: receipt.file, + absPath: target, + before: readFileSync(target, "utf-8"), + after: readFileSync(join(project.dir, receipt.backupPath), "utf-8"), + expectedVersion: receipt.version, + }; + }); receipts = applyFileMutations(project.dir, inputs); } catch (error) { - if (isFileChanged(error)) - return refusal(error.message, "read the latest timeline before retrying undo", json); - throw error; + return refuseUndoError(error, project.dir, json); } const result = { ok: true, diff --git a/packages/cli/src/timeline/timeline.e2e.test.ts b/packages/cli/src/timeline/timeline.e2e.test.ts index 34701fbfa3..9cfa23daf5 100644 --- a/packages/cli/src/timeline/timeline.e2e.test.ts +++ b/packages/cli/src/timeline/timeline.e2e.test.ts @@ -1,5 +1,13 @@ import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -778,6 +786,55 @@ describe("timeline edit command", () => { }, ); + it.each([ + { format: "single", missing: "target", index: 1 }, + { format: "single", missing: "backup", index: 1 }, + { format: "batch", missing: "target", index: 1 }, + { format: "batch", missing: "backup", index: 1 }, + { format: "single", missing: "target", index: 0 }, + { format: "batch", missing: "target", index: 0 }, + ])( + "refuses a $format undo when its $missing at index $index is missing", + ({ format, missing, index }) => { + const { dir, files, planPath } = batchProject(); + try { + const applied = run(dir, "apply", planPath); + expect(applied.status, applied.stderr).toBe(0); + const receipt: { file: string; backupPath: string } = JSON.parse(applied.stdout).receipt[ + index + ]; + const missingFile = missing === "target" ? receipt.file : receipt.backupPath; + const missingPath = join(dir, missingFile); + rmSync(missingPath); + const remainingFiles = files.filter((file) => join(dir, file) !== missingPath); + const sources = () => remainingFiles.map((file) => readFileSync(join(dir, file), "utf8")); + const current = sources(); + const backupDir = join(dir, ".hyperframes", "backup"); + const backups = () => + readdirSync(backupDir) + .sort() + .map((file) => [file, readFileSync(join(backupDir, file), "utf8")]); + const currentBackups = backups(); + const input = format === "single" ? JSON.stringify(receipt) : applied.stdout; + + const undone = run(dir, "undo", input); + + expect(undone.status, undone.stderr).toBe(2); + expect(undone.stdout).toBe(""); + expect(JSON.parse(undone.stderr)).toEqual({ + ok: false, + reason: `timeline undo: undo file is missing: ${missingFile}`, + fix: "restore the missing target or backup file before retrying undo", + }); + expect(sources()).toEqual(current); + expect(backups()).toEqual(currentBackups); + expect(existsSync(missingPath)).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + it("undoes a complete timeline ids response", () => { const dir = project(); try {