flatten-tool 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +127 -0
  3. package/index.ts +216 -0
  4. package/package.json +54 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Your Name
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # flatten-tool
2
+
3
+ [![npm version](https://img.shields.io/npm/v/flatten-tool)](https://www.npmjs.com/package/flatten-tool)
4
+
5
+ A CLI utility to flatten directory structures.
6
+
7
+ ## Installation
8
+
9
+ Requires [Bun](https://bun.sh) runtime (v1.1+).
10
+
11
+ ```bash
12
+ npm install -g flatten-tool
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ By default, the tool merges all file contents into a single Markdown file, with each file's content placed under a header with its relative path, followed by a code block with appropriate language highlighting based on the file extension. Ignores and filters are applied as usual.
18
+
19
+ The `<source>` argument is optional and defaults to the current directory (`.`). The `<target>` argument is also optional and defaults to `flattened.md` (or `flattened/` when using `--directory`).
20
+
21
+ ### Options
22
+
23
+ - `--directory`, `-d`: Flatten to individual files in a directory instead of merging to Markdown.
24
+ - `--move`, `-m`: Move files instead of copying (original files will be deleted).
25
+ - `--overwrite`, `-o`: Overwrite existing target files.
26
+ - `--gitignore`, `-g`: Respect `.gitignore` files (default: true). Use `--no-gitignore` to disable.
27
+ - `--help`, `-h`: Show help.
28
+ - `--version`, `-v`: Show version.
29
+
30
+ ### Examples
31
+
32
+ Flatten current directory to `flattened.md`:
33
+
34
+ ```bash
35
+ flatten-tool
36
+ ```
37
+
38
+ Flatten a specific directory to `flattened.md`:
39
+
40
+ ```bash
41
+ flatten-tool /path/to/source
42
+ ```
43
+
44
+ Flatten current directory to a custom Markdown file:
45
+
46
+ ```bash
47
+ flatten-tool output.md
48
+ ```
49
+
50
+ Flatten a specific directory to a custom Markdown file:
51
+
52
+ ```bash
53
+ flatten-tool /path/to/source output.md
54
+ ```
55
+
56
+ Flatten to individual files in a directory:
57
+
58
+ ```bash
59
+ flatten-tool --directory
60
+ ```
61
+
62
+ This creates a `flattened/` directory with flattened files.
63
+
64
+ Flatten a specific directory to a custom output directory:
65
+
66
+ ```bash
67
+ flatten-tool /path/to/source output-dir --directory
68
+ ```
69
+
70
+ Move files instead of copying:
71
+
72
+ ```bash
73
+ flatten-tool --move
74
+ ```
75
+
76
+ Overwrite existing files:
77
+
78
+ ```bash
79
+ flatten-tool --overwrite
80
+ ```
81
+
82
+ Combine options:
83
+
84
+ ```bash
85
+ flatten-tool /path/to/source output.md --move --overwrite
86
+ ```
87
+
88
+ ## Testing
89
+
90
+ Run all tests:
91
+
92
+ ```bash
93
+ bun test
94
+ ```
95
+
96
+ Run tests in watch mode:
97
+
98
+ ```bash
99
+ bun test --watch
100
+ ```
101
+
102
+ Run a specific test:
103
+
104
+ ```bash
105
+ bun test -t "flattens a simple nested directory"
106
+ ```
107
+
108
+ ## Development
109
+
110
+ This project uses Bun for runtime, TypeScript for type safety, and follows the guidelines in `AGENTS.md` for coding standards.
111
+
112
+ ## Changelog
113
+
114
+ ### v1.2.0
115
+ - Implemented streaming for Markdown merging to improve memory efficiency for large files/directories.
116
+ - Updated documentation and coding guidelines.
117
+
118
+ ### v1.1.0
119
+ - Made source argument optional (defaults to current directory).
120
+ - Improved CLI defaults and options.
121
+
122
+ ### v1.0.0
123
+ - Initial release with Markdown merging and directory flattening capabilities.
124
+
125
+ ## License
126
+
127
+ This project was created using `bun init` in bun v1.3.8. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
package/index.ts ADDED
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env bun
2
+ import { copyFile, rename, rm, stat, mkdir, readdir, rmdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { join, relative, sep, resolve, extname } from 'node:path';
4
+ import { createReadStream, createWriteStream } from 'node:fs';
5
+ import yargs from 'yargs';
6
+ import { hideBin } from 'yargs/helpers';
7
+ import { globby } from 'globby';
8
+ import pkg from './package.json' assert { type: 'json' };
9
+
10
+ function escapePathComponent(component: string): string {
11
+ return component.replace(/_/g, '__');
12
+ }
13
+
14
+ async function removeEmptyDirs(dir: string, root?: string): Promise<void> {
15
+ const entries = await readdir(dir, { withFileTypes: true });
16
+ for (const entry of entries) {
17
+ if (entry.isDirectory()) {
18
+ await removeEmptyDirs(join(dir, entry.name), root);
19
+ }
20
+ }
21
+ if (dir !== root) {
22
+ try {
23
+ await rmdir(dir);
24
+ } catch {
25
+ // Not empty, skip
26
+ }
27
+ }
28
+ }
29
+
30
+ export async function flattenDirectory(
31
+ source: string,
32
+ target: string,
33
+ move: boolean = false,
34
+ overwrite: boolean = false,
35
+ ignorePatterns: string[] = [],
36
+ respectGitignore: boolean = true,
37
+ flattenToDirectory: boolean = false
38
+ ): Promise<void> {
39
+ const absSource = resolve(source);
40
+ const absTarget = resolve(target);
41
+
42
+ if (absSource === absTarget) {
43
+ console.log('Source and target are the same; skipping.');
44
+ return;
45
+ }
46
+
47
+ const negativeIgnores = ignorePatterns.map(pattern => `!${pattern}`);
48
+
49
+ const files = await globby(['**', ...negativeIgnores], {
50
+ cwd: absSource,
51
+ gitignore: respectGitignore,
52
+ absolute: true,
53
+ dot: true,
54
+ onlyFiles: true,
55
+ ignore: ['.git'],
56
+ });
57
+
58
+ if (!flattenToDirectory) {
59
+ // Check if target exists
60
+ try {
61
+ await stat(absTarget);
62
+ if (!overwrite) {
63
+ throw new Error(`Target file "${absTarget}" already exists. Use --overwrite to force.`);
64
+ }
65
+ console.warn(`Overwriting existing file: ${absTarget}`);
66
+ } catch (err: any) {
67
+ if (err.code !== 'ENOENT') throw err;
68
+ }
69
+
70
+ const writeStream = createWriteStream(absTarget);
71
+
72
+ for (const srcPath of files) {
73
+ const relPath = relative(absSource, srcPath).replace(/\\/g, '/');
74
+ let ext = extname(srcPath).slice(1) || 'text';
75
+ const isMd = ['md', 'markdown'].includes(ext.toLowerCase());
76
+ const ticks = isMd ? '````' : '```';
77
+
78
+ // Header + opening fence
79
+ writeStream.write(`# ${relPath}\n\n${ticks}${ext}\n`);
80
+
81
+ // Stream file content as UTF-8 text (matches previous readFile(..., 'utf8') behavior)
82
+ const readStream = createReadStream(srcPath, { encoding: 'utf8' });
83
+
84
+ await new Promise<void>((resolve, reject) => {
85
+ readStream.pipe(writeStream, { end: false });
86
+
87
+ readStream.on('end', () => {
88
+ // Closing fence + trailing newlines
89
+ writeStream.write(`\n${ticks}\n\n`);
90
+ resolve();
91
+ });
92
+
93
+ readStream.on('error', reject);
94
+ writeStream.on('error', reject);
95
+ });
96
+ }
97
+
98
+ // Finalise the output file
99
+ await new Promise<void>((resolve, reject) => {
100
+ writeStream.end();
101
+ writeStream.on('close', resolve);
102
+ writeStream.on('error', reject);
103
+ });
104
+
105
+ if (move) {
106
+ for (const srcPath of files) {
107
+ await rm(srcPath);
108
+ }
109
+ await removeEmptyDirs(absSource, absSource);
110
+ }
111
+ } else {
112
+ await mkdir(absTarget, { recursive: true });
113
+
114
+ for (const srcPath of files) {
115
+ const relPath = relative(absSource, srcPath);
116
+ const components = relPath.split(sep);
117
+ const escapedComponents = components.map(escapePathComponent);
118
+ const newName = escapedComponents.join('_');
119
+ const tgtPath = join(absTarget, newName);
120
+
121
+ try {
122
+ await stat(tgtPath);
123
+ if (!overwrite) {
124
+ throw new Error(`Target file "${tgtPath}" already exists. Use --overwrite to force.`);
125
+ }
126
+ console.warn(`Overwriting existing file: ${tgtPath}`);
127
+ } catch (err: any) {
128
+ if (err.code !== 'ENOENT') throw err;
129
+ }
130
+
131
+ if (move) {
132
+ await rename(srcPath, tgtPath);
133
+ } else {
134
+ await copyFile(srcPath, tgtPath);
135
+ }
136
+ }
137
+
138
+ if (move) {
139
+ await removeEmptyDirs(absSource, absSource);
140
+ }
141
+ }
142
+ }
143
+
144
+ // Main CLI logic
145
+ if (import.meta.url === `file://${process.argv[1]}`) {
146
+ yargs(hideBin(process.argv))
147
+ .command('$0 [source] [target]', 'Flatten or merge a directory structure (default source: current directory)', (yargs) => {
148
+ yargs
149
+ .positional('source', {
150
+ describe: 'Directory to flatten (default: current directory ".")',
151
+ type: 'string',
152
+ default: '.',
153
+ })
154
+ .positional('target', {
155
+ describe: 'Target file or directory. Default: flattened.md or flattened/ depending on --directory',
156
+ type: 'string',
157
+ })
158
+ .option('move', {
159
+ alias: 'm',
160
+ describe: 'Move files instead of copying (original files will be deleted)',
161
+ type: 'boolean',
162
+ default: false,
163
+ })
164
+ .option('overwrite', {
165
+ alias: 'o',
166
+ describe: 'Overwrite existing files in target if conflicts occur',
167
+ type: 'boolean',
168
+ default: false,
169
+ })
170
+ .option('gitignore', {
171
+ alias: 'g',
172
+ describe: 'Respect .gitignore files (default: true). Use --no-gitignore to include everything',
173
+ type: 'boolean',
174
+ default: true,
175
+ })
176
+ .option('directory', {
177
+ alias: 'd',
178
+ describe: 'Flatten files into a directory structure (escaped filenames). When not set, merges everything into a single Markdown file (default behavior).',
179
+ type: 'boolean',
180
+ default: false,
181
+ })
182
+ }, async (argv) => {
183
+ let source = argv.source as string; // now always defined (default '.')
184
+ let target = argv.target as string; // may be undefined
185
+
186
+ const move: boolean = argv.move as boolean;
187
+ const overwrite: boolean = argv.overwrite as boolean;
188
+ const ignorePatterns: string[] = argv.ignore as string[] || [];
189
+ const respectGitignore: boolean = argv.gitignore as boolean;
190
+ const flattenToDirectory: boolean = argv.directory as boolean;
191
+
192
+ // If user didn't provide explicit target, choose sensible default
193
+ if (!target) {
194
+ target = flattenToDirectory
195
+ ? join(process.cwd(), 'flattened')
196
+ : join(process.cwd(), 'flattened.md');
197
+ }
198
+
199
+ try {
200
+ await stat(source);
201
+ } catch {
202
+ console.error(`Source directory "${source}" does not exist.`);
203
+ process.exit(1);
204
+ }
205
+
206
+ const action = move ? 'moved' : 'copied';
207
+ const mode = flattenToDirectory ? 'directory' : 'Markdown file';
208
+ await flattenDirectory(source, target, move, overwrite, ignorePatterns, respectGitignore, flattenToDirectory);
209
+ console.log(`Directory flattened successfully (${action}) into ${target} (${mode}).`);
210
+ })
211
+ .help('h')
212
+ .alias('h', 'help')
213
+ .version(pkg.version ?? '0.0.0')
214
+ .alias('v', 'version')
215
+ .parse();
216
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "flatten-tool",
3
+ "version": "1.2.0",
4
+ "description": "CLI tool to flatten directory structures: merge files into a single Markdown file (default) or copy/move to a flat directory with escaped filenames. Respects .gitignore, supports move/overwrite, and more.",
5
+ "module": "index.ts",
6
+ "type": "module",
7
+ "bin": {
8
+ "flatten-tool": "index.ts"
9
+ },
10
+ "scripts": {
11
+ "test": "bun test"
12
+ },
13
+ "engines": {
14
+ "bun": ">=1.1.0"
15
+ },
16
+ "keywords": [
17
+ "flatten",
18
+ "directory",
19
+ "cli",
20
+ "markdown",
21
+ "merge",
22
+ "filesystem",
23
+ "bun",
24
+ "gitignore"
25
+ ],
26
+ "author": "Your Name <your.email@example.com>",
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/yourusername/flatten-tool.git"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/yourusername/flatten-tool/issues"
34
+ },
35
+ "homepage": "https://github.com/yourusername/flatten-tool#readme",
36
+ "files": [
37
+ "index.ts",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "devDependencies": {
42
+ "@types/bun": "latest",
43
+ "@types/yargs": "^17.0.35"
44
+ },
45
+ "peerDependencies": {
46
+ "typescript": "^5"
47
+ },
48
+ "dependencies": {
49
+ "globby": "^16.1.0",
50
+ "ignore": "^7.0.5",
51
+ "minimatch": "^10.1.1",
52
+ "yargs": "^18.0.0"
53
+ }
54
+ }