diff --git a/graphql/codegen/src/__tests__/codegen/expand-targets.test.ts b/graphql/codegen/src/__tests__/codegen/expand-targets.test.ts index 6d405cf4b..b1dbd34f4 100644 --- a/graphql/codegen/src/__tests__/codegen/expand-targets.test.ts +++ b/graphql/codegen/src/__tests__/codegen/expand-targets.test.ts @@ -2,7 +2,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { expandApiNamesToMultiTarget, expandSchemaDirToMultiTarget, removeStaleTargetDirs } from '../../core/generate'; +import { expandApiNamesToMultiTarget, expandSchemaDirToMultiTarget, removeStaleTargetDirs, GENERATED_SENTINEL } from '../../core/generate'; describe('expandApiNamesToMultiTarget', () => { it('returns null for no apiNames', () => { @@ -151,11 +151,25 @@ describe('removeStaleTargetDirs', () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); - it('removes directories not in current target list', () => { - fs.mkdirSync(path.join(tempDir, 'admin')); - fs.mkdirSync(path.join(tempDir, 'auth')); - fs.mkdirSync(path.join(tempDir, 'public')); - fs.mkdirSync(path.join(tempDir, 'objects')); + /** Create a directory with the .generated sentinel (codegen output). */ + function mkGeneratedDir(name: string) { + const dir = path.join(tempDir, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, GENERATED_SENTINEL), 'generated by codegen\n'); + } + + /** Create a plain directory without the sentinel (hand-written code). */ + function mkHandwrittenDir(name: string) { + const dir = path.join(tempDir, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'index.ts'), 'export function hello() {}\n'); + } + + it('removes generated directories not in current target list', () => { + mkGeneratedDir('admin'); + mkGeneratedDir('auth'); + mkGeneratedDir('public'); + mkGeneratedDir('objects'); const removed = removeStaleTargetDirs(tempDir, ['admin', 'auth']); @@ -166,8 +180,21 @@ describe('removeStaleTargetDirs', () => { expect(fs.existsSync(path.join(tempDir, 'objects'))).toBe(false); }); + it('preserves hand-written directories even when not in target list', () => { + mkGeneratedDir('admin'); + mkHandwrittenDir('config'); + mkHandwrittenDir('utils'); + + const removed = removeStaleTargetDirs(tempDir, ['admin']); + + expect(removed).toEqual([]); + expect(fs.existsSync(path.join(tempDir, 'admin'))).toBe(true); + expect(fs.existsSync(path.join(tempDir, 'config'))).toBe(true); + expect(fs.existsSync(path.join(tempDir, 'utils'))).toBe(true); + }); + it('preserves files (only removes directories)', () => { - fs.mkdirSync(path.join(tempDir, 'admin')); + mkGeneratedDir('admin'); fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export {}'); const removed = removeStaleTargetDirs(tempDir, ['admin']); @@ -182,19 +209,28 @@ describe('removeStaleTargetDirs', () => { }); it('returns empty array when no stale directories exist', () => { - fs.mkdirSync(path.join(tempDir, 'admin')); - fs.mkdirSync(path.join(tempDir, 'auth')); + mkGeneratedDir('admin'); + mkGeneratedDir('auth'); const removed = removeStaleTargetDirs(tempDir, ['admin', 'auth']); expect(removed).toEqual([]); }); - it('removes all directories when target list is empty', () => { - fs.mkdirSync(path.join(tempDir, 'old-target')); + it('removes all generated directories when target list is empty', () => { + mkGeneratedDir('old-target'); const removed = removeStaleTargetDirs(tempDir, []); expect(removed).toEqual(['old-target']); expect(fs.existsSync(path.join(tempDir, 'old-target'))).toBe(false); }); + + it('skips directories without sentinel', () => { + fs.mkdirSync(path.join(tempDir, 'empty-dir')); + + const removed = removeStaleTargetDirs(tempDir, []); + + expect(removed).toEqual([]); + expect(fs.existsSync(path.join(tempDir, 'empty-dir'))).toBe(true); + }); }); diff --git a/graphql/codegen/src/core/generate.ts b/graphql/codegen/src/core/generate.ts index 026d117f6..899019654 100644 --- a/graphql/codegen/src/core/generate.ts +++ b/graphql/codegen/src/core/generate.ts @@ -404,6 +404,12 @@ export async function generate( } allFilesWritten.push(...(writeResult.filesWritten ?? [])); + // Drop sentinel so removeStaleTargetDirs knows this is a generated dir + fs.writeFileSync( + path.join(outputRoot, GENERATED_SENTINEL), + `generated by @constructive-io/graphql-codegen\n`, + ); + if (skillsToWrite.length > 0) { const skillsOutputDir = resolveSkillsOutputDir(config, outputRoot); const skillsWriteResult = await writeGeneratedFiles(skillsToWrite, skillsOutputDir, [], { @@ -626,9 +632,13 @@ function applySharedPgpmDb( }; } +/** Sentinel file written into every generated target directory. */ +export const GENERATED_SENTINEL = '.generated'; + /** * Remove subdirectories in `outputRoot` that are not in `currentTargetNames`. - * Useful for cleaning up stale target output before a fresh multi-target generate. + * Only removes directories that contain a `.generated` sentinel file — hand-written + * directories (e.g. `config/`, `utils/`) are preserved automatically. * Returns the list of directory names that were removed. */ export function removeStaleTargetDirs( @@ -643,7 +653,14 @@ export function removeStaleTargetDirs( const entries = fs.readdirSync(outputRoot, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && !currentTargets.has(entry.name)) { - fs.rmSync(path.join(outputRoot, entry.name), { recursive: true, force: true }); + const dirPath = path.join(outputRoot, entry.name); + if (!fs.existsSync(path.join(dirPath, GENERATED_SENTINEL))) { + if (verbose) { + console.log(`Preserved non-generated directory: ${entry.name}`); + } + continue; + } + fs.rmSync(dirPath, { recursive: true, force: true }); removed.push(entry.name); if (verbose) { console.log(`Removed stale target directory: ${entry.name}`); diff --git a/graphql/codegen/src/index.ts b/graphql/codegen/src/index.ts index a8a417da4..8bff0db6a 100644 --- a/graphql/codegen/src/index.ts +++ b/graphql/codegen/src/index.ts @@ -23,7 +23,7 @@ export { defineConfig } from './types/config'; // Main generate function (orchestrates the entire pipeline) export type { GenerateOptions, GenerateResult, GenerateMultiOptions, GenerateMultiResult } from './core/generate'; -export { generate, generateMulti, expandApiNamesToMultiTarget, expandSchemaDirToMultiTarget, removeStaleTargetDirs } from './core/generate'; +export { generate, generateMulti, expandApiNamesToMultiTarget, expandSchemaDirToMultiTarget, removeStaleTargetDirs, GENERATED_SENTINEL } from './core/generate'; // Config utilities export { findConfigFile, loadConfigFile } from './core/config';