Files

544 lines
15 KiB
TypeScript

import { execSync } from 'node:child_process'
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import * as p from '@clack/prompts'
import { manual, submodules, vendors } from '../meta.ts'
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = join(__dirname, '..')
function exec(cmd: string, cwd = root): string {
return execSync(cmd, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
}
function execSafe(cmd: string, cwd = root): string | null {
try {
return exec(cmd, cwd)
}
catch {
return null
}
}
function getGitSha(dir: string): string | null {
return execSafe('git rev-parse HEAD', dir)
}
function submoduleExists(path: string): boolean {
const gitmodules = join(root, '.gitmodules')
if (!existsSync(gitmodules))
return false
const content = readFileSync(gitmodules, 'utf-8')
return content.includes(`path = ${path}`)
}
const RE_SUBMODULE_PATH = /path\s*=\s*(.+)/g
function getExistingSubmodulePaths(): string[] {
const gitmodules = join(root, '.gitmodules')
if (!existsSync(gitmodules))
return []
const content = readFileSync(gitmodules, 'utf-8')
const matches = content.matchAll(RE_SUBMODULE_PATH)
return Array.from(matches, match => match[1].trim())
}
function removeSubmodule(submodulePath: string): void {
// Deinitialize the submodule
execSafe(`git submodule deinit -f ${submodulePath}`)
// Remove from .git/modules
const gitModulesPath = join(root, '.git', 'modules', submodulePath)
if (existsSync(gitModulesPath)) {
rmSync(gitModulesPath, { recursive: true })
}
// Remove from working tree and .gitmodules
exec(`git rm -f ${submodulePath}`)
}
interface Project {
name: string
url: string
type: 'source' | 'vendor'
path: string
}
interface VendorConfig {
source: string
skillsPath?: string // Optional custom path to skills directory (default: 'skills')
skills: Record<string, string> // sourceSkillName -> outputSkillName
}
async function initSubmodules(skipPrompt = false) {
const allProjects: Project[] = [
...Object.entries(submodules).map(([name, url]) => ({
name,
url,
type: 'source' as const,
path: `sources/${name}`,
})),
...Object.entries(vendors).map(([name, config]) => ({
name,
url: (config as VendorConfig).source,
type: 'vendor' as const,
path: `vendor/${name}`,
})),
]
const spinner = p.spinner()
// Check for extra submodules that are not in meta.ts
const existingSubmodulePaths = getExistingSubmodulePaths()
const expectedPaths = new Set(allProjects.map(p => p.path))
const extraSubmodules = existingSubmodulePaths.filter(path => !expectedPaths.has(path))
if (extraSubmodules.length > 0) {
p.log.warn(`Found ${extraSubmodules.length} submodule(s) not in meta.ts:`)
for (const path of extraSubmodules) {
p.log.message(` - ${path}`)
}
const shouldRemove = skipPrompt
? true
: await p.confirm({
message: 'Remove these extra submodules?',
initialValue: true,
})
if (p.isCancel(shouldRemove)) {
p.cancel('Cancelled')
return
}
if (shouldRemove) {
for (const submodulePath of extraSubmodules) {
spinner.start(`Removing submodule: ${submodulePath}`)
try {
removeSubmodule(submodulePath)
spinner.stop(`Removed: ${submodulePath}`)
}
catch (e) {
spinner.stop(`Failed to remove ${submodulePath}: ${e}`)
}
}
}
}
const existingProjects = allProjects.filter(p => submoduleExists(p.path))
const newProjects = allProjects.filter(p => !submoduleExists(p.path))
if (newProjects.length === 0) {
p.log.info('All submodules already initialized')
return
}
const selected = skipPrompt
? newProjects
: await p.multiselect({
message: 'Select projects to initialize',
options: newProjects.map(project => ({
value: project,
label: `${project.name} (${project.type})`,
hint: project.url,
})),
initialValues: newProjects,
})
if (p.isCancel(selected)) {
p.cancel('Cancelled')
return
}
for (const project of selected as Project[]) {
spinner.start(`Adding submodule: ${project.name}`)
// Ensure parent directory exists
const parentDir = join(root, dirname(project.path))
if (!existsSync(parentDir)) {
mkdirSync(parentDir, { recursive: true })
}
try {
exec(`git submodule add ${project.url} ${project.path}`)
spinner.stop(`Added: ${project.name}`)
}
catch (e) {
spinner.stop(`Failed to add ${project.name}: ${e}`)
}
}
p.log.success('Submodules initialized')
if (existingProjects.length > 0) {
p.log.info(`Already initialized: ${existingProjects.map(p => p.name).join(', ')}`)
}
}
async function syncSubmodules() {
const spinner = p.spinner()
// Update all submodules
spinner.start('Updating submodules...')
try {
exec('git submodule update --remote --merge')
spinner.stop('Submodules updated')
}
catch (e) {
spinner.stop(`Failed to update submodules: ${e}`)
return
}
// Sync Type 2 skills
for (const [vendorName, config] of Object.entries(vendors)) {
const vendorConfig = config as VendorConfig
const vendorPath = join(root, 'vendor', vendorName)
const skillsBasePath = vendorConfig.skillsPath || 'skills'
const vendorSkillsPath = join(vendorPath, skillsBasePath)
if (!existsSync(vendorPath)) {
p.log.warn(`Vendor submodule not found: ${vendorName}. Run init first.`)
continue
}
if (!existsSync(vendorSkillsPath)) {
p.log.warn(`No skills directory in vendor/${vendorName}/${skillsBasePath}/`)
continue
}
// Sync each specified skill
for (const [sourceSkillName, outputSkillName] of Object.entries(vendorConfig.skills)) {
const sourceSkillPath = join(vendorSkillsPath, sourceSkillName)
const outputPath = join(root, 'skills', outputSkillName)
if (!existsSync(sourceSkillPath)) {
p.log.warn(`Skill not found: vendor/${vendorName}/skills/${sourceSkillName}`)
continue
}
spinner.start(`Syncing skill: ${sourceSkillName}${outputSkillName}`)
// Remove existing output directory to ensure clean sync
if (existsSync(outputPath)) {
rmSync(outputPath, { recursive: true })
}
mkdirSync(outputPath, { recursive: true })
// Copy all files from source skill to output
const files = readdirSync(sourceSkillPath, { recursive: true, withFileTypes: true })
for (const file of files) {
if (file.isFile()) {
const fullPath = join(file.parentPath, file.name)
const relativePath = fullPath.replace(sourceSkillPath, '')
const destPath = join(outputPath, relativePath)
// Ensure destination directory exists
const destDir = dirname(destPath)
if (!existsSync(destDir)) {
mkdirSync(destDir, { recursive: true })
}
cpSync(fullPath, destPath)
}
}
// Copy LICENSE file from vendor repo root if it exists
const licenseNames = ['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'license', 'license.md', 'license.txt']
for (const licenseName of licenseNames) {
const licensePath = join(vendorPath, licenseName)
if (existsSync(licensePath)) {
cpSync(licensePath, join(outputPath, 'LICENSE.md'))
break
}
}
// Update SYNC.md (instead of GENERATION.md for vendored skills)
const sha = getGitSha(vendorPath)
const syncPath = join(outputPath, 'SYNC.md')
const date = new Date().toISOString().split('T')[0]
const syncContent = `# Sync Info
- **Source:** \`vendor/${vendorName}/${skillsBasePath}/${sourceSkillName}\`
- **Git SHA:** \`${sha}\`
- **Synced:** ${date}
`
writeFileSync(syncPath, syncContent)
spinner.stop(`Synced: ${sourceSkillName}${outputSkillName}`)
}
}
p.log.success('All skills synced')
}
async function checkUpdates() {
const spinner = p.spinner()
spinner.start('Fetching remote changes...')
try {
exec('git submodule foreach git fetch')
spinner.stop('Fetched remote changes')
}
catch (e) {
spinner.stop(`Failed to fetch: ${e}`)
return
}
const updates: { name: string, type: string, behind: number }[] = []
// Check sources
for (const name of Object.keys(submodules)) {
const path = join(root, 'sources', name)
if (!existsSync(path))
continue
const behind = execSafe('git rev-list HEAD..@{u} --count', path)
const count = behind ? Number.parseInt(behind) : 0
if (count > 0) {
updates.push({ name, type: 'source', behind: count })
}
}
// Check vendors
for (const [name, config] of Object.entries(vendors)) {
const vendorConfig = config as VendorConfig
const path = join(root, 'vendor', name)
if (!existsSync(path))
continue
const behind = execSafe('git rev-list HEAD..@{u} --count', path)
const count = behind ? Number.parseInt(behind) : 0
if (count > 0) {
const skillNames = Object.values(vendorConfig.skills).join(', ')
updates.push({ name: `${name} (${skillNames})`, type: 'vendor', behind: count })
}
}
if (updates.length === 0) {
p.log.success('All submodules are up to date')
}
else {
p.log.info('Updates available:')
for (const update of updates) {
p.log.message(` ${update.name} (${update.type}): ${update.behind} commits behind`)
}
}
}
function getExpectedSkillNames(): Set<string> {
const expected = new Set<string>()
// Skills from submodules (generated skills use same name as submodule key)
for (const name of Object.keys(submodules)) {
expected.add(name)
}
// Skills from vendors (use the output skill name)
for (const config of Object.values(vendors)) {
const vendorConfig = config as VendorConfig
for (const outputName of Object.values(vendorConfig.skills)) {
expected.add(outputName)
}
}
// Manual skills
for (const name of manual) {
expected.add(name)
}
return expected
}
function getExistingSkillNames(): string[] {
const skillsDir = join(root, 'skills')
if (!existsSync(skillsDir))
return []
return readdirSync(skillsDir, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
}
async function cleanup(skipPrompt = false) {
const spinner = p.spinner()
let hasChanges = false
// 1. Find and remove extra submodules
const allProjects: Project[] = [
...Object.entries(submodules).map(([name, url]) => ({
name,
url,
type: 'source' as const,
path: `sources/${name}`,
})),
...Object.entries(vendors).map(([name, config]) => ({
name,
url: (config as VendorConfig).source,
type: 'vendor' as const,
path: `vendor/${name}`,
})),
]
const existingSubmodulePaths = getExistingSubmodulePaths()
const expectedSubmodulePaths = new Set(allProjects.map(p => p.path))
const extraSubmodules = existingSubmodulePaths.filter(path => !expectedSubmodulePaths.has(path))
if (extraSubmodules.length > 0) {
p.log.warn(`Found ${extraSubmodules.length} submodule(s) not in meta.ts:`)
for (const path of extraSubmodules) {
p.log.message(` - ${path}`)
}
const shouldRemove = skipPrompt
? true
: await p.confirm({
message: 'Remove these extra submodules?',
initialValue: true,
})
if (p.isCancel(shouldRemove)) {
p.cancel('Cancelled')
return
}
if (shouldRemove) {
hasChanges = true
for (const submodulePath of extraSubmodules) {
spinner.start(`Removing submodule: ${submodulePath}`)
try {
removeSubmodule(submodulePath)
spinner.stop(`Removed: ${submodulePath}`)
}
catch (e) {
spinner.stop(`Failed to remove ${submodulePath}: ${e}`)
}
}
}
}
// 2. Find and remove extra skills
const existingSkills = getExistingSkillNames()
const expectedSkills = getExpectedSkillNames()
const extraSkills = existingSkills.filter(name => !expectedSkills.has(name))
if (extraSkills.length > 0) {
p.log.warn(`Found ${extraSkills.length} skill(s) not in meta.ts:`)
for (const name of extraSkills) {
p.log.message(` - skills/${name}`)
}
const shouldRemove = skipPrompt
? true
: await p.confirm({
message: 'Remove these extra skills?',
initialValue: true,
})
if (p.isCancel(shouldRemove)) {
p.cancel('Cancelled')
return
}
if (shouldRemove) {
hasChanges = true
for (const skillName of extraSkills) {
spinner.start(`Removing skill: ${skillName}`)
try {
rmSync(join(root, 'skills', skillName), { recursive: true })
spinner.stop(`Removed: skills/${skillName}`)
}
catch (e) {
spinner.stop(`Failed to remove skills/${skillName}: ${e}`)
}
}
}
}
if (!hasChanges && extraSubmodules.length === 0 && extraSkills.length === 0) {
p.log.success('Everything is clean, no unused submodules or skills found')
}
else if (hasChanges) {
p.log.success('Cleanup completed')
}
}
async function main() {
const args = process.argv.slice(2)
const skipPrompt = args.includes('-y') || args.includes('--yes')
const command = args.find(arg => !arg.startsWith('-'))
// Handle subcommands directly
if (command === 'init') {
p.intro('Skills Manager - Init')
await initSubmodules(skipPrompt)
p.outro('Done')
return
}
if (command === 'sync') {
p.intro('Skills Manager - Sync')
await syncSubmodules()
p.outro('Done')
return
}
if (command === 'check') {
p.intro('Skills Manager - Check')
await checkUpdates()
p.outro('Done')
return
}
if (command === 'cleanup') {
p.intro('Skills Manager - Cleanup')
await cleanup(skipPrompt)
p.outro('Done')
return
}
// No subcommand: show interactive menu (requires interaction)
if (skipPrompt) {
p.log.error('Command required when using -y flag')
p.log.info('Available commands: init, sync, check, cleanup')
process.exit(1)
}
p.intro('Skills Manager')
const action = await p.select({
message: 'What would you like to do?',
options: [
{ value: 'sync', label: 'Sync submodules', hint: 'Pull latest and sync Type 2 skills' },
{ value: 'init', label: 'Init submodules', hint: 'Add new submodules' },
{ value: 'check', label: 'Check updates', hint: 'See available updates' },
{ value: 'cleanup', label: 'Cleanup', hint: 'Remove unused submodules and skills' },
],
})
if (p.isCancel(action)) {
p.cancel('Cancelled')
process.exit(0)
}
switch (action) {
case 'init':
await initSubmodules()
break
case 'sync':
await syncSubmodules()
break
case 'check':
await checkUpdates()
break
case 'cleanup':
await cleanup()
break
}
p.outro('Done')
}
main().catch(console.error)