fsmap-cli
Version:
📁 CLI to visualize and export the file structure of any folder or GitHub repo
360 lines (351 loc) • 12.2 kB
JavaScript
import path from 'path';
import fs3 from 'fs';
import { simpleGit } from 'simple-git';
import os from 'os';
import { Command } from 'commander';
import { Spinner } from 'cli-spinner';
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.statSync(dir);
const name = path.basename(dir);
const resolvedPath = path.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.readdirSync(dir);
const children = [];
for (const child of childNames) {
const childEntry = await buildFsTree(
path.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.resolve(process.cwd(), file);
if (fs3.existsSync(filePath)) {
try {
const content = fs3.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(rootDir);
const status = await git2.status();
const ignored = await git2.checkIgnore("*");
const map = {};
status.modified.forEach((f) => map[path.resolve(rootDir, f)] = "modified");
status.not_added.forEach((f) => map[path.resolve(rootDir, f)] = "untracked");
(ignored || []).forEach((f) => map[path.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();
function cleanAllRepos(baseDir) {
if (fs3.existsSync(baseDir)) {
fs3.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.join(os.tmpdir(), ".fsmap");
const repoDir = path.join(baseDir, `${user}-${repo}`);
const otherReposExist = fs3.existsSync(baseDir) && fs3.readdirSync(baseDir).some((entry) => entry !== `${user}-${repo}`);
if (otherReposExist) {
cleanAllRepos(baseDir);
}
if (fs3.existsSync(repoDir)) {
console.log(` Reusing cached repo: ${repoDir}`);
return repoDir;
}
console.log(` Cloning fresh repo: https://github.com/${user}/${repo}`);
fs3.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.resolve("fsmap.config.js"),
path.resolve("dist/fsmap.config.js")
// use built JS version if exists
];
for (const configPath of configPaths) {
if (fs3.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 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 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.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.writeFileSync(mergedOptions.outputFile, outputText2, "utf-8");
console.log(`\u2705 Output written to ${path.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.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.mjs.map
//# sourceMappingURL=index.mjs.map