diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 3ca742f..c16d7c1 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -11,7 +11,7 @@ import { TargetLanguage, GeneratorConfig } from "../lib/types"; import BaseCommand from "../lib/base-command"; import { isInteractive, missingFlag } from "../lib/utils/interactive"; import { performance } from "perf_hooks"; -import { createFile } from "../lib/utils/file-system"; +import { createFile, removeStaleGeneratedFiles } from "../lib/utils/file-system"; import { installCoAuthorHook } from "../lib/utils/git-hooks"; import { ensureCrudDeps } from "../lib/utils/crud-deps"; import { upgradeOrmConfigForPglite } from "../lib/utils/pglite-upgrade"; @@ -108,6 +108,16 @@ export default class Generate extends BaseCommand { let generatedFileCount = 0; + // Every path this run writes. After generation, anything else under + // autogen/ that carries the Apso header belongs to an entity that was + // removed or renamed in .apsorc and gets deleted (apsoai/cli#157). + const generatedPaths = new Set(); + const writeGenerated = (filePath: string, content: string) => { + const fullPath = path.join(autogenPath, filePath); + generatedPaths.add(fullPath); + return createFile(fullPath, content); + }; + const generatorConfig: GeneratorConfig = { language, rootFolder, @@ -132,18 +142,16 @@ export default class Generate extends BaseCommand { // Generate enums const enumFiles = await generator.generateEnums(entities, lowerCaseApiType); for (const file of enumFiles) { - const fullPath = path.join(autogenPath, file.path); // eslint-disable-next-line no-await-in-loop - await createFile(fullPath, file.content); + await writeGenerated(file.path, file.content); } generatedFileCount += enumFiles.length; // Generate shared query utilities const queryUtilFiles = await generator.generateQueryUtils(entities, lowerCaseApiType); for (const file of queryUtilFiles) { - const fullPath = path.join(autogenPath, file.path); // eslint-disable-next-line no-await-in-loop - await createFile(fullPath, file.content); + await writeGenerated(file.path, file.content); } generatedFileCount += queryUtilFiles.length; @@ -232,9 +240,8 @@ export default class Generate extends BaseCommand { // eslint-disable-next-line no-await-in-loop for (const files of allFiles) { for (const file of files) { - const fullPath = path.join(autogenPath, file.path); // eslint-disable-next-line no-await-in-loop - await createFile(fullPath, file.content); + await writeGenerated(file.path, file.content); generatedFileCount += 1; } } @@ -248,9 +255,8 @@ export default class Generate extends BaseCommand { // Generate guards const guardFiles = await generator.generateGuards(entities, auth); for (const file of guardFiles) { - const fullPath = path.join(autogenPath, file.path); // eslint-disable-next-line no-await-in-loop - await createFile(fullPath, file.content); + await writeGenerated(file.path, file.content); } generatedFileCount += guardFiles.length; @@ -261,20 +267,26 @@ export default class Generate extends BaseCommand { { emitEvents } ); for (const file of domainEventFiles) { - const fullPath = path.join(autogenPath, file.path); // eslint-disable-next-line no-await-in-loop - await createFile(fullPath, file.content); + await writeGenerated(file.path, file.content); } // Generate index module const indexFiles = await generator.generateIndexModule(entities, lowerCaseApiType, { emitEvents, http }); for (const file of indexFiles) { - const fullPath = path.join(autogenPath, file.path); // eslint-disable-next-line no-await-in-loop - await createFile(fullPath, file.content); + await writeGenerated(file.path, file.content); } generatedFileCount += indexFiles.length; + // Skip cleanup when nothing was generated: an empty or missing .apsorc + // would otherwise wipe every generated file. + if (entities.length > 0) { + for (const removed of removeStaleGeneratedFiles(autogenPath, generatedPaths)) { + console.log(`[apso] Removed stale ${path.relative(process.cwd(), removed)}`); + } + } + // Format generated files (TypeScript only) with Biome — bundled with the // CLI and invoked directly, so it's a fast native pass (~ms) with no npx // download and no dependence on the project having a formatter installed. diff --git a/src/lib/utils/file-system.ts b/src/lib/utils/file-system.ts index aad2a48..c906c06 100644 --- a/src/lib/utils/file-system.ts +++ b/src/lib/utils/file-system.ts @@ -16,6 +16,39 @@ export const createFile = async ( await fs.promises.writeFile(file, content); }; +// Every file the CLI generates starts with this line (see templates/header.eta). +const APSO_HEADER = "Generated with Apso"; + +/** + * Delete files under `dir` that carry the Apso header but are not in `keep`, + * i.e. files left over from entities removed or renamed in .apsorc. Files + * without the header (template seeds like .gitkeep, hand-written code) are + * never touched. Directories emptied this way are removed too. Returns the + * deleted paths. + */ +export const removeStaleGeneratedFiles = ( + dir: string, + keep: Set +): string[] => { + if (!fs.existsSync(dir)) return []; + const removed: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + removed.push(...removeStaleGeneratedFiles(fullPath, keep)); + if (fs.readdirSync(fullPath).length === 0) fs.rmdirSync(fullPath); + } else if ( + entry.isFile() && + !keep.has(fullPath) && + fs.readFileSync(fullPath, "utf8").includes(APSO_HEADER) + ) { + fs.unlinkSync(fullPath); + removed.push(fullPath); + } + } + return removed; +}; + export const createDirectoryContents = ( templatePath: string, newProjectPath: string, diff --git a/test/commands/generate.test.ts b/test/commands/generate.test.ts new file mode 100644 index 0000000..f757919 --- /dev/null +++ b/test/commands/generate.test.ts @@ -0,0 +1,77 @@ +import { expect, describe, test, afterEach, jest } from "@jest/globals"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import Generate from "../../src/commands/generate"; + +// apsoai/cli#157: entities removed from .apsorc must lose their autogen files. +const entity = (name: string) => ({ + name, + created_at: true, + updated_at: true, + primaryKeyType: "serial", + fields: [{ name: "title", type: "text" }], +}); + +const writeApsorc = (dir: string, language: string, rootFolder: string, names: string[]) => + fs.writeFileSync( + path.join(dir, ".apsorc"), + JSON.stringify({ + version: 2, + rootFolder, + language, + apiType: "Rest", + entities: names.map((name) => entity(name)), + relationships: [], + }) + ); + +const listFiles = (dir: string): string[] => + fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter((e) => e.isFile()) + .map((e) => path.relative(dir, path.join(e.parentPath, e.name))) + .sort(); + +const cwd = process.cwd(); +afterEach(() => process.chdir(cwd)); + +describe("apso generate removes stale autogen files", () => { + test.each([ + ["typescript", "src"], + ["python", "app"], + ["go", "."], + ])("%s", async (language, rootFolder) => { + jest.spyOn(console, "log").mockReturnValue(); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "apso-generate-")); + process.chdir(dir); + const autogen = path.join(dir, rootFolder, "autogen"); + const extension = path.join(dir, rootFolder, "extensions", "Widget", "custom.txt"); + fs.mkdirSync(path.dirname(extension), { recursive: true }); + fs.writeFileSync(extension, "hand-written"); + // A template seed without the Apso header must survive cleanup. + fs.mkdirSync(autogen, { recursive: true }); + fs.writeFileSync(path.join(autogen, ".gitkeep"), ""); + + writeApsorc(dir, language, rootFolder, ["Gadget", "Widget"]); + await Generate.run(["--skip-format"]); + const before = listFiles(autogen); + const widgetFiles = before.filter((f) => /widget/i.test(f)); + expect(widgetFiles.length).toBeGreaterThan(0); + const gadgetFiles = before.filter((f) => /gadget/i.test(f)); + expect(gadgetFiles.length).toBeGreaterThan(0); + const gadgetContents = gadgetFiles.map((f) => fs.readFileSync(path.join(autogen, f), "utf8")); + + writeApsorc(dir, language, rootFolder, ["Gadget"]); + await Generate.run(["--skip-format"]); + const after = listFiles(autogen); + + expect(after.filter((f) => /widget/i.test(f))).toEqual([]); + expect(after.filter((f) => /gadget/i.test(f))).toEqual(gadgetFiles); + expect(gadgetFiles.map((f) => fs.readFileSync(path.join(autogen, f), "utf8"))).toEqual( + gadgetContents + ); + expect(after).toContain(".gitkeep"); + expect(fs.readFileSync(extension, "utf8")).toBe("hand-written"); + }); +});