From f77338b4a24a334d8f2773a0f2ae888075651d51 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 26 May 2026 17:40:46 +0900 Subject: [PATCH] refactor(omo-codex): sync telemetry component sources --- package.json | 2 +- .../components/telemetry/src/atomic-write.ts | 31 ++--- .../components/telemetry/src/data-path.ts | 43 +++---- .../components/telemetry/src/env-flags.ts | 41 ++++--- .../telemetry/src/posthog-activity-state.ts | 100 ++++++++-------- packages/omo-codex/plugin/package.json | 2 +- .../omo-codex/plugin/test/aggregate.test.mjs | 13 ++ .../scripts/sync-telemetry-component.mjs | 111 ++++++++++++++++++ .../scripts/sync-telemetry-component.test.mjs | 70 +++++++++++ 9 files changed, 307 insertions(+), 106 deletions(-) create mode 100644 packages/omo-codex/scripts/sync-telemetry-component.mjs create mode 100644 packages/omo-codex/scripts/sync-telemetry-component.test.mjs diff --git a/package.json b/package.json index 1d28e42d6..bf2bbe8dc 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "typecheck:packages": "tsgo --noEmit -p packages/rules-engine/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json && tsgo --noEmit -p packages/model-core/tsconfig.json && tsgo --noEmit -p packages/prompts-core/tsconfig.json && tsgo --noEmit -p packages/comment-checker-core/tsconfig.json && tsgo --noEmit -p packages/hashline-core/tsconfig.json && tsgo --noEmit -p packages/boulder-state/tsconfig.json && tsgo --noEmit -p packages/agents-md-core/tsconfig.json && tsgo --noEmit -p packages/omo-codex/tsconfig.json", "typecheck:script": "tsgo --noEmit -p script/tsconfig.json", "test": "bun test", - "test:codex": "bun test src/cli/install-codex/codex-cache.test.ts src/cli/install-codex/install-codex.test.ts packages/omo-codex/src/**/*.test.ts packages/utils/src/jsonc-parser.test.ts packages/utils/src/frontmatter.test.ts packages/hashline-core/src/hash-computation.test.ts packages/hashline-core/src/smoke-untested-modules.test.ts packages/rules-engine/src/index.test.ts packages/rules-engine/src/security-boundary.test.ts packages/agents-md-core/src/injector.test.ts && node --test packages/omo-codex/plugin/test/*.test.mjs packages/omo-codex/scripts/install-local.test.mjs", + "test:codex": "bun test src/cli/install-codex/codex-cache.test.ts src/cli/install-codex/install-codex.test.ts packages/omo-codex/src/**/*.test.ts packages/utils/src/jsonc-parser.test.ts packages/utils/src/frontmatter.test.ts packages/hashline-core/src/hash-computation.test.ts packages/hashline-core/src/smoke-untested-modules.test.ts packages/rules-engine/src/index.test.ts packages/rules-engine/src/security-boundary.test.ts packages/agents-md-core/src/injector.test.ts && node --test packages/omo-codex/plugin/test/*.test.mjs packages/omo-codex/scripts/install-local.test.mjs packages/omo-codex/scripts/sync-telemetry-component.test.mjs", "test:windows-codex": "bun run test:codex", "build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build" }, diff --git a/packages/omo-codex/plugin/components/telemetry/src/atomic-write.ts b/packages/omo-codex/plugin/components/telemetry/src/atomic-write.ts index e9dd6465c..c87df29d4 100644 --- a/packages/omo-codex/plugin/components/telemetry/src/atomic-write.ts +++ b/packages/omo-codex/plugin/components/telemetry/src/atomic-write.ts @@ -1,21 +1,22 @@ -import { renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { renameSync, unlinkSync, writeFileSync } from "node:fs" export function writeFileAtomically(filePath: string, content: string): void { - const tempPath = `${filePath}.tmp`; - writeFileSync(tempPath, content, "utf-8"); + const tempPath = `${filePath}.tmp` + writeFileSync(tempPath, content, "utf-8") - try { - renameSync(tempPath, filePath); - } catch (error) { - const isPermissionError = - error instanceof Error && (error.message.includes("EPERM") || error.message.includes("EACCES")); + try { + renameSync(tempPath, filePath) + } catch (error) { + const isPermissionError = + error instanceof Error && + (error.message.includes("EPERM") || error.message.includes("EACCES")) - if (process.platform === "win32" && isPermissionError) { - unlinkSync(filePath); - renameSync(tempPath, filePath); - return; - } + if (process.platform === "win32" && isPermissionError) { + unlinkSync(filePath) + renameSync(tempPath, filePath) + return + } - throw error; - } + throw error + } } diff --git a/packages/omo-codex/plugin/components/telemetry/src/data-path.ts b/packages/omo-codex/plugin/components/telemetry/src/data-path.ts index 408d842b5..06d102762 100644 --- a/packages/omo-codex/plugin/components/telemetry/src/data-path.ts +++ b/packages/omo-codex/plugin/components/telemetry/src/data-path.ts @@ -1,44 +1,45 @@ -import { accessSync, constants, mkdirSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import { accessSync, constants, mkdirSync } from "node:fs" +import os from "node:os" +import path from "node:path" -import { CACHE_DIR_NAME } from "./product-identity.js"; +import { CACHE_DIR_NAME } from "./product-identity.js" -type OsProvider = Pick; +type OsProvider = Pick -let osProviderOverride: OsProvider | null = null; +let osProviderOverride: OsProvider | null = null export function getOsProvider(): OsProvider { - return osProviderOverride ?? os; + return osProviderOverride ?? os } /** @internal test-only */ export function __setOsProviderForTesting(provider: OsProvider): void { - osProviderOverride = provider; + osProviderOverride = provider } /** @internal test-only */ export function __resetOsProviderForTesting(): void { - osProviderOverride = null; + osProviderOverride = null } function resolveWritableDirectory(preferredDir: string, fallbackSuffix: string): string { - try { - mkdirSync(preferredDir, { recursive: true }); - accessSync(preferredDir, constants.W_OK); - return preferredDir; - } catch { - const fallbackDir = path.join(getOsProvider().tmpdir(), fallbackSuffix); - mkdirSync(fallbackDir, { recursive: true }); - return fallbackDir; - } + try { + mkdirSync(preferredDir, { recursive: true }) + accessSync(preferredDir, constants.W_OK) + return preferredDir + } catch { + const fallbackDir = path.join(getOsProvider().tmpdir(), fallbackSuffix) + mkdirSync(fallbackDir, { recursive: true }) + return fallbackDir + } } export function getDataDir(): string { - const preferredDataDir = process.env["XDG_DATA_HOME"] ?? path.join(getOsProvider().homedir(), ".local", "share"); - return resolveWritableDirectory(preferredDataDir, "omo-codex-data"); + const preferredDataDir = + process.env["XDG_DATA_HOME"] ?? path.join(getOsProvider().homedir(), ".local", "share") + return resolveWritableDirectory(preferredDataDir, "omo-codex-data") } export function getActivityStateDir(): string { - return path.join(getDataDir(), CACHE_DIR_NAME); + return path.join(getDataDir(), CACHE_DIR_NAME) } diff --git a/packages/omo-codex/plugin/components/telemetry/src/env-flags.ts b/packages/omo-codex/plugin/components/telemetry/src/env-flags.ts index 134f91341..fa51b9bca 100644 --- a/packages/omo-codex/plugin/components/telemetry/src/env-flags.ts +++ b/packages/omo-codex/plugin/components/telemetry/src/env-flags.ts @@ -1,40 +1,43 @@ -import { DEFAULT_POSTHOG_API_KEY, DEFAULT_POSTHOG_HOST } from "./product-identity.js"; +import { + DEFAULT_POSTHOG_API_KEY, + DEFAULT_POSTHOG_HOST, +} from "./product-identity.js" function normalizeEnvValue(value: string | undefined): string | undefined { - return value?.trim().toLowerCase(); + return value?.trim().toLowerCase() } function isDisableFlag(value: string | undefined): boolean { - const normalized = normalizeEnvValue(value); - return normalized === "1" || normalized === "true"; + const normalized = normalizeEnvValue(value) + return normalized === "1" || normalized === "true" } function isTelemetryOptOutFlag(value: string | undefined): boolean { - const normalized = normalizeEnvValue(value); - return normalized === "0" || normalized === "false" || normalized === "no"; + const normalized = normalizeEnvValue(value) + return normalized === "0" || normalized === "false" || normalized === "no" } export function shouldDisablePostHog(): boolean { - return ( - isDisableFlag(process.env["OMO_DISABLE_POSTHOG"]) || - isTelemetryOptOutFlag(process.env["OMO_SEND_ANONYMOUS_TELEMETRY"]) || - isDisableFlag(process.env["OMO_CODEX_DISABLE_POSTHOG"]) || - isTelemetryOptOutFlag(process.env["OMO_CODEX_SEND_ANONYMOUS_TELEMETRY"]) - ); + return ( + isDisableFlag(process.env["OMO_DISABLE_POSTHOG"]) || + isTelemetryOptOutFlag(process.env["OMO_SEND_ANONYMOUS_TELEMETRY"]) || + isDisableFlag(process.env["OMO_CODEX_DISABLE_POSTHOG"]) || + isTelemetryOptOutFlag(process.env["OMO_CODEX_SEND_ANONYMOUS_TELEMETRY"]) + ) } export function getPostHogApiKey(): string { - const explicit = process.env["POSTHOG_API_KEY"]; - if (explicit === undefined) { - return DEFAULT_POSTHOG_API_KEY; - } - return explicit.trim(); + const explicit = process.env["POSTHOG_API_KEY"] + if (explicit === undefined) { + return DEFAULT_POSTHOG_API_KEY + } + return explicit.trim() } export function hasPostHogApiKey(): boolean { - return getPostHogApiKey().length > 0; + return getPostHogApiKey().length > 0 } export function getPostHogHost(): string { - return process.env["POSTHOG_HOST"]?.trim() || DEFAULT_POSTHOG_HOST; + return process.env["POSTHOG_HOST"]?.trim() || DEFAULT_POSTHOG_HOST } diff --git a/packages/omo-codex/plugin/components/telemetry/src/posthog-activity-state.ts b/packages/omo-codex/plugin/components/telemetry/src/posthog-activity-state.ts index 84233999f..b9fa9a8b6 100644 --- a/packages/omo-codex/plugin/components/telemetry/src/posthog-activity-state.ts +++ b/packages/omo-codex/plugin/components/telemetry/src/posthog-activity-state.ts @@ -1,79 +1,81 @@ -import { existsSync, mkdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, mkdirSync, readFileSync } from "node:fs" +import { join } from "node:path" -import { writeFileAtomically } from "./atomic-write.js"; -import { getActivityStateDir } from "./data-path.js"; +import { writeFileAtomically } from "./atomic-write.js" +import { getActivityStateDir } from "./data-path.js" export type PostHogActivityState = { - readonly lastActiveDayUTC?: string; -}; + readonly lastActiveDayUTC?: string +} export type PostHogActivityCaptureState = { - readonly dayUTC: string; - readonly captureDaily: boolean; -}; + readonly dayUTC: string + readonly captureDaily: boolean +} -const POSTHOG_ACTIVITY_STATE_FILE = "posthog-activity.json"; +const POSTHOG_ACTIVITY_STATE_FILE = "posthog-activity.json" function getPostHogActivityStateFilePath(): string { - return join(getActivityStateDir(), POSTHOG_ACTIVITY_STATE_FILE); + return join(getActivityStateDir(), POSTHOG_ACTIVITY_STATE_FILE) } function getUtcDayString(date: Date): string { - return date.toISOString().slice(0, 10); + return date.toISOString().slice(0, 10) } function isPostHogActivityState(value: unknown): value is PostHogActivityState { - return value !== null && typeof value === "object" && !Array.isArray(value); + return value !== null && typeof value === "object" && !Array.isArray(value) } function readPostHogActivityState(): PostHogActivityState { - const stateFilePath = getPostHogActivityStateFilePath(); + const stateFilePath = getPostHogActivityStateFilePath() - if (!existsSync(stateFilePath)) { - return {}; - } + if (!existsSync(stateFilePath)) { + return {} + } - try { - const stateContent = readFileSync(stateFilePath, "utf-8"); - const stateJson: unknown = JSON.parse(stateContent); + try { + const stateContent = readFileSync(stateFilePath, "utf-8") + const stateJson: unknown = JSON.parse(stateContent) - if (!isPostHogActivityState(stateJson)) { - return {}; - } + if (!isPostHogActivityState(stateJson)) { + return {} + } - return stateJson; - } catch { - return {}; - } + return stateJson + } catch { + return {} + } } function writePostHogActivityState(nextState: PostHogActivityState): void { - const stateDir = getActivityStateDir(); - const stateFilePath = getPostHogActivityStateFilePath(); + const stateDir = getActivityStateDir() + const stateFilePath = getPostHogActivityStateFilePath() - try { - mkdirSync(stateDir, { recursive: true }); - writeFileAtomically(stateFilePath, `${JSON.stringify(nextState, null, 2)}\n`); - } catch { - return; - } + try { + mkdirSync(stateDir, { recursive: true }) + writeFileAtomically(stateFilePath, `${JSON.stringify(nextState, null, 2)}\n`) + } catch { + return + } } -export function getPostHogActivityCaptureState(now: Date = new Date()): PostHogActivityCaptureState { - const state = readPostHogActivityState(); - const dayUTC = getUtcDayString(now); - const captureDaily = state.lastActiveDayUTC !== dayUTC; +export function getPostHogActivityCaptureState( + now: Date = new Date(), +): PostHogActivityCaptureState { + const state = readPostHogActivityState() + const dayUTC = getUtcDayString(now) + const captureDaily = state.lastActiveDayUTC !== dayUTC - if (captureDaily) { - writePostHogActivityState({ - ...state, - lastActiveDayUTC: dayUTC, - }); - } + if (captureDaily) { + writePostHogActivityState({ + ...state, + lastActiveDayUTC: dayUTC, + }) + } - return { - dayUTC, - captureDaily, - }; + return { + dayUTC, + captureDaily, + } } diff --git a/packages/omo-codex/plugin/package.json b/packages/omo-codex/plugin/package.json index 06b4b858b..5b88cc7d7 100644 --- a/packages/omo-codex/plugin/package.json +++ b/packages/omo-codex/plugin/package.json @@ -15,7 +15,7 @@ "components/ultrawork" ], "scripts": { - "build": "node scripts/sync-skills.mjs && npm run build --workspaces --if-present", + "build": "node scripts/sync-skills.mjs && node ../scripts/sync-telemetry-component.mjs && npm run build --workspaces --if-present", "check": "npm run build && npm test", "sync:skills": "node scripts/sync-skills.mjs", "test": "node --test test/*.test.mjs" diff --git a/packages/omo-codex/plugin/test/aggregate.test.mjs b/packages/omo-codex/plugin/test/aggregate.test.mjs index 6e1ee3cea..95c7196ac 100644 --- a/packages/omo-codex/plugin/test/aggregate.test.mjs +++ b/packages/omo-codex/plugin/test/aggregate.test.mjs @@ -62,6 +62,19 @@ test("#given aggregate MCP config #when inspected #then lsp server stays compone assert.equal(server.cwd, "."); }); +test("#given aggregate plugin build script #when inspected #then telemetry sync runs before workspace builds", async () => { + // given + const packageJson = await readJson("package.json"); + const telemetrySyncScript = await readFile(join(root, "..", "scripts", "sync-telemetry-component.mjs"), "utf8"); + + // when + const buildScript = packageJson.scripts.build; + + // then + assert.equal(buildScript, "node scripts/sync-skills.mjs && node ../scripts/sync-telemetry-component.mjs && npm run build --workspaces --if-present"); + assert.match(telemetrySyncScript, /syncTelemetryComponent/); +}); + test("#given component directories #when scanned #then only root owns plugin identity", async () => { // given const components = await readdir(join(root, "components"), { withFileTypes: true }); diff --git a/packages/omo-codex/scripts/sync-telemetry-component.mjs b/packages/omo-codex/scripts/sync-telemetry-component.mjs new file mode 100644 index 000000000..5f4cf0140 --- /dev/null +++ b/packages/omo-codex/scripts/sync-telemetry-component.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const TELEMETRY_SYNC_FILES = [ + "atomic-write.ts", + "data-path.ts", + "env-flags.ts", + "posthog-activity-state.ts", +]; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const PACKAGE_ROOT = dirname(SCRIPT_DIR); +const DEFAULT_SOURCE_DIR = join(PACKAGE_ROOT, "src", "telemetry"); +const DEFAULT_COMPONENT_DIR = join(PACKAGE_ROOT, "plugin", "components", "telemetry", "src"); + +export async function syncTelemetryComponent(options = {}) { + const sourceDir = resolve(options.sourceDir ?? DEFAULT_SOURCE_DIR); + const componentDir = resolve(options.componentDir ?? DEFAULT_COMPONENT_DIR); + const files = options.files ?? TELEMETRY_SYNC_FILES; + const check = options.check ?? false; + const changed = []; + + for (const fileName of files) { + const sourcePath = join(sourceDir, fileName); + const componentPath = join(componentDir, fileName); + const sourceText = await readFile(sourcePath, "utf8"); + const componentText = await readOptionalText(componentPath); + const nextText = toComponentSource(sourceText); + if (componentText === nextText) continue; + changed.push(fileName); + if (!check) { + await mkdir(dirname(componentPath), { recursive: true }); + await writeFile(componentPath, nextText); + } + } + + if (check && changed.length > 0) { + throw new Error(`telemetry component out of sync: ${changed.join(", ")}`); + } + + return { checked: check, changed }; +} + +function toComponentSource(sourceText) { + return sourceText + .replaceAll(/\bprocess\.env\.([A-Z0-9_]+)/g, 'process.env["$1"]') + .replaceAll(/from "(\.\/[^"]+)"/g, 'from "$1.js"'); +} + +async function readOptionalText(path) { + try { + return await readFile(path, "utf8"); + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") return null; + throw error; + } +} + +function isNodeError(error) { + return error instanceof Error && "code" in error; +} + +function parseArgs(args) { + const parsed = { + check: false, + sourceDir: DEFAULT_SOURCE_DIR, + componentDir: DEFAULT_COMPONENT_DIR, + }; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--check") { + parsed.check = true; + continue; + } + if (arg === "--source-dir") { + const value = args[index + 1]; + if (value === undefined) throw new Error("--source-dir requires a value"); + parsed.sourceDir = value; + index += 1; + continue; + } + if (arg === "--component-dir") { + const value = args[index + 1]; + if (value === undefined) throw new Error("--component-dir requires a value"); + parsed.componentDir = value; + index += 1; + continue; + } + throw new Error(`unknown argument: ${arg}`); + } + return parsed; +} + +async function main() { + const result = await syncTelemetryComponent(parseArgs(process.argv.slice(2))); + if (result.changed.length === 0) { + console.log("telemetry component in sync"); + return; + } + console.log(`synced telemetry component: ${result.changed.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; + }); +} diff --git a/packages/omo-codex/scripts/sync-telemetry-component.test.mjs b/packages/omo-codex/scripts/sync-telemetry-component.test.mjs new file mode 100644 index 000000000..1aed15da9 --- /dev/null +++ b/packages/omo-codex/scripts/sync-telemetry-component.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +const SCRIPT_PATH = new URL("./sync-telemetry-component.mjs", import.meta.url); + +async function makeTempDir() { + return mkdtemp(join(tmpdir(), "omo-codex-telemetry-sync-")); +} + +async function runSync(args) { + const { syncTelemetryComponent } = await import(SCRIPT_PATH); + return syncTelemetryComponent(args); +} + +test("#given stale telemetry component files #when sync runs #then pure package telemetry source rewrites the component copy", async () => { + // given + const root = await makeTempDir(); + const sourceDir = join(root, "source"); + const componentDir = join(root, "component"); + await mkdir(sourceDir); + await mkdir(componentDir); + await writeFile(join(sourceDir, "atomic-write.ts"), "export const source = true\n", { flush: true }); + await writeFile(join(componentDir, "atomic-write.ts"), "export const stale = true\n", { flush: true }); + + try { + // when + const result = await runSync({ + sourceDir, + componentDir, + files: ["atomic-write.ts"], + check: false, + }); + + // then + assert.deepEqual(result, { + checked: false, + changed: ["atomic-write.ts"], + }); + assert.equal(await readFile(join(componentDir, "atomic-write.ts"), "utf8"), "export const source = true\n"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("#given a missing pure telemetry source file #when sync runs #then it fails with the missing source path", async () => { + // given + const root = await makeTempDir(); + const sourceDir = join(root, "source"); + const componentDir = join(root, "component"); + await mkdir(componentDir); + await writeFile(join(componentDir, "atomic-write.ts"), "export const stale = true\n", { flush: true }); + + try { + // when / then + await assert.rejects( + runSync({ + sourceDir, + componentDir, + files: ["atomic-write.ts"], + check: false, + }), + (error) => error instanceof Error && error.message.includes(join(sourceDir, "atomic-write.ts")), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +});