From e941ae676c54782f8b14edd566b4c2a40a0f24a7 Mon Sep 17 00:00:00 2001 From: Eugene Nesvetaev Date: Thu, 12 Feb 2026 12:30:53 +0400 Subject: [PATCH 1/9] feat(cli): Add pancake emoji --- src/cli/help.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/help.ts b/src/cli/help.ts index 1a464b6..c5400cb 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -7,7 +7,7 @@ export function formatHelpMessage(useAnsi: boolean): string { const cyan = (text: string) => applyColor(text, colors.info, useAnsi); const green = (text: string) => applyColor(text, colors.success, useAnsi); - 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]")} From f69bbce101124ad42b54263f1fb2d903ca0c4ffc Mon Sep 17 00:00:00 2001 From: Eugene Nesvetaev Date: Thu, 26 Feb 2026 13:42:33 +0400 Subject: [PATCH 2/9] refactor(path): Extract Path Normalization Utilities --- src/infra/gitDiff.ts | 44 ++++++++++++++++++++++++++++++++++++++++++ src/path/canonical.ts | 18 +++++++++++++++++ src/path/ignoreSafe.ts | 20 +++++++++++++++++++ src/path/index.ts | 6 ++++++ src/path/normalize.ts | 13 +++++++++++++ src/path/output.ts | 15 ++++++++++++++ src/path/posix.ts | 6 ++++++ src/path/resolve.ts | 9 +++++++++ 8 files changed, 131 insertions(+) create mode 100644 src/infra/gitDiff.ts create mode 100644 src/path/canonical.ts create mode 100644 src/path/ignoreSafe.ts create mode 100644 src/path/index.ts create mode 100644 src/path/normalize.ts create mode 100644 src/path/output.ts create mode 100644 src/path/posix.ts create mode 100644 src/path/resolve.ts diff --git a/src/infra/gitDiff.ts b/src/infra/gitDiff.ts new file mode 100644 index 0000000..b738f98 --- /dev/null +++ b/src/infra/gitDiff.ts @@ -0,0 +1,44 @@ +import { spawnSync } from "node:child_process"; +import { join, relative } from "node:path"; +import { toPosixPath } from "../path/index.js"; + + +export function getChangedFilesSince(ref: string, cwd: string): string[] { + const result = spawnSync("git", [ "diff", "--name-only", ref ], { + cwd, + encoding: "utf8", + stdio: [ "pipe", "pipe", "pipe" ] + }); + + if (result.error) + throw new Error(`fln: git not found (${result.error.message}). Install git and ensure it is in PATH.`); + + if (result.status !== 0) { + const stderr = (result.stderr ?? "").trim(); + + throw new Error( + stderr ? + `fln: git diff failed: ${stderr}` : + `fln: git diff failed (exit ${result.status}). Not a git repository or invalid ref: ${ref}` + ); + } + + const output = (result.stdout ?? "").trim(); + if (!output) + return []; + + return output.split("\n").filter(Boolean); +} + +export function filterPathsUnderBase(gitPaths: string[], cwd: string, inputBase: string): string[] { + return gitPaths + .map(gitPath => { + const absolutePath = join(cwd, gitPath); + const relativeToInput = relative(inputBase, absolutePath); + if (relativeToInput.startsWith("..") || relativeToInput === "") + return null; + + return toPosixPath(relativeToInput); + }) + .filter((path): path is string => path !== null && path !== ""); +} diff --git a/src/path/canonical.ts b/src/path/canonical.ts new file mode 100644 index 0000000..5e9515b --- /dev/null +++ b/src/path/canonical.ts @@ -0,0 +1,18 @@ +import { relative, resolve } from "node:path"; +import { toPosixPath } from "./posix.js"; + + +export function toCanonicalRelative(path: string, base: string): string | null { + if (!base || base === "") + return null; + + const resolved = resolve(base, path); + const relativePath = relative(base, resolved); + const withForwardSlash = toPosixPath(relativePath); + const withoutLeadingDot = withForwardSlash.startsWith("./") ? withForwardSlash.slice(2) : withForwardSlash; + + if (withoutLeadingDot.startsWith("../") || withoutLeadingDot === "..") + return null; + + return withoutLeadingDot === "" ? "" : withoutLeadingDot; +} diff --git a/src/path/ignoreSafe.ts b/src/path/ignoreSafe.ts new file mode 100644 index 0000000..a8c6804 --- /dev/null +++ b/src/path/ignoreSafe.ts @@ -0,0 +1,20 @@ +import ignore from "ignore"; +import { toCanonicalRelative } from "./canonical.js"; +import { toPosixPath } from "./posix.js"; + + +export function toIgnoreSafePath(relativePath: string, base: string): string | null { + const hasTrailingSlash = relativePath.endsWith("/") && relativePath !== "/"; + const pathWithoutSlash = hasTrailingSlash ? relativePath.slice(0, -1) : relativePath; + + const canonical = toCanonicalRelative(pathWithoutSlash, base); + if (canonical === null) + return null; + + const posix = toPosixPath(canonical); + const result = hasTrailingSlash ? `${posix}/` : posix; + if (!ignore.isPathValid(result)) + return null; + + return result; +} diff --git a/src/path/index.ts b/src/path/index.ts new file mode 100644 index 0000000..02ad5fc --- /dev/null +++ b/src/path/index.ts @@ -0,0 +1,6 @@ +export { toCanonicalRelative } from "./canonical.js"; +export { toIgnoreSafePath } from "./ignoreSafe.js"; +export { stripLeadingDotSlash, toDisplayPath } from "./normalize.js"; +export { getNullishOutput, hasTrailingSeparator, isNullishOutput, isStdoutOutput } from "./output.js"; +export { toPosixPath } from "./posix.js"; +export { resolveFromBase } from "./resolve.js"; diff --git a/src/path/normalize.ts b/src/path/normalize.ts new file mode 100644 index 0000000..f465e6c --- /dev/null +++ b/src/path/normalize.ts @@ -0,0 +1,13 @@ +import { toIgnoreSafePath } from "./ignoreSafe.js"; +import { toPosixPath } from "./posix.js"; + + +export function stripLeadingDotSlash(path: string): string { + return path.startsWith("./") ? path.slice(2) : path; +} + +export function toDisplayPath(relativePath: string, base: string): string { + const safe = toIgnoreSafePath(relativePath, base); + + return safe ?? (stripLeadingDotSlash(toPosixPath(relativePath)) || "."); +} diff --git a/src/path/output.ts b/src/path/output.ts new file mode 100644 index 0000000..1df31ac --- /dev/null +++ b/src/path/output.ts @@ -0,0 +1,15 @@ +export function hasTrailingSeparator(path: string): boolean { + return /[/\\]+$/.test(path); +} + +export function getNullishOutput(): string { + return process.platform === "win32" ? "nul" : "/dev/null"; +} + +export function isNullishOutput(path: string): boolean { + return path === "/dev/null" || path === "nul"; +} + +export function isStdoutOutput(path: string): boolean { + return path === "-"; +} diff --git a/src/path/posix.ts b/src/path/posix.ts new file mode 100644 index 0000000..82f50d3 --- /dev/null +++ b/src/path/posix.ts @@ -0,0 +1,6 @@ +import { sep } from "node:path"; + + +export function toPosixPath(path: string): string { + return path.split(sep).join("/").replaceAll("\\", "/"); +} diff --git a/src/path/resolve.ts b/src/path/resolve.ts new file mode 100644 index 0000000..d0958fc --- /dev/null +++ b/src/path/resolve.ts @@ -0,0 +1,9 @@ +import { isAbsolute, resolve } from "node:path"; + + +export function resolveFromBase(path: string | null | undefined, base: string): string { + if (path === null || path === undefined) + return base; + + return isAbsolute(path) ? path : resolve(base, path); +} From 86991096e127ae08414bf2c6a2a68f8af6439091 Mon Sep 17 00:00:00 2001 From: Eugene Nesvetaev Date: Thu, 26 Feb 2026 13:42:52 +0400 Subject: [PATCH 3/9] fix(pattern): Normalize Include and Exclude Rules Safely --- src/config/utils.ts | 118 +++++++++++++++-------------- src/core/ignoreMatcher.ts | 87 +++++++++++---------- src/core/scanTree.ts | 154 ++++++++++++++++---------------------- src/pattern/index.ts | 1 + src/pattern/normalize.ts | 61 +++++++++++++++ 5 files changed, 231 insertions(+), 190 deletions(-) create mode 100644 src/pattern/index.ts create mode 100644 src/pattern/normalize.ts 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/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("`); - 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/size.ts b/src/core/size.ts index ab22b5c..ca1d4a2 100644 --- a/src/core/size.ts +++ b/src/core/size.ts @@ -1,39 +1,25 @@ -const kibibyte = 1024; -const mebibyte = kibibyte * 1024; -const gibibyte = mebibyte * 1024; +import bytes from "bytes"; + export function parseByteSize(input: string): number { - const normalizedInput = input.trim().toLowerCase(); - const match = normalizedInput.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/); + const result = bytes.parse(input.trim()); - if (!match) + if (result === null || result < 0) throw new Error(`Invalid size: "${input}"`); - const value = Number(match[1]); - const unit = match[2] ?? "b"; - const multiplier = - unit === "kb" ? kibibyte : - unit === "mb" ? mebibyte : - unit === "gb" ? gibibyte : - 1; - - return Math.floor(value * multiplier); + return Math.floor(result); } export function formatByteSize(sizeBytes: number): string { - if (sizeBytes >= gibibyte) - return `${(sizeBytes / gibibyte).toFixed(2)} GB`; - if (sizeBytes >= mebibyte) - return `${(sizeBytes / mebibyte).toFixed(2)} MB`; - if (sizeBytes >= kibibyte) - return `${(sizeBytes / kibibyte).toFixed(2)} KB`; + const result = bytes(sizeBytes, { unitSeparator: " " }); - return `${sizeBytes} B`; + return result ?? `${sizeBytes} B`; } export function formatTokenCount(count: number): string { if (count >= 1_000_000) return `β‰ˆ ${(count / 1_000_000).toFixed(1)}M`; + if (count >= 1000) return `β‰ˆ ${(count / 1000).toFixed(1)}K`; diff --git a/src/core/types.ts b/src/core/types.ts index 240ed62..4c5f5c8 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -38,24 +38,24 @@ export type ProgressCallback = (current: number, total: number) => void; export type ScanOptions = { projectName: string; - rootDirectory: string; + input: string; excludePatterns: string[]; includePatterns: string[]; excludedPaths: string[]; includeHidden: boolean; - useGitignore: boolean; - maximumFileSizeBytes: number; - maximumTotalSizeBytes: number; + gitignore: boolean; + maxFileSize: number; + maxTotalSize: number; followSymlinks: boolean; onProgress?: ProgressCallback; }; export type RenderOptions = { - outputFile: string; + output: string; format: OutputFormat; includeTree: boolean; includeContents: boolean; - useAnsi: boolean; + ansi: boolean; banner?: string; footer?: string; }; diff --git a/src/infra/datetime.ts b/src/infra/datetime.ts index c501ff1..d803c81 100644 --- a/src/infra/datetime.ts +++ b/src/infra/datetime.ts @@ -2,13 +2,15 @@ const generatedDateRegex = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/; export function formatDateTime(): string { const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - const hours = String(now.getHours()).padStart(2, "0"); - const minutes = String(now.getMinutes()).padStart(2, "0"); - return `${year}-${month}-${day} ${hours}:${minutes}`; + return `${[ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, "0"), + String(now.getDate()).padStart(2, "0") + ].join("-")} ${[ + String(now.getHours()).padStart(2, "0"), + String(now.getMinutes()).padStart(2, "0") + ].join(":")}`; } export function parseGeneratedDate(value: string): string { diff --git a/src/infra/logger.ts b/src/infra/logger.ts index b189c2c..fd85f77 100644 --- a/src/infra/logger.ts +++ b/src/infra/logger.ts @@ -1,14 +1,10 @@ +import pc from "picocolors"; import type { LogLevel } from "../core/index.js"; -import { - ansi, - getTerminalInfo, - renderBox, - symbols -} from "./terminal.js"; +import { getTerminalInfo, renderBox, symbols } from "./terminal.js"; type LoggerOptions = { - useAnsi: boolean; + ansi: boolean; logLevel: LogLevel; }; @@ -25,17 +21,13 @@ export type Logger = { }; export function createLogger(options: LoggerOptions): Logger { - const { useAnsi, logLevel } = options; + const { ansi: useAnsi, logLevel } = options; const { width } = getTerminalInfo(); const isSilent = logLevel === "silent"; const isVerbose = logLevel === "verbose" || logLevel === "debug"; - const formatMessage = (symbol: string, color: string, message: string): string => { - if (!useAnsi) - return `${symbol} ${message}`; - - return ` ${color}${symbol}${ansi.reset} ${message}`; - }; + const formatMessage = (symbolOrColored: string, message: string): string => + `${useAnsi ? " " : ""}${symbolOrColored} ${message}`; const writeInfo = (formatted: string) => { if (!isSilent) @@ -46,28 +38,28 @@ export function createLogger(options: LoggerOptions): Logger { info: (message: string) => { if (!isSilent) if (useAnsi) - console.info(` ${ansi.dim}${message}${ansi.reset}`); + console.info(` ${pc.dim(message)}`); else console.info(message); }, success: (message: string) => { - writeInfo(formatMessage(symbols.check, ansi.green, message)); + writeInfo(formatMessage(useAnsi ? pc.green(symbols.check) : symbols.check, message)); }, warn: (message: string) => { if (!isSilent) - console.warn(formatMessage(symbols.warning, ansi.yellow, message)); + console.warn(formatMessage(useAnsi ? pc.yellow(symbols.warning) : symbols.warning, message)); }, error: (message: string) => { - console.error(formatMessage(symbols.cross, ansi.red, message)); + console.error(formatMessage(useAnsi ? pc.red(symbols.cross) : symbols.cross, message)); }, debug: (message: string) => { if (!isSilent && isVerbose) if (useAnsi) - console.info(` ${ansi.dim}${symbols.info} ${message}${ansi.reset}`); + console.info(` ${pc.dim(`${symbols.info} ${message}`)}`); else console.info(`${symbols.info} ${message}`); }, @@ -80,9 +72,9 @@ export function createLogger(options: LoggerOptions): Logger { const boxWidth = Math.min(width - 4, 60); const paddedText = text.padEnd(boxWidth - 4); console.info(""); - console.info(`${ansi.dim}${symbols.boxTopLeft}${symbols.boxHorizontal.repeat(boxWidth - 2)}${symbols.boxTopRight}${ansi.reset}`); - console.info(`${ansi.dim}${symbols.boxVertical}${ansi.reset}${ansi.bold}${paddedText}${ansi.reset} ${ansi.dim}${symbols.boxVertical}${ansi.reset}`); - console.info(`${ansi.dim}${symbols.boxBottomLeft}${symbols.boxHorizontal.repeat(boxWidth - 2)}${symbols.boxBottomRight}${ansi.reset}`); + console.info(pc.dim(`${symbols.boxTopLeft}${symbols.boxHorizontal.repeat(boxWidth - 2)}${symbols.boxTopRight}`)); + console.info(`${pc.dim(symbols.boxVertical)}${pc.bold(paddedText)} ${pc.dim(symbols.boxVertical)}`); + console.info(pc.dim(`${symbols.boxBottomLeft}${symbols.boxHorizontal.repeat(boxWidth - 2)}${symbols.boxBottomRight}`)); console.info(""); } else { console.info(""); @@ -97,7 +89,7 @@ export function createLogger(options: LoggerOptions): Logger { if (useAnsi) { console.info(""); - console.info(`${ansi.bold}${title}${ansi.reset}`); + console.info(pc.bold(title)); console.info(""); } else { console.info(""); @@ -109,7 +101,7 @@ export function createLogger(options: LoggerOptions): Logger { for (const [ key, value ] of Object.entries(items)) { const paddedKey = key.padEnd(maxKeyLength); if (useAnsi) - console.info(` ${ansi.dim}${paddedKey}${ansi.reset} ${value}`); + console.info(` ${pc.dim(paddedKey)} ${value}`); else console.info(` ${paddedKey} ${value}`); } @@ -124,7 +116,7 @@ export function createLogger(options: LoggerOptions): Logger { title, content, width: boxWidth, - useAnsi, + ansi: useAnsi, showDivider }); diff --git a/src/infra/outputWriter.ts b/src/infra/outputWriter.ts index 47122be..7f27a1b 100644 --- a/src/infra/outputWriter.ts +++ b/src/infra/outputWriter.ts @@ -13,14 +13,34 @@ type OutputWriter = { }; export async function createOutputWriter( - outputFile: string, + output: string, maxSizeBytes = 0 ): Promise { - const outputDirectory = dirname(outputFile); + if (output === "-") { + let bytesWritten = 0; + let totalTokenCount = 0; + const write = async (text: string): Promise => { // eslint-disable-line @typescript-eslint/require-await + const textBytes = Buffer.byteLength(text); + if (maxSizeBytes > 0 && bytesWritten + textBytes > maxSizeBytes) + throw new Error(`Output size would exceed maximum of ${maxSizeBytes} bytes`); + bytesWritten += textBytes; + totalTokenCount += countTokens(text); + process.stdout.write(text); + }; + + return { + write, + writeLine: (text: string) => write(`${text}\n`), + getStats: () => ({ sizeBytes: bytesWritten, tokenCount: totalTokenCount }), + close: async () => ({ sizeBytes: bytesWritten, tokenCount: totalTokenCount })// eslint-disable-line @typescript-eslint/require-await + }; + } + + const outputDirectory = dirname(output); if (outputDirectory !== ".") await mkdir(outputDirectory, { recursive: true }); - const stream = createWriteStream(outputFile, { encoding: "utf8" }); + const stream = createWriteStream(output, { encoding: "utf8" }); let bytesWritten = 0; let totalTokenCount = 0; diff --git a/src/infra/terminal.ts b/src/infra/terminal.ts index 1df4ef1..54c6565 100644 --- a/src/infra/terminal.ts +++ b/src/infra/terminal.ts @@ -1,3 +1,7 @@ +import pc from "picocolors"; +import stripAnsi from "strip-ansi"; + + type TerminalInfo = { width: number; supportsAnsi: boolean; @@ -18,46 +22,14 @@ export function shouldUseColors(): boolean { return isTTY() && !process.env.NO_COLOR && process.env.TERM !== "dumb"; } -export const ansi = { +export const cursor = { cursorHide: "\x1B[?25l", cursorShow: "\x1B[?25h", cursorUp: (lines: number) => `\x1B[${lines}A`, cursorDown: (lines: number) => `\x1B[${lines}B`, cursorTo: (column: number) => `\x1B[${column}G`, clearLine: "\x1B[2K", - clearLineRight: "\x1B[0K", - - reset: "\x1B[0m", - bold: "\x1B[1m", - dim: "\x1B[2m", - - black: "\x1B[30m", - red: "\x1B[31m", - green: "\x1B[32m", - yellow: "\x1B[33m", - blue: "\x1B[34m", - magenta: "\x1B[35m", - cyan: "\x1B[36m", - white: "\x1B[37m", - gray: "\x1B[90m", - - bgBlack: "\x1B[40m", - bgRed: "\x1B[41m", - bgGreen: "\x1B[42m", - bgYellow: "\x1B[43m", - bgBlue: "\x1B[44m", - bgMagenta: "\x1B[45m", - bgCyan: "\x1B[46m", - bgWhite: "\x1B[47m", - - brightBlack: "\x1B[90m", - brightRed: "\x1B[91m", - brightGreen: "\x1B[92m", - brightYellow: "\x1B[93m", - brightBlue: "\x1B[94m", - brightMagenta: "\x1B[95m", - brightCyan: "\x1B[96m", - brightWhite: "\x1B[97m" + clearLineRight: "\x1B[0K" }; export const symbols = { @@ -88,13 +60,13 @@ type ProgressBarOptions = { total: number; current: number; width: number; - useAnsi: boolean; + ansi: boolean; label?: string; suffix?: string; }; export function renderProgressBar(options: ProgressBarOptions): string { - const { total, current, width, useAnsi, label, suffix } = options; + const { total, current, width, ansi: useAnsi, label, suffix } = options; if (!useAnsi) { const percentage = total > 0 ? Math.round((current / total) * 100) : 0; @@ -107,14 +79,14 @@ export function renderProgressBar(options: ProgressBarOptions): string { const percentText = `${Math.round(percentage * 100)}%`; const prefixText = label ? `${label} ` : ""; - const suffixText = suffix ? ` ${ansi.dim}${suffix}${ansi.reset}` : ""; - const statsText = ` ${percentText} ${ansi.dim}${symbols.dot} ${current}/${total}${ansi.reset}`; + const suffixText = suffix ? ` ${pc.dim(suffix)}` : ""; + const statsText = ` ${percentText} ${pc.dim(`${symbols.dot} ${current}/${total}`)}`; const availableWidth = Math.max(10, width - prefixText.length - statsText.length - suffixText.length - 4); - const filledWidth = Math.floor(availableWidth * percentage); - const emptyWidth = availableWidth - filledWidth; + const filledWidth = Math.min(availableWidth, Math.max(0, Math.floor(availableWidth * percentage))); + const emptyWidth = Math.max(0, availableWidth - filledWidth); - const bar = `${ansi.cyan}${symbols.barFull.repeat(filledWidth)}${ansi.reset}${ansi.dim}${symbols.barEmpty.repeat(emptyWidth)}${ansi.reset}`; + const bar = `${pc.cyan(symbols.barFull.repeat(filledWidth))}${pc.dim(symbols.barEmpty.repeat(emptyWidth))}`; return `${prefixText}${bar}${statsText}${suffixText}`; } @@ -123,12 +95,12 @@ type BoxOptions = { title?: string; content: string[]; width?: number; - useAnsi: boolean; + ansi: boolean; showDivider?: boolean; }; export function renderBox(options: BoxOptions): string { - const { title, content, width = 50, useAnsi, showDivider = false } = options; + const { title, content, width = 50, ansi: useAnsi, showDivider = false } = options; if (!useAnsi) { const lines = []; @@ -147,16 +119,16 @@ export function renderBox(options: BoxOptions): string { const lines: string[] = []; const innerWidth = width - 4; - const topLine = `${ansi.dim}${symbols.boxTopLeft}${symbols.boxHorizontal.repeat(width - 2)}${symbols.boxTopRight}${ansi.reset}`; + const topLine = pc.dim(`${symbols.boxTopLeft}${symbols.boxHorizontal.repeat(width - 2)}${symbols.boxTopRight}`); lines.push(topLine); if (title) { - const titleWithAnsi = `${ansi.bold}${title}${ansi.reset}`; - lines.push(`${ansi.dim}${symbols.boxVertical}${ansi.reset} ${titleWithAnsi.padEnd(innerWidth + (titleWithAnsi.length - stripAnsi(titleWithAnsi).length), " ")} ${ansi.dim}${symbols.boxVertical}${ansi.reset}`); + const titleWithAnsi = pc.bold(title); + lines.push(`${pc.dim(symbols.boxVertical)} ${titleWithAnsi.padEnd(innerWidth + (titleWithAnsi.length - stripAnsi(titleWithAnsi).length), " ")} ${pc.dim(symbols.boxVertical)}`); } if (showDivider && title) { - const dividerLine = `${ansi.dim}${symbols.boxTLeft}${symbols.boxHorizontal.repeat(width - 2)}${symbols.boxTRight}${ansi.reset}`; + const dividerLine = pc.dim(`${symbols.boxTLeft}${symbols.boxHorizontal.repeat(width - 2)}${symbols.boxTRight}`); lines.push(dividerLine); } @@ -164,19 +136,15 @@ export function renderBox(options: BoxOptions): string { const strippedLength = stripAnsi(line).length; const ansiLength = line.length - strippedLength; const paddedLine = line.padEnd(innerWidth + ansiLength, " "); - lines.push(`${ansi.dim}${symbols.boxVertical}${ansi.reset} ${paddedLine} ${ansi.dim}${symbols.boxVertical}${ansi.reset}`); + lines.push(`${pc.dim(symbols.boxVertical)} ${paddedLine} ${pc.dim(symbols.boxVertical)}`); } - const bottomLine = `${ansi.dim}${symbols.boxBottomLeft}${symbols.boxHorizontal.repeat(width - 2)}${symbols.boxBottomRight}${ansi.reset}`; + const bottomLine = pc.dim(`${symbols.boxBottomLeft}${symbols.boxHorizontal.repeat(width - 2)}${symbols.boxBottomRight}`); lines.push(bottomLine); return lines.join("\n"); } -function stripAnsi(text: string): string { - return text.replaceAll(/\x1B\[[\d;]*m/g, "");// eslint-disable-line no-control-regex -} - export type ProgressRenderer = { start: () => void; update: (current: number, total: number, suffix?: string) => void; @@ -184,21 +152,22 @@ export type ProgressRenderer = { cleanup: () => void; }; -export function createProgressRenderer(label: string, useAnsi: boolean, isQuiet: boolean): ProgressRenderer { +export function createProgressRenderer(label: string, ansi: boolean, isQuiet: boolean): ProgressRenderer { let isActive = false; let lastUpdate = 0; const { width } = getTerminalInfo(); + const useAnsi = ansi; const cleanup = () => { if (!isActive || isQuiet || !useAnsi) return; - process.stdout.write(`${ansi.clearLine}\r`); + process.stdout.write(`${cursor.clearLine}\r`); }; const handleExit = () => { cleanup(); - process.stdout.write(ansi.cursorShow); + process.stdout.write(cursor.cursorShow); }; return { @@ -209,7 +178,7 @@ export function createProgressRenderer(label: string, useAnsi: boolean, isQuiet: isActive = true; if (useAnsi) { - process.stdout.write(ansi.cursorHide); + process.stdout.write(cursor.cursorHide); process.once("SIGINT", handleExit); process.once("SIGTERM", handleExit); } @@ -224,12 +193,12 @@ export function createProgressRenderer(label: string, useAnsi: boolean, isQuiet: total, current, width, - useAnsi, + ansi: useAnsi, label, suffix }); - process.stdout.write(`${ansi.clearLine}\r${bar}`); + process.stdout.write(`${cursor.clearLine}\r${bar}`); } else { const now = Date.now(); if (now - lastUpdate < 100 && current < total) @@ -246,7 +215,7 @@ export function createProgressRenderer(label: string, useAnsi: boolean, isQuiet: cleanup(); if (message && useAnsi) - console.info(`${ansi.green}${symbols.check}${ansi.reset} ${message}`); + console.info(`${pc.green(symbols.check)} ${message}`); else if (message) console.info(message); @@ -260,7 +229,7 @@ export function createProgressRenderer(label: string, useAnsi: boolean, isQuiet: cleanup(); if (useAnsi) - process.stdout.write(ansi.cursorShow); + process.stdout.write(cursor.cursorShow); isActive = false; } From fd780adac42b683679a1a198c172c3b44c6f596a Mon Sep 17 00:00:00 2001 From: Eugene Nesvetaev Date: Thu, 26 Feb 2026 15:06:57 +0400 Subject: [PATCH 7/9] test(core): Cover Path Pattern and Config Migration --- tests/api.test.ts | 26 +- tests/config.test.ts | 217 ++++++++++ tests/end-to-end.test.ts | 777 +++++++++++++++++++++++++++++++++++- tests/ignoreMatcher.test.ts | 133 ++++++ tests/path.test.ts | 215 ++++++++++ tests/pattern.test.ts | 98 +++++ tests/size.test.ts | 6 +- 7 files changed, 1450 insertions(+), 22 deletions(-) create mode 100644 tests/config.test.ts create mode 100644 tests/ignoreMatcher.test.ts create mode 100644 tests/path.test.ts create mode 100644 tests/pattern.test.ts diff --git a/tests/api.test.ts b/tests/api.test.ts index d0628ac..d590235 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -1,4 +1,3 @@ -import { existsSync } from "node:fs"; import { mkdir, mkdtemp, @@ -8,34 +7,31 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "bun:test"; +import { fln } from "../src/api/index.js"; -const distExists = existsSync(join(import.meta.dir, "../dist/api/index.js")); - -describe.skipIf(!distExists)("fln API", () => { +describe("fln API", () => { it("processes project and returns FlnResult", async () => { - // @ts-expect-error β€” dynamic import of built output, exists only after build - const { fln } = await import("../dist/api/index.js"); - const rootDirectory = await mkdtemp(join(tmpdir(), "fln-api-")); - await writeFile(join(rootDirectory, "package.json"), JSON.stringify({ name: "api-test", version: "1.0.0" }, null, "\t")); - await mkdir(join(rootDirectory, "src"), { recursive: true }); - await writeFile(join(rootDirectory, "src/index.ts"), "export const x = 1;\n"); + const input = await mkdtemp(join(tmpdir(), "fln-api-")); + await writeFile(join(input, "package.json"), JSON.stringify({ name: "api-test", version: "1.0.0" }, null, "\t")); + await mkdir(join(input, "src"), { recursive: true }); + await writeFile(join(input, "src/index.ts"), "export const x = 1;\n"); - const outputFile = join(rootDirectory, "out.md"); + const output = join(input, "out.md"); const result = await fln({ - rootDirectory, - outputFile, + input, + output, includeContents: true, includeTree: true }); expect(result.projectName).toBe("api-test"); expect(result.files).toBeGreaterThanOrEqual(1); - expect(result.outputPath).toBe(outputFile); + expect(result.outputPath).toBe(output); expect(result.outputTokenCount).toBeGreaterThan(0); expect(result.outputSizeBytes).toBeGreaterThan(0); - const content = await readFile(outputFile, "utf8"); + const content = await readFile(output, "utf8"); expect(content).toContain("src/index.ts"); expect(content).toContain("export const x = 1"); }); diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..6573197 --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,217 @@ +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "bun:test"; +import { getProjectMetadata, resolveOutputPath } from "../src/config/index.js"; + + +async function createTempProject(name: string, version?: string): Promise { + const rootDirectory = await mkdtemp(join(tmpdir(), "fln-config-test-")); + const packageJson = version === undefined ? { name } : { name, version }; + await writeFile(join(rootDirectory, "package.json"), JSON.stringify(packageJson, null, "\t")); + + return rootDirectory; +} + +async function createTempPomProject(pomContent: string): Promise { + const rootDirectory = await mkdtemp(join(tmpdir(), "fln-config-test-")); + await writeFile(join(rootDirectory, "pom.xml"), pomContent); + + return rootDirectory; +} + +describe("resolveOutputPath", () => { + it("returns join(input, baseFileName) when outputValue is undefined", async () => { + const input = await createTempProject("my-project", "1.0.0"); + const projectMetadata = await getProjectMetadata(input); + const result = await resolveOutputPath(undefined, input, projectMetadata, true, "md"); + expect(result).toBe(join(input, "my-project-1.0.0.md")); + }); + + it("returns outputValue as-is when it is /dev/null or nul", async () => { + const input = await createTempProject("p", "1.0.0"); + const projectMetadata = await getProjectMetadata(input); + expect(await resolveOutputPath("/dev/null", input, projectMetadata, true, "md")).toBe("/dev/null"); + expect(await resolveOutputPath("nul", input, projectMetadata, true, "md")).toBe("nul"); + }); + + it("returns outputValue as-is when it is - (stdout)", async () => { + const input = await createTempProject("p", "1.0.0"); + const projectMetadata = await getProjectMetadata(input); + expect(await resolveOutputPath("-", input, projectMetadata, true, "md")).toBe("-"); + }); + + it("returns join(outputValue, baseFileName) when outputValue is directory", async () => { + const input = await createTempProject("dir-output", "2.0.0"); + const projectMetadata = await getProjectMetadata(input); + const outputDir = join(input, "out"); + await mkdir(outputDir, { recursive: true }); + + const result = await resolveOutputPath(outputDir, input, projectMetadata, true, "md"); + expect(result).toBe(join(outputDir, "dir-output-2.0.0.md")); + }); + + it("returns join(outputValue, baseFileName) when outputValue has trailing slash", async () => { + const input = await createTempProject("trailing", "1.0.0"); + const projectMetadata = await getProjectMetadata(input); + const outputDir = join(input, "dist"); + await mkdir(outputDir, { recursive: true }); + + const result = await resolveOutputPath(`${outputDir}/`, input, projectMetadata, true, "json"); + expect(result).toBe(join(outputDir, "trailing-1.0.0.json")); + }); + + it("returns path when outputValue is file and does not exist", async () => { + const input = await createTempProject("new-file", "1.0.0"); + const projectMetadata = await getProjectMetadata(input); + const outputFile = join(input, "custom-output.md"); + + const result = await resolveOutputPath(outputFile, input, projectMetadata, true, "md"); + expect(result).toBe(outputFile); + }); + + it("appends .md when outputValue has no extension and format is md", async () => { + const input = await createTempProject("fln", "1.2.0"); + const projectMetadata = await getProjectMetadata(input); + const outputFile = join(input, "fln-1.2.0"); + + const result = await resolveOutputPath(outputFile, input, projectMetadata, true, "md"); + expect(result).toBe(join(input, "fln-1.2.0.md")); + }); + + it("appends .json when outputValue has no extension and format is json", async () => { + const input = await createTempProject("fln", "1.2.0"); + const projectMetadata = await getProjectMetadata(input); + const outputFile = join(input, "fln-1.2.0"); + + const result = await resolveOutputPath(outputFile, input, projectMetadata, true, "json"); + expect(result).toBe(join(input, "fln-1.2.0.json")); + }); + + it("leaves path unchanged when outputValue has explicit extension", async () => { + const input = await createTempProject("fln", "1.2.0"); + const projectMetadata = await getProjectMetadata(input); + const outputFile = join(input, "fln-1.2.0.txt"); + + const result = await resolveOutputPath(outputFile, input, projectMetadata, true, "md"); + expect(result).toBe(outputFile); + }); + + it("returns path-name-1.ext when file exists and overwrite is false", async () => { + const input = await createTempProject("existing", "1.0.0"); + const projectMetadata = await getProjectMetadata(input); + const outputFile = join(input, "existing-1.0.0.md"); + await writeFile(outputFile, "content"); + + const result = await resolveOutputPath(outputFile, input, projectMetadata, false, "md"); + expect(result).toBe(join(input, "existing-1.0.0-1.md")); + }); + + it("returns path when file exists and overwrite is true", async () => { + const input = await createTempProject("overwrite", "1.0.0"); + const projectMetadata = await getProjectMetadata(input); + const outputFile = join(input, "overwrite-1.0.0.md"); + await writeFile(outputFile, "content"); + + const result = await resolveOutputPath(outputFile, input, projectMetadata, true, "md"); + expect(result).toBe(outputFile); + }); + + it("increments counter when -1 file also exists", async () => { + const input = await createTempProject("increment", "1.0.0"); + const projectMetadata = await getProjectMetadata(input); + const baseFile = join(input, "increment-1.0.0.md"); + await writeFile(baseFile, "a"); + await writeFile(join(input, "increment-1.0.0-1.md"), "b"); + + const result = await resolveOutputPath(baseFile, input, projectMetadata, false, "md"); + expect(result).toBe(join(input, "increment-1.0.0-2.md")); + }); + + it("uses name only when project has no version", async () => { + const input = await createTempProject("no-version"); + const projectMetadata = await getProjectMetadata(input); + const result = await resolveOutputPath(undefined, input, projectMetadata, true, "md"); + expect(result).toBe(join(input, "no-version.md")); + }); + + it("falls back to basename when project has no package.json", async () => { + const parent = await mkdtemp(join(tmpdir(), "fln-")); + const root = join(parent, "basename-project"); + await mkdir(root, { recursive: true }); + const projectMetadata = await getProjectMetadata(root); + + const result = await resolveOutputPath(undefined, root, projectMetadata, true, "md"); + expect(result).toBe(join(root, "basename-project.md")); + }); +}); + +describe("getProjectMetadata (pom.xml)", () => { + it("extracts artifactId and version", async () => { + const input = await createTempPomProject(` + + my-app + 1.2.0 + + `); + const projectMetadata = await getProjectMetadata(input); + const result = await resolveOutputPath(undefined, input, projectMetadata, true, "md"); + expect(result).toBe(join(input, "my-app-1.2.0.md")); + }); + + it("uses parent version when project has no version", async () => { + const input = await createTempPomProject(` + + + parent-app + 2.0.0 + + child-module + + `); + const projectMetadata = await getProjectMetadata(input); + const result = await resolveOutputPath(undefined, input, projectMetadata, true, "md"); + expect(result).toBe(join(input, "child-module-2.0.0.md")); + }); + + it("skips version when it is a property reference", async () => { + const input = await createTempPomProject(` + + my-app + \${revision} + + `); + const projectMetadata = await getProjectMetadata(input); + const result = await resolveOutputPath(undefined, input, projectMetadata, true, "md"); + expect(result).toBe(join(input, "my-app.md")); + }); + + it("normalizes artifactId with dots", async () => { + const input = await createTempPomProject(` + + my.app.core + 1.0 + + `); + const projectMetadata = await getProjectMetadata(input); + const result = await resolveOutputPath(undefined, input, projectMetadata, true, "md"); + expect(result).toBe(join(input, "my.app.core-1.0.md")); + }); + + it("ignores artifactId inside dependencies", async () => { + const input = await createTempPomProject(` + + my-app + 1.0.0 + + + spring-core + + + + `); + const projectMetadata = await getProjectMetadata(input); + const result = await resolveOutputPath(undefined, input, projectMetadata, true, "md"); + expect(result).toBe(join(input, "my-app-1.0.0.md")); + }); +}); diff --git a/tests/end-to-end.test.ts b/tests/end-to-end.test.ts index 4ade9f4..92664b7 100644 --- a/tests/end-to-end.test.ts +++ b/tests/end-to-end.test.ts @@ -1,14 +1,18 @@ +import { execSync } from "node:child_process"; import { mkdir, mkdtemp, readdir, readFile, realpath, + symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; +import { Writable } from "node:stream"; import { describe, expect, it } from "bun:test"; +import { fln } from "../src/api/index.js"; import { runCommandLine } from "../src/cli/commandLine.js"; @@ -17,6 +21,8 @@ type RuntimeState = { argv: string[]; }; +type BufferEncoding = Parameters[1]; + async function runCli(rootDirectory: string, args: string[]): Promise { const runtimeState: RuntimeState = { cwd: process.cwd(), @@ -33,6 +39,26 @@ async function runCli(rootDirectory: string, args: string[]): Promise { } } +async function runCliWithStdout(rootDirectory: string, args: string[]): Promise { + const chunks: Buffer[] = []; + const capture = new Writable({ + write(chunk: Buffer | string, encoding: BufferEncoding, callback: () => void) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding)); + callback(); + } + }); + const originalStdout = process.stdout; + (process as { stdout: Writable }).stdout = capture; + + try { + await runCli(rootDirectory, args); + + return Buffer.concat(chunks).toString("utf8"); + } finally { + (process as { stdout: Writable }).stdout = originalStdout; + } +} + async function createTempProject(name: string, version: string): Promise { const rootDirectory = await mkdtemp(join(tmpdir(), "fln-")); const packageJson = { @@ -79,6 +105,20 @@ describe("fln end-to-end", () => { expect(entries).toContain("fln-counter-1.0.0-1.md"); }); + it("resolves relative --output from cwd instead of input directory", async () => { + const parentDirectory = await mkdtemp(join(tmpdir(), "fln-cwd-output-")); + const inputDirectory = join(parentDirectory, "cnvr"); + await mkdir(inputDirectory, { recursive: true }); + await writeFile(join(inputDirectory, "package.json"), JSON.stringify({ name: "cnvr", version: "1.0.0" }, null, "\t")); + await writeFile(join(inputDirectory, "readme.txt"), "ok\n"); + + await runCli(parentDirectory, [ "cnvr", "--output", "cnvr.md", "--quiet", "--no-ansi" ]); + + const outputInCwd = await readFile(join(parentDirectory, "cnvr.md"), "utf8"); + expect(outputInCwd).toContain("readme.txt"); + await expect(readFile(join(inputDirectory, "cnvr.md"), "utf8")).rejects.toThrow(); + }); + it("overwrites output file when --overwrite is set", async () => { const rootDirectory = await createTempProject("fln-overwrite", "1.0.0"); const outputDirectory = join(rootDirectory, "out"); @@ -114,6 +154,18 @@ describe("fln end-to-end", () => { expect(after).toEqual(before); }); + it("outputs to stdout when --stdout is set", async () => { + const rootDirectory = await createTempProject("fln-stdout", "1.0.0"); + await mkdir(join(rootDirectory, "src"), { recursive: true }); + await writeFile(join(rootDirectory, "src", "index.ts"), "export const x = 1;\n"); + + const content = await runCliWithStdout(rootDirectory, [ "--stdout", "--no-ansi" ]); + + expect(content).not.toContain(" +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/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/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)`); From 59cb83749ceb62b7b6b0e291ecfe94c8d2b6f9a2 Mon Sep 17 00:00:00 2001 From: Eugene Nesvetaev Date: Thu, 26 Feb 2026 15:07:03 +0400 Subject: [PATCH 9/9] chore(release): 1.2.0 --- .github/workflows/ci.yaml | 2 ++ .github/workflows/publish.yaml | 2 ++ .github/workflows/release-binary.yaml | 4 ++- .gitignore | 4 +++ CHANGELOG.md | 43 ++++++++++++++++++++++++++- bun.lock | 30 +++++++++++++++---- package.json | 10 +++++-- 7 files changed, 84 insertions(+), 11 deletions(-) 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/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/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"