@our2ndbrain/cli 1.1.3
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/.obsidian/.2ndbrain-manifest.json +8 -0
- package/.obsidian/app.json +6 -0
- package/.obsidian/appearance.json +1 -0
- package/.obsidian/community-plugins.json +4 -0
- package/.obsidian/core-plugins.json +33 -0
- package/.obsidian/graph.json +22 -0
- package/.obsidian/plugins/calendar/data.json +10 -0
- package/.obsidian/plugins/calendar/main.js +4459 -0
- package/.obsidian/plugins/calendar/manifest.json +10 -0
- package/.obsidian/plugins/obsidian-custom-attachment-location/data.json +32 -0
- package/.obsidian/plugins/obsidian-custom-attachment-location/main.js +575 -0
- package/.obsidian/plugins/obsidian-custom-attachment-location/manifest.json +11 -0
- package/.obsidian/plugins/obsidian-custom-attachment-location/styles.css +1 -0
- package/.obsidian/plugins/obsidian-git/data.json +62 -0
- package/.obsidian/plugins/obsidian-git/main.js +426 -0
- package/.obsidian/plugins/obsidian-git/manifest.json +10 -0
- package/.obsidian/plugins/obsidian-git/obsidian_askpass.sh +23 -0
- package/.obsidian/plugins/obsidian-git/styles.css +629 -0
- package/.obsidian/plugins/obsidian-tasks-plugin/main.js +504 -0
- package/.obsidian/plugins/obsidian-tasks-plugin/manifest.json +12 -0
- package/.obsidian/plugins/obsidian-tasks-plugin/styles.css +1 -0
- package/.obsidian/types.json +28 -0
- package/00_Dashboard/01_All_Tasks.md +118 -0
- package/00_Dashboard/09_All_Done.md +42 -0
- package/10_Inbox/Agents/Journal.md +1 -0
- package/99_System/Scripts/init_member.sh +108 -0
- package/99_System/Templates/tpl_daily_note.md +13 -0
- package/99_System/Templates/tpl_member_done.md +32 -0
- package/99_System/Templates/tpl_member_tasks.md +97 -0
- package/AGENTS.md +193 -0
- package/CHANGELOG.md +67 -0
- package/CLAUDE.md +153 -0
- package/LICENSE +201 -0
- package/README.md +636 -0
- package/bin/2ndbrain.js +117 -0
- package/package.json +56 -0
- package/src/commands/completion.js +198 -0
- package/src/commands/init.js +308 -0
- package/src/commands/member.js +123 -0
- package/src/commands/remove.js +88 -0
- package/src/commands/update.js +507 -0
- package/src/index.js +17 -0
- package/src/lib/config.js +112 -0
- package/src/lib/diff.js +222 -0
- package/src/lib/files.js +340 -0
- package/src/lib/obsidian.js +366 -0
- package/src/lib/prompt.js +182 -0
package/src/lib/diff.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 2ndBrain CLI - Diff Operations
|
|
3
|
+
*
|
|
4
|
+
* Provides file comparison and diff generation utilities.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs-extra');
|
|
8
|
+
const { diffLines, diffWords } = require('diff');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Binary file extensions that should be detected and skipped
|
|
12
|
+
*/
|
|
13
|
+
const BINARY_EXTENSIONS = new Set([
|
|
14
|
+
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp',
|
|
15
|
+
'.pdf', '.zip', '.tar', '.gz', '.rar', '.7z',
|
|
16
|
+
'.mp3', '.mp4', '.avi', '.mov', '.wav',
|
|
17
|
+
'.ttf', '.otf', '.woff', '.woff2', '.eot',
|
|
18
|
+
'.psd', '.ai', '.sketch',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Large file threshold in bytes (100KB)
|
|
23
|
+
*/
|
|
24
|
+
const LARGE_FILE_THRESHOLD = 100 * 1024;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check if a file is likely binary based on its extension
|
|
28
|
+
* @param {string} filePath - File path
|
|
29
|
+
* @returns {boolean} True if file is likely binary
|
|
30
|
+
*/
|
|
31
|
+
function isBinaryFile(filePath) {
|
|
32
|
+
const ext = filePath.toLowerCase().split('.').pop();
|
|
33
|
+
return BINARY_EXTENSIONS.has(`.${ext}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check if a file is too large for diff display
|
|
38
|
+
* @param {string} filePath - File path
|
|
39
|
+
* @returns {Promise<boolean>} True if file exceeds threshold
|
|
40
|
+
*/
|
|
41
|
+
async function isLargeFile(filePath) {
|
|
42
|
+
try {
|
|
43
|
+
const stats = await fs.stat(filePath);
|
|
44
|
+
return stats.size > LARGE_FILE_THRESHOLD;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compare two file contents for equality
|
|
52
|
+
* @param {string} content1 - First content
|
|
53
|
+
* @param {string} content2 - Second content
|
|
54
|
+
* @returns {boolean} True if contents are equal
|
|
55
|
+
*/
|
|
56
|
+
function areContentsEqual(content1, content2) {
|
|
57
|
+
if (content1 === content2) return true;
|
|
58
|
+
|
|
59
|
+
// Normalize line endings for comparison
|
|
60
|
+
const normalize = (str) => str.replace(/\r\n/g, '\n');
|
|
61
|
+
return normalize(content1) === normalize(content2);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read file content safely
|
|
66
|
+
* @param {string} filePath - File path
|
|
67
|
+
* @returns {Promise<string|null>} File content or null if error
|
|
68
|
+
*/
|
|
69
|
+
async function readFileContent(filePath) {
|
|
70
|
+
try {
|
|
71
|
+
return await fs.readFile(filePath, 'utf8');
|
|
72
|
+
} catch (err) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Compare source and destination files
|
|
79
|
+
* @param {string} src - Source file path
|
|
80
|
+
* @param {string} dest - Destination file path
|
|
81
|
+
* @returns {Promise<{equal: boolean, error?: string, binary?: boolean, large?: boolean}>}
|
|
82
|
+
*/
|
|
83
|
+
async function compareFiles(src, dest) {
|
|
84
|
+
// Check for binary file
|
|
85
|
+
if (isBinaryFile(src) || isBinaryFile(dest)) {
|
|
86
|
+
return { equal: false, binary: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Check for large file
|
|
90
|
+
if (await isLargeFile(src) || await isLargeFile(dest)) {
|
|
91
|
+
return { equal: false, large: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const [srcContent, destContent] = await Promise.all([
|
|
96
|
+
readFileContent(src),
|
|
97
|
+
readFileContent(dest),
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
// If destination doesn't exist, files are not equal
|
|
101
|
+
if (destContent === null) {
|
|
102
|
+
return { equal: false };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// If source doesn't exist, that's an error
|
|
106
|
+
if (srcContent === null) {
|
|
107
|
+
return { equal: false, error: 'source file not found' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { equal: areContentsEqual(srcContent, destContent) };
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return { equal: false, error: err.message };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Generate a unified diff between two contents
|
|
118
|
+
* @param {string} oldContent - Old content
|
|
119
|
+
* @param {string} newContent - New content
|
|
120
|
+
* @param {string} oldPath - Old file path (for display)
|
|
121
|
+
* @param {string} newPath - New file path (for display)
|
|
122
|
+
* @returns {string} Unified diff string
|
|
123
|
+
*/
|
|
124
|
+
function generateDiff(oldContent, newContent, oldPath = 'old', newPath = 'new') {
|
|
125
|
+
const changes = diffLines(oldContent, newContent);
|
|
126
|
+
|
|
127
|
+
if (changes.length === 0) {
|
|
128
|
+
return 'No differences found.';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let diffText = '';
|
|
132
|
+
let oldLineNum = 1;
|
|
133
|
+
let newLineNum = 1;
|
|
134
|
+
|
|
135
|
+
// Count changes
|
|
136
|
+
const addedLines = changes.filter(c => c.added).reduce((sum, c) => sum + c.count, 0);
|
|
137
|
+
const removedLines = changes.filter(c => c.removed).reduce((sum, c) => sum + c.count, 0);
|
|
138
|
+
|
|
139
|
+
diffText += `--- ${oldPath}\n`;
|
|
140
|
+
diffText += `+++ ${newPath}\n`;
|
|
141
|
+
diffText += `@@ -1 +${addedLines > 0 || removedLines === 0 ? '' : ''},${removedLines > 0 ? '?' : '1'} `;
|
|
142
|
+
diffText += `+1 ${removedLines > 0 || addedLines === 0 ? '' : ''},${addedLines > 0 ? '?' : '1'} @@\n`;
|
|
143
|
+
|
|
144
|
+
for (const change of changes) {
|
|
145
|
+
const lines = change.value.split('\n');
|
|
146
|
+
// Remove trailing empty line from split
|
|
147
|
+
if (lines[lines.length - 1] === '') {
|
|
148
|
+
lines.pop();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const line of lines) {
|
|
152
|
+
if (change.added) {
|
|
153
|
+
diffText += `+${line}\n`;
|
|
154
|
+
newLineNum++;
|
|
155
|
+
} else if (change.removed) {
|
|
156
|
+
diffText += `-${line}\n`;
|
|
157
|
+
oldLineNum++;
|
|
158
|
+
} else {
|
|
159
|
+
diffText += ` ${line}\n`;
|
|
160
|
+
oldLineNum++;
|
|
161
|
+
newLineNum++;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return diffText;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Format diff text with colors for terminal display
|
|
171
|
+
* @param {string} diffText - Raw diff text
|
|
172
|
+
* @param {Object} chalk - Chalk module for colors
|
|
173
|
+
* @returns {string} Formatted diff with ANSI colors
|
|
174
|
+
*/
|
|
175
|
+
function formatDiffForTerminal(diffText, chalk) {
|
|
176
|
+
const lines = diffText.split('\n');
|
|
177
|
+
const formatted = lines.map(line => {
|
|
178
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
179
|
+
return chalk.green(line);
|
|
180
|
+
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
181
|
+
return chalk.red(line);
|
|
182
|
+
} else if (line.startsWith('@@')) {
|
|
183
|
+
return chalk.cyan(line);
|
|
184
|
+
} else if (line.startsWith('---')) {
|
|
185
|
+
return chalk.yellow(line);
|
|
186
|
+
} else if (line.startsWith('+++')) {
|
|
187
|
+
return chalk.yellow(line);
|
|
188
|
+
}
|
|
189
|
+
return line;
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return formatted.join('\n');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Generate a summary of changes between two contents
|
|
197
|
+
* @param {string} oldContent - Old content
|
|
198
|
+
* @param {string} newContent - New content
|
|
199
|
+
* @returns {{added: number, removed: number, changed: boolean}}
|
|
200
|
+
*/
|
|
201
|
+
function summarizeChanges(oldContent, newContent) {
|
|
202
|
+
const changes = diffLines(oldContent, newContent);
|
|
203
|
+
const added = changes.filter(c => c.added).reduce((sum, c) => sum + c.count, 0);
|
|
204
|
+
const removed = changes.filter(c => c.removed).reduce((sum, c) => sum + c.count, 0);
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
added,
|
|
208
|
+
removed,
|
|
209
|
+
changed: added > 0 || removed > 0,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
areContentsEqual,
|
|
215
|
+
compareFiles,
|
|
216
|
+
generateDiff,
|
|
217
|
+
formatDiffForTerminal,
|
|
218
|
+
summarizeChanges,
|
|
219
|
+
isBinaryFile,
|
|
220
|
+
isLargeFile,
|
|
221
|
+
LARGE_FILE_THRESHOLD,
|
|
222
|
+
};
|
package/src/lib/files.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 2ndBrain CLI - File Operations
|
|
3
|
+
*
|
|
4
|
+
* Provides file copy, remove, and directory operations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs-extra');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { compareFiles, summarizeChanges, isBinaryFile, isLargeFile } = require('./diff');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Copy a file from source to destination
|
|
13
|
+
* @param {string} src - Source file path
|
|
14
|
+
* @param {string} dest - Destination file path
|
|
15
|
+
* @returns {Promise<void>}
|
|
16
|
+
*/
|
|
17
|
+
async function copyFile(src, dest) {
|
|
18
|
+
await fs.ensureDir(path.dirname(dest));
|
|
19
|
+
await fs.copy(src, dest);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Copy multiple files from template to target
|
|
24
|
+
* @param {string[]} files - Array of relative file paths
|
|
25
|
+
* @param {string} templateRoot - Template root directory
|
|
26
|
+
* @param {string} targetRoot - Target root directory
|
|
27
|
+
* @param {Function} [onFile] - Callback for each file (relativePath, action)
|
|
28
|
+
* @returns {Promise<{copied: string[], skipped: string[], errors: string[]}>}
|
|
29
|
+
*/
|
|
30
|
+
async function copyFiles(files, templateRoot, targetRoot, onFile) {
|
|
31
|
+
const result = { copied: [], skipped: [], errors: [] };
|
|
32
|
+
|
|
33
|
+
for (const file of files) {
|
|
34
|
+
const src = path.join(templateRoot, file);
|
|
35
|
+
const dest = path.join(targetRoot, file);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
// Check if source exists
|
|
39
|
+
if (!await fs.pathExists(src)) {
|
|
40
|
+
result.skipped.push(file);
|
|
41
|
+
if (onFile) onFile(file, 'skip', 'source not found');
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await copyFile(src, dest);
|
|
46
|
+
result.copied.push(file);
|
|
47
|
+
if (onFile) onFile(file, 'copy');
|
|
48
|
+
} catch (err) {
|
|
49
|
+
result.errors.push(`${file}: ${err.message}`);
|
|
50
|
+
if (onFile) onFile(file, 'error', err.message);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Remove a file if it exists
|
|
59
|
+
* @param {string} filePath - File path to remove
|
|
60
|
+
* @returns {Promise<boolean>} True if removed, false if not found
|
|
61
|
+
*/
|
|
62
|
+
async function removeFile(filePath) {
|
|
63
|
+
if (await fs.pathExists(filePath)) {
|
|
64
|
+
await fs.remove(filePath);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Remove multiple files from target directory
|
|
72
|
+
* @param {string[]} files - Array of relative file paths
|
|
73
|
+
* @param {string} targetRoot - Target root directory
|
|
74
|
+
* @param {Function} [onFile] - Callback for each file (relativePath, action)
|
|
75
|
+
* @returns {Promise<{removed: string[], skipped: string[], errors: string[]}>}
|
|
76
|
+
*/
|
|
77
|
+
async function removeFiles(files, targetRoot, onFile) {
|
|
78
|
+
const result = { removed: [], skipped: [], errors: [] };
|
|
79
|
+
|
|
80
|
+
for (const file of files) {
|
|
81
|
+
const filePath = path.join(targetRoot, file);
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
if (await removeFile(filePath)) {
|
|
85
|
+
result.removed.push(file);
|
|
86
|
+
if (onFile) onFile(file, 'remove');
|
|
87
|
+
} else {
|
|
88
|
+
result.skipped.push(file);
|
|
89
|
+
if (onFile) onFile(file, 'skip', 'not found');
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
result.errors.push(`${file}: ${err.message}`);
|
|
93
|
+
if (onFile) onFile(file, 'error', err.message);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Ensure directories exist
|
|
102
|
+
* @param {string[]} dirs - Array of relative directory paths
|
|
103
|
+
* @param {string} targetRoot - Target root directory
|
|
104
|
+
* @returns {Promise<void>}
|
|
105
|
+
*/
|
|
106
|
+
async function ensureDirs(dirs, targetRoot) {
|
|
107
|
+
for (const dir of dirs) {
|
|
108
|
+
await fs.ensureDir(path.join(targetRoot, dir));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Remove empty directories (bottom-up)
|
|
114
|
+
* @param {string[]} dirs - Array of relative directory paths
|
|
115
|
+
* @param {string} targetRoot - Target root directory
|
|
116
|
+
* @param {Function} [onDir] - Callback for each directory (relativePath, action)
|
|
117
|
+
* @returns {Promise<string[]>} Array of removed directories
|
|
118
|
+
*/
|
|
119
|
+
async function removeEmptyDirs(dirs, targetRoot, onDir) {
|
|
120
|
+
const removed = [];
|
|
121
|
+
|
|
122
|
+
// Sort by depth (deepest first)
|
|
123
|
+
const sortedDirs = [...dirs].sort((a, b) => {
|
|
124
|
+
return b.split('/').length - a.split('/').length;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
for (const dir of sortedDirs) {
|
|
128
|
+
const dirPath = path.join(targetRoot, dir);
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
if (await fs.pathExists(dirPath)) {
|
|
132
|
+
const contents = await fs.readdir(dirPath);
|
|
133
|
+
if (contents.length === 0) {
|
|
134
|
+
await fs.remove(dirPath);
|
|
135
|
+
removed.push(dir);
|
|
136
|
+
if (onDir) onDir(dir, 'remove');
|
|
137
|
+
} else {
|
|
138
|
+
if (onDir) onDir(dir, 'skip', 'not empty');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
// Ignore errors for directory removal
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Also try to remove parent directories if empty
|
|
147
|
+
const parentDirs = new Set();
|
|
148
|
+
for (const dir of removed) {
|
|
149
|
+
const parent = path.dirname(dir);
|
|
150
|
+
if (parent !== '.') {
|
|
151
|
+
parentDirs.add(parent);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const parent of parentDirs) {
|
|
156
|
+
const parentPath = path.join(targetRoot, parent);
|
|
157
|
+
try {
|
|
158
|
+
if (await fs.pathExists(parentPath)) {
|
|
159
|
+
const contents = await fs.readdir(parentPath);
|
|
160
|
+
if (contents.length === 0) {
|
|
161
|
+
await fs.remove(parentPath);
|
|
162
|
+
removed.push(parent);
|
|
163
|
+
if (onDir) onDir(parent, 'remove');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
// Ignore errors
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return removed;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Create a file with content
|
|
176
|
+
* @param {string} filePath - File path
|
|
177
|
+
* @param {string} content - File content
|
|
178
|
+
* @returns {Promise<void>}
|
|
179
|
+
*/
|
|
180
|
+
async function createFile(filePath, content) {
|
|
181
|
+
await fs.ensureDir(path.dirname(filePath));
|
|
182
|
+
await fs.writeFile(filePath, content, 'utf8');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Check if directory is empty or doesn't exist
|
|
187
|
+
* @param {string} dirPath - Directory path
|
|
188
|
+
* @returns {Promise<boolean>}
|
|
189
|
+
*/
|
|
190
|
+
async function isDirEmpty(dirPath) {
|
|
191
|
+
if (!await fs.pathExists(dirPath)) {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
const contents = await fs.readdir(dirPath);
|
|
195
|
+
return contents.length === 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Compare source and destination files
|
|
200
|
+
* @param {string} src - Source file path
|
|
201
|
+
* @param {string} dest - Destination file path
|
|
202
|
+
* @returns {Promise<{equal: boolean, exists: boolean, error?: string, binary?: boolean, large?: boolean}>}
|
|
203
|
+
*/
|
|
204
|
+
async function compareFilesWrapper(src, dest) {
|
|
205
|
+
const destExists = await fs.pathExists(dest);
|
|
206
|
+
|
|
207
|
+
if (!destExists) {
|
|
208
|
+
return { equal: false, exists: false };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const result = await compareFiles(src, dest);
|
|
212
|
+
return { ...result, exists: true };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Copy a single file with comparison
|
|
217
|
+
* @param {string} src - Source file path
|
|
218
|
+
* @param {string} dest - Destination file path
|
|
219
|
+
* @param {Object} options - Copy options
|
|
220
|
+
* @param {boolean} [options.force] - Force copy even if equal
|
|
221
|
+
* @returns {Promise<{copied: boolean, unchanged: boolean, error?: string, change?: any}>}
|
|
222
|
+
*/
|
|
223
|
+
async function copyFileWithCompare(src, dest, options = {}) {
|
|
224
|
+
try {
|
|
225
|
+
// Check if source exists
|
|
226
|
+
if (!await fs.pathExists(src)) {
|
|
227
|
+
return { copied: false, unchanged: false, error: 'source not found' };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Compare files if destination exists
|
|
231
|
+
const comparison = await compareFilesWrapper(src, dest);
|
|
232
|
+
|
|
233
|
+
if (comparison.exists && comparison.equal && !options.force) {
|
|
234
|
+
return { copied: false, unchanged: true };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Perform the copy
|
|
238
|
+
await fs.ensureDir(path.dirname(dest));
|
|
239
|
+
await fs.copy(src, dest);
|
|
240
|
+
|
|
241
|
+
// Generate change summary
|
|
242
|
+
let change = null;
|
|
243
|
+
if (!comparison.equal) {
|
|
244
|
+
if (comparison.binary) {
|
|
245
|
+
change = { binary: true };
|
|
246
|
+
} else if (comparison.large) {
|
|
247
|
+
change = { large: true };
|
|
248
|
+
} else {
|
|
249
|
+
try {
|
|
250
|
+
const [srcContent, destContent] = await Promise.all([
|
|
251
|
+
fs.readFile(src, 'utf8'),
|
|
252
|
+
comparison.exists ? fs.readFile(dest, 'utf8') : '',
|
|
253
|
+
]);
|
|
254
|
+
const summary = summarizeChanges(destContent, srcContent);
|
|
255
|
+
change = { added: summary.added, removed: summary.removed };
|
|
256
|
+
} catch {
|
|
257
|
+
// If we can't read as text, mark as binary
|
|
258
|
+
change = { binary: true };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return { copied: true, unchanged: false, change };
|
|
264
|
+
} catch (err) {
|
|
265
|
+
return { copied: false, unchanged: false, error: err.message };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Copy multiple files with smart comparison
|
|
271
|
+
* @param {string[]} files - Array of relative file paths
|
|
272
|
+
* @param {string} templateRoot - Template root directory
|
|
273
|
+
* @param {string} targetRoot - Target root directory
|
|
274
|
+
* @param {Object} options - Copy options
|
|
275
|
+
* @param {boolean} [options.force] - Force copy even if equal
|
|
276
|
+
* @param {boolean} [options.dryRun] - Dry run mode
|
|
277
|
+
* @param {Function} [onFile] - Callback for each file (relativePath, action, detail)
|
|
278
|
+
* @returns {Promise<{copied: string[], skipped: string[], unchanged: string[], errors: string[], changes: Array}>}
|
|
279
|
+
*/
|
|
280
|
+
async function copyFilesSmart(files, templateRoot, targetRoot, options = {}, onFile) {
|
|
281
|
+
const result = {
|
|
282
|
+
copied: [],
|
|
283
|
+
skipped: [],
|
|
284
|
+
unchanged: [],
|
|
285
|
+
errors: [],
|
|
286
|
+
changes: [],
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
for (const file of files) {
|
|
290
|
+
const src = path.join(templateRoot, file);
|
|
291
|
+
const dest = path.join(targetRoot, file);
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const fileResult = await copyFileWithCompare(src, dest, options);
|
|
295
|
+
|
|
296
|
+
if (fileResult.error) {
|
|
297
|
+
result.errors.push(`${file}: ${fileResult.error}`);
|
|
298
|
+
if (onFile) onFile(file, 'error', fileResult.error);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (fileResult.unchanged) {
|
|
303
|
+
result.unchanged.push(file);
|
|
304
|
+
if (onFile) onFile(file, 'unchanged');
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (options.dryRun) {
|
|
309
|
+
result.skipped.push(file);
|
|
310
|
+
if (onFile) onFile(file, 'dryrun', fileResult.change);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
result.copied.push(file);
|
|
315
|
+
if (fileResult.change) {
|
|
316
|
+
result.changes.push({ file, ...fileResult.change });
|
|
317
|
+
}
|
|
318
|
+
if (onFile) onFile(file, 'copy', fileResult.change);
|
|
319
|
+
} catch (err) {
|
|
320
|
+
result.errors.push(`${file}: ${err.message}`);
|
|
321
|
+
if (onFile) onFile(file, 'error', err.message);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return result;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
module.exports = {
|
|
329
|
+
copyFile,
|
|
330
|
+
copyFiles,
|
|
331
|
+
removeFile,
|
|
332
|
+
removeFiles,
|
|
333
|
+
ensureDirs,
|
|
334
|
+
removeEmptyDirs,
|
|
335
|
+
createFile,
|
|
336
|
+
isDirEmpty,
|
|
337
|
+
compareFilesWrapper,
|
|
338
|
+
copyFileWithCompare,
|
|
339
|
+
copyFilesSmart,
|
|
340
|
+
};
|