diff --git a/.fln.json b/.fln.json new file mode 100644 index 0000000..76c672f --- /dev/null +++ b/.fln.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://fln.nesvet.dev/schema", + "output": "output.md", + "excludePatterns": [], + "includePatterns": [], + "gitignore": true, + "includeHidden": false, + "maxFileSize": "10mb", + "maxTotalSize": "0", + "includeTree": true, + "includeContents": true, + "format": "md", + "followSymlinks": false, + "overwrite": false +} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f47e16a..d3c1dc8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -21,6 +21,8 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.10" - name: Cache dependencies uses: actions/cache@v5 diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 6e40201..95ed812 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -22,6 +22,8 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.10" - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/release-binary.yaml b/.github/workflows/release-binary.yaml index f0ec88e..ac17dc7 100644 --- a/.github/workflows/release-binary.yaml +++ b/.github/workflows/release-binary.yaml @@ -60,7 +60,7 @@ jobs: architecture: arm64 archiveName: fln-windows-arm64.zip packageType: zip - targetName: bun-windows-x64 + targetName: bun-windows-arm64 outputName: fln.exe steps: - name: Checkout @@ -68,6 +68,8 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.10" - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.gitignore b/.gitignore index 78afc6a..b85c2e3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ src/version.ts node_modules/ dist/ fln +fln-*.md +output.md +output-*.md +docs/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f0d7da8..197a01e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2026-02-26 + +### Removed (Breaking) + +- Public API reduced to `fln`, `FlnOptions`, `FlnResult`, `LogLevel`, `ProgressCallback`. Removed exports: `scanTree`, `writeOutput`, `renderTree`, `IgnoreMatcher`, `parseByteSize`, `formatByteSize`, `formatTokenCount`, `collectExtensionStats`, `collectProcessedFiles`, core types (`FileNode`, `ScanResult`, etc.), `VERSION`. + +### Added + +- New option names: `input` (replaces `rootDirectory`), `output` (replaces `outputFile`), `maxFileSize`, `maxTotalSize`, `date`, `gitignore`, `ansi` (API, config, CLI) +- JSON output now includes `input` field (in addition to `rootDirectory` for backward compatibility) +- `bannerFile` — path to file whose contents are prepended to output (file excluded from tree) +- `footerFile` — path to file whose contents are appended to output (file excluded from tree) +- New CLI features: `fln init`, `--stdout`, `--ext`, `--since` +- New CLI flags: `--date`, `--banner-file`, `--footer-file` +- Config schema and `fln init` template (`$schema`) for `.fln.json` + +### Changed + +- JSON output `options` object now uses `maxFileSize`, `maxTotalSize`, `gitignore` (old names deprecated) +- Output now supports stdout target (`-`) and auto-adds extension (`.md`/`.json`) when missing +- Banner/footer content now combines inline text with file-based content +- Project metadata detection now also supports `pom.xml` + +### Fixed + +- `excludePatterns` and `includePatterns` now normalize leading `./` and safely ignore paths resolving outside input (for example, `../...`) +- Windows ARM64 binary release target corrected in release workflow + +### Deprecated + +- `rootDirectory` — use `input` instead. Will be removed in 2.0. +- `outputFile` — use `output` instead (in API options and `.fln.json`). Will be removed in 2.0. +- `maximumFileSizeBytes` — use `maxFileSize` instead. Will be removed in 2.0. +- `maximumTotalSizeBytes` — use `maxTotalSize` instead. Will be removed in 2.0. +- `generatedDate` — use `date` instead. Will be removed in 2.0. +- `useGitignore` — use `gitignore` instead. Will be removed in 2.0. +- `useAnsi` — use `ansi` instead. Will be removed in 2.0. +- CLI `--generated-date` — use `--date` instead. Will be removed in 2.0. +- JSON output field `rootDirectory` — use `input` instead. Will be removed in 2.0. + ## [1.1.3] - 2026-02-12 ### Fixed @@ -75,7 +115,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cross-platform shell installers with SHA256 verification (macOS, Linux, Windows) - Comprehensive test suite -[Unreleased]: https://github.com/nesvet/fln/compare/1.1.3...HEAD +[Unreleased]: https://github.com/nesvet/fln/compare/1.2.0...HEAD +[1.2.0]: https://github.com/nesvet/fln/compare/1.1.3...1.2.0 [1.1.3]: https://github.com/nesvet/fln/compare/1.1.2...1.1.3 [1.1.2]: https://github.com/nesvet/fln/compare/1.1.1...1.1.2 [1.1.1]: https://github.com/nesvet/fln/compare/1.0.0...1.1.1 diff --git a/README.md b/README.md index 8717cd6..1e936a4 100644 --- a/README.md +++ b/README.md @@ -2,124 +2,190 @@ [![CI](https://github.com/nesvet/fln/actions/workflows/ci.yaml/badge.svg)](https://github.com/nesvet/fln/actions/workflows/ci.yaml) [![npm](https://img.shields.io/npm/v/fln)](https://www.npmjs.com/package/fln) +[![npm downloads](https://img.shields.io/npm/dm/fln)](https://www.npmjs.com/package/fln) [![license](https://img.shields.io/npm/l/fln)](LICENSE) -**Your entire codebase → One AI-ready file.** +**Your entire codebase → one AI-ready file.** Stop wrestling with file pickers and attachment limits — feed your whole project to any LLM in one shot. ```bash -fln . -``` - -Or run instantly: - -```bash -npx fln . -o codebase.md +npx fln ``` Works with **Claude**, **ChatGPT**, **Gemini**, **Grok**, **Cursor**, **Copilot**, and *any* AI tool. -**`fln`** (short for *flatten*) is **language-agnostic** by design: TypeScript, Python, Java, Go, Rust, Bash, SQL, mixed monorepos — it treats everything as plain text, detects project metadata from common manifests (`package.json`, `pyproject.toml`, `pom.xml`, `go.mod`, `Cargo.toml`, `CMakeLists.txt`, `vcpkg.json`), respects `.gitignore`, and skips binaries by default. +**`fln`** (short for *flatten*) is language-agnostic by design: TypeScript, Python, Rust, Go, Java, mixed monorepos — it treats everything as plain text, auto-detects project metadata from ecosystem manifests, respects `.gitignore`, and skips binaries automatically. + +--- ## Why fln exists -If you use LLMs for real projects, you’ve hit these limits: +If you use LLMs for real projects, you've hit these limits: -- **Context windows** — large projects don’t fit. -- **Upload friction** — selecting dozens of files for every session. -- **Partial understanding** — AI sees fragments, not the architecture. -- **Manual prep** — repeating the same setup context again and again. +- **Context windows** — large projects don't fit +- **Upload friction** — selecting dozens of files for every session +- **Partial understanding** — AI sees fragments, not the architecture +- **Manual prep** — repeating the same setup context again and again **`fln` removes that overhead.** It turns your project into a single, structured snapshot that LLMs can actually reason about. +--- + ## What fln enables **→ Full-context refactoring** -Ask architectural questions that are impossible file-by-file: -> “Where is the real coupling here?” -> “What should be split into modules?” +Ask architectural questions impossible file-by-file: +> "Where is the real coupling here?" +> "What should be split into modules?" **→ Instant onboarding** -One markdown file instead of “start by opening these 12 folders”. Perfect for reading code on a tablet or onboarding new developers without an IDE. +One Markdown file instead of "start by opening these 12 folders." Perfect for reading code on a tablet or onboarding new developers without an IDE. **→ Project-level code reviews** -Let AI detect patterns, inconsistencies, and risks across the entire codebase. +Detect patterns, inconsistencies, and risks across the entire codebase at once. -**→ Auditable Snapshots** -Create a single, clean artifact of your codebase state for security reviews, compliance audits, or legal records without granting full repo access. +**→ Auditable snapshots** +A single, clean artifact of your codebase state for security reviews, compliance audits, or legal records — without granting full repo access. -**→ Dataset Preparation** +**→ Dataset preparation** Generate clean, formatted data for RAG pipelines and fine-tuning custom models. **→ LLM-friendly diffs** -Flatten → commit → flatten again. See how the *whole project* changed structurally. +Flatten → commit → flatten again. See how the whole project changed structurally. + +--- + +## Instant clipboard + +```bash +fln --stdout | pbcopy # macOS +fln --stdout | wl-copy # Linux (Wayland) +fln --stdout | xclip -selection clipboard # Linux (X11) +``` + +One command — your entire codebase is in the clipboard. Open any AI chat, paste, ask. + +--- + +## Pipe directly to a CLI LLM tool -## Compatible with your AI workflow +```bash +fln --stdout | llm "What are the biggest architecture issues here?" +fln --stdout | aichat "Write a CHANGELOG entry for the latest changes" +fln --stdout | sgpt "Suggest a refactoring plan for this codebase" +``` + +--- + +## Only changed files — perfect for code review + +```bash +# Everything changed since the last commit +fln --since HEAD~1 + +# Everything that differs from main +fln --since main -- **Claude** — ideal for large architectural prompts (200K+ tokens). -- **Gemini** — push massive codebases into 1M token windows. -- **ChatGPT** — single-shot analysis without attachments. -- **Cursor / Windsurf** — reference the full project in prompts. -- **GitHub Copilot** — better context → better suggestions. -- **Local LLMs** — datasets for RAG and fine-tuning. +# Only changed TypeScript files (intersection, not union) +fln --since main --ext ts,tsx +``` + +`--since` calls `git diff --name-only` under the hood. Zero config. + +--- + +## Only the file types you care about + +```bash +fln --ext ts,tsx # TypeScript only — dramatically reduces token count +fln --ext py # Python only +fln --ext rs # Rust only +fln --ext go # Go only +``` + +--- + +## Embed a custom prompt right into the snapshot + +Put your system prompt in a file — it will be prepended to the output and excluded from the directory tree: + +```bash +fln --banner-file .prompt.md +``` + +```markdown + +You are a senior engineer reviewing a production codebase. +Identify architecture issues, suggest improvements, point out any bugs. +``` + +Or inline: + +```bash +fln --banner "Review this codebase for security vulnerabilities." +``` + +Same for footers: `--footer-file` / `--footer`. + +--- ## Built for real projects -- ⚡ **Fast parallel scanning** — thousands of files in seconds. -- 🎯 **Smart filtering** — respects `.gitignore`, excludes binaries, configurable size limits. -- 📁 **Intentional file order** — entry points and configs first, not alphabetical noise. -- 🔄 **Auto-detection** — skips files previously generated by `fln`. -- 📐 **Deterministic output** — same input → same snapshot. -- 🧠 **Project metadata detection** — name & version from ecosystem-native manifests. -- 🛠️ **Developer-friendly** — `Markdown` for humans, `JSON` for tooling, `--dry-run` mode for safety. -- 🔒 **No surprises** — runs locally, no data leaves your machine. +- ⚡ **Fast parallel scanning** — thousands of files in seconds +- 🎯 **Smart filtering** — respects `.gitignore`, skips binaries and lock files, configurable size limits +- 📁 **Intentional file order** — `README`, entry points, and configs first; `LICENSE`, `CHANGELOG` last. LLMs see the most important context first +- 📊 **Token count upfront** — every run reports estimated tokens so you know what you're sending before you hit send +- 🔍 **Extension breakdown** — `--verbose` shows token distribution by file type, so you know exactly what's eating your context window +- 🔄 **Self-aware** — skips files previously generated by `fln`, never recurses into its own output +- 🛡️ **Backtick-safe** — if a file contains ` ``` `, fln automatically uses longer fences so the Markdown never breaks +- 📐 **Deterministic output** — same input → same snapshot +- 🧠 **Project metadata detection** — output named `my-app-1.2.0.md` automatically from `package.json`, `Cargo.toml`, `pyproject.toml`, `pom.xml`, `go.mod`, `vcpkg.json`, `CMakeLists.txt` +- 🛠️ **Two output formats** — `md` for humans, `json` for tooling +- 🔒 **Fully local** — zero telemetry, zero network calls, no data leaves your machine -Zero dependencies on external services. Zero tracking. Just a tool that does its job. +--- ## Install -##### npm +##### npm / Bun ```bash +npx fln # run once without installing +bunx fln + npm install -g fln +bun add -g fln ``` -##### Linux & macOS ([view install script](./install.sh)) +##### macOS / Linux — native binary, no Node.js required ```bash curl -fsSL https://fln.nesvet.dev/install | sh ``` -##### Windows ([view install script](./install.ps1)) -```bash +##### Windows — native binary, no Node.js required +```powershell powershell -c "irm fln.nesvet.dev/install.ps1 | iex" ``` -##### Or just run without installing -```bash -npx fln . -o codebase.md -``` -
More installation options -### One-line installer options (macOS/Linux) - -Pin a version or custom install directory: +**Pin a specific version or install to a custom directory (macOS/Linux):** ```bash -curl -fsSL "https://fln.nesvet.dev/install" | FLN_VERSION="" INSTALL_DIR="$HOME/.local/bin" sh +curl -fsSL https://fln.nesvet.dev/install | FLN_VERSION="" INSTALL_DIR="$HOME/.local/bin" sh ``` -### One-line installer options (Windows PowerShell) +**Windows PowerShell:** ```powershell $env:FLN_VERSION = "" -$env:INSTALL_DIR = "$env:LOCALAPPDATA\\fln\\bin" +$env:INSTALL_DIR = "$env:LOCALAPPDATA\fln\bin" powershell -c "irm fln.nesvet.dev/install.ps1 | iex" ``` -### Manual download (GitHub Releases) +**Manual download from GitHub Releases:** ```bash curl -L "https://github.com/nesvet/fln/releases/latest/download/fln-macos-x64.tar.gz" | tar -xz -C /usr/local/bin @@ -128,74 +194,160 @@ chmod +x /usr/local/bin/fln
+--- + ## Usage ```bash -fln [directory] [options] +fln [directory] [...flags] +fln init [--overwrite] ``` -Examples: - ```bash -# Flatten entire project -fln . +# Flatten the current directory → my-app-1.2.0.md +fln + +# Specify input and output +fln . -o context.md + +# Scan src/, save output to the project root +fln src -o . + +# Source files only — no tests, no fixtures +fln -e "*.test.ts" -e "*.spec.ts" -e "fixtures/" + +# Include all source files but exclude markdown — except README +fln -e "*.md" -e '!README.md' -# Exclude tests and fixtures -fln src -e "**/*.test.ts" -e "fixtures/" +# TypeScript source only +fln --ext ts,tsx -# Force include a file (even if ignored) -fln . -i "dist/output.md" +# Changed files since last commit +fln --since HEAD~1 -# Generate JSON for tooling -fln . --no-contents --format json +# Tree only — no file contents +fln --no-contents -# Preview without writing -fln . --dry-run +# Force-include a file that's in .gitignore +fln -i "src/generated/schema.ts" -# Overwrite output file instead of creating codebase-1.md -fln . -o codebase.md -w +# Preview what would be included, with per-extension breakdown +fln --dry-run --verbose + +# Overwrite instead of creating codebase-1.md +fln -o codebase.md -w + +# JSON output for programmatic use +fln --format json -o snapshot.json ```
-All CLI options - -- `-o, --output ` Output file or directory -- `-w, --overwrite` Overwrite output file instead of adding numeric suffix -- `-e, --exclude ` Exclude patterns (repeatable) -- `-i, --include ` Force include patterns -- `--include-hidden` Include hidden files and directories -- `--no-gitignore` Ignore `.gitignore` -- `--max-size ` Max file size (`10mb`, `512kb`) -- `--max-total-size ` Max total included size -- `--no-contents` Exclude file contents -- `--no-tree` Exclude directory tree -- `--format ` Output format -- `--dry-run` Scan without writing output -- `--follow-symlinks` Follow symlinks -- `--no-ansi` Disable ANSI colors -- `--no-sponsor-message` Hide support message (also: `FLN_NO_SPONSOR=1`) -- `--generated-date ` Use this date in the “Generated” header (format: `YYYY-MM-DD HH:mm`) -- `--banner ` Add text at the beginning -- `--footer ` Add text at the end of the output -- `-q, --quiet` Minimal output -- `-V, --verbose` Verbose output with breakdown -- `--debug` Debug output with file list -- `-v, --version` Show version -- `-h, --help` Show help +All CLI flags + +**Output** + +| Flag | Description | +|---|---| +| `-o, --output ` | Output file or directory. Adds `.md`/`.json` if no extension given. Default: `-.md` | +| `-w, --overwrite` | Overwrite instead of adding numeric suffix | +| `--stdout` | Write to stdout instead of file (implies `--quiet`) | +| `--format ` | Output format (default: `md`) | +| `--dry-run` | Scan and report without writing anything | + +**Filtering** + +| Flag | Description | +|---|---| +| `-e, --exclude ` | Exclude pattern — repeatable | +| `-i, --include ` | Whitelist mode — only matching files are included, repeatable | +| `--ext ` | Include only these extensions, e.g. `ts,tsx,js` | +| `--since ` | Only files changed since git ref, e.g. `HEAD~1`, `main` | +| `--include-hidden` | Include hidden files and directories | +| `--no-gitignore` | Ignore `.gitignore` rules | +| `--max-size ` | Max individual file size, e.g. `10mb`, `512kb` | +| `--max-total-size ` | Max total size of all included files | +| `--follow-symlinks` | Follow symlinks while scanning | + +**Content** + +| Flag | Description | +|---|---| +| `--no-contents` | Exclude file contents (tree only) | +| `--no-tree` | Exclude directory tree | +| `--banner ` | Prepend text after the header | +| `--banner-file ` | Prepend file contents — relative to input, excluded from tree | +| `--footer ` | Append text at the end | +| `--footer-file ` | Append file contents — relative to input, excluded from tree | +| `--date ` | Fix the Generated date (useful for reproducible output) | + +**Logging & other** + +| Flag | Description | +|---|---| +| `-q, --quiet` | Minimal output | +| `-V, --verbose` | Verbose output with per-extension token breakdown | +| `--debug` | Debug output with full file list | +| `--no-ansi` | Disable colors | +| `--no-sponsor-message` | Hide support message (also: `FLN_NO_SPONSOR=1`) | +| `-v, --version` | Show version | +| `-h, --help` | Show help | + +> **Note:** Quote glob patterns to prevent shell expansion — `"*.test.ts"`, not `*.test.ts`. +> To un-exclude a specific file, use negation in `--exclude`: `-e "*.md" -e '!README.md'`.
-## CI/CD & Automation +--- + +## Config file + +```bash +fln init +``` -Integrate `fln` into your pipeline to keep your codebase “AI-ready” automatically. +Generates `.fln.json` with full IntelliSense support in VS Code, WebStorm, and any editor with JSON Schema — autocomplete and validation out of the box, no extensions needed. -### GitHub Actions: Auto-generate Snapshots +
+.fln.json reference -Generate a fresh `codebase.md` artifact on every push. Download it anytime to chat with LLMs about the *exact* state of your main branch or a specific PR without manual scanning. +```json +{ + "$schema": "https://fln.nesvet.dev/schema", + "output": "snapshot.md", + "overwrite": false, + "excludePatterns": [ "dist/", "**/*.snap" ], + "includePatterns": [], + "includeHidden": false, + "gitignore": true, + "maxFileSize": "10mb", + "maxTotalSize": "0", + "includeTree": true, + "includeContents": true, + "format": "md", + "followSymlinks": false, + "logLevel": "normal", + "date": "2026-02-20 12:00", + "banner": "You are reviewing a production codebase.", + "bannerFile": ".prompt.md", + "footer": "End of snapshot.", + "footerFile": "docs/footer.md" +} +``` + +**Pattern format:** gitignore-style globs relative to the input directory. Leading `./` is normalized. Use `!` for negation (`*.log` + `!important.log`). Trailing slash `src/` matches directories only. CLI flags always override the config file. -Create `.github/workflows/codebase-snapshot.yaml`: +
+ +--- + +## CI/CD & Automation + +### GitHub Actions — auto-generate snapshots + +Fresh `codebase.md` on every push. Download it anytime to chat with LLMs about the exact state of your main branch or a specific PR: ```yaml +# .github/workflows/codebase-snapshot.yaml name: Snapshot Codebase on: @@ -206,136 +358,140 @@ on: jobs: snapshot: runs-on: ubuntu-latest - permissions: - contents: read steps: - uses: actions/checkout@v6 - - - name: Generate Snapshot - # Generates codebase.md without installing fln globally + - name: Generate snapshot run: npx fln . -o codebase.md -w --no-ansi - - - name: Upload Artifact - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v6 with: name: codebase-snapshot path: codebase.md retention-days: 7 ``` -### Git Hooks: Pre-commit Context Guard +### Pre-commit context guard -Prevent accidental “context bloat” (e.g., committing large datasets or wrong lockfiles) by failing commits if the flattened codebase exceeds a specific size. This ensures your project always fits within LLM context windows. - -Add to your pre-commit hook (e.g., via `husky` or `lint-staged`): +Fail the commit if the flattened codebase exceeds a size limit — ensures the project always fits in LLM context windows: ```bash -# Fails the commit if the flattened codebase exceeds 5MB (configurable) -# --dry-run ensures no files are written to disk +# .husky/pre-commit npx fln . --dry-run --max-total-size 5mb ``` -## JavaScript API +--- + +## API + +```bash +npm install fln +``` ```typescript import { fln } from "fln"; const result = await fln({ - rootDirectory: "./src", - outputFile: "output.md", - overwrite: true, + input: "./src", + output: "snapshot.md", excludePatterns: [ "*.test.ts", "fixtures/" ], - format: "md", onProgress: (current, total) => { - console.log(`Progress: ${current}/${total}`); + process.stdout.write(`\r${current}/${total} files`); } }); -console.log(`Processed ${result.files} files`); -console.log(`Output: ${result.outputPath}`); -console.log(`Tokens: ${result.outputTokenCount}`); +console.log(`${result.files} files → ${result.outputPath}`); +console.log(`~${result.outputTokenCount.toLocaleString()} tokens`); ``` -All CLI options are available via `FlnOptions`. - - -## Advanced -
-Configuration file (.fln.json) +Full API reference + +**Options** + +| Option | Type | Default | Description | +|---|---|---|---| +| `input` | `string` | `process.cwd()` | Directory to flatten | +| `output` | `string` | auto | Output file path or directory. `"-"` for stdout | +| `overwrite` | `boolean` | `false` | Overwrite instead of numeric suffix | +| `excludePatterns` | `string[]` | `[]` | Glob patterns to exclude | +| `includePatterns` | `string[]` | `[]` | Glob patterns to force include | +| `includeHidden` | `boolean` | `false` | Include hidden files/dirs | +| `gitignore` | `boolean` | `true` | Respect `.gitignore` rules | +| `maxFileSize` | `number \| string` | `"10mb"` | Max individual file size | +| `maxTotalSize` | `number \| string` | `0` | Max total size (0 = unlimited) | +| `includeContents` | `boolean` | `true` | Include file contents | +| `includeTree` | `boolean` | `true` | Include directory tree | +| `format` | `"md" \| "json"` | `"md"` | Output format | +| `followSymlinks` | `boolean` | `false` | Follow symlinks | +| `date` | `string` | current | Fixed `YYYY-MM-DD HH:mm` for Generated header | +| `banner` | `string` | — | Text prepended after header | +| `bannerFile` | `string` | — | File prepended (relative to input) | +| `footer` | `string` | — | Text appended at end | +| `footerFile` | `string` | — | File appended (relative to input) | +| `logLevel` | `"silent" \| "normal" \| "verbose" \| "debug"` | `"silent"` | Log level | +| `ansi` | `boolean` | `false` | ANSI colors in log output | +| `onProgress` | `(current, total) => void` | — | Progress callback | + +**Result** -```json -{ - "outputFile": "output.md", - "overwrite": false, - "excludePatterns": [ - "dist/", - "**/*.snap" - ], - "includePatterns": [], - "includeHidden": false, - "useGitignore": true, - "maximumFileSizeBytes": "10mb", - "maximumTotalSizeBytes": "0", - "includeTree": true, - "includeContents": true, - "format": "md", - "followSymlinks": false, - "logLevel": "normal", - "generatedDate": "2026-02-09 12:00", - "banner": "This is a snapshot of the codebase.", - "footer": "End of snapshot." -} +```typescript +type FlnResult = { + projectName: string; // from package.json, pom.xml, Cargo.toml, etc. + files: number; // files included + directories: number; // directories scanned + binary: number; // binary files (shown as [BINARY FILE: X kb] in output) + skipped: number; // skipped — too large, generated by fln, or read errors + errors: number; // read errors + totalSizeBytes: number; // total input size + outputSizeBytes: number; // output file size + outputTokenCount: number; // estimated token count + outputPath: string; // absolute path ("-" for stdout) +}; ```
-
-Output naming & formats +--- -- Uses project name + version if available (`package.json`, `pyproject.toml`, `pom.xml`, `go.mod`, `Cargo.toml`, `CMakeLists.txt` or `vcpkg.json`) -- `md` includes tree + contents -- `json` includes `rootDirectory`, `tree`, `stats` +## Preview -
+Real outputs from [`examples/`](examples/): + +- [TypeScript](examples/ts-app.md) +- [Python](examples/python-app.md) +- [Go](examples/go-app.md) +- [Rust](examples/rust-app.md) +- [Java](examples/java-app.md) + +---
Runtime compatibility -**Node.js** -- Requires Node.js `>=18.3` -- ESM-only package (`"type": "module"`) -- CLI works via `npm i -g fln` or `npx fln` +**Node.js** — requires `>=18.3.0`, ESM-only (`"type": "module"`). Install via `npm i -g fln` or run with `npx`. -**Bun** -- Requires Bun `>=1.0.0` -- CLI works via `bun install -g fln` or `bunx fln` +**Bun** — requires `>=1.0.0`. Install via `bun add -g fln` or run with `bunx`. -
+**Standalone binary** — no runtime required. Install via the `curl` / PowerShell one-liner above. -## Preview + -Full real outputs are provided below. Each example is a compact project in [`examples/`](examples/). `fln` outputs the directory tree and file contents with **entry points and configs first** (intentional file order): - -- [TypeScript](examples/ts-app.md) -- [Python](examples/python-app.md) -- [Java](examples/java-app.md) -- [Go](examples/go-app.md) -- [Rust](examples/rust-app.md) +--- ## Support this project -**fln is free, open-source, and maintained by one developer.** +**`fln` is free, open-source, and maintained by one developer.** If it saves you time or improves your AI workflow: -- ⭐️ Star the repo — it genuinely helps discoverability -- 💙 Support on [Patreon](https://www.patreon.com/nesvet) — priority features & long-term maintenance + +- ⭐️ **Star the repo** — it genuinely helps discoverability +- 💙 **[Support on Patreon](https://www.patreon.com/nesvet)** — keeps development going + +--- ## Contributing -PRs and issues are welcome. -See [`CONTRIBUTING.md`](CONTRIBUTING.md) for setup and guidelines. +PRs and issues are welcome. See [`CONTRIBUTING.md`](CONTRIBUTING.md) for setup and guidelines. ## License -MIT +MIT © [Eugene Nesvetaev](https://nesvet.dev) diff --git a/bun.lock b/bun.lock index ed05529..f777f84 100644 --- a/bun.lock +++ b/bun.lock @@ -5,12 +5,16 @@ "": { "name": "fln", "dependencies": { + "bytes": "^3.1.2", "ignore": "^7.0.5", + "p-limit": "^7.3.0", "picocolors": "^1.1.1", + "strip-ansi": "^7.1.2", }, "devDependencies": { "@nesvet/eslint-config": "latest", "@types/bun": "latest", + "@types/bytes": "latest", "@types/node": "latest", "typescript": "latest", }, @@ -89,7 +93,9 @@ "@stylistic/stylelint-plugin": ["@stylistic/stylelint-plugin@5.0.1", "", { "dependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0", "style-search": "^0.1.0" }, "peerDependencies": { "stylelint": "^17.0.0" } }, "sha512-NaVwCNVZ2LyPA3TnUwvjO9c6P6VUjgRB8UP8SOW+cAOJBVqPPuOIDawsvvtql/LhkuR3JuTdGvr/RM3dUl8l2Q=="], - "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + + "@types/bytes": ["@types/bytes@3.1.5", "", {}, "sha512-VgZkrJckypj85YxEsEavcMmmSOIzkUHqWmM4CCyia5dc54YwsXzJ5uT4fYxBQNEXx+oF1krlhgCbvfubXqZYsQ=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -101,7 +107,7 @@ "@types/minimatch": ["@types/minimatch@6.0.0", "", { "dependencies": { "minimatch": "*" } }, "sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA=="], - "@types/node": ["@types/node@25.2.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ=="], + "@types/node": ["@types/node@25.3.1", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.54.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/type-utils": "8.54.0", "@typescript-eslint/utils": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.54.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ=="], @@ -173,7 +179,9 @@ "builtin-modules": ["builtin-modules@5.0.0", "", {}, "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg=="], - "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "cacheable": ["cacheable@2.3.2", "", { "dependencies": { "@cacheable/memory": "^2.0.7", "@cacheable/utils": "^2.3.3", "hookified": "^1.15.0", "keyv": "^5.5.5", "qified": "^0.6.0" } }, "sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA=="], @@ -589,7 +597,7 @@ "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], - "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "p-limit": ["p-limit@7.3.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], @@ -765,7 +773,7 @@ "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "unicorn-magic": ["unicorn-magic@0.4.0", "", {}, "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw=="], @@ -791,7 +799,7 @@ "write-file-atomic": ["write-file-atomic@7.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg=="], - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], "@cacheable/memory/keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], @@ -807,6 +815,8 @@ "@keyv/bigmap/keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], + "@types/glob/@types/node": ["@types/node@25.2.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ=="], + "@types/minimatch/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], @@ -815,6 +825,8 @@ "brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "bun-types/@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + "cacheable/keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], "clean-regexp/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], @@ -859,6 +871,8 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "sort-package-json/globby": ["globby@10.0.0", "", { "dependencies": { "@types/glob": "^7.1.1", "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.0.3", "glob": "^7.1.3", "ignore": "^5.1.1", "merge2": "^1.2.3", "slash": "^3.0.0" } }, "sha512-3LifW9M4joGZasyYPz2A1U74zbC/45fvpXUvO/9KbSa+VV0aGZarWkfdgKyR9sExNP0t0x0ss/UMJpNpcaTspw=="], "stylelint/file-entry-cache": ["file-entry-cache@11.1.2", "", { "dependencies": { "flat-cache": "^6.1.20" } }, "sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log=="], @@ -873,6 +887,8 @@ "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "@types/glob/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@types/minimatch/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], @@ -881,6 +897,8 @@ "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "sort-package-json/globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "sort-package-json/globby/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], diff --git a/examples/go-app.md b/examples/go-app.md index f7f298e..8d4cd38 100644 --- a/examples/go-app.md +++ b/examples/go-app.md @@ -1,8 +1,8 @@ - + # Codebase Snapshot: go-app -Generated: 2026-01-01 00:00 +Generated: 2026-02-26 00:00 Files: 7 | Directories: 8 --- diff --git a/examples/java-app.md b/examples/java-app.md index 0f33cea..f745f77 100644 --- a/examples/java-app.md +++ b/examples/java-app.md @@ -1,8 +1,8 @@ - + # Codebase Snapshot: java-app -Generated: 2026-01-01 00:00 +Generated: 2026-02-26 00:00 Files: 7 | Directories: 6 --- diff --git a/examples/python-app.md b/examples/python-app.md index 97583e9..86b60dc 100644 --- a/examples/python-app.md +++ b/examples/python-app.md @@ -1,8 +1,8 @@ - + # Codebase Snapshot: python-app -Generated: 2026-01-01 00:00 +Generated: 2026-02-26 00:00 Files: 8 | Directories: 3 --- diff --git a/examples/rust-app.md b/examples/rust-app.md index 34f018e..27f5adc 100644 --- a/examples/rust-app.md +++ b/examples/rust-app.md @@ -1,8 +1,8 @@ - + # Codebase Snapshot: rust-app -Generated: 2026-01-01 00:00 +Generated: 2026-02-26 00:00 Files: 8 | Directories: 2 --- diff --git a/examples/ts-app.md b/examples/ts-app.md index 32fc21d..a57911e 100644 --- a/examples/ts-app.md +++ b/examples/ts-app.md @@ -1,8 +1,8 @@ - + # Codebase Snapshot: ts-app -Generated: 2026-01-01 00:00 +Generated: 2026-02-26 00:00 Files: 8 | Directories: 2 --- diff --git a/package.json b/package.json index 3058ccc..a1406b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fln", - "version": "1.1.3", + "version": "1.2.0", "description": "Feed your entire codebase to any LLM in one shot. No attachment limits, no upload hassles.", "keywords": [ "cli", @@ -62,16 +62,20 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "bytes": "^3.1.2", "ignore": "^7.0.5", - "picocolors": "^1.1.1" + "p-limit": "^7.3.0", + "picocolors": "^1.1.1", + "strip-ansi": "^7.1.2" }, "devDependencies": { "@nesvet/eslint-config": "latest", "@types/bun": "latest", + "@types/bytes": "latest", "@types/node": "latest", "typescript": "latest" }, - "packageManager": "bun@1.3.9", + "packageManager": "bun@1.3.10", "engines": { "bun": ">=1.0.0", "node": ">=18.3.0" diff --git a/schema/fln.json b/schema/fln.json new file mode 100644 index 0000000..5442ec7 --- /dev/null +++ b/schema/fln.json @@ -0,0 +1,121 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://fln.nesvet.dev/schema", + "title": "fln configuration", + "description": "Configuration file for fln — flatten your codebase into a single file for LLMs", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string" + }, + "output": { + "type": "string", + "description": "Output file path or directory. Default: auto-generated from project metadata (e.g. my-app-1.0.0.md)" + }, + "overwrite": { + "type": "boolean", + "description": "Overwrite output file instead of adding numeric suffix when it already exists", + "default": false + }, + "excludePatterns": { + "type": "array", + "items": { "type": "string" }, + "description": "Glob patterns to exclude (e.g. [\"*.test.ts\", \"fixtures/\"])", + "default": [] + }, + "includePatterns": { + "type": "array", + "items": { "type": "string" }, + "description": "Glob patterns to force include, overriding .gitignore rules", + "default": [] + }, + "includeHidden": { + "type": "boolean", + "description": "Include hidden files and directories (starting with .)", + "default": false + }, + "gitignore": { + "type": "boolean", + "description": "Use .gitignore rules for filtering", + "default": true + }, + "maxFileSize": { + "oneOf": [ + { "type": "integer", "minimum": 1 }, + { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?\\s*(b|kb|mb|gb)?$", + "examples": [ "10mb", "512kb", "1gb" ] + } + ], + "description": "Maximum individual file size. Default: 10mb" + }, + "maxTotalSize": { + "oneOf": [ + { "type": "integer", "minimum": 0 }, + { + "type": "string", + "pattern": "^\\d+(\\.\\d+)?\\s*(b|kb|mb|gb)?$", + "examples": [ "100mb", "1gb" ] + } + ], + "description": "Maximum total size for all included files. 0 means unlimited", + "default": 0 + }, + "includeContents": { + "type": "boolean", + "description": "Include file contents in output", + "default": true + }, + "includeTree": { + "type": "boolean", + "description": "Include directory tree structure", + "default": true + }, + "format": { + "type": "string", + "enum": [ "md", "json" ], + "description": "Output format", + "default": "md" + }, + "followSymlinks": { + "type": "boolean", + "description": "Follow symlinks while scanning", + "default": false + }, + "ansi": { + "type": "boolean", + "description": "Use ANSI colors in log output", + "default": true + }, + "logLevel": { + "type": "string", + "enum": [ "silent", "normal", "verbose", "debug" ], + "description": "Logging level", + "default": "normal" + }, + "date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}$", + "description": "Fixed date string for the Generated header (overrides current date). Format: YYYY-MM-DD HH:mm", + "examples": [ "2026-01-01 00:00" ] + }, + "banner": { + "type": "string", + "description": "Custom text prepended to output after the header" + }, + "bannerFile": { + "type": "string", + "description": "Path to file whose contents are prepended to output (relative to input directory). File is excluded from tree" + }, + "footer": { + "type": "string", + "description": "Custom text appended to the end of output" + }, + "footerFile": { + "type": "string", + "description": "Path to file whose contents are appended to output (relative to input directory). File is excluded from tree" + } + } +} diff --git a/scripts/generate-examples.ts b/scripts/generate-examples.ts index 1fda4fb..85718da 100644 --- a/scripts/generate-examples.ts +++ b/scripts/generate-examples.ts @@ -11,10 +11,10 @@ const names = readdirSync(examplesDir, { withFileTypes: true }).filter(entry => for (const name of names) await fln({ - rootDirectory: join(examplesDir, name), - outputFile: join(examplesDir, `${name}.md`), + input: join(examplesDir, name), + output: join(examplesDir, `${name}.md`), overwrite: true, - generatedDate: "2026-01-01 00:00" + date: "2026-02-26 00:00" }); console.info(`✓ Generated ${names.length} snapshot(s)`); diff --git a/src/api/fln.ts b/src/api/fln.ts index ad8b531..9a9e296 100644 --- a/src/api/fln.ts +++ b/src/api/fln.ts @@ -1,4 +1,5 @@ -import { relative, resolve, sep } from "node:path"; +import { readFile, stat } from "node:fs/promises"; +import { resolve } from "node:path"; import { defaultConfigFileName, getProjectMetadata, @@ -8,7 +9,8 @@ import { resolveOutputPath } from "../config/index.js"; import { parseByteSize, scanTree, writeOutput } from "../core/index.js"; -import { createLogger } from "../infra/index.js"; +import { createLogger, resolveOption, symbols } from "../infra/index.js"; +import { resolveFromBase, toCanonicalRelative } from "../path/index.js"; import type { FlnOptions, FlnResult } from "./types.js"; /** @@ -19,8 +21,8 @@ import type { FlnOptions, FlnResult } from "./types.js"; * import { fln } from "fln"; * * const result = await fln({ - * rootDirectory: "./src", - * outputFile: "output.md", + * input: "./src", + * output: "output.md", * excludePatterns: ["*.test.ts"], * onProgress: (current, total) => { * console.log(`Progress: ${current}/${total}`); @@ -33,73 +35,121 @@ import type { FlnOptions, FlnResult } from "./types.js"; * ``` */ export async function fln(options: FlnOptions = {}): Promise { - const rootDirectory = resolve(options.rootDirectory ?? process.cwd()); + // TODO(major): remove rootDirectory fallback + const input = resolve(options.input ?? options.rootDirectory ?? process.cwd()); + const inputStats = await stat(input); + if (!inputStats.isDirectory()) + throw new Error(`Input must be a directory, got file: ${input}`); - const projectMetadata = await getProjectMetadata(rootDirectory); + const projectMetadata = await getProjectMetadata(input); - const configFilePath = resolve(rootDirectory, defaultConfigFileName); + const configFilePath = resolve(input, defaultConfigFileName); - const fileConfig = normalizeConfigFile(await loadConfigFile(configFilePath)); + const { config: rawFileConfig, loaded: configLoaded, parseError } = await loadConfigFile(configFilePath); + if (parseError) + console.warn(parseError); + + const fileConfig = normalizeConfigFile(rawFileConfig); const format: "json" | "md" = options.format ?? (fileConfig.format as "json" | "md" | undefined) ?? "md"; const overwrite = options.overwrite ?? fileConfig.overwrite ?? false; - const outputValue = options.outputFile ?? fileConfig.outputFile; - const outputFile = await resolveOutputPath( - outputValue ? resolve(outputValue) : undefined, - rootDirectory, + // TODO(major): remove outputFile fallback + const outputValue = options.output ?? options.outputFile ?? fileConfig.output; + const output = await resolveOutputPath( + outputValue === "-" ? "-" : (outputValue ? resolveFromBase(outputValue, input) : undefined), + input, + projectMetadata, overwrite, format ); + const gitignore = resolveOption(options, "gitignore", "useGitignore", "API"); + const maxFileSize = resolveOption(options, "maxFileSize", "maximumFileSizeBytes", "API"); + const maxTotalSize = resolveOption(options, "maxTotalSize", "maximumTotalSizeBytes", "API"); + const date = resolveOption(options, "date", "generatedDate", "API"); + const ansi = resolveOption(options, "ansi", "useAnsi", "API") ?? false; + const userConfig = { - outputFile, + output, overwrite: options.overwrite, excludePatterns: options.excludePatterns, includePatterns: options.includePatterns, includeHidden: options.includeHidden, - useGitignore: options.useGitignore, - maximumFileSizeBytes: typeof options.maximumFileSizeBytes === "string" ? - parseByteSize(options.maximumFileSizeBytes) : - options.maximumFileSizeBytes, - maximumTotalSizeBytes: typeof options.maximumTotalSizeBytes === "string" ? - parseByteSize(options.maximumTotalSizeBytes) : - options.maximumTotalSizeBytes, + gitignore, + maxFileSize: maxFileSize === undefined ? + undefined : + (typeof maxFileSize === "string" ? parseByteSize(maxFileSize) : maxFileSize), + maxTotalSize: maxTotalSize === undefined ? + undefined : + (typeof maxTotalSize === "string" ? parseByteSize(maxTotalSize) : maxTotalSize), includeContents: options.includeContents, includeTree: options.includeTree, format, followSymlinks: options.followSymlinks, - useAnsi: false, + ansi, logLevel: options.logLevel ?? "silent", - generatedDate: options.generatedDate, + date, banner: options.banner, - footer: options.footer + bannerFile: options.bannerFile, + footer: options.footer, + footerFile: options.footerFile }; - const config = resolveConfig(rootDirectory, fileConfig, userConfig); + const config = resolveConfig(input, fileConfig, userConfig); + + const outputCanonical = config.output === "-" ? null : toCanonicalRelative(config.output, input); + if (outputCanonical && outputCanonical !== "") + config.excludedPaths = [ outputCanonical ]; + + async function resolveBannerFooterFile( + filePath: string | undefined + ): Promise<{ content: string; excludedPath?: string }> { + if (filePath) { + const absolutePath = resolveFromBase(filePath, input); + const content = await readFile(absolutePath, "utf8"); + const excludedPath = toCanonicalRelative(absolutePath, input) ?? undefined; + + return { content, excludedPath }; + } + + return { content: "" }; + } - const outputRelativePath = relative(rootDirectory, config.outputFile); - const outputRelativeNormalized = outputRelativePath.split(sep).join("/"); + const [ bannerFileResult, footerFileResult ] = await Promise.all([ + resolveBannerFooterFile(config.bannerFile), + resolveBannerFooterFile(config.footerFile) + ]); - if (outputRelativeNormalized !== "" && !outputRelativeNormalized.startsWith("../") && outputRelativeNormalized !== "..") - config.excludedPaths = [ outputRelativeNormalized ]; + const bannerParts = [ config.banner, bannerFileResult.content ].filter(Boolean) as string[]; + const footerParts = [ config.footer, footerFileResult.content ].filter(Boolean) as string[]; + config.banner = bannerParts.length > 0 ? bannerParts.join("\n\n") : undefined; + config.footer = footerParts.length > 0 ? footerParts.join("\n\n") : undefined; + + if (bannerFileResult.excludedPath) + config.excludedPaths.push(bannerFileResult.excludedPath); + if (footerFileResult.excludedPath) + config.excludedPaths.push(footerFileResult.excludedPath); if (!config.includeContents) { - config.maximumFileSizeBytes = Number.MAX_SAFE_INTEGER; - config.maximumTotalSizeBytes = 0; + config.maxFileSize = Number.MAX_SAFE_INTEGER; + config.maxTotalSize = 0; } - if (config.maximumFileSizeBytes <= 0) + if (config.maxFileSize <= 0) throw new Error("Max file size must be greater than 0."); - if (config.maximumTotalSizeBytes < 0) + if (config.maxTotalSize < 0) throw new Error("Max total size must be 0 or greater."); const logger = createLogger({ - useAnsi: config.useAnsi, + ansi: config.ansi, logLevel: config.logLevel }); + if (configLoaded) + logger.info(`${symbols.info} Using config: ${defaultConfigFileName}`); + const result = await scanTree({ projectName: projectMetadata.name, ...config, @@ -118,7 +168,7 @@ export async function fln(options: FlnOptions = {}): Promise { totalSizeBytes: result.stats.totalSizeBytes, outputSizeBytes: result.stats.outputSizeBytes, outputTokenCount: result.stats.outputTokenCount, - outputPath: config.outputFile, + outputPath: config.output, _root: result.root }; } diff --git a/src/api/types.ts b/src/api/types.ts index 7dd18b1..e26f079 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,20 +1,31 @@ -import type { LogLevel, ProgressCallback } from "../core/index.js"; +export type LogLevel = "debug" | "normal" | "silent" | "verbose"; +export type ProgressCallback = (current: number, total: number) => void; /** * Configuration options for fln function */ export type FlnOptions = { /** - * Root directory to scan + * Directory to flatten (input) * @default process.cwd() */ - rootDirectory?: string; + input?: string; /** * Output file path or directory * @default Auto-generated from project metadata (e.g., "my-app-1.0.0.md") */ + output?: string; + + /** + * @deprecated Use input. Remove in next major. + */ + rootDirectory?: string; + + /** + * @deprecated Use output. Remove in next major. + */ outputFile?: string; /** @@ -45,18 +56,33 @@ export type FlnOptions = { * Use .gitignore rules for filtering * @default true */ + gitignore?: boolean; + + /** + * @deprecated Use gitignore. Remove in next major. + */ useGitignore?: boolean; /** * Maximum individual file size (bytes or string like "10mb") * @default 10485760 (10 MB) */ + maxFileSize?: number | string; + + /** + * @deprecated Use maxFileSize. Remove in next major. + */ maximumFileSizeBytes?: number | string; /** * Maximum total size for all included files (bytes or string like "100mb") * @default 0 (unlimited) */ + maxTotalSize?: number | string; + + /** + * @deprecated Use maxTotalSize. Remove in next major. + */ maximumTotalSizeBytes?: number | string; /** @@ -86,6 +112,11 @@ export type FlnOptions = { /** * Date string for the "Generated" header (format: YYYY-MM-DD HH:mm). If omitted, current date is used. */ + date?: string; + + /** + * @deprecated Use date. Remove in next major. + */ generatedDate?: string; /** @@ -93,11 +124,21 @@ export type FlnOptions = { */ banner?: string; + /** + * Path to file whose contents are prepended to output (relative to input). File is excluded from tree. + */ + bannerFile?: string; + /** * Custom footer text at the end of output */ footer?: string; + /** + * Path to file whose contents are appended to output (relative to input). File is excluded from tree. + */ + footerFile?: string; + /** * Progress callback function * @param current Current number of processed items @@ -110,6 +151,17 @@ export type FlnOptions = { * @default "silent" */ logLevel?: LogLevel; + + /** + * Use ANSI colors in log output + * @default false (for programmatic use) + */ + ansi?: boolean; + + /** + * @deprecated Use ansi. Remove in next major. + */ + useAnsi?: boolean; }; /** diff --git a/src/cli/commandLine.ts b/src/cli/commandLine.ts index a17fc71..6e4749e 100644 --- a/src/cli/commandLine.ts +++ b/src/cli/commandLine.ts @@ -1,6 +1,8 @@ import { resolve } from "node:path"; import { parseArgs } from "node:util"; +import ignore from "ignore"; import { fln } from "../api/index.js"; +import { runInit } from "../config/index.js"; import { collectExtensionStats, collectProcessedFiles, @@ -8,11 +10,17 @@ import { type FileNode } from "../core/index.js"; import { + createProgressRenderer, + filterPathsUnderBase, + getChangedFilesSince, getTerminalInfo, incrementUsageCount, shouldShowSponsorMessage, - shouldUseColors + shouldUseColors, + warnDeprecated } from "../infra/index.js"; +import { getNullishOutput, resolveFromBase } from "../path/index.js"; +import { normalizeIncludePattern } from "../pattern/index.js"; import { VERSION } from "../version.js"; import { formatHelpMessage } from "./help.js"; import { OutputRenderer } from "./output/index.js"; @@ -44,6 +52,7 @@ export async function runCommandLine(): Promise { output: { type: "string", short: "o" }, exclude: { type: "string", short: "e", multiple: true }, include: { type: "string", short: "i", multiple: true }, + ext: { type: "string" }, "include-hidden": { type: "boolean" }, "no-gitignore": { type: "boolean" }, "max-size": { type: "string" }, @@ -52,6 +61,7 @@ export async function runCommandLine(): Promise { "no-tree": { type: "boolean" }, format: { type: "string" }, "dry-run": { type: "boolean" }, + stdout: { type: "boolean" }, overwrite: { type: "boolean", short: "w" }, quiet: { type: "boolean", short: "q" }, verbose: { type: "boolean", short: "V" }, @@ -59,9 +69,13 @@ export async function runCommandLine(): Promise { "no-ansi": { type: "boolean" }, "follow-symlinks": { type: "boolean" }, "no-sponsor-message": { type: "boolean" }, + date: { type: "string" }, "generated-date": { type: "string" }, banner: { type: "string" }, + "banner-file": { type: "string" }, footer: { type: "string" }, + "footer-file": { type: "string" }, + since: { type: "string" }, version: { type: "boolean", short: "v" }, help: { type: "boolean", short: "h" } }, @@ -80,6 +94,11 @@ export async function runCommandLine(): Promise { process.exit(0); } + if (positionals[0] === "init") { + await runInit(values.overwrite ?? false); + process.exit(0); + } + if (values.quiet && values.verbose) throw new Error("Cannot use --quiet and --verbose together."); @@ -90,45 +109,86 @@ export async function runCommandLine(): Promise { throw new Error("Cannot use --verbose and --debug together."); const runCount = await incrementUsageCount(); - const rootDirectory = resolve(process.cwd(), positionals[0] || "."); + const input = resolve(process.cwd(), positionals[0] || "."); const isDryRun = values["dry-run"] ?? false; + const isStdout = values.stdout ?? false; + + const logLevel = (values.quiet || isStdout) ? "silent" : values.debug ? "debug" : values.verbose ? "verbose" : "normal"; + const ansi = shouldUseColors() && !values["no-ansi"]; + + if (values["generated-date"] !== undefined && values.date === undefined) + warnDeprecated("--generated-date", "--date", "CLI"); + + const date = values.date ?? values["generated-date"]; + + const cwd = process.cwd(); + const sincePatterns = values.since ? + filterPathsUnderBase(getChangedFilesSince(values.since, cwd), cwd, input) : + []; + const extPatterns = values.ext ? + values.ext.split(",").map(ext => `**/*.${ext.trim().replace(/^\./, "")}`).filter(Boolean) : + []; + const sinceFiltered = values.since && values.ext ? + (() => { + const normalized = extPatterns + .map(p => normalizeIncludePattern(p, input)) + .filter((p): p is string => p !== null); + + if (normalized.length === 0) + return sincePatterns; + const extMatcher = ignore().add(normalized); + + return sincePatterns.filter(path => extMatcher.ignores(path)); + })() : + sincePatterns; + if (values.since && sinceFiltered.length === 0 && !values.include?.length) { + const extSuffix = values.ext ? ` matching --ext ${values.ext}` : ""; + console.info(`No changed files since ${values.since}${extSuffix}`); + process.exit(0); + } + const includePatterns = values.since && values.ext ? + [ ...sinceFiltered, ...(values.include ?? []) ] : + [ ...sinceFiltered, ...extPatterns, ...(values.include ?? []) ]; - const logLevel = values.quiet ? "silent" : values.debug ? "debug" : values.verbose ? "verbose" : "normal"; - const useAnsi = shouldUseColors() && !values["no-ansi"]; - - const renderer = new OutputRenderer({ logLevel, useAnsi }); - - const progress = renderer.createProgressBar(100); + const renderer = new OutputRenderer({ logLevel, ansi }); + const progress = createProgressRenderer("🥞 Scanning...", ansi, (values.quiet ?? false) || isStdout); const startTime = Date.now(); + progress.start(); const result = await fln({ - rootDirectory, - outputFile: isDryRun ? - (process.platform === "win32" ? "nul" : "/dev/null") : - (values.output ? resolve(values.output) : undefined), + input, + ansi, + output: isDryRun ? + getNullishOutput() : + (isStdout ? "-" : (values.output ? resolveFromBase(values.output, cwd) : undefined)), overwrite: values.overwrite, excludePatterns: values.exclude, - includePatterns: values.include, + includePatterns: + includePatterns.length > 0 || values.since ? + includePatterns : + undefined, includeHidden: values["include-hidden"], - useGitignore: values["no-gitignore"] ? false : undefined, - maximumFileSizeBytes: values["max-size"] ? parseByteSize(values["max-size"]) : undefined, - maximumTotalSizeBytes: values["max-total-size"] ? parseByteSize(values["max-total-size"]) : undefined, + gitignore: values["no-gitignore"] ? false : undefined, + maxFileSize: values["max-size"] ? parseByteSize(values["max-size"]) : undefined, + maxTotalSize: values["max-total-size"] ? parseByteSize(values["max-total-size"]) : undefined, includeContents: values["no-contents"] ? false : undefined, includeTree: values["no-tree"] ? false : undefined, format: values.format as "json" | "md" | undefined, followSymlinks: values["follow-symlinks"], - generatedDate: values["generated-date"], + date, banner: values.banner, + bannerFile: values["banner-file"], footer: values.footer, - onProgress: () => { - progress.increment(); + footerFile: values["footer-file"], + onProgress: (current, total) => { + progress.update(current, total, "files"); }, logLevel }); const elapsedMs = Date.now() - startTime; - progress.clear(); + progress.cleanup(); if (isDryRun && logLevel !== "silent") console.info("Dry run mode — output was not written"); diff --git a/src/cli/help.ts b/src/cli/help.ts index 1a464b6..b42e71d 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -1,21 +1,24 @@ import { applyColor, colors } from "./output/index.js"; -export function formatHelpMessage(useAnsi: boolean): string { - const bold = (text: string) => applyColor(text, colors.bold, useAnsi); - const dim = (text: string) => applyColor(text, colors.dim, useAnsi); - const cyan = (text: string) => applyColor(text, colors.info, useAnsi); - const green = (text: string) => applyColor(text, colors.success, useAnsi); +export function formatHelpMessage(ansi: boolean): string { + const bold = (text: string) => applyColor(text, colors.bold, ansi); + const dim = (text: string) => applyColor(text, colors.dim, ansi); + const cyan = (text: string) => applyColor(text, colors.info, ansi); + const green = (text: string) => applyColor(text, colors.success, ansi); - return `${bold("fln")} ${dim("—")} Flatten your codebase into a single file for LLMs. + return `🥞 ${bold("fln")} ${dim("—")} Flatten your codebase into a single file for LLMs. ${bold("Usage:")} fln ${cyan("[directory]")} ${dim("[...flags]")} + fln ${cyan("init")} ${dim("[--overwrite]")} ${bold("Options:")} - ${cyan("-o, --output")} ${dim("")} Output file or directory path ${dim("(default: -.)")} + ${cyan("-o, --output")} ${dim("")} Output file or directory path ${dim("(default: -.; adds .md/.json if no extension)")} ${cyan("-w, --overwrite")} Overwrite output file instead of adding numeric suffix ${cyan("-e, --exclude")} ${dim("")} Exclude patterns ${dim("(repeatable)")} ${cyan("-i, --include")} ${dim("")} Force include patterns ${dim("(repeatable)")} + ${cyan(" --ext")} ${dim("")} Include only these extensions ${dim("(e.g. ts,tsx,js)")} + ${cyan(" --since")} ${dim("")} Include only files changed since git ref ${dim("(e.g. HEAD~1, main)")} ${cyan(" --include-hidden")} Include hidden files and directories ${cyan(" --no-gitignore")} Ignore .gitignore files ${cyan(" --max-size")} ${dim("")} Max file size ${dim("(e.g. 10mb, 512kb)")} @@ -24,12 +27,15 @@ ${bold("Options:")} ${cyan(" --no-tree")} Exclude directory tree ${cyan(" --format")} ${dim("")} Output format ${dim("(default: md)")} ${cyan(" --dry-run")} Scan and report without writing output + ${cyan(" --stdout")} Write output to stdout instead of file ${dim("(implies --quiet)")} ${cyan(" --follow-symlinks")} Follow symlinks while scanning ${cyan(" --no-ansi")} Disable ANSI colors ${cyan(" --no-sponsor-message")} Hide support message ${dim("(also: FLN_NO_SPONSOR=1)")} - ${cyan(" --generated-date")} ${dim("")} Use this date in the "Generated" header ${dim("(YYYY-MM-DD HH:mm)")} + ${cyan(" --date")} ${dim("")} Use this date in the "Generated" header ${dim("(YYYY-MM-DD HH:mm)")} ${cyan(" --banner")} ${dim("")} Add text at the beginning of the output + ${cyan(" --banner-file")} ${dim("")} Prepend file contents at the beginning ${dim("(relative to input)")} ${cyan(" --footer")} ${dim("")} Add text at the end of the output + ${cyan(" --footer-file")} ${dim("")} Append file contents at the end ${dim("(relative to input)")} ${cyan("-q, --quiet")} Minimal output ${cyan("-V, --verbose")} Verbose output ${cyan(" --debug")} Debug output with file list @@ -37,8 +43,14 @@ ${bold("Options:")} ${cyan("-h, --help")} Show this help message ${bold("Examples:")} + ${dim("$")} fln ${cyan("init")} ${dim("$")} fln . ${cyan("-o")} output.md + ${dim("$")} fln ${cyan("--stdout")} | pbcopy + ${dim("$")} fln ${cyan("--ext")} ts,tsx,js + ${dim("$")} fln ${cyan("--since")} HEAD~1 ${dim("$")} fln src ${cyan("-e")} ${green('"*.test.ts"')} ${cyan("-e")} ${green('"fixtures/"')} ${dim("$")} fln . ${cyan("--no-contents --format")} json + +${bold("Note:")} Quote glob patterns ${dim('(e.g. "*.md")')} to prevent shell expansion. `; } diff --git a/src/cli/output/components/index.ts b/src/cli/output/components/index.ts index dff0ae9..127fbdf 100644 --- a/src/cli/output/components/index.ts +++ b/src/cli/output/components/index.ts @@ -1,5 +1,4 @@ export * from "./breakdown.js"; export * from "./errors.js"; -export * from "./progressBar.js"; export * from "./summary.js"; export * from "./warnings.js"; diff --git a/src/cli/output/components/progressBar.ts b/src/cli/output/components/progressBar.ts deleted file mode 100644 index a13fac3..0000000 --- a/src/cli/output/components/progressBar.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { applyColor, colors, symbols } from "../styles.js"; - - -export class ProgressBar { - #total: number; - #current = 0; - #visible = false; - #isTTY: boolean; - #useColors: boolean; - - constructor(total: number, useColors: boolean) { - this.#total = total; - this.#isTTY = Boolean(process.stdout.isTTY); - this.#useColors = useColors; - } - - increment(message?: string): void { - this.#current++; - - if (!this.#isTTY) - return; - - if (!this.#visible) - this.#visible = true; - - this.#render(message); - } - - clear(): void { - if (!this.#visible || !this.#isTTY) - return; - - process.stdout.write(`\r${" ".repeat(80)}\r`); - this.#visible = false; - } - - #render(message?: string): void { - const percent = Math.floor((this.#current / this.#total) * 100); - const filledCount = Math.floor(percent / 5); - const emptyCount = 20 - filledCount; - - const filledBar = applyColor( - symbols.barFull.repeat(filledCount), - colors.info, - this.#useColors - ); - const emptyBar = applyColor( - symbols.barEmpty.repeat(emptyCount), - colors.dim, - this.#useColors - ); - - const bar = filledBar + emptyBar; - const text = message ?? "Scanning..."; - const counter = applyColor( - `${this.#current}/${this.#total}`, - colors.dim, - this.#useColors - ); - - process.stdout.write( - `\r${symbols.pancake} ${text} ${bar} ${percent}% (${counter} files)` - ); - } -} diff --git a/src/cli/output/renderer.ts b/src/cli/output/renderer.ts index d0fb8c4..5a6378b 100644 --- a/src/cli/output/renderer.ts +++ b/src/cli/output/renderer.ts @@ -1,7 +1,6 @@ import type { FlnResult } from "../../api/index.js"; import type { LogLevel } from "../../core/index.js"; import { - ProgressBar, renderBreakdown, renderErrors, renderSummary, @@ -14,7 +13,7 @@ import { applyColor, colors, symbols } from "./styles.js"; export type RendererOptions = { logLevel: LogLevel; - useAnsi: boolean; + ansi: boolean; }; export type SuccessData = { @@ -31,11 +30,7 @@ export class OutputRenderer { constructor(options: RendererOptions) { this.#logLevel = options.logLevel; - this.#useColors = options.useAnsi; - } - - createProgressBar(total: number): ProgressBar { - return new ProgressBar(total, this.#useColors); + this.#useColors = options.ansi; } renderSuccess(data: SuccessData): void { diff --git a/src/config/defaults.ts b/src/config/defaults.ts index f7b6267..a4817c8 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -1,2 +1,2 @@ -export const defaultMaximumFileSizeBytes = 10 * 1024 * 1024; +export const defaultMaxFileSize = 10 * 1024 * 1024; export const defaultConfigFileName = ".fln.json"; diff --git a/src/config/index.ts b/src/config/index.ts index 35c1693..5d808d4 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,4 +1,5 @@ export * from "./defaults.js"; +export * from "./initTemplate.js"; export * from "./loader.js"; export * from "./resolver.js"; export * from "./types.js"; diff --git a/src/config/initTemplate.ts b/src/config/initTemplate.ts new file mode 100644 index 0000000..6606b94 --- /dev/null +++ b/src/config/initTemplate.ts @@ -0,0 +1,38 @@ +import { access, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { defaultConfigFileName } from "./defaults.js"; + + +const SCHEMA_URL = "https://fln.nesvet.dev/schema"; + +const initTemplate = { + $schema: SCHEMA_URL, + output: "output.md", + excludePatterns: [] as string[], + includePatterns: [] as string[], + gitignore: true, + includeHidden: false, + maxFileSize: "10mb", + maxTotalSize: "0", + includeTree: true, + includeContents: true, + format: "md", + followSymlinks: false, + overwrite: false +}; + +export async function runInit(overwrite: boolean): Promise { + const configPath = resolve(process.cwd(), defaultConfigFileName); + + if (!overwrite) + try { + await access(configPath); + console.error(`fln: ${defaultConfigFileName} already exists. Use --overwrite to replace.`); + process.exit(1); + } catch {} + + + const content = `${JSON.stringify(initTemplate, null, "\t")}\n`; + await writeFile(configPath, content); + console.info(`✓ Created ${defaultConfigFileName}`); +} diff --git a/src/config/loader.ts b/src/config/loader.ts index dea050a..9baa77a 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -2,13 +2,27 @@ import { readFile } from "node:fs/promises"; import type { RawConfigFile } from "./types.js"; -export async function loadConfigFile(configPath: string): Promise { +export type LoadConfigResult = { + config: RawConfigFile; + loaded: boolean; + parseError?: string; +}; + +export async function loadConfigFile(configPath: string): Promise { try { const content = await readFile(configPath, "utf8"); - const parsed = JSON.parse(content) as RawConfigFile; + const { $schema: _, ...parsed } = JSON.parse(content) as RawConfigFile; + + return { config: parsed, loaded: true }; + } catch (error) { + const typedError = error as { code?: string; message?: string }; + if (typedError.code === "ENOENT") + return { config: {}, loaded: false }; + + const message = error instanceof SyntaxError ? + `Invalid JSON in ${configPath}: ${error.message}` : + `Failed to load ${configPath}: ${typedError.message}`; - return parsed; - } catch { - return {}; + return { config: {}, loaded: false, parseError: message }; } } diff --git a/src/config/resolver.ts b/src/config/resolver.ts index e6222ea..62d2cf0 100644 --- a/src/config/resolver.ts +++ b/src/config/resolver.ts @@ -1,28 +1,28 @@ import { parseByteSize, type OutputFormat } from "../core/index.js"; -import { parseGeneratedDate } from "../infra/index.js"; -import { defaultMaximumFileSizeBytes } from "./defaults.js"; +import { parseGeneratedDate, resolveOption } from "../infra/index.js"; +import { defaultMaxFileSize } from "./defaults.js"; import type { FlnConfig, RawConfigFile } from "./types.js"; type ConfigOverrides = Partial> & Partial<{ format: string }>; + "maxFileSize" | + "maxTotalSize" | + "output" | + "overwrite" +>> & Partial<{ bannerFile: string; format: string; footerFile: string; outputFile: string }>; // outputFile: TODO(major): remove function parseOptionalSize(value: number | string | undefined): number | undefined { if (value === undefined) @@ -39,42 +39,57 @@ function getDefaultOutputFile(format: OutputFormat): string { return format === "json" ? "output.json" : "output.md"; } - export function normalizeConfigFile(fileConfig: RawConfigFile): ConfigOverrides { + // TODO(major): remove outputFile fallback + const output = fileConfig.output ?? fileConfig.outputFile; + + const gitignore = resolveOption(fileConfig, "gitignore", "useGitignore", "config file"); + const maxFileSize = resolveOption(fileConfig, "maxFileSize", "maximumFileSizeBytes", "config file", v => + parseOptionalSize(v as number | string | undefined) + ); + const maxTotalSize = resolveOption(fileConfig, "maxTotalSize", "maximumTotalSizeBytes", "config file", v => + parseOptionalSize(v as number | string | undefined) + ); + const ansi = resolveOption(fileConfig, "ansi", "useAnsi", "config file"); + const date = resolveOption(fileConfig, "date", "generatedDate", "config file"); + return { - outputFile: fileConfig.outputFile, + output, overwrite: fileConfig.overwrite, excludePatterns: fileConfig.excludePatterns, includePatterns: fileConfig.includePatterns, includeHidden: fileConfig.includeHidden, - useGitignore: fileConfig.useGitignore, - maximumFileSizeBytes: parseOptionalSize(fileConfig.maximumFileSizeBytes), - maximumTotalSizeBytes: parseOptionalSize(fileConfig.maximumTotalSizeBytes), + gitignore, + maxFileSize, + maxTotalSize, includeTree: fileConfig.includeTree, includeContents: fileConfig.includeContents, format: fileConfig.format, followSymlinks: fileConfig.followSymlinks, - useAnsi: fileConfig.useAnsi, + ansi, logLevel: fileConfig.logLevel, - generatedDate: fileConfig.generatedDate, + date, banner: fileConfig.banner, - footer: fileConfig.footer + bannerFile: fileConfig.bannerFile, + footer: fileConfig.footer, + footerFile: fileConfig.footerFile }; } export function resolveConfig( - rootDirectory: string, + input: string, fileConfig: ConfigOverrides, userConfig: ConfigOverrides ): FlnConfig { const format = resolveFormat(userConfig.format ?? fileConfig.format); - const outputFile = userConfig.outputFile ?? fileConfig.outputFile ?? getDefaultOutputFile(format); - const rawGeneratedDate = userConfig.generatedDate ?? fileConfig.generatedDate; - const generatedDate = rawGeneratedDate === undefined ? undefined : parseGeneratedDate(rawGeneratedDate); + // TODO(major): remove outputFile fallback + const output = userConfig.output ?? userConfig.outputFile ?? fileConfig.output ?? fileConfig.outputFile ?? getDefaultOutputFile(format); + const rawDate = userConfig.date ?? fileConfig.date; + const date = rawDate === undefined ? undefined : parseGeneratedDate(rawDate); return { - rootDirectory, - outputFile, + input, + output, overwrite: userConfig.overwrite ?? fileConfig.overwrite ?? false, excludePatterns: [ ...(fileConfig.excludePatterns ?? []), @@ -86,17 +101,19 @@ export function resolveConfig( ], excludedPaths: [], includeHidden: userConfig.includeHidden ?? fileConfig.includeHidden ?? false, - useGitignore: userConfig.useGitignore ?? fileConfig.useGitignore ?? true, - maximumFileSizeBytes: userConfig.maximumFileSizeBytes ?? fileConfig.maximumFileSizeBytes ?? defaultMaximumFileSizeBytes, - maximumTotalSizeBytes: userConfig.maximumTotalSizeBytes ?? fileConfig.maximumTotalSizeBytes ?? 0, + gitignore: userConfig.gitignore ?? fileConfig.gitignore ?? true, + maxFileSize: userConfig.maxFileSize ?? fileConfig.maxFileSize ?? defaultMaxFileSize, + maxTotalSize: userConfig.maxTotalSize ?? fileConfig.maxTotalSize ?? 0, includeContents: userConfig.includeContents ?? fileConfig.includeContents ?? true, includeTree: userConfig.includeTree ?? fileConfig.includeTree ?? true, format, followSymlinks: userConfig.followSymlinks ?? fileConfig.followSymlinks ?? false, - useAnsi: userConfig.useAnsi ?? fileConfig.useAnsi ?? true, + ansi: userConfig.ansi ?? fileConfig.ansi ?? true, logLevel: userConfig.logLevel ?? fileConfig.logLevel ?? "normal", - generatedDate, + date, banner: userConfig.banner ?? fileConfig.banner, - footer: userConfig.footer ?? fileConfig.footer + bannerFile: userConfig.bannerFile ?? fileConfig.bannerFile, + footer: userConfig.footer ?? fileConfig.footer, + footerFile: userConfig.footerFile ?? fileConfig.footerFile }; } diff --git a/src/config/types.ts b/src/config/types.ts index 6d66c70..4a8d375 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -2,43 +2,54 @@ import type { LogLevel, OutputFormat } from "../core/index.js"; export type RawConfigFile = Partial<{ - outputFile: string; + $schema: string; + output: string; + outputFile: string; // TODO(major): remove outputFile fallback overwrite: boolean; excludePatterns: string[]; includePatterns: string[]; includeHidden: boolean; - useGitignore: boolean; - maximumFileSizeBytes: number | string; - maximumTotalSizeBytes: number | string; + gitignore: boolean; + useGitignore: boolean; // deprecated + maxFileSize: number | string; + maximumFileSizeBytes: number | string; // deprecated + maxTotalSize: number | string; + maximumTotalSizeBytes: number | string; // deprecated includeTree: boolean; includeContents: boolean; format: OutputFormat; followSymlinks: boolean; - useAnsi: boolean; + ansi: boolean; + useAnsi: boolean; // deprecated logLevel: LogLevel; - generatedDate: string; + date: string; + generatedDate: string; // deprecated banner: string; + bannerFile: string; footer: string; + footerFile: string; }>; export type FlnConfig = { - rootDirectory: string; - outputFile: string; + input: string; + output: string; overwrite: boolean; excludePatterns: string[]; includePatterns: string[]; excludedPaths: string[]; includeHidden: boolean; - useGitignore: boolean; - maximumFileSizeBytes: number; - maximumTotalSizeBytes: number; + gitignore: boolean; + maxFileSize: number; + maxTotalSize: number; includeTree: boolean; includeContents: boolean; format: OutputFormat; followSymlinks: boolean; - useAnsi: boolean; + ansi: boolean; logLevel: LogLevel; - generatedDate?: string; + date?: string; banner?: string; + bannerFile?: string; footer?: string; + footerFile?: string; }; diff --git a/src/config/utils.ts b/src/config/utils.ts index cec3cc7..d7c8d70 100644 --- a/src/config/utils.ts +++ b/src/config/utils.ts @@ -1,15 +1,12 @@ import { readFile, stat } from "node:fs/promises"; import { basename, join, parse } from "node:path"; +import { hasTrailingSeparator, isNullishOutput } from "../path/index.js"; export function normalizeFileToken(rawValue: string): string { return rawValue .trim() - .replaceAll("@", "") - .replaceAll("/", "-") - .replaceAll("\\", "-") - .replaceAll(" ", "-") - .replaceAll(/[^\w.-]/g, "-") + .replaceAll(/[^\w.-]+/g, "-") .replaceAll(/-+/g, "-") .replaceAll(/^[.-]+|[.-]+$/g, ""); } @@ -23,35 +20,9 @@ async function readTextFile(filePath: string): Promise { } } -function extractTomlValue(content: string, sectionName: string, key: string): string | undefined { - const lines = content.split("\n"); - let isInSection = false; - - for (const rawLine of lines) { - const trimmedLine = rawLine.split("#")[0]?.trim() ?? ""; - if (trimmedLine === "") - continue; - - if (trimmedLine.startsWith("[") && trimmedLine.endsWith("]")) { - isInSection = trimmedLine === `[${sectionName}]`; - continue; - } - - if (!isInSection) - continue; - - const match = trimmedLine.match(new RegExp(String.raw`^${key}\s*=\s*["'](.+)["']\s*$`)); - if (match) - return match[1]; - } - - return undefined; -} - - -export async function getProjectMetadata(rootDirectory: string): Promise<{ name: string; version?: string }> { +export async function getProjectMetadata(input: string): Promise<{ name: string; version?: string }> { // Node.js (package.json) - const packageJsonContent = await readTextFile(join(rootDirectory, "package.json")); + const packageJsonContent = await readTextFile(join(input, "package.json")); if (packageJsonContent) try { const packageJson = JSON.parse(packageJsonContent) as { name?: string; version?: string }; @@ -65,7 +36,7 @@ export async function getProjectMetadata(rootDirectory: string): Promise<{ name: } catch {} // C++ Modern (vcpkg.json) - const vcpkgContent = await readTextFile(join(rootDirectory, "vcpkg.json")); + const vcpkgContent = await readTextFile(join(input, "vcpkg.json")); if (vcpkgContent) try { const vcpkg = JSON.parse(vcpkgContent) as { name?: string; version?: string }; @@ -78,13 +49,43 @@ export async function getProjectMetadata(rootDirectory: string): Promise<{ name: }; } catch {} + // Java/Kotlin (pom.xml) + const pomContent = await readTextFile(join(input, "pom.xml")); + if (pomContent) { + const projectSection = pomContent + .replace(//i, ""); + const artifactIdMatch = projectWithoutParent.match(/\s*([^\s<]+)\s*<\/artifactid>/i); + if (artifactIdMatch) { + const normalizedName = normalizeFileToken(artifactIdMatch[1]); + if (normalizedName) { + const directVersionMatch = projectSection + .replace(//i, "") + .match(/\s*([^\s$<][^\s<]*)\s*<\/version>/i); + const parentVersionMatch = projectSection.match( + /\s*([^\s$<][^\s<]*)\s*<\/version>[\S\s]*?<\/parent>/i + ); + const rawVersion = directVersionMatch?.[1] ?? parentVersionMatch?.[1]; + const normalizedVersion = rawVersion ? normalizeFileToken(rawVersion) : ""; + + return { + name: normalizedName, + ...(normalizedVersion && { version: normalizedVersion }) + }; + } + } + } + // Python (pyproject.toml) - const pyprojectContent = await readTextFile(join(rootDirectory, "pyproject.toml")); + const pyprojectContent = await readTextFile(join(input, "pyproject.toml")); if (pyprojectContent) { - const pythonName = extractTomlValue(pyprojectContent, "project", "name") ?? - extractTomlValue(pyprojectContent, "tool.poetry", "name"); - const pythonVersion = extractTomlValue(pyprojectContent, "project", "version") ?? - extractTomlValue(pyprojectContent, "tool.poetry", "version"); + const pythonName = pyprojectContent.match(/^\[project][^[]*?^name\s*=\s*["']([^\n\r"']+)["']/ms)?.[1] ?? + pyprojectContent.match(/^\[tool\.poetry][^[]*?^name\s*=\s*["']([^\n\r"']+)["']/ms)?.[1]; + const pythonVersion = pyprojectContent.match(/^\[project][^[]*?^version\s*=\s*["']([^\n\r"']+)["']/ms)?.[1] ?? + pyprojectContent.match(/^\[tool\.poetry][^[]*?^version\s*=\s*["']([^\n\r"']+)["']/ms)?.[1]; const normalizedName = pythonName ? normalizeFileToken(pythonName) : ""; const normalizedVersion = pythonVersion ? normalizeFileToken(pythonVersion) : ""; @@ -96,10 +97,10 @@ export async function getProjectMetadata(rootDirectory: string): Promise<{ name: } // Rust (Cargo.toml) - const cargoContent = await readTextFile(join(rootDirectory, "Cargo.toml")); + const cargoContent = await readTextFile(join(input, "Cargo.toml")); if (cargoContent) { - const rustName = extractTomlValue(cargoContent, "package", "name"); - const rustVersion = extractTomlValue(cargoContent, "package", "version"); + const rustName = cargoContent.match(/^\[package][^[]*?^name\s*=\s*["']([^\n\r"']+)["']/ms)?.[1]; + const rustVersion = cargoContent.match(/^\[package][^[]*?^version\s*=\s*["']([^\n\r"']+)["']/ms)?.[1]; const normalizedName = rustName ? normalizeFileToken(rustName) : ""; const normalizedVersion = rustVersion ? normalizeFileToken(rustVersion) : ""; @@ -111,7 +112,7 @@ export async function getProjectMetadata(rootDirectory: string): Promise<{ name: } // Go (go.mod) - const goModContent = await readTextFile(join(rootDirectory, "go.mod")); + const goModContent = await readTextFile(join(input, "go.mod")); if (goModContent) { const match = goModContent.match(/^module\s+(.+)$/m); @@ -128,7 +129,7 @@ export async function getProjectMetadata(rootDirectory: string): Promise<{ name: } // C++ Legacy/Standard (CMakeLists.txt) - const cmakeContent = await readTextFile(join(rootDirectory, "CMakeLists.txt")); + const cmakeContent = await readTextFile(join(input, "CMakeLists.txt")); if (cmakeContent) { const nameMatch = cmakeContent.match(/project\s*\(\s*([\w.-]+)/i); const versionMatch = cmakeContent.match(/version\s+([\d.]+)/i); @@ -144,7 +145,7 @@ export async function getProjectMetadata(rootDirectory: string): Promise<{ name: } return { - name: normalizeFileToken(basename(rootDirectory)) || "project" + name: normalizeFileToken(basename(input)) || "project" }; } @@ -179,32 +180,37 @@ async function resolveUniquePath(filePath: string, overwrite: boolean): Promise< } } - export async function resolveOutputPath( outputValue: string | undefined, - rootDirectory: string, + input: string, + projectMetadata: { name: string; version?: string }, overwrite: boolean, format: "json" | "md" ): Promise { - const projectMeta = await getProjectMetadata(rootDirectory); - const baseFileName = projectMeta.version ? - `${projectMeta.name}-${projectMeta.version}.${format}` : - `${projectMeta.name}.${format}`; + const baseFileName = projectMetadata.version ? + `${projectMetadata.name}-${projectMetadata.version}.${format}` : + `${projectMetadata.name}.${format}`; if (!outputValue) - return await resolveUniquePath(join(rootDirectory, baseFileName), overwrite); + return await resolveUniquePath(join(input, baseFileName), overwrite); - if (outputValue === "/dev/null" || outputValue === "nul") + if (outputValue === "-") + return "-"; + + if (isNullishOutput(outputValue)) return outputValue; - const hasTrailingSeparator = /[/\\]+$/.test(outputValue); + const hasTrailingSep = hasTrailingSeparator(outputValue); const outputStats = await tryStat(outputValue); - if (hasTrailingSeparator || outputStats?.isDirectory()) { + if (hasTrailingSep || outputStats?.isDirectory()) { const filePath = join(outputValue, baseFileName); return await resolveUniquePath(filePath, overwrite); } - return await resolveUniquePath(outputValue, overwrite); + const hasRealExtension = /\.[A-Za-z]+$/.test(outputValue); + const filePath = hasRealExtension ? outputValue : `${outputValue}.${format}`; + + return await resolveUniquePath(filePath, overwrite); } diff --git a/src/core/ignoreMatcher.ts b/src/core/ignoreMatcher.ts index 970d573..a9cc858 100644 --- a/src/core/ignoreMatcher.ts +++ b/src/core/ignoreMatcher.ts @@ -1,14 +1,20 @@ -import { constants } from "node:fs"; -import { access, readFile } from "node:fs/promises"; -import { join, relative, sep } from "node:path"; +import { readFile } from "node:fs/promises"; +import { join, relative } from "node:path"; import ignore from "ignore"; +import { + stripLeadingDotSlash, + toDisplayPath, + toIgnoreSafePath, + toPosixPath +} from "../path/index.js"; +import { normalizeExcludePattern } from "../pattern/index.js"; import type { Logger } from "../infra/index.js"; type IgnoreMatcherOptions = { - rootDirectory: string; + input: string; excludePatterns: string[]; - useGitignore: boolean; + gitignore: boolean; logger?: Logger; }; @@ -25,15 +31,6 @@ const defaultIgnorePatterns = [ "pnpm-lock.yaml" ]; -function normalizeRelativePath(relativePath: string): string { - const normalized = relativePath.split(sep).join("/"); - - if (normalized.startsWith("./")) - return normalized.slice(2); - - return normalized; -} - function convertGitignorePattern(pattern: string, relativeDirectory: string): string | undefined { const trimmed = pattern.trim(); if (trimmed === "" || trimmed.startsWith("#")) @@ -43,7 +40,7 @@ function convertGitignorePattern(pattern: string, relativeDirectory: string): st const rawPattern = isEscaped ? trimmed.slice(1) : trimmed; const isNegated = !isEscaped && rawPattern.startsWith("!"); const patternBody = isNegated ? rawPattern.slice(1) : rawPattern; - const normalizedDirectory = normalizeRelativePath(relativeDirectory); + const normalizedDirectory = stripLeadingDotSlash(toPosixPath(relativeDirectory)); const prefix = normalizedDirectory === "" ? "" : `${normalizedDirectory}/`; if (patternBody === "") @@ -63,44 +60,44 @@ function convertGitignorePattern(pattern: string, relativeDirectory: string): st return isNegated ? `!${convertedPattern}` : convertedPattern; } -function normalizeExcludePattern(pattern: string): string { - const normalized = pattern.trim(); - const isNegated = normalized.startsWith("!"); - const body = isNegated ? normalized.slice(1) : normalized; - const trimmedTrailingSlash = body.endsWith("/") ? body.slice(0, -1) : body; - - if (body.startsWith("/")) - return isNegated ? `!${body.slice(1)}` : body.slice(1); - - const result = trimmedTrailingSlash.includes("/") ? body : `**/${body}`; - - return isNegated ? `!${result}` : result; -} - export class IgnoreMatcher { - #rootDirectory: string; - #useGitignore: boolean; + #input: string; + #gitignore: boolean; #logger?: Logger; #processedGitignore = new Set(); #matcher = ignore(); constructor(options: IgnoreMatcherOptions) { - this.#rootDirectory = options.rootDirectory; - this.#useGitignore = options.useGitignore; + this.#input = options.input; + this.#gitignore = options.gitignore; this.#logger = options.logger; - this.#matcher.add(defaultIgnorePatterns.map(pattern => normalizeExcludePattern(pattern))); - this.#matcher.add(options.excludePatterns.map(pattern => normalizeExcludePattern(pattern))); + const defaultPatterns = defaultIgnorePatterns + .map(pattern => normalizeExcludePattern(pattern, this.#input)) + .filter((p): p is string => p !== null); + this.#matcher.add(defaultPatterns); + + const userPatterns = options.excludePatterns + .map(pattern => normalizeExcludePattern(pattern, this.#input)) + .filter((p): p is string => p !== null); + this.#matcher.add(userPatterns); } public ignores(relativePath: string): boolean { - const normalized = normalizeRelativePath(relativePath); + const safe = toIgnoreSafePath(relativePath, this.#input); - return normalized !== "" && this.#matcher.ignores(normalized); + return this.ignoresSafePath(safe); + } + + public ignoresSafePath(safePath: string | null): boolean { + if (safePath === null || safePath === "") + return false; + + return this.#matcher.ignores(safePath); } public async addGitignoreForDirectory(directoryPath: string): Promise { - if (!this.#useGitignore) + if (!this.#gitignore) return; if (this.#processedGitignore.has(directoryPath)) @@ -109,15 +106,17 @@ export class IgnoreMatcher { this.#processedGitignore.add(directoryPath); const gitignorePath = join(directoryPath, ".gitignore"); - const relativeDirectory = relative(this.#rootDirectory, directoryPath); + const relativeDirectory = relative(this.#input, directoryPath); + let content: string; try { - await access(gitignorePath, constants.F_OK); - } catch { + content = await readFile(gitignorePath, "utf8"); + } catch (error) { + if ((error as { code?: string }).code !== "ENOENT") + this.#logger?.debug(`Failed to read .gitignore at ${gitignorePath}: ${String(error)}`); + return; } - - const content = await readFile(gitignorePath, "utf8"); const patterns = content .split("\n") .map(line => convertGitignorePattern(line, relativeDirectory)) @@ -125,7 +124,7 @@ export class IgnoreMatcher { if (patterns.length > 0) { this.#matcher.add(patterns); - this.#logger?.debug(`Loaded ${patterns.length} patterns from ${normalizeRelativePath(relativeDirectory) || "."}/.gitignore`); + this.#logger?.debug(`Loaded ${patterns.length} patterns from ${toDisplayPath(relativeDirectory, this.#input)}/.gitignore`); } } } diff --git a/src/core/renderOutput.ts b/src/core/renderOutput.ts index fd4475f..7c13d64 100644 --- a/src/core/renderOutput.ts +++ b/src/core/renderOutput.ts @@ -68,42 +68,47 @@ async function writeMarkdownContent( await writer.write("\n"); } -function *iterateFileNodes(node: FileNode): Generator { - if (node.type === "file") - yield node; - - if (node.children) - for (const child of node.children) - yield* iterateFileNodes(child); -} - -function filterSkippedNodes(node: FileNode): FileNode | undefined { +function filterAndCollectFileNodes(node: FileNode): { + filtered: FileNode | undefined; + fileNodes: FileNode[]; +} { if (node.skipReason) - return undefined; + return { filtered: undefined, fileNodes: [] }; - if (!node.children || node.children.length === 0) - return { ...node }; + if (node.type === "file") + return { filtered: { ...node }, fileNodes: [ node ] }; + + const allFileNodes: FileNode[] = []; + const filteredChildren: FileNode[] = []; + for (const child of node.children ?? []) { + const { filtered, fileNodes } = filterAndCollectFileNodes(child); + allFileNodes.push(...fileNodes); + if (filtered) + filteredChildren.push(filtered); + } - const children = node.children - .map(child => filterSkippedNodes(child)) - .filter((child): child is FileNode => child !== undefined); + if (filteredChildren.length === 0) + return { filtered: undefined, fileNodes: allFileNodes }; - return { ...node, children }; + return { + filtered: { ...node, children: filteredChildren }, + fileNodes: allFileNodes + }; } async function writeMarkdown(result: ScanResult, config: FlnConfig): Promise { - const writer = await createOutputWriter(config.outputFile, config.maximumTotalSizeBytes); - const outputRoot = filterSkippedNodes(result.root); - - if (!outputRoot) - throw new Error("Root directory was skipped."); + const writer = await createOutputWriter(config.output, config.maxTotalSize); + const { filtered: outputRoot, fileNodes } = filterAndCollectFileNodes(result.root); + const effectiveRoot = outputRoot ?? { ...result.root, children: [] }; try { - await writer.writeLine(``); - await writer.writeLine(""); + if (config.output !== "-") { + await writer.writeLine(``); + await writer.writeLine(""); + } await writer.writeLine(`# Codebase Snapshot: ${result.projectName}`); await writer.writeLine(""); - await writer.writeLine(`Generated: ${config.generatedDate ?? formatDateTime()} `); + await writer.writeLine(`Generated: ${config.date ?? formatDateTime()} `); await writer.writeLine(`Files: ${result.stats.files} | Directories: ${result.stats.directories}`); await writer.writeLine(""); await writer.writeLine("---"); @@ -117,15 +122,15 @@ async function writeMarkdown(result: ScanResult, config: FlnConfig): Promise 0) + await writeMarkdownFiles(fileNodes, writer, config); if (config.footer) { await writer.writeLine(""); @@ -144,20 +149,18 @@ async function writeMarkdown(result: ScanResult, config: FlnConfig): Promise>, renderConfig: FlnConfig ): Promise { await outputWriter.writeLine("## Source Files"); await outputWriter.writeLine(""); - const fileNodes = Array.from(iterateFileNodes(rootNode)); - for (let i = 0; i < fileNodes.length; i++) { const node = fileNodes[i]; const language = getLanguageFromFilename(node.name); const isLastFile = i === fileNodes.length - 1; - const filePath = join(renderConfig.rootDirectory, node.path); + const filePath = join(renderConfig.input, node.path); let fenceLength = 3; if (!node.isBinary) @@ -189,41 +192,41 @@ async function writeMarkdownFiles( } async function writeJson(result: ScanResult, config: FlnConfig): Promise { - const writer = await createOutputWriter(config.outputFile, config.maximumTotalSizeBytes); - const outputRoot = filterSkippedNodes(result.root); - - if (!outputRoot) - throw new Error("Root directory was skipped."); + const writer = await createOutputWriter(config.output, config.maxTotalSize); + const { filtered: outputRoot, fileNodes } = filterAndCollectFileNodes(result.root); + const effectiveRoot = outputRoot ?? { ...result.root, children: [] }; try { await writer.write("{"); await writer.write(`"version":${JSON.stringify(VERSION)}`); - await writer.write(`,"generated":${JSON.stringify(config.generatedDate ?? formatDateTime())}`); + await writer.write(`,"generated":${JSON.stringify(config.date ?? formatDateTime())}`); await writer.write(`,"projectName":${JSON.stringify(result.projectName)}`); - await writer.write(`,"rootDirectory":${JSON.stringify(config.rootDirectory)}`); - await writer.write(`,"stats":${JSON.stringify(result.stats)}`); + // TODO(major): remove rootDirectory from JSON output + await writer.write(`,"input":${JSON.stringify(config.input)}`); + await writer.write(`,"rootDirectory":${JSON.stringify(config.input)}`); + const { outputSizeBytes: _, outputTokenCount: __, ...statsForJson } = result.stats; + await writer.write(`,"stats":${JSON.stringify(statsForJson)}`); await writer.write(`,"options":${JSON.stringify({ includeTree: config.includeTree, includeContents: config.includeContents, format: config.format, - maximumFileSizeBytes: config.maximumFileSizeBytes, - maximumTotalSizeBytes: config.maximumTotalSizeBytes, + maxFileSize: config.maxFileSize, + maxTotalSize: config.maxTotalSize, includeHidden: config.includeHidden, - useGitignore: config.useGitignore, + gitignore: config.gitignore, excludePatterns: config.excludePatterns, includePatterns: config.includePatterns, followSymlinks: config.followSymlinks, banner: config.banner, footer: config.footer })}`); - await writer.write(`,"tree":${JSON.stringify(outputRoot)}`); - await writer.write(`,"stats":${JSON.stringify(result.stats)}`); + await writer.write(`,"tree":${JSON.stringify(effectiveRoot)}`); if (config.includeContents) { await writer.write(",\"files\":["); let isFirst = true; - for (const node of iterateFileNodes(outputRoot)) { + for (const node of fileNodes) { if (!isFirst) await writer.write(","); @@ -234,14 +237,11 @@ async function writeJson(result: ScanResult, config: FlnConfig): Promise { await writer.write(`,"language":${JSON.stringify(getLanguageFromFilename(node.name))}`); await writer.write(`,"isBinary":${JSON.stringify(Boolean(node.isBinary))}`); - if (node.skipReason) - await writer.write(`,"skipReason":${JSON.stringify(node.skipReason)}`); - - if (node.isBinary || node.skipReason) + if (node.isBinary) await writer.write(",\"content\":null"); else try { - const filePath = join(config.rootDirectory, node.path); + const filePath = join(config.input, node.path); const content = await readFile(filePath, "utf8"); await writer.write(`,"content":${JSON.stringify(content)}`); diff --git a/src/core/scanTree.ts b/src/core/scanTree.ts index fe7fc04..1e20feb 100644 --- a/src/core/scanTree.ts +++ b/src/core/scanTree.ts @@ -10,6 +10,9 @@ import { cpus } from "node:os"; import { relative, sep } from "node:path"; import type { Dirent } from "node:fs"; import ignore from "ignore"; +import pLimit from "p-limit"; +import { toCanonicalRelative, toIgnoreSafePath, toPosixPath } from "../path/index.js"; +import { normalizeIncludePattern } from "../pattern/index.js"; import type { Logger } from "../infra/index.js"; import { IgnoreMatcher } from "./ignoreMatcher.js"; import type { @@ -21,13 +24,6 @@ import type { } from "./types.js"; -function normalizePathSegment(pathSegment: string): string { - if (sep === "/") - return pathSegment; - - return pathSegment.split(sep).join("/"); -} - function getFileScore(fileName: string): number { const lowerName = fileName.toLowerCase(); @@ -44,6 +40,7 @@ function getFileScore(fileName: string): number { lowerName === "makefile" || lowerName === "dockerfile" || lowerName === "vcpkg.json" || + lowerName === "pom.xml" || lowerName.startsWith(".env") || lowerName.includes(".config.") || lowerName.startsWith(".prettier") || @@ -93,38 +90,23 @@ function getFileScore(fileName: string): number { return 10; } -async function isBinaryFile(filePath: string, fileSize: number): Promise { +async function inspectFile(filePath: string, fileSize: number): Promise<{ + isGenerated: boolean; + isBinary: boolean; +}> { if (fileSize === 0) - return false; + return { isGenerated: false, isBinary: false }; const handle = await open(filePath, "r"); try { const buffer = Buffer.alloc(Math.min(512, fileSize)); const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + const header = buffer.toString("utf8", 0, Math.min(100, bytesRead)); - for (let index = 0; index < bytesRead; index++) - if (buffer[index] === 0) - return true; + const isGenerated = header.includes("