aios-core 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ };
@@ -0,0 +1,137 @@
1
+ /**
2
+ * File Hasher Utility
3
+ * Cross-platform file hashing with line ending normalization
4
+ *
5
+ * @module src/installer/file-hasher
6
+ * @story 6.18 - Dynamic Manifest & Brownfield Upgrade System
7
+ */
8
+
9
+ const crypto = require('crypto');
10
+ const fs = require('fs-extra');
11
+ const path = require('path');
12
+
13
+ /**
14
+ * List of file extensions that should be treated as binary (not normalized)
15
+ */
16
+ const BINARY_EXTENSIONS = [
17
+ '.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.svg',
18
+ '.pdf', '.zip', '.tar', '.gz', '.7z',
19
+ '.woff', '.woff2', '.ttf', '.eot',
20
+ '.mp3', '.mp4', '.wav', '.avi',
21
+ '.exe', '.dll', '.so', '.dylib',
22
+ ];
23
+
24
+ /**
25
+ * Check if a file should be treated as binary based on extension
26
+ * @param {string} filePath - Path to the file
27
+ * @returns {boolean} - True if file is binary
28
+ */
29
+ function isBinaryFile(filePath) {
30
+ const ext = path.extname(filePath).toLowerCase();
31
+ return BINARY_EXTENSIONS.includes(ext);
32
+ }
33
+
34
+ /**
35
+ * Normalize line endings from CRLF to LF for consistent cross-platform hashing
36
+ * @param {string} content - File content as string
37
+ * @returns {string} - Normalized content with LF line endings
38
+ */
39
+ function normalizeLineEndings(content) {
40
+ return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
41
+ }
42
+
43
+ /**
44
+ * Remove UTF-8 BOM if present
45
+ * @param {string} content - File content
46
+ * @returns {string} - Content without BOM
47
+ */
48
+ function removeBOM(content) {
49
+ if (content.charCodeAt(0) === 0xFEFF) {
50
+ return content.slice(1);
51
+ }
52
+ return content;
53
+ }
54
+
55
+ /**
56
+ * Compute SHA256 hash of a file with cross-platform normalization
57
+ * Text files have line endings normalized for consistent hashes across OS
58
+ * Binary files are hashed as-is
59
+ *
60
+ * @param {string} filePath - Absolute path to the file
61
+ * @returns {string} - SHA256 hash as hex string
62
+ * @throws {Error} - If file cannot be read
63
+ */
64
+ function hashFile(filePath) {
65
+ if (!fs.existsSync(filePath)) {
66
+ throw new Error(`File not found: ${filePath}`);
67
+ }
68
+
69
+ const stats = fs.statSync(filePath);
70
+ if (stats.isDirectory()) {
71
+ throw new Error(`Cannot hash directory: ${filePath}`);
72
+ }
73
+
74
+ let content;
75
+
76
+ if (isBinaryFile(filePath)) {
77
+ // Binary files: hash raw bytes
78
+ content = fs.readFileSync(filePath);
79
+ } else {
80
+ // Text files: normalize line endings and remove BOM
81
+ const rawContent = fs.readFileSync(filePath, 'utf8');
82
+ const withoutBOM = removeBOM(rawContent);
83
+ const normalized = normalizeLineEndings(withoutBOM);
84
+ content = Buffer.from(normalized, 'utf8');
85
+ }
86
+
87
+ return crypto.createHash('sha256').update(content).digest('hex');
88
+ }
89
+
90
+ /**
91
+ * Compute SHA256 hash of a string (for manifest integrity)
92
+ * @param {string} content - String content to hash
93
+ * @returns {string} - SHA256 hash as hex string
94
+ */
95
+ function hashString(content) {
96
+ return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
97
+ }
98
+
99
+ /**
100
+ * Compare two hashes for equality
101
+ * @param {string} hash1 - First hash
102
+ * @param {string} hash2 - Second hash
103
+ * @returns {boolean} - True if hashes match
104
+ */
105
+ function hashesMatch(hash1, hash2) {
106
+ if (!hash1 || !hash2) return false;
107
+ return hash1.toLowerCase() === hash2.toLowerCase();
108
+ }
109
+
110
+ /**
111
+ * Get file metadata including hash
112
+ * @param {string} filePath - Absolute path to the file
113
+ * @param {string} basePath - Base path for relative path calculation
114
+ * @returns {Object} - File metadata object
115
+ */
116
+ function getFileMetadata(filePath, basePath) {
117
+ const stats = fs.statSync(filePath);
118
+ const relativePath = path.relative(basePath, filePath).replace(/\\/g, '/');
119
+
120
+ return {
121
+ path: relativePath,
122
+ hash: `sha256:${hashFile(filePath)}`,
123
+ size: stats.size,
124
+ isBinary: isBinaryFile(filePath),
125
+ };
126
+ }
127
+
128
+ module.exports = {
129
+ hashFile,
130
+ hashString,
131
+ hashesMatch,
132
+ getFileMetadata,
133
+ isBinaryFile,
134
+ normalizeLineEndings,
135
+ removeBOM,
136
+ BINARY_EXTENSIONS,
137
+ };