UNPKG

fsmap-cli

Version:

📁 CLI to visualize and export the file structure of any folder or GitHub repo

368 lines (356 loc) 13 kB
#!/usr/bin/env node 'use strict'; var path = require('path'); var fs3 = require('fs'); var simpleGit = require('simple-git'); var os = require('os'); var commander = require('commander'); var cliSpinner = require('cli-spinner'); function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; } var path__default = /*#__PURE__*/_interopDefault(path); var fs3__default = /*#__PURE__*/_interopDefault(fs3); var os__default = /*#__PURE__*/_interopDefault(os); var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) { if (typeof require !== "undefined") return require.apply(this, arguments); throw Error('Dynamic require of "' + x + '" is not supported'); }); function shouldIncludeEntry(name, options) { const { include = [], exclude = [], showHidden = false } = options; if (!showHidden && name.startsWith(".")) return false; if (include.length > 0) { return include.some((pattern) => name === pattern || name.startsWith(pattern)); } if (exclude.length > 0) { return !exclude.some((pattern) => name === pattern || name.startsWith(pattern)); } return true; } async function buildFsTree(dir, options = {}, gitStatusMap = {}, currentDepth = 0, statsInfo = { files: 0, folders: 0 }, transform) { const stats2 = fs3__default.default.statSync(dir); const name = path__default.default.basename(dir); const resolvedPath = path__default.default.resolve(dir); if (!shouldIncludeEntry(name, options)) { return null; } let entry = { name, path: dir, isDirectory: stats2.isDirectory(), size: options.showSize ? stats2.size : void 0, modifiedAt: options.showDate ? stats2.mtime : void 0, gitStatus: gitStatusMap[resolvedPath] || "clean" }; if (stats2.isDirectory() && currentDepth < (options.depth ?? Infinity)) { statsInfo.folders++; const childNames = fs3__default.default.readdirSync(dir); const children = []; for (const child of childNames) { const childEntry = await buildFsTree( path__default.default.join(dir, child), options, gitStatusMap, currentDepth + 1, statsInfo, transform ); if (childEntry) { children.push(childEntry); } } entry.children = children; if (options.showSize) { entry.size = children.reduce((sum, c) => sum + (c.size || 0), 0); } } else { statsInfo.files++; } return transform ? transform(entry) : entry; } var CONFIG_FILES = [ "fsmap.config.json", ".fsmaprc" ]; function loadFsMapConfig() { for (const file of CONFIG_FILES) { const filePath = path__default.default.resolve(process.cwd(), file); if (fs3__default.default.existsSync(filePath)) { try { const content = fs3__default.default.readFileSync(filePath, "utf-8"); return JSON.parse(content); } catch (e) { console.warn(`\u26A0\uFE0F Failed to parse config file: ${file}`); } } } return {}; } async function getGitStatusMap(rootDir) { try { const git2 = simpleGit.simpleGit(rootDir); const status = await git2.status(); const ignored = await git2.checkIgnore("*"); const map = {}; status.modified.forEach((f) => map[path__default.default.resolve(rootDir, f)] = "modified"); status.not_added.forEach((f) => map[path__default.default.resolve(rootDir, f)] = "untracked"); (ignored || []).forEach((f) => map[path__default.default.resolve(rootDir, f)] = "ignored"); return map; } catch (err) { if (err.message?.includes("not a git repository")) { console.warn("\u26A0\uFE0F Not a Git repository. Skipping Git status."); return {}; } throw err; } } var git = simpleGit.simpleGit(); function cleanAllRepos(baseDir) { if (fs3__default.default.existsSync(baseDir)) { fs3__default.default.rmSync(baseDir, { recursive: true, force: true }); console.log(` Cleared previous cached repo from: ${baseDir}`); } } async function cloneRepoToTemp(userRepo) { const [user, repo] = userRepo.split("/"); const baseDir = path__default.default.join(os__default.default.tmpdir(), ".fsmap"); const repoDir = path__default.default.join(baseDir, `${user}-${repo}`); const otherReposExist = fs3__default.default.existsSync(baseDir) && fs3__default.default.readdirSync(baseDir).some((entry) => entry !== `${user}-${repo}`); if (otherReposExist) { cleanAllRepos(baseDir); } if (fs3__default.default.existsSync(repoDir)) { console.log(` Reusing cached repo: ${repoDir}`); return repoDir; } console.log(` Cloning fresh repo: https://github.com/${user}/${repo}`); fs3__default.default.mkdirSync(baseDir, { recursive: true }); await git.clone(`https://github.com/${user}/${repo}.git`, repoDir, ["--depth", "1"]); console.log(` Repo cloned to: ${repoDir}`); return repoDir; } // package.json var package_default = { version: "1.0.1"}; function loadPlugin() { const configPaths = [ path__default.default.resolve("fsmap.config.js"), path__default.default.resolve("dist/fsmap.config.js") // use built JS version if exists ]; for (const configPath of configPaths) { if (fs3__default.default.existsSync(configPath)) { try { const plugin2 = __require(configPath); return plugin2; } catch (err) { console.warn("\u274C Failed to load plugin:", configPath); console.error(err); return {}; } } } return {}; } // src/utils/constants/constants.ts var program = new commander.Command(); var plugin = loadPlugin(); var stats = { files: 0, folders: 0 }; // src/utils/lib/git/format-git-status.ts function formatGitStatus(status) { switch (status) { case "modified": return "\u{1F7E1} modified"; case "untracked": return "\u{1F7E2} untracked"; case "ignored": return "\u{1F535} ignored"; default: return ""; } } // src/utils/helper/renderer.ts function renderTree(entry, prefix = "") { const connector = prefix === "" ? "" : prefix.slice(0, -4) + "\u2514\u2500\u2500 "; let line = connector + entry.name; if (!entry.isDirectory) { if (entry.size !== void 0) { line += ` (${formatSize(entry.size)})`; } if (entry.modifiedAt) { const date = entry.modifiedAt.toISOString().split("T")[0]; line += ` [${date}]`; } if (entry.gitStatus && entry.gitStatus !== "clean") { line += ` [${formatGitStatus(entry.gitStatus)}]`; } } console.log(line); if (entry.children && entry.children.length > 0) { const lastIndex = entry.children.length - 1; entry.children.forEach((child, i) => { const isLast = i === lastIndex; const newPrefix = prefix + (isLast ? " " : "\u2502 "); renderTree(child, newPrefix); }); } } function formatSize(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } // src/utils/export/exporter.ts function exportAsJSON(tree) { console.log("Exporting as JSON"); return JSON.stringify(tree, null, 2); } function exportAsMarkdown(tree, depth = 0) { if (!tree) return ""; const indent = " ".repeat(depth); const sizeStr = tree.size ? ` (${formatSize(tree.size)})` : ""; const dateStr = tree.modifiedAt ? ` [${tree.modifiedAt.toISOString().split("T")[0]}]` : ""; let line = `${indent}- ${tree.name}${sizeStr}${dateStr}`; const children = tree.children?.filter(Boolean).map( (child) => exportAsMarkdown(child, depth + 1) ); return [line, ...children || []].join("\n"); } // src/utils/helper/format.ts function format({ mergedOptions, tree, stats: stats2 }) { let outputText; switch (mergedOptions.outputFormat) { case "json": console.log("\n"); outputText = exportAsJSON(tree); break; case "markdown": console.log("\n"); outputText = exportAsMarkdown(tree); break; case "text": default: console.log("\n"); renderTree(tree); console.log(` \u{1F4E6} Scanned ${stats2.files} files and ${stats2.folders} folders.`); return ""; } return outputText; } var spinner = new cliSpinner.Spinner({ text: " %s", stream: process.stdout }); spinner.setSpinnerString("\u280B\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F"); function startSpinner() { spinner.start(); } function stopSpinner(successMsg) { spinner.stop(true); } function failSpinner(failureMsg) { spinner.stop(true); if (failureMsg) console.error(failureMsg); } // src/utils/helper/sleep.ts function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } // src/index.ts program.name("fsmap").description("\u{1F50D} Visualize the folder structure of any directory.\n\nNote: use -- before arguments when using npm run.\nExample: npm run start -- . -d 2").version(package_default.version, "-v, --version", "Output the version number").argument("[path]", "The directory to scan", ".").option("-d, --depth <number>", "Limit recursion depth", parseInt).option("-e, --exclude <names>", "Comma-separated list of files/folders to exclude (e.g. node_modules,.git)", (value) => value.split(",")).option("--show-size", "Display file sizes").option("--show-date", "Display last modified dates").option("--show-hidden", "Include hidden files and folders").option("-o, --output-format <format>", "Choose output format: text (default), json, markdown, html").option("--output-file <path>", "Write output to a file instead of console").option("--show-git", "Display Git status for each file").option("--dry-run", "Preview without writing output").option("--repo <user/repo>", "Clone and scan a GitHub repo remotely").action((inputPath, options) => { const run = async () => { let tree; let gitMap = {}; const resolvedPath = path__default.default.resolve(inputPath); const defaultOptions = { exclude: ["node_modules", ".git", ".vscode", "dist", "build", "coverage", "tmp", "temp"], depth: 2, outputFormat: "text", showHidden: false }; const configFileOptions = loadFsMapConfig(); const cliOptions = options; if (options.include && options.exclude) { console.error("You cannot use both --include and --exclude at the same time."); process.exit(1); } const mergedOptions = { ...defaultOptions, ...configFileOptions, ...cliOptions }; if (mergedOptions.include?.length) { mergedOptions.exclude = []; } if (mergedOptions.exclude?.length) { mergedOptions.include = []; } try { if (mergedOptions.repo) { startSpinner(); const tmpDir = await cloneRepoToTemp(mergedOptions.repo); const result2 = await buildFsTree(tmpDir, mergedOptions, {}, 0, stats, plugin.transform); if (result2 === null) { failSpinner("\u274C No valid file structure found."); return; } tree = result2; await sleep(1e3); stopSpinner(); if (mergedOptions.dryRun) { console.log(); console.log(`\u2705 Dry run complete. Found ${stats.files} files and ${stats.folders} folders.`); return; } let outputText2 = format({ mergedOptions, tree, stats }); if (!outputText2) { return; } if (mergedOptions.outputFile) { fs3__default.default.writeFileSync(mergedOptions.outputFile, outputText2, "utf-8"); console.log(`\u2705 Output written to ${path__default.default.resolve(mergedOptions.outputFile)}`); } else { console.log(outputText2); } console.log(` \u{1F4E6} Scanned ${stats.files} files and ${stats.folders} folders.`); return; } if (mergedOptions.showGit) { gitMap = await getGitStatusMap(resolvedPath); } startSpinner(); const result = await buildFsTree(resolvedPath, mergedOptions, gitMap, 0, stats, plugin.transform); if (result === null) { failSpinner("\u274C No valid file structure found."); return; } tree = result; await sleep(1e3); stopSpinner(); if (mergedOptions.dryRun) { return; } let outputText = format({ mergedOptions, tree, stats }); if (!outputText) { return; } if (mergedOptions.outputFile) { fs3__default.default.writeFileSync(mergedOptions.outputFile, outputText, "utf-8"); ; } else { console.log(outputText); } console.log(` \u{1F4E6} Scanned ${stats.files} files and ${stats.folders} folders.`); } catch (error) { failSpinner("\u274C An error occurred while scanning the directory."); console.error("\n", error instanceof Error ? error.message : error); } }; run(); }); program.parse(); //# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map