feat(omo-claude): add sync-components vendoring script with --check drift gate
Copies 6 omo-codex components (rules, comment-checker, lsp, ultrawork, ultragoal, start-work-continuation) into the CC plugin tree and applies a declarative patch manifest: relax model/turn_id validators, CLAUDE_PLUGIN_ROOT/ DATA env fallbacks, .codex-plugin->.claude-plugin, rules matcher widened to Write|Edit|MultiEdit, start-work-continuation claude: session prefix. ultragoal copied verbatim (deep refactor is a later task). --check detects vendor drift. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,563 @@
|
||||
#!/usr/bin/env node
|
||||
// Vendors omo-codex plugin components into the omo-claude (Claude Code) plugin.
|
||||
//
|
||||
// For each handled component this script copies the component's runtime-source
|
||||
// surface (src/, package.json, tsconfig.build.json, hooks/hooks.json, skills/)
|
||||
// from packages/omo-codex/plugin/components/<c>/ into
|
||||
// packages/omo-claude/plugin/components/<c>/, then applies a DECLARATIVE PATCH
|
||||
// MANIFEST of string replacements to turn the Codex component into the vendored
|
||||
// Claude Code component.
|
||||
//
|
||||
// Modes:
|
||||
// node sync-components.mjs [componentName] sync one component (or all)
|
||||
// node sync-components.mjs --check re-derive the patched output and
|
||||
// compare against the dest on disk;
|
||||
// exit nonzero with a diff summary if
|
||||
// they differ (detects hand-drift).
|
||||
//
|
||||
// Path resolution mirrors sync-skills.mjs / sync-telemetry-component.mjs: all
|
||||
// roots are derived from import.meta.url so the script is location-stable.
|
||||
//
|
||||
// The telemetry component is intentionally EXCLUDED here (it is owned by a
|
||||
// different track and synced by scripts/sync-telemetry-component.mjs).
|
||||
|
||||
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const CLAUDE_PLUGIN_ROOT = dirname(SCRIPT_DIR);
|
||||
const CLAUDE_PACKAGE_ROOT = dirname(CLAUDE_PLUGIN_ROOT);
|
||||
const PACKAGES_ROOT = dirname(CLAUDE_PACKAGE_ROOT);
|
||||
|
||||
export const SOURCE_COMPONENTS_ROOT = join(PACKAGES_ROOT, "omo-codex", "plugin", "components");
|
||||
export const DEST_COMPONENTS_ROOT = join(CLAUDE_PLUGIN_ROOT, "components");
|
||||
|
||||
// Components handled by this script (telemetry is EXCLUDED — different track).
|
||||
export const HANDLED_COMPONENTS = [
|
||||
"rules",
|
||||
"comment-checker",
|
||||
"lsp",
|
||||
"ultrawork",
|
||||
"ultragoal",
|
||||
"start-work-continuation",
|
||||
];
|
||||
|
||||
// Per-component copy surface. Directories are copied recursively; files are
|
||||
// copied verbatim. Entries that do not exist in the source are skipped.
|
||||
const COPY_DIRS = ["src", "skills"];
|
||||
const COPY_FILES = ["package.json", "tsconfig.build.json", join("hooks", "hooks.json")];
|
||||
|
||||
// Shared hooks.json env-var transform applied to EVERY handled component that
|
||||
// ships a hooks/hooks.json. Only replaces tokens that are actually present.
|
||||
const HOOKS_ENV_REPLACEMENTS = [
|
||||
{ find: "${PLUGIN_ROOT}", replace: "${CLAUDE_PLUGIN_ROOT}" },
|
||||
{ find: "${PLUGIN_DATA}", replace: "${CLAUDE_PLUGIN_DATA}" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DECLARATIVE PATCH MANIFEST
|
||||
//
|
||||
// Each entry: { component, file, replacements: [{ find, replace, required? }] }
|
||||
// - `file` is relative to the component dest root.
|
||||
// - `find` is an exact substring (replaceAll); a `required:true` replacement
|
||||
// whose `find` does not occur in the source throws.
|
||||
// - hooks/hooks.json env-var transforms are injected automatically for all
|
||||
// handled components (see buildHooksJsonReplacements) and need NOT be listed
|
||||
// here.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const PATCH_MANIFEST = [
|
||||
// ----- rules -----------------------------------------------------------
|
||||
{
|
||||
component: "rules",
|
||||
file: "src/cli.ts",
|
||||
replacements: [
|
||||
// PLUGIN_DATA env fallback (cli.ts reads it for the cache root).
|
||||
{
|
||||
find: 'const pluginDataRoot = process.env["PLUGIN_DATA"];',
|
||||
replace:
|
||||
'const pluginDataRoot = process.env["CLAUDE_PLUGIN_DATA"] ?? process.env["PLUGIN_DATA"];',
|
||||
required: true,
|
||||
},
|
||||
// UserPromptSubmit: drop turn_id requirement, make model optional.
|
||||
{
|
||||
find:
|
||||
'\t\tvalue["hook_event_name"] === "UserPromptSubmit" &&\n' +
|
||||
'\t\ttypeof value["session_id"] === "string" &&\n' +
|
||||
'\t\ttypeof value["turn_id"] === "string" &&\n' +
|
||||
'\t\tisStringOrNull(value["transcript_path"]) &&\n' +
|
||||
'\t\ttypeof value["cwd"] === "string" &&\n' +
|
||||
'\t\ttypeof value["model"] === "string" &&\n' +
|
||||
'\t\ttypeof value["permission_mode"] === "string" &&\n' +
|
||||
'\t\ttypeof value["prompt"] === "string"',
|
||||
replace:
|
||||
'\t\tvalue["hook_event_name"] === "UserPromptSubmit" &&\n' +
|
||||
'\t\ttypeof value["session_id"] === "string" &&\n' +
|
||||
'\t\t(typeof value["turn_id"] === "string" || value["turn_id"] === undefined) &&\n' +
|
||||
'\t\tisStringOrNull(value["transcript_path"]) &&\n' +
|
||||
'\t\ttypeof value["cwd"] === "string" &&\n' +
|
||||
'\t\t(typeof value["model"] === "string" || value["model"] === undefined) &&\n' +
|
||||
'\t\ttypeof value["permission_mode"] === "string" &&\n' +
|
||||
'\t\ttypeof value["prompt"] === "string"',
|
||||
required: true,
|
||||
},
|
||||
// PostToolUse: drop turn_id requirement, make model optional.
|
||||
{
|
||||
find:
|
||||
'\t\tvalue["hook_event_name"] === "PostToolUse" &&\n' +
|
||||
'\t\ttypeof value["session_id"] === "string" &&\n' +
|
||||
'\t\ttypeof value["turn_id"] === "string" &&\n' +
|
||||
'\t\tisStringOrNull(value["transcript_path"]) &&\n' +
|
||||
'\t\ttypeof value["cwd"] === "string" &&\n' +
|
||||
'\t\ttypeof value["model"] === "string" &&\n' +
|
||||
'\t\ttypeof value["permission_mode"] === "string" &&\n' +
|
||||
'\t\ttypeof value["tool_name"] === "string" &&\n' +
|
||||
'\t\ttypeof value["tool_use_id"] === "string"',
|
||||
replace:
|
||||
'\t\tvalue["hook_event_name"] === "PostToolUse" &&\n' +
|
||||
'\t\ttypeof value["session_id"] === "string" &&\n' +
|
||||
'\t\t(typeof value["turn_id"] === "string" || value["turn_id"] === undefined) &&\n' +
|
||||
'\t\tisStringOrNull(value["transcript_path"]) &&\n' +
|
||||
'\t\ttypeof value["cwd"] === "string" &&\n' +
|
||||
'\t\t(typeof value["model"] === "string" || value["model"] === undefined) &&\n' +
|
||||
'\t\ttypeof value["permission_mode"] === "string" &&\n' +
|
||||
'\t\ttypeof value["tool_name"] === "string" &&\n' +
|
||||
'\t\ttypeof value["tool_use_id"] === "string"',
|
||||
required: true,
|
||||
},
|
||||
// PostCompact: drop turn_id requirement, make model optional.
|
||||
{
|
||||
find:
|
||||
'\t\tvalue["hook_event_name"] === "PostCompact" &&\n' +
|
||||
'\t\ttypeof value["session_id"] === "string" &&\n' +
|
||||
'\t\ttypeof value["turn_id"] === "string" &&\n' +
|
||||
'\t\tisStringOrNull(value["transcript_path"]) &&\n' +
|
||||
'\t\ttypeof value["cwd"] === "string" &&\n' +
|
||||
'\t\ttypeof value["model"] === "string" &&\n' +
|
||||
'\t\t(value["trigger"] === "manual" || value["trigger"] === "auto")',
|
||||
replace:
|
||||
'\t\tvalue["hook_event_name"] === "PostCompact" &&\n' +
|
||||
'\t\ttypeof value["session_id"] === "string" &&\n' +
|
||||
'\t\t(typeof value["turn_id"] === "string" || value["turn_id"] === undefined) &&\n' +
|
||||
'\t\tisStringOrNull(value["transcript_path"]) &&\n' +
|
||||
'\t\ttypeof value["cwd"] === "string" &&\n' +
|
||||
'\t\t(typeof value["model"] === "string" || value["model"] === undefined) &&\n' +
|
||||
'\t\t(value["trigger"] === "manual" || value["trigger"] === "auto")',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
component: "rules",
|
||||
file: "src/rules/plugin-root.ts",
|
||||
replacements: [
|
||||
{
|
||||
find: 'const PLUGIN_MANIFEST_PATH = join(".codex-plugin", "plugin.json");',
|
||||
replace: 'const PLUGIN_MANIFEST_PATH = join(".claude-plugin", "plugin.json");',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
find: 'const configuredRoot = pluginRoot ?? process.env["PLUGIN_ROOT"];',
|
||||
replace:
|
||||
'const configuredRoot = pluginRoot ?? process.env["CLAUDE_PLUGIN_ROOT"] ?? process.env["PLUGIN_ROOT"];',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
component: "rules",
|
||||
file: "src/persistent-cache.ts",
|
||||
replacements: [
|
||||
{
|
||||
find:
|
||||
'const root = pluginDataRoot ?? process.env["PLUGIN_DATA"] ?? join(homedir(), ".codex", "codex-rules");',
|
||||
replace:
|
||||
'const root = pluginDataRoot ?? process.env["CLAUDE_PLUGIN_DATA"] ?? process.env["PLUGIN_DATA"] ?? join(homedir(), ".claude", "omo-claude-rules");',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
component: "rules",
|
||||
file: "src/config.ts",
|
||||
// Add an OMO_CLAUDE_RULES_* alias as the highest-priority lookup in each
|
||||
// firstEnv() call, keeping the existing CODEX_RULES_* / PI_RULES_* aliases.
|
||||
replacements: [
|
||||
{
|
||||
find: 'firstEnv(env, "CODEX_RULES_DISABLE_BUNDLED", "PI_RULES_DISABLE_BUNDLED")',
|
||||
replace:
|
||||
'firstEnv(env, "OMO_CLAUDE_RULES_DISABLE_BUNDLED", "CODEX_RULES_DISABLE_BUNDLED", "PI_RULES_DISABLE_BUNDLED")',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
find: 'firstEnv(env, "CODEX_RULES_DISABLED", "PI_RULES_DISABLED")',
|
||||
replace:
|
||||
'firstEnv(env, "OMO_CLAUDE_RULES_DISABLED", "CODEX_RULES_DISABLED", "PI_RULES_DISABLED")',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
find: 'firstEnv(env, "CODEX_RULES_MODE", "PI_RULES_MODE")',
|
||||
replace: 'firstEnv(env, "OMO_CLAUDE_RULES_MODE", "CODEX_RULES_MODE", "PI_RULES_MODE")',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
find: 'firstEnv(env, "CODEX_RULES_MAX_RULE_CHARS", "PI_RULES_MAX_RULE_CHARS")',
|
||||
replace:
|
||||
'firstEnv(env, "OMO_CLAUDE_RULES_MAX_RULE_CHARS", "CODEX_RULES_MAX_RULE_CHARS", "PI_RULES_MAX_RULE_CHARS")',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
find: 'firstEnv(env, "CODEX_RULES_MAX_RESULT_CHARS", "PI_RULES_MAX_RESULT_CHARS")',
|
||||
replace:
|
||||
'firstEnv(env, "OMO_CLAUDE_RULES_MAX_RESULT_CHARS", "CODEX_RULES_MAX_RESULT_CHARS", "PI_RULES_MAX_RESULT_CHARS")',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
find:
|
||||
'firstEnv(env, "CODEX_RULES_POST_COMPACT_MAX_RULE_CHARS", "PI_RULES_POST_COMPACT_MAX_RULE_CHARS")',
|
||||
replace:
|
||||
'firstEnv(env, "OMO_CLAUDE_RULES_POST_COMPACT_MAX_RULE_CHARS", "CODEX_RULES_POST_COMPACT_MAX_RULE_CHARS", "PI_RULES_POST_COMPACT_MAX_RULE_CHARS")',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
find:
|
||||
'firstEnv(env, "CODEX_RULES_POST_COMPACT_MAX_RESULT_CHARS", "PI_RULES_POST_COMPACT_MAX_RESULT_CHARS")',
|
||||
replace:
|
||||
'firstEnv(env, "OMO_CLAUDE_RULES_POST_COMPACT_MAX_RESULT_CHARS", "CODEX_RULES_POST_COMPACT_MAX_RESULT_CHARS", "PI_RULES_POST_COMPACT_MAX_RESULT_CHARS")',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
find: 'firstEnv(env, "CODEX_RULES_ENABLED_SOURCES", "PI_RULES_ENABLED_SOURCES")',
|
||||
replace:
|
||||
'firstEnv(env, "OMO_CLAUDE_RULES_ENABLED_SOURCES", "CODEX_RULES_ENABLED_SOURCES", "PI_RULES_ENABLED_SOURCES")',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
component: "rules",
|
||||
file: "hooks/hooks.json",
|
||||
// Widen the PostToolUse matcher so CC native edit tools fire the hook.
|
||||
replacements: [
|
||||
{
|
||||
find: '"matcher": "^apply_patch$",',
|
||||
replace: '"matcher": "^(apply_patch|Write|Edit|MultiEdit)$",',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ----- comment-checker -------------------------------------------------
|
||||
{
|
||||
component: "comment-checker",
|
||||
file: "src/codex-hook.ts",
|
||||
replacements: [
|
||||
// Make the type fields model?/turn_id? optional.
|
||||
{
|
||||
find: "\tsession_id: string;\n\tturn_id: string;\n",
|
||||
replace: "\tsession_id: string;\n\tturn_id?: string;\n",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
find: '\thook_event_name: "PostToolUse";\n\tmodel: string;\n',
|
||||
replace: '\thook_event_name: "PostToolUse";\n\tmodel?: string;\n',
|
||||
required: true,
|
||||
},
|
||||
// Weaken the runtime validator (keep permission_mode required).
|
||||
{
|
||||
find: '\t\ttypeof value["turn_id"] === "string" &&',
|
||||
replace:
|
||||
'\t\t(typeof value["turn_id"] === "string" || value["turn_id"] === undefined) &&',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
find: '\t\ttypeof value["model"] === "string" &&',
|
||||
replace: '\t\t(typeof value["model"] === "string" || value["model"] === undefined) &&',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ----- lsp -------------------------------------------------------------
|
||||
// hooks.json env-var transform only (handled automatically).
|
||||
|
||||
// ----- ultrawork -------------------------------------------------------
|
||||
// hooks.json env-var transform only (handled automatically).
|
||||
|
||||
// ----- ultragoal -------------------------------------------------------
|
||||
// COPY VERBATIM — no patch entries (its deep refactor is a separate task).
|
||||
// The hooks.json env-var transform still applies (handled automatically).
|
||||
|
||||
// ----- start-work-continuation ----------------------------------------
|
||||
{
|
||||
component: "start-work-continuation",
|
||||
file: "src/boulder-reader.ts",
|
||||
replacements: [
|
||||
{
|
||||
find: "const work = findMatchingWork(parsed, `codex:${sessionId}`);",
|
||||
replace: "const work = findMatchingWork(parsed, `claude:${sessionId}`);",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
component: "start-work-continuation",
|
||||
file: "src/codex-hook.ts",
|
||||
// Make model/turn_id optional in the Stop/SubagentStop validator (keep
|
||||
// permission_mode required).
|
||||
replacements: [
|
||||
{
|
||||
find: '\t\ttypeof value["turn_id"] === "string" &&',
|
||||
replace:
|
||||
'\t\t(typeof value["turn_id"] === "string" || value["turn_id"] === undefined) &&',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
find: '\t\ttypeof value["model"] === "string" &&',
|
||||
replace: '\t\t(typeof value["model"] === "string" || value["model"] === undefined) &&',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core: derive the expected vendored tree (in memory), then either write it or
|
||||
// compare it against what is currently on disk.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class PatchError extends Error {}
|
||||
|
||||
function buildPatchIndex(components) {
|
||||
const index = new Map();
|
||||
for (const component of components) {
|
||||
index.set(component, new Map());
|
||||
}
|
||||
for (const entry of PATCH_MANIFEST) {
|
||||
if (!index.has(entry.component)) continue;
|
||||
index.get(entry.component).set(normalizeRel(entry.file), entry.replacements);
|
||||
}
|
||||
// Inject the shared hooks.json env-var transform for every handled component.
|
||||
for (const component of components) {
|
||||
const hooksRel = normalizeRel(join("hooks", "hooks.json"));
|
||||
const existing = index.get(component).get(hooksRel) ?? [];
|
||||
index.get(component).set(hooksRel, [
|
||||
...HOOKS_ENV_REPLACEMENTS.map((replacement) => ({ ...replacement, required: false })),
|
||||
...existing,
|
||||
]);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function normalizeRel(relPath) {
|
||||
return relPath.split(/[\\/]/).join("/");
|
||||
}
|
||||
|
||||
function applyReplacements(component, relPath, text, replacements, matched) {
|
||||
let result = text;
|
||||
for (const { find, replace, required } of replacements) {
|
||||
const hit = result.includes(find);
|
||||
matched.push({ component, file: relPath, find, replace, matched: hit, required: Boolean(required) });
|
||||
if (!hit) {
|
||||
if (required) {
|
||||
throw new PatchError(
|
||||
`required replacement did not match in ${component}/${relPath}: ${JSON.stringify(find)}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
result = result.split(find).join(replace);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function pathExists(target) {
|
||||
try {
|
||||
await stat(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectFiles(dir) {
|
||||
const out = [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...(await collectFiles(full)));
|
||||
} else if (entry.isFile()) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Returns the set of dest files (relative to the component dest root) that the
|
||||
// sync would produce for one component, along with their expected Buffer/text
|
||||
// content. Patched files carry their post-patch text.
|
||||
async function deriveComponentFiles(component, patchIndex, matched) {
|
||||
const sourceRoot = join(SOURCE_COMPONENTS_ROOT, component);
|
||||
const replacementsByRel = patchIndex.get(component) ?? new Map();
|
||||
const files = new Map(); // relPath -> { content: Buffer, patched: boolean }
|
||||
|
||||
const sourceFiles = [];
|
||||
for (const dirName of COPY_DIRS) {
|
||||
const dirPath = join(sourceRoot, dirName);
|
||||
if (await pathExists(dirPath)) {
|
||||
sourceFiles.push(...(await collectFiles(dirPath)));
|
||||
}
|
||||
}
|
||||
for (const fileRel of COPY_FILES) {
|
||||
const filePath = join(sourceRoot, fileRel);
|
||||
if (await pathExists(filePath)) {
|
||||
sourceFiles.push(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const sourcePath of sourceFiles) {
|
||||
const relPath = normalizeRel(relative(sourceRoot, sourcePath));
|
||||
const replacements = replacementsByRel.get(relPath);
|
||||
if (replacements && replacements.length > 0) {
|
||||
const sourceText = await readFile(sourcePath, "utf8");
|
||||
const patchedText = applyReplacements(component, relPath, sourceText, replacements, matched);
|
||||
files.set(relPath, { content: Buffer.from(patchedText, "utf8"), patched: true });
|
||||
} else {
|
||||
files.set(relPath, { content: await readFile(sourcePath), patched: false });
|
||||
}
|
||||
}
|
||||
|
||||
// A required patch targeting a file that does not exist in the source is a
|
||||
// manifest bug — surface it loudly.
|
||||
for (const [relPath, replacements] of replacementsByRel.entries()) {
|
||||
if (files.has(relPath)) continue;
|
||||
const hasRequired = replacements.some((replacement) => replacement.required);
|
||||
if (hasRequired) {
|
||||
throw new PatchError(`patch target missing from source: ${component}/${relPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async function writeComponent(component, files) {
|
||||
const destRoot = join(DEST_COMPONENTS_ROOT, component);
|
||||
await rm(destRoot, { recursive: true, force: true });
|
||||
for (const [relPath, { content }] of files) {
|
||||
const destPath = join(destRoot, relPath);
|
||||
await mkdir(dirname(destPath), { recursive: true });
|
||||
await writeFile(destPath, content);
|
||||
}
|
||||
}
|
||||
|
||||
async function readDestFiles(component) {
|
||||
const destRoot = join(DEST_COMPONENTS_ROOT, component);
|
||||
const files = new Map();
|
||||
if (!(await pathExists(destRoot))) return files;
|
||||
for (const fullPath of await collectFiles(destRoot)) {
|
||||
const relPath = normalizeRel(relative(destRoot, fullPath));
|
||||
files.set(relPath, await readFile(fullPath));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function diffComponent(component, expected, actual) {
|
||||
const drift = [];
|
||||
for (const [relPath, { content }] of expected) {
|
||||
const onDisk = actual.get(relPath);
|
||||
if (onDisk === undefined) {
|
||||
drift.push(`${component}/${relPath}: missing on disk`);
|
||||
} else if (!onDisk.equals(content)) {
|
||||
drift.push(`${component}/${relPath}: content differs`);
|
||||
}
|
||||
}
|
||||
for (const relPath of actual.keys()) {
|
||||
if (!expected.has(relPath)) {
|
||||
drift.push(`${component}/${relPath}: unexpected file on disk`);
|
||||
}
|
||||
}
|
||||
return drift;
|
||||
}
|
||||
|
||||
export async function syncComponents(options = {}) {
|
||||
const check = options.check ?? false;
|
||||
const components =
|
||||
options.components && options.components.length > 0 ? options.components : HANDLED_COMPONENTS;
|
||||
|
||||
for (const component of components) {
|
||||
if (!HANDLED_COMPONENTS.includes(component)) {
|
||||
throw new Error(
|
||||
`unknown component: ${component} (handled: ${HANDLED_COMPONENTS.join(", ")})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const patchIndex = buildPatchIndex(components);
|
||||
const matched = [];
|
||||
const drift = [];
|
||||
|
||||
for (const component of components) {
|
||||
const expected = await deriveComponentFiles(component, patchIndex, matched);
|
||||
if (check) {
|
||||
const actual = await readDestFiles(component);
|
||||
drift.push(...diffComponent(component, expected, actual));
|
||||
} else {
|
||||
await writeComponent(component, expected);
|
||||
}
|
||||
}
|
||||
|
||||
return { check, components, matched, drift };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseArgs(args) {
|
||||
const parsed = { check: false, components: [] };
|
||||
for (const arg of args) {
|
||||
if (arg === "--check") {
|
||||
parsed.check = true;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--")) {
|
||||
throw new Error(`unknown argument: ${arg}`);
|
||||
}
|
||||
parsed.components.push(arg);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isNodeError(error) {
|
||||
return error instanceof Error && "code" in error;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { check, components } = parseArgs(process.argv.slice(2));
|
||||
const result = await syncComponents({ check, components });
|
||||
|
||||
if (check) {
|
||||
if (result.drift.length > 0) {
|
||||
console.error("vendored components out of sync:");
|
||||
for (const line of result.drift) {
|
||||
console.error(` - ${line}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(`vendored components in sync (${result.components.length} checked)`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`synced components: ${result.components.join(", ")}`);
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1] ? resolve(process.argv[1]) : "";
|
||||
if (invokedPath === fileURLToPath(import.meta.url)) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { HANDLED_COMPONENTS, PATCH_MANIFEST, syncComponents } from "./sync-components.mjs";
|
||||
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const SCRIPT_PATH = join(SCRIPT_DIR, "sync-components.mjs");
|
||||
const PLUGIN_ROOT = dirname(SCRIPT_DIR);
|
||||
const DRIFT_FILE = join(PLUGIN_ROOT, "components", "lsp", "src", "codex-hook.ts");
|
||||
|
||||
function runCli(args) {
|
||||
return spawnSync(process.execPath, [SCRIPT_PATH, ...args], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
test("sync then --check is GREEN (exit 0)", () => {
|
||||
const sync = runCli([]);
|
||||
assert.equal(sync.status, 0, `sync failed: ${sync.stderr}`);
|
||||
|
||||
const check = runCli(["--check"]);
|
||||
assert.equal(check.status, 0, `--check should be green after sync but got: ${check.stderr}`);
|
||||
assert.match(check.stdout, /in sync/);
|
||||
});
|
||||
|
||||
test("--check is RED (nonzero) after hand-drift, then re-sync restores GREEN", async () => {
|
||||
runCli([]);
|
||||
|
||||
const original = await readFile(DRIFT_FILE, "utf8");
|
||||
try {
|
||||
await writeFile(DRIFT_FILE, `${original}\n// drift\n`);
|
||||
const drifted = runCli(["--check"]);
|
||||
assert.notEqual(drifted.status, 0, "--check must fail after hand-drift");
|
||||
assert.match(drifted.stderr, /out of sync/);
|
||||
assert.match(drifted.stderr, /lsp\/src\/codex-hook\.ts/);
|
||||
} finally {
|
||||
// Re-sync to restore the vendored tree regardless of assertion outcome.
|
||||
const restore = runCli([]);
|
||||
assert.equal(restore.status, 0, `restore sync failed: ${restore.stderr}`);
|
||||
}
|
||||
|
||||
const check = runCli(["--check"]);
|
||||
assert.equal(check.status, 0, `--check should be green again after re-sync: ${check.stderr}`);
|
||||
});
|
||||
|
||||
test("sync is idempotent (running twice yields no drift)", () => {
|
||||
assert.equal(runCli([]).status, 0);
|
||||
assert.equal(runCli([]).status, 0);
|
||||
assert.equal(runCli(["--check"]).status, 0);
|
||||
});
|
||||
|
||||
test("a single component can be synced and check passes", () => {
|
||||
const sync = runCli(["rules"]);
|
||||
assert.equal(sync.status, 0, sync.stderr);
|
||||
assert.match(sync.stdout, /rules/);
|
||||
// Re-sync all so the rest of the suite/state is whole.
|
||||
assert.equal(runCli([]).status, 0);
|
||||
assert.equal(runCli(["--check"]).status, 0);
|
||||
});
|
||||
|
||||
test("unknown component is rejected", () => {
|
||||
const result = runCli(["does-not-exist"]);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /unknown component/);
|
||||
});
|
||||
|
||||
test("every required replacement in the manifest matches the source", async () => {
|
||||
const matched = [];
|
||||
await syncComponents({ check: true, components: HANDLED_COMPONENTS });
|
||||
// syncComponents records matches via its internal collector; re-derive by
|
||||
// running a check pass and asserting no required replacement is unmatched.
|
||||
const result = await syncComponents({ check: true });
|
||||
for (const entry of result.matched) {
|
||||
if (entry.required) {
|
||||
assert.equal(
|
||||
entry.matched,
|
||||
true,
|
||||
`required replacement did not match: ${entry.component}/${entry.file} :: ${entry.find}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
assert.ok(result.matched.length >= PATCH_MANIFEST.length, "expected manifest replacements to be exercised");
|
||||
// matched local kept for clarity; not otherwise used.
|
||||
void matched;
|
||||
});
|
||||
Reference in New Issue
Block a user