modify template
This commit is contained in:
@@ -0,0 +1,598 @@
|
||||
#!/usr/bin/env node
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { inspect, promisify } from "node:util";
|
||||
|
||||
const DEFAULT_MANUAL_URL = "https://developers.openai.com/codex/codex-manual.md";
|
||||
const DEFAULT_CACHE_DIR_NAME = "openai-docs-cache";
|
||||
const CACHE_FILE_NAME = "codex-manual.md";
|
||||
const OUTLINE_FILE_NAME = "codex-manual.outline.md";
|
||||
const HASH_HEADER = "x-content-sha256";
|
||||
const USER_AGENT = "codex-openai-docs";
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
class ManualFetchError extends Error {
|
||||
constructor(message, options) {
|
||||
super(message, options);
|
||||
this.name = "ManualFetchError";
|
||||
}
|
||||
}
|
||||
|
||||
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
||||
|
||||
const withTimeout = async (promiseFactory, timeoutMs) => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await promiseFactory(controller.signal);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
const proxyConfigured = () =>
|
||||
process.env.HTTP_PROXY ||
|
||||
process.env.HTTPS_PROXY ||
|
||||
process.env.http_proxy ||
|
||||
process.env.https_proxy;
|
||||
|
||||
const responseHeaders = (headers) => ({
|
||||
get(name) {
|
||||
return headers.get(name.toLowerCase()) ?? null;
|
||||
},
|
||||
});
|
||||
|
||||
const makeResponse = ({ body, headers, status }) => ({
|
||||
headers: responseHeaders(headers),
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
async text() {
|
||||
return body;
|
||||
},
|
||||
});
|
||||
|
||||
const parseCurlHeaders = (rawHeaders) => {
|
||||
const normalized = rawHeaders.replace(/\r\n/g, "\n").trim();
|
||||
const blocks = normalized.split(/\n\n+/).filter(Boolean);
|
||||
const headerBlock = [...blocks]
|
||||
.reverse()
|
||||
.find((block) => block.startsWith("HTTP/"));
|
||||
|
||||
if (!headerBlock) {
|
||||
throw new ManualFetchError("curl did not return HTTP response headers.");
|
||||
}
|
||||
|
||||
const [statusLine, ...lines] = headerBlock.split("\n");
|
||||
const statusMatch = /^HTTP\/\S+\s+(\d{3})/.exec(statusLine);
|
||||
if (!statusMatch) {
|
||||
throw new ManualFetchError(
|
||||
`Could not parse HTTP status from curl response: ${statusLine}`
|
||||
);
|
||||
}
|
||||
|
||||
const headers = new Map();
|
||||
lines.forEach((line) => {
|
||||
const separator = line.indexOf(":");
|
||||
if (separator === -1) return;
|
||||
const name = line.slice(0, separator).trim().toLowerCase();
|
||||
const value = line.slice(separator + 1).trim();
|
||||
headers.set(name, value);
|
||||
});
|
||||
|
||||
return {
|
||||
headers,
|
||||
status: Number(statusMatch[1]),
|
||||
};
|
||||
};
|
||||
|
||||
const tempFilePath = (cacheDir, suffix) =>
|
||||
path.join(
|
||||
cacheDir,
|
||||
`.fetch-codex-manual-${process.pid}-${Date.now()}-${Math.random()
|
||||
.toString(16)
|
||||
.slice(2)}${suffix}`
|
||||
);
|
||||
|
||||
const requestManualWithCurl = async (url, { cacheDir, method, timeoutMs }) => {
|
||||
const headerPath = tempFilePath(cacheDir, ".headers");
|
||||
const bodyPath = tempFilePath(cacheDir, ".body");
|
||||
const curlNames =
|
||||
process.platform === "win32" ? ["curl.exe", "curl"] : ["curl"];
|
||||
const args = [
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--location",
|
||||
"--dump-header",
|
||||
headerPath,
|
||||
"--output",
|
||||
bodyPath,
|
||||
"--user-agent",
|
||||
USER_AGENT,
|
||||
"--max-time",
|
||||
String(Math.max(1, Math.ceil(timeoutMs / 1000))),
|
||||
];
|
||||
|
||||
if (method === "HEAD") {
|
||||
args.push("--head");
|
||||
} else {
|
||||
args.push("--request", method);
|
||||
}
|
||||
args.push(url);
|
||||
|
||||
let lastError;
|
||||
for (const curlName of curlNames) {
|
||||
try {
|
||||
await execFileAsync(curlName, args, { windowsHide: true });
|
||||
const [rawHeaders, body] = await Promise.all([
|
||||
readFile(headerPath, "utf8"),
|
||||
readFile(bodyPath, "utf8"),
|
||||
]);
|
||||
const { headers, status } = parseCurlHeaders(rawHeaders);
|
||||
return makeResponse({ body, headers, status });
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (error?.code !== "ENOENT") break;
|
||||
} finally {
|
||||
await Promise.all([
|
||||
rm(headerPath, { force: true }),
|
||||
rm(bodyPath, { force: true }),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError?.code === "ENOENT") {
|
||||
throw new ManualFetchError("curl is unavailable in this environment.", {
|
||||
cause: lastError,
|
||||
});
|
||||
}
|
||||
throw new ManualFetchError(`${method} ${url} could not be fetched.`, {
|
||||
cause: lastError,
|
||||
});
|
||||
};
|
||||
|
||||
const requestManualWithFetch = async (url, { method, timeoutMs }) => {
|
||||
if (typeof fetch !== "function") {
|
||||
throw new ManualFetchError(
|
||||
"Native fetch is unavailable in this Node runtime."
|
||||
);
|
||||
}
|
||||
|
||||
return withTimeout(
|
||||
(signal) =>
|
||||
fetch(url, {
|
||||
method,
|
||||
headers: { "User-Agent": USER_AGENT },
|
||||
signal,
|
||||
}),
|
||||
timeoutMs
|
||||
);
|
||||
};
|
||||
|
||||
const requestManual = async (url, { cacheDir, method, timeoutMs }) => {
|
||||
const preferCurl = Boolean(proxyConfigured()) || typeof fetch !== "function";
|
||||
const transports = preferCurl
|
||||
? [
|
||||
() => requestManualWithCurl(url, { cacheDir, method, timeoutMs }),
|
||||
() => requestManualWithFetch(url, { method, timeoutMs }),
|
||||
]
|
||||
: [
|
||||
() => requestManualWithFetch(url, { method, timeoutMs }),
|
||||
() => requestManualWithCurl(url, { cacheDir, method, timeoutMs }),
|
||||
];
|
||||
|
||||
let lastError;
|
||||
for (const transport of transports) {
|
||||
try {
|
||||
const response = await transport();
|
||||
if (!response.ok) {
|
||||
throw new ManualFetchError(
|
||||
`${method} ${url} failed with HTTP ${response.status}.`
|
||||
);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ManualFetchError(`${method} ${url} could not be fetched.`, {
|
||||
cause: lastError,
|
||||
});
|
||||
};
|
||||
|
||||
const readHeaderSha = (response) => {
|
||||
const value = response.headers.get(HASH_HEADER);
|
||||
if (!value || !/^[a-f0-9]{64}$/i.test(value)) {
|
||||
throw new ManualFetchError(`Manual response is missing ${HASH_HEADER}.`);
|
||||
}
|
||||
return value.toLowerCase();
|
||||
};
|
||||
|
||||
const nearestExistingParent = async (target) => {
|
||||
let current = target;
|
||||
while (true) {
|
||||
try {
|
||||
const info = await stat(current);
|
||||
return info.isDirectory() ? current : null;
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") return null;
|
||||
}
|
||||
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
};
|
||||
|
||||
const usableCacheDir = async (cacheDir) => {
|
||||
if (!cacheDir) return null;
|
||||
const resolved = path.resolve(cacheDir);
|
||||
|
||||
try {
|
||||
const info = await stat(resolved);
|
||||
if (!info.isDirectory()) return null;
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") return null;
|
||||
}
|
||||
|
||||
const parent = await nearestExistingParent(resolved);
|
||||
if (!parent) return null;
|
||||
|
||||
try {
|
||||
await access(parent, fsConstants.W_OK | fsConstants.X_OK);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const defaultCacheDirCandidates = () => {
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
const pushCandidate = (candidate) => {
|
||||
if (!candidate || seen.has(candidate)) return;
|
||||
seen.add(candidate);
|
||||
candidates.push(candidate);
|
||||
};
|
||||
|
||||
[process.env.TMPDIR, process.env.TEMP, process.env.TMP].forEach((baseDir) => {
|
||||
if (baseDir) {
|
||||
pushCandidate(path.join(baseDir, DEFAULT_CACHE_DIR_NAME));
|
||||
}
|
||||
});
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
pushCandidate(`/private/tmp/${DEFAULT_CACHE_DIR_NAME}`);
|
||||
pushCandidate(`/tmp/${DEFAULT_CACHE_DIR_NAME}`);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
};
|
||||
|
||||
const resolveCacheDir = async (cacheDir) => {
|
||||
if (cacheDir) {
|
||||
return usableCacheDir(cacheDir);
|
||||
}
|
||||
|
||||
for (const candidate of defaultCacheDirCandidates()) {
|
||||
const usable = await usableCacheDir(candidate);
|
||||
if (usable) return usable;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const cacheFilePath = (cacheDir) => path.join(cacheDir, CACHE_FILE_NAME);
|
||||
|
||||
const outlineFilePath = (cacheDir) => path.join(cacheDir, OUTLINE_FILE_NAME);
|
||||
|
||||
const manualLines = (manual) => {
|
||||
const lines = manual.replace(/\r\n/g, "\n").split("\n");
|
||||
if (lines[lines.length - 1] === "") lines.pop();
|
||||
return lines;
|
||||
};
|
||||
|
||||
const sectionTitle = (rawTitle) =>
|
||||
rawTitle.replace(/\s+#+\s*$/, "").replace(/\s+/g, " ").trim();
|
||||
|
||||
const buildOutline = (manual) => {
|
||||
const lines = manualLines(manual);
|
||||
const headings = [];
|
||||
let inFence = false;
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
if (/^\s*(```|~~~)/.test(line)) {
|
||||
inFence = !inFence;
|
||||
return;
|
||||
}
|
||||
if (inFence) return;
|
||||
|
||||
const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
|
||||
if (!match) return;
|
||||
|
||||
const level = match[1].length;
|
||||
if (level < 2 || level > 3) return;
|
||||
|
||||
headings.push({
|
||||
level,
|
||||
title: sectionTitle(match[2]),
|
||||
startLine: index + 1,
|
||||
endLine: lines.length,
|
||||
});
|
||||
});
|
||||
|
||||
for (let index = 0; index < headings.length; index += 1) {
|
||||
const heading = headings[index];
|
||||
const nextPeer = headings
|
||||
.slice(index + 1)
|
||||
.find((candidate) => candidate.level <= heading.level);
|
||||
if (nextPeer) {
|
||||
heading.endLine = nextPeer.startLine - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (headings.length === 0) {
|
||||
return {
|
||||
headingCount: 0,
|
||||
lineCount: lines.length,
|
||||
text: "No markdown headings found.",
|
||||
};
|
||||
}
|
||||
|
||||
const minLevel = Math.min(...headings.map((heading) => heading.level));
|
||||
return {
|
||||
headingCount: headings.length,
|
||||
lineCount: lines.length,
|
||||
text: headings
|
||||
.map((heading) => {
|
||||
const indent = " ".repeat(heading.level - minLevel);
|
||||
return `${indent}- ${heading.title} (lines ${heading.startLine}-${heading.endLine})`;
|
||||
})
|
||||
.join("\n"),
|
||||
};
|
||||
};
|
||||
|
||||
const outlineMarkdown = (outline) => `# Codex Manual Outline\n\n${outline.text}\n`;
|
||||
|
||||
const manualStatusLine = (status) =>
|
||||
status.cacheStatus === "hit"
|
||||
? "Manual status: local manual was already current."
|
||||
: "Manual status: local manual was updated.";
|
||||
|
||||
const formatResult = ({ status, outlineText }) =>
|
||||
[
|
||||
`Manual path: ${status.manualPath}`,
|
||||
`Outline path: ${status.outlinePath}`,
|
||||
manualStatusLine(status),
|
||||
"",
|
||||
outlineText,
|
||||
].join("\n");
|
||||
|
||||
const readCachedManual = async (cacheDir, expectedSha256) => {
|
||||
try {
|
||||
const manual = await readFile(cacheFilePath(cacheDir), "utf8");
|
||||
return sha256(manual) === expectedSha256 ? manual : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeCachedManual = async (cacheDir, manual) => {
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
const tmpPath = tempFilePath(cacheDir, `.${CACHE_FILE_NAME}.tmp`);
|
||||
await writeFile(tmpPath, manual, "utf8");
|
||||
await rename(tmpPath, cacheFilePath(cacheDir));
|
||||
};
|
||||
|
||||
const writeOutline = async (cacheDir, outlineText) => {
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
const tmpPath = tempFilePath(cacheDir, `.${OUTLINE_FILE_NAME}.tmp`);
|
||||
await writeFile(tmpPath, outlineText, "utf8");
|
||||
await rename(tmpPath, outlineFilePath(cacheDir));
|
||||
};
|
||||
|
||||
const fetchCodexManual = async ({
|
||||
manualUrl = DEFAULT_MANUAL_URL,
|
||||
cacheDir,
|
||||
timeoutMs = 30000,
|
||||
} = {}) => {
|
||||
const resolvedCacheDir = await resolveCacheDir(cacheDir);
|
||||
if (!resolvedCacheDir) {
|
||||
throw new ManualFetchError(
|
||||
"Manual cache directory is unavailable; pass --cache-dir to override or use OpenAI Docs MCP fallback."
|
||||
);
|
||||
}
|
||||
await mkdir(resolvedCacheDir, { recursive: true });
|
||||
|
||||
const headResponse = await requestManual(manualUrl, {
|
||||
cacheDir: resolvedCacheDir,
|
||||
method: "HEAD",
|
||||
timeoutMs,
|
||||
});
|
||||
const expectedSha256 = readHeaderSha(headResponse);
|
||||
const manualPath = cacheFilePath(resolvedCacheDir);
|
||||
const outlinePath = outlineFilePath(resolvedCacheDir);
|
||||
const checkedAt = new Date().toISOString();
|
||||
|
||||
const cachedManual = await readCachedManual(resolvedCacheDir, expectedSha256);
|
||||
if (cachedManual !== null) {
|
||||
const outline = buildOutline(cachedManual);
|
||||
const outlineText = outlineMarkdown(outline);
|
||||
await writeOutline(resolvedCacheDir, outlineText);
|
||||
|
||||
return {
|
||||
outlineText,
|
||||
status: {
|
||||
manualUrl,
|
||||
headerSha256: expectedSha256,
|
||||
fetchedManualSha256: expectedSha256,
|
||||
manualHashMatches: true,
|
||||
cacheStatus: "hit",
|
||||
cacheDir: resolvedCacheDir,
|
||||
manualPath,
|
||||
outlinePath,
|
||||
checkedAt,
|
||||
lineCount: outline.lineCount,
|
||||
headingCount: outline.headingCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const getResponse = await requestManual(manualUrl, {
|
||||
cacheDir: resolvedCacheDir,
|
||||
method: "GET",
|
||||
timeoutMs,
|
||||
});
|
||||
const getHeaderSha256 = readHeaderSha(getResponse);
|
||||
if (getHeaderSha256 !== expectedSha256) {
|
||||
throw new ManualFetchError(
|
||||
`${HASH_HEADER} changed between HEAD and GET for ${manualUrl}.`
|
||||
);
|
||||
}
|
||||
|
||||
const manualText = await getResponse.text();
|
||||
const actualSha256 = sha256(manualText);
|
||||
const manualHashMatches = actualSha256 === expectedSha256;
|
||||
if (!manualHashMatches) {
|
||||
throw new ManualFetchError(
|
||||
`${HASH_HEADER} did not match the fetched manual body for ${manualUrl}.`
|
||||
);
|
||||
}
|
||||
|
||||
await writeCachedManual(resolvedCacheDir, manualText);
|
||||
const outline = buildOutline(manualText);
|
||||
const outlineText = outlineMarkdown(outline);
|
||||
await writeOutline(resolvedCacheDir, outlineText);
|
||||
|
||||
return {
|
||||
outlineText,
|
||||
status: {
|
||||
manualUrl,
|
||||
headerSha256: expectedSha256,
|
||||
fetchedManualSha256: actualSha256,
|
||||
manualHashMatches,
|
||||
cacheStatus: "updated",
|
||||
cacheDir: resolvedCacheDir,
|
||||
manualPath,
|
||||
outlinePath,
|
||||
checkedAt,
|
||||
lineCount: outline.lineCount,
|
||||
headingCount: outline.headingCount,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const args = {
|
||||
manualUrl: DEFAULT_MANUAL_URL,
|
||||
cacheDir: undefined,
|
||||
timeoutMs: 30000,
|
||||
statusJson: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--manual-url") {
|
||||
args.manualUrl = argv[++index];
|
||||
} else if (arg === "--cache-dir") {
|
||||
args.cacheDir = argv[++index];
|
||||
} else if (arg === "--timeout-ms") {
|
||||
args.timeoutMs = Number(argv[++index]);
|
||||
} else if (arg === "--status-json") {
|
||||
args.statusJson = true;
|
||||
} else {
|
||||
throw new ManualFetchError(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!args.manualUrl) {
|
||||
throw new ManualFetchError("--manual-url cannot be empty.");
|
||||
}
|
||||
if (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0) {
|
||||
throw new ManualFetchError("--timeout-ms must be a positive number.");
|
||||
}
|
||||
|
||||
return args;
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const { outlineText, status } = await fetchCodexManual(args);
|
||||
|
||||
process.stdout.write(formatResult({ status, outlineText }));
|
||||
|
||||
if (args.statusJson) {
|
||||
console.error(JSON.stringify(status));
|
||||
}
|
||||
};
|
||||
|
||||
const envProxyHint = () => {
|
||||
if (proxyConfigured()) {
|
||||
return "Hint: proxy env vars are present. This helper prefers `curl` in proxied sessions; if requests still fail, verify `curl` is installed and the proxy configuration is valid.";
|
||||
}
|
||||
if (typeof fetch !== "function") {
|
||||
return "Hint: native fetch is unavailable in this Node runtime. Install `curl` or use a newer Node version to fetch the manual.";
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return "Hint: on Windows, pass a cache dir under `%TEMP%` or `%TMP%`.";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatErrorDetails = (error) => {
|
||||
const details = inspect(error, {
|
||||
breakLength: 120,
|
||||
colors: false,
|
||||
compact: false,
|
||||
depth: 8,
|
||||
});
|
||||
if (!error?.cause) {
|
||||
return details;
|
||||
}
|
||||
|
||||
return `${details}\n\nCause:\n${inspect(error.cause, {
|
||||
breakLength: 120,
|
||||
colors: false,
|
||||
compact: false,
|
||||
depth: 8,
|
||||
})}`;
|
||||
};
|
||||
|
||||
const isCliEntrypoint = () => {
|
||||
const entrypoint = process.argv[1];
|
||||
if (!entrypoint) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pathToFileURL(entrypoint).href === import.meta.url;
|
||||
};
|
||||
|
||||
if (isCliEntrypoint()) {
|
||||
main().catch((error) => {
|
||||
console.error(`Error: ${error.message}`);
|
||||
const hint = envProxyHint();
|
||||
if (hint) {
|
||||
console.error(hint);
|
||||
}
|
||||
console.error("");
|
||||
console.error("Details:");
|
||||
console.error(formatErrorDetails(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
export { DEFAULT_MANUAL_URL, fetchCodexManual };
|
||||
Reference in New Issue
Block a user