2026-05-30 01:01:34 +00:00
|
|
|
import { spawnSync } from "node:child_process";
|
2026-07-10 03:46:38 +00:00
|
|
|
import { mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
2026-05-30 01:01:34 +00:00
|
|
|
import path from "node:path";
|
2026-06-25 10:05:43 +00:00
|
|
|
import { fileURLToPath } from "node:url";
|
2026-07-14 12:15:21 +00:00
|
|
|
import { readAppServerGenerationPolicy } from "./app-server-compatibility.mjs";
|
2026-05-30 01:01:34 +00:00
|
|
|
|
2026-06-25 10:05:43 +00:00
|
|
|
const generatedRelativeDir = "src/generated/app-server";
|
2026-05-30 01:01:34 +00:00
|
|
|
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.";
|
|
|
|
|
|
2026-06-25 10:05:43 +00:00
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
2026-07-10 03:46:38 +00:00
|
|
|
try {
|
2026-07-14 12:15:21 +00:00
|
|
|
const args = process.argv.slice(2);
|
|
|
|
|
if (args.some((arg) => arg !== "--check") || args.length > 1) {
|
|
|
|
|
throw new Error("Usage: node scripts/generate-app-server-types.mjs [--check]");
|
|
|
|
|
}
|
|
|
|
|
await generateAppServerTypes({ check: args.includes("--check") });
|
2026-07-10 03:46:38 +00:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
|
|
|
process.exitCode = 1;
|
|
|
|
|
}
|
2026-06-25 10:05:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function generateAppServerTypes(options = {}) {
|
|
|
|
|
const cwd = options.cwd ?? process.cwd();
|
|
|
|
|
const generatedDir = path.resolve(cwd, generatedRelativeDir);
|
|
|
|
|
const runCommand = options.runCommand ?? run;
|
2026-07-14 12:15:21 +00:00
|
|
|
const readCodexVersion = options.readCodexVersion ?? readInstalledCodexVersion;
|
|
|
|
|
const policy = await readAppServerGenerationPolicy(cwd);
|
|
|
|
|
const installedCliVersion = await readCodexVersion(cwd);
|
|
|
|
|
if (installedCliVersion !== policy.testedCliVersion) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Codex CLI ${policy.testedCliVersion} is required to generate app-server bindings; found ${installedCliVersion ?? "an unreadable version"}.`,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-07-10 03:46:38 +00:00
|
|
|
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);
|
2026-07-14 12:15:21 +00:00
|
|
|
await runCommand("codex", [...policy.generationArguments, "--out", stagedRelativeDir], { cwd });
|
2026-07-10 03:46:38 +00:00
|
|
|
await normalizeGeneratedTypes(stagedDir);
|
2026-07-14 12:15:21 +00:00
|
|
|
if (options.check === true) {
|
|
|
|
|
const differences = await compareGeneratedTrees(generatedDir, stagedDir);
|
|
|
|
|
if (differences.length > 0) {
|
|
|
|
|
throw new Error(`generated app-server bindings are out of date:\n${differences.map((difference) => ` ${difference}`).join("\n")}`);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
await replaceGeneratedTypes(generatedDir, stagedDir);
|
|
|
|
|
}
|
2026-07-10 03:46:38 +00:00
|
|
|
} finally {
|
|
|
|
|
await rm(stagedDir, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 12:15:21 +00:00
|
|
|
async function compareGeneratedTrees(trackedDir, stagedDir) {
|
|
|
|
|
const [trackedFiles, stagedFiles] = await Promise.all([listFiles(trackedDir), listFiles(stagedDir)]);
|
|
|
|
|
const trackedSet = new Set(trackedFiles);
|
|
|
|
|
const stagedSet = new Set(stagedFiles);
|
|
|
|
|
const differences = [];
|
|
|
|
|
|
|
|
|
|
for (const file of stagedFiles) {
|
|
|
|
|
if (!trackedSet.has(file)) {
|
|
|
|
|
differences.push(`added: ${file}`);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const [trackedSource, stagedSource] = await Promise.all([readFile(path.join(trackedDir, file)), readFile(path.join(stagedDir, file))]);
|
|
|
|
|
if (!trackedSource.equals(stagedSource)) differences.push(`changed: ${file}`);
|
|
|
|
|
}
|
|
|
|
|
for (const file of trackedFiles) {
|
|
|
|
|
if (!stagedSet.has(file)) differences.push(`removed: ${file}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return differences;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-10 03:46:38 +00:00
|
|
|
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;
|
|
|
|
|
}
|
2026-06-25 10:05:43 +00:00
|
|
|
}
|
2026-05-30 01:01:34 +00:00
|
|
|
|
2026-07-10 03:46:38 +00:00
|
|
|
function isMissingPathError(error) {
|
|
|
|
|
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
2026-05-30 01:01:34 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-25 10:05:43 +00:00
|
|
|
async function normalizeGeneratedTypes(generatedDir) {
|
2026-05-30 01:01:34 +00:00
|
|
|
const files = await listTypeScriptFiles(generatedDir);
|
|
|
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
files.map(async (file) => {
|
|
|
|
|
const source = await readFile(file, "utf8");
|
|
|
|
|
const normalized = normalizeSource(source);
|
|
|
|
|
const nextSource = normalized === source ? source : addNormalizationNotice(normalized);
|
|
|
|
|
if (nextSource !== source) await writeFile(file, nextSource);
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function listTypeScriptFiles(dir) {
|
|
|
|
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
|
|
|
const files = await Promise.all(
|
|
|
|
|
entries.map((entry) => {
|
|
|
|
|
const fullPath = path.join(dir, entry.name);
|
|
|
|
|
if (entry.isDirectory()) return listTypeScriptFiles(fullPath);
|
|
|
|
|
return entry.isFile() && entry.name.endsWith(".ts") ? [fullPath] : [];
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
return files.flat();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 12:15:21 +00:00
|
|
|
async function listFiles(dir, relativeDir = "") {
|
|
|
|
|
let entries;
|
|
|
|
|
try {
|
|
|
|
|
entries = await readdir(path.join(dir, relativeDir), { withFileTypes: true });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (isMissingPathError(error)) return [];
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
const files = await Promise.all(
|
|
|
|
|
entries.map((entry) => {
|
|
|
|
|
const relativePath = path.join(relativeDir, entry.name);
|
|
|
|
|
if (entry.isDirectory()) return listFiles(dir, relativePath);
|
|
|
|
|
return entry.isFile() ? [relativePath.replaceAll(path.sep, "/")] : [];
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
return files.flat().sort();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 01:01:34 +00:00
|
|
|
function normalizeSource(source) {
|
|
|
|
|
let normalized = source;
|
|
|
|
|
do {
|
|
|
|
|
source = normalized;
|
|
|
|
|
normalized = source.replaceAll("| null | null", "| null");
|
|
|
|
|
} while (normalized !== source);
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addNormalizationNotice(source) {
|
|
|
|
|
if (source.includes(normalizationNotice)) return source;
|
|
|
|
|
if (!source.startsWith(generatedHeader)) return source;
|
|
|
|
|
return source.replace(generatedHeader, `${generatedHeader}\n${normalizationNotice}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 10:05:43 +00:00
|
|
|
function run(command, args, options = {}) {
|
2026-05-30 01:01:34 +00:00
|
|
|
const result = spawnSync(command, args, {
|
2026-06-25 10:05:43 +00:00
|
|
|
cwd: options.cwd ?? process.cwd(),
|
2026-05-30 01:01:34 +00:00
|
|
|
stdio: "inherit",
|
|
|
|
|
shell: false,
|
|
|
|
|
});
|
|
|
|
|
if (result.error) {
|
2026-07-10 03:46:38 +00:00
|
|
|
throw new Error(`Failed to run ${command} ${args.join(" ")}: ${result.error.message}`);
|
2026-05-30 01:01:34 +00:00
|
|
|
}
|
2026-07-10 03:46:38 +00:00
|
|
|
if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} exited with status ${(result.status ?? 1).toString()}.`);
|
2026-05-30 01:01:34 +00:00
|
|
|
}
|
2026-07-14 12:15:21 +00:00
|
|
|
|
|
|
|
|
function readInstalledCodexVersion(cwd) {
|
|
|
|
|
const result = spawnSync("codex", ["--version"], {
|
|
|
|
|
cwd,
|
|
|
|
|
encoding: "utf8",
|
|
|
|
|
stdio: "pipe",
|
|
|
|
|
shell: false,
|
|
|
|
|
});
|
|
|
|
|
if (result.error || result.status !== 0) return null;
|
|
|
|
|
return `${result.stdout}\n${result.stderr}`.match(/\b\d+\.\d+\.\d+\b/)?.[0] ?? null;
|
|
|
|
|
}
|