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.
- package/.aios-core/development/agents/squad-creator.md +261 -0
- package/.aios-core/development/scripts/squad/index.js +36 -2
- package/.aios-core/development/scripts/squad/squad-designer.js +1010 -0
- package/.aios-core/development/scripts/squad/squad-generator.js +1317 -0
- package/.aios-core/development/tasks/squad-creator-create.md +289 -0
- package/.aios-core/development/tasks/squad-creator-design.md +334 -0
- package/.aios-core/development/tasks/squad-creator-download.md +65 -0
- package/.aios-core/development/tasks/squad-creator-list.md +225 -0
- package/.aios-core/development/tasks/squad-creator-publish.md +86 -0
- package/.aios-core/development/tasks/squad-creator-sync-synkra.md +83 -0
- package/.aios-core/install-manifest.yaml +2233 -349
- package/.aios-core/schemas/squad-design-schema.json +299 -0
- package/bin/aios-init.js +126 -0
- package/package.json +4 -1
- package/scripts/generate-install-manifest.js +337 -0
- package/scripts/validate-manifest.js +265 -0
- package/squads/.designs/duplicate-test-design.yaml +23 -0
- package/squads/.designs/force-test-design.yaml +23 -0
- package/squads/.designs/nested-test-design.yaml +23 -0
- package/squads/.designs/test-squad-design.yaml +23 -0
- package/src/installer/brownfield-upgrader.js +438 -0
- package/src/installer/file-hasher.js +137 -0
|
@@ -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
|
+
};
|