mirror of
https://github.com/cexll/myclaude.git
synced 2026-02-05 02:30:26 +08:00
feat(install): add uninstall command and merge_dir file tracking
- JS: add uninstall subcommand with --module and -y options - JS: merge hooks to settings.json after module install - Python: record merge_dir files for reversible uninstall - Both: track installed files in installed_modules.json Generated with SWE-Agent.ai Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
This commit is contained in:
444
bin/cli.js
444
bin/cli.js
@@ -18,15 +18,24 @@ const API_HEADERS = {
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {
|
||||
command: "install",
|
||||
installDir: "~/.claude",
|
||||
force: false,
|
||||
dryRun: false,
|
||||
list: false,
|
||||
update: false,
|
||||
tag: null,
|
||||
module: null,
|
||||
yes: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
let i = 0;
|
||||
if (argv[i] && !argv[i].startsWith("-")) {
|
||||
out.command = argv[i];
|
||||
i++;
|
||||
}
|
||||
|
||||
for (; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--install-dir") out.installDir = argv[++i];
|
||||
else if (a === "--force") out.force = true;
|
||||
@@ -34,6 +43,8 @@ function parseArgs(argv) {
|
||||
else if (a === "--list") out.list = true;
|
||||
else if (a === "--update") out.update = true;
|
||||
else if (a === "--tag") out.tag = argv[++i];
|
||||
else if (a === "--module") out.module = argv[++i];
|
||||
else if (a === "-y" || a === "--yes") out.yes = true;
|
||||
else if (a === "-h" || a === "--help") out.help = true;
|
||||
else throw new Error(`Unknown arg: ${a}`);
|
||||
}
|
||||
@@ -51,6 +62,8 @@ function printHelp() {
|
||||
" npx github:cexll/myclaude --list",
|
||||
" npx github:cexll/myclaude --update",
|
||||
" npx github:cexll/myclaude --install-dir ~/.claude --force",
|
||||
" npx github:cexll/myclaude uninstall",
|
||||
" npx github:cexll/myclaude uninstall --module bmad,do -y",
|
||||
"",
|
||||
"Options:",
|
||||
" --install-dir <path> Default: ~/.claude",
|
||||
@@ -59,6 +72,8 @@ function printHelp() {
|
||||
" --list List installable items and exit",
|
||||
" --update Update already installed modules",
|
||||
" --tag <tag> Install a specific GitHub tag",
|
||||
" --module <names> For uninstall: comma-separated module names",
|
||||
" -y, --yes For uninstall: skip confirmation prompt",
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
}
|
||||
@@ -202,6 +217,187 @@ function readInstalledModuleNamesFromStatus(installDir) {
|
||||
}
|
||||
}
|
||||
|
||||
function loadInstalledStatus(installDir) {
|
||||
const p = path.join(installDir, "installed_modules.json");
|
||||
if (!fs.existsSync(p)) return { modules: {} };
|
||||
try {
|
||||
const json = JSON.parse(fs.readFileSync(p, "utf8"));
|
||||
const modules = json && json.modules;
|
||||
if (!modules || typeof modules !== "object" || Array.isArray(modules)) return { modules: {} };
|
||||
return { ...json, modules };
|
||||
} catch {
|
||||
return { modules: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function saveInstalledStatus(installDir, status) {
|
||||
const p = path.join(installDir, "installed_modules.json");
|
||||
fs.mkdirSync(installDir, { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(status, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
function upsertModuleStatus(installDir, moduleResult) {
|
||||
const status = loadInstalledStatus(installDir);
|
||||
status.modules = status.modules || {};
|
||||
status.modules[moduleResult.module] = moduleResult;
|
||||
status.updated_at = new Date().toISOString();
|
||||
saveInstalledStatus(installDir, status);
|
||||
}
|
||||
|
||||
function deleteModuleStatus(installDir, moduleName) {
|
||||
const status = loadInstalledStatus(installDir);
|
||||
if (status.modules && Object.prototype.hasOwnProperty.call(status.modules, moduleName)) {
|
||||
delete status.modules[moduleName];
|
||||
status.updated_at = new Date().toISOString();
|
||||
saveInstalledStatus(installDir, status);
|
||||
}
|
||||
}
|
||||
|
||||
function loadSettings(installDir) {
|
||||
const p = path.join(installDir, "settings.json");
|
||||
if (!fs.existsSync(p)) return {};
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(p, "utf8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings(installDir, settings) {
|
||||
const p = path.join(installDir, "settings.json");
|
||||
fs.mkdirSync(installDir, { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
function isPlainObject(x) {
|
||||
return !!x && typeof x === "object" && !Array.isArray(x);
|
||||
}
|
||||
|
||||
function deepEqual(a, b) {
|
||||
if (a === b) return true;
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
|
||||
return true;
|
||||
}
|
||||
if (isPlainObject(a) && isPlainObject(b)) {
|
||||
const aKeys = Object.keys(a);
|
||||
const bKeys = Object.keys(b);
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
for (const k of aKeys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
|
||||
if (!deepEqual(a[k], b[k])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hooksEqual(h1, h2) {
|
||||
if (!isPlainObject(h1) || !isPlainObject(h2)) return false;
|
||||
const a = { ...h1 };
|
||||
const b = { ...h2 };
|
||||
delete a.__module__;
|
||||
delete b.__module__;
|
||||
return deepEqual(a, b);
|
||||
}
|
||||
|
||||
function replaceHookVariables(obj, pluginRoot) {
|
||||
if (typeof obj === "string") return obj.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, pluginRoot);
|
||||
if (Array.isArray(obj)) return obj.map((v) => replaceHookVariables(v, pluginRoot));
|
||||
if (isPlainObject(obj)) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(obj)) out[k] = replaceHookVariables(v, pluginRoot);
|
||||
return out;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
function mergeHooksToSettings(moduleName, hooksConfig, installDir, pluginRoot) {
|
||||
if (!hooksConfig || !isPlainObject(hooksConfig)) return false;
|
||||
const rawHooks = hooksConfig.hooks;
|
||||
if (!rawHooks || !isPlainObject(rawHooks)) return false;
|
||||
|
||||
const settings = loadSettings(installDir);
|
||||
if (!settings.hooks || !isPlainObject(settings.hooks)) settings.hooks = {};
|
||||
|
||||
const moduleHooks = pluginRoot ? replaceHookVariables(rawHooks, pluginRoot) : rawHooks;
|
||||
let modified = false;
|
||||
|
||||
for (const [hookType, hookEntries] of Object.entries(moduleHooks)) {
|
||||
if (!Array.isArray(hookEntries)) continue;
|
||||
if (!Array.isArray(settings.hooks[hookType])) settings.hooks[hookType] = [];
|
||||
|
||||
for (const entry of hookEntries) {
|
||||
if (!isPlainObject(entry)) continue;
|
||||
const entryCopy = { ...entry, __module__: moduleName };
|
||||
|
||||
let exists = false;
|
||||
for (const existing of settings.hooks[hookType]) {
|
||||
if (existing && existing.__module__ === moduleName && hooksEqual(existing, entryCopy)) {
|
||||
exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!exists) {
|
||||
settings.hooks[hookType].push(entryCopy);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) saveSettings(installDir, settings);
|
||||
return modified;
|
||||
}
|
||||
|
||||
function unmergeHooksFromSettings(moduleName, installDir) {
|
||||
const settings = loadSettings(installDir);
|
||||
if (!settings.hooks || !isPlainObject(settings.hooks)) return false;
|
||||
|
||||
let modified = false;
|
||||
for (const hookType of Object.keys(settings.hooks)) {
|
||||
const entries = settings.hooks[hookType];
|
||||
if (!Array.isArray(entries)) continue;
|
||||
const kept = entries.filter((e) => !(e && e.__module__ === moduleName));
|
||||
if (kept.length !== entries.length) {
|
||||
settings.hooks[hookType] = kept;
|
||||
modified = true;
|
||||
}
|
||||
if (!settings.hooks[hookType].length) {
|
||||
delete settings.hooks[hookType];
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) saveSettings(installDir, settings);
|
||||
return modified;
|
||||
}
|
||||
|
||||
function mergeModuleHooks(moduleName, mod, installDir) {
|
||||
const ops = Array.isArray(mod && mod.operations) ? mod.operations : [];
|
||||
let merged = false;
|
||||
|
||||
for (const op of ops) {
|
||||
if (!op || op.type !== "copy_dir") continue;
|
||||
const target = typeof op.target === "string" ? op.target : "";
|
||||
if (!target) continue;
|
||||
|
||||
const targetDir = path.join(installDir, target);
|
||||
const hooksFile = path.join(targetDir, "hooks", "hooks.json");
|
||||
if (!fs.existsSync(hooksFile)) continue;
|
||||
|
||||
let hooksConfig;
|
||||
try {
|
||||
hooksConfig = JSON.parse(fs.readFileSync(hooksFile, "utf8"));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (mergeHooksToSettings(moduleName, hooksConfig, installDir, targetDir)) merged = true;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function dirExists(p) {
|
||||
try {
|
||||
return (await fs.promises.stat(p)).isDirectory();
|
||||
@@ -305,7 +501,8 @@ async function updateInstalledModules(installDir, tag, config, dryRun) {
|
||||
await fs.promises.mkdir(installDir, { recursive: true });
|
||||
for (const name of toUpdate) {
|
||||
process.stdout.write(`Updating module: ${name}\n`);
|
||||
await applyModule(name, config, repoRoot, installDir, true);
|
||||
const r = await applyModule(name, config, repoRoot, installDir, true);
|
||||
upsertModuleStatus(installDir, r);
|
||||
}
|
||||
} finally {
|
||||
if (tmp) await rmTree(tmp);
|
||||
@@ -513,11 +710,12 @@ async function extractTarGz(archivePath, destDir) {
|
||||
}
|
||||
|
||||
async function copyFile(src, dst, force) {
|
||||
if (!force && fs.existsSync(dst)) return;
|
||||
if (!force && fs.existsSync(dst)) return false;
|
||||
await fs.promises.mkdir(path.dirname(dst), { recursive: true });
|
||||
await fs.promises.copyFile(src, dst);
|
||||
const st = await fs.promises.stat(src);
|
||||
await fs.promises.chmod(dst, st.mode);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function copyDirRecursive(src, dst, force) {
|
||||
@@ -534,6 +732,7 @@ async function copyDirRecursive(src, dst, force) {
|
||||
}
|
||||
|
||||
async function mergeDir(src, installDir, force) {
|
||||
const installed = [];
|
||||
const subdirs = await fs.promises.readdir(src, { withFileTypes: true });
|
||||
for (const d of subdirs) {
|
||||
if (!d.isDirectory()) continue;
|
||||
@@ -543,9 +742,11 @@ async function mergeDir(src, installDir, force) {
|
||||
const entries = await fs.promises.readdir(srcSub, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (!e.isFile()) continue;
|
||||
await copyFile(path.join(srcSub, e.name), path.join(dstSub, e.name), force);
|
||||
const didCopy = await copyFile(path.join(srcSub, e.name), path.join(dstSub, e.name), force);
|
||||
if (didCopy) installed.push(`${d.name}/${e.name}`);
|
||||
}
|
||||
}
|
||||
return installed;
|
||||
}
|
||||
|
||||
function runInstallSh(repoRoot, installDir) {
|
||||
@@ -577,33 +778,154 @@ async function applyModule(moduleName, config, repoRoot, installDir, force) {
|
||||
const mod = config && config.modules && config.modules[moduleName];
|
||||
if (!mod) throw new Error(`Unknown module: ${moduleName}`);
|
||||
const ops = Array.isArray(mod.operations) ? mod.operations : [];
|
||||
const result = {
|
||||
module: moduleName,
|
||||
status: "success",
|
||||
operations: [],
|
||||
installed_at: new Date().toISOString(),
|
||||
};
|
||||
const mergeDirFiles = [];
|
||||
|
||||
for (const op of ops) {
|
||||
const type = op && op.type;
|
||||
if (type === "copy_file") {
|
||||
await copyFile(
|
||||
path.join(repoRoot, op.source),
|
||||
path.join(installDir, op.target),
|
||||
force
|
||||
);
|
||||
} else if (type === "copy_dir") {
|
||||
await copyDirRecursive(
|
||||
path.join(repoRoot, op.source),
|
||||
path.join(installDir, op.target),
|
||||
force
|
||||
);
|
||||
} else if (type === "merge_dir") {
|
||||
await mergeDir(path.join(repoRoot, op.source), installDir, force);
|
||||
} else if (type === "run_command") {
|
||||
const cmd = typeof op.command === "string" ? op.command.trim() : "";
|
||||
if (cmd !== "bash install.sh") {
|
||||
throw new Error(`Refusing run_command: ${cmd || "(empty)"}`);
|
||||
try {
|
||||
if (type === "copy_file") {
|
||||
await copyFile(path.join(repoRoot, op.source), path.join(installDir, op.target), force);
|
||||
} else if (type === "copy_dir") {
|
||||
await copyDirRecursive(path.join(repoRoot, op.source), path.join(installDir, op.target), force);
|
||||
} else if (type === "merge_dir") {
|
||||
mergeDirFiles.push(...(await mergeDir(path.join(repoRoot, op.source), installDir, force)));
|
||||
} else if (type === "run_command") {
|
||||
const cmd = typeof op.command === "string" ? op.command.trim() : "";
|
||||
if (cmd !== "bash install.sh") {
|
||||
throw new Error(`Refusing run_command: ${cmd || "(empty)"}`);
|
||||
}
|
||||
await runInstallSh(repoRoot, installDir);
|
||||
} else {
|
||||
throw new Error(`Unsupported operation type: ${type}`);
|
||||
}
|
||||
await runInstallSh(repoRoot, installDir);
|
||||
} else {
|
||||
throw new Error(`Unsupported operation type: ${type}`);
|
||||
result.operations.push({ type, status: "success" });
|
||||
} catch (err) {
|
||||
result.status = "failed";
|
||||
result.operations.push({
|
||||
type,
|
||||
status: "failed",
|
||||
error: err && err.message ? err.message : String(err),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (mergeDirFiles.length) result.merge_dir_files = mergeDirFiles;
|
||||
|
||||
try {
|
||||
if (mergeModuleHooks(moduleName, mod, installDir)) {
|
||||
result.has_hooks = true;
|
||||
result.operations.push({ type: "merge_hooks", status: "success" });
|
||||
}
|
||||
} catch (err) {
|
||||
result.operations.push({
|
||||
type: "merge_hooks",
|
||||
status: "failed",
|
||||
error: err && err.message ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function tryRemoveEmptyDir(p) {
|
||||
try {
|
||||
const entries = await fs.promises.readdir(p);
|
||||
if (!entries.length) await fs.promises.rmdir(p);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function removePathIfExists(p) {
|
||||
if (!fs.existsSync(p)) return;
|
||||
const st = await fs.promises.lstat(p);
|
||||
if (st.isDirectory()) {
|
||||
await rmTree(p);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fs.promises.unlink(p);
|
||||
} catch (err) {
|
||||
if (!err || err.code !== "ENOENT") throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallModule(moduleName, config, repoRoot, installDir, dryRun) {
|
||||
const mod = config && config.modules && config.modules[moduleName];
|
||||
if (!mod) throw new Error(`Unknown module: ${moduleName}`);
|
||||
const ops = Array.isArray(mod.operations) ? mod.operations : [];
|
||||
const status = loadInstalledStatus(installDir);
|
||||
const moduleStatus = (status.modules && status.modules[moduleName]) || {};
|
||||
const recordedMerge = Array.isArray(moduleStatus.merge_dir_files) ? moduleStatus.merge_dir_files : null;
|
||||
|
||||
for (const op of ops) {
|
||||
const type = op && op.type;
|
||||
if (type === "copy_file" || type === "copy_dir") {
|
||||
const target = typeof op.target === "string" ? op.target : "";
|
||||
if (!target) continue;
|
||||
const p = path.join(installDir, target);
|
||||
if (dryRun) process.stdout.write(`- remove ${p}\n`);
|
||||
else await removePathIfExists(p);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type !== "merge_dir") continue;
|
||||
const source = typeof op.source === "string" ? op.source : "";
|
||||
if (!source) continue;
|
||||
|
||||
if (recordedMerge && recordedMerge.length) {
|
||||
for (const rel of recordedMerge) {
|
||||
const parts = String(rel).split("/").filter(Boolean);
|
||||
if (parts.includes("..")) continue;
|
||||
const p = path.join(installDir, ...parts);
|
||||
if (dryRun) process.stdout.write(`- remove ${p}\n`);
|
||||
else {
|
||||
await removePathIfExists(p);
|
||||
await tryRemoveEmptyDir(path.dirname(p));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const srcDir = path.join(repoRoot, source);
|
||||
if (!(await dirExists(srcDir))) continue;
|
||||
const subdirs = await fs.promises.readdir(srcDir, { withFileTypes: true });
|
||||
for (const d of subdirs) {
|
||||
if (!d.isDirectory()) continue;
|
||||
const srcSub = path.join(srcDir, d.name);
|
||||
const entries = await fs.promises.readdir(srcSub, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (!e.isFile()) continue;
|
||||
const dst = path.join(installDir, d.name, e.name);
|
||||
if (!fs.existsSync(dst)) continue;
|
||||
try {
|
||||
const [srcBuf, dstBuf] = await Promise.all([
|
||||
fs.promises.readFile(path.join(srcSub, e.name)),
|
||||
fs.promises.readFile(dst),
|
||||
]);
|
||||
if (Buffer.compare(srcBuf, dstBuf) !== 0) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (dryRun) process.stdout.write(`- remove ${dst}\n`);
|
||||
else {
|
||||
await removePathIfExists(dst);
|
||||
await tryRemoveEmptyDir(path.dirname(dst));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) return;
|
||||
unmergeHooksFromSettings(moduleName, installDir);
|
||||
deleteModuleStatus(installDir, moduleName);
|
||||
}
|
||||
|
||||
async function installSelected(picks, tag, config, installDir, force, dryRun) {
|
||||
@@ -647,7 +969,8 @@ async function installSelected(picks, tag, config, installDir, force, dryRun) {
|
||||
}
|
||||
if (p.kind === "module") {
|
||||
process.stdout.write(`Installing module: ${p.moduleName}\n`);
|
||||
await applyModule(p.moduleName, config, repoRoot, installDir, force);
|
||||
const r = await applyModule(p.moduleName, config, repoRoot, installDir, force);
|
||||
upsertModuleStatus(installDir, r);
|
||||
continue;
|
||||
}
|
||||
if (p.kind === "skill") {
|
||||
@@ -672,8 +995,77 @@ async function main() {
|
||||
}
|
||||
|
||||
const installDir = expandHome(args.installDir);
|
||||
if (args.command !== "install" && args.command !== "uninstall") {
|
||||
throw new Error(`Unknown command: ${args.command}`);
|
||||
}
|
||||
if (args.list && args.update) throw new Error("Cannot combine --list and --update");
|
||||
|
||||
if (args.command === "uninstall") {
|
||||
const config = readLocalConfig();
|
||||
const repoRoot = repoRootFromHere();
|
||||
const fromStatus = readInstalledModuleNamesFromStatus(installDir);
|
||||
const installed = fromStatus || (await detectInstalledModuleNames(config, repoRoot, installDir));
|
||||
const installedSet = new Set(installed);
|
||||
|
||||
let toRemove = [];
|
||||
if (args.module) {
|
||||
const v = String(args.module).trim();
|
||||
if (v.toLowerCase() === "all") {
|
||||
toRemove = installed;
|
||||
} else {
|
||||
toRemove = v
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
} else {
|
||||
const modules = (config && config.modules) || {};
|
||||
const items = [];
|
||||
for (const [name, mod] of Object.entries(modules)) {
|
||||
if (!installedSet.has(name)) continue;
|
||||
const desc = mod && typeof mod.description === "string" ? mod.description : "";
|
||||
items.push({
|
||||
id: `module:${name}`,
|
||||
label: `module:${name}${desc ? ` - ${desc}` : ""}`,
|
||||
kind: "module",
|
||||
moduleName: name,
|
||||
});
|
||||
}
|
||||
if (!items.length) {
|
||||
process.stdout.write(`No installed modules found in ${installDir}.\n`);
|
||||
return;
|
||||
}
|
||||
const picks = await promptMultiSelect(items, "myclaude uninstall");
|
||||
toRemove = picks.map((p) => p.moduleName);
|
||||
}
|
||||
|
||||
toRemove = toRemove.filter((m) => installedSet.has(m));
|
||||
if (!toRemove.length) {
|
||||
process.stdout.write("Nothing selected.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.yes && !args.dryRun) {
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new Error("No TTY. Use -y/--yes to skip confirmation.");
|
||||
}
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise((resolve) => rl.question("Confirm uninstall? (y/N): ", resolve));
|
||||
rl.close();
|
||||
if (String(answer).trim().toLowerCase() !== "y") {
|
||||
process.stdout.write("Cancelled.\n");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of toRemove) {
|
||||
process.stdout.write(`Uninstalling module: ${name}\n`);
|
||||
await uninstallModule(name, config, repoRoot, installDir, args.dryRun);
|
||||
}
|
||||
process.stdout.write("Done.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
let tag = args.tag;
|
||||
if (!tag) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user