diff --git a/README.md b/README.md index ad214d5..6afad71 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ pnpm contrail lexicons generate pnpm contrail lexicons check ``` -Use `contrail lexicons all` to generate Contrail methods, pull referenced source Lexicons, and generate TypeScript types in one pass. The `pull` and `types` actions are also available separately. Contrail owns its config-specific query generation while delegating generic pulling and TypeScript generation to [Atcute](https://github.com/mary-ext/atcute). +Use `contrail lexicons all` to generate Contrail methods, pull referenced source Lexicons, and generate TypeScript types in one pass. The `pull` and `types` actions are also available separately. Contrail updates `lex.config.js` only when the file carries its generated marker; user-owned Atcute configuration is preserved. Pass `--no-atcute-config` to skip creating or checking that generated file. Contrail owns its config-specific query generation while delegating generic pulling and TypeScript generation to [Atcute](https://github.com/mary-ext/atcute). ## Other databases diff --git a/packages/contrail/README.md b/packages/contrail/README.md index d3520a8..b312637 100644 --- a/packages/contrail/README.md +++ b/packages/contrail/README.md @@ -89,7 +89,7 @@ pnpm contrail lexicons generate pnpm contrail lexicons check ``` -`contrail lexicons all` also pulls referenced record Lexicons and runs Atcute TypeScript generation. Use `--public` when generating only the collection methods intended for a public read surface. The generator is also exported from `@atmo-dev/contrail/lexicons` for programmatic use. +`contrail lexicons all` also pulls referenced record Lexicons and runs Atcute TypeScript generation. Use `--public` when generating only the collection methods intended for a public read surface. Generated `lex.config.js` files carry an ownership marker; existing user-owned Atcute configuration is never replaced. Pass `--no-atcute-config` to manage that file yourself. The generator is also exported from `@atmo-dev/contrail/lexicons` for programmatic use. ## Runtime record validation diff --git a/packages/contrail/src/cli/commands/lexicons.ts b/packages/contrail/src/cli/commands/lexicons.ts index 588fc0d..bad978a 100644 --- a/packages/contrail/src/cli/commands/lexicons.ts +++ b/packages/contrail/src/cli/commands/lexicons.ts @@ -16,6 +16,7 @@ interface LexiconOptions { root: string; output: string; public?: boolean; + atcuteConfig?: boolean; } function surface(options: LexiconOptions): LexiconSurface { @@ -29,6 +30,7 @@ async function generate(options: LexiconOptions) { rootDir: resolve(options.root), outputDir: resolve(options.root, options.output), surface: surface(options), + writeAtcuteConfig: options.atcuteConfig !== false, }); } @@ -44,6 +46,10 @@ export function registerLexicons(cli: CAC): void { default: join("lexicons", "generated"), }) .option("--public", "Generate only methods exposed by public read mode") + .option( + "--no-atcute-config", + "Do not create or update a generated lex.config.js", + ) .action(async (action: string, options: LexiconOptions) => { const root = resolve(options.root); if (action === "generate") { @@ -57,6 +63,7 @@ export function registerLexicons(cli: CAC): void { rootDir: root, outputDir: resolve(root, options.output), surface: surface(options), + writeAtcuteConfig: options.atcuteConfig !== false, }); console.log("Contrail Lexicons are current."); return; diff --git a/packages/contrail/src/lexicons/generate.ts b/packages/contrail/src/lexicons/generate.ts index 0804a4f..7458d6a 100644 --- a/packages/contrail/src/lexicons/generate.ts +++ b/packages/contrail/src/lexicons/generate.ts @@ -483,12 +483,28 @@ function calculatePullNsids( return [...values].sort(); } -function atcuteConfiguration(pullNsids: string[]): string { - return `import { defineLexiconConfig } from "@atcute/lex-cli";\n\nexport default defineLexiconConfig({\n generate: {\n files: [\n "lexicons/custom/**/*.json",\n "lexicons/pulled/**/*.json",\n "lexicons/generated/**/*.json",\n ],\n outdir: "src/lexicon-types/",\n },\n pull: {\n outdir: "lexicons/pulled/",\n clean: true,\n sources: [\n {\n type: "atproto",\n mode: "nsids",\n nsids: ${JSON.stringify(pullNsids, null, 2).replace(/^/gm, " ").trim()},\n },\n ],\n },\n});\n`; -} +const GENERATED_ATCUTE_CONFIG_HEADER = + "// Generated by `contrail lexicons generate`. Re-run the command to update; do not edit.\n"; -function writeAtcuteConfiguration(rootDir: string, pullNsids: string[]) { - writeFileSync(join(rootDir, "lex.config.js"), atcuteConfiguration(pullNsids)); +function atcuteConfiguration(pullNsids: string[]): string { + return `${GENERATED_ATCUTE_CONFIG_HEADER}import { defineLexiconConfig } from "@atcute/lex-cli";\n\nexport default defineLexiconConfig({\n generate: {\n files: [\n "lexicons/custom/**/*.json",\n "lexicons/pulled/**/*.json",\n "lexicons/generated/**/*.json",\n ],\n outdir: "src/lexicon-types/",\n },\n pull: {\n outdir: "lexicons/pulled/",\n clean: true,\n sources: [\n {\n type: "atproto",\n mode: "nsids",\n nsids: ${JSON.stringify(pullNsids, null, 2).replace(/^/gm, " ").trim()},\n },\n ],\n },\n});\n`; +} + +function writeAtcuteConfiguration(rootDir: string, pullNsids: string[]): boolean { + const path = join(rootDir, "lex.config.js"); + const source = atcuteConfiguration(pullNsids); + if (existsSync(path)) { + const current = readFileSync(path, "utf8"); + const legacyGeneratedSource = source.slice(GENERATED_ATCUTE_CONFIG_HEADER.length); + if ( + !current.startsWith(GENERATED_ATCUTE_CONFIG_HEADER) && + current !== legacyGeneratedSource + ) { + return false; + } + } + writeFileSync(path, source); + return true; } function writeBundle( @@ -923,8 +939,11 @@ export function generateLexicons( } const pullNsids = calculatePullNsids(config, sourceDirs); - if (options.writeAtcuteConfig !== false) { - writeAtcuteConfiguration(rootDir, pullNsids); + if ( + options.writeAtcuteConfig !== false && + !writeAtcuteConfiguration(rootDir, pullNsids) + ) { + log("Preserved user-owned lex.config.js; update its pull sources manually."); } writeBundle(outputDir, generated, sourceDirs); const methods = [ @@ -967,12 +986,23 @@ export function checkLexicons(options: GenerateLexiconsOptions): void { const changed = paths.filter( (path) => expected.get(path) !== actual.get(path), ); - const configPath = join(rootDir, "lex.config.js"); - const actualConfig = existsSync(configPath) - ? readFileSync(configPath, "utf8") - : undefined; - if (actualConfig !== atcuteConfiguration(result.pullNsids)) { - changed.push("lex.config.js"); + if (options.writeAtcuteConfig !== false) { + const configPath = join(rootDir, "lex.config.js"); + const expectedConfig = atcuteConfiguration(result.pullNsids); + const actualConfig = existsSync(configPath) + ? readFileSync(configPath, "utf8") + : undefined; + const legacyGeneratedConfig = expectedConfig.slice( + GENERATED_ATCUTE_CONFIG_HEADER.length, + ); + if ( + actualConfig === undefined || + ((actualConfig.startsWith(GENERATED_ATCUTE_CONFIG_HEADER) || + actualConfig === legacyGeneratedConfig) && + actualConfig !== expectedConfig) + ) { + changed.push("lex.config.js"); + } } if (changed.length > 0) { throw new Error( diff --git a/packages/contrail/tests/lexicon-generation.test.ts b/packages/contrail/tests/lexicon-generation.test.ts index 81e4e9c..cbd998f 100644 --- a/packages/contrail/tests/lexicon-generation.test.ts +++ b/packages/contrail/tests/lexicon-generation.test.ts @@ -204,6 +204,7 @@ describe("Contrail Lexicon generation", () => { expect(bundle).toContain("Regenerate with `contrail lexicons generate`"); const atcute = readFileSync(join(root, "lex.config.js"), "utf8"); + expect(atcute).toContain("Generated by `contrail lexicons generate`"); expect(atcute).toContain("generate: {"); expect(atcute).toContain("pull: {"); expect(atcute).toContain('"community.example.event"'); @@ -214,7 +215,10 @@ describe("Contrail Lexicon generation", () => { surface: "public", quiet: true, }); - writeFileSync(join(root, "lex.config.js"), "// stale\n"); + writeFileSync( + join(root, "lex.config.js"), + "// Generated by `contrail lexicons generate`. Re-run the command to update; do not edit.\n// stale\n", + ); expect(() => checkLexicons({ config, @@ -225,6 +229,70 @@ describe("Contrail Lexicon generation", () => { ).toThrow("lex.config.js"); }); + it("preserves user-owned Atcute configuration and supports opting out", () => { + const { root, config } = fixture(); + const path = join(root, "lex.config.js"); + writeFileSync(path, "export default { mine: true };\n"); + + generateLexicons({ + config, + rootDir: root, + surface: "public", + quiet: true, + }); + expect(readFileSync(path, "utf8")).toBe( + "export default { mine: true };\n", + ); + expect(() => + checkLexicons({ + config, + rootDir: root, + surface: "public", + quiet: true, + }), + ).not.toThrow(); + + rmSync(path); + generateLexicons({ + config, + rootDir: root, + surface: "public", + writeAtcuteConfig: false, + quiet: true, + }); + expect(existsSync(path)).toBe(false); + expect(() => + checkLexicons({ + config, + rootDir: root, + surface: "public", + writeAtcuteConfig: false, + quiet: true, + }), + ).not.toThrow(); + }); + + it("adopts an exact legacy generated Atcute configuration", () => { + const { root, config } = fixture(); + generateLexicons({ + config, + rootDir: root, + surface: "public", + quiet: true, + }); + const path = join(root, "lex.config.js"); + const generated = readFileSync(path, "utf8"); + writeFileSync(path, generated.slice(generated.indexOf("import "))); + + generateLexicons({ + config, + rootDir: root, + surface: "public", + quiet: true, + }); + expect(readFileSync(path, "utf8")).toBe(generated); + }); + it("detects checked-in drift", () => { const { root, config } = fixture(); generateLexicons({