@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
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 2ndBrain CLI - Smart Obsidian Directory Updates
|
|
3
|
+
*
|
|
4
|
+
* Provides intelligent merging for .obsidian directory that preserves user
|
|
5
|
+
* configurations while adding new plugins from templates.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const fs = require('fs-extra');
|
|
10
|
+
const chalk = require('chalk');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Merge strategies for different file types
|
|
14
|
+
*/
|
|
15
|
+
const MERGE_STRATEGIES = {
|
|
16
|
+
/**
|
|
17
|
+
* ARRAY_UNION - Merge arrays, add new items, preserve user items
|
|
18
|
+
* Used for: community-plugins.json
|
|
19
|
+
*/
|
|
20
|
+
ARRAY_UNION: 'ARRAY_UNION',
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ADD_ONLY - Only add new files/directories, never overwrite
|
|
24
|
+
* Used for: plugins directory (never overwrite user plugin configs)
|
|
25
|
+
*/
|
|
26
|
+
ADD_ONLY: 'ADD_ONLY',
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* TEMPLATE_WINS - Template replaces user config
|
|
30
|
+
* Used for: other JSON files where template is authoritative
|
|
31
|
+
*/
|
|
32
|
+
TEMPLATE_WINS: 'TEMPLATE_WINS',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Manifest filename for merge strategies
|
|
37
|
+
*/
|
|
38
|
+
const MANIFEST_FILE = '.2ndbrain-manifest.json';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the merge strategy manifest from template
|
|
42
|
+
* @param {string} obsidianTemplatePath - Path to .obsidian in template
|
|
43
|
+
* @returns {Promise<Object>} Manifest object
|
|
44
|
+
*/
|
|
45
|
+
async function getManifest(obsidianTemplatePath) {
|
|
46
|
+
const manifestPath = path.join(obsidianTemplatePath, MANIFEST_FILE);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const content = await fs.readFile(manifestPath, 'utf8');
|
|
50
|
+
return JSON.parse(content);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
// If no manifest exists, return default strategies
|
|
53
|
+
return {
|
|
54
|
+
version: '1.0.0',
|
|
55
|
+
description: '2ndBrain Obsidian directory merge manifest',
|
|
56
|
+
strategies: {
|
|
57
|
+
'community-plugins.json': 'ARRAY_UNION',
|
|
58
|
+
'plugins': 'ADD_ONLY',
|
|
59
|
+
},
|
|
60
|
+
deprecatedPlugins: [],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get all files recursively in a directory
|
|
67
|
+
* @param {string} dirPath - Directory path
|
|
68
|
+
* @returns {Promise<string[]>} Array of relative file paths
|
|
69
|
+
*/
|
|
70
|
+
async function getAllFiles(dirPath) {
|
|
71
|
+
const files = [];
|
|
72
|
+
|
|
73
|
+
if (!(await fs.pathExists(dirPath))) {
|
|
74
|
+
return files;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
78
|
+
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
81
|
+
|
|
82
|
+
if (entry.isDirectory()) {
|
|
83
|
+
const subFiles = await getAllFiles(fullPath);
|
|
84
|
+
files.push(...subFiles.map(f => path.join(entry.name, f)));
|
|
85
|
+
} else {
|
|
86
|
+
files.push(entry.name);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return files;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Merge community plugins using array union strategy
|
|
95
|
+
* Adds new plugins from template, preserves user-added plugins
|
|
96
|
+
* @param {string} userPluginsPath - Path to user's community-plugins.json
|
|
97
|
+
* @param {string} templatePluginsPath - Path to template's community-plugins.json
|
|
98
|
+
* @param {Object} manifest - Merge manifest
|
|
99
|
+
* @returns {Promise<Object>} Result with status and details
|
|
100
|
+
*/
|
|
101
|
+
async function mergeCommunityPlugins(userPluginsPath, templatePluginsPath, manifest) {
|
|
102
|
+
let userPlugins = [];
|
|
103
|
+
let templatePlugins = [];
|
|
104
|
+
|
|
105
|
+
// Read user plugins
|
|
106
|
+
if (await fs.pathExists(userPluginsPath)) {
|
|
107
|
+
try {
|
|
108
|
+
const content = await fs.readFile(userPluginsPath, 'utf8');
|
|
109
|
+
userPlugins = JSON.parse(content);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
// If file is invalid, start fresh
|
|
112
|
+
userPlugins = [];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Read template plugins
|
|
117
|
+
if (await fs.pathExists(templatePluginsPath)) {
|
|
118
|
+
try {
|
|
119
|
+
const content = await fs.readFile(templatePluginsPath, 'utf8');
|
|
120
|
+
templatePlugins = JSON.parse(content);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
templatePlugins = [];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Union merge: template plugins + user plugins (excluding duplicates)
|
|
127
|
+
const mergedPlugins = [
|
|
128
|
+
...new Set([
|
|
129
|
+
...templatePlugins,
|
|
130
|
+
...userPlugins.filter(p => !templatePlugins.includes(p)),
|
|
131
|
+
]),
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
// Sort for consistency
|
|
135
|
+
mergedPlugins.sort();
|
|
136
|
+
|
|
137
|
+
// Detect changes
|
|
138
|
+
const hasChanges = (
|
|
139
|
+
mergedPlugins.length !== userPlugins.length ||
|
|
140
|
+
!mergedPlugins.every(p => userPlugins.includes(p))
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
if (!hasChanges) {
|
|
144
|
+
return {
|
|
145
|
+
action: 'unchanged',
|
|
146
|
+
oldPlugins: userPlugins,
|
|
147
|
+
newPlugins: userPlugins,
|
|
148
|
+
added: [],
|
|
149
|
+
removed: [],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const added = mergedPlugins.filter(p => !userPlugins.includes(p));
|
|
154
|
+
const removed = userPlugins.filter(p => !mergedPlugins.includes(p));
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
action: 'merged',
|
|
158
|
+
oldPlugins: userPlugins,
|
|
159
|
+
newPlugins: mergedPlugins,
|
|
160
|
+
added,
|
|
161
|
+
removed,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Process a single file in the .obsidian directory
|
|
167
|
+
* @param {string} relativePath - Relative path from .obsidian root
|
|
168
|
+
* @param {string} obsidianTemplatePath - Template .obsidian path
|
|
169
|
+
* @param {string} obsidianTargetPath - Target .obsidian path
|
|
170
|
+
* @param {Object} manifest - Merge manifest
|
|
171
|
+
* @param {Object} options - Options
|
|
172
|
+
* @param {boolean} options.dryRun - Dry run mode
|
|
173
|
+
* @returns {Promise<Object>} Result object
|
|
174
|
+
*/
|
|
175
|
+
async function processObsidianFile(relativePath, obsidianTemplatePath, obsidianTargetPath, manifest, options = {}) {
|
|
176
|
+
const templateFilePath = path.join(obsidianTemplatePath, relativePath);
|
|
177
|
+
const targetFilePath = path.join(obsidianTargetPath, relativePath);
|
|
178
|
+
|
|
179
|
+
// Skip the manifest file itself
|
|
180
|
+
if (relativePath === MANIFEST_FILE) {
|
|
181
|
+
return { action: 'skip', file: relativePath, reason: 'internal manifest' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Check if this path or parent has a merge strategy
|
|
185
|
+
let strategy = null;
|
|
186
|
+
|
|
187
|
+
// Direct file strategy
|
|
188
|
+
if (manifest.strategies[relativePath]) {
|
|
189
|
+
strategy = manifest.strategies[relativePath];
|
|
190
|
+
} else {
|
|
191
|
+
// Check directory strategies
|
|
192
|
+
const pathParts = relativePath.split(path.sep);
|
|
193
|
+
for (let i = pathParts.length - 1; i >= 0; i--) {
|
|
194
|
+
const dirPath = pathParts.slice(0, i).join(path.sep);
|
|
195
|
+
if (manifest.strategies[dirPath]) {
|
|
196
|
+
strategy = manifest.strategies[dirPath];
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Handle community-plugins.json specially
|
|
203
|
+
if (relativePath === 'community-plugins.json') {
|
|
204
|
+
const result = await mergeCommunityPlugins(targetFilePath, templateFilePath, manifest);
|
|
205
|
+
|
|
206
|
+
if (!options.dryRun && result.action === 'merged') {
|
|
207
|
+
await fs.ensureDir(path.dirname(targetFilePath));
|
|
208
|
+
await fs.writeFile(targetFilePath, JSON.stringify(result.newPlugins, null, 2) + '\n');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
action: result.action,
|
|
213
|
+
file: relativePath,
|
|
214
|
+
added: result.added,
|
|
215
|
+
removed: result.removed,
|
|
216
|
+
strategy: 'ARRAY_UNION',
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// For ADD_ONLY strategy, only add if target doesn't exist
|
|
221
|
+
if (strategy === MERGE_STRATEGIES.ADD_ONLY) {
|
|
222
|
+
if (await fs.pathExists(targetFilePath)) {
|
|
223
|
+
return { action: 'preserved', file: relativePath, reason: 'user file' };
|
|
224
|
+
}
|
|
225
|
+
// File doesn't exist, copy it
|
|
226
|
+
if (!options.dryRun) {
|
|
227
|
+
await fs.ensureDir(path.dirname(targetFilePath));
|
|
228
|
+
await fs.copy(templateFilePath, targetFilePath);
|
|
229
|
+
}
|
|
230
|
+
return { action: 'added', file: relativePath };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// For TEMPLATE_WINS or no strategy, copy if different
|
|
234
|
+
const templateExists = await fs.pathExists(templateFilePath);
|
|
235
|
+
const targetExists = await fs.pathExists(targetFilePath);
|
|
236
|
+
|
|
237
|
+
if (!templateExists) {
|
|
238
|
+
return { action: 'skip', file: relativePath, reason: 'not in template' };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!targetExists) {
|
|
242
|
+
if (!options.dryRun) {
|
|
243
|
+
await fs.ensureDir(path.dirname(targetFilePath));
|
|
244
|
+
await fs.copy(templateFilePath, targetFilePath);
|
|
245
|
+
}
|
|
246
|
+
return { action: 'added', file: relativePath };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Compare contents
|
|
250
|
+
const [templateContent, targetContent] = await Promise.all([
|
|
251
|
+
fs.readFile(templateFilePath, 'utf8'),
|
|
252
|
+
fs.readFile(targetFilePath, 'utf8'),
|
|
253
|
+
]);
|
|
254
|
+
|
|
255
|
+
if (templateContent === targetContent) {
|
|
256
|
+
return { action: 'unchanged', file: relativePath };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// TEMPLATE_WINS: replace with template
|
|
260
|
+
if (!options.dryRun) {
|
|
261
|
+
await fs.copy(templateFilePath, targetFilePath);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
action: 'updated',
|
|
266
|
+
file: relativePath,
|
|
267
|
+
strategy: strategy || 'TEMPLATE_WINS',
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Smart copy of .obsidian directory with merge strategies
|
|
273
|
+
* @param {string} obsidianTemplatePath - Template .obsidian path
|
|
274
|
+
* @param {string} obsidianTargetPath - Target .obsidian path
|
|
275
|
+
* @param {Object} options - Options
|
|
276
|
+
* @param {boolean} options.dryRun - Dry run mode
|
|
277
|
+
* @param {Function} options.onProgress - Callback for progress updates (file, action, detail)
|
|
278
|
+
* @returns {Promise<Object>} Result with counts and changes
|
|
279
|
+
*/
|
|
280
|
+
async function copyObsidianDirSmart(obsidianTemplatePath, obsidianTargetPath, options = {}) {
|
|
281
|
+
const { dryRun = false, onProgress } = options;
|
|
282
|
+
|
|
283
|
+
// Ensure target directory exists
|
|
284
|
+
if (!dryRun) {
|
|
285
|
+
await fs.ensureDir(obsidianTargetPath);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Get manifest
|
|
289
|
+
const manifest = await getManifest(obsidianTemplatePath);
|
|
290
|
+
|
|
291
|
+
// Get all template files
|
|
292
|
+
const templateFiles = await getAllFiles(obsidianTemplatePath);
|
|
293
|
+
|
|
294
|
+
// Process each file
|
|
295
|
+
const results = {
|
|
296
|
+
added: [],
|
|
297
|
+
updated: [],
|
|
298
|
+
merged: [],
|
|
299
|
+
unchanged: [],
|
|
300
|
+
preserved: [],
|
|
301
|
+
skipped: [],
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
for (const relativePath of templateFiles) {
|
|
305
|
+
const result = await processObsidianFile(
|
|
306
|
+
relativePath,
|
|
307
|
+
obsidianTemplatePath,
|
|
308
|
+
obsidianTargetPath,
|
|
309
|
+
manifest,
|
|
310
|
+
{ dryRun }
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
// Categorize result
|
|
314
|
+
switch (result.action) {
|
|
315
|
+
case 'added':
|
|
316
|
+
results.added.push(result);
|
|
317
|
+
if (onProgress) onProgress(result.file, 'added', result);
|
|
318
|
+
break;
|
|
319
|
+
case 'updated':
|
|
320
|
+
results.updated.push(result);
|
|
321
|
+
if (onProgress) onProgress(result.file, 'updated', result);
|
|
322
|
+
break;
|
|
323
|
+
case 'merged':
|
|
324
|
+
results.merged.push(result);
|
|
325
|
+
if (onProgress) onProgress(result.file, 'merged', result);
|
|
326
|
+
break;
|
|
327
|
+
case 'unchanged':
|
|
328
|
+
results.unchanged.push(result);
|
|
329
|
+
if (onProgress) onProgress(result.file, 'unchanged', result);
|
|
330
|
+
break;
|
|
331
|
+
case 'preserved':
|
|
332
|
+
results.preserved.push(result);
|
|
333
|
+
if (onProgress) onProgress(result.file, 'preserved', result);
|
|
334
|
+
break;
|
|
335
|
+
case 'skip':
|
|
336
|
+
results.skipped.push(result);
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Check for user files not in template (should be preserved)
|
|
342
|
+
const targetFiles = await fs.pathExists(obsidianTargetPath)
|
|
343
|
+
? await getAllFiles(obsidianTargetPath)
|
|
344
|
+
: [];
|
|
345
|
+
|
|
346
|
+
for (const relativePath of targetFiles) {
|
|
347
|
+
if (relativePath === MANIFEST_FILE) continue;
|
|
348
|
+
if (templateFiles.includes(relativePath)) continue;
|
|
349
|
+
|
|
350
|
+
// This is a user-only file
|
|
351
|
+
results.preserved.push({ file: relativePath, reason: 'user-only' });
|
|
352
|
+
if (onProgress) onProgress(relativePath, 'preserved', { reason: 'user-only' });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return results;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
module.exports = {
|
|
359
|
+
MERGE_STRATEGIES,
|
|
360
|
+
MANIFEST_FILE,
|
|
361
|
+
getManifest,
|
|
362
|
+
getAllFiles,
|
|
363
|
+
mergeCommunityPlugins,
|
|
364
|
+
processObsidianFile,
|
|
365
|
+
copyObsidianDirSmart,
|
|
366
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 2ndBrain CLI - Interactive Prompt Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides user confirmation and selection prompts for CLI interactions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const readline = require('readline');
|
|
8
|
+
const chalk = require('chalk');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create a readline interface
|
|
12
|
+
* @returns {readline.Interface}
|
|
13
|
+
*/
|
|
14
|
+
function createInterface() {
|
|
15
|
+
return readline.createInterface({
|
|
16
|
+
input: process.stdin,
|
|
17
|
+
output: process.stdout,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Ask a Yes/No confirmation question
|
|
23
|
+
* @param {string} question - Question to ask
|
|
24
|
+
* @param {boolean} [default=true] - Default answer if user presses Enter
|
|
25
|
+
* @returns {Promise<boolean>} True if user confirms, false otherwise
|
|
26
|
+
*/
|
|
27
|
+
function confirm(question, defaultValue = true) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const rl = createInterface();
|
|
30
|
+
const prompt = defaultValue ? ' [Y/n]: ' : ' [y/N]: ';
|
|
31
|
+
|
|
32
|
+
rl.question(`${question}${prompt}`, (answer) => {
|
|
33
|
+
rl.close();
|
|
34
|
+
|
|
35
|
+
if (answer.trim() === '') {
|
|
36
|
+
resolve(defaultValue);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const normalized = answer.trim().toLowerCase();
|
|
41
|
+
resolve(normalized === 'y' || normalized === 'yes');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Ask a selection question with options
|
|
48
|
+
* @param {string} question - Question to ask
|
|
49
|
+
* @param {Array<{label: string, value: any, description?: string}>} options - Options to display
|
|
50
|
+
* @param {number|string} [default] - Default option value if user presses Enter
|
|
51
|
+
* @returns {Promise<any>} Selected value
|
|
52
|
+
*/
|
|
53
|
+
function select(question, options, defaultValue) {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const rl = createInterface();
|
|
56
|
+
|
|
57
|
+
console.log('');
|
|
58
|
+
console.log(chalk.cyan(question));
|
|
59
|
+
|
|
60
|
+
options.forEach((opt, idx) => {
|
|
61
|
+
const isDefault = defaultValue !== undefined && opt.value === defaultValue;
|
|
62
|
+
const marker = isDefault ? ' (default)' : '';
|
|
63
|
+
const num = `${idx + 1})`.padStart(3);
|
|
64
|
+
const label = `${num} ${opt.label}${marker}`;
|
|
65
|
+
|
|
66
|
+
if (opt.description) {
|
|
67
|
+
console.log(` ${chalk.dim(label)} - ${opt.description}`);
|
|
68
|
+
} else {
|
|
69
|
+
console.log(` ${label}`);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
rl.question(chalk.yellow('\nYour choice: '), (answer) => {
|
|
74
|
+
rl.close();
|
|
75
|
+
|
|
76
|
+
const trimmed = answer.trim();
|
|
77
|
+
|
|
78
|
+
if (trimmed === '' && defaultValue !== undefined) {
|
|
79
|
+
resolve(defaultValue);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const num = parseInt(trimmed, 10);
|
|
84
|
+
if (!isNaN(num) && num >= 1 && num <= options.length) {
|
|
85
|
+
resolve(options[num - 1].value);
|
|
86
|
+
} else {
|
|
87
|
+
// Try to find matching value
|
|
88
|
+
const match = options.find(opt => opt.value === trimmed);
|
|
89
|
+
if (match) {
|
|
90
|
+
resolve(match.value);
|
|
91
|
+
} else {
|
|
92
|
+
console.log(chalk.yellow('Invalid choice, using default (1).'));
|
|
93
|
+
resolve(options[0].value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Confirm batch updates with a summary of changes
|
|
102
|
+
* @param {Array<{file: string, added: number, removed: number, binary?: boolean, large?: boolean}>} changes - List of changes
|
|
103
|
+
* @param {Function} log - Logger function
|
|
104
|
+
* @param {Object} chalk - Chalk module for colors
|
|
105
|
+
* @returns {Promise<'all'|'review'|'skip'>} User's choice
|
|
106
|
+
*/
|
|
107
|
+
async function confirmBatchUpdates(changes, log, chalk) {
|
|
108
|
+
if (changes.length === 0) {
|
|
109
|
+
return 'skip';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log('');
|
|
113
|
+
log.info(`${changes.length} file(s) have changes:`);
|
|
114
|
+
|
|
115
|
+
for (const change of changes) {
|
|
116
|
+
if (change.binary) {
|
|
117
|
+
console.log(` * ${chalk.yellow(change.file)} (binary file)`);
|
|
118
|
+
} else if (change.large) {
|
|
119
|
+
console.log(` * ${chalk.yellow(change.file)} (large file, use --force to review)`);
|
|
120
|
+
} else {
|
|
121
|
+
const parts = [];
|
|
122
|
+
if (change.added > 0) parts.push(chalk.green(`+${change.added}`));
|
|
123
|
+
if (change.removed > 0) parts.push(chalk.red(`-${change.removed}`));
|
|
124
|
+
const summary = parts.join(' ');
|
|
125
|
+
console.log(` * ${chalk.yellow(change.file)} (${summary} lines)`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log('');
|
|
130
|
+
|
|
131
|
+
const choice = await select('How would you like to proceed?', [
|
|
132
|
+
{ label: 'Apply all changes', value: 'all', description: 'Update all changed files without review' },
|
|
133
|
+
{ label: 'Review each file individually', value: 'review', description: 'Confirm each file one by one' },
|
|
134
|
+
{ label: 'Skip all changes', value: 'skip', description: 'Cancel the update' },
|
|
135
|
+
], 1);
|
|
136
|
+
|
|
137
|
+
return choice;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Confirm a single file update
|
|
142
|
+
* @param {string} file - File path
|
|
143
|
+
* @param {boolean} [default=true] - Default answer
|
|
144
|
+
* @returns {Promise<boolean>} True if user wants to update
|
|
145
|
+
*/
|
|
146
|
+
function confirmFile(file, defaultValue = true) {
|
|
147
|
+
return confirm(`Update ${chalk.yellow(file)}?`, defaultValue);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Prompt for handling large files
|
|
152
|
+
* @param {string} file - File path
|
|
153
|
+
* @param {number} size - File size in bytes
|
|
154
|
+
* @returns {Promise<boolean>} True if user wants to proceed
|
|
155
|
+
*/
|
|
156
|
+
function confirmLargeFile(file, size) {
|
|
157
|
+
const sizeKB = (size / 1024).toFixed(2);
|
|
158
|
+
console.log('');
|
|
159
|
+
console.log(chalk.yellow(`Warning: ${file} is a large file (${sizeKB} KB).`));
|
|
160
|
+
return confirm('Update this file anyway?', false);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Prompt for handling binary files
|
|
165
|
+
* @param {string} file - File path
|
|
166
|
+
* @returns {Promise<boolean>} True if user wants to proceed
|
|
167
|
+
*/
|
|
168
|
+
function confirmBinaryFile(file) {
|
|
169
|
+
console.log('');
|
|
170
|
+
console.log(chalk.yellow(`Warning: ${file} appears to be a binary file.`));
|
|
171
|
+
console.log(chalk.dim('Binary files cannot show diffs and will be completely replaced.'));
|
|
172
|
+
return confirm('Update this file anyway?', false);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = {
|
|
176
|
+
confirm,
|
|
177
|
+
select,
|
|
178
|
+
confirmBatchUpdates,
|
|
179
|
+
confirmFile,
|
|
180
|
+
confirmLargeFile,
|
|
181
|
+
confirmBinaryFile,
|
|
182
|
+
};
|