/** * Module types command * * Generates TypeScript `/celilo/types.d.ts` files from a * module's `variables.owns` / `variables.imports` declarations in * manifest.yml. The generated file exposes a `Config` * interface that hook scripts import via `defineHook`. * * See `openspec/changes/hook-api-v2/proposal.md` D2 for the design rationale. * * Subcommands: * celilo module types generate — write types.d.ts * celilo module types check — CI drift check */ import { existsSync } from 'node:fs'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import type { ModuleManifest } from '../../manifest/schema'; import { validateManifest } from '../../manifest/validate'; import { generateModuleTypes } from '../../services/module-types-generator'; import { getArg, validateRequiredArgs } from '../parser'; import type { CommandResult } from '../types'; const TYPES_FILE_RELATIVE_PATH = join('celilo', 'types.d.ts'); type LoadManifestResult = | { ok: true; manifest: ModuleManifest } | { ok: false; failure: CommandResult }; /** * Load and validate a module's manifest.yml from a directory path. * * Returns a discriminated result: either the parsed manifest on success, * or a CommandResult describing the failure that callers can return * directly. */ async function loadManifest(modulePath: string): Promise { const absolute = resolve(modulePath); if (!existsSync(absolute)) { return { ok: false, failure: { success: false, error: `Module directory does not exist: ${modulePath}`, }, }; } const manifestPath = join(absolute, 'manifest.yml'); if (!existsSync(manifestPath)) { return { ok: false, failure: { success: false, error: `No manifest.yml found in ${modulePath}`, }, }; } const yamlContent = await readFile(manifestPath, 'utf-8'); const result = validateManifest(yamlContent); if (!result.success) { const errorMessages = result.errors.map((e) => ` ${e.path}: ${e.message}`).join('\n'); return { ok: false, failure: { success: false, error: `Manifest validation failed:\n${errorMessages}`, }, }; } return { ok: true, manifest: result.data }; } /** * Handle `celilo module types generate `. * * Reads the manifest, generates the types file content, writes it to * `/celilo/types.d.ts`, creating the `celilo/` * subdirectory if needed. */ export async function handleModuleTypesGenerate(args: string[]): Promise { const err = validateRequiredArgs(args, 1); if (err) { return { success: false, error: `${err}\n\nUsage: celilo module types generate `, }; } const modulePath = getArg(args, 0); if (!modulePath) { return { success: false, error: 'Module directory is required\n\nUsage: celilo module types generate ', }; } const loaded = await loadManifest(modulePath); if (!loaded.ok) return loaded.failure; const content = generateModuleTypes(loaded.manifest); const outputPath = resolve(modulePath, TYPES_FILE_RELATIVE_PATH); await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, content, 'utf-8'); return { success: true, message: `Generated ${outputPath}`, }; } /** * Handle `celilo module types check `. * * Regenerates the expected types file content in-memory and compares it * byte-for-byte to the committed file. Fails if they differ or if the * committed file is missing. Used as a CI drift check. */ export async function handleModuleTypesCheck(args: string[]): Promise { const err = validateRequiredArgs(args, 1); if (err) { return { success: false, error: `${err}\n\nUsage: celilo module types check `, }; } const modulePath = getArg(args, 0); if (!modulePath) { return { success: false, error: 'Module directory is required\n\nUsage: celilo module types check ', }; } const loaded = await loadManifest(modulePath); if (!loaded.ok) return loaded.failure; const expected = generateModuleTypes(loaded.manifest); const outputPath = resolve(modulePath, TYPES_FILE_RELATIVE_PATH); if (!existsSync(outputPath)) { return { success: false, error: `Types file missing: ${outputPath}\n\nRun 'celilo module types generate ${modulePath}' to create it.`, }; } const actual = await readFile(outputPath, 'utf-8'); if (actual !== expected) { return { success: false, error: `Types file is stale: ${outputPath}\n\nIt does not match what would be generated from the current manifest.yml.\nRun 'celilo module types generate ${modulePath}' to regenerate.`, }; } return { success: true, message: `Types file is in sync: ${outputPath}`, }; } /** * Silent generator used as the belt-and-suspenders step in * `celilo module import`. Writes types to the imported module's * directory and swallows errors with a warning — failing to regenerate * types should not abort an import. * * Returns a string describing what was done (for logging), or `null` if * the operation was skipped or failed silently. */ export async function generateTypesForImportedModule(modulePath: string): Promise { try { const loaded = await loadManifest(modulePath); if (!loaded.ok) return null; const content = generateModuleTypes(loaded.manifest); const outputPath = resolve(modulePath, TYPES_FILE_RELATIVE_PATH); await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, content, 'utf-8'); return outputPath; } catch { return null; } }