config-fs-utils 0.1.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.
- package/README.md +348 -0
- package/index.js +259 -0
- package/index.test.js +376 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# config-fs-utils
|
|
2
|
+
|
|
3
|
+
File system utilities for configuration file management
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/config-fs-utils)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- ✅ **Zero dependencies** - Uses only Node.js built-in modules
|
|
11
|
+
- ✅ **Simple & Flexible** - Two API styles for different use cases
|
|
12
|
+
- ✅ **Safe file operations** - Automatic backups and permission management
|
|
13
|
+
- ✅ **Tilde expansion** - Automatically expands `~` to home directory
|
|
14
|
+
- ✅ **Recursive directory creation** - Creates nested directories automatically
|
|
15
|
+
- ✅ **Perfect for config files** - Designed for configuration management
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install config-fs-utils
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
### Simple API (Recommended for most users)
|
|
26
|
+
|
|
27
|
+
```javascript
|
|
28
|
+
const { setupStandardMuttDirs, writeConfigFiles } = require('config-fs-utils');
|
|
29
|
+
|
|
30
|
+
// Create standard directory structure
|
|
31
|
+
const dirs = await setupStandardMuttDirs();
|
|
32
|
+
// Creates: ~/.config/mutt/, ~/.local/etc/oauth-tokens/, ~/.cache/mutt/, etc.
|
|
33
|
+
|
|
34
|
+
// Write config files with automatic backups and secure permissions
|
|
35
|
+
await writeConfigFiles({
|
|
36
|
+
'~/.config/myapp/config.ini': 'config content',
|
|
37
|
+
'~/.config/myapp/settings.json': JSON.stringify({ key: 'value' })
|
|
38
|
+
});
|
|
39
|
+
// Files are created with 0o600 permissions
|
|
40
|
+
// Existing files are backed up automatically
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Flexible API (For advanced use cases)
|
|
44
|
+
|
|
45
|
+
```javascript
|
|
46
|
+
const { ensureDirectories, writeFile } = require('config-fs-utils');
|
|
47
|
+
|
|
48
|
+
// Create custom directories
|
|
49
|
+
await ensureDirectories([
|
|
50
|
+
'/custom/path/one',
|
|
51
|
+
'/custom/path/two',
|
|
52
|
+
'~/relative/path'
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
// Write file with custom options
|
|
56
|
+
await writeFile('/path/to/file', 'content', {
|
|
57
|
+
backup: false,
|
|
58
|
+
permissions: 0o644
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## API Reference
|
|
63
|
+
|
|
64
|
+
### Simple API
|
|
65
|
+
|
|
66
|
+
#### `setupStandardMuttDirs(options)`
|
|
67
|
+
|
|
68
|
+
Creates standard Mutt/Neomutt directory structure.
|
|
69
|
+
|
|
70
|
+
**Parameters:**
|
|
71
|
+
- `options.baseDir` (string, optional): Base directory (defaults to `~`)
|
|
72
|
+
|
|
73
|
+
**Returns:** Object with created directory paths
|
|
74
|
+
|
|
75
|
+
```javascript
|
|
76
|
+
const dirs = await setupStandardMuttDirs();
|
|
77
|
+
// {
|
|
78
|
+
// config: '/Users/username/.config/mutt',
|
|
79
|
+
// accounts: '/Users/username/.config/mutt/accounts',
|
|
80
|
+
// tokens: '/Users/username/.local/etc/oauth-tokens',
|
|
81
|
+
// cacheHeaders: '/Users/username/.cache/mutt/headers',
|
|
82
|
+
// cacheBodies: '/Users/username/.cache/mutt/bodies'
|
|
83
|
+
// }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
#### `writeConfigFiles(files, options)`
|
|
87
|
+
|
|
88
|
+
Writes multiple config files with secure defaults.
|
|
89
|
+
|
|
90
|
+
**Parameters:**
|
|
91
|
+
- `files` (Object): Map of filepath to content
|
|
92
|
+
- `options.backup` (boolean, default: `true`): Create backups of existing files
|
|
93
|
+
- `options.permissions` (number, default: `0o600`): File permissions
|
|
94
|
+
|
|
95
|
+
**Returns:** Array of result objects
|
|
96
|
+
|
|
97
|
+
```javascript
|
|
98
|
+
const results = await writeConfigFiles({
|
|
99
|
+
'~/.config/app/config.yml': yamlContent,
|
|
100
|
+
'~/.config/app/secrets.json': secretsContent
|
|
101
|
+
});
|
|
102
|
+
// Each result: { path, backup, created }
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Flexible API
|
|
106
|
+
|
|
107
|
+
#### `ensureDirectory(dir)`
|
|
108
|
+
|
|
109
|
+
Creates a single directory (and parents if needed).
|
|
110
|
+
|
|
111
|
+
```javascript
|
|
112
|
+
await ensureDirectory('~/my/nested/directory');
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
#### `ensureDirectories(dirs)`
|
|
116
|
+
|
|
117
|
+
Creates multiple directories.
|
|
118
|
+
|
|
119
|
+
```javascript
|
|
120
|
+
await ensureDirectories([
|
|
121
|
+
'~/dir1',
|
|
122
|
+
'~/dir2/nested',
|
|
123
|
+
'/absolute/path'
|
|
124
|
+
]);
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
#### `writeFile(filepath, content, options)`
|
|
128
|
+
|
|
129
|
+
Writes a single file with options.
|
|
130
|
+
|
|
131
|
+
**Options:**
|
|
132
|
+
- `backup` (boolean): Create backup if file exists
|
|
133
|
+
- `permissions` (number): File permissions (e.g., `0o600`)
|
|
134
|
+
|
|
135
|
+
```javascript
|
|
136
|
+
const result = await writeFile('~/config.txt', 'content', {
|
|
137
|
+
backup: true,
|
|
138
|
+
permissions: 0o600
|
|
139
|
+
});
|
|
140
|
+
// { path: '/Users/username/config.txt', backup: '...backup-2026-01-26...', created: false }
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
#### `writeFiles(files, options)`
|
|
144
|
+
|
|
145
|
+
Writes multiple files.
|
|
146
|
+
|
|
147
|
+
```javascript
|
|
148
|
+
await writeFiles({
|
|
149
|
+
'~/file1.txt': 'content1',
|
|
150
|
+
'~/file2.txt': 'content2'
|
|
151
|
+
}, {
|
|
152
|
+
backup: true,
|
|
153
|
+
permissions: 0o644
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
#### `backupFile(filepath)`
|
|
158
|
+
|
|
159
|
+
Creates a backup of a file if it exists.
|
|
160
|
+
|
|
161
|
+
```javascript
|
|
162
|
+
const backupPath = await backupFile('~/important.txt');
|
|
163
|
+
// Returns: '~/important.txt.backup-2026-01-26T12-34-56-789Z' or null
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Utility Functions
|
|
167
|
+
|
|
168
|
+
#### `expandHome(filepath)`
|
|
169
|
+
|
|
170
|
+
Expands `~` to home directory.
|
|
171
|
+
|
|
172
|
+
```javascript
|
|
173
|
+
expandHome('~/Documents');
|
|
174
|
+
// Returns: '/Users/username/Documents'
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
#### `exists(filepath)`
|
|
178
|
+
|
|
179
|
+
Checks if a file or directory exists.
|
|
180
|
+
|
|
181
|
+
```javascript
|
|
182
|
+
const fileExists = await exists('~/config.txt');
|
|
183
|
+
// Returns: true or false
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
#### `getStats(filepath)`
|
|
187
|
+
|
|
188
|
+
Gets file/directory stats.
|
|
189
|
+
|
|
190
|
+
```javascript
|
|
191
|
+
const stats = await getStats('~/file.txt');
|
|
192
|
+
// Returns: fs.Stats object or null
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
#### `ensureDirectoriesFromPaths(paths)`
|
|
196
|
+
|
|
197
|
+
Creates directories from a paths object (like from `mutt-config-core`).
|
|
198
|
+
|
|
199
|
+
```javascript
|
|
200
|
+
const { getConfigPaths } = require('mutt-config-core');
|
|
201
|
+
const paths = getConfigPaths('user@gmail.com');
|
|
202
|
+
|
|
203
|
+
await ensureDirectoriesFromPaths(paths);
|
|
204
|
+
// Creates all necessary parent directories
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Constants
|
|
208
|
+
|
|
209
|
+
#### `STANDARD_MUTT_DIRS`
|
|
210
|
+
|
|
211
|
+
Array of standard Mutt directory paths:
|
|
212
|
+
|
|
213
|
+
```javascript
|
|
214
|
+
[
|
|
215
|
+
'.config/mutt',
|
|
216
|
+
'.config/mutt/accounts',
|
|
217
|
+
'.local/etc/oauth-tokens',
|
|
218
|
+
'.cache/mutt/headers',
|
|
219
|
+
'.cache/mutt/bodies'
|
|
220
|
+
]
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Integration with mutt-config-core
|
|
224
|
+
|
|
225
|
+
Perfect companion for [`mutt-config-core`](https://www.npmjs.com/package/mutt-config-core):
|
|
226
|
+
|
|
227
|
+
```javascript
|
|
228
|
+
const { generateMuttConfigs, getConfigPaths } = require('mutt-config-core');
|
|
229
|
+
const { setupStandardMuttDirs, writeConfigFiles } = require('config-fs-utils');
|
|
230
|
+
|
|
231
|
+
// 1. Generate configurations
|
|
232
|
+
const configs = generateMuttConfigs({
|
|
233
|
+
email: 'user@gmail.com',
|
|
234
|
+
realName: 'John Doe',
|
|
235
|
+
editor: 'nvim',
|
|
236
|
+
locale: 'en'
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// 2. Setup directories
|
|
240
|
+
const dirs = await setupStandardMuttDirs();
|
|
241
|
+
|
|
242
|
+
// 3. Write config files
|
|
243
|
+
const paths = getConfigPaths('user@gmail.com');
|
|
244
|
+
await writeConfigFiles({
|
|
245
|
+
[`~/${paths.accountMuttrc}`]: configs.accountMuttrc,
|
|
246
|
+
[`~/${paths.mainMuttrc}`]: configs.mainMuttrc
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
console.log('Setup complete!');
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Use Cases
|
|
253
|
+
|
|
254
|
+
### Configuration Management
|
|
255
|
+
|
|
256
|
+
```javascript
|
|
257
|
+
// Safely update config files with automatic backups
|
|
258
|
+
await writeConfigFiles({
|
|
259
|
+
'~/.bashrc': bashrcContent,
|
|
260
|
+
'~/.vimrc': vimrcContent,
|
|
261
|
+
'~/.gitconfig': gitconfigContent
|
|
262
|
+
});
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### Application Setup
|
|
266
|
+
|
|
267
|
+
```javascript
|
|
268
|
+
// Create app directory structure
|
|
269
|
+
await ensureDirectories([
|
|
270
|
+
'~/.config/myapp',
|
|
271
|
+
'~/.config/myapp/plugins',
|
|
272
|
+
'~/.local/share/myapp',
|
|
273
|
+
'~/.cache/myapp'
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
// Write initial config
|
|
277
|
+
await writeFile('~/.config/myapp/config.json', JSON.stringify({
|
|
278
|
+
version: '1.0.0',
|
|
279
|
+
settings: {}
|
|
280
|
+
}, null, 2));
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Secure File Operations
|
|
284
|
+
|
|
285
|
+
```javascript
|
|
286
|
+
// Write sensitive files with restricted permissions
|
|
287
|
+
await writeFile('~/.ssh/id_rsa', privateKey, {
|
|
288
|
+
permissions: 0o600 // Owner read/write only
|
|
289
|
+
});
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## Error Handling
|
|
293
|
+
|
|
294
|
+
All async functions can throw errors. Always use try-catch:
|
|
295
|
+
|
|
296
|
+
```javascript
|
|
297
|
+
try {
|
|
298
|
+
await writeConfigFiles({
|
|
299
|
+
'/protected/path': 'content'
|
|
300
|
+
});
|
|
301
|
+
} catch (error) {
|
|
302
|
+
console.error('Failed to write config:', error.message);
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
## Development
|
|
307
|
+
|
|
308
|
+
```bash
|
|
309
|
+
# Install dependencies
|
|
310
|
+
npm install
|
|
311
|
+
|
|
312
|
+
# Run tests
|
|
313
|
+
npm test
|
|
314
|
+
|
|
315
|
+
# Watch mode
|
|
316
|
+
npm run test:watch
|
|
317
|
+
|
|
318
|
+
# Coverage
|
|
319
|
+
npm run test:coverage
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
## Testing
|
|
323
|
+
|
|
324
|
+
Fully tested with 29 test cases covering:
|
|
325
|
+
- Directory creation (nested, recursive)
|
|
326
|
+
- File operations (write, backup, permissions)
|
|
327
|
+
- Tilde expansion
|
|
328
|
+
- Path utilities
|
|
329
|
+
- Integration scenarios
|
|
330
|
+
|
|
331
|
+
## Related Projects
|
|
332
|
+
|
|
333
|
+
- [mutt-config-core](https://github.com/a-lost-social-misfit/mutt-config-core) - Configuration generator
|
|
334
|
+
- [create-neomutt-gmail](https://github.com/a-lost-social-misfit/create-neomutt-gmail) - CLI tool (coming soon)
|
|
335
|
+
|
|
336
|
+
## Contributing
|
|
337
|
+
|
|
338
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
339
|
+
|
|
340
|
+
## License
|
|
341
|
+
|
|
342
|
+
MIT © a-lost-social-misfit
|
|
343
|
+
|
|
344
|
+
## Author
|
|
345
|
+
|
|
346
|
+
Created by [a-lost-social-misfit](https://github.com/a-lost-social-misfit)
|
|
347
|
+
|
|
348
|
+
Part of a suite of tools for managing Neomutt + Gmail + OAuth2.0 setup.
|
package/index.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config-fs-utils
|
|
3
|
+
* File system utilities for configuration file management
|
|
4
|
+
*
|
|
5
|
+
* No external dependencies. Uses Node.js built-in modules only.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require("fs").promises;
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const os = require("os");
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Standard Mutt directory structure
|
|
14
|
+
*/
|
|
15
|
+
const STANDARD_MUTT_DIRS = [
|
|
16
|
+
".config/mutt",
|
|
17
|
+
".config/mutt/accounts",
|
|
18
|
+
".local/etc/oauth-tokens",
|
|
19
|
+
".cache/mutt/headers",
|
|
20
|
+
".cache/mutt/bodies",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Expand tilde (~) to home directory
|
|
25
|
+
* @param {string} filepath - Path that may contain ~
|
|
26
|
+
* @returns {string} Expanded absolute path
|
|
27
|
+
*/
|
|
28
|
+
function expandHome(filepath) {
|
|
29
|
+
if (filepath.startsWith("~/") || filepath === "~") {
|
|
30
|
+
return path.join(os.homedir(), filepath.slice(2));
|
|
31
|
+
}
|
|
32
|
+
return filepath;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Ensure a single directory exists
|
|
37
|
+
* @param {string} dir - Directory path
|
|
38
|
+
* @returns {Promise<string>} Created directory path
|
|
39
|
+
*/
|
|
40
|
+
async function ensureDirectory(dir) {
|
|
41
|
+
const expandedDir = expandHome(dir);
|
|
42
|
+
await fs.mkdir(expandedDir, { recursive: true });
|
|
43
|
+
return expandedDir;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Ensure multiple directories exist
|
|
48
|
+
* @param {string[]} dirs - Array of directory paths
|
|
49
|
+
* @returns {Promise<string[]>} Array of created directory paths
|
|
50
|
+
*/
|
|
51
|
+
async function ensureDirectories(dirs) {
|
|
52
|
+
const results = [];
|
|
53
|
+
for (const dir of dirs) {
|
|
54
|
+
const created = await ensureDirectory(dir);
|
|
55
|
+
results.push(created);
|
|
56
|
+
}
|
|
57
|
+
return results;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Setup standard Mutt directory structure
|
|
62
|
+
* @param {Object} options - Options
|
|
63
|
+
* @param {string} [options.baseDir='~'] - Base directory (defaults to home)
|
|
64
|
+
* @returns {Promise<Object>} Created directory paths
|
|
65
|
+
*/
|
|
66
|
+
async function setupStandardMuttDirs(options = {}) {
|
|
67
|
+
const baseDir = options.baseDir || "~";
|
|
68
|
+
const dirs = STANDARD_MUTT_DIRS.map((dir) => path.join(baseDir, dir));
|
|
69
|
+
|
|
70
|
+
const created = await ensureDirectories(dirs);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
config: created[0],
|
|
74
|
+
accounts: created[1],
|
|
75
|
+
tokens: created[2],
|
|
76
|
+
cacheHeaders: created[3],
|
|
77
|
+
cacheBodies: created[4],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Create backup of a file if it exists
|
|
83
|
+
* @param {string} filepath - File to backup
|
|
84
|
+
* @returns {Promise<string|null>} Backup file path or null if file doesn't exist
|
|
85
|
+
*/
|
|
86
|
+
async function backupFile(filepath) {
|
|
87
|
+
const expandedPath = expandHome(filepath);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
await fs.access(expandedPath);
|
|
91
|
+
// File exists, create backup
|
|
92
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
93
|
+
const backupPath = `${expandedPath}.backup-${timestamp}`;
|
|
94
|
+
await fs.copyFile(expandedPath, backupPath);
|
|
95
|
+
return backupPath;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// File doesn't exist, no backup needed
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Write a single file with options
|
|
104
|
+
* @param {string} filepath - File path
|
|
105
|
+
* @param {string} content - File content
|
|
106
|
+
* @param {Object} options - Options
|
|
107
|
+
* @param {boolean} [options.backup=false] - Create backup if file exists
|
|
108
|
+
* @param {number} [options.permissions] - File permissions (e.g., 0o600)
|
|
109
|
+
* @returns {Promise<Object>} Result object
|
|
110
|
+
*/
|
|
111
|
+
async function writeFile(filepath, content, options = {}) {
|
|
112
|
+
const expandedPath = expandHome(filepath);
|
|
113
|
+
const dir = path.dirname(expandedPath);
|
|
114
|
+
|
|
115
|
+
// Ensure directory exists
|
|
116
|
+
await ensureDirectory(dir);
|
|
117
|
+
|
|
118
|
+
// Backup if requested
|
|
119
|
+
let backupPath = null;
|
|
120
|
+
if (options.backup) {
|
|
121
|
+
backupPath = await backupFile(expandedPath);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Write file
|
|
125
|
+
await fs.writeFile(expandedPath, content, "utf8");
|
|
126
|
+
|
|
127
|
+
// Set permissions if specified
|
|
128
|
+
if (options.permissions !== undefined) {
|
|
129
|
+
await fs.chmod(expandedPath, options.permissions);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
path: expandedPath,
|
|
134
|
+
backup: backupPath,
|
|
135
|
+
created: backupPath === null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Write multiple files at once
|
|
141
|
+
* @param {Object} files - Map of filepath to content
|
|
142
|
+
* @param {Object} options - Options
|
|
143
|
+
* @param {boolean} [options.backup=false] - Create backups
|
|
144
|
+
* @param {number} [options.permissions] - File permissions
|
|
145
|
+
* @returns {Promise<Object[]>} Array of result objects
|
|
146
|
+
*/
|
|
147
|
+
async function writeFiles(files, options = {}) {
|
|
148
|
+
const results = [];
|
|
149
|
+
|
|
150
|
+
for (const [filepath, content] of Object.entries(files)) {
|
|
151
|
+
const result = await writeFile(filepath, content, options);
|
|
152
|
+
results.push(result);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return results;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Write config files with standard permissions (0o600)
|
|
160
|
+
* @param {Object} files - Map of filepath to content
|
|
161
|
+
* @param {Object} options - Options
|
|
162
|
+
* @param {boolean} [options.backup=true] - Create backups (default: true)
|
|
163
|
+
* @returns {Promise<Object[]>} Array of result objects
|
|
164
|
+
*/
|
|
165
|
+
async function writeConfigFiles(files, options = {}) {
|
|
166
|
+
const defaultOptions = {
|
|
167
|
+
backup: true,
|
|
168
|
+
permissions: 0o600,
|
|
169
|
+
...options,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return writeFiles(files, defaultOptions);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Check if a file or directory exists
|
|
177
|
+
* @param {string} filepath - Path to check
|
|
178
|
+
* @returns {Promise<boolean>} True if exists
|
|
179
|
+
*/
|
|
180
|
+
async function exists(filepath) {
|
|
181
|
+
const expandedPath = expandHome(filepath);
|
|
182
|
+
try {
|
|
183
|
+
await fs.access(expandedPath);
|
|
184
|
+
return true;
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get file stats
|
|
192
|
+
* @param {string} filepath - File path
|
|
193
|
+
* @returns {Promise<Object|null>} Stats object or null if doesn't exist
|
|
194
|
+
*/
|
|
195
|
+
async function getStats(filepath) {
|
|
196
|
+
const expandedPath = expandHome(filepath);
|
|
197
|
+
try {
|
|
198
|
+
return await fs.stat(expandedPath);
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Create directories from mutt-config-core paths object
|
|
206
|
+
* @param {Object} paths - Paths object from mutt-config-core
|
|
207
|
+
* @returns {Promise<string[]>} Created directories
|
|
208
|
+
*/
|
|
209
|
+
async function ensureDirectoriesFromPaths(paths) {
|
|
210
|
+
const dirs = new Set();
|
|
211
|
+
|
|
212
|
+
// Keys that represent files (not directories)
|
|
213
|
+
const fileKeys = ["accountmuttrc", "mainmuttrc"];
|
|
214
|
+
|
|
215
|
+
for (const [key, filepath] of Object.entries(paths)) {
|
|
216
|
+
const expandedPath = expandHome(filepath);
|
|
217
|
+
const normalizedKey = key.toLowerCase();
|
|
218
|
+
|
|
219
|
+
// Check if this is a file path or directory path
|
|
220
|
+
if (fileKeys.includes(normalizedKey)) {
|
|
221
|
+
// It's a file, create its parent directory
|
|
222
|
+
const dir = path.dirname(expandedPath);
|
|
223
|
+
dirs.add(dir);
|
|
224
|
+
} else {
|
|
225
|
+
// It's a directory path, create it directly
|
|
226
|
+
dirs.add(expandedPath);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return ensureDirectories(Array.from(dirs));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Export API
|
|
234
|
+
module.exports = {
|
|
235
|
+
// Simple API (most users)
|
|
236
|
+
setupStandardMuttDirs,
|
|
237
|
+
writeConfigFiles,
|
|
238
|
+
|
|
239
|
+
// Flexible API (advanced users)
|
|
240
|
+
ensureDirectory,
|
|
241
|
+
ensureDirectories,
|
|
242
|
+
writeFile,
|
|
243
|
+
writeFiles,
|
|
244
|
+
backupFile,
|
|
245
|
+
|
|
246
|
+
// Utility functions
|
|
247
|
+
expandHome,
|
|
248
|
+
exists,
|
|
249
|
+
getStats,
|
|
250
|
+
ensureDirectoriesFromPaths,
|
|
251
|
+
|
|
252
|
+
// Constants
|
|
253
|
+
STANDARD_MUTT_DIRS,
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// For testing
|
|
257
|
+
module.exports._internal = {
|
|
258
|
+
expandHome,
|
|
259
|
+
};
|
package/index.test.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for config-fs-utils
|
|
3
|
+
*
|
|
4
|
+
* Run with: npm test
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require("fs").promises;
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const os = require("os");
|
|
10
|
+
const {
|
|
11
|
+
setupStandardMuttDirs,
|
|
12
|
+
writeConfigFiles,
|
|
13
|
+
ensureDirectory,
|
|
14
|
+
ensureDirectories,
|
|
15
|
+
writeFile,
|
|
16
|
+
writeFiles,
|
|
17
|
+
backupFile,
|
|
18
|
+
expandHome,
|
|
19
|
+
exists,
|
|
20
|
+
getStats,
|
|
21
|
+
ensureDirectoriesFromPaths,
|
|
22
|
+
STANDARD_MUTT_DIRS,
|
|
23
|
+
_internal,
|
|
24
|
+
} = require("./index");
|
|
25
|
+
|
|
26
|
+
// Test utilities
|
|
27
|
+
const TEST_DIR = path.join(os.tmpdir(), "config-fs-utils-test");
|
|
28
|
+
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
// Clean up test directory before each test
|
|
31
|
+
try {
|
|
32
|
+
await fs.rm(TEST_DIR, { recursive: true, force: true });
|
|
33
|
+
} catch (error) {
|
|
34
|
+
// Directory might not exist, ignore
|
|
35
|
+
}
|
|
36
|
+
await fs.mkdir(TEST_DIR, { recursive: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(async () => {
|
|
40
|
+
// Clean up after all tests
|
|
41
|
+
try {
|
|
42
|
+
await fs.rm(TEST_DIR, { recursive: true, force: true });
|
|
43
|
+
} catch (error) {
|
|
44
|
+
// Ignore cleanup errors
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("config-fs-utils", () => {
|
|
49
|
+
describe("expandHome", () => {
|
|
50
|
+
test("expands ~ to home directory", () => {
|
|
51
|
+
const result = expandHome("~/test/path");
|
|
52
|
+
expect(result).toBe(path.join(os.homedir(), "test/path"));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("expands ~ alone", () => {
|
|
56
|
+
const result = expandHome("~");
|
|
57
|
+
expect(result).toBe(os.homedir());
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("does not modify absolute paths", () => {
|
|
61
|
+
const result = expandHome("/absolute/path");
|
|
62
|
+
expect(result).toBe("/absolute/path");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("does not modify relative paths", () => {
|
|
66
|
+
const result = expandHome("relative/path");
|
|
67
|
+
expect(result).toBe("relative/path");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("ensureDirectory", () => {
|
|
72
|
+
test("creates a single directory", async () => {
|
|
73
|
+
const testPath = path.join(TEST_DIR, "test-dir");
|
|
74
|
+
await ensureDirectory(testPath);
|
|
75
|
+
|
|
76
|
+
const stat = await fs.stat(testPath);
|
|
77
|
+
expect(stat.isDirectory()).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("creates nested directories", async () => {
|
|
81
|
+
const testPath = path.join(TEST_DIR, "nested/deep/directory");
|
|
82
|
+
await ensureDirectory(testPath);
|
|
83
|
+
|
|
84
|
+
const stat = await fs.stat(testPath);
|
|
85
|
+
expect(stat.isDirectory()).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("does not error if directory already exists", async () => {
|
|
89
|
+
const testPath = path.join(TEST_DIR, "existing-dir");
|
|
90
|
+
await fs.mkdir(testPath);
|
|
91
|
+
|
|
92
|
+
await expect(ensureDirectory(testPath)).resolves.not.toThrow();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("ensureDirectories", () => {
|
|
97
|
+
test("creates multiple directories", async () => {
|
|
98
|
+
const dirs = [
|
|
99
|
+
path.join(TEST_DIR, "dir1"),
|
|
100
|
+
path.join(TEST_DIR, "dir2"),
|
|
101
|
+
path.join(TEST_DIR, "dir3"),
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
const created = await ensureDirectories(dirs);
|
|
105
|
+
expect(created).toHaveLength(3);
|
|
106
|
+
|
|
107
|
+
for (const dir of dirs) {
|
|
108
|
+
const stat = await fs.stat(dir);
|
|
109
|
+
expect(stat.isDirectory()).toBe(true);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("returns created directory paths", async () => {
|
|
114
|
+
const dirs = [path.join(TEST_DIR, "test1"), path.join(TEST_DIR, "test2")];
|
|
115
|
+
|
|
116
|
+
const created = await ensureDirectories(dirs);
|
|
117
|
+
expect(created).toEqual(dirs);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("setupStandardMuttDirs", () => {
|
|
122
|
+
test("creates standard Mutt directory structure", async () => {
|
|
123
|
+
const result = await setupStandardMuttDirs({ baseDir: TEST_DIR });
|
|
124
|
+
|
|
125
|
+
expect(result).toHaveProperty("config");
|
|
126
|
+
expect(result).toHaveProperty("accounts");
|
|
127
|
+
expect(result).toHaveProperty("tokens");
|
|
128
|
+
expect(result).toHaveProperty("cacheHeaders");
|
|
129
|
+
expect(result).toHaveProperty("cacheBodies");
|
|
130
|
+
|
|
131
|
+
// Verify directories exist
|
|
132
|
+
for (const dir of Object.values(result)) {
|
|
133
|
+
const stat = await fs.stat(dir);
|
|
134
|
+
expect(stat.isDirectory()).toBe(true);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("creates directories in home by default", async () => {
|
|
139
|
+
// We can't test actual home directory creation, but we can test the structure
|
|
140
|
+
const result = await setupStandardMuttDirs({ baseDir: TEST_DIR });
|
|
141
|
+
|
|
142
|
+
expect(result.config).toContain(".config/mutt");
|
|
143
|
+
expect(result.accounts).toContain(".config/mutt/accounts");
|
|
144
|
+
expect(result.tokens).toContain(".local/etc/oauth-tokens");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("backupFile", () => {
|
|
149
|
+
test("creates backup if file exists", async () => {
|
|
150
|
+
const testFile = path.join(TEST_DIR, "test.txt");
|
|
151
|
+
await fs.writeFile(testFile, "original content");
|
|
152
|
+
|
|
153
|
+
const backupPath = await backupFile(testFile);
|
|
154
|
+
|
|
155
|
+
expect(backupPath).not.toBeNull();
|
|
156
|
+
expect(backupPath).toContain(".backup-");
|
|
157
|
+
|
|
158
|
+
const backupContent = await fs.readFile(backupPath, "utf8");
|
|
159
|
+
expect(backupContent).toBe("original content");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("returns null if file does not exist", async () => {
|
|
163
|
+
const testFile = path.join(TEST_DIR, "nonexistent.txt");
|
|
164
|
+
const backupPath = await backupFile(testFile);
|
|
165
|
+
|
|
166
|
+
expect(backupPath).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("writeFile", () => {
|
|
171
|
+
test("writes file with content", async () => {
|
|
172
|
+
const testFile = path.join(TEST_DIR, "test.txt");
|
|
173
|
+
await writeFile(testFile, "test content");
|
|
174
|
+
|
|
175
|
+
const content = await fs.readFile(testFile, "utf8");
|
|
176
|
+
expect(content).toBe("test content");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("creates parent directories", async () => {
|
|
180
|
+
const testFile = path.join(TEST_DIR, "nested/deep/test.txt");
|
|
181
|
+
await writeFile(testFile, "test content");
|
|
182
|
+
|
|
183
|
+
const content = await fs.readFile(testFile, "utf8");
|
|
184
|
+
expect(content).toBe("test content");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("creates backup when option is true", async () => {
|
|
188
|
+
const testFile = path.join(TEST_DIR, "test.txt");
|
|
189
|
+
await fs.writeFile(testFile, "original");
|
|
190
|
+
|
|
191
|
+
const result = await writeFile(testFile, "new content", { backup: true });
|
|
192
|
+
|
|
193
|
+
expect(result.backup).not.toBeNull();
|
|
194
|
+
expect(result.created).toBe(false);
|
|
195
|
+
|
|
196
|
+
const backupContent = await fs.readFile(result.backup, "utf8");
|
|
197
|
+
expect(backupContent).toBe("original");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("sets file permissions", async () => {
|
|
201
|
+
const testFile = path.join(TEST_DIR, "test.txt");
|
|
202
|
+
await writeFile(testFile, "content", { permissions: 0o600 });
|
|
203
|
+
|
|
204
|
+
const stat = await fs.stat(testFile);
|
|
205
|
+
expect(stat.mode & 0o777).toBe(0o600);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("returns result object", async () => {
|
|
209
|
+
const testFile = path.join(TEST_DIR, "test.txt");
|
|
210
|
+
const result = await writeFile(testFile, "content");
|
|
211
|
+
|
|
212
|
+
expect(result).toHaveProperty("path");
|
|
213
|
+
expect(result).toHaveProperty("backup");
|
|
214
|
+
expect(result).toHaveProperty("created");
|
|
215
|
+
expect(result.created).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("writeFiles", () => {
|
|
220
|
+
test("writes multiple files", async () => {
|
|
221
|
+
const files = {
|
|
222
|
+
[path.join(TEST_DIR, "file1.txt")]: "content1",
|
|
223
|
+
[path.join(TEST_DIR, "file2.txt")]: "content2",
|
|
224
|
+
[path.join(TEST_DIR, "file3.txt")]: "content3",
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
await writeFiles(files);
|
|
228
|
+
|
|
229
|
+
for (const [filepath, expectedContent] of Object.entries(files)) {
|
|
230
|
+
const content = await fs.readFile(filepath, "utf8");
|
|
231
|
+
expect(content).toBe(expectedContent);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("returns array of results", async () => {
|
|
236
|
+
const files = {
|
|
237
|
+
[path.join(TEST_DIR, "file1.txt")]: "content1",
|
|
238
|
+
[path.join(TEST_DIR, "file2.txt")]: "content2",
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const results = await writeFiles(files);
|
|
242
|
+
|
|
243
|
+
expect(results).toHaveLength(2);
|
|
244
|
+
expect(results[0]).toHaveProperty("path");
|
|
245
|
+
expect(results[1]).toHaveProperty("path");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("writeConfigFiles", () => {
|
|
250
|
+
test("writes files with 0o600 permissions by default", async () => {
|
|
251
|
+
const files = {
|
|
252
|
+
[path.join(TEST_DIR, "config.txt")]: "config content",
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
await writeConfigFiles(files);
|
|
256
|
+
|
|
257
|
+
const stat = await fs.stat(path.join(TEST_DIR, "config.txt"));
|
|
258
|
+
expect(stat.mode & 0o777).toBe(0o600);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("creates backups by default", async () => {
|
|
262
|
+
const testFile = path.join(TEST_DIR, "config.txt");
|
|
263
|
+
await fs.writeFile(testFile, "original");
|
|
264
|
+
|
|
265
|
+
const results = await writeConfigFiles({
|
|
266
|
+
[testFile]: "new content",
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(results[0].backup).not.toBeNull();
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("exists", () => {
|
|
274
|
+
test("returns true for existing file", async () => {
|
|
275
|
+
const testFile = path.join(TEST_DIR, "exists.txt");
|
|
276
|
+
await fs.writeFile(testFile, "content");
|
|
277
|
+
|
|
278
|
+
const result = await exists(testFile);
|
|
279
|
+
expect(result).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("returns false for non-existing file", async () => {
|
|
283
|
+
const testFile = path.join(TEST_DIR, "nonexistent.txt");
|
|
284
|
+
const result = await exists(testFile);
|
|
285
|
+
expect(result).toBe(false);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("returns true for existing directory", async () => {
|
|
289
|
+
const result = await exists(TEST_DIR);
|
|
290
|
+
expect(result).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("getStats", () => {
|
|
295
|
+
test("returns stats for existing file", async () => {
|
|
296
|
+
const testFile = path.join(TEST_DIR, "test.txt");
|
|
297
|
+
await fs.writeFile(testFile, "content");
|
|
298
|
+
|
|
299
|
+
const stats = await getStats(testFile);
|
|
300
|
+
expect(stats).not.toBeNull();
|
|
301
|
+
expect(stats.isFile()).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("returns null for non-existing file", async () => {
|
|
305
|
+
const testFile = path.join(TEST_DIR, "nonexistent.txt");
|
|
306
|
+
const stats = await getStats(testFile);
|
|
307
|
+
expect(stats).toBeNull();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe("ensureDirectoriesFromPaths", () => {
|
|
312
|
+
test("creates directories from paths object", async () => {
|
|
313
|
+
const paths = {
|
|
314
|
+
accountMuttrc: path.join(
|
|
315
|
+
TEST_DIR,
|
|
316
|
+
".config/mutt/accounts/test@gmail.com.muttrc"
|
|
317
|
+
),
|
|
318
|
+
mainMuttrc: path.join(TEST_DIR, ".config/mutt/muttrc"),
|
|
319
|
+
oauthTokens: path.join(TEST_DIR, ".local/etc/oauth-tokens"),
|
|
320
|
+
cacheHeaders: path.join(TEST_DIR, ".cache/mutt/headers"),
|
|
321
|
+
cacheBodies: path.join(TEST_DIR, ".cache/mutt/bodies"),
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
await ensureDirectoriesFromPaths(paths);
|
|
325
|
+
|
|
326
|
+
// Check that parent directories were created for files
|
|
327
|
+
const accountsDir = await fs.stat(
|
|
328
|
+
path.join(TEST_DIR, ".config/mutt/accounts")
|
|
329
|
+
);
|
|
330
|
+
expect(accountsDir.isDirectory()).toBe(true);
|
|
331
|
+
|
|
332
|
+
const configDir = await fs.stat(path.join(TEST_DIR, ".config/mutt"));
|
|
333
|
+
expect(configDir.isDirectory()).toBe(true);
|
|
334
|
+
|
|
335
|
+
// Check that directory paths were created directly
|
|
336
|
+
const tokensDir = await fs.stat(
|
|
337
|
+
path.join(TEST_DIR, ".local/etc/oauth-tokens")
|
|
338
|
+
);
|
|
339
|
+
expect(tokensDir.isDirectory()).toBe(true);
|
|
340
|
+
|
|
341
|
+
const headersDir = await fs.stat(
|
|
342
|
+
path.join(TEST_DIR, ".cache/mutt/headers")
|
|
343
|
+
);
|
|
344
|
+
expect(headersDir.isDirectory()).toBe(true);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe("Integration tests", () => {
|
|
349
|
+
test("complete workflow: setup dirs and write configs", async () => {
|
|
350
|
+
// Setup standard directories
|
|
351
|
+
const dirs = await setupStandardMuttDirs({ baseDir: TEST_DIR });
|
|
352
|
+
|
|
353
|
+
// Write config files
|
|
354
|
+
const files = {
|
|
355
|
+
[path.join(dirs.config, "muttrc")]: "main config",
|
|
356
|
+
[path.join(dirs.accounts, "test@gmail.com.muttrc")]: "account config",
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const results = await writeConfigFiles(files);
|
|
360
|
+
|
|
361
|
+
// Verify files exist with correct content
|
|
362
|
+
const mainContent = await fs.readFile(
|
|
363
|
+
path.join(dirs.config, "muttrc"),
|
|
364
|
+
"utf8"
|
|
365
|
+
);
|
|
366
|
+
const accountContent = await fs.readFile(
|
|
367
|
+
path.join(dirs.accounts, "test@gmail.com.muttrc"),
|
|
368
|
+
"utf8"
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
expect(mainContent).toBe("main config");
|
|
372
|
+
expect(accountContent).toBe("account config");
|
|
373
|
+
expect(results).toHaveLength(2);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "config-fs-utils",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "File system utilities for configuration file management",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "jest",
|
|
8
|
+
"test:watch": "jest --watch",
|
|
9
|
+
"test:coverage": "jest --coverage"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"config",
|
|
13
|
+
"filesystem",
|
|
14
|
+
"utilities",
|
|
15
|
+
"backup",
|
|
16
|
+
"permissions",
|
|
17
|
+
"mutt",
|
|
18
|
+
"neomutt"
|
|
19
|
+
],
|
|
20
|
+
"author": "a-lost-social-misfit",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/a-lost-social-misfit/config-fs-utils.git"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"jest": "^30.2.0",
|
|
28
|
+
"mutt-config-core": "^0.1.0"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=14.0.0"
|
|
32
|
+
}
|
|
33
|
+
}
|