feat(team-mode): add team registry path utilities with tests
This commit is contained in:
@@ -0,0 +1,119 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||||
|
import { mkdtemp, mkdir, rm, stat, writeFile } from "node:fs/promises"
|
||||||
|
import { homedir, tmpdir } from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
|
||||||
|
import { TeamModeConfigSchema } from "../../../config/schema/team-mode"
|
||||||
|
|
||||||
|
const logCalls: Array<[string, unknown?]> = []
|
||||||
|
|
||||||
|
mock.module("../../../shared/logger", () => ({
|
||||||
|
log: (message: string, data?: unknown) => {
|
||||||
|
logCalls.push([message, data])
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { discoverTeamSpecs, ensureBaseDirs, resolveBaseDir } = await import("./paths")
|
||||||
|
|
||||||
|
async function createTemporaryRoot(): Promise<string> {
|
||||||
|
return await mkdtemp(path.join(tmpdir(), "team-mode-paths-"))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("paths", () => {
|
||||||
|
const temporaryDirectories: string[] = []
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
logCalls.splice(0)
|
||||||
|
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => {
|
||||||
|
await rm(directoryPath, { recursive: true, force: true })
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolveBaseDir defaults to ~/.omo", () => {
|
||||||
|
// given
|
||||||
|
const config = TeamModeConfigSchema.parse({ base_dir: undefined })
|
||||||
|
|
||||||
|
// when
|
||||||
|
const resolvedBaseDir = resolveBaseDir(config)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(resolvedBaseDir).toBe(path.join(homedir(), ".omo"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolveBaseDir honors override", () => {
|
||||||
|
// given
|
||||||
|
const config = TeamModeConfigSchema.parse({ base_dir: "/tmp/test-abc" })
|
||||||
|
|
||||||
|
// when
|
||||||
|
const resolvedBaseDir = resolveBaseDir(config)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(resolvedBaseDir).toBe("/tmp/test-abc")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("discoverTeamSpecs prefers project scope", async () => {
|
||||||
|
// given
|
||||||
|
const rootDirectory = await createTemporaryRoot()
|
||||||
|
temporaryDirectories.push(rootDirectory)
|
||||||
|
|
||||||
|
const projectRoot = path.join(rootDirectory, "project")
|
||||||
|
const userBaseDir = path.join(rootDirectory, "home", ".omo")
|
||||||
|
const projectTeamDir = path.join(projectRoot, ".omo", "teams", "foo")
|
||||||
|
const userTeamDir = path.join(userBaseDir, "teams", "foo")
|
||||||
|
|
||||||
|
await mkdir(projectTeamDir, { recursive: true })
|
||||||
|
await mkdir(userTeamDir, { recursive: true })
|
||||||
|
|
||||||
|
await writeFile(path.join(projectTeamDir, "config.json"), "{}")
|
||||||
|
await writeFile(path.join(userTeamDir, "config.json"), "{}")
|
||||||
|
|
||||||
|
// when
|
||||||
|
const teamSpecs = await discoverTeamSpecs(TeamModeConfigSchema.parse({ base_dir: userBaseDir }), projectRoot)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(teamSpecs).toEqual([
|
||||||
|
{
|
||||||
|
name: "foo",
|
||||||
|
scope: "project",
|
||||||
|
path: path.join(projectTeamDir, "config.json"),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(logCalls).toEqual([
|
||||||
|
[
|
||||||
|
"team-spec collision",
|
||||||
|
{
|
||||||
|
event: "team-spec-collision",
|
||||||
|
teamName: "foo",
|
||||||
|
projectPath: path.join(projectTeamDir, "config.json"),
|
||||||
|
userPath: path.join(userTeamDir, "config.json"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ensureBaseDirs creates all dirs with mode 0700", async () => {
|
||||||
|
// given
|
||||||
|
const baseDir = path.join(tmpdir(), `omo-test-${randomUUID()}`)
|
||||||
|
|
||||||
|
// when
|
||||||
|
await ensureBaseDirs(baseDir)
|
||||||
|
await ensureBaseDirs(baseDir)
|
||||||
|
|
||||||
|
// then
|
||||||
|
const directoryPaths = [
|
||||||
|
baseDir,
|
||||||
|
path.join(baseDir, "teams"),
|
||||||
|
path.join(baseDir, "runtime"),
|
||||||
|
path.join(baseDir, "worktrees"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const directoryPath of directoryPaths) {
|
||||||
|
const directoryStat = await stat(directoryPath)
|
||||||
|
expect(directoryStat.isDirectory()).toBe(true)
|
||||||
|
expect(directoryStat.mode & 0o777).toBe(0o700)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { mkdir, readdir, stat, chmod } from "node:fs/promises"
|
||||||
|
import { homedir } from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
import type { TeamModeConfig } from "../../../config/schema/team-mode"
|
||||||
|
import { log } from "../../../shared/logger"
|
||||||
|
|
||||||
|
type TeamSpecEntry = {
|
||||||
|
name: string
|
||||||
|
scope: "project" | "user"
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTeamDirectory(baseDir: string, teamName: string, scope: "user" | "project", projectRoot?: string): string {
|
||||||
|
if (scope === "project") {
|
||||||
|
return path.join(projectRoot ?? "", ".omo", "teams", teamName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(baseDir, "teams", teamName)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBaseDir(config: TeamModeConfig): string {
|
||||||
|
return config.base_dir ?? path.join(homedir(), ".omo")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTeamSpecPath(
|
||||||
|
baseDir: string,
|
||||||
|
teamName: string,
|
||||||
|
scope: "user" | "project",
|
||||||
|
projectRoot?: string,
|
||||||
|
): string {
|
||||||
|
return path.join(getTeamDirectory(baseDir, teamName, scope, projectRoot), "config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuntimeStateDir(baseDir: string, teamRunId: string): string {
|
||||||
|
return path.join(baseDir, "runtime", teamRunId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInboxDir(baseDir: string, teamRunId: string, memberName: string): string {
|
||||||
|
return path.join(baseDir, "runtime", teamRunId, "inboxes", memberName)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTasksDir(baseDir: string, teamRunId: string): string {
|
||||||
|
return path.join(baseDir, "runtime", teamRunId, "tasks")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorktreeDir(baseDir: string, teamRunId: string, memberName: string): string {
|
||||||
|
return path.join(baseDir, "worktrees", teamRunId, memberName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTeamSpecDirectories(directoryPath: string, scope: "project" | "user"): Promise<TeamSpecEntry[]> {
|
||||||
|
try {
|
||||||
|
const entries = await readdir(directoryPath, { withFileTypes: true })
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) => ({
|
||||||
|
name: entry.name,
|
||||||
|
scope,
|
||||||
|
path: path.resolve(directoryPath, entry.name, "config.json"),
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discoverTeamSpecs(
|
||||||
|
config: TeamModeConfig,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<Array<{ name: string; scope: "project" | "user"; path: string }>> {
|
||||||
|
const baseDir = resolveBaseDir(config)
|
||||||
|
const projectTeamsDir = path.resolve(projectRoot, ".omo", "teams")
|
||||||
|
const userTeamsDir = path.resolve(baseDir, "teams")
|
||||||
|
|
||||||
|
const [projectTeamSpecs, userTeamSpecs] = await Promise.all([
|
||||||
|
readTeamSpecDirectories(projectTeamsDir, "project"),
|
||||||
|
readTeamSpecDirectories(userTeamsDir, "user"),
|
||||||
|
])
|
||||||
|
|
||||||
|
const discoveredTeamSpecs: TeamSpecEntry[] = [...projectTeamSpecs]
|
||||||
|
const projectTeamNames = new Set(projectTeamSpecs.map((entry) => entry.name))
|
||||||
|
|
||||||
|
for (const userTeamSpec of userTeamSpecs) {
|
||||||
|
if (projectTeamNames.has(userTeamSpec.name)) {
|
||||||
|
const projectTeamSpec = projectTeamSpecs.find((entry) => entry.name === userTeamSpec.name)
|
||||||
|
if (projectTeamSpec) {
|
||||||
|
log("team-spec collision", {
|
||||||
|
event: "team-spec-collision",
|
||||||
|
teamName: userTeamSpec.name,
|
||||||
|
projectPath: projectTeamSpec.path,
|
||||||
|
userPath: userTeamSpec.path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveredTeamSpecs.push(userTeamSpec)
|
||||||
|
}
|
||||||
|
|
||||||
|
return discoveredTeamSpecs
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureBaseDirs(baseDir: string): Promise<void> {
|
||||||
|
const directories = [
|
||||||
|
baseDir,
|
||||||
|
path.join(baseDir, "teams"),
|
||||||
|
path.join(baseDir, "runtime"),
|
||||||
|
path.join(baseDir, "worktrees"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const directoryPath of directories) {
|
||||||
|
await mkdir(directoryPath, { recursive: true, mode: 0o700 })
|
||||||
|
await chmod(directoryPath, 0o700)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(directories.map(async (directoryPath) => {
|
||||||
|
const directoryStat = await stat(directoryPath)
|
||||||
|
if ((directoryStat.mode & 0o777) !== 0o700) {
|
||||||
|
await chmod(directoryPath, 0o700)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user