diff --git a/docs/changelog.md b/docs/changelog.md index d41fbc4..620a8cc 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,12 @@ User-visible deltas as [ROADMAP.md](ROADMAP.md) items land. The roadmap is the s ## Unreleased +### #28 — Support current seed4j per-module history files + +- **Shipped:** 2026-06-09 +- **User impact:** `remove_module` now supports both seed4j history layouts: legacy `.seed4j/modules/history.json` and current timestamped per-module JSON files such as `.seed4j/modules/20260609195013012-maven-java.json`. Preview and replay behavior stay the same; confirmed removal rewrites legacy `history.json` or deletes the matching per-module action file. +- **Docs touched:** [tools.md](tools.md), [seed4j-api.md](seed4j-api.md). + ### #27 — Add a remove-module prompt - **Shipped:** 2026-05-29 diff --git a/docs/seed4j-api.md b/docs/seed4j-api.md index 4cdde76..6928a57 100644 --- a/docs/seed4j-api.md +++ b/docs/seed4j-api.md @@ -225,20 +225,18 @@ Spring Boot Actuator info endpoint. Used by `ping_seed4j` as a best-effort versi ## Project-local files -seed4j persists project history inside the project folder. The MCP server reads this file directly (and, for the `remove_module` tool, writes it back) — there is no HTTP endpoint to mutate history. +seed4j persists project history inside the project folder. The MCP server reads these files directly (and, for the `remove_module` tool, updates them) — there is no HTTP endpoint to mutate history. -### `.seed4j/modules/history.json` +### `.seed4j/modules` History Files -**Source:** [`FileSystemProjectsRepository.java`](https://github.com/seed4j/seed4j/blob/main/src/main/java/com/seed4j/project/infrastructure/secondary/FileSystemProjectsRepository.java) (`HISTORY_FOLDER = ".seed4j/modules"`, `HISTORY_FILE = "history.json"`), [`PersistedProjectHistory.java`](https://github.com/seed4j/seed4j/blob/main/src/main/java/com/seed4j/project/infrastructure/secondary/PersistedProjectHistory.java), [`PersistedProjectAction.java`](https://github.com/seed4j/seed4j/blob/main/src/main/java/com/seed4j/project/infrastructure/secondary/PersistedProjectAction.java). - -**Shape:** +**Legacy shape:** older seed4j versions store all actions in `.seed4j/modules/history.json`. ```jsonc { "actions": [ { "module": "string", // module slug (e.g. "init", "maven-java") - "date": "string", // ISO-8601 instant (Jackson Instant serialiser) + "date": "string", // ISO-8601 instant "properties": { /* Map — the properties the module was applied with */ }, @@ -247,12 +245,35 @@ seed4j persists project history inside the project folder. The MCP server reads } ``` -**Consumed by:** `remove_module` reads the file to determine which modules are applied and with what per-action properties; on a successful confirmed removal it writes the file back **atomically** (temp file in `.seed4j/modules/` + `rename`) with the targeted action filtered out. `get_project_status` indirectly relies on this file too — seed4j's `GET /api/projects` derives `RestProjectHistory` from it. +**Current shape:** recent seed4j versions store one JSON file per applied module in `.seed4j/modules/`, ordered by the timestamp prefix in the filename. + +Examples: + +```text +20260609195012528-init.json +20260609195013012-maven-java.json +20260609195013668-spring-boot.json +``` + +Each file contains one action: + +```jsonc +{ + "module": "string", + "date": "string", + "properties": { + /* Map — the properties the module was applied with */ + }, +} +``` + +**Consumed by:** `remove_module` reads either layout to determine which modules are applied and with what per-action properties. On a successful confirmed removal it writes legacy `history.json` back **atomically** (temp file in `.seed4j/modules/` + `rename`) or deletes the matching timestamped action file for current per-module history. `get_project_status` indirectly relies on this history too — seed4j's `GET /api/projects` derives `RestProjectHistory` from it. **Notes:** -- Missing file or unparseable JSON → `remove_module` returns `action: "not-applied"`. -- The MCP server is **not** holding a lock on the file. If seed4j is concurrently mutating it during a `remove_module` call, the atomic rename minimises the corruption window but cannot eliminate it. Operators running both side-by-side should serialise their operations. +- Missing history files or unparseable legacy JSON → `remove_module` returns `action: "not-applied"`. +- Malformed per-module JSON files are skipped; valid timestamped files are still used. +- The MCP server is **not** holding a lock on the history files. If seed4j is concurrently mutating them during a `remove_module` call, operators running both side-by-side should serialise their operations. - The on-disk file shape is part of seed4j's persistence layer rather than its public HTTP API; re-verify it against the seed4j source when bumping the seed4j version. ## Endpoints we don't use diff --git a/docs/tools.md b/docs/tools.md index fcc65d7..c12be95 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -161,7 +161,7 @@ Set `commit: true` when scaffolding a project end-to-end and the caller wants a ### `remove_module` - **Input:** `moduleSlug: string`, `projectFolder: string`, `confirm?: boolean` (default `false`), `force?: boolean` (default `false`). -- **Behaviour:** removes a previously-applied module by reading the project's [`.seed4j/modules/history.json`](seed4j-api.md#seed4jmoduleshistoryjson), replaying the full history twice into scratch dirs — final state with the target and final state without — using each action's **own** properties, and diffing both against the current project folder. Classifies each touched file as **clean** (current bytes match the generated final state with the target) or **locally-modified** (current bytes differ — typically business code the user added on top of the scaffold). With `confirm: true`, deletes clean added files, reverts clean modified files to their generated final state without the target, skips locally-modified files (unless `force: true`), and writes `.seed4j/modules/history.json` back atomically with the targeted action removed. Cost: up to `2 × N - 1` apply-patch calls, where N is the number of applied modules. `.git/` and `.seed4j/` are excluded from the diff on every side. +- **Behaviour:** removes a previously-applied module by reading the project's [`.seed4j/modules` history files](seed4j-api.md#seed4jmodules-history-files), replaying the full history twice into scratch dirs — final state with the target and final state without — using each action's **own** properties, and diffing both against the current project folder. Classifies each touched file as **clean** (current bytes match the generated final state with the target) or **locally-modified** (current bytes differ — typically business code the user added on top of the scaffold). With `confirm: true`, deletes clean added files, reverts clean modified files to their generated final state without the target, skips locally-modified files (unless `force: true`), and updates the seed4j history by either rewriting legacy `.seed4j/modules/history.json` atomically or deleting the target timestamped module JSON file. Cost: up to `2 × N - 1` apply-patch calls, where N is the number of applied modules. `.git/` and `.seed4j/` are excluded from the diff on every side. - **Output (preview, default):** ```json { diff --git a/src/client.ts b/src/client.ts index b92448f..1fe2c7d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -123,17 +123,22 @@ const REMOVE_SKIP_SEGMENTS = new Set([".git", ".seed4j"]); const SEED4J_HISTORY_DIR = ".seed4j/modules"; const SEED4J_HISTORY_FILE = "history.json"; +const SEED4J_HISTORY_JSON_SUFFIX = ".json"; // Contract: docs/seed4j-api.md#project-local-files -// .seed4j/modules/history.json shape inside a seed4j project folder. +// seed4j stores history either as legacy .seed4j/modules/history.json or as one +// timestamp-prefixed JSON file per applied module in .seed4j/modules/. interface SeedHistoryAction { module: string; date: string; properties: Properties; } +type SeedHistoryStorage = { kind: "legacy" } | { kind: "module-files"; actionFiles: string[] }; + interface SeedHistory { actions: SeedHistoryAction[]; + storage: SeedHistoryStorage; } interface RemoveModuleOptions { @@ -544,8 +549,9 @@ export class Seed4jClient { const updatedHistory: SeedHistory = { actions: history.actions.filter((_: SeedHistoryAction, i: number) => i !== actionIndex), + storage: history.storage, }; - await writeSeed4jHistoryAtomic(projectFolder, updatedHistory); + await removeSeed4jHistoryAction(projectFolder, history, updatedHistory, actionIndex); return JSON.stringify({ moduleSlug, @@ -1067,13 +1073,18 @@ async function walkInto( } async function readSeed4jHistory(projectFolder: string): Promise { - const filePath = path.join(projectFolder, SEED4J_HISTORY_DIR, SEED4J_HISTORY_FILE); + const dirPath = path.join(projectFolder, SEED4J_HISTORY_DIR); + const filePath = path.join(dirPath, SEED4J_HISTORY_FILE); let raw: string; try { raw = await readFile(filePath, "utf8"); + return parseLegacySeed4jHistory(raw); } catch { - return null; + return readSeed4jModuleFileHistory(dirPath); } +} + +function parseLegacySeed4jHistory(raw: string): SeedHistory | null { let parsed: unknown; try { parsed = JSON.parse(raw); @@ -1082,32 +1093,88 @@ async function readSeed4jHistory(projectFolder: string): Promise; - const module = typeof candidate.module === "string" ? candidate.module : null; - if (!module) continue; - const date = typeof candidate.date === "string" ? candidate.date : ""; - const properties = - candidate.properties && typeof candidate.properties === "object" - ? (candidate.properties as Properties) - : {}; - actions.push({ module, date, properties }); + const action = parseSeed4jHistoryAction(item); + if (action) actions.push(action); } - return { actions }; + return { actions, storage: { kind: "legacy" } }; } -async function writeSeed4jHistoryAtomic( +async function readSeed4jModuleFileHistory(dirPath: string): Promise { + let entries; + try { + entries = await readdir(dirPath, { withFileTypes: true }); + } catch { + return null; + } + const files = entries + .filter( + (entry) => + entry.isFile() && + entry.name !== SEED4J_HISTORY_FILE && + entry.name.endsWith(SEED4J_HISTORY_JSON_SUFFIX), + ) + .map((entry) => entry.name) + .sort(); + + if (files.length === 0) return null; + + const actions: SeedHistoryAction[] = []; + const actionFiles: string[] = []; + for (const file of files) { + const raw = await readFile(path.join(dirPath, file), "utf8").catch(() => null); + if (raw === null) continue; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + continue; + } + + const action = parseSeed4jHistoryAction(parsed); + if (!action) continue; + actions.push(action); + actionFiles.push(file); + } + + return { actions, storage: { kind: "module-files", actionFiles } }; +} + +function parseSeed4jHistoryAction(item: unknown): SeedHistoryAction | null { + if (!item || typeof item !== "object") return null; + const candidate = item as Record; + const module = typeof candidate.module === "string" ? candidate.module : null; + if (!module) return null; + const date = typeof candidate.date === "string" ? candidate.date : ""; + const properties = + candidate.properties && typeof candidate.properties === "object" + ? (candidate.properties as Properties) + : {}; + return { module, date, properties }; +} + +async function removeSeed4jHistoryAction( projectFolder: string, - history: SeedHistory, + previousHistory: SeedHistory, + updatedHistory: SeedHistory, + actionIndex: number, ): Promise { + if (previousHistory.storage.kind === "module-files") { + const file = previousHistory.storage.actionFiles[actionIndex]; + if (file) { + await rm(path.join(projectFolder, SEED4J_HISTORY_DIR, file), { force: true }); + } + return; + } + const dirPath = path.join(projectFolder, SEED4J_HISTORY_DIR); await mkdir(dirPath, { recursive: true }); const finalPath = path.join(dirPath, SEED4J_HISTORY_FILE); const tempPath = path.join(dirPath, `.${SEED4J_HISTORY_FILE}.tmp-${process.pid}-${Date.now()}`); - const body = `${JSON.stringify(history, null, 2)}\n`; + const body = `${JSON.stringify({ actions: updatedHistory.actions }, null, 2)}\n`; await writeFile(tempPath, body); await rename(tempPath, finalPath); } diff --git a/src/tools.ts b/src/tools.ts index 1af00d1..6a650f9 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -198,19 +198,19 @@ export function buildTools(client: Seed4jClient): ToolDefinition[] { { name: "remove_module", description: - "Remove a previously-applied seed4j module from a project: identifies the files that module installed (by replaying the project's history twice — with and without the target — into scratch dirs), classifies them as clean-since-install vs locally-modified (the latter typically contains business code the user added on top of the scaffold), and either previews or executes deletion/revert. Default mode is preview — no disk mutation. Set confirm: true to execute. By default, locally-modified files are skipped; set force: true to act on them too. On a successful confirm, also updates .seed4j/modules/history.json so get_project_status reflects the removal. The operation is heavyweight: replays ~2N apply-patch calls (N = number of applied modules in the project's history). Before flipping confirm: true, surface the locallyModifiedFiles list to the user — those are the files the caller will skip or destroy.", + "Remove a previously-applied seed4j module from a project: identifies the files that module installed (by replaying the project's history twice — with and without the target — into scratch dirs), classifies them as clean-since-install vs locally-modified (the latter typically contains business code the user added on top of the scaffold), and either previews or executes deletion/revert. Default mode is preview — no disk mutation. Set confirm: true to execute. By default, locally-modified files are skipped; set force: true to act on them too. On a successful confirm, also updates .seed4j/modules history so get_project_status reflects the removal; legacy history.json and current timestamped per-module JSON files are both supported. The operation is heavyweight: replays ~2N apply-patch calls (N = number of applied modules in the project's history). Before flipping confirm: true, surface the locallyModifiedFiles list to the user — those are the files the caller will skip or destroy.", inputSchema: { moduleSlug: z.string().describe("Slug identifier of the seed4j module to remove."), projectFolder: z .string() .describe( - "Absolute path to the project folder. Must contain a `.seed4j/modules/history.json` (otherwise the call returns action: 'not-applied').", + "Absolute path to the project folder. Must contain seed4j module history in `.seed4j/modules/history.json` or timestamped `.seed4j/modules/*.json` files (otherwise the call returns action: 'not-applied').", ), confirm: z .boolean() .optional() .describe( - "Default false. When false (or omitted), returns a preview only — no disk mutation. When true, actually deletes/reverts the files and updates history.json.", + "Default false. When false (or omitted), returns a preview only — no disk mutation. When true, actually deletes/reverts the files and updates seed4j module history.", ), force: z .boolean() diff --git a/tests/client.test.ts b/tests/client.test.ts index d6bfb14..ab660ca 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -834,7 +834,31 @@ describe("Seed4jClient", () => { ); } - return { newTmp, cleanup, buildClient, plantHistory }; + async function plantModuleFileHistory( + projectFolder: string, + actions: Array<{ module: string; properties?: Record }>, + ) { + const dir = path.join(projectFolder, ".seed4j", "modules"); + await mkdir(dir, { recursive: true }); + for (const [i, action] of actions.entries()) { + const date = `2026-06-09T19:50:${String(i).padStart(2, "0")}Z`; + const timestamp = `202606091950${String(i).padStart(5, "0")}`; + await writeFile( + path.join(dir, `${timestamp}-${action.module}.json`), + JSON.stringify( + { + module: action.module, + date, + properties: action.properties ?? {}, + }, + null, + 2, + ), + ); + } + } + + return { newTmp, cleanup, buildClient, plantHistory, plantModuleFileHistory }; } it("returns action: 'not-applied' when history.json is missing", async () => { @@ -906,6 +930,42 @@ describe("Seed4jClient", () => { } }); + it("reads timestamped module history files when history.json is absent", async () => { + const fx = removeFixture(); + try { + const projectFolder = await fx.newTmp(); + await fx.plantModuleFileHistory(projectFolder, [ + { module: "init", properties: { baseName: "demo" } }, + { module: "maven-java", properties: { packageName: "com.example" } }, + ]); + await writeFile(path.join(projectFolder, "pom.xml"), ""); + + const capturedProperties: Array<{ slug: string; props: Record }> = []; + const remover = fx.buildClient({ + init: async (_dir, props) => { + capturedProperties.push({ slug: "init", props }); + }, + "maven-java": async (dir, props) => { + capturedProperties.push({ slug: "maven-java", props }); + await writeFile(path.join(dir, "pom.xml"), ""); + }, + }); + + const payload = JSON.parse(await remover.removeModule("maven-java", projectFolder, {})); + expect(payload.action).toBe("preview"); + expect(payload.filesToDelete).toEqual([{ path: "pom.xml", sizeBytes: "".length }]); + expect(payload.historyUpdate).toEqual({ currentActions: 2, afterRemoval: 1 }); + expect(capturedProperties.filter((c) => c.slug === "init")[0]?.props).toEqual({ + baseName: "demo", + }); + expect(capturedProperties.filter((c) => c.slug === "maven-java")[0]?.props).toEqual({ + packageName: "com.example", + }); + } finally { + await fx.cleanup(); + } + }); + it("preview flags a locally-modified added file (not in filesToDelete)", async () => { const fx = removeFixture(); try { @@ -1039,6 +1099,42 @@ describe("Seed4jClient", () => { } }); + it("confirm deletes the matching timestamped module history file", async () => { + const fx = removeFixture(); + try { + const projectFolder = await fx.newTmp(); + await fx.plantModuleFileHistory(projectFolder, [ + { module: "init" }, + { module: "maven-java" }, + ]); + await writeFile(path.join(projectFolder, "pom.xml"), ""); + + const remover = fx.buildClient({ + init: async () => undefined, + "maven-java": async (dir) => { + await writeFile(path.join(dir, "pom.xml"), ""); + }, + }); + + const payload = JSON.parse( + await remover.removeModule("maven-java", projectFolder, { confirm: true }), + ); + expect(payload.action).toBe("removed"); + expect(payload.historyUpdated).toBe(true); + + await expect( + access( + path.join(projectFolder, ".seed4j", "modules", "20260609195000001-maven-java.json"), + ), + ).rejects.toThrow(); + await expect( + access(path.join(projectFolder, ".seed4j", "modules", "20260609195000000-init.json")), + ).resolves.toBeUndefined(); + } finally { + await fx.cleanup(); + } + }); + it("confirm without force skips locally-modified files and leaves them on disk", async () => { const fx = removeFixture(); try {