Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 25 additions & 13 deletions src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string>();
const writeGenerated = (filePath: string, content: string) => {
const fullPath = path.join(autogenPath, filePath);
generatedPaths.add(fullPath);
return createFile(fullPath, content);
};

const generatorConfig: GeneratorConfig = {
language,
rootFolder,
Expand All @@ -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;

Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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;

Expand All @@ -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.
Expand Down
33 changes: 33 additions & 0 deletions src/lib/utils/file-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
): 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,
Expand Down
77 changes: 77 additions & 0 deletions test/commands/generate.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading