aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
813 lines (704 loc) • 23.3 kB
JavaScript
/**
* AIOS Updater
* Intelligent update system for AIOS-Core installations
*
* @module packages/installer/src/updater
* @story Epic 7 - CLI Update Command
* @version 1.0.0
*
* Features:
* - Detects installed version vs latest available
* - Preserves user customizations during updates
* - Supports dry-run and check-only modes
* - Automatic rollback on failure
* - Changelog integration from GitHub releases
*/
;
const fs = require('fs-extra');
const path = require('path');
const https = require('https');
const { execSync } = require('child_process');
const { hashFile, hashesMatch } = require('../installer/file-hasher');
const { PostInstallValidator, formatReport: formatValidationReport } = require('../installer/post-install-validator');
/**
* Update status types
* @enum {string}
*/
const UpdateStatus = {
UP_TO_DATE: 'up_to_date',
UPDATE_AVAILABLE: 'update_available',
UPDATE_REQUIRED: 'update_required', // Breaking changes
CHECK_FAILED: 'check_failed',
};
/**
* File update actions
* @enum {string}
*/
const FileAction = {
NEW: 'new',
UPDATED: 'updated',
PRESERVED: 'preserved', // User customization
DELETED: 'deleted',
UNCHANGED: 'unchanged',
};
/**
* AIOS Updater Class
* Handles intelligent updates while preserving user customizations
*/
class AIOSUpdater {
/**
* Create a new AIOSUpdater instance
*
* @param {string} projectRoot - Project root directory
* @param {Object} [options] - Update options
* @param {boolean} [options.verbose=false] - Enable verbose logging
* @param {boolean} [options.force=false] - Force update even if up-to-date
* @param {boolean} [options.preserveAll=true] - Preserve all customizations
* @param {number} [options.timeout=30000] - HTTP request timeout
*/
constructor(projectRoot, options = {}) {
this.projectRoot = path.resolve(projectRoot);
this.aiosCoreDir = path.join(this.projectRoot, '.aios-core');
this.aiosConfigDir = path.join(this.projectRoot, '.aios');
this.options = {
verbose: options.verbose === true,
force: options.force === true,
preserveAll: options.preserveAll !== false,
timeout: options.timeout || 30000,
};
this.installedVersion = null;
this.latestVersion = null;
this.versionInfo = null;
this.changelog = null;
this.backupDir = null;
}
/**
* Check for available updates
* Compares installed version with latest from npm registry
*
* @returns {Promise<Object>} Update check result
*/
async checkForUpdates() {
const result = {
status: UpdateStatus.CHECK_FAILED,
installed: null,
latest: null,
installedAt: null,
hasUpdate: false,
isBreaking: false,
error: null,
};
try {
// Get installed version
this.installedVersion = await this.getInstalledVersion();
result.installed = this.installedVersion?.version || null;
result.installedAt = this.installedVersion?.installedAt || null;
if (!result.installed) {
result.error = 'AIOS not installed or version info not found';
return result;
}
// Get latest version from npm
this.latestVersion = await this.getLatestVersion();
result.latest = this.latestVersion;
if (!result.latest) {
// Check if we're offline or package doesn't exist
const isOnline = await this.checkConnectivity();
if (!isOnline) {
result.error = 'You appear to be offline. Please check your internet connection.';
} else {
result.error = 'Package aios-core not found on npm registry. This may be a local development installation.';
}
return result;
}
// Compare versions
const comparison = this.compareVersions(result.installed, result.latest);
if (comparison >= 0 && !this.options.force) {
result.status = UpdateStatus.UP_TO_DATE;
result.hasUpdate = false;
} else {
result.hasUpdate = true;
result.isBreaking = this.isBreakingUpdate(result.installed, result.latest);
result.status = result.isBreaking
? UpdateStatus.UPDATE_REQUIRED
: UpdateStatus.UPDATE_AVAILABLE;
}
this.log(`Installed: v${result.installed}, Latest: v${result.latest}`);
return result;
} catch (error) {
result.error = error.message;
this.log(`Check failed: ${error.message}`);
return result;
}
}
/**
* Get installed version from version.json or package.json
*
* @returns {Promise<Object|null>} Version info or null
*/
async getInstalledVersion() {
// Try version.json first (new format)
const versionJsonPath = path.join(this.aiosCoreDir, 'version.json');
if (fs.existsSync(versionJsonPath)) {
try {
const versionInfo = await fs.readJson(versionJsonPath);
this.versionInfo = versionInfo;
return versionInfo;
} catch (error) {
this.log(`Could not read version.json: ${error.message}`);
}
}
// Fallback to package.json
const packageJsonPath = path.join(this.projectRoot, 'node_modules', 'aios-core', 'package.json');
if (fs.existsSync(packageJsonPath)) {
try {
const pkg = await fs.readJson(packageJsonPath);
return { version: pkg.version, installedAt: null, mode: 'unknown' };
} catch (error) {
this.log(`Could not read package.json: ${error.message}`);
}
}
// Try local package.json for framework-development mode
const localPackageJsonPath = path.join(this.projectRoot, 'package.json');
if (fs.existsSync(localPackageJsonPath)) {
try {
const pkg = await fs.readJson(localPackageJsonPath);
if (pkg.name === '@synkra/aios-core' || pkg.name === 'aios-core') {
return { version: pkg.version, installedAt: null, mode: 'framework-development' };
}
} catch (error) {
this.log(`Could not read local package.json: ${error.message}`);
}
}
return null;
}
/**
* Get latest version from npm registry
*
* @returns {Promise<string|null>} Latest version or null
*/
async getLatestVersion() {
return new Promise((resolve) => {
const request = https.get(
'https://registry.npmjs.org/aios-core/latest',
{ timeout: this.options.timeout },
(res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const json = JSON.parse(data);
resolve(json.version || null);
} catch {
resolve(null);
}
});
},
);
request.on('error', (error) => {
this.log(`npm registry error: ${error.message}`);
resolve(null);
});
request.on('timeout', () => {
request.destroy();
this.log('npm registry timeout');
resolve(null);
});
});
}
/**
* Check internet connectivity
*
* @returns {Promise<boolean>} True if online
*/
async checkConnectivity() {
return new Promise((resolve) => {
const request = https.get(
'https://registry.npmjs.org/',
{ timeout: 5000 },
(res) => {
resolve(res.statusCode === 200);
},
);
request.on('error', () => resolve(false));
request.on('timeout', () => {
request.destroy();
resolve(false);
});
});
}
/**
* Compare two semantic versions
*
* @param {string} v1 - First version
* @param {string} v2 - Second version
* @returns {number} -1 if v1 < v2, 0 if equal, 1 if v1 > v2
*/
compareVersions(v1, v2) {
const parts1 = v1.replace(/^v/, '').split('.').map(Number);
const parts2 = v2.replace(/^v/, '').split('.').map(Number);
for (let i = 0; i < 3; i++) {
const p1 = parts1[i] || 0;
const p2 = parts2[i] || 0;
if (p1 > p2) return 1;
if (p1 < p2) return -1;
}
return 0;
}
/**
* Check if update is a breaking (major) version change
*
* @param {string} installed - Installed version
* @param {string} latest - Latest version
* @returns {boolean} True if breaking change
*/
isBreakingUpdate(installed, latest) {
const installedMajor = parseInt(installed.replace(/^v/, '').split('.')[0], 10);
const latestMajor = parseInt(latest.replace(/^v/, '').split('.')[0], 10);
return latestMajor > installedMajor;
}
/**
* Detect user customizations by comparing file hashes
*
* @returns {Promise<Object>} Customization detection result
*/
async detectCustomizations() {
const result = {
customized: [],
unchanged: [],
missing: [],
error: null,
};
// Need version.json with fileHashes
if (!this.versionInfo?.fileHashes) {
// Try to load it
const versionJsonPath = path.join(this.aiosCoreDir, 'version.json');
if (fs.existsSync(versionJsonPath)) {
try {
this.versionInfo = await fs.readJson(versionJsonPath);
} catch (error) {
result.error = `Could not read version.json: ${error.message}`;
return result;
}
} else {
result.error = 'version.json not found - cannot detect customizations';
return result;
}
}
if (!this.versionInfo.fileHashes) {
result.error = 'No file hashes in version.json - cannot detect customizations';
return result;
}
// Compare each file
for (const [relativePath, originalHash] of Object.entries(this.versionInfo.fileHashes)) {
const absolutePath = path.join(this.aiosCoreDir, relativePath);
if (!fs.existsSync(absolutePath)) {
result.missing.push(relativePath);
continue;
}
try {
const currentHash = `sha256:${hashFile(absolutePath)}`;
if (hashesMatch(currentHash, originalHash)) {
result.unchanged.push(relativePath);
} else {
result.customized.push(relativePath);
}
} catch (error) {
this.log(`Could not hash ${relativePath}: ${error.message}`);
result.missing.push(relativePath);
}
}
return result;
}
/**
* Preview what would be updated (dry-run)
*
* @returns {Promise<Object>} Preview result
*/
async previewUpdate() {
const checkResult = await this.checkForUpdates();
if (!checkResult.hasUpdate && !this.options.force) {
return {
willUpdate: false,
reason: 'Already up to date',
...checkResult,
};
}
const customizations = await this.detectCustomizations();
const filesToUpdate = await this.getFilesToUpdate();
return {
willUpdate: true,
currentVersion: checkResult.installed,
targetVersion: checkResult.latest,
isBreaking: checkResult.isBreaking,
files: {
new: filesToUpdate.filter((f) => f.action === FileAction.NEW),
updated: filesToUpdate.filter((f) => f.action === FileAction.UPDATED),
preserved: filesToUpdate.filter((f) => f.action === FileAction.PRESERVED),
deleted: filesToUpdate.filter((f) => f.action === FileAction.DELETED),
},
customizations: customizations.customized,
preserveCustomizations: this.options.preserveAll,
};
}
/**
* Get list of files that would be updated
*
* @returns {Promise<Array>} List of files with actions
*/
async getFilesToUpdate() {
// For now, return empty - will be implemented when we have manifest comparison
// This would compare local manifest with latest manifest
return [];
}
/**
* Perform the update
*
* @param {Object} [options] - Update options
* @param {boolean} [options.dryRun=false] - Only preview, don't update
* @param {Function} [options.onProgress] - Progress callback
* @returns {Promise<Object>} Update result
*/
async update(options = {}) {
const dryRun = options.dryRun === true;
const onProgress = options.onProgress || (() => {});
const result = {
success: false,
dryRun,
previousVersion: null,
newVersion: null,
filesUpdated: 0,
filesPreserved: 0,
error: null,
rollbackAvailable: false,
};
try {
// Check for updates
onProgress('checking', 'Checking for updates...');
const checkResult = await this.checkForUpdates();
if (!checkResult.hasUpdate && !this.options.force) {
result.success = true;
result.previousVersion = checkResult.installed;
result.newVersion = checkResult.installed;
result.error = 'Already up to date';
return result;
}
result.previousVersion = checkResult.installed;
result.newVersion = checkResult.latest;
if (dryRun) {
const preview = await this.previewUpdate();
return {
...result,
success: true,
dryRun: true,
preview,
};
}
// Create backup for rollback
onProgress('backup', 'Creating backup...');
await this.createBackup();
result.rollbackAvailable = true;
// Detect customizations to preserve
onProgress('detecting', 'Detecting customizations...');
const customizations = await this.detectCustomizations();
// Download and apply update
onProgress('downloading', 'Downloading update...');
const updateApplied = await this.applyUpdate(checkResult.latest, customizations.customized);
if (!updateApplied.success) {
// Rollback on failure
onProgress('rollback', 'Update failed, rolling back...');
await this.rollback();
result.error = updateApplied.error;
return result;
}
result.filesUpdated = updateApplied.filesUpdated;
result.filesPreserved = customizations.customized.length;
// Update version.json
onProgress('finalizing', 'Updating version info...');
await this.updateVersionInfo(checkResult.latest);
// Validate installation after update
onProgress('validating', 'Validating installation...');
const validationResult = await this.validateAfterUpdate();
result.validationPassed = validationResult.success;
result.integrityScore = validationResult.integrityScore;
if (!validationResult.success && validationResult.integrityScore < 80) {
// Critical validation failure - rollback
onProgress('rollback', 'Validation failed, rolling back...');
await this.rollback();
result.error = `Validation failed (integrity: ${validationResult.integrityScore}%)`;
return result;
}
// Cleanup backup
await this.cleanupBackup();
result.rollbackAvailable = false;
result.success = true;
onProgress('complete', 'Update complete!');
return result;
} catch (error) {
result.error = error.message;
// Attempt rollback
if (result.rollbackAvailable) {
try {
await this.rollback();
result.error += ' (rolled back successfully)';
} catch (rollbackError) {
result.error += ` (rollback failed: ${rollbackError.message})`;
}
}
return result;
}
}
/**
* Create backup before update
*
* @returns {Promise<void>}
*/
async createBackup() {
this.backupDir = path.join(this.aiosConfigDir, 'backup', `pre-update-${Date.now()}`);
await fs.ensureDir(this.backupDir);
// Copy critical files
const filesToBackup = [
'version.json',
'install-manifest.yaml',
];
for (const file of filesToBackup) {
const src = path.join(this.aiosCoreDir, file);
const dest = path.join(this.backupDir, file);
if (fs.existsSync(src)) {
await fs.copy(src, dest);
}
}
this.log(`Backup created at ${this.backupDir}`);
}
/**
* Rollback to previous state
*
* @returns {Promise<void>}
*/
async rollback() {
if (!this.backupDir || !fs.existsSync(this.backupDir)) {
throw new Error('No backup available for rollback');
}
// Restore backed up files
const backupFiles = await fs.readdir(this.backupDir);
for (const file of backupFiles) {
const src = path.join(this.backupDir, file);
const dest = path.join(this.aiosCoreDir, file);
await fs.copy(src, dest, { overwrite: true });
}
this.log('Rollback completed');
}
/**
* Cleanup backup after successful update
*
* @returns {Promise<void>}
*/
async cleanupBackup() {
if (this.backupDir && fs.existsSync(this.backupDir)) {
await fs.remove(this.backupDir);
this.backupDir = null;
}
}
/**
* Apply the update
*
* @param {string} targetVersion - Target version
* @param {Array<string>} customizedFiles - Files to preserve
* @returns {Promise<Object>} Apply result
*/
async applyUpdate(targetVersion, _customizedFiles = []) {
const result = {
success: false,
filesUpdated: 0,
error: null,
};
try {
// Use npm to update the package
const cmd = `npm install aios-core@${targetVersion} --save-exact`;
this.log(`Running: ${cmd}`);
execSync(cmd, {
cwd: this.projectRoot,
stdio: this.options.verbose ? 'inherit' : 'pipe',
timeout: 120000, // 2 minutes
});
result.success = true;
result.filesUpdated = 1; // At least package updated
// TODO: Copy new files from node_modules to .aios-core
// preserving customizedFiles
return result;
} catch (error) {
result.error = error.message;
return result;
}
}
/**
* Update version.json after successful update
*
* @param {string} newVersion - New version
* @returns {Promise<void>}
*/
async updateVersionInfo(newVersion) {
const versionJsonPath = path.join(this.aiosCoreDir, 'version.json');
const versionInfo = {
version: newVersion,
installedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
mode: this.versionInfo?.mode || 'project-development',
fileHashes: {}, // Will be populated by file copy
};
await fs.writeJson(versionJsonPath, versionInfo, { spaces: 2 });
this.log(`Updated version.json to v${newVersion}`);
}
/**
* Validate installation after update using PostInstallValidator
*
* @param {Object} [options] - Validation options
* @param {boolean} [options.verbose=false] - Show detailed output
* @returns {Promise<Object>} Validation result
*/
async validateAfterUpdate(options = {}) {
const result = {
success: false,
integrityScore: 0,
issues: [],
error: null,
};
try {
const validator = new PostInstallValidator(this.projectRoot, null, {
verifyHashes: true,
detectExtras: false,
verbose: options.verbose || this.options.verbose,
requireSignature: false, // Signature may not be available after npm update
});
const report = await validator.validate();
result.success = report.status === 'success' || report.status === 'warning';
result.integrityScore = report.integrityScore;
result.issues = report.issues || [];
result.report = report;
if (options.verbose) {
console.log(formatValidationReport(report, { colors: true }));
}
return result;
} catch (error) {
result.error = error.message;
this.log(`Validation failed: ${error.message}`);
return result;
}
}
/**
* Log if verbose
*
* @param {string} message - Message
*/
log(message) {
if (this.options.verbose) {
console.log(`[AIOSUpdater] ${message}`);
}
}
}
/**
* Format update check result for console
*
* @param {Object} result - Check result
* @param {Object} [options] - Format options
* @returns {string} Formatted output
*/
function formatCheckResult(result, options = {}) {
const useColors = options.colors !== false;
const c = {
reset: useColors ? '\x1b[0m' : '',
bold: useColors ? '\x1b[1m' : '',
green: useColors ? '\x1b[32m' : '',
yellow: useColors ? '\x1b[33m' : '',
red: useColors ? '\x1b[31m' : '',
cyan: useColors ? '\x1b[36m' : '',
dim: useColors ? '\x1b[2m' : '',
};
const lines = [];
lines.push('');
lines.push(`${c.bold}🔍 AIOS Update Check${c.reset}`);
lines.push('');
if (result.installed) {
lines.push(`📦 Current: ${c.cyan}v${result.installed}${c.reset}${result.installedAt ? ` ${c.dim}(installed ${result.installedAt})${c.reset}` : ''}`);
} else {
lines.push(`📦 Current: ${c.red}Not installed${c.reset}`);
}
if (result.latest) {
lines.push(`📦 Latest: ${c.cyan}v${result.latest}${c.reset}`);
}
lines.push('');
switch (result.status) {
case UpdateStatus.UP_TO_DATE:
lines.push(`${c.green}✓ You're up to date!${c.reset}`);
break;
case UpdateStatus.UPDATE_AVAILABLE:
lines.push(`${c.yellow}⬆ Update available!${c.reset}`);
lines.push(` Run ${c.cyan}npx aios-core update${c.reset} to update.`);
break;
case UpdateStatus.UPDATE_REQUIRED:
lines.push(`${c.red}⚠ Breaking update available!${c.reset}`);
lines.push(' Review changelog before updating.');
break;
case UpdateStatus.CHECK_FAILED:
lines.push(`${c.red}✗ Check failed: ${result.error}${c.reset}`);
break;
}
lines.push('');
return lines.join('\n');
}
/**
* Format update result for console
*
* @param {Object} result - Update result
* @param {Object} [options] - Format options
* @returns {string} Formatted output
*/
function formatUpdateResult(result, options = {}) {
const useColors = options.colors !== false;
const c = {
reset: useColors ? '\x1b[0m' : '',
bold: useColors ? '\x1b[1m' : '',
green: useColors ? '\x1b[32m' : '',
yellow: useColors ? '\x1b[33m' : '',
red: useColors ? '\x1b[31m' : '',
cyan: useColors ? '\x1b[36m' : '',
dim: useColors ? '\x1b[2m' : '',
};
const lines = [];
lines.push('');
if (result.success) {
if (result.dryRun) {
lines.push(`${c.bold}📋 Update Preview (dry-run)${c.reset}`);
} else {
lines.push(`${c.green}${c.bold}✅ Updated to v${result.newVersion}${c.reset}`);
}
lines.push('');
lines.push(` ${c.dim}Previous:${c.reset} v${result.previousVersion}`);
lines.push(` ${c.dim}New:${c.reset} v${result.newVersion}`);
if (result.filesUpdated > 0) {
lines.push(` ${c.dim}Files:${c.reset} ${result.filesUpdated} updated`);
}
if (result.filesPreserved > 0) {
lines.push(` ${c.yellow}Preserved:${c.reset} ${result.filesPreserved} customizations`);
}
lines.push('');
lines.push(`Run ${c.cyan}npx aios-core validate${c.reset} to verify installation.`);
} else {
lines.push(`${c.red}${c.bold}✗ Update failed${c.reset}`);
lines.push('');
lines.push(` ${c.red}Error: ${result.error}${c.reset}`);
if (result.rollbackAvailable) {
lines.push(` ${c.yellow}Rolled back to previous version.${c.reset}`);
}
}
lines.push('');
return lines.join('\n');
}
module.exports = {
AIOSUpdater,
UpdateStatus,
FileAction,
formatCheckResult,
formatUpdateResult,
};