UNPKG

@reliverse/rse-sdk

Version:

@reliverse/rse-sdk allows you to create new plugins for @reliverse/rse CLI, interact with reliverse.org, and even extend your own CLI functionality (you may also try @reliverse/dler-sdk for this case).

345 lines (344 loc) 12.4 kB
import path from "@reliverse/pathkit"; import { ensuredir } from "@reliverse/relifso"; import fs from "@reliverse/relifso"; import { relinka } from "@reliverse/relinka"; import { multiselectPrompt, confirmPrompt } from "@reliverse/rempts"; import { ofetch } from "ofetch"; import pMap from "p-map"; import { getMaxHeightSize } from "../../utils/microHelpers.js"; import { DEFAULT_BRANCH, getRepoCacheDir, RULE_FILE_EXTENSION, RULES_REPOS } from "./add-rule-const.js"; function cleanTsContent(content) { content = content.replace(/^.*?content:\s*/s, ""); content = content.replace(/,\s*author:\s*\{[\s\S]*$/s, ""); return content.trim(); } export function convertTsToMdc(content, filePath) { let cleaned = content.replace(/export\s+const\s+\w+\s*=\s*/, ""); cleaned = cleaned.replace(/;\s*$/, ""); cleaned = cleaned.replace(/import\s+.*?from\s+['"].*?['"];?\s*/g, ""); cleaned = cleaned.replace(/export\s+default\s+/, ""); cleaned = cleaned.replace(/`/g, ""); cleaned = cleaned.trim(); cleaned = cleanTsContent(cleaned); const frontmatter = [ "---", `description: ${path.basename(filePath, ".ts")}`, "glob: []", "alwaysApply: false", "---", "" ].join("\n"); return frontmatter + cleaned; } export async function hasCursorRulesDir(cwd) { const rulesDir = path.join(cwd, ".cursor", "rules"); return fs.pathExists(rulesDir); } export async function hasInstalledRules(cwd) { const rulesDir = path.join(cwd, ".cursor", "rules"); if (!await fs.pathExists(rulesDir)) return false; const files = await fs.readdir(rulesDir); return files.some((file) => file.endsWith(RULE_FILE_EXTENSION)); } export async function checkRulesRepoUpdate(repoId) { const [owner, repoName] = repoId.split("/"); if (!owner || !repoName) return false; const repoCacheDir = getRepoCacheDir(owner); const versionFilePath = path.join(repoCacheDir, ".last_updated"); if (!await fs.pathExists(repoCacheDir)) { return true; } let currentDate = null; if (await fs.pathExists(versionFilePath)) { currentDate = await fs.readFile(versionFilePath, "utf-8"); } else { return true; } try { const url = `https://ungh.cc/repos/${owner}/${repoName}`; const data = await ofetch(url); const latestDate = data.repo?.pushedAt ?? null; if (!latestDate) return false; return new Date(currentDate) < new Date(latestDate); } catch (error) { console.error("Failed to check for updates:", error); return false; } } export async function checkForRuleUpdates(isDev) { let hasUpdates = false; for (const repo of RULES_REPOS) { const [owner] = repo.id.split("/"); if (!owner) continue; if (!await fs.pathExists(getRepoCacheDir(owner))) continue; const hasRepoUpdate = await checkRulesRepoUpdate(repo.id); if (hasRepoUpdate) { hasUpdates = true; if (isDev) { relinka("info", `Updates available for ${repo.id}`); } } } return hasUpdates; } async function getCachedRuleFiles(owner) { relinka("verbose", `Getting cached rule files for owner: ${owner}`); const repoCacheDir = getRepoCacheDir(owner); if (!await fs.pathExists(repoCacheDir)) { relinka("verbose", `Cache directory does not exist: ${repoCacheDir}`); return []; } try { const files = await fs.readdir(repoCacheDir, { recursive: true }); const filteredFiles = files.filter( (file) => typeof file === "string" && file.endsWith(RULE_FILE_EXTENSION) && !file.startsWith(".") ); relinka( "success", `Found ${filteredFiles.length} rule files (${RULE_FILE_EXTENSION}) in cache` ); return filteredFiles; } catch (error) { relinka("error", `Failed to read cached rules: ${error}`); return []; } } export async function downloadRules(repoId, isDev) { relinka("verbose", `Development mode: ${isDev}`); relinka("verbose", `Starting download process for repository: ${repoId}`); const [owner, repoName] = repoId.split("/"); if (!owner || !repoName) { relinka("error", "Invalid repository ID format"); return []; } const repo = RULES_REPOS.find((r) => r.id === repoId); if (!repo) { relinka("error", "Repository not found in configuration"); return []; } const branch = repo.branch || DEFAULT_BRANCH; const repoCacheDir = getRepoCacheDir(owner); await ensuredir(repoCacheDir); relinka("verbose", `Cache directory: ${repoCacheDir}`); const apiUrl = `https://api.github.com/repos/${owner}/${repoName}/git/trees/${branch}?recursive=1`; let availableFiles = []; try { const repoData = await ofetch(apiUrl, { retry: 3, retryDelay: 1e3, onResponseError: (error) => { if (error.response?.status === 404) { throw new Error( `Repository or branch not found: ${repoId}#${branch}` ); } if (error.response?.status === 403) { throw new Error( "GitHub API rate limit exceeded. Please try again later." ); } throw new Error( `Failed to fetch repository: ${error.response?.statusText || "Unknown error"}` ); } }); if (repo.isCommunity) { const communityPath = repo.communityPath; if (!communityPath) { relinka("error", "Community path not defined for repository"); return []; } availableFiles = repoData.tree.filter( (item) => item.type === "blob" && item.path.endsWith(".ts") && item.path.startsWith(communityPath) && !item.path.split("/").some((part) => part.startsWith(".")) ); } else { availableFiles = repoData.tree.filter( (item) => item.type === "blob" && item.path.endsWith(".md") && item.path.toLowerCase() !== "readme.md" && !item.path.split("/").some((part) => part.startsWith(".")) ); } if (availableFiles.length === 0) { relinka("error", "No rule files found in repository"); return []; } const maxItems = getMaxHeightSize(); const totalFiles = availableFiles.length; availableFiles = availableFiles.slice(0, maxItems); const cachedFiles = await getCachedRuleFiles(owner); const options = availableFiles.map((file, index) => { const baseName = path.basename(file.path); let expectedCacheFile; if (repo.isCommunity) { expectedCacheFile = `${path.basename(file.path, ".ts")}${RULE_FILE_EXTENSION}`; } else { expectedCacheFile = `${path.basename(file.path, ".md")}${RULE_FILE_EXTENSION}`; } const isCached = cachedFiles.includes(expectedCacheFile); return { value: file.path, label: `${index + 1}. ${baseName}`, hint: isCached ? "cached" : void 0 }; }); const selectedFiles = await multiselectPrompt({ title: `Select rules to download from ${repoId} (${Math.min( totalFiles, maxItems )} of ${totalFiles} available)`, content: `If you don't see all rules, try increasing your terminal height (current: ${maxItems})`, options }); if (selectedFiles.length === 0) { relinka("error", "No rules selected for download"); return []; } const downloadedFiles = []; const total = selectedFiles.length; await pMap( selectedFiles, async (filePath) => { let expectedCacheFile; if (repo.isCommunity) { expectedCacheFile = `${path.basename(filePath, ".ts")}${RULE_FILE_EXTENSION}`; } else { expectedCacheFile = `${path.basename(filePath, ".md")}${RULE_FILE_EXTENSION}`; } const cacheFilePath = path.join(repoCacheDir, expectedCacheFile); if (await fs.pathExists(cacheFilePath)) { relinka("verbose", `[Cached] ${filePath} is already in cache.`); downloadedFiles.push(expectedCacheFile); return; } try { const contentUrl = `https://raw.githubusercontent.com/${owner}/${repoName}/${branch}/${filePath}`; const content = await ofetch(contentUrl, { responseType: "text", retry: 2, retryDelay: 500 }); if (repo.isCommunity) { const mdContent = convertTsToMdc(content, filePath); await fs.writeFile(cacheFilePath, mdContent); } else { await fs.writeFile(cacheFilePath, content); } downloadedFiles.push(expectedCacheFile); relinka( "verbose", `[${downloadedFiles.length}/${total}] Processed ${filePath}` ); } catch (error) { relinka("error", `Failed to process ${filePath}: ${error}`); } }, { concurrency: 5 } ); if (downloadedFiles.length === 0) { relinka("error", "Failed to download any rule files"); return []; } const metadata = { repoId, branch, downloadedAt: (/* @__PURE__ */ new Date()).toISOString(), totalFiles: downloadedFiles.length, tags: repo.tags || [], isOfficial: repo.isOfficial || false, isCommunity: repo.isCommunity || false }; await fs.writeFile( path.join(repoCacheDir, ".metadata.json"), JSON.stringify(metadata, null, 2) ); await fs.writeFile( path.join(repoCacheDir, ".last_updated"), (/* @__PURE__ */ new Date()).toISOString() ); relinka( "verbose", `Processed ${downloadedFiles.length}/${total} rule file(s) successfully` ); return downloadedFiles; } catch (error) { relinka("error", `Failed to download rules: ${error}`); return []; } } export async function installRules(files, owner, cwd) { relinka("verbose", `Installing ${files.length} rule(s) from owner: ${owner}`); const repoCacheDir = getRepoCacheDir(owner); const cursorRulesDir = path.join(cwd, ".cursor", "rules"); await ensuredir(cursorRulesDir); relinka("verbose", `Source directory: ${repoCacheDir}`); relinka("verbose", `Target directory: ${cursorRulesDir}`); let installedCount = 0; let skippedCount = 0; for (const file of files) { const sourceFile = path.join(repoCacheDir, file); const baseFileName = path.basename(file, path.extname(file)); const targetFileName = `${baseFileName}${RULE_FILE_EXTENSION}`; const targetFile = path.join(cursorRulesDir, targetFileName); relinka("verbose", `Copying file: ${file} -> ${targetFile}`); try { await fs.copy(sourceFile, targetFile, { overwrite: true }); installedCount++; relinka("success", `Installed ${targetFileName}`); } catch (error) { relinka("error", `Failed to install ${file}: ${error}`); skippedCount++; } } if (files.length > 5) { relinka("success", `Installed ${installedCount} rule file(s)`); } if (skippedCount > 0) { relinka("verbose", `Skipped ${skippedCount} rule file(s)`); } relinka( "verbose", `Total installed: ${installedCount}, skipped: ${skippedCount}` ); } export async function handleRuleUpdates(cwd, isDev) { relinka("verbose", `Checking for rule updates in workspace: ${cwd}`); const hasUpdates = await checkForRuleUpdates(isDev); if (!hasUpdates) { relinka("success", "No updates available for installed rules"); return; } relinka("verbose", "Updates available for installed rules"); const shouldUpdate = await confirmPrompt({ title: "Updates available for installed rules. Download now?" }); if (!shouldUpdate) { relinka("verbose", "User chose not to update rules"); return; } relinka("verbose", "Starting rule update process"); for (const repo of RULES_REPOS) { const [owner] = repo.id.split("/"); if (!owner) continue; if (!await fs.pathExists(getRepoCacheDir(owner))) continue; relinka("info", `Updating rules for repository: ${repo.id}`); const repoId = repo.id; const hasRepoUpdate = await checkRulesRepoUpdate(repoId); if (hasRepoUpdate) { relinka("info", `Updates available for ${repoId}, downloading...`); const files = await downloadRules(repoId, isDev); if (files.length > 0 && await hasCursorRulesDir(cwd)) { relinka( "info", `Installing downloaded rules into ${cwd}/.cursor/rules` ); await installRules(files, owner, cwd); } } else { relinka("info", `No updates available for ${repo.id}`); } } relinka("success", "Rule updates completed"); }