aios-core 3.1.0 → 3.3.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.
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Validate Install Manifest
4
+ * Ensures install-manifest.yaml is up-to-date with actual files
5
+ *
6
+ * @script scripts/validate-manifest.js
7
+ * @story 6.18 - Dynamic Manifest & Brownfield Upgrade System
8
+ *
9
+ * Usage:
10
+ * node scripts/validate-manifest.js
11
+ * npm run validate:manifest
12
+ *
13
+ * Exit codes:
14
+ * 0 - Manifest is valid and up-to-date
15
+ * 1 - Manifest is outdated or has issues
16
+ */
17
+
18
+ const fs = require('fs-extra');
19
+ const path = require('path');
20
+ const yaml = require('js-yaml');
21
+ const { hashFile } = require('../src/installer/file-hasher');
22
+ const {
23
+ scanDirectory,
24
+ FOLDERS_TO_COPY,
25
+ ROOT_FILES_TO_COPY,
26
+ getFileType,
27
+ } = require('./generate-install-manifest');
28
+
29
+ /**
30
+ * Validation result object
31
+ * @typedef {Object} ValidationResult
32
+ * @property {boolean} valid - Whether manifest is valid
33
+ * @property {string[]} newFiles - Files in filesystem but not in manifest
34
+ * @property {string[]} removedFiles - Files in manifest but not in filesystem
35
+ * @property {string[]} modifiedFiles - Files with different hashes
36
+ * @property {string[]} errors - Error messages
37
+ */
38
+
39
+ /**
40
+ * Load and parse the install manifest
41
+ * @returns {Object|null} - Parsed manifest or null if not found
42
+ */
43
+ function loadManifest() {
44
+ const manifestPath = path.join(__dirname, '..', '.aios-core', 'install-manifest.yaml');
45
+
46
+ if (!fs.existsSync(manifestPath)) {
47
+ return null;
48
+ }
49
+
50
+ const content = fs.readFileSync(manifestPath, 'utf8');
51
+ return yaml.load(content);
52
+ }
53
+
54
+ /**
55
+ * Get current files from filesystem
56
+ * @returns {Map<string, Object>} - Map of relativePath -> file metadata
57
+ */
58
+ function getCurrentFiles() {
59
+ const aiosCoreDir = path.join(__dirname, '..', '.aios-core');
60
+ const filesMap = new Map();
61
+
62
+ // Scan folders
63
+ const allFiles = [];
64
+ for (const folder of FOLDERS_TO_COPY) {
65
+ const folderPath = path.join(aiosCoreDir, folder);
66
+ if (fs.existsSync(folderPath)) {
67
+ scanDirectory(folderPath, aiosCoreDir, allFiles);
68
+ }
69
+ }
70
+
71
+ // Add root files
72
+ for (const file of ROOT_FILES_TO_COPY) {
73
+ const filePath = path.join(aiosCoreDir, file);
74
+ if (fs.existsSync(filePath)) {
75
+ allFiles.push(filePath);
76
+ }
77
+ }
78
+
79
+ // Build map
80
+ for (const fullPath of allFiles) {
81
+ const relativePath = path.relative(aiosCoreDir, fullPath).replace(/\\/g, '/');
82
+ try {
83
+ const hash = hashFile(fullPath);
84
+ filesMap.set(relativePath, {
85
+ path: relativePath,
86
+ hash: `sha256:${hash}`,
87
+ type: getFileType(relativePath),
88
+ });
89
+ } catch (error) {
90
+ console.warn(`Warning: Could not hash ${relativePath}: ${error.message}`);
91
+ }
92
+ }
93
+
94
+ return filesMap;
95
+ }
96
+
97
+ /**
98
+ * Validate manifest against current filesystem
99
+ * @returns {ValidationResult} - Validation results
100
+ */
101
+ function validateManifest() {
102
+ const result = {
103
+ valid: true,
104
+ newFiles: [],
105
+ removedFiles: [],
106
+ modifiedFiles: [],
107
+ errors: [],
108
+ };
109
+
110
+ // Load manifest
111
+ const manifest = loadManifest();
112
+ if (!manifest) {
113
+ result.valid = false;
114
+ result.errors.push('install-manifest.yaml not found');
115
+ return result;
116
+ }
117
+
118
+ if (!manifest.files || !Array.isArray(manifest.files)) {
119
+ result.valid = false;
120
+ result.errors.push('Manifest has no files array');
121
+ return result;
122
+ }
123
+
124
+ // Get current files
125
+ const currentFiles = getCurrentFiles();
126
+
127
+ // Build set of manifest paths
128
+ const manifestPaths = new Set();
129
+ const manifestMap = new Map();
130
+
131
+ for (const entry of manifest.files) {
132
+ const normalizedPath = entry.path.replace(/\\/g, '/');
133
+ manifestPaths.add(normalizedPath);
134
+ manifestMap.set(normalizedPath, entry);
135
+ }
136
+
137
+ // Check for new files (in filesystem but not in manifest)
138
+ for (const [filePath, _fileData] of currentFiles) {
139
+ if (!manifestPaths.has(filePath)) {
140
+ result.newFiles.push(filePath);
141
+ result.valid = false;
142
+ }
143
+ }
144
+
145
+ // Check for removed files and hash mismatches
146
+ for (const [manifestPath, manifestEntry] of manifestMap) {
147
+ const currentFile = currentFiles.get(manifestPath);
148
+
149
+ if (!currentFile) {
150
+ // File in manifest but not in filesystem
151
+ result.removedFiles.push(manifestPath);
152
+ result.valid = false;
153
+ } else if (currentFile.hash !== manifestEntry.hash) {
154
+ // Hash mismatch
155
+ result.modifiedFiles.push({
156
+ path: manifestPath,
157
+ manifestHash: manifestEntry.hash,
158
+ currentHash: currentFile.hash,
159
+ });
160
+ result.valid = false;
161
+ }
162
+ }
163
+
164
+ return result;
165
+ }
166
+
167
+ /**
168
+ * Print validation report
169
+ * @param {ValidationResult} result - Validation results
170
+ */
171
+ function printReport(result) {
172
+ console.log('='.repeat(60));
173
+ console.log('AIOS-Core Manifest Validation Report');
174
+ console.log('='.repeat(60));
175
+ console.log('');
176
+
177
+ if (result.errors.length > 0) {
178
+ console.log('❌ ERRORS:');
179
+ for (const error of result.errors) {
180
+ console.log(` - ${error}`);
181
+ }
182
+ console.log('');
183
+ }
184
+
185
+ if (result.newFiles.length > 0) {
186
+ console.log(`📁 NEW FILES (${result.newFiles.length}) - not in manifest:`);
187
+ for (const file of result.newFiles.slice(0, 20)) {
188
+ console.log(` + ${file}`);
189
+ }
190
+ if (result.newFiles.length > 20) {
191
+ console.log(` ... and ${result.newFiles.length - 20} more`);
192
+ }
193
+ console.log('');
194
+ }
195
+
196
+ if (result.removedFiles.length > 0) {
197
+ console.log(`🗑️ REMOVED FILES (${result.removedFiles.length}) - in manifest but missing:`);
198
+ for (const file of result.removedFiles.slice(0, 20)) {
199
+ console.log(` - ${file}`);
200
+ }
201
+ if (result.removedFiles.length > 20) {
202
+ console.log(` ... and ${result.removedFiles.length - 20} more`);
203
+ }
204
+ console.log('');
205
+ }
206
+
207
+ if (result.modifiedFiles.length > 0) {
208
+ console.log(`📝 MODIFIED FILES (${result.modifiedFiles.length}) - hash mismatch:`);
209
+ for (const file of result.modifiedFiles.slice(0, 20)) {
210
+ console.log(` ~ ${file.path}`);
211
+ if (process.env.VERBOSE) {
212
+ console.log(` manifest: ${file.manifestHash}`);
213
+ console.log(` current: ${file.currentHash}`);
214
+ }
215
+ }
216
+ if (result.modifiedFiles.length > 20) {
217
+ console.log(` ... and ${result.modifiedFiles.length - 20} more`);
218
+ }
219
+ console.log('');
220
+ }
221
+
222
+ // Summary
223
+ console.log('-'.repeat(60));
224
+ if (result.valid) {
225
+ console.log('✅ Manifest is VALID and up-to-date');
226
+ } else {
227
+ console.log('❌ Manifest is OUTDATED');
228
+ console.log('');
229
+ console.log('To fix, run: npm run generate:manifest');
230
+ }
231
+ console.log('-'.repeat(60));
232
+ }
233
+
234
+ /**
235
+ * Main execution
236
+ */
237
+ async function main() {
238
+ try {
239
+ const result = validateManifest();
240
+ printReport(result);
241
+
242
+ if (!result.valid) {
243
+ process.exit(1);
244
+ }
245
+ process.exit(0);
246
+ } catch (error) {
247
+ console.error('\n❌ Error validating manifest:', error.message);
248
+ if (process.env.DEBUG) {
249
+ console.error(error.stack);
250
+ }
251
+ process.exit(1);
252
+ }
253
+ }
254
+
255
+ // Run if called directly
256
+ if (require.main === module) {
257
+ main();
258
+ }
259
+
260
+ module.exports = {
261
+ validateManifest,
262
+ loadManifest,
263
+ getCurrentFiles,
264
+ printReport,
265
+ };
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Brownfield Upgrader
3
+ * Handles incremental upgrades for existing AIOS-Core installations
4
+ *
5
+ * @module src/installer/brownfield-upgrader
6
+ * @story 6.18 - Dynamic Manifest & Brownfield Upgrade System
7
+ */
8
+
9
+ const fs = require('fs-extra');
10
+ const path = require('path');
11
+ const yaml = require('js-yaml');
12
+ const semver = require('semver');
13
+ const { hashFile, hashesMatch } = require('./file-hasher');
14
+
15
+ /**
16
+ * Upgrade report structure
17
+ * @typedef {Object} UpgradeReport
18
+ * @property {string} sourceVersion - Version being upgraded to
19
+ * @property {string} installedVersion - Currently installed version
20
+ * @property {Object[]} newFiles - Files to be added
21
+ * @property {Object[]} modifiedFiles - Files that changed (framework side)
22
+ * @property {Object[]} userModifiedFiles - Files modified by user (won't overwrite)
23
+ * @property {Object[]} deletedFiles - Files removed from framework
24
+ * @property {boolean} upgradeAvailable - Whether an upgrade is available
25
+ */
26
+
27
+ /**
28
+ * Load manifest from a given base path
29
+ * @param {string} basePath - Base path to look for manifest
30
+ * @param {string} manifestName - Name of manifest file
31
+ * @returns {Object|null} - Parsed manifest or null
32
+ */
33
+ function loadManifest(basePath, manifestName = 'install-manifest.yaml') {
34
+ const manifestPath = path.join(basePath, manifestName);
35
+
36
+ if (!fs.existsSync(manifestPath)) {
37
+ return null;
38
+ }
39
+
40
+ try {
41
+ const content = fs.readFileSync(manifestPath, 'utf8');
42
+ return yaml.load(content);
43
+ } catch (error) {
44
+ console.error(`Error loading manifest from ${manifestPath}:`, error.message);
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Load installed manifest from target project
51
+ * @param {string} targetDir - Target project directory
52
+ * @returns {Object|null} - Installed manifest or null if not found
53
+ */
54
+ function loadInstalledManifest(targetDir) {
55
+ return loadManifest(
56
+ path.join(targetDir, '.aios-core'),
57
+ '.installed-manifest.yaml',
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Load source manifest from package
63
+ * @param {string} sourceDir - Source package directory (.aios-core from npm)
64
+ * @returns {Object|null} - Source manifest
65
+ */
66
+ function loadSourceManifest(sourceDir) {
67
+ return loadManifest(sourceDir, 'install-manifest.yaml');
68
+ }
69
+
70
+ /**
71
+ * Build file map from manifest for quick lookup
72
+ * @param {Object} manifest - Manifest object
73
+ * @returns {Map<string, Object>} - Map of path -> entry
74
+ */
75
+ function buildFileMap(manifest) {
76
+ const map = new Map();
77
+ if (manifest && manifest.files) {
78
+ for (const entry of manifest.files) {
79
+ const normalizedPath = entry.path.replace(/\\/g, '/');
80
+ map.set(normalizedPath, entry);
81
+ }
82
+ }
83
+ return map;
84
+ }
85
+
86
+ /**
87
+ * Check if a file has been modified by the user
88
+ * Compares current file hash against installed manifest hash
89
+ *
90
+ * @param {string} filePath - Absolute path to file
91
+ * @param {string} expectedHash - Hash from installed manifest (with sha256: prefix)
92
+ * @returns {boolean} - True if file was modified by user
93
+ */
94
+ function isUserModified(filePath, expectedHash) {
95
+ if (!fs.existsSync(filePath)) {
96
+ return false;
97
+ }
98
+
99
+ try {
100
+ const currentHash = `sha256:${hashFile(filePath)}`;
101
+ return !hashesMatch(currentHash, expectedHash);
102
+ } catch (_error) {
103
+ // If we can't hash, assume it's modified
104
+ return true;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Generate upgrade report comparing source and installed manifests
110
+ *
111
+ * @param {Object} sourceManifest - Manifest from source (npm package)
112
+ * @param {Object} installedManifest - Manifest from target installation
113
+ * @param {string} targetDir - Target project directory
114
+ * @returns {UpgradeReport} - Detailed upgrade report
115
+ */
116
+ function generateUpgradeReport(sourceManifest, installedManifest, targetDir) {
117
+ const report = {
118
+ sourceVersion: sourceManifest?.version || 'unknown',
119
+ installedVersion: installedManifest?.installed_version || installedManifest?.version || 'unknown',
120
+ newFiles: [],
121
+ modifiedFiles: [],
122
+ userModifiedFiles: [],
123
+ deletedFiles: [],
124
+ unchangedFiles: 0,
125
+ upgradeAvailable: false,
126
+ };
127
+
128
+ // Check if upgrade is available via semver
129
+ if (sourceManifest?.version && installedManifest?.installed_version) {
130
+ const sourceVer = semver.coerce(sourceManifest.version);
131
+ const installedVer = semver.coerce(installedManifest.installed_version);
132
+
133
+ if (sourceVer && installedVer) {
134
+ report.upgradeAvailable = semver.gt(sourceVer, installedVer);
135
+ }
136
+ } else if (sourceManifest?.version && !installedManifest) {
137
+ // No installed manifest means this is a fresh install scenario
138
+ report.upgradeAvailable = false;
139
+ }
140
+
141
+ const sourceMap = buildFileMap(sourceManifest);
142
+ const installedMap = buildFileMap(installedManifest);
143
+
144
+ const aiosCoreDir = path.join(targetDir, '.aios-core');
145
+
146
+ // Check source files against installed
147
+ for (const [filePath, sourceEntry] of sourceMap) {
148
+ const installedEntry = installedMap.get(filePath);
149
+ const absolutePath = path.join(aiosCoreDir, filePath);
150
+
151
+ if (!installedEntry) {
152
+ // New file in source
153
+ report.newFiles.push({
154
+ path: filePath,
155
+ type: sourceEntry.type,
156
+ hash: sourceEntry.hash,
157
+ size: sourceEntry.size,
158
+ });
159
+ } else if (!hashesMatch(sourceEntry.hash, installedEntry.hash)) {
160
+ // File changed in source
161
+ // Check if user modified the local copy
162
+ if (isUserModified(absolutePath, installedEntry.hash)) {
163
+ report.userModifiedFiles.push({
164
+ path: filePath,
165
+ type: sourceEntry.type,
166
+ sourceHash: sourceEntry.hash,
167
+ installedHash: installedEntry.hash,
168
+ reason: 'User modified local file',
169
+ });
170
+ } else {
171
+ report.modifiedFiles.push({
172
+ path: filePath,
173
+ type: sourceEntry.type,
174
+ sourceHash: sourceEntry.hash,
175
+ installedHash: installedEntry.hash,
176
+ });
177
+ }
178
+ } else {
179
+ report.unchangedFiles++;
180
+ }
181
+ }
182
+
183
+ // Check for deleted files (in installed but not in source)
184
+ for (const [filePath, installedEntry] of installedMap) {
185
+ if (!sourceMap.has(filePath)) {
186
+ report.deletedFiles.push({
187
+ path: filePath,
188
+ type: installedEntry.type,
189
+ hash: installedEntry.hash,
190
+ });
191
+ }
192
+ }
193
+
194
+ return report;
195
+ }
196
+
197
+ /**
198
+ * Apply upgrade to target directory
199
+ *
200
+ * @param {UpgradeReport} report - Upgrade report
201
+ * @param {string} sourceDir - Source directory (npm package .aios-core)
202
+ * @param {string} targetDir - Target project directory
203
+ * @param {Object} options - Upgrade options
204
+ * @param {boolean} options.dryRun - If true, don't actually copy files
205
+ * @param {boolean} options.includeModified - If true, also update modified files
206
+ * @returns {Object} - Result of upgrade operation
207
+ */
208
+ async function applyUpgrade(report, sourceDir, targetDir, options = {}) {
209
+ const { dryRun = false, includeModified = true } = options;
210
+ const result = {
211
+ success: true,
212
+ filesInstalled: [],
213
+ filesSkipped: [],
214
+ errors: [],
215
+ };
216
+
217
+ const aiosCoreDir = path.join(targetDir, '.aios-core');
218
+
219
+ // Ensure .aios-core directory exists
220
+ if (!dryRun) {
221
+ fs.ensureDirSync(aiosCoreDir);
222
+ }
223
+
224
+ // Install new files
225
+ for (const file of report.newFiles) {
226
+ const sourcePath = path.join(sourceDir, file.path);
227
+ const targetPath = path.join(aiosCoreDir, file.path);
228
+
229
+ try {
230
+ if (!dryRun) {
231
+ fs.ensureDirSync(path.dirname(targetPath));
232
+ fs.copyFileSync(sourcePath, targetPath);
233
+ }
234
+ result.filesInstalled.push({ path: file.path, action: 'new' });
235
+ } catch (error) {
236
+ result.errors.push({ path: file.path, error: error.message });
237
+ result.success = false;
238
+ }
239
+ }
240
+
241
+ // Update modified files (if option enabled)
242
+ if (includeModified) {
243
+ for (const file of report.modifiedFiles) {
244
+ const sourcePath = path.join(sourceDir, file.path);
245
+ const targetPath = path.join(aiosCoreDir, file.path);
246
+
247
+ try {
248
+ if (!dryRun) {
249
+ fs.ensureDirSync(path.dirname(targetPath));
250
+ fs.copyFileSync(sourcePath, targetPath);
251
+ }
252
+ result.filesInstalled.push({ path: file.path, action: 'updated' });
253
+ } catch (error) {
254
+ result.errors.push({ path: file.path, error: error.message });
255
+ result.success = false;
256
+ }
257
+ }
258
+ }
259
+
260
+ // Skip user-modified files
261
+ for (const file of report.userModifiedFiles) {
262
+ result.filesSkipped.push({
263
+ path: file.path,
264
+ reason: 'User modified - preserving local changes',
265
+ });
266
+ }
267
+
268
+ // Note: We don't delete files that were removed from source
269
+ // This is intentional to preserve user additions
270
+ for (const file of report.deletedFiles) {
271
+ result.filesSkipped.push({
272
+ path: file.path,
273
+ reason: 'Removed from source - keeping local copy',
274
+ });
275
+ }
276
+
277
+ return result;
278
+ }
279
+
280
+ /**
281
+ * Create or update the installed manifest after upgrade
282
+ *
283
+ * @param {string} targetDir - Target project directory
284
+ * @param {Object} sourceManifest - Source manifest that was installed
285
+ * @param {string} sourcePackage - Name and version of source package
286
+ */
287
+ function updateInstalledManifest(targetDir, sourceManifest, sourcePackage) {
288
+ const installedManifestPath = path.join(targetDir, '.aios-core', '.installed-manifest.yaml');
289
+
290
+ const installedManifest = {
291
+ installed_at: new Date().toISOString(),
292
+ installed_from: sourcePackage,
293
+ installed_version: sourceManifest.version,
294
+ source_manifest_hash: `sha256:${require('./file-hasher').hashString(
295
+ JSON.stringify(sourceManifest.files),
296
+ )}`,
297
+ file_count: sourceManifest.files.length,
298
+ files: sourceManifest.files.map(f => ({
299
+ path: f.path,
300
+ hash: f.hash,
301
+ modified_by_user: false,
302
+ })),
303
+ };
304
+
305
+ const yamlContent = yaml.dump(installedManifest, {
306
+ indent: 2,
307
+ lineWidth: 120,
308
+ noRefs: true,
309
+ });
310
+
311
+ const header = `# AIOS-Core Installed Manifest
312
+ # This file tracks what was installed from the npm package
313
+ # Used for brownfield upgrades to detect changes
314
+ # DO NOT EDIT MANUALLY
315
+ #
316
+ `;
317
+
318
+ fs.ensureDirSync(path.dirname(installedManifestPath));
319
+ fs.writeFileSync(installedManifestPath, header + yamlContent, 'utf8');
320
+
321
+ return installedManifestPath;
322
+ }
323
+
324
+ /**
325
+ * Check if an upgrade is available
326
+ *
327
+ * @param {string} sourceDir - Source package directory
328
+ * @param {string} targetDir - Target project directory
329
+ * @returns {Object} - { available: boolean, from: string, to: string }
330
+ */
331
+ function checkUpgradeAvailable(sourceDir, targetDir) {
332
+ const sourceManifest = loadSourceManifest(sourceDir);
333
+ const installedManifest = loadInstalledManifest(targetDir);
334
+
335
+ if (!sourceManifest) {
336
+ return { available: false, error: 'Source manifest not found' };
337
+ }
338
+
339
+ if (!installedManifest) {
340
+ return { available: false, reason: 'No installed manifest (fresh install)' };
341
+ }
342
+
343
+ const sourceVer = semver.coerce(sourceManifest.version);
344
+ const installedVer = semver.coerce(installedManifest.installed_version);
345
+
346
+ if (!sourceVer || !installedVer) {
347
+ return { available: false, error: 'Invalid version numbers' };
348
+ }
349
+
350
+ return {
351
+ available: semver.gt(sourceVer, installedVer),
352
+ from: installedManifest.installed_version,
353
+ to: sourceManifest.version,
354
+ };
355
+ }
356
+
357
+ /**
358
+ * Format upgrade report for display
359
+ * @param {UpgradeReport} report - Upgrade report
360
+ * @returns {string} - Formatted report string
361
+ */
362
+ function formatUpgradeReport(report) {
363
+ const lines = [];
364
+
365
+ lines.push('═'.repeat(60));
366
+ lines.push('AIOS-Core Upgrade Report');
367
+ lines.push('═'.repeat(60));
368
+ lines.push('');
369
+ lines.push(`Current Version: ${report.installedVersion}`);
370
+ lines.push(`Available Version: ${report.sourceVersion}`);
371
+ lines.push(`Upgrade Available: ${report.upgradeAvailable ? 'Yes ✅' : 'No'}`);
372
+ lines.push('');
373
+
374
+ if (report.newFiles.length > 0) {
375
+ lines.push(`📁 New Files (${report.newFiles.length}):`);
376
+ for (const file of report.newFiles.slice(0, 10)) {
377
+ lines.push(` + ${file.path} [${file.type}]`);
378
+ }
379
+ if (report.newFiles.length > 10) {
380
+ lines.push(` ... and ${report.newFiles.length - 10} more`);
381
+ }
382
+ lines.push('');
383
+ }
384
+
385
+ if (report.modifiedFiles.length > 0) {
386
+ lines.push(`📝 Modified Files (${report.modifiedFiles.length}):`);
387
+ for (const file of report.modifiedFiles.slice(0, 10)) {
388
+ lines.push(` ~ ${file.path} [${file.type}]`);
389
+ }
390
+ if (report.modifiedFiles.length > 10) {
391
+ lines.push(` ... and ${report.modifiedFiles.length - 10} more`);
392
+ }
393
+ lines.push('');
394
+ }
395
+
396
+ if (report.userModifiedFiles.length > 0) {
397
+ lines.push(`⚠️ User Modified (${report.userModifiedFiles.length}) - will be preserved:`);
398
+ for (const file of report.userModifiedFiles.slice(0, 10)) {
399
+ lines.push(` ⊘ ${file.path}`);
400
+ }
401
+ if (report.userModifiedFiles.length > 10) {
402
+ lines.push(` ... and ${report.userModifiedFiles.length - 10} more`);
403
+ }
404
+ lines.push('');
405
+ }
406
+
407
+ if (report.deletedFiles.length > 0) {
408
+ lines.push(`🗑️ Removed from Source (${report.deletedFiles.length}):`);
409
+ for (const file of report.deletedFiles.slice(0, 5)) {
410
+ lines.push(` - ${file.path}`);
411
+ }
412
+ if (report.deletedFiles.length > 5) {
413
+ lines.push(` ... and ${report.deletedFiles.length - 5} more`);
414
+ }
415
+ lines.push('');
416
+ }
417
+
418
+ lines.push('─'.repeat(60));
419
+ const totalChanges = report.newFiles.length + report.modifiedFiles.length;
420
+ const totalSkipped = report.userModifiedFiles.length + report.deletedFiles.length;
421
+ lines.push(`Summary: ${totalChanges} files to update, ${totalSkipped} files preserved`);
422
+ lines.push('─'.repeat(60));
423
+
424
+ return lines.join('\n');
425
+ }
426
+
427
+ module.exports = {
428
+ loadManifest,
429
+ loadInstalledManifest,
430
+ loadSourceManifest,
431
+ generateUpgradeReport,
432
+ applyUpgrade,
433
+ updateInstalledManifest,
434
+ checkUpgradeAvailable,
435
+ formatUpgradeReport,
436
+ buildFileMap,
437
+ isUserModified,
438
+ };