2026-03-24 11:30:14 +01:00
|
|
|
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: [],
|
2026-04-01 22:36:42 +02:00
|
|
|
hostDirs: [],
|
2026-03-24 11:30:14 +01:00
|
|
|
stagingDir: path.join(cwd, '.glitch-release')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (let index = 0; index < rest.length; index += 1) {
|
|
|
|
|
const arg = rest[index]
|
|
|
|
|
if (arg === '--host') {
|
2026-04-01 22:36:42 +02:00
|
|
|
options.hostDirs.push(path.resolve(cwd, rest[index + 1] ?? ''))
|
2026-03-24 11:30:14 +01:00
|
|
|
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 })
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 22:36:42 +02:00
|
|
|
function getDefaultHostDirs() {
|
|
|
|
|
return [
|
|
|
|
|
path.resolve(cwd, '..', 'gnommoweb', 'public', 'glitch'),
|
|
|
|
|
path.resolve(cwd, '..', 'gnommoplayer', 'public', 'glitch'),
|
|
|
|
|
path.resolve(cwd, '..', 'gnommoeditor', 'public', 'glitch')
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 11:30:14 +01:00
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 22:36:42 +02:00
|
|
|
function releaseComponent(component, hostDirs, stagingDir) {
|
2026-03-24 11:30:14 +01:00
|
|
|
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']
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 22:36:42 +02:00
|
|
|
for (const hostDir of hostDirs) {
|
|
|
|
|
const releaseDir = path.join(hostDir, manifest.folderName)
|
|
|
|
|
copyDirectoryContents(distDir, releaseDir)
|
|
|
|
|
fs.writeFileSync(path.join(releaseDir, 'glitch.manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
|
|
|
}
|
2026-03-24 11:30:14 +01:00
|
|
|
|
|
|
|
|
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': {
|
2026-04-01 22:36:42 +02:00
|
|
|
const hostDirs = options.hostDirs.length > 0 ? options.hostDirs : getDefaultHostDirs()
|
2026-03-24 11:30:14 +01:00
|
|
|
|
2026-04-01 22:36:42 +02:00
|
|
|
for (const hostDir of hostDirs) {
|
|
|
|
|
ensureDirectory(hostDir)
|
|
|
|
|
}
|
2026-03-24 11:30:14 +01:00
|
|
|
ensureDirectory(options.stagingDir)
|
|
|
|
|
|
|
|
|
|
for (const component of components) {
|
|
|
|
|
console.log(`=== Releasing ${component.componentDirName} ===`)
|
|
|
|
|
runBuild(component)
|
2026-04-01 22:36:42 +02:00
|
|
|
releaseComponent(component, hostDirs, options.stagingDir)
|
2026-03-24 11:30:14 +01:00
|
|
|
}
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
fail(`Unknown command: ${options.command}`)
|
|
|
|
|
}
|