diff --git a/scripts/generate-app-server-types.mjs b/scripts/generate-app-server-types.mjs index 55ed9d9e..16fb405c 100644 --- a/scripts/generate-app-server-types.mjs +++ b/scripts/generate-app-server-types.mjs @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -8,21 +8,54 @@ const generatedHeader = "// GENERATED CODE! DO NOT MODIFY BY HAND!"; const normalizationNotice = "// This file was mechanically normalized after generation by scripts/generate-app-server-types.mjs."; if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - await generateAppServerTypes(); + try { + await generateAppServerTypes(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } } export async function generateAppServerTypes(options = {}) { const cwd = options.cwd ?? process.cwd(); const generatedDir = path.resolve(cwd, generatedRelativeDir); const runCommand = options.runCommand ?? run; - await cleanGeneratedTypes(generatedDir); - await runCommand("codex", ["app-server", "generate-ts", "--experimental", "--out", generatedRelativeDir], { cwd }); - await normalizeGeneratedTypes(generatedDir); + const generatedParent = path.dirname(generatedDir); + await mkdir(generatedParent, { recursive: true }); + const stagedDir = await mkdtemp(path.join(generatedParent, ".app-server-")); + try { + const stagedRelativeDir = path.relative(cwd, stagedDir); + await runCommand("codex", ["app-server", "generate-ts", "--experimental", "--out", stagedRelativeDir], { cwd }); + await normalizeGeneratedTypes(stagedDir); + await replaceGeneratedTypes(generatedDir, stagedDir); + } finally { + await rm(stagedDir, { recursive: true, force: true }); + } } -async function cleanGeneratedTypes(generatedDir) { - await rm(generatedDir, { recursive: true, force: true }); - await mkdir(generatedDir, { recursive: true }); +async function replaceGeneratedTypes(generatedDir, stagedDir) { + const backupDir = `${generatedDir}.backup-${process.pid.toString()}-${Date.now().toString()}`; + let hasBackup = false; + try { + try { + await rename(generatedDir, backupDir); + hasBackup = true; + } catch (error) { + if (!isMissingPathError(error)) throw error; + } + await rename(stagedDir, generatedDir); + if (hasBackup) await rm(backupDir, { recursive: true, force: true }); + } catch (error) { + if (hasBackup) { + await rm(generatedDir, { recursive: true, force: true }); + await rename(backupDir, generatedDir); + } + throw error; + } +} + +function isMissingPathError(error) { + return error instanceof Error && "code" in error && error.code === "ENOENT"; } async function normalizeGeneratedTypes(generatedDir) { @@ -72,8 +105,7 @@ function run(command, args, options = {}) { shell: false, }); if (result.error) { - console.error(`Failed to run ${command} ${args.join(" ")}: ${result.error.message}`); - process.exit(1); + throw new Error(`Failed to run ${command} ${args.join(" ")}: ${result.error.message}`); } - if (result.status !== 0) process.exit(result.status ?? 1); + if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} exited with status ${(result.status ?? 1).toString()}.`); } diff --git a/tests/scripts/development-scripts.test.ts b/tests/scripts/development-scripts.test.ts index f654173d..f808a4e7 100644 --- a/tests/scripts/development-scripts.test.ts +++ b/tests/scripts/development-scripts.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -26,26 +26,46 @@ describe("development scripts", () => { cwd, async runCommand(command: string, args: string[], options: { cwd: string }) { calls.push({ command, args, cwd: options.cwd }); - await mkdir(path.join(options.cwd, "src", "generated", "app-server", "v2"), { recursive: true }); + const outIndex = args.indexOf("--out"); + const outputDir = args[outIndex + 1]; + if (!outputDir) throw new Error("Missing generated output directory"); + await mkdir(path.join(options.cwd, outputDir, "v2"), { recursive: true }); await writeFile( - path.join(options.cwd, "src", "generated", "app-server", "v2", "Example.ts"), + path.join(options.cwd, outputDir, "v2", "Example.ts"), "// GENERATED CODE! DO NOT MODIFY BY HAND!\nexport type Example = string | null | null;\n", ); }, }); - expect(calls).toEqual([ - { - command: "codex", - args: ["app-server", "generate-ts", "--experimental", "--out", "src/generated/app-server"], - cwd, - }, - ]); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ command: "codex", cwd }); + expect(calls[0]?.args.slice(0, 4)).toEqual(["app-server", "generate-ts", "--experimental", "--out"]); + expect(calls[0]?.args[4]?.replaceAll("\\", "/")).toMatch(/^src\/generated\/\.app-server-/); await expect(readFile(path.join(cwd, "src", "generated", "app-server", "v2", "Example.ts"), "utf8")).resolves.toContain( "export type Example = string | null;", ); }); + it("preserves generated bindings when generation fails", async () => { + const cwd = await tempWorkspace(); + const generatedDir = path.join(cwd, "src", "generated", "app-server"); + await mkdir(generatedDir, { recursive: true }); + await writeFile(path.join(generatedDir, "Existing.ts"), "export type Existing = true;\n"); + const { generateAppServerTypes } = await import(pathToFileURL(path.join(repoRoot, "scripts", "generate-app-server-types.mjs")).href); + + await expect( + generateAppServerTypes({ + cwd, + runCommand: async () => { + throw new Error("generation failed"); + }, + }), + ).rejects.toThrow("generation failed"); + + await expect(readFile(path.join(generatedDir, "Existing.ts"), "utf8")).resolves.toBe("export type Existing = true;\n"); + await expect(readdir(path.join(cwd, "src", "generated"))).resolves.toEqual(["app-server"]); + }); + it("reads app-server compatibility policy from the declared baseline in API baseline checks", async () => { const cwd = await tempWorkspace(); await mkdir(path.join(cwd, "scripts"), { recursive: true });