Stage generated app-server bindings before replacement

This commit is contained in:
murashit 2026-07-10 12:46:38 +09:00
parent b6f6d04147
commit 5e362add5b
2 changed files with 73 additions and 21 deletions

View file

@ -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()}.`);
}

View file

@ -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 });