@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
JavaScript
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");
}