Files
cal-diy-oidc/scripts/pull-coss-ui-components.ts
sean-brydon 42f2c170aa feat: add coss-ui core package (#25984)
* Install/add components in base coss-ui package

* fix components imports

* add yarn script to pull

* Update packagejson

* remove skeleton duplicates

* feat: allow importing components with the cli

* fix: update exports in package.json to reference styles.css instead of globals.css

* add lucide dep

* refactor: re-pull components

* refactor: remove unused registries section from components.json

* refactor: implement changes suggested by Volnei

* refactor(wip): implemet coss ui components on the profile page

* fix src/src problem

* update command to overwrite existing components

* pulled from registry

* restore globals.css

* feat: add pull script

* feat: enhance pull script with validation and error handling

* remove duplicate components

* refactor: reimport coss component and fix ts errors

* fix: components pulling

* chore: regenerate yarn.lock

* chore: remove tailwindcss/forms

* trying different moduleResolution

* TypeScript requires module to also be "NodeNext".

* remove unnecessary changes

* type fixes plus resolve @coss/ui

* use bundler tsconfig

* add missing deps to features and ui

* Add bundler to other packages

* fix module enext resolution with bundler

* remove reduant files

* remove package json from features

* revert unrelated change to the PR

---------

Co-authored-by: pasqualevitiello <pasqualevitiello@gmail.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-12-18 19:57:21 +00:00

259 lines
8.0 KiB
JavaScript

#!/usr/bin/env node
/**
* Script to pull coss ui components from the registry
* and write them to packages/coss-ui/src/components/
* while rewriting imports to use @coss/ui paths.
*/
import { promises as fs } from 'node:fs';
import https from 'node:https';
import path from 'node:path';
const REGISTRY_BASE_URL = 'https://coss.com/ui/r';
const UI_JSON_URL = `${REGISTRY_BASE_URL}/ui.json`;
// Security limits
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const REQUEST_TIMEOUT = 30000; // 30 seconds
const MAX_FILE_NAME_LENGTH = 255;
const VALID_COMPONENT_NAME_REGEX = /^[a-z0-9-]+$/i;
type ComponentFile = {
type?: string;
path: string;
content: string;
};
type ComponentJson = {
files?: ComponentFile[];
registryDependencies?: string[];
};
type TargetDirs = {
components: string;
};
async function resolvePaths(): Promise<{ targetDirs: TargetDirs; targetRoot: string }> {
const cwd = process.cwd();
// Check if we're running from within packages/coss-ui or from monorepo root
const isInPackage = cwd.endsWith('packages/coss-ui') || cwd.endsWith('packages\\coss-ui');
const targetRoot = isInPackage
? path.resolve(cwd, 'src')
: path.resolve(cwd, 'packages/coss-ui/src');
const targetDirs: TargetDirs = {
components: path.join(targetRoot, 'components'),
};
await fs.mkdir(targetDirs.components, { recursive: true });
return { targetDirs, targetRoot };
}
async function fetchJSON<T>(url: string): Promise<T> {
return new Promise((resolve, reject) => {
const req = https.get(url, (res) => {
// Validate HTTP status code
if (res.statusCode !== 200) {
clearTimeout(timeout);
req.destroy();
reject(new Error(`HTTP ${res.statusCode} error fetching ${url}`));
return;
}
// Validate content type
const contentType = res.headers['content-type'] || '';
if (!contentType.includes('application/json')) {
console.warn(`⚠️ Unexpected content-type: ${contentType} for ${url}`);
}
let data = '';
let size = 0;
res.on('data', (chunk: Buffer | string) => {
size += chunk.length;
if (size > MAX_RESPONSE_SIZE) {
clearTimeout(timeout);
req.destroy();
reject(new Error(`Response too large (max ${MAX_RESPONSE_SIZE} bytes) for ${url}`));
return;
}
data += chunk.toString();
});
res.on('end', () => {
clearTimeout(timeout);
try {
const json = JSON.parse(data) as T;
resolve(json);
} catch (error) {
reject(new Error(`Failed to parse JSON from ${url}: ${(error as Error).message}`));
}
});
})
.on('error', (error: Error) => {
clearTimeout(timeout);
reject(new Error(`Failed to fetch ${url}: ${error.message}`));
});
const timeout = setTimeout(() => {
req.destroy();
reject(new Error(`Request timeout after ${REQUEST_TIMEOUT}ms for ${url}`));
}, REQUEST_TIMEOUT);
});
}
function validateComponentName(name: string): boolean {
if (!name || name.length === 0 || name.length > MAX_FILE_NAME_LENGTH) {
return false;
}
return VALID_COMPONENT_NAME_REGEX.test(name);
}
function getComponentName(registryDependency: string): string {
if (!registryDependency.startsWith('@coss/')) {
throw new Error(`Invalid registry dependency format: ${registryDependency}`);
}
const componentName = registryDependency.replace('@coss/', '');
if (!validateComponentName(componentName)) {
throw new Error(`Invalid component name: ${componentName}`);
}
return componentName;
}
function getFileName(filePath: string): string {
const fileName = path.basename(filePath);
// Additional validation
if (!fileName || fileName.length === 0 || fileName.length > MAX_FILE_NAME_LENGTH) {
throw new Error(`Invalid file name: ${fileName}`);
}
// Ensure it's a .tsx file
if (!fileName.endsWith('.tsx')) {
throw new Error(`File must be a .tsx file: ${fileName}`);
}
// Prevent path traversal attempts
if (fileName.includes('..') || fileName.includes('/') || fileName.includes('\\')) {
throw new Error(`Invalid file name (path traversal detected): ${fileName}`);
}
return fileName;
}
function validateContent(content: string): void {
if (!content || typeof content !== 'string') {
throw new Error('Content must be a non-empty string');
}
if (content.length > MAX_RESPONSE_SIZE) {
throw new Error(`Content too large (max ${MAX_RESPONSE_SIZE} bytes)`);
}
}
function rewriteImports(code: string): string {
let result = code;
// Align imports with @coss/ui paths
result = result.replace(/(['"])@\/lib\//g, "$1@coss/ui/lib/");
result = result.replace(/(['"])@\/hooks\//g, "$1@coss/ui/hooks/");
result = result.replace(/(['"])@\/registry\/default\/ui\//g, "$1@coss/ui/components/");
result = result.replace(/(['"])@\/registry\/default\/hooks\//g, "$1@coss/ui/hooks/");
result = result.replace(/(['"])@\/registry\/default\/lib\//g, "$1@coss/ui/lib/");
return result;
}
async function processComponent(registryDependency: string, targetDirs: TargetDirs): Promise<boolean> {
let componentName: string;
try {
componentName = getComponentName(registryDependency);
} catch (error) {
console.error(`❌ Invalid component dependency: ${registryDependency}`, (error as Error).message);
return false;
}
const componentUrl = `${REGISTRY_BASE_URL}/${componentName}.json`;
console.log(`Pulling ${componentName}...`);
try {
const componentJson = await fetchJSON<ComponentJson>(componentUrl);
// Validate JSON structure
if (!componentJson || typeof componentJson !== 'object') {
throw new Error('Invalid component JSON structure');
}
if (!Array.isArray(componentJson.files)) {
throw new Error('Component files must be an array');
}
const componentFile = componentJson.files.find(
(file) => file.type === 'registry:ui' && file.path.endsWith('.tsx')
);
if (!componentFile) {
console.warn(`⚠️ No component file found for ${componentName}`);
return false;
}
// Validate file structure
if (!componentFile.path || !componentFile.content) {
throw new Error('Component file missing path or content');
}
const fileName = getFileName(componentFile.path);
validateContent(componentFile.content);
const content = rewriteImports(componentFile.content);
const filePath = path.join(targetDirs.components, fileName);
await fs.writeFile(filePath, content, 'utf8');
console.log(`✅ Pulled ${fileName}`);
return true;
} catch (error) {
console.error(`❌ Error pulling ${componentName}:`, (error as Error).message);
return false;
}
}
async function main(): Promise<void> {
console.log('🚀 Pulling coss ui components...\n');
try {
const { targetDirs, targetRoot } = await resolvePaths();
console.log(`Target root: ${targetRoot}`);
console.log(`Components dir: ${targetDirs.components}\n`);
console.log(`Pulling index: ${UI_JSON_URL}...`);
const uiJson = await fetchJSON<ComponentJson>(UI_JSON_URL);
// Validate index structure
if (!uiJson || typeof uiJson !== 'object') {
throw new Error('Invalid UI index JSON structure');
}
if (!Array.isArray(uiJson.registryDependencies)) {
throw new Error('registryDependencies must be an array');
}
const registryDependencies = uiJson.registryDependencies || [];
console.log(`Found ${registryDependencies.length} components\n`);
const results = await Promise.all(
registryDependencies.map((dep) => processComponent(dep, targetDirs))
);
const successCount = results.filter((r) => r === true).length;
const failCount = results.filter((r) => r === false).length;
console.log(`\n✨ Done!`);
console.log(`✅ Successfully pulled: ${successCount} components`);
if (failCount > 0) {
console.log(`❌ Failed: ${failCount} components`);
}
} catch (error) {
console.error('❌ Fatal error:', (error as Error).message);
process.exit(1);
}
}
void main();