import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; const componentsDir = process.env.HA_CORE_COMPONENTS_DIR || '/tmp/opencode/homeassistant-core/homeassistant/components'; const integrationsRoot = new URL('../ts/integrations/', import.meta.url); const generatedRoot = new URL('../ts/integrations/generated/', import.meta.url); const markerName = '.generated-by-smarthome-exchange'; const toClassName = (domain) => { const parts = domain.split(/[^a-zA-Z0-9]+/).filter(Boolean); const pascal = parts.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`).join('') || 'Unknown'; return `HomeAssistant${pascal}Integration`; }; const readManifest = async (componentDir, fallbackDomain) => { try { return JSON.parse(await readFile(join(componentDir, 'manifest.json'), 'utf8')); } catch { return { domain: fallbackDomain, name: fallbackDomain }; } }; const isGeneratedFolder = async (folderUrl) => { try { await stat(new URL(`${markerName}`, folderUrl)); return true; } catch { return false; } }; const fileExists = async (fileUrl) => { try { await stat(fileUrl); return true; } catch { return false; } }; const json = (value) => JSON.stringify(value, null, 2); await mkdir(integrationsRoot, { recursive: true }); await mkdir(generatedRoot, { recursive: true }); for (const entry of await readdir(integrationsRoot, { withFileTypes: true })) { if (!entry.isDirectory()) continue; if (entry.name === 'generated') continue; const folderUrl = new URL(`./${entry.name}/`, integrationsRoot); if (await isGeneratedFolder(folderUrl)) { await rm(folderUrl, { recursive: true, force: true }); } } const ports = []; for (const entry of await readdir(componentsDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; if (entry.name.startsWith('__')) continue; const componentDir = join(componentsDir, entry.name); const manifest = await readManifest(componentDir, entry.name); const domain = String(manifest.domain || entry.name); const folderName = domain.replace(/[^a-z0-9_]/gi, '_').toLowerCase(); const folderUrl = new URL(`./${folderName}/`, integrationsRoot); const className = toClassName(domain); const metadata = { source: 'home-assistant/core', upstreamPath: `homeassistant/components/${entry.name}`, upstreamDomain: domain, integrationType: manifest.integration_type ? String(manifest.integration_type) : undefined, iotClass: manifest.iot_class ? String(manifest.iot_class) : undefined, qualityScale: manifest.quality_scale ? String(manifest.quality_scale) : undefined, requirements: Array.isArray(manifest.requirements) ? manifest.requirements.map(String) : [], dependencies: Array.isArray(manifest.dependencies) ? manifest.dependencies.map(String) : [], afterDependencies: Array.isArray(manifest.after_dependencies) ? manifest.after_dependencies.map(String) : [], codeowners: Array.isArray(manifest.codeowners) ? manifest.codeowners.map(String) : [], }; let handwritten = false; try { await stat(folderUrl); handwritten = !(await isGeneratedFolder(folderUrl)); } catch {} if (!handwritten) { await mkdir(folderUrl, { recursive: true }); await writeFile(new URL(markerName, folderUrl), 'This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.\n'); await writeFile( new URL('index.ts', folderUrl), `export * from './${folderName}.classes.integration.js';\nexport * from './${folderName}.types.js';\n` ); await writeFile( new URL(`${folderName}.types.ts`, folderUrl), `export interface I${className.replace(/Integration$/, 'Config')} {\n // TODO: replace with the TypeScript-native config for ${domain}.\n [key: string]: unknown;\n}\n` ); await writeFile( new URL(`${folderName}.classes.integration.ts`, folderUrl), `import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';\n\nexport class ${className} extends DescriptorOnlyIntegration {\n constructor() {\n super({\n domain: ${JSON.stringify(domain)},\n displayName: ${JSON.stringify(manifest.name ? String(manifest.name) : domain)},\n status: 'descriptor-only',\n metadata: ${json(metadata)},\n });\n }\n}\n` ); } ports.push({ domain, folderName, className, handwritten, }); } ports.sort((a, b) => a.domain.localeCompare(b.domain)); const imports = ports .filter((port) => !port.handwritten) .map((port) => `import { ${port.className} } from '../${port.folderName}/index.js';`) .join('\n'); const constructorPushes = ports .filter((port) => !port.handwritten) .map((port) => `generatedHomeAssistantPortIntegrations.push(new ${port.className}());`) .join('\n'); await writeFile( new URL('index.ts', generatedRoot), `// Generated by scripts/generate-homeassistant-ports.mjs. Do not edit manually.\n\nimport type { BaseIntegration } from '../../core/classes.baseintegration.js';\n${imports}\n\nexport const generatedHomeAssistantPortIntegrations: BaseIntegration[] = [];\n${constructorPushes}\n\nexport const generatedHomeAssistantPortCount = ${ports.filter((port) => !port.handwritten).length};\nexport const handwrittenHomeAssistantPortDomains = ${json(ports.filter((port) => port.handwritten).map((port) => port.domain))};\n` ); const handwrittenFolders = []; for (const entry of await readdir(integrationsRoot, { withFileTypes: true })) { if (!entry.isDirectory()) continue; if (entry.name === 'generated') continue; const folderUrl = new URL(`./${entry.name}/`, integrationsRoot); if (await isGeneratedFolder(folderUrl)) continue; if (!(await fileExists(new URL('index.ts', folderUrl)))) continue; handwrittenFolders.push(entry.name); } handwrittenFolders.sort((a, b) => a.localeCompare(b)); await writeFile( new URL('index.ts', integrationsRoot), [ '// Generated by scripts/generate-homeassistant-ports.mjs. Do not edit manually.', "export * from './generated/index.js';", ...handwrittenFolders.map((folderName) => `export * from './${folderName}/index.js';`), '', ].join('\n') ); console.log(`Generated ${ports.filter((port) => !port.handwritten).length} native TypeScript port skeletons. Preserved ${ports.filter((port) => port.handwritten).length} handwritten folders.`);