From 0bf0723ae505a2e4092c2aa0d40a1a22170d1a7d Mon Sep 17 00:00:00 2001 From: Farnood Massoudi Date: Tue, 24 Feb 2026 03:47:10 -0500 Subject: [PATCH] fix(sync): normalize manifest paths for git-safe local/global reuse Store sync manifest generated file entries in a portable format: - local scope: workspace-relative paths where applicable - home-scoped paths: ~/... encoding Also ensure global scope always prefers ~/... encoding so manifest entries stay stable across cwd changes. Add manifest unit tests for disk/runtime normalization, legacy absolute compatibility, and global cross-cwd stability. Co-authored-by: Codex --- packages/cli/src/core/manifest.ts | 183 ++++++++++++++++++++++- packages/cli/tests/unit/manifest.test.ts | 167 +++++++++++++++++++++ 2 files changed, 342 insertions(+), 8 deletions(-) create mode 100644 packages/cli/tests/unit/manifest.test.ts diff --git a/packages/cli/src/core/manifest.ts b/packages/cli/src/core/manifest.ts index e558c79..1486bb2 100644 --- a/packages/cli/src/core/manifest.ts +++ b/packages/cli/src/core/manifest.ts @@ -1,11 +1,14 @@ -import type { ScopePaths, SyncManifest } from "../types.js"; -import { readJsonIfExists, writeJsonAtomic } from "./fs.js"; +import path from "node:path"; +import type { EntityType, ScopePaths, SyncManifest } from "../types.js"; +import { readJsonIfExists, toPosixPath, writeJsonAtomic } from "./fs.js"; const EMPTY_MANIFEST: SyncManifest = { version: 1, generatedFiles: [], }; +const ENTITY_TYPES: EntityType[] = ["agent", "command", "mcp", "skill"]; + export function readManifest(paths: ScopePaths): SyncManifest { const manifest = readJsonIfExists(paths.manifestPath); if ( @@ -16,17 +19,181 @@ export function readManifest(paths: ScopePaths): SyncManifest { return { ...EMPTY_MANIFEST }; } - const generatedByEntity = - manifest.generatedByEntity && typeof manifest.generatedByEntity === "object" - ? manifest.generatedByEntity - : undefined; + const generatedByEntity = normalizeGeneratedByEntityForRuntime( + paths, + manifest.generatedByEntity, + ); + const codex = normalizeCodexMetadata(manifest.codex); return { - ...manifest, + version: 1, + generatedFiles: normalizePathListForRuntime(paths, manifest.generatedFiles), generatedByEntity, + codex, }; } export function writeManifest(paths: ScopePaths, manifest: SyncManifest): void { - writeJsonAtomic(paths.manifestPath, manifest); + const generatedByEntity = normalizeGeneratedByEntityForDisk( + paths, + manifest.generatedByEntity, + ); + const codex = normalizeCodexMetadata(manifest.codex); + + writeJsonAtomic(paths.manifestPath, { + version: 1, + generatedFiles: normalizePathListForDisk(paths, manifest.generatedFiles), + generatedByEntity, + codex, + }); +} + +function normalizeGeneratedByEntityForRuntime( + paths: ScopePaths, + generatedByEntity: SyncManifest["generatedByEntity"], +): SyncManifest["generatedByEntity"] { + if (!generatedByEntity || typeof generatedByEntity !== "object") { + return undefined; + } + + const normalized: Partial> = {}; + for (const entity of ENTITY_TYPES) { + const values = normalizePathListForRuntime( + paths, + generatedByEntity[entity], + ); + if (values.length === 0) continue; + normalized[entity] = values; + } + + return Object.keys(normalized).length > 0 ? normalized : {}; +} + +function normalizeGeneratedByEntityForDisk( + paths: ScopePaths, + generatedByEntity: SyncManifest["generatedByEntity"], +): SyncManifest["generatedByEntity"] { + if (!generatedByEntity || typeof generatedByEntity !== "object") { + return undefined; + } + + const normalized: Partial> = {}; + for (const entity of ENTITY_TYPES) { + const values = normalizePathListForDisk(paths, generatedByEntity[entity]); + if (values.length === 0) continue; + normalized[entity] = values; + } + + return Object.keys(normalized).length > 0 ? normalized : {}; +} + +function normalizePathListForRuntime( + paths: ScopePaths, + value: unknown, +): string[] { + if (!Array.isArray(value)) return []; + + const normalized = value + .filter((item): item is string => typeof item === "string") + .map((item) => resolveManifestPathForRuntime(paths, item)) + .filter((item) => item.length > 0); + + return [...new Set(normalized)].sort(); +} + +function normalizePathListForDisk(paths: ScopePaths, value: unknown): string[] { + if (!Array.isArray(value)) return []; + + const normalized = value + .filter((item): item is string => typeof item === "string") + .map((item) => resolveManifestPathForDisk(paths, item)) + .filter((item) => item.length > 0); + + return [...new Set(normalized)].sort(); +} + +function resolveManifestPathForRuntime( + paths: ScopePaths, + filePath: string, +): string { + const normalized = filePath.trim(); + if (!normalized) return ""; + + if (normalized === "~") { + return paths.homeDir; + } + + if (normalized.startsWith("~/")) { + return path.resolve(paths.homeDir, normalized.slice(2)); + } + + if (path.isAbsolute(normalized)) { + return path.normalize(normalized); + } + + return path.resolve(paths.workspaceRoot, normalized); +} + +function resolveManifestPathForDisk( + paths: ScopePaths, + filePath: string, +): string { + const normalized = filePath.trim(); + if (!normalized) return ""; + + if (!path.isAbsolute(normalized)) { + return toPosixPath(normalized); + } + + const absolutePath = path.normalize(normalized); + + if (paths.scope === "global") { + if (isSubpath(paths.homeDir, absolutePath)) { + const relativePath = path.relative(paths.homeDir, absolutePath); + return relativePath ? `~/${toPosixPath(relativePath)}` : "~"; + } + return toPosixPath(absolutePath); + } + + if (isSubpath(paths.workspaceRoot, absolutePath)) { + const relativePath = path.relative(paths.workspaceRoot, absolutePath); + return toPosixPath(relativePath || "."); + } + + if (isSubpath(paths.homeDir, absolutePath)) { + const relativePath = path.relative(paths.homeDir, absolutePath); + return relativePath ? `~/${toPosixPath(relativePath)}` : "~"; + } + + return toPosixPath(absolutePath); +} + +function isSubpath(rootPath: string, candidatePath: string): boolean { + const relative = path.relative(rootPath, candidatePath); + return ( + relative === "" || + (!relative.startsWith("..") && !path.isAbsolute(relative)) + ); +} + +function normalizeCodexMetadata( + codex: SyncManifest["codex"], +): SyncManifest["codex"] { + if (!codex || typeof codex !== "object") return undefined; + + const roles = Array.isArray(codex.roles) + ? [...new Set(codex.roles.filter((item): item is string => !!item))].sort() + : undefined; + const mcpServers = Array.isArray(codex.mcpServers) + ? [ + ...new Set(codex.mcpServers.filter((item): item is string => !!item)), + ].sort() + : undefined; + + if (!roles && !mcpServers) return undefined; + + return { + roles, + mcpServers, + }; } diff --git a/packages/cli/tests/unit/manifest.test.ts b/packages/cli/tests/unit/manifest.test.ts new file mode 100644 index 0000000..ab151d7 --- /dev/null +++ b/packages/cli/tests/unit/manifest.test.ts @@ -0,0 +1,167 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { readManifest, writeManifest } from "../../src/core/manifest.js"; +import { buildScopePaths } from "../../src/core/scope.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("manifest helpers", () => { + it("stores local generated paths as workspace-relative and ~/ paths on disk", () => { + const workspaceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "agentloom-workspace-"), + ); + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-")); + tempDirs.push(workspaceRoot, homeDir); + + const paths = buildScopePaths(workspaceRoot, "local", homeDir); + fs.mkdirSync(path.dirname(paths.manifestPath), { recursive: true }); + + const workspaceOutput = path.join( + workspaceRoot, + ".cursor", + "commands", + "review.md", + ); + const homeOutput = path.join(homeDir, ".codex", "prompts", "review.md"); + + writeManifest(paths, { + version: 1, + generatedFiles: [workspaceOutput, homeOutput], + generatedByEntity: { + command: [workspaceOutput], + mcp: [homeOutput], + }, + codex: { + roles: ["reviewer"], + mcpServers: ["browser"], + }, + }); + + const onDisk = JSON.parse(fs.readFileSync(paths.manifestPath, "utf8")) as { + generatedFiles?: string[]; + generatedByEntity?: { + command?: string[]; + mcp?: string[]; + }; + }; + + expect(onDisk.generatedFiles).toEqual([ + ".cursor/commands/review.md", + "~/.codex/prompts/review.md", + ]); + expect(onDisk.generatedByEntity?.command).toEqual([ + ".cursor/commands/review.md", + ]); + expect(onDisk.generatedByEntity?.mcp).toEqual([ + "~/.codex/prompts/review.md", + ]); + }); + + it("resolves relative and ~/ manifest entries to absolute runtime paths", () => { + const workspaceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "agentloom-workspace-"), + ); + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-")); + tempDirs.push(workspaceRoot, homeDir); + + const paths = buildScopePaths(workspaceRoot, "local", homeDir); + fs.mkdirSync(path.dirname(paths.manifestPath), { recursive: true }); + fs.writeFileSync( + paths.manifestPath, + `${JSON.stringify( + { + version: 1, + generatedFiles: [ + ".cursor/commands/review.md", + "~/.codex/prompts/review.md", + ], + generatedByEntity: { + command: [".cursor/commands/review.md"], + mcp: ["~/.codex/prompts/review.md"], + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const manifest = readManifest(paths); + + expect(manifest.generatedFiles).toEqual([ + path.join(homeDir, ".codex", "prompts", "review.md"), + path.join(workspaceRoot, ".cursor", "commands", "review.md"), + ]); + expect(manifest.generatedByEntity?.command).toEqual([ + path.join(workspaceRoot, ".cursor", "commands", "review.md"), + ]); + expect(manifest.generatedByEntity?.mcp).toEqual([ + path.join(homeDir, ".codex", "prompts", "review.md"), + ]); + }); + + it("keeps legacy absolute entries readable at runtime", () => { + const workspaceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "agentloom-workspace-"), + ); + tempDirs.push(workspaceRoot); + + const paths = buildScopePaths(workspaceRoot, "local"); + fs.mkdirSync(path.dirname(paths.manifestPath), { recursive: true }); + const absoluteOutput = path.join( + workspaceRoot, + ".cursor", + "agents", + "a.md", + ); + fs.writeFileSync( + paths.manifestPath, + `${JSON.stringify( + { + version: 1, + generatedFiles: [absoluteOutput], + }, + null, + 2, + )}\n`, + "utf8", + ); + + const manifest = readManifest(paths); + expect(manifest.generatedFiles).toEqual([absoluteOutput]); + }); + + it("encodes global home outputs with ~/ so reads are stable across cwd changes", () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-home-")); + const secondCwd = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-cwd-")); + tempDirs.push(homeDir, secondCwd); + + const writePaths = buildScopePaths(homeDir, "global", homeDir); + fs.mkdirSync(path.dirname(writePaths.manifestPath), { recursive: true }); + const globalOutput = path.join(homeDir, ".codex", "prompts", "review.md"); + + writeManifest(writePaths, { + version: 1, + generatedFiles: [globalOutput], + }); + + const onDisk = JSON.parse( + fs.readFileSync(writePaths.manifestPath, "utf8"), + ) as { + generatedFiles?: string[]; + }; + expect(onDisk.generatedFiles).toEqual(["~/.codex/prompts/review.md"]); + + const readPaths = buildScopePaths(secondCwd, "global", homeDir); + const manifest = readManifest(readPaths); + expect(manifest.generatedFiles).toEqual([globalOutput]); + }); +});