import fs from 'node:fs' import path from 'node:path' import { spawnSync } from 'node:child_process' const cwd = process.cwd() function fail(message, exitCode = 1) { console.error(message) process.exit(exitCode) } function discoverComponents() { return fs .readdirSync(cwd, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && entry.name.startsWith('glitch_')) .map((entry) => entry.name) .filter((name) => fs.existsSync(path.join(cwd, name, 'package.json'))) .sort() } function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')) } function parseArgs(argv) { const [command = 'list', ...rest] = argv const options = { command, components: [], hostDir: '', stagingDir: path.join(cwd, '.glitch-release') } for (let index = 0; index < rest.length; index += 1) { const arg = rest[index] if (arg === '--host') { options.hostDir = path.resolve(cwd, rest[index + 1] ?? '') index += 1 continue } if (arg === '--staging') { options.stagingDir = path.resolve(cwd, rest[index + 1] ?? '') index += 1 continue } options.components.push(arg) } return options } function resolveComponents(requested) { const discovered = discoverComponents() if (requested.length === 0) return discovered const missing = requested.filter((name) => !discovered.includes(name)) if (missing.length > 0) { fail(`Unknown components: ${missing.join(', ')}`) } return requested } function loadComponent(componentDirName) { const componentDir = path.join(cwd, componentDirName) const packagePath = path.join(componentDir, 'package.json') const manifestPath = path.join(componentDir, 'glitch.manifest.json') const packageJson = readJson(packagePath) const manifest = fs.existsSync(manifestPath) ? readJson(manifestPath) : null return { componentDirName, componentDir, packagePath, manifestPath, packageJson, manifest } } function validateComponent(component) { const requiredFiles = ['package.json', 'src/index.tsx', 'src/Component.tsx', 'src/types.ts', 'src/dev.tsx'] const errors = [] const warnings = [] const hasManifest = Boolean(component.manifest) const hasStandardSignals = hasManifest for (const relativePath of requiredFiles) { if (!fs.existsSync(path.join(component.componentDir, relativePath))) { if (hasStandardSignals) { errors.push(`Missing required file: ${relativePath}`) } else { warnings.push(`Legacy structure is missing standard file: ${relativePath}`) } } } const hasViteConfig = fs.existsSync(path.join(component.componentDir, 'vite.config.ts')) || fs.existsSync(path.join(component.componentDir, 'vite.config.mjs')) || fs.existsSync(path.join(component.componentDir, 'vite.config.js')) if (!hasViteConfig) { if (hasStandardSignals) { errors.push('Missing required file: vite.config.ts') } else { warnings.push('Legacy structure is missing standard file: vite.config.ts') } } if (!component.packageJson.scripts?.dev && hasStandardSignals) { errors.push('Missing package script: dev') } if (!component.packageJson.scripts?.build && hasStandardSignals) { errors.push('Missing package script: build') } const indexPath = path.join(component.componentDir, 'src', 'index.tsx') if (fs.existsSync(indexPath)) { const source = fs.readFileSync(indexPath, 'utf8') if (!source.includes('export default') && hasStandardSignals) { errors.push('src/index.tsx must export default') } if (!source.includes('export const metadata') && hasStandardSignals) { errors.push('src/index.tsx must export metadata') } if (source.includes("import './dev.css'") || source.includes("import './dev-theme.css'")) { warnings.push('Production entry imports a dev-only stylesheet') } } const styleFiles = ['src/styles.css', 'src/styles.module.css'].filter((relativePath) => fs.existsSync(path.join(component.componentDir, relativePath)) ) for (const relativePath of styleFiles) { const source = fs.readFileSync(path.join(component.componentDir, relativePath), 'utf8') if (source.includes('@import url(')) { warnings.push(`${relativePath} imports a remote stylesheet`) } } if (!component.manifest) { warnings.push('Missing glitch.manifest.json') } else { for (const key of ['componentId', 'displayName', 'version', 'folderName', 'packageName', 'entry']) { if (!component.manifest[key]) { errors.push(`glitch.manifest.json is missing "${key}"`) } } } if (!fs.existsSync(path.join(component.componentDir, 'README.md'))) { warnings.push('Missing README.md') } return { errors, warnings } } function runBuild(component) { const result = spawnSync('npm', ['run', 'build'], { cwd: component.componentDir, stdio: 'inherit' }) if (result.status !== 0) { fail(`Build failed for ${component.componentDirName}`, result.status ?? 1) } } function ensureDirectory(directory) { fs.mkdirSync(directory, { recursive: true }) } function copyDirectoryContents(sourceDir, destinationDir) { ensureDirectory(destinationDir) for (const entry of fs.readdirSync(sourceDir)) { fs.cpSync(path.join(sourceDir, entry), path.join(destinationDir, entry), { force: true, recursive: true }) } } function releaseComponent(component, hostDir, stagingDir) { const distDir = path.join(component.componentDir, 'dist') if (!fs.existsSync(distDir)) { fail(`Missing dist directory for ${component.componentDirName}. Run build first.`) } const manifest = component.manifest ?? { componentId: component.componentDirName.replace(/^glitch_/, '').replaceAll('_', '-'), displayName: component.packageJson.description ?? component.packageJson.name, version: component.packageJson.version, folderName: component.componentDirName, packageName: component.packageJson.name, entry: `dist/${component.packageJson.main?.replace(/^\.\/dist\//, '') ?? 'index.js'}`, source: 'src/index.tsx', tags: ['legacy'] } const releaseDir = path.join(hostDir, manifest.folderName) copyDirectoryContents(distDir, releaseDir) fs.writeFileSync(path.join(releaseDir, 'glitch.manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`) const stagingComponentDir = path.join(stagingDir, manifest.folderName) copyDirectoryContents(distDir, stagingComponentDir) fs.writeFileSync( path.join(stagingComponentDir, 'glitch.manifest.json'), `${JSON.stringify(manifest, null, 2)}\n` ) } const options = parseArgs(process.argv.slice(2)) const componentNames = resolveComponents(options.components) const components = componentNames.map(loadComponent) switch (options.command) { case 'list': { for (const componentName of componentNames) { console.log(componentName) } break } case 'validate': { let errorCount = 0 let warningCount = 0 for (const component of components) { const { errors, warnings } = validateComponent(component) if (errors.length === 0 && warnings.length === 0) { console.log(`OK ${component.componentDirName}`) continue } console.log(component.componentDirName) for (const error of errors) { errorCount += 1 console.log(` ERROR ${error}`) } for (const warning of warnings) { warningCount += 1 console.log(` WARN ${warning}`) } } if (errorCount > 0) { fail(`Validation failed with ${errorCount} error(s) and ${warningCount} warning(s).`) } console.log(`Validation passed with ${warningCount} warning(s).`) break } case 'build': { for (const component of components) { console.log(`=== Building ${component.componentDirName} ===`) runBuild(component) } break } case 'release': { const hostDir = options.hostDir || path.resolve(cwd, '..', 'gnommoweb', 'src', 'components', 'glitch') ensureDirectory(hostDir) ensureDirectory(options.stagingDir) for (const component of components) { console.log(`=== Releasing ${component.componentDirName} ===`) runBuild(component) releaseComponent(component, hostDir, options.stagingDir) } break } default: fail(`Unknown command: ${options.command}`) }