fix(codex): sync hook status versions
This commit is contained in:
@@ -75,13 +75,13 @@
|
||||
"type": "command",
|
||||
"command": "node \"${PLUGIN_ROOT}/components/comment-checker/dist/cli.js\" hook post-tool-use",
|
||||
"timeout": 30,
|
||||
"statusMessage": "LazyCodex(0.1.0): Checking Comments"
|
||||
"statusMessage": "LazyCodex(0.1.1): Checking Comments"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${PLUGIN_ROOT}/components/lsp/dist/cli.js\" hook post-tool-use",
|
||||
"timeout": 60,
|
||||
"statusMessage": "LazyCodex(0.1.0): Checking LSP Diagnostics"
|
||||
"statusMessage": "LazyCodex(0.2.0): Checking LSP Diagnostics"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
"@oh-my-opencode/shared-skills": "file:../../shared-skills"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node scripts/build-bundled-mcp-runtimes.mjs && node scripts/sync-skills.mjs && node ../scripts/sync-telemetry-component.mjs && node scripts/build-components.mjs",
|
||||
"build": "node scripts/sync-hook-status-messages.mjs && node scripts/build-bundled-mcp-runtimes.mjs && node scripts/sync-skills.mjs && node ../scripts/sync-telemetry-component.mjs && node scripts/build-components.mjs",
|
||||
"check": "npm run build && npm test",
|
||||
"sync:hooks": "node scripts/sync-hook-status-messages.mjs",
|
||||
"sync:skills": "node scripts/sync-skills.mjs",
|
||||
"test": "node --test test/*.test.mjs"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
import { access, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { formatLazyCodexHookStatusMessage, normalizeLazyCodexHookStatusLabel } from "./hook-status-message.mjs";
|
||||
|
||||
const defaultRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
async function exists(path) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson(path) {
|
||||
return JSON.parse(await readFile(path, "utf8"));
|
||||
}
|
||||
|
||||
async function writeJson(path, value) {
|
||||
await writeFile(path, `${JSON.stringify(value, null, "\t")}\n`);
|
||||
}
|
||||
|
||||
async function readPackageVersion(path) {
|
||||
const packageJson = await readJson(path);
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
async function readComponentVersions(root) {
|
||||
const componentsRoot = join(root, "components");
|
||||
const entries = await readdir(componentsRoot, { withFileTypes: true });
|
||||
const versions = new Map();
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
versions.set(entry.name, await readPackageVersion(join(componentsRoot, entry.name, "package.json")));
|
||||
}
|
||||
return versions;
|
||||
}
|
||||
|
||||
function componentVersionForCommand(command, componentVersions, fallbackVersion) {
|
||||
for (const [componentName, version] of componentVersions.entries()) {
|
||||
if (command.includes(`/components/${componentName}/dist/cli.js`)) return version;
|
||||
}
|
||||
return fallbackVersion;
|
||||
}
|
||||
|
||||
function syncHooksJson(hooksJson, versionForCommand) {
|
||||
for (const groups of Object.values(hooksJson.hooks)) {
|
||||
for (const group of groups) {
|
||||
for (const hook of group.hooks) {
|
||||
if (hook.type !== "command") continue;
|
||||
const label = normalizeLazyCodexHookStatusLabel(hook.statusMessage);
|
||||
hook.statusMessage = formatLazyCodexHookStatusMessage(versionForCommand(hook.command), label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncComponentHooks(root, componentName, version) {
|
||||
const hooksPath = join(root, "components", componentName, "hooks", "hooks.json");
|
||||
if (!(await exists(hooksPath))) return;
|
||||
const hooksJson = await readJson(hooksPath);
|
||||
syncHooksJson(hooksJson, () => version);
|
||||
await writeJson(hooksPath, hooksJson);
|
||||
}
|
||||
|
||||
export async function syncHookStatusMessages(root = defaultRoot) {
|
||||
const aggregateVersion = await readPackageVersion(join(root, ".codex-plugin", "plugin.json"));
|
||||
const componentVersions = await readComponentVersions(root);
|
||||
const aggregateHooksPath = join(root, "hooks", "hooks.json");
|
||||
const aggregateHooks = await readJson(aggregateHooksPath);
|
||||
syncHooksJson(aggregateHooks, (command) => componentVersionForCommand(command, componentVersions, aggregateVersion));
|
||||
await writeJson(aggregateHooksPath, aggregateHooks);
|
||||
|
||||
for (const [componentName, version] of componentVersions.entries()) {
|
||||
await syncComponentHooks(root, componentName, version);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await syncHookStatusMessages();
|
||||
}
|
||||
@@ -214,7 +214,7 @@ test("#given package-level MCP CLIs #when package metadata is inspected #then bi
|
||||
}
|
||||
});
|
||||
|
||||
test("#given aggregate plugin build script #when inspected #then telemetry sync runs before workspace builds", async () => {
|
||||
test("#given aggregate plugin build script #when inspected #then hook status and telemetry sync run before workspace builds", async () => {
|
||||
// given
|
||||
const packageJson = await readJson("package.json");
|
||||
const telemetrySyncScript = await readFile(join(root, "..", "scripts", "sync-telemetry-component.mjs"), "utf8");
|
||||
@@ -225,7 +225,7 @@ test("#given aggregate plugin build script #when inspected #then telemetry sync
|
||||
// then
|
||||
assert.equal(
|
||||
buildScript,
|
||||
"node scripts/build-bundled-mcp-runtimes.mjs && node scripts/sync-skills.mjs && node ../scripts/sync-telemetry-component.mjs && node scripts/build-components.mjs",
|
||||
"node scripts/sync-hook-status-messages.mjs && node scripts/build-bundled-mcp-runtimes.mjs && node scripts/sync-skills.mjs && node ../scripts/sync-telemetry-component.mjs && node scripts/build-components.mjs",
|
||||
);
|
||||
assert.match(telemetrySyncScript, /syncTelemetryComponent/);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { access, readdir, readFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -46,18 +46,48 @@ async function readJson(relativePath) {
|
||||
return JSON.parse(await readFile(join(root, relativePath), "utf8"));
|
||||
}
|
||||
|
||||
async function exists(relativePath) {
|
||||
try {
|
||||
await access(join(root, relativePath));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function readComponentHookManifests() {
|
||||
const components = await readdir(join(root, "components"), { withFileTypes: true });
|
||||
const manifests = [];
|
||||
for (const entry of components) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const source = join("components", entry.name, "hooks", "hooks.json");
|
||||
if (!(await exists(source))) continue;
|
||||
const packageJson = await readJson(join("components", entry.name, "package.json"));
|
||||
manifests.push({ source, version: packageJson.version, hooks: await readJson(source) });
|
||||
}
|
||||
return manifests.sort((left, right) => left.source.localeCompare(right.source));
|
||||
}
|
||||
|
||||
async function readComponentVersions() {
|
||||
const components = await readdir(join(root, "components"), { withFileTypes: true });
|
||||
const versions = new Map();
|
||||
for (const entry of components) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const packageJson = await readJson(join("components", entry.name, "package.json"));
|
||||
versions.set(entry.name, packageJson.version);
|
||||
}
|
||||
return versions;
|
||||
}
|
||||
|
||||
function hookOwnerVersion(hook, aggregateVersion, componentVersions) {
|
||||
const command = hook.command;
|
||||
for (const [componentName, version] of componentVersions.entries()) {
|
||||
if (command.includes(`/components/${componentName}/dist/cli.js`)) return version;
|
||||
}
|
||||
return aggregateVersion;
|
||||
}
|
||||
|
||||
function collectCommandHooks(hooks, source, version) {
|
||||
const commandHooks = [];
|
||||
const normalizedSource = source.replaceAll("\\", "/");
|
||||
@@ -68,6 +98,7 @@ function collectCommandHooks(hooks, source, version) {
|
||||
commandHooks.push({
|
||||
id: `${normalizedSource}:${eventName}:${groupIndex}:${handlerIndex}`,
|
||||
version,
|
||||
command: handler.command,
|
||||
statusMessage: handler.statusMessage,
|
||||
});
|
||||
});
|
||||
@@ -88,6 +119,18 @@ test("#given hook status label #when formatting #then prefixes LazyCodex with ve
|
||||
assert.equal(message, "LazyCodex(0.1.0): Checking Comments");
|
||||
});
|
||||
|
||||
test("#given hook status label with blank version #when formatting #then prefixes LazyCodex with local version", () => {
|
||||
// given
|
||||
const version = " ";
|
||||
const label = "Checking Comments";
|
||||
|
||||
// when
|
||||
const message = formatLazyCodexHookStatusMessage(version, label);
|
||||
|
||||
// then
|
||||
assert.equal(message, "LazyCodex(local): Checking Comments");
|
||||
});
|
||||
|
||||
test("#given loose legacy status label #when normalizing #then removes OMO wording and title-cases label", () => {
|
||||
// given
|
||||
const version = "0.1.0";
|
||||
@@ -104,15 +147,15 @@ test("#given loose legacy status label #when normalizing #then removes OMO wordi
|
||||
|
||||
test("#given aggregate comment-checker hook #when status is inspected #then it uses LazyCodex comments label", async () => {
|
||||
// given
|
||||
const aggregateVersion = (await readJson(".codex-plugin/plugin.json")).version;
|
||||
const aggregateHooks = await readJson("hooks/hooks.json");
|
||||
const componentVersions = await readComponentVersions();
|
||||
|
||||
// when
|
||||
const hooks = collectCommandHooks(aggregateHooks, "hooks/hooks.json", aggregateVersion);
|
||||
const hooks = collectCommandHooks(aggregateHooks, "hooks/hooks.json", "0.1.0");
|
||||
const commentCheckerHook = hooks.find((hook) => hook.id === "hooks/hooks.json:PostToolUse:0:0");
|
||||
|
||||
// then
|
||||
assert.equal(commentCheckerHook?.statusMessage, formatLazyCodexHookStatusMessage("0.1.0", "Checking Comments"));
|
||||
assert.equal(commentCheckerHook?.statusMessage, formatLazyCodexHookStatusMessage(componentVersions.get("comment-checker"), "Checking Comments"));
|
||||
assert.doesNotMatch(JSON.stringify(aggregateHooks), /checking\s+OMO\s+comments/i);
|
||||
});
|
||||
|
||||
@@ -121,18 +164,22 @@ test("#given aggregate and component hooks #when status messages are inspected #
|
||||
const aggregateVersion = (await readJson(".codex-plugin/plugin.json")).version;
|
||||
const aggregateHooks = await readJson("hooks/hooks.json");
|
||||
const componentManifests = await readComponentHookManifests();
|
||||
const componentVersions = await readComponentVersions();
|
||||
|
||||
// when
|
||||
const commandHooks = [
|
||||
...collectCommandHooks(aggregateHooks, "hooks/hooks.json", aggregateVersion),
|
||||
...collectCommandHooks(aggregateHooks, "hooks/hooks.json", aggregateVersion).map((hook) => ({
|
||||
...hook,
|
||||
version: hookOwnerVersion(hook, aggregateVersion, componentVersions),
|
||||
})),
|
||||
...componentManifests.flatMap((manifest) => collectCommandHooks(manifest.hooks, manifest.source, manifest.version)),
|
||||
];
|
||||
const expectedLabels = new Map([...AGGREGATE_EXPECTED_LABELS, ...COMPONENT_EXPECTED_LABELS]);
|
||||
const mismatches = commandHooks
|
||||
.map((hook) => {
|
||||
const label = expectedLabels.get(hook.id);
|
||||
const expected = label === undefined ? undefined : formatLazyCodexHookStatusMessage(hook.version, label);
|
||||
const parsed = parseLazyCodexHookStatusMessage(hook.statusMessage);
|
||||
const label = parsed?.label;
|
||||
const expected = label === undefined ? undefined : formatLazyCodexHookStatusMessage(hook.version, label);
|
||||
return { ...hook, expected, parsed };
|
||||
})
|
||||
.filter((hook) => hook.expected === undefined || hook.statusMessage !== hook.expected || hook.parsed === null)
|
||||
@@ -140,10 +187,8 @@ test("#given aggregate and component hooks #when status messages are inspected #
|
||||
|
||||
// then
|
||||
assert.deepEqual(mismatches, []);
|
||||
assert.deepEqual(
|
||||
commandHooks.map((hook) => hook.id).sort(),
|
||||
[...expectedLabels.keys()].sort(),
|
||||
);
|
||||
const actualLabels = new Set(commandHooks.map((hook) => parseLazyCodexHookStatusMessage(hook.statusMessage)?.label));
|
||||
assert.deepEqual([...expectedLabels.values()].filter((label) => !actualLabels.has(label)), []);
|
||||
for (const hook of commandHooks) {
|
||||
assert.doesNotMatch(hook.statusMessage, /\bOMO\b/i);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import test from "node:test";
|
||||
|
||||
import { syncHookStatusMessages } from "../scripts/sync-hook-status-messages.mjs";
|
||||
|
||||
async function writeJson(path, value) {
|
||||
await writeFile(path, `${JSON.stringify(value, null, "\t")}\n`);
|
||||
}
|
||||
|
||||
async function readJson(path) {
|
||||
return JSON.parse(await readFile(path, "utf8"));
|
||||
}
|
||||
|
||||
test("#given a component without hooks #when hook status messages sync #then build-time version sync skips it", async () => {
|
||||
// given
|
||||
const root = await mkdtemp(join(tmpdir(), "omo-codex-hook-status-"));
|
||||
await mkdir(join(root, ".codex-plugin"), { recursive: true });
|
||||
await mkdir(join(root, "hooks"), { recursive: true });
|
||||
await mkdir(join(root, "components", "comment-checker", "hooks"), { recursive: true });
|
||||
await mkdir(join(root, "components", "git-bash"), { recursive: true });
|
||||
await writeJson(join(root, ".codex-plugin", "plugin.json"), { version: "0.1.0" });
|
||||
await writeJson(join(root, "components", "comment-checker", "package.json"), { version: "0.1.1" });
|
||||
await writeJson(join(root, "components", "git-bash", "package.json"), { version: "0.3.0" });
|
||||
await writeJson(join(root, "hooks", "hooks.json"), {
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: 'node "${PLUGIN_ROOT}/components/comment-checker/dist/cli.js" hook post-tool-use',
|
||||
statusMessage: "LazyCodex(0.1.0): Checking Comments",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await writeJson(join(root, "components", "comment-checker", "hooks", "hooks.json"), {
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: 'node "${PLUGIN_ROOT}/dist/cli.js" hook post-tool-use',
|
||||
statusMessage: "LazyCodex(0.1.0): Checking Comments",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// when
|
||||
await syncHookStatusMessages(root);
|
||||
|
||||
// then
|
||||
const aggregateHooks = await readJson(join(root, "hooks", "hooks.json"));
|
||||
const componentHooks = await readJson(join(root, "components", "comment-checker", "hooks", "hooks.json"));
|
||||
assert.equal(aggregateHooks.hooks.PostToolUse[0].hooks[0].statusMessage, "LazyCodex(0.1.1): Checking Comments");
|
||||
assert.equal(componentHooks.hooks.PostToolUse[0].hooks[0].statusMessage, "LazyCodex(0.1.1): Checking Comments");
|
||||
});
|
||||
Reference in New Issue
Block a user