leetkick
Version:
A CLI tool for scaffolding LeetCode exercises with language-specific testing setups
243 lines (204 loc) • 6.67 kB
text/typescript
import {Command} from 'commander';
import {readdir, copyFile, unlink, readFile} from 'fs/promises';
import {join, dirname} from 'path';
import {fileURLToPath} from 'url';
import {existsSync} from 'fs';
import {findWorkspaceRoot} from '../utils/workspace.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const TEMPLATES_DIR = join(__dirname, '../../../templates');
interface SyncResult {
updated: string[];
added: string[];
removed: string[];
}
export const syncCommand = new Command()
.name('sync')
.description('Sync workspace configuration files with latest templates')
.argument('[language]', 'Language to sync (omit to sync all)')
.option('--all', 'Sync all languages in workspace')
.option('--dry-run', 'Preview changes without applying them')
.action(
async (language?: string, options?: {all?: boolean; dryRun?: boolean}) => {
try {
const workspaceRoot = await findWorkspaceRoot();
if (!workspaceRoot) {
throw new Error(
'No leetkick workspace found. Run "leetkick init" first.',
);
}
const languages = language
? [language]
: await getWorkspaceLanguages(workspaceRoot);
if (languages.length === 0) {
console.log(
'No languages found in workspace. Use "leetkick add <language>" first.',
);
return;
}
console.log(
`Syncing ${languages.length === 1 ? languages[0] : languages.length + ' languages'}...`,
);
for (const lang of languages) {
const result = await syncLanguage(
workspaceRoot,
lang,
options?.dryRun || false,
);
displaySyncResult(lang, result, options?.dryRun || false);
}
} catch (error) {
console.error(
'❌ Sync failed:',
error instanceof Error ? error.message : String(error),
);
throw error;
}
},
);
async function getWorkspaceLanguages(workspaceRoot: string): Promise<string[]> {
const languages: string[] = [];
try {
const entries = await readdir(workspaceRoot, {withFileTypes: true});
for (const entry of entries) {
if (
entry.isDirectory() &&
entry.name !== '.git' &&
!entry.name.startsWith('.')
) {
// Check if this directory has a template
const templateDir = join(TEMPLATES_DIR, entry.name);
if (existsSync(templateDir)) {
languages.push(entry.name);
}
}
}
} catch (error) {
console.error('Failed to scan workspace languages:', error);
}
return languages;
}
async function syncLanguage(
workspaceRoot: string,
language: string,
dryRun: boolean,
): Promise<SyncResult> {
const result: SyncResult = {updated: [], added: [], removed: []};
const languageDir = join(workspaceRoot, language);
const templateDir = join(TEMPLATES_DIR, language);
if (!existsSync(languageDir)) {
throw new Error(`Language workspace '${language}' not found`);
}
if (!existsSync(templateDir)) {
throw new Error(`Template for '${language}' not found`);
}
// Get all template files (excluding exercise/test templates)
const templateFiles = await getConfigFiles(templateDir);
for (const file of templateFiles) {
const sourcePath = join(templateDir, file);
const targetPath = join(languageDir, file);
const exists = existsSync(targetPath);
if (await shouldSyncFile(sourcePath, targetPath)) {
if (!dryRun) {
await copyFile(sourcePath, targetPath);
}
if (exists) {
result.updated.push(file);
} else {
result.added.push(file);
}
}
}
// Check for files to remove (e.g., .prettierrc.json when migrating to Biome)
const obsoleteFiles = await getObsoleteFiles(languageDir, language);
for (const file of obsoleteFiles) {
const filePath = join(languageDir, file);
if (existsSync(filePath)) {
if (!dryRun) {
await unlink(filePath);
}
result.removed.push(file);
}
}
return result;
}
async function getConfigFiles(templateDir: string): Promise<string[]> {
const files: string[] = [];
const entries = await readdir(templateDir, {withFileTypes: true});
for (const entry of entries) {
if (entry.isFile() && !isTemplateFile(entry.name)) {
files.push(entry.name);
}
}
return files;
}
function isTemplateFile(filename: string): boolean {
return (
filename.includes('_template.') ||
filename.startsWith('exercise_') ||
filename.startsWith('test_')
);
}
async function shouldSyncFile(
sourcePath: string,
targetPath: string,
): Promise<boolean> {
if (!existsSync(targetPath)) {
return true; // File doesn't exist, should add it
}
try {
const sourceContent = await readFile(sourcePath, 'utf-8');
const targetContent = await readFile(targetPath, 'utf-8');
// Only sync if files are different
return sourceContent !== targetContent;
} catch {
return true; // If we can't read files, err on the side of syncing
}
}
async function getObsoleteFiles(
languageDir: string,
language: string,
): Promise<string[]> {
const obsoleteFiles: string[] = [];
// Common obsolete files when migrating from Biome to ESLint + Prettier
const biomeFiles = ['biome.json'];
for (const file of biomeFiles) {
if (existsSync(join(languageDir, file))) {
// Only mark as obsolete if we have ESLint + Prettier configs
if (
(existsSync(join(languageDir, 'eslint.config.js')) ||
existsSync(join(TEMPLATES_DIR, language, 'eslint.config.js'))) &&
(existsSync(join(languageDir, '.prettierrc')) ||
existsSync(join(TEMPLATES_DIR, language, '.prettierrc')))
) {
obsoleteFiles.push(file);
}
}
}
return obsoleteFiles;
}
function displaySyncResult(
language: string,
result: SyncResult,
dryRun: boolean,
): void {
const {updated, added, removed} = result;
const total = updated.length + added.length + removed.length;
if (total === 0) {
console.log(`✓ ${language}: Already up to date`);
return;
}
const prefix = dryRun ? '[DRY RUN] ' : '';
console.log(`${prefix}${language}:`);
if (added.length > 0) {
added.forEach(file => console.log(` + Added ${file}`));
}
if (updated.length > 0) {
updated.forEach(file => console.log(` ✓ Updated ${file}`));
}
if (removed.length > 0) {
removed.forEach(file => console.log(` - Removed ${file}`));
}
console.log(
` → ${total} file${total === 1 ? '' : 's'} ${dryRun ? 'would be ' : ''}changed\n`,
);
}