diff --git a/bin/aiox.js b/bin/aiox.js index 59c521cf2d..5638f0159f 100755 --- a/bin/aiox.js +++ b/bin/aiox.js @@ -75,6 +75,8 @@ USAGE: npx aiox-core@latest info # Show system info npx aiox-core@latest doctor # Run diagnostics aiox-delegate codex -t # Delegate implementation to external executor + npx aiox-core@latest enterprise upgrade --target . --dry-run --enterprise-source + # Plan Pro to Enterprise upgrade npx aiox-core@latest --version # Show version npx aiox-core@latest --version -d # Show detailed version info npx aiox-core@latest --help # Show this help @@ -110,6 +112,10 @@ EXTERNAL EXECUTION: aiox-delegate codex -t story-4.3 -f prompt.md aiox-delegate codex -t story-4.3 -p "Implement AC1" --dry-run +ENTERPRISE: + aiox enterprise upgrade --target . --enterprise-source /path/to/AIOX-enterprise --dry-run + aiox enterprise upgrade --target . --enterprise-source /path/to/AIOX-enterprise --dry-run --plan outputs/enterprise-upgrade-plan.yaml + EXAMPLES: # Install in current directory npx aiox-core@latest @@ -415,6 +421,35 @@ async function runDoctor(options = {}) { } } +// Helper: Run Enterprise commands +async function runEnterprise() { + const enterpriseArgs = args.slice(1); + const enterprisePath = path.join( + __dirname, + '..', + 'packages', + 'installer', + 'src', + 'enterprise', + 'enterprise-upgrade-plan.js', + ); + + try { + const { runEnterpriseUpgradeCli } = require(enterprisePath); + const exitCode = await runEnterpriseUpgradeCli(enterpriseArgs, { + stdout: process.stdout, + stderr: process.stderr, + }); + + if (exitCode !== 0) { + process.exit(exitCode); + } + } catch (error) { + console.error(`❌ Enterprise command error: ${error.message}`); + process.exit(1); + } +} + // Helper: Format bytes to human readable function formatBytes(bytes) { if (bytes === 0) return '0 Bytes'; @@ -884,6 +919,11 @@ async function main() { } break; + case 'enterprise': + // AIOX Enterprise Upgrade Planning - Story PEM.1 + await runEnterprise(); + break; + case 'install': { // Install in current project with flag support const installArgs = args.slice(1); diff --git a/package.json b/package.json index 2062c0ccd1..18ceb9c951 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,12 @@ ], "exports": { "./installer/aiox-core-installer": "./packages/installer/src/installer/aiox-core-installer.js", + "./installer/enterprise-detector": "./packages/installer/src/enterprise/enterprise-detector.js", + "./installer/enterprise-errors": "./packages/installer/src/enterprise/enterprise-errors.js", + "./installer/enterprise-manifest-loader": "./packages/installer/src/enterprise/enterprise-manifest-loader.js", + "./installer/enterprise-rollback": "./packages/installer/src/enterprise/enterprise-rollback.js", + "./installer/enterprise-upgrader": "./packages/installer/src/enterprise/enterprise-upgrader.js", + "./installer/enterprise-upgrade-plan": "./packages/installer/src/enterprise/enterprise-upgrade-plan.js", "./installer/pro-scaffolder": "./packages/installer/src/pro/pro-scaffolder.js", "./package.json": "./package.json" }, @@ -60,6 +66,7 @@ "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:e2e:installed-skills": "node scripts/e2e/installed-skills-smoke.js", + "test:e2e:pro-enterprise-upgrade": "node scripts/e2e/pro-to-enterprise-upgrade-smoke.js", "test:health-check": "mocha tests/health-check/**/*.test.js --timeout 30000", "lint": "eslint . --cache --cache-location .eslintcache", "typecheck": "tsc --noEmit", diff --git a/packages/installer/src/enterprise/enterprise-detector.js b/packages/installer/src/enterprise/enterprise-detector.js new file mode 100644 index 0000000000..6d5c1b3d73 --- /dev/null +++ b/packages/installer/src/enterprise/enterprise-detector.js @@ -0,0 +1,342 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs-extra'); +const yaml = require('js-yaml'); + +const IDE_SURFACES = [ + { id: 'claude', path: '.claude' }, + { id: 'codex', path: '.codex' }, + { id: 'gemini', path: '.gemini' }, + { id: 'agents', path: '.agents' }, + { id: 'cursor', path: '.cursor' }, + { id: 'windsurf', path: '.windsurf' }, +]; + +const ENTERPRISE_REQUIRED_MARKERS = [ + 'enterprise-config.yaml', + '.aiox-sync.yaml', + 'package.json:aiox-enterprise', + 'scripts/hub-sync.js', +]; + +function toAbsolute(inputPath, fallback = process.cwd()) { + return path.resolve(inputPath || fallback); +} + +function toPortablePath(filePath) { + return filePath.split(path.sep).join('/'); +} + +function toRelativePath(baseDir, filePath) { + return toPortablePath(path.relative(baseDir, filePath)); +} + +function readJsonIfExists(filePath) { + if (!fs.existsSync(filePath)) { + return null; + } + + try { + return fs.readJsonSync(filePath); + } catch (error) { + return { __readError: error.message }; + } +} + +function readYamlIfExists(filePath) { + if (!fs.existsSync(filePath)) { + return null; + } + + try { + return yaml.load(fs.readFileSync(filePath, 'utf8')) || {}; + } catch (error) { + return { __readError: error.message }; + } +} + +function resolvePackageJson(targetDir, packageName) { + const scopedPath = path.join(targetDir, 'node_modules', ...packageName.split('/'), 'package.json'); + if (fs.existsSync(scopedPath)) { + return scopedPath; + } + + try { + return require.resolve(`${packageName}/package.json`, { paths: [targetDir] }); + } catch { + return null; + } +} + +function valueAt(source, keys) { + for (const keyPath of keys) { + const parts = keyPath.split('.'); + let current = source; + for (const part of parts) { + current = current && typeof current === 'object' ? current[part] : undefined; + } + if (typeof current === 'string' && current.trim()) { + return current.trim(); + } + } + return null; +} + +function pushMarker(markers, baseDir, type, markerPath, metadata = {}) { + markers.push({ + type, + path: toRelativePath(baseDir, markerPath), + ...metadata, + }); +} + +function detectCoreInstallation(targetDir = process.cwd()) { + const root = toAbsolute(targetDir); + const markers = []; + const coreDir = path.join(root, '.aiox-core'); + const manifestPath = path.join(coreDir, '.installed-manifest.yaml'); + const versionJsonPath = path.join(coreDir, 'version.json'); + const rootPackagePath = path.join(root, 'package.json'); + const corePackagePath = resolvePackageJson(root, '@aiox-squads/core'); + const binPath = path.join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'aiox-core.cmd' : 'aiox-core'); + + const manifest = readYamlIfExists(manifestPath); + const versionJson = readJsonIfExists(versionJsonPath); + const rootPackageJson = readJsonIfExists(rootPackagePath); + const packageJson = corePackagePath ? readJsonIfExists(corePackagePath) : null; + + if (fs.existsSync(coreDir)) { + pushMarker(markers, root, 'core-directory', coreDir); + } + if (manifest) { + pushMarker(markers, root, 'installed-manifest', manifestPath); + } + if (versionJson) { + pushMarker(markers, root, 'version-json', versionJsonPath); + } + if (rootPackageJson && rootPackageJson.name === '@aiox-squads/core') { + pushMarker(markers, root, 'root-package', rootPackagePath); + } + if (packageJson && !packageJson.__readError) { + pushMarker(markers, root, 'node-package', corePackagePath); + } + if (fs.existsSync(binPath)) { + pushMarker(markers, root, 'bin', binPath); + } + + const version = valueAt(manifest, [ + 'version', + 'installedVersion', + 'installed_version', + 'core.version', + 'package.version', + ]) || valueAt(versionJson, [ + 'version', + 'coreVersion', + 'package.version', + ]) || valueAt(rootPackageJson, ['version']) + || valueAt(packageJson, ['version']); + + let source = 'none'; + if (markers.find(marker => marker.type === 'installed-manifest')) { + source = 'installed-manifest'; + } else if (markers.find(marker => marker.type === 'root-package')) { + source = 'root-package'; + } else if (markers.find(marker => marker.type === 'node-package')) { + source = 'node-package'; + } else if (markers.find(marker => marker.type === 'bin')) { + source = 'bin'; + } else if (markers.find(marker => marker.type === 'core-directory')) { + source = 'core-directory'; + } + + return { + detected: markers.length > 0, + source, + version, + manifestPath: manifest ? toRelativePath(root, manifestPath) : null, + rootPackagePath: rootPackageJson && rootPackageJson.name === '@aiox-squads/core' + ? toRelativePath(root, rootPackagePath) + : null, + packagePath: packageJson && !packageJson.__readError ? toRelativePath(root, corePackagePath) : null, + binPath: fs.existsSync(binPath) ? toRelativePath(root, binPath) : null, + markers, + }; +} + +function detectProInstallation(targetDir = process.cwd()) { + const root = toAbsolute(targetDir); + const markers = []; + const manifestPath = path.join(root, 'pro-installed-manifest.yaml'); + const versionJsonPath = path.join(root, 'pro-version.json'); + const proConfigPath = path.join(root, '.aiox-core', 'pro-config.yaml'); + const featureRegistryPath = path.join(root, '.aiox-core', 'feature-registry.yaml'); + const proPackagePath = resolvePackageJson(root, '@aiox-squads/pro'); + const submodulePackagePath = path.join(root, 'pro', 'package.json'); + + const manifest = readYamlIfExists(manifestPath); + const versionJson = readJsonIfExists(versionJsonPath); + const proConfig = readYamlIfExists(proConfigPath); + const packageJson = proPackagePath ? readJsonIfExists(proPackagePath) : null; + const submodulePackageJson = readJsonIfExists(submodulePackagePath); + + if (manifest) { + pushMarker(markers, root, 'pro-installed-manifest', manifestPath); + } + if (versionJson) { + pushMarker(markers, root, 'pro-version-json', versionJsonPath); + } + if (proConfig) { + pushMarker(markers, root, 'pro-config', proConfigPath); + } + if (fs.existsSync(featureRegistryPath)) { + pushMarker(markers, root, 'feature-registry', featureRegistryPath); + } + if (packageJson && !packageJson.__readError) { + pushMarker(markers, root, 'node-package', proPackagePath); + } + if (submodulePackageJson && !submodulePackageJson.__readError) { + pushMarker(markers, root, 'submodule', submodulePackagePath); + } + + const version = valueAt(versionJson, [ + 'proVersion', + 'version', + 'package.version', + ]) || valueAt(manifest, [ + 'version', + 'proVersion', + 'installedVersion', + 'installed_version', + 'pro.version', + ]) || valueAt(packageJson, ['version']) + || valueAt(submodulePackageJson, ['version']) + || valueAt(proConfig, ['pro.version', 'version']); + + const source = markers.find(marker => marker.type === 'node-package') ? 'node-package' + : markers.find(marker => marker.type === 'submodule') ? 'submodule' + : markers.find(marker => marker.type === 'pro-installed-manifest') ? 'installed-manifest' + : markers.find(marker => marker.type === 'pro-config') ? 'project-config' + : markers.find(marker => marker.type === 'pro-version-json') ? 'version-json' + : 'none'; + + return { + detected: markers.length > 0, + source, + version, + manifestPath: manifest ? toRelativePath(root, manifestPath) : null, + packagePath: packageJson && !packageJson.__readError ? toRelativePath(root, proPackagePath) : null, + submodulePath: submodulePackageJson && !submodulePackageJson.__readError ? 'pro' : null, + markers, + }; +} + +function detectEnterpriseSource(enterpriseSourceDir) { + const root = toAbsolute(enterpriseSourceDir); + const markers = []; + const missingMarkers = []; + const configPath = path.join(root, 'enterprise-config.yaml'); + const syncPath = path.join(root, '.aiox-sync.yaml'); + const packageJsonPath = path.join(root, 'package.json'); + const hubSyncPath = path.join(root, 'scripts', 'hub-sync.js'); + + const config = readYamlIfExists(configPath); + const syncConfig = readYamlIfExists(syncPath); + const packageJson = readJsonIfExists(packageJsonPath); + + if (config) { + pushMarker(markers, root, 'enterprise-config', configPath); + } else { + missingMarkers.push('enterprise-config.yaml'); + } + + if (syncConfig) { + pushMarker(markers, root, 'ide-sync-config', syncPath); + } else { + missingMarkers.push('.aiox-sync.yaml'); + } + + const packageName = valueAt(packageJson, ['name']); + if (packageName && packageName.includes('aiox-enterprise')) { + pushMarker(markers, root, 'package-json', packageJsonPath, { packageName }); + } else { + missingMarkers.push('package.json:aiox-enterprise'); + } + + if (fs.existsSync(hubSyncPath)) { + pushMarker(markers, root, 'hub-sync-script', hubSyncPath); + } else { + missingMarkers.push('scripts/hub-sync.js'); + } + + const enterpriseConfig = config && !config.__readError ? config.enterprise || config : {}; + const version = valueAt(enterpriseConfig, ['version']) || valueAt(packageJson, ['version']); + const product = valueAt(enterpriseConfig, ['product', 'name']) || packageName || null; + const source = valueAt(enterpriseConfig, ['source', 'repository']) || valueAt(packageJson, [ + 'repository.url', + 'repository', + ]); + + return { + detected: markers.length > 0, + valid: missingMarkers.length === 0, + path: root, + product, + version, + source, + missingMarkers, + requiredMarkers: ENTERPRISE_REQUIRED_MARKERS, + markers, + }; +} + +function detectActiveIdes(targetDir = process.cwd()) { + const root = toAbsolute(targetDir); + + return IDE_SURFACES + .map(surface => ({ + id: surface.id, + path: surface.path, + active: fs.existsSync(path.join(root, surface.path)), + })) + .filter(surface => surface.active); +} + +function detectEnterpriseUpgradeContext(options = {}) { + const targetDir = toAbsolute(options.targetDir); + const enterpriseSourceDir = options.enterpriseSource ? toAbsolute(options.enterpriseSource) : null; + const emptyEnterpriseContext = { + detected: false, + valid: false, + path: null, + product: null, + version: null, + source: null, + missingMarkers: ENTERPRISE_REQUIRED_MARKERS, + requiredMarkers: ENTERPRISE_REQUIRED_MARKERS, + markers: [], + }; + + return { + targetDir, + enterpriseSourceDir, + core: detectCoreInstallation(targetDir), + pro: detectProInstallation(targetDir), + enterprise: enterpriseSourceDir + ? detectEnterpriseSource(enterpriseSourceDir) + : emptyEnterpriseContext, + activeIdes: detectActiveIdes(targetDir), + }; +} + +module.exports = { + ENTERPRISE_REQUIRED_MARKERS, + IDE_SURFACES, + detectActiveIdes, + detectCoreInstallation, + detectEnterpriseSource, + detectEnterpriseUpgradeContext, + detectProInstallation, + toPortablePath, +}; diff --git a/packages/installer/src/enterprise/enterprise-errors.js b/packages/installer/src/enterprise/enterprise-errors.js new file mode 100644 index 0000000000..f479aa58ff --- /dev/null +++ b/packages/installer/src/enterprise/enterprise-errors.js @@ -0,0 +1,14 @@ +'use strict'; + +class EnterpriseUpgradeError extends Error { + constructor(code, message, details = {}) { + super(message); + this.name = 'EnterpriseUpgradeError'; + this.code = code; + this.details = details; + } +} + +module.exports = { + EnterpriseUpgradeError, +}; diff --git a/packages/installer/src/enterprise/enterprise-manifest-loader.js b/packages/installer/src/enterprise/enterprise-manifest-loader.js new file mode 100644 index 0000000000..bc22012720 --- /dev/null +++ b/packages/installer/src/enterprise/enterprise-manifest-loader.js @@ -0,0 +1,211 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs-extra'); +const yaml = require('js-yaml'); +const Ajv = require('ajv'); +const { EnterpriseUpgradeError } = require('./enterprise-errors'); +const { toPortablePath } = require('./enterprise-detector'); + +const DEFAULT_MANIFEST_PATH = path.join(__dirname, 'enterprise-upgrade-manifest.yaml'); +const MANIFEST_SCHEMA_PATH = path.join(__dirname, 'enterprise-upgrade-manifest.schema.json'); + +function normalizeMigrationPath(inputPath) { + return toPortablePath(inputPath || '') + .replace(/^\/+/, '') + .replace(/^\.\//, ''); +} + +function escapeRegExp(input) { + return input.replace(/[|\\{}()[\]^$+?.]/g, '\\$&'); +} + +function globToRegExp(pattern) { + let regex = '^'; + const normalized = normalizeMigrationPath(pattern); + + for (let index = 0; index < normalized.length; index += 1) { + const char = normalized[index]; + const next = normalized[index + 1]; + + if (char === '*' && next === '*') { + regex += '.*'; + index += 1; + } else if (char === '*') { + regex += '[^/]*'; + } else { + regex += escapeRegExp(char); + } + } + + regex += '$'; + return new RegExp(regex); +} + +function pathMatchesPattern(filePath, pattern) { + const normalizedPath = normalizeMigrationPath(filePath); + const normalizedPattern = normalizeMigrationPath(pattern); + + if (normalizedPattern.endsWith('/**')) { + const prefix = normalizedPattern.slice(0, -3); + return normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`); + } + + if (normalizedPattern.startsWith('**/')) { + const rootPattern = normalizedPattern.slice(3); + return globToRegExp(normalizedPattern).test(normalizedPath) + || globToRegExp(rootPattern).test(normalizedPath); + } + + return globToRegExp(normalizedPattern).test(normalizedPath); +} + +function createValidator(schemaPath = MANIFEST_SCHEMA_PATH) { + const schema = fs.readJsonSync(schemaPath); + const ajv = new Ajv({ + allErrors: true, + allowUnionTypes: true, + strict: false, + }); + + return ajv.compile(schema); +} + +function formatValidationErrors(errors = []) { + return errors.map(error => { + const location = error.instancePath || '/'; + return `${location} ${error.message}`; + }).join('; '); +} + +function validateEnterpriseUpgradeManifest(manifest, options = {}) { + const validate = createValidator(options.schemaPath || MANIFEST_SCHEMA_PATH); + const valid = validate(manifest); + + if (!valid) { + throw new EnterpriseUpgradeError( + 'AIOX_ENTERPRISE_MANIFEST_INVALID', + `Enterprise upgrade manifest invalid: ${formatValidationErrors(validate.errors)}`, + { errors: validate.errors }, + ); + } + + return true; +} + +function loadEnterpriseUpgradeManifest(manifestPath = DEFAULT_MANIFEST_PATH, options = {}) { + const absoluteManifestPath = path.resolve(manifestPath || DEFAULT_MANIFEST_PATH); + + if (!fs.existsSync(absoluteManifestPath)) { + throw new EnterpriseUpgradeError( + 'AIOX_ENTERPRISE_MANIFEST_NOT_FOUND', + `Enterprise upgrade manifest not found: ${absoluteManifestPath}`, + ); + } + + const manifest = yaml.load(fs.readFileSync(absoluteManifestPath, 'utf8')) || {}; + validateEnterpriseUpgradeManifest(manifest, options); + + return { + path: absoluteManifestPath, + manifest, + }; +} + +function findBlockedPathRule(filePath, manifest) { + return manifest.blockedPaths.find(rule => pathMatchesPattern(filePath, rule.pattern)) || null; +} + +function findAllowlistEntry(filePath, manifest) { + return manifest.allowlist.find(entry => pathMatchesPattern(filePath, entry.path)) || null; +} + +function resolveMigrationPolicy(filePath, manifest) { + const normalizedPath = normalizeMigrationPath(filePath); + const blocked = findBlockedPathRule(normalizedPath, manifest); + + if (blocked) { + return { + allowed: false, + path: normalizedPath, + action: blocked.action, + policy: blocked.action, + reason: blocked.reason, + blockedBy: blocked.pattern, + severity: blocked.severity || 'high', + group: null, + }; + } + + const allowed = findAllowlistEntry(normalizedPath, manifest); + if (!allowed) { + return { + allowed: false, + path: normalizedPath, + action: 'deny', + policy: 'deny', + reason: 'Path is not declared in the Enterprise migration allowlist.', + blockedBy: 'allowlist', + severity: 'high', + group: null, + }; + } + + return { + allowed: true, + path: normalizedPath, + action: allowed.policy, + policy: allowed.policy, + group: allowed.group, + reason: allowed.reason, + required: allowed.required === true, + allowlistPath: allowed.path, + }; +} + +function getPreservedPaths(manifest) { + return manifest.blockedPaths + .filter(rule => rule.action === 'preserve') + .map(rule => rule.pattern); +} + +function buildCandidateOpsFromManifest(manifest, enterpriseSourceDir = null) { + return manifest.allowlist.map(entry => { + const sourcePath = enterpriseSourceDir ? path.join(enterpriseSourceDir, entry.path.replace(/\/\*\*$/, '')) : null; + const exists = sourcePath ? fs.existsSync(sourcePath) : null; + + return { + action: entry.policy, + path: entry.path, + group: entry.group, + source: 'enterprise-source', + required: entry.required === true, + exists, + reason: entry.reason, + }; + }); +} + +function buildBlockedOpsFromManifest(manifest) { + return manifest.blockedPaths.map(rule => ({ + action: rule.action, + path: rule.pattern, + severity: rule.severity || 'high', + reason: rule.reason, + })); +} + +module.exports = { + DEFAULT_MANIFEST_PATH, + MANIFEST_SCHEMA_PATH, + buildBlockedOpsFromManifest, + buildCandidateOpsFromManifest, + findAllowlistEntry, + findBlockedPathRule, + getPreservedPaths, + loadEnterpriseUpgradeManifest, + normalizeMigrationPath, + pathMatchesPattern, + resolveMigrationPolicy, + validateEnterpriseUpgradeManifest, +}; diff --git a/packages/installer/src/enterprise/enterprise-rollback.js b/packages/installer/src/enterprise/enterprise-rollback.js new file mode 100644 index 0000000000..8ffe7a1e18 --- /dev/null +++ b/packages/installer/src/enterprise/enterprise-rollback.js @@ -0,0 +1,95 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs-extra'); +const yaml = require('js-yaml'); +const { EnterpriseUpgradeError } = require('./enterprise-errors'); +const { sha256File } = require('./enterprise-utils'); + +function readExecutionManifest(manifestPath) { + const absoluteManifestPath = path.resolve(manifestPath); + + if (!fs.existsSync(absoluteManifestPath)) { + throw new EnterpriseUpgradeError( + 'AIOX_ENTERPRISE_ROLLBACK_MANIFEST_NOT_FOUND', + `Rollback manifest not found: ${absoluteManifestPath}`, + ); + } + + const manifest = yaml.load(fs.readFileSync(absoluteManifestPath, 'utf8')) || {}; + + if (manifest.kind !== 'aiox.enterprise.upgrade-execution-manifest') { + throw new EnterpriseUpgradeError( + 'AIOX_ENTERPRISE_ROLLBACK_MANIFEST_INVALID', + 'Rollback requires an Enterprise upgrade execution manifest', + ); + } + + return { + path: absoluteManifestPath, + manifest, + }; +} + +function rollbackEnterpriseUpgrade(manifestPath) { + const bundle = readExecutionManifest(manifestPath); + const manifest = bundle.manifest; + const targetDir = path.resolve(manifest.target.path); + const restored = []; + const errors = []; + + for (const backup of manifest.backedUp || []) { + const backupPath = path.join(targetDir, backup.backupPath); + const destPath = path.join(targetDir, backup.path); + + try { + if (!fs.existsSync(backupPath)) { + throw new Error(`Backup file missing: ${backup.backupPath}`); + } + + fs.ensureDirSync(path.dirname(destPath)); + fs.copyFileSync(backupPath, destPath); + restored.push({ + path: backup.path, + sha256: sha256File(destPath), + }); + } catch (error) { + errors.push({ + path: backup.path, + message: error.message, + }); + } + } + + const rollbackManifest = { + schemaVersion: '1.0', + kind: 'aiox.enterprise.rollback-manifest', + rolledBackAt: new Date().toISOString(), + sourceManifest: bundle.path, + target: manifest.target, + restored, + errors, + }; + const rollbackPath = path.join(targetDir, '.aiox', 'enterprise-upgrade-rollback.yaml'); + fs.ensureDirSync(path.dirname(rollbackPath)); + fs.writeFileSync(rollbackPath, yaml.dump(rollbackManifest, { lineWidth: 120, noRefs: true, sortKeys: false }), 'utf8'); + + if (errors.length > 0) { + throw new EnterpriseUpgradeError( + 'AIOX_ENTERPRISE_ROLLBACK_INCOMPLETE', + 'Enterprise rollback completed with errors', + { errors, rollbackPath }, + ); + } + + return { + success: true, + rollbackPath, + restored, + }; +} + +module.exports = { + readExecutionManifest, + rollbackEnterpriseUpgrade, +}; diff --git a/packages/installer/src/enterprise/enterprise-upgrade-manifest.schema.json b/packages/installer/src/enterprise/enterprise-upgrade-manifest.schema.json new file mode 100644 index 0000000000..b070862636 --- /dev/null +++ b/packages/installer/src/enterprise/enterprise-upgrade-manifest.schema.json @@ -0,0 +1,109 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://aiox.local/schemas/enterprise-upgrade-manifest.schema.json", + "title": "AIOX Enterprise Upgrade Manifest", + "type": "object", + "required": ["schemaVersion", "kind", "entitlement", "groups", "blockedPaths", "allowlist"], + "additionalProperties": false, + "properties": { + "schemaVersion": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+$" + }, + "kind": { + "const": "aiox.enterprise.upgrade-manifest" + }, + "entitlement": { + "type": "object", + "required": ["required", "envVar", "testFixtureEnvVar"], + "additionalProperties": false, + "properties": { + "required": { "type": "boolean" }, + "envVar": { "type": "string", "minLength": 1 }, + "testFixtureEnvVar": { "type": "string", "minLength": 1 }, + "status": { "type": "string" } + } + }, + "groups": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "description", "defaultPolicy"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "enum": [ + "config", + "ide-surfaces", + "workspace-templates", + "workspace-scripts", + "services", + "squads", + "governance", + "docs" + ] + }, + "description": { "type": "string", "minLength": 1 }, + "defaultPolicy": { + "type": "string", + "enum": ["copy-if-missing", "merge-yaml", "hash-overwrite-with-backup", "preserve", "deny"] + } + } + } + }, + "blockedPaths": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["pattern", "action", "reason"], + "additionalProperties": false, + "properties": { + "pattern": { "type": "string", "minLength": 1 }, + "action": { + "type": "string", + "enum": ["preserve", "deny"] + }, + "reason": { "type": "string", "minLength": 1 }, + "severity": { + "type": "string", + "enum": ["low", "medium", "high", "critical"] + } + } + } + }, + "allowlist": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["path", "group", "policy", "reason"], + "additionalProperties": false, + "properties": { + "path": { "type": "string", "minLength": 1 }, + "group": { + "type": "string", + "enum": [ + "config", + "ide-surfaces", + "workspace-templates", + "workspace-scripts", + "services", + "squads", + "governance", + "docs" + ] + }, + "policy": { + "type": "string", + "enum": ["copy-if-missing", "merge-yaml", "hash-overwrite-with-backup", "preserve", "deny"] + }, + "reason": { "type": "string", "minLength": 1 }, + "required": { "type": "boolean" } + } + } + } + } +} diff --git a/packages/installer/src/enterprise/enterprise-upgrade-manifest.yaml b/packages/installer/src/enterprise/enterprise-upgrade-manifest.yaml new file mode 100644 index 0000000000..0ca30ba6bf --- /dev/null +++ b/packages/installer/src/enterprise/enterprise-upgrade-manifest.yaml @@ -0,0 +1,173 @@ +schemaVersion: "1.0" +kind: "aiox.enterprise.upgrade-manifest" + +entitlement: + required: true + envVar: "AIOX_ENTERPRISE_KEY" + testFixtureEnvVar: "AIOX_ENTERPRISE_TEST_FIXTURE" + status: "not-checked" + +groups: + - id: "config" + description: "Enterprise product and sync configuration." + defaultPolicy: "merge-yaml" + - id: "ide-surfaces" + description: "IDE agent, command, hook, skill, and rule surfaces." + defaultPolicy: "copy-if-missing" + - id: "workspace-templates" + description: "Workspace templates only; business-owned runtime data is preserved." + defaultPolicy: "copy-if-missing" + - id: "workspace-scripts" + description: "Workspace helper scripts and system configuration." + defaultPolicy: "hash-overwrite-with-backup" + - id: "services" + description: "Explicit Enterprise services only; no recursive services/** import." + defaultPolicy: "copy-if-missing" + - id: "squads" + description: "Explicit squad bundles by squad slug." + defaultPolicy: "copy-if-missing" + - id: "governance" + description: "Repository governance surfaces and validation rules." + defaultPolicy: "copy-if-missing" + - id: "docs" + description: "Reference docs that do not contain project-local stories or runtime evidence." + defaultPolicy: "copy-if-missing" + +blockedPaths: + - pattern: ".env*" + action: "preserve" + severity: "critical" + reason: "Environment files can contain secrets and are project-owned." + - pattern: "workspace/businesses/**" + action: "preserve" + severity: "critical" + reason: "Business workspace data belongs to the target project." + - pattern: "outputs/**" + action: "preserve" + severity: "high" + reason: "Generated outputs are local runtime artifacts." + - pattern: "docs/stories/**" + action: "preserve" + severity: "high" + reason: "Story backlog is project-owned." + - pattern: ".git/**" + action: "deny" + severity: "critical" + reason: "Git internals must never be copied from Enterprise source." + - pattern: "node_modules/**" + action: "deny" + severity: "critical" + reason: "Dependencies must be installed, never migrated as files." + - pattern: "**/*secret*" + action: "deny" + severity: "critical" + reason: "Potential secret-bearing file." + - pattern: "**/*credential*" + action: "deny" + severity: "critical" + reason: "Potential credential-bearing file." + - pattern: "**/*pii*" + action: "deny" + severity: "critical" + reason: "Potential PII-bearing file." + - pattern: "**/*founder*" + action: "deny" + severity: "high" + reason: "Founder-specific data is not part of generic Enterprise migration." + +allowlist: + - path: "enterprise-config.yaml" + group: "config" + policy: "merge-yaml" + required: true + reason: "Enterprise product metadata and version gate." + - path: ".aiox-sync.yaml" + group: "config" + policy: "merge-yaml" + required: true + reason: "Enterprise IDE sync policy." + - path: "scripts/hub-sync.js" + group: "workspace-scripts" + policy: "hash-overwrite-with-backup" + required: true + reason: "Enterprise sync runtime used by apply/rollback slices." + - path: "scripts/enterprise-sync.js" + group: "workspace-scripts" + policy: "hash-overwrite-with-backup" + reason: "Enterprise distribution sync helper." + - path: "scripts/enterprise-sanitize.js" + group: "workspace-scripts" + policy: "hash-overwrite-with-backup" + reason: "Enterprise sanitization helper." + - path: "workspace/_templates/**" + group: "workspace-templates" + policy: "copy-if-missing" + reason: "Templates are reusable and do not carry target business data." + - path: "workspace/scripts/**" + group: "workspace-scripts" + policy: "hash-overwrite-with-backup" + reason: "Workspace automation can be upgraded with backups." + - path: "services/service-catalog.yaml" + group: "services" + policy: "merge-yaml" + reason: "Catalog is explicit metadata, not recursive service import." + - path: "services/registry-engine/**" + group: "services" + policy: "copy-if-missing" + reason: "Registry engine is an explicit Enterprise service." + - path: "squads/repo-ops/**" + group: "squads" + policy: "copy-if-missing" + reason: "Repo Ops is an explicit Enterprise squad bundle." + - path: "squads/visual-knowledge-squad/**" + group: "squads" + policy: "copy-if-missing" + reason: "Visual Knowledge is an explicit Enterprise squad bundle." + - path: ".claude/agents/**" + group: "ide-surfaces" + policy: "copy-if-missing" + reason: "Claude agent surface." + - path: ".claude/commands/**" + group: "ide-surfaces" + policy: "copy-if-missing" + reason: "Claude command surface." + - path: ".claude/skills/**" + group: "ide-surfaces" + policy: "copy-if-missing" + reason: "Claude skill surface." + - path: ".claude/rules/**" + group: "ide-surfaces" + policy: "copy-if-missing" + reason: "Claude rules surface." + - path: ".codex/agents/**" + group: "ide-surfaces" + policy: "copy-if-missing" + reason: "Codex local agent surface." + - path: ".codex/skills/**" + group: "ide-surfaces" + policy: "copy-if-missing" + reason: "Codex local skill surface." + - path: ".agents/agents/**" + group: "ide-surfaces" + policy: "copy-if-missing" + reason: "Generic agent projection surface." + - path: ".agents/skills/**" + group: "ide-surfaces" + policy: "copy-if-missing" + reason: "Generic skill projection surface." + - path: ".github/CODEOWNERS" + group: "governance" + policy: "copy-if-missing" + reason: "Enterprise ownership policy is copied only if the target has none." + - path: "docs/reference/**" + group: "docs" + policy: "copy-if-missing" + reason: "Reference docs are reusable and separate from project stories." + - path: "docs/guides/**" + group: "docs" + policy: "copy-if-missing" + reason: "Enterprise usage guides." + - path: "docs/schemas/**" + group: "docs" + policy: "copy-if-missing" + reason: "Reusable Enterprise schemas." diff --git a/packages/installer/src/enterprise/enterprise-upgrade-plan.js b/packages/installer/src/enterprise/enterprise-upgrade-plan.js new file mode 100644 index 0000000000..0f1fe3ae38 --- /dev/null +++ b/packages/installer/src/enterprise/enterprise-upgrade-plan.js @@ -0,0 +1,371 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs-extra'); +const yaml = require('js-yaml'); +const { + detectEnterpriseUpgradeContext, + toPortablePath, +} = require('./enterprise-detector'); +const { EnterpriseUpgradeError } = require('./enterprise-errors'); +const { + buildBlockedOpsFromManifest, + buildCandidateOpsFromManifest, + getPreservedPaths, + loadEnterpriseUpgradeManifest, +} = require('./enterprise-manifest-loader'); + +const PLAN_SCHEMA_VERSION = '1.0'; + +function normalizeOptions(options = {}) { + return { + targetDir: path.resolve(options.targetDir || process.cwd()), + enterpriseSource: options.enterpriseSource ? path.resolve(options.enterpriseSource) : null, + manifestPath: options.manifestPath || null, + mode: options.mode || 'dry-run', + }; +} + +function buildWarnings(context, options = {}) { + const warnings = []; + + if (context.activeIdes.length === 0) { + warnings.push('No IDE surfaces detected in target project.'); + } + + if (!context.core.version) { + warnings.push('AIOX Core version could not be resolved from detected markers.'); + } + + if (!context.pro.version) { + warnings.push('AIOX Pro version could not be resolved from detected markers.'); + } + + if (options.mode === 'dry-run' || options.dryRun) { + warnings.push('Dry-run mode: no files will be modified.'); + } + + return warnings; +} + +function validateContext(context) { + if (!context.core.detected) { + throw new EnterpriseUpgradeError( + 'AIOX_CORE_NOT_DETECTED', + 'AIOX Core installation not detected', + { targetDir: context.targetDir }, + ); + } + + if (!context.pro.detected) { + throw new EnterpriseUpgradeError( + 'AIOX_PRO_NOT_DETECTED', + 'AIOX Pro activation not detected', + { targetDir: context.targetDir }, + ); + } + + if (!context.enterprise.valid) { + throw new EnterpriseUpgradeError( + 'AIOX_ENTERPRISE_SOURCE_NOT_DETECTED', + 'AIOX Enterprise source not detected', + { + enterpriseSource: context.enterpriseSourceDir, + missingMarkers: context.enterprise.missingMarkers, + }, + ); + } +} + +function buildEnterpriseUpgradePlan(options = {}) { + const normalized = normalizeOptions(options); + const context = detectEnterpriseUpgradeContext(normalized); + const manifestBundle = loadEnterpriseUpgradeManifest(normalized.manifestPath || undefined); + const migrationManifest = manifestBundle.manifest; + + validateContext(context); + + const targetPath = toPortablePath(context.targetDir); + const enterprisePath = toPortablePath(context.enterprise.path); + const pro = { + detected: context.pro.detected, + source: context.pro.source, + version: context.pro.version, + manifestPath: context.pro.manifestPath, + packagePath: context.pro.packagePath, + submodulePath: context.pro.submodulePath, + markers: context.pro.markers, + }; + const enterprise = { + detected: context.enterprise.detected, + valid: context.enterprise.valid, + path: enterprisePath, + source: context.enterprise.source, + product: context.enterprise.product, + version: context.enterprise.version, + missingMarkers: context.enterprise.missingMarkers, + markers: context.enterprise.markers, + }; + + return { + schemaVersion: PLAN_SCHEMA_VERSION, + kind: 'aiox.enterprise.upgrade-plan', + mode: normalized.mode, + generatedAt: new Date().toISOString(), + target: { + path: targetPath, + }, + enterpriseSource: enterprise, + enterprise, + installedCore: context.core, + core: context.core, + installedPro: pro, + pro, + activeIdes: context.activeIdes, + entitlement: { + status: migrationManifest.entitlement.status || 'not-checked', + required: migrationManifest.entitlement.required, + envVar: migrationManifest.entitlement.envVar, + testFixtureEnvVar: migrationManifest.entitlement.testFixtureEnvVar, + }, + migrationManifest: { + schemaVersion: migrationManifest.schemaVersion, + path: toPortablePath(manifestBundle.path), + groups: migrationManifest.groups.map(group => group.id), + allowlistCount: migrationManifest.allowlist.length, + blockedPathCount: migrationManifest.blockedPaths.length, + }, + preservedPaths: getPreservedPaths(migrationManifest), + candidateOps: buildCandidateOpsFromManifest(migrationManifest, context.enterprise.path), + blockedOps: buildBlockedOpsFromManifest(migrationManifest), + warnings: buildWarnings(context, { mode: normalized.mode }), + }; +} + +function serializePlan(plan, format = 'yaml') { + if (format === 'json') { + return `${JSON.stringify(plan, null, 2)}\n`; + } + + return yaml.dump(plan, { + lineWidth: 120, + noRefs: true, + sortKeys: false, + }); +} + +function inferPlanFormat(planPath, explicitFormat) { + if (explicitFormat) { + return explicitFormat; + } + + const ext = path.extname(planPath || '').toLowerCase(); + return ext === '.json' ? 'json' : 'yaml'; +} + +function writeEnterpriseUpgradePlan(plan, planPath, options = {}) { + if (!planPath) { + throw new EnterpriseUpgradeError( + 'AIOX_ENTERPRISE_PLAN_PATH_REQUIRED', + '--plan is required to write an Enterprise upgrade plan', + ); + } + + const absolutePlanPath = path.resolve(planPath); + const format = inferPlanFormat(absolutePlanPath, options.format); + fs.ensureDirSync(path.dirname(absolutePlanPath)); + fs.writeFileSync(absolutePlanPath, serializePlan(plan, format), 'utf8'); + + return { + path: absolutePlanPath, + format, + }; +} + +function readRequiredOptionValue(argv, index, optionName) { + const value = argv[index + 1]; + + if (!value || value.startsWith('-')) { + throw new EnterpriseUpgradeError( + 'AIOX_ENTERPRISE_OPTION_VALUE_REQUIRED', + `Missing value for ${optionName}`, + { option: optionName }, + ); + } + + return value; +} + +function parseEnterpriseUpgradeArgs(argv = []) { + const options = { + command: argv[0], + subcommand: null, + dryRun: false, + apply: false, + targetDir: process.cwd(), + enterpriseSource: null, + manifestPath: null, + planPath: null, + format: null, + help: false, + }; + + let startIndex = 1; + if (options.command === 'upgrade' && argv[1] === 'rollback') { + options.subcommand = 'rollback'; + startIndex = 2; + } + + for (let index = startIndex; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === '--dry-run') { + options.dryRun = true; + } else if (arg === '--apply') { + options.apply = true; + } else if (arg === '--target') { + options.targetDir = readRequiredOptionValue(argv, index, arg); + index += 1; + } else if (arg === '--enterprise-source') { + options.enterpriseSource = readRequiredOptionValue(argv, index, arg); + index += 1; + } else if (arg === '--manifest') { + options.manifestPath = readRequiredOptionValue(argv, index, arg); + index += 1; + } else if (arg === '--plan') { + options.planPath = readRequiredOptionValue(argv, index, arg); + index += 1; + } else if (arg === '--format') { + options.format = readRequiredOptionValue(argv, index, arg); + index += 1; + } else if (arg === '--help' || arg === '-h') { + options.help = true; + } else { + throw new EnterpriseUpgradeError( + 'AIOX_ENTERPRISE_UNKNOWN_OPTION', + `Unknown enterprise upgrade option: ${arg}`, + ); + } + } + + return options; +} + +function enterpriseUpgradeHelp() { + return ` +Usage: + aiox enterprise upgrade --target --enterprise-source --dry-run [--plan ] + aiox enterprise upgrade --target --enterprise-source --apply + aiox enterprise upgrade rollback --manifest + +Plan, apply, or rollback an Enterprise upgrade for an existing AIOX Core + Pro project. + +Options: + --target Target project to inspect (default: current directory) + --enterprise-source Local AIOX Enterprise source checkout + --manifest Enterprise migration manifest override + --dry-run Generate plan; do not modify the target project + --apply Apply migration transactionally with backup and manifest + --plan Write the plan to YAML or JSON + --format Override plan format + -h, --help Show this help message +`; +} + +async function runEnterpriseUpgradeCli(argv = [], io = {}) { + const stdout = io.stdout || process.stdout; + const stderr = io.stderr || process.stderr; + + try { + const options = parseEnterpriseUpgradeArgs(argv); + + if (options.help || options.command !== 'upgrade') { + stdout.write(enterpriseUpgradeHelp()); + return options.command === 'upgrade' || options.help ? 0 : 1; + } + + if (options.subcommand === 'rollback') { + if (!options.manifestPath) { + throw new EnterpriseUpgradeError( + 'AIOX_ENTERPRISE_ROLLBACK_MANIFEST_REQUIRED', + '--manifest is required for rollback', + ); + } + + const { rollbackEnterpriseUpgrade } = require('./enterprise-rollback'); + const result = rollbackEnterpriseUpgrade(options.manifestPath); + stdout.write(`Enterprise upgrade rollback written: ${result.rollbackPath}\n`); + return 0; + } + + if (options.dryRun && options.apply) { + throw new EnterpriseUpgradeError( + 'AIOX_ENTERPRISE_MODE_CONFLICT', + 'Use either --dry-run or --apply, not both', + ); + } + + if (!options.dryRun && !options.apply) { + throw new EnterpriseUpgradeError( + 'AIOX_ENTERPRISE_MODE_REQUIRED', + 'Use --dry-run to plan or --apply to apply the Enterprise upgrade', + ); + } + + if (!options.enterpriseSource) { + throw new EnterpriseUpgradeError( + 'AIOX_ENTERPRISE_SOURCE_REQUIRED', + '--enterprise-source is required', + ); + } + + if (options.format && !['yaml', 'json'].includes(options.format)) { + throw new EnterpriseUpgradeError( + 'AIOX_ENTERPRISE_UNSUPPORTED_FORMAT', + `Unsupported --format value: ${options.format}`, + ); + } + + if (options.apply) { + const { applyEnterpriseUpgrade } = require('./enterprise-upgrader'); + const result = applyEnterpriseUpgrade({ + targetDir: options.targetDir, + enterpriseSource: options.enterpriseSource, + manifestPath: options.manifestPath, + }); + stdout.write(`Enterprise upgrade applied: ${result.manifestPath}\n`); + return 0; + } + + const plan = buildEnterpriseUpgradePlan({ + targetDir: options.targetDir, + enterpriseSource: options.enterpriseSource, + manifestPath: options.manifestPath, + mode: 'dry-run', + }); + + if (options.planPath) { + const written = writeEnterpriseUpgradePlan(plan, options.planPath, { format: options.format }); + stdout.write(`Enterprise upgrade plan written: ${written.path}\n`); + } else { + stdout.write(serializePlan(plan, options.format || 'yaml')); + } + + return 0; + } catch (error) { + stderr.write(`${error.message}\n`); + return 1; + } +} + +module.exports = { + EnterpriseUpgradeError, + PLAN_SCHEMA_VERSION, + buildEnterpriseUpgradePlan, + enterpriseUpgradeHelp, + parseEnterpriseUpgradeArgs, + readRequiredOptionValue, + runEnterpriseUpgradeCli, + serializePlan, + writeEnterpriseUpgradePlan, +}; diff --git a/packages/installer/src/enterprise/enterprise-upgrader.js b/packages/installer/src/enterprise/enterprise-upgrader.js new file mode 100644 index 0000000000..74ffb5f4ae --- /dev/null +++ b/packages/installer/src/enterprise/enterprise-upgrader.js @@ -0,0 +1,489 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs-extra'); +const yaml = require('js-yaml'); +const fastGlob = require('fast-glob'); +const { spawnSync } = require('child_process'); +const { buildEnterpriseUpgradePlan } = require('./enterprise-upgrade-plan'); +const { EnterpriseUpgradeError } = require('./enterprise-errors'); +const { + resolveMigrationPolicy, +} = require('./enterprise-manifest-loader'); +const { toPortablePath } = require('./enterprise-detector'); +const { sha256File } = require('./enterprise-utils'); + +function nowStamp() { + return new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14); +} + +function hasGlob(pattern) { + return /[*?[\]{}]/.test(pattern); +} + +function readYamlIfExists(filePath) { + if (!fs.existsSync(filePath)) { + return {}; + } + + return yaml.load(fs.readFileSync(filePath, 'utf8')) || {}; +} + +function writeYaml(filePath, data) { + fs.ensureDirSync(path.dirname(filePath)); + fs.writeFileSync(filePath, yaml.dump(data, { lineWidth: 120, noRefs: true, sortKeys: false }), 'utf8'); +} + +function arrayItemKey(item) { + return typeof item === 'object' && item !== null ? JSON.stringify(item) : String(item); +} + +function mergeArrays(source, target) { + const merged = [...target]; + const seen = new Set(target.map(item => arrayItemKey(item))); + + for (const item of source) { + const key = arrayItemKey(item); + if (!seen.has(key)) { + merged.push(item); + seen.add(key); + } + } + + return merged; +} + +function mergeDeep(source, target) { + if (Array.isArray(source) && Array.isArray(target)) { + return mergeArrays(source, target); + } + + if (Array.isArray(source) || Array.isArray(target)) { + return target === undefined ? source : target; + } + + if (!source || typeof source !== 'object') { + return target === undefined ? source : target; + } + + const result = { ...source }; + if (!target || typeof target !== 'object') { + return result; + } + + for (const [key, value] of Object.entries(target)) { + result[key] = mergeDeep(result[key], value); + } + + return result; +} + +function expandEntryPaths(enterpriseSourceDir, pattern) { + const normalized = toPortablePath(pattern); + + if (hasGlob(normalized)) { + return fastGlob.sync(normalized, { + cwd: enterpriseSourceDir, + dot: true, + onlyFiles: true, + unique: true, + }).sort(); + } + + const absolutePath = path.join(enterpriseSourceDir, normalized); + if (!fs.existsSync(absolutePath)) { + return []; + } + + if (fs.statSync(absolutePath).isDirectory()) { + return fastGlob.sync('**/*', { + cwd: absolutePath, + dot: true, + onlyFiles: true, + unique: true, + }).map(filePath => toPortablePath(path.join(normalized, filePath))).sort(); + } + + return [normalized]; +} + +function ensureEnterpriseEntitlement(entitlement, env = process.env) { + const keyName = entitlement.envVar || 'AIOX_ENTERPRISE_KEY'; + const fixtureName = entitlement.testFixtureEnvVar || 'AIOX_ENTERPRISE_TEST_FIXTURE'; + + if (!entitlement.required) { + return { + status: 'not-required', + envVar: keyName, + testFixtureEnvVar: fixtureName, + }; + } + + if (env[keyName]) { + return { + status: 'present', + envVar: keyName, + testFixtureEnvVar: fixtureName, + }; + } + + if (env[fixtureName]) { + return { + status: 'test-fixture', + envVar: keyName, + testFixtureEnvVar: fixtureName, + }; + } + + throw new EnterpriseUpgradeError( + 'AIOX_ENTERPRISE_ENTITLEMENT_REQUIRED', + `Enterprise entitlement required: set ${keyName} or ${fixtureName}`, + ); +} + +function createEmptyExecutionManifest(options) { + return { + schemaVersion: '1.0', + kind: 'aiox.enterprise.upgrade-execution-manifest', + mode: 'apply', + startedAt: new Date().toISOString(), + completedAt: null, + status: 'running', + target: { + path: toPortablePath(options.targetDir), + }, + enterpriseSource: { + path: toPortablePath(options.enterpriseSource), + product: options.plan.enterprise.product, + version: options.plan.enterprise.version, + source: options.plan.enterprise.source, + }, + migrationManifest: options.plan.migrationManifest, + entitlement: options.entitlement, + backupPath: toPortablePath(options.backupDir), + copied: [], + merged: [], + preserved: [], + denied: [], + backedUp: [], + validated: [], + errors: [], + doctor: null, + }; +} + +function backupFile(targetDir, backupDir, relativePath, executionManifest) { + const sourcePath = path.join(targetDir, relativePath); + const backupPath = path.join(backupDir, relativePath); + + fs.ensureDirSync(path.dirname(backupPath)); + fs.copyFileSync(sourcePath, backupPath); + + const record = { + path: relativePath, + backupPath: toPortablePath(path.relative(targetDir, backupPath)), + sha256: sha256File(backupPath), + }; + executionManifest.backedUp.push(record); + + return record; +} + +function validateCopiedFile(relativePath, sourcePath, destPath, executionManifest) { + const sourceHash = sha256File(sourcePath); + const destHash = sha256File(destPath); + + if (sourceHash !== destHash) { + throw new EnterpriseUpgradeError( + 'AIOX_ENTERPRISE_CHECKSUM_MISMATCH', + `Checksum mismatch after copy: ${relativePath}`, + { relativePath, sourceHash, destHash }, + ); + } + + executionManifest.validated.push({ + path: relativePath, + type: 'checksum', + sha256: destHash, + }); + + return destHash; +} + +function recordPreserved(executionManifest, relativePath, reason) { + executionManifest.preserved.push({ + path: relativePath, + reason, + }); +} + +function applyCopyIfMissing(operation) { + const { + relativePath, + sourcePath, + destPath, + executionManifest, + } = operation; + + if (fs.existsSync(destPath)) { + recordPreserved(executionManifest, relativePath, 'Destination already exists; copy-if-missing preserved it.'); + return; + } + + fs.ensureDirSync(path.dirname(destPath)); + fs.copyFileSync(sourcePath, destPath); + const sha256 = validateCopiedFile(relativePath, sourcePath, destPath, executionManifest); + + executionManifest.copied.push({ + path: relativePath, + policy: 'copy-if-missing', + sha256, + }); +} + +function applyHashOverwriteWithBackup(operation) { + const { + relativePath, + sourcePath, + destPath, + targetDir, + backupDir, + executionManifest, + } = operation; + + if (fs.existsSync(destPath)) { + const sourceHash = sha256File(sourcePath); + const destHash = sha256File(destPath); + + if (sourceHash === destHash) { + executionManifest.validated.push({ + path: relativePath, + type: 'unchanged', + sha256: destHash, + }); + return; + } + + backupFile(targetDir, backupDir, relativePath, executionManifest); + } + + fs.ensureDirSync(path.dirname(destPath)); + fs.copyFileSync(sourcePath, destPath); + const sha256 = validateCopiedFile(relativePath, sourcePath, destPath, executionManifest); + + executionManifest.copied.push({ + path: relativePath, + policy: 'hash-overwrite-with-backup', + sha256, + }); +} + +function applyMergeYaml(operation) { + const { + relativePath, + sourcePath, + destPath, + targetDir, + backupDir, + executionManifest, + } = operation; + + const sourceYaml = readYamlIfExists(sourcePath); + const targetYaml = readYamlIfExists(destPath); + const existed = fs.existsSync(destPath); + + if (existed) { + backupFile(targetDir, backupDir, relativePath, executionManifest); + } + + const merged = mergeDeep(sourceYaml, targetYaml); + writeYaml(destPath, merged); + const mergedHash = sha256File(destPath); + + executionManifest.merged.push({ + path: relativePath, + policy: 'merge-yaml', + sha256: mergedHash, + backedUp: existed, + }); + executionManifest.validated.push({ + path: relativePath, + type: 'exists', + sha256: mergedHash, + }); +} + +function runDoctorIfAvailable(targetDir) { + const localBin = path.join(targetDir, 'bin', 'aiox.js'); + + if (!fs.existsSync(localBin)) { + return { + skipped: true, + reason: 'aiox doctor --json not available in target', + }; + } + + const result = spawnSync(process.execPath, [localBin, 'doctor', '--json'], { + cwd: targetDir, + encoding: 'utf8', + timeout: 30000, + }); + + return { + skipped: false, + status: result.status, + stdout: result.stdout ? result.stdout.slice(0, 4000) : '', + stderr: result.stderr ? result.stderr.slice(0, 4000) : '', + }; +} + +function assertProMarkersPreserved(targetDir) { + const markers = ['pro', 'pro-installed-manifest.yaml', 'pro-version.json']; + return markers.map(marker => ({ + path: marker, + exists: fs.existsSync(path.join(targetDir, marker)), + })); +} + +function applyEnterpriseUpgrade(options = {}) { + const targetDir = path.resolve(options.targetDir || process.cwd()); + const enterpriseSource = options.enterpriseSource ? path.resolve(options.enterpriseSource) : null; + + if (!enterpriseSource) { + throw new EnterpriseUpgradeError( + 'AIOX_ENTERPRISE_SOURCE_REQUIRED', + '--enterprise-source is required', + ); + } + + const plan = options.plan || buildEnterpriseUpgradePlan({ + targetDir, + enterpriseSource, + manifestPath: options.manifestPath, + mode: 'dry-run', + }); + const entitlement = ensureEnterpriseEntitlement(plan.entitlement, options.env || process.env); + const backupDir = path.join(targetDir, '.aiox', 'enterprise-upgrade-backups', options.timestamp || nowStamp()); + const manifestPath = options.manifestPathOut + ? path.resolve(options.manifestPathOut) + : path.join(targetDir, '.aiox', 'enterprise-upgrade-manifest.yaml'); + const executionManifest = createEmptyExecutionManifest({ + targetDir, + enterpriseSource, + plan, + entitlement, + backupDir, + }); + + try { + for (const blockedOp of plan.blockedOps) { + executionManifest.denied.push({ + path: blockedOp.path, + action: blockedOp.action, + reason: blockedOp.reason, + }); + } + + for (const candidate of plan.candidateOps) { + const files = expandEntryPaths(enterpriseSource, candidate.path); + + if (candidate.required && files.length === 0) { + throw new EnterpriseUpgradeError( + 'AIOX_ENTERPRISE_REQUIRED_SOURCE_MISSING', + `Required Enterprise source path missing: ${candidate.path}`, + { path: candidate.path }, + ); + } + + for (const relativePath of files) { + const policy = resolveMigrationPolicy(relativePath, { + allowlist: plan.candidateOps.map(op => ({ + path: op.path, + group: op.group, + policy: op.action, + reason: op.reason, + required: op.required, + })), + blockedPaths: plan.blockedOps.map(op => ({ + pattern: op.path, + action: op.action, + reason: op.reason, + severity: op.severity, + })), + }); + + if (!policy.allowed) { + executionManifest.denied.push({ + path: relativePath, + action: policy.action, + reason: policy.reason, + }); + continue; + } + + const sourcePath = path.join(enterpriseSource, relativePath); + const destPath = path.join(targetDir, relativePath); + const operation = { + relativePath, + sourcePath, + destPath, + targetDir, + backupDir, + executionManifest, + }; + + if (policy.policy === 'copy-if-missing') { + applyCopyIfMissing(operation); + } else if (policy.policy === 'merge-yaml') { + applyMergeYaml(operation); + } else if (policy.policy === 'hash-overwrite-with-backup') { + applyHashOverwriteWithBackup(operation); + } else if (policy.policy === 'preserve') { + recordPreserved(executionManifest, relativePath, policy.reason); + } else { + executionManifest.denied.push({ + path: relativePath, + action: policy.policy, + reason: policy.reason, + }); + } + } + } + + executionManifest.validated.push(...assertProMarkersPreserved(targetDir).map(marker => ({ + path: marker.path, + type: 'pro-marker-preserved', + exists: marker.exists, + }))); + executionManifest.doctor = runDoctorIfAvailable(targetDir); + executionManifest.status = 'success'; + } catch (error) { + executionManifest.status = 'failed'; + executionManifest.errors.push({ + code: error.code || 'AIOX_ENTERPRISE_APPLY_FAILED', + message: error.message, + details: error.details || {}, + }); + throw error; + } finally { + executionManifest.completedAt = new Date().toISOString(); + writeYaml(manifestPath, executionManifest); + } + + return { + success: executionManifest.status === 'success', + manifestPath, + backupDir, + manifest: executionManifest, + }; +} + +module.exports = { + applyEnterpriseUpgrade, + assertProMarkersPreserved, + createEmptyExecutionManifest, + ensureEnterpriseEntitlement, + expandEntryPaths, + mergeDeep, + sha256File, +}; diff --git a/packages/installer/src/enterprise/enterprise-utils.js b/packages/installer/src/enterprise/enterprise-utils.js new file mode 100644 index 0000000000..4b000f99e2 --- /dev/null +++ b/packages/installer/src/enterprise/enterprise-utils.js @@ -0,0 +1,15 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs-extra'); + +function sha256File(filePath) { + return crypto + .createHash('sha256') + .update(fs.readFileSync(filePath)) + .digest('hex'); +} + +module.exports = { + sha256File, +}; diff --git a/packages/installer/tests/unit/enterprise/enterprise-manifest-loader.test.js b/packages/installer/tests/unit/enterprise/enterprise-manifest-loader.test.js new file mode 100644 index 0000000000..925a30ec2c --- /dev/null +++ b/packages/installer/tests/unit/enterprise/enterprise-manifest-loader.test.js @@ -0,0 +1,172 @@ +'use strict'; + +const os = require('os'); +const path = require('path'); +const fs = require('fs-extra'); +const yaml = require('js-yaml'); +const { + loadEnterpriseUpgradeManifest, + pathMatchesPattern, + resolveMigrationPolicy, +} = require('../../../src/enterprise/enterprise-manifest-loader'); +const { + buildEnterpriseUpgradePlan, +} = require('../../../src/enterprise/enterprise-upgrade-plan'); + +function makeTempDir(name) { + return fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`)); +} + +function writeJson(filePath, data) { + fs.outputJsonSync(filePath, data, { spaces: 2 }); +} + +function writeYaml(filePath, data) { + fs.outputFileSync(filePath, yaml.dump(data, { noRefs: true }), 'utf8'); +} + +function createCoreInstall(targetDir, version = '5.1.15') { + writeYaml(path.join(targetDir, '.aiox-core', '.installed-manifest.yaml'), { + installedVersion: version, + }); +} + +function createProInstall(targetDir, version = '2.0.0') { + writeYaml(path.join(targetDir, 'pro-installed-manifest.yaml'), { + version, + }); + writeJson(path.join(targetDir, 'pro-version.json'), { + proVersion: version, + }); +} + +function createEnterpriseSource(sourceDir, version = '1.0.0') { + writeYaml(path.join(sourceDir, 'enterprise-config.yaml'), { + enterprise: { + product: 'AIOX Enterprise', + version, + source: 'SynkraAI/aiox-enterprise', + }, + }); + writeYaml(path.join(sourceDir, '.aiox-sync.yaml'), { + active_ides: ['claude', 'codex'], + }); + writeJson(path.join(sourceDir, 'package.json'), { + name: 'aiox-enterprise', + version, + }); + fs.outputFileSync(path.join(sourceDir, 'scripts', 'hub-sync.js'), "'use strict';\n", 'utf8'); +} + +describe('Enterprise upgrade manifest loader', () => { + let tempDirs; + + beforeEach(() => { + tempDirs = []; + }); + + afterEach(() => { + for (const dir of tempDirs) { + fs.removeSync(dir); + } + }); + + function temp(name) { + const dir = makeTempDir(name); + tempDirs.push(dir); + return dir; + } + + test('loads and validates the default Enterprise upgrade manifest', () => { + const { manifest } = loadEnterpriseUpgradeManifest(); + + expect(manifest.kind).toBe('aiox.enterprise.upgrade-manifest'); + expect(manifest.entitlement.envVar).toBe('AIOX_ENTERPRISE_KEY'); + expect(manifest.groups.map(group => group.id)).toEqual(expect.arrayContaining([ + 'config', + 'ide-surfaces', + 'workspace-templates', + 'workspace-scripts', + 'services', + 'squads', + 'governance', + 'docs', + ])); + }); + + test('invalid manifest fails with actionable schema error', () => { + const manifestPath = path.join(temp('invalid-manifest'), 'manifest.yaml'); + writeYaml(manifestPath, { + schemaVersion: '1.0', + kind: 'aiox.enterprise.upgrade-manifest', + allowlist: [], + }); + + expect(() => loadEnterpriseUpgradeManifest(manifestPath)) + .toThrow(/Enterprise upgrade manifest invalid:/); + }); + + test('deny and preserve rules take precedence over allowlist matches', () => { + const { manifest } = loadEnterpriseUpgradeManifest(); + const envPolicy = resolveMigrationPolicy('.env.production', manifest); + const secretPolicy = resolveMigrationPolicy('workspace/_templates/secret-template.yaml', manifest); + + expect(envPolicy.allowed).toBe(false); + expect(envPolicy.action).toBe('preserve'); + expect(envPolicy.blockedBy).toBe('.env*'); + expect(secretPolicy.allowed).toBe(false); + expect(secretPolicy.action).toBe('deny'); + expect(secretPolicy.blockedBy).toBe('**/*secret*'); + }); + + test('all migrated paths must match a positive allowlist entry', () => { + const { manifest } = loadEnterpriseUpgradeManifest(); + const allowed = resolveMigrationPolicy('enterprise-config.yaml', manifest); + const denied = resolveMigrationPolicy('services/clickup/token-loader.js', manifest); + + expect(allowed.allowed).toBe(true); + expect(allowed.policy).toBe('merge-yaml'); + expect(denied.allowed).toBe(false); + expect(denied.blockedBy).toBe('allowlist'); + }); + + test('services and squads are explicit, not unrestricted recursive imports', () => { + const { manifest } = loadEnterpriseUpgradeManifest(); + const broadServices = manifest.allowlist.filter(entry => entry.path === 'services/**'); + const broadSquads = manifest.allowlist.filter(entry => entry.path === 'squads/**'); + const explicitService = resolveMigrationPolicy('services/registry-engine/registry-utils.js', manifest); + const explicitSquad = resolveMigrationPolicy('squads/repo-ops/config.yaml', manifest); + const unlistedSquad = resolveMigrationPolicy('squads/private-squad/config.yaml', manifest); + + expect(broadServices).toEqual([]); + expect(broadSquads).toEqual([]); + expect(explicitService.allowed).toBe(true); + expect(explicitSquad.allowed).toBe(true); + expect(unlistedSquad.allowed).toBe(false); + }); + + test('glob matcher supports root and nested sensitive patterns', () => { + expect(pathMatchesPattern('secret.txt', '**/*secret*')).toBe(true); + expect(pathMatchesPattern('nested/private-secret.txt', '**/*secret*')).toBe(true); + expect(pathMatchesPattern('workspace/businesses/aiox/config.yaml', 'workspace/businesses/**')).toBe(true); + }); + + test('dry-run plan emits manifest-driven candidate and blocked operations with reasons', () => { + const targetDir = temp('aiox-pro-target'); + const enterpriseSource = temp('aiox-enterprise-source'); + createCoreInstall(targetDir); + createProInstall(targetDir); + createEnterpriseSource(enterpriseSource); + + const plan = buildEnterpriseUpgradePlan({ targetDir, enterpriseSource }); + const enterpriseConfigOp = plan.candidateOps.find(op => op.path === 'enterprise-config.yaml'); + const syncConfigOp = plan.candidateOps.find(op => op.path === '.aiox-sync.yaml'); + const outputsBlockedOp = plan.blockedOps.find(op => op.path === 'outputs/**'); + + expect(plan.migrationManifest.allowlistCount).toBeGreaterThan(0); + expect(enterpriseConfigOp.policy || enterpriseConfigOp.action).toBe('merge-yaml'); + expect(syncConfigOp.reason).toContain('IDE sync'); + expect(outputsBlockedOp.action).toBe('preserve'); + expect(plan.blockedOps.every(op => Boolean(op.reason))).toBe(true); + }); +}); diff --git a/packages/installer/tests/unit/enterprise/enterprise-upgrade-plan.test.js b/packages/installer/tests/unit/enterprise/enterprise-upgrade-plan.test.js new file mode 100644 index 0000000000..eeb6bc6f7d --- /dev/null +++ b/packages/installer/tests/unit/enterprise/enterprise-upgrade-plan.test.js @@ -0,0 +1,266 @@ +'use strict'; + +const crypto = require('crypto'); +const os = require('os'); +const path = require('path'); +const fs = require('fs-extra'); +const yaml = require('js-yaml'); +const { + buildEnterpriseUpgradePlan, + parseEnterpriseUpgradeArgs, + runEnterpriseUpgradeCli, + writeEnterpriseUpgradePlan, +} = require('../../../src/enterprise/enterprise-upgrade-plan'); + +function makeTempDir(name) { + return fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`)); +} + +function writeJson(filePath, data) { + fs.outputJsonSync(filePath, data, { spaces: 2 }); +} + +function writeYaml(filePath, data) { + fs.outputFileSync(filePath, yaml.dump(data, { noRefs: true }), 'utf8'); +} + +function createCoreInstall(targetDir, version = '5.1.15') { + writeYaml(path.join(targetDir, '.aiox-core', '.installed-manifest.yaml'), { + installedVersion: version, + }); + writeJson(path.join(targetDir, '.aiox-core', 'version.json'), { + version, + mode: 'core', + }); +} + +function createProInstall(targetDir, version = '2.0.0') { + writeYaml(path.join(targetDir, 'pro-installed-manifest.yaml'), { + version, + files: ['pro-version.json'], + }); + writeJson(path.join(targetDir, 'pro-version.json'), { + proVersion: version, + }); + writeYaml(path.join(targetDir, '.aiox-core', 'pro-config.yaml'), { + pro: { + version, + enabled: true, + }, + }); + writeYaml(path.join(targetDir, '.aiox-core', 'feature-registry.yaml'), { + features: { + pro: true, + }, + }); +} + +function createEnterpriseSource(sourceDir, version = '1.0.0') { + writeYaml(path.join(sourceDir, 'enterprise-config.yaml'), { + enterprise: { + product: 'AIOX Enterprise', + version, + source: 'SynkraAI/aiox-enterprise', + }, + }); + writeYaml(path.join(sourceDir, '.aiox-sync.yaml'), { + active_ides: ['claude', 'codex', 'gemini'], + }); + writeJson(path.join(sourceDir, 'package.json'), { + name: 'aiox-enterprise', + version, + }); + fs.outputFileSync(path.join(sourceDir, 'scripts', 'hub-sync.js'), "'use strict';\n", 'utf8'); +} + +function createStream() { + let output = ''; + return { + write(chunk) { + output += chunk; + }, + getOutput() { + return output; + }, + }; +} + +function fileSnapshot(rootDir) { + const snapshot = {}; + + function visit(currentDir) { + for (const item of fs.readdirSync(currentDir, { withFileTypes: true })) { + const absolutePath = path.join(currentDir, item.name); + const relativePath = path.relative(rootDir, absolutePath).split(path.sep).join('/'); + + if (item.isDirectory()) { + visit(absolutePath); + continue; + } + + const hash = crypto + .createHash('sha256') + .update(fs.readFileSync(absolutePath)) + .digest('hex'); + snapshot[relativePath] = hash; + } + } + + visit(rootDir); + return snapshot; +} + +describe('Enterprise upgrade dry-run planning', () => { + let tempDirs; + + beforeEach(() => { + tempDirs = []; + }); + + afterEach(() => { + for (const dir of tempDirs) { + fs.removeSync(dir); + } + }); + + function temp(name) { + const dir = makeTempDir(name); + tempDirs.push(dir); + return dir; + } + + test('fails when AIOX Core installation is not detected', () => { + const targetDir = temp('aiox-no-core'); + const enterpriseSource = temp('aiox-enterprise-source'); + createEnterpriseSource(enterpriseSource); + + expect(() => buildEnterpriseUpgradePlan({ targetDir, enterpriseSource })) + .toThrow('AIOX Core installation not detected'); + }); + + test('fails when AIOX Core exists without Pro activation', () => { + const targetDir = temp('aiox-core-no-pro'); + const enterpriseSource = temp('aiox-enterprise-source'); + createCoreInstall(targetDir); + createEnterpriseSource(enterpriseSource); + + expect(() => buildEnterpriseUpgradePlan({ targetDir, enterpriseSource })) + .toThrow('AIOX Pro activation not detected'); + }); + + test('creates a parseable dry-run plan for Core plus Pro projects', () => { + const targetDir = temp('aiox-pro-target'); + const enterpriseSource = temp('aiox-enterprise-source'); + createCoreInstall(targetDir, '5.1.15'); + createProInstall(targetDir, '2.3.4'); + createEnterpriseSource(enterpriseSource, '1.2.0'); + fs.ensureDirSync(path.join(targetDir, '.codex')); + fs.ensureDirSync(path.join(targetDir, '.claude')); + + const plan = buildEnterpriseUpgradePlan({ targetDir, enterpriseSource }); + const serialized = yaml.dump(plan); + const parsed = yaml.load(serialized); + + expect(parsed.mode).toBe('dry-run'); + expect(parsed.pro.source).toBe('installed-manifest'); + expect(parsed.pro.version).toBe('2.3.4'); + expect(parsed.pro.manifestPath).toBe('pro-installed-manifest.yaml'); + expect(parsed.enterprise.product).toBe('AIOX Enterprise'); + expect(parsed.enterprise.version).toBe('1.2.0'); + expect(parsed.enterprise.source).toBe('SynkraAI/aiox-enterprise'); + expect(parsed.activeIdes.map(ide => ide.id)).toEqual(['claude', 'codex']); + expect(parsed.preservedPaths).toEqual(expect.arrayContaining([ + '.env*', + 'workspace/businesses/**', + 'outputs/**', + 'docs/stories/**', + ])); + expect(parsed.candidateOps.length).toBeGreaterThan(0); + expect(parsed.blockedOps.map(op => op.path)).toEqual(expect.arrayContaining(['.env*'])); + expect(parsed.warnings).toContain('Dry-run mode: no files will be modified.'); + expect(parsed.warnings).not.toContain('Dry-run only: apply, rollback, and validation policy are implemented in later slices.'); + }); + + test.each([ + '--target', + '--enterprise-source', + '--manifest', + '--plan', + '--format', + ])('fails fast when %s is missing its value', optionName => { + expect(() => parseEnterpriseUpgradeArgs(['upgrade', optionName, '--dry-run'])) + .toThrow(`Missing value for ${optionName}`); + }); + + test('fails when the Enterprise source is invalid', () => { + const targetDir = temp('aiox-pro-target'); + const enterpriseSource = temp('invalid-enterprise-source'); + createCoreInstall(targetDir); + createProInstall(targetDir); + + expect(() => buildEnterpriseUpgradePlan({ targetDir, enterpriseSource })) + .toThrow('AIOX Enterprise source not detected'); + }); + + test('writes only the requested plan file during dry-run', () => { + const targetDir = temp('aiox-dry-run-target'); + const enterpriseSource = temp('aiox-enterprise-source'); + const planPath = path.join(targetDir, 'outputs', 'enterprise-upgrade-plan.yaml'); + createCoreInstall(targetDir); + createProInstall(targetDir); + createEnterpriseSource(enterpriseSource); + fs.ensureDirSync(path.join(targetDir, 'outputs')); + fs.outputFileSync(path.join(targetDir, 'README.md'), '# Existing project\n', 'utf8'); + + const before = fileSnapshot(targetDir); + const plan = buildEnterpriseUpgradePlan({ targetDir, enterpriseSource }); + writeEnterpriseUpgradePlan(plan, planPath); + const after = fileSnapshot(targetDir); + delete after['outputs/enterprise-upgrade-plan.yaml']; + + expect(after).toEqual(before); + expect(yaml.load(fs.readFileSync(planPath, 'utf8')).mode).toBe('dry-run'); + }); + + test('CLI returns non-zero for missing prereqs and zero for valid dry-run', async () => { + const invalidTarget = temp('aiox-cli-invalid-target'); + const validTarget = temp('aiox-cli-valid-target'); + const enterpriseSource = temp('aiox-enterprise-source'); + createEnterpriseSource(enterpriseSource); + createCoreInstall(validTarget); + createProInstall(validTarget); + + const badOut = createStream(); + const badErr = createStream(); + const badCode = await runEnterpriseUpgradeCli([ + 'upgrade', + '--target', + invalidTarget, + '--enterprise-source', + enterpriseSource, + '--dry-run', + ], { stdout: badOut, stderr: badErr }); + + expect(badCode).toBe(1); + expect(badErr.getOutput()).toContain('AIOX Core installation not detected'); + + const planPath = path.join(validTarget, 'outputs', 'enterprise-upgrade-plan.yaml'); + const goodOut = createStream(); + const goodErr = createStream(); + const goodCode = await runEnterpriseUpgradeCli([ + 'upgrade', + '--target', + validTarget, + '--enterprise-source', + enterpriseSource, + '--dry-run', + '--plan', + planPath, + ], { stdout: goodOut, stderr: goodErr }); + + expect(goodCode).toBe(0); + expect(goodErr.getOutput()).toBe(''); + expect(goodOut.getOutput()).toContain('Enterprise upgrade plan written:'); + expect(yaml.load(fs.readFileSync(planPath, 'utf8')).mode).toBe('dry-run'); + }); +}); diff --git a/packages/installer/tests/unit/enterprise/enterprise-upgrader.test.js b/packages/installer/tests/unit/enterprise/enterprise-upgrader.test.js new file mode 100644 index 0000000000..0a7a419310 --- /dev/null +++ b/packages/installer/tests/unit/enterprise/enterprise-upgrader.test.js @@ -0,0 +1,269 @@ +'use strict'; + +const os = require('os'); +const path = require('path'); +const fs = require('fs-extra'); +const yaml = require('js-yaml'); +const { + applyEnterpriseUpgrade, +} = require('../../../src/enterprise/enterprise-upgrader'); +const { + rollbackEnterpriseUpgrade, +} = require('../../../src/enterprise/enterprise-rollback'); +const { + runEnterpriseUpgradeCli, +} = require('../../../src/enterprise/enterprise-upgrade-plan'); + +function makeTempDir(name) { + return fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`)); +} + +function writeJson(filePath, data) { + fs.outputJsonSync(filePath, data, { spaces: 2 }); +} + +function writeYaml(filePath, data) { + fs.outputFileSync(filePath, yaml.dump(data, { noRefs: true }), 'utf8'); +} + +function createCoreInstall(targetDir, version = '5.1.15') { + writeYaml(path.join(targetDir, '.aiox-core', '.installed-manifest.yaml'), { + installedVersion: version, + }); +} + +function createProInstall(targetDir, version = '2.0.0') { + fs.ensureDirSync(path.join(targetDir, 'pro')); + writeYaml(path.join(targetDir, 'pro-installed-manifest.yaml'), { + version, + }); + writeJson(path.join(targetDir, 'pro-version.json'), { + proVersion: version, + }); +} + +function createEnterpriseSource(sourceDir, version = '1.0.0') { + writeYaml(path.join(sourceDir, 'enterprise-config.yaml'), { + enterprise: { + product: 'AIOX Enterprise', + version, + source: 'SynkraAI/aiox-enterprise', + }, + }); + writeYaml(path.join(sourceDir, '.aiox-sync.yaml'), { + active_ides: ['claude', 'codex'], + }); + writeJson(path.join(sourceDir, 'package.json'), { + name: 'aiox-enterprise', + version, + }); + fs.outputFileSync(path.join(sourceDir, 'scripts', 'hub-sync.js'), "module.exports = 'enterprise';\n", 'utf8'); + fs.outputFileSync(path.join(sourceDir, 'workspace', '_templates', 'secret-template.yaml'), 'secret: true\n', 'utf8'); +} + +function createStream() { + let output = ''; + return { + write(chunk) { + output += chunk; + }, + getOutput() { + return output; + }, + }; +} + +describe('Enterprise transactional upgrader and rollback', () => { + let tempDirs; + + beforeEach(() => { + tempDirs = []; + }); + + afterEach(() => { + for (const dir of tempDirs) { + fs.removeSync(dir); + } + }); + + function temp(name) { + const dir = makeTempDir(name); + tempDirs.push(dir); + return dir; + } + + function createReadyTarget() { + const targetDir = temp('aiox-apply-target'); + createCoreInstall(targetDir); + createProInstall(targetDir); + return targetDir; + } + + test('apply refuses to run without Enterprise entitlement or test fixture flag', () => { + const targetDir = createReadyTarget(); + const enterpriseSource = temp('aiox-enterprise-source'); + createEnterpriseSource(enterpriseSource); + + expect(() => applyEnterpriseUpgrade({ + targetDir, + enterpriseSource, + env: {}, + })).toThrow(/Enterprise entitlement required/); + }); + + test('apply creates backup before overwriting a changed file and writes execution manifest', () => { + const targetDir = createReadyTarget(); + const enterpriseSource = temp('aiox-enterprise-source'); + createEnterpriseSource(enterpriseSource); + fs.outputFileSync(path.join(targetDir, 'scripts', 'hub-sync.js'), "module.exports = 'target';\n", 'utf8'); + + const result = applyEnterpriseUpgrade({ + targetDir, + enterpriseSource, + env: { AIOX_ENTERPRISE_TEST_FIXTURE: '1' }, + timestamp: '20260508070126', + }); + const manifest = yaml.load(fs.readFileSync(result.manifestPath, 'utf8')); + + expect(result.success).toBe(true); + expect(fs.readFileSync(path.join(targetDir, 'scripts', 'hub-sync.js'), 'utf8')).toContain('enterprise'); + expect(manifest.backedUp.map(entry => entry.path)).toContain('scripts/hub-sync.js'); + expect(fs.existsSync(path.join(targetDir, '.aiox', 'enterprise-upgrade-backups', '20260508070126', 'scripts', 'hub-sync.js'))).toBe(true); + expect(manifest.copied.length).toBeGreaterThan(0); + expect(manifest.merged.map(entry => entry.path)).toEqual(expect.arrayContaining([ + 'enterprise-config.yaml', + '.aiox-sync.yaml', + ])); + expect(Array.isArray(manifest.preserved)).toBe(true); + expect(Array.isArray(manifest.denied)).toBe(true); + expect(Array.isArray(manifest.validated)).toBe(true); + expect(Array.isArray(manifest.errors)).toBe(true); + }); + + test('apply preserves copy-if-missing destinations and denies sensitive allowlist matches', () => { + const targetDir = createReadyTarget(); + const enterpriseSource = temp('aiox-enterprise-source'); + createEnterpriseSource(enterpriseSource); + fs.outputFileSync(path.join(targetDir, 'workspace', '_templates', 'existing.yaml'), 'target: true\n', 'utf8'); + fs.outputFileSync(path.join(enterpriseSource, 'workspace', '_templates', 'existing.yaml'), 'source: true\n', 'utf8'); + + const result = applyEnterpriseUpgrade({ + targetDir, + enterpriseSource, + env: { AIOX_ENTERPRISE_TEST_FIXTURE: '1' }, + }); + const manifest = yaml.load(fs.readFileSync(result.manifestPath, 'utf8')); + + expect(fs.readFileSync(path.join(targetDir, 'workspace', '_templates', 'existing.yaml'), 'utf8')).toContain('target'); + expect(manifest.preserved.map(entry => entry.path)).toContain('workspace/_templates/existing.yaml'); + expect(manifest.denied.map(entry => entry.path)).toContain('workspace/_templates/secret-template.yaml'); + expect(fs.existsSync(path.join(targetDir, 'workspace', '_templates', 'secret-template.yaml'))).toBe(false); + }); + + test('merge-yaml unions arrays while preserving target order', () => { + const targetDir = createReadyTarget(); + const enterpriseSource = temp('aiox-enterprise-source'); + createEnterpriseSource(enterpriseSource); + writeYaml(path.join(targetDir, '.aiox-sync.yaml'), { + active_ides: ['codex'], + }); + + applyEnterpriseUpgrade({ + targetDir, + enterpriseSource, + env: { AIOX_ENTERPRISE_TEST_FIXTURE: '1' }, + }); + const merged = yaml.load(fs.readFileSync(path.join(targetDir, '.aiox-sync.yaml'), 'utf8')); + + expect(merged.active_ides).toEqual(['codex', 'claude']); + }); + + test('rollback restores backed up files from execution manifest', () => { + const targetDir = createReadyTarget(); + const enterpriseSource = temp('aiox-enterprise-source'); + createEnterpriseSource(enterpriseSource); + const targetHubSync = path.join(targetDir, 'scripts', 'hub-sync.js'); + fs.outputFileSync(targetHubSync, "module.exports = 'target';\n", 'utf8'); + + const result = applyEnterpriseUpgrade({ + targetDir, + enterpriseSource, + env: { AIOX_ENTERPRISE_TEST_FIXTURE: '1' }, + timestamp: '20260508070127', + }); + rollbackEnterpriseUpgrade(result.manifestPath); + + expect(fs.readFileSync(targetHubSync, 'utf8')).toContain('target'); + expect(fs.existsSync(path.join(targetDir, '.aiox', 'enterprise-upgrade-rollback.yaml'))).toBe(true); + }); + + test('apply keeps Pro markers and records doctor output when available', () => { + const targetDir = createReadyTarget(); + const enterpriseSource = temp('aiox-enterprise-source'); + createEnterpriseSource(enterpriseSource); + fs.outputFileSync( + path.join(targetDir, 'bin', 'aiox.js'), + 'console.log(JSON.stringify({ summary: { fail: 0 } }));\n', + 'utf8', + ); + + const result = applyEnterpriseUpgrade({ + targetDir, + enterpriseSource, + env: { AIOX_ENTERPRISE_TEST_FIXTURE: '1' }, + }); + const manifest = yaml.load(fs.readFileSync(result.manifestPath, 'utf8')); + + expect(fs.existsSync(path.join(targetDir, 'pro'))).toBe(true); + expect(fs.existsSync(path.join(targetDir, 'pro-installed-manifest.yaml'))).toBe(true); + expect(fs.existsSync(path.join(targetDir, 'pro-version.json'))).toBe(true); + expect(manifest.validated.filter(entry => entry.type === 'pro-marker-preserved').every(entry => entry.exists)).toBe(true); + expect(manifest.doctor.skipped).toBe(false); + expect(manifest.doctor.stdout).toContain('"fail":0'); + }); + + test('CLI apply and rollback return expected exit codes', async () => { + const targetDir = createReadyTarget(); + const enterpriseSource = temp('aiox-enterprise-source'); + createEnterpriseSource(enterpriseSource); + fs.outputFileSync(path.join(targetDir, 'scripts', 'hub-sync.js'), "module.exports = 'target';\n", 'utf8'); + + const out = createStream(); + const err = createStream(); + const previousFixture = process.env.AIOX_ENTERPRISE_TEST_FIXTURE; + process.env.AIOX_ENTERPRISE_TEST_FIXTURE = '1'; + + try { + const applyCode = await runEnterpriseUpgradeCli([ + 'upgrade', + '--target', + targetDir, + '--enterprise-source', + enterpriseSource, + '--apply', + ], { stdout: out, stderr: err }); + + expect(applyCode).toBe(0); + expect(out.getOutput()).toContain('Enterprise upgrade applied:'); + + const rollbackOut = createStream(); + const rollbackErr = createStream(); + const rollbackCode = await runEnterpriseUpgradeCli([ + 'upgrade', + 'rollback', + '--manifest', + path.join(targetDir, '.aiox', 'enterprise-upgrade-manifest.yaml'), + ], { stdout: rollbackOut, stderr: rollbackErr }); + + expect(rollbackCode).toBe(0); + expect(rollbackErr.getOutput()).toBe(''); + expect(rollbackOut.getOutput()).toContain('Enterprise upgrade rollback written:'); + } finally { + if (previousFixture === undefined) { + delete process.env.AIOX_ENTERPRISE_TEST_FIXTURE; + } else { + process.env.AIOX_ENTERPRISE_TEST_FIXTURE = previousFixture; + } + } + }); +}); diff --git a/scripts/e2e/pro-to-enterprise-upgrade-smoke.js b/scripts/e2e/pro-to-enterprise-upgrade-smoke.js new file mode 100644 index 0000000000..4d50b14738 --- /dev/null +++ b/scripts/e2e/pro-to-enterprise-upgrade-smoke.js @@ -0,0 +1,232 @@ +#!/usr/bin/env node + +'use strict'; + +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const yaml = require('js-yaml'); + +const repoRoot = path.resolve(__dirname, '..', '..'); +const cliPath = path.join(repoRoot, 'bin', 'aiox.js'); +const keepTemp = process.env.AIOX_E2E_KEEP_TEMP === '1'; +const verbose = process.env.AIOX_E2E_VERBOSE === '1'; +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'aiox-pro-enterprise-e2e-')); +const targetDir = path.join(tempRoot, 'target-project'); +const enterpriseSource = path.join(tempRoot, 'enterprise-fixture'); + +function log(message) { + console.log(`[pro-enterprise-e2e] ${message}`); +} + +function fail(message, details = '') { + const suffix = details ? `\n${details}` : ''; + throw new Error(`${message}${suffix}`); +} + +function writeFile(relativePath, content, root = targetDir) { + const filePath = path.join(root, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf8'); +} + +function readYaml(filePath) { + return yaml.load(fs.readFileSync(filePath, 'utf8')) || {}; +} + +function runCli(args, options = {}) { + const result = spawnSync(process.execPath, [cliPath, ...args], { + cwd: repoRoot, + env: { ...process.env, ...(options.env || {}) }, + encoding: 'utf8', + timeout: 120000, + maxBuffer: 1024 * 1024 * 20, + }); + + if (verbose && result.stdout) process.stdout.write(result.stdout); + if (verbose && result.stderr) process.stderr.write(result.stderr); + + if (result.error) { + fail(`Command failed to start: aiox ${args.join(' ')}`, result.error.message); + } + + if (result.status !== 0) { + fail( + `Command failed (${result.status}): aiox ${args.join(' ')}`, + [result.stdout && `STDOUT:\n${result.stdout}`, result.stderr && `STDERR:\n${result.stderr}`] + .filter(Boolean) + .join('\n\n'), + ); + } + + return result.stdout || ''; +} + +function assertPath(relativePath, root = targetDir) { + const filePath = path.join(root, relativePath); + if (!fs.existsSync(filePath)) { + fail(`Missing expected path: ${relativePath}`); + } + return filePath; +} + +function assertNotPath(relativePath, root = targetDir) { + const filePath = path.join(root, relativePath); + if (fs.existsSync(filePath)) { + fail(`Unexpected path exists: ${relativePath}`); + } +} + +function assertContains(filePath, expected) { + const content = fs.readFileSync(filePath, 'utf8'); + if (!content.includes(expected)) { + fail(`Expected ${filePath} to contain: ${expected}`); + } +} + +function setupTarget() { + fs.mkdirSync(targetDir, { recursive: true }); + writeFile('.aiox-core/.installed-manifest.yaml', 'installedVersion: 5.1.15\n'); + writeFile('pro-installed-manifest.yaml', 'version: 2.0.0\n'); + writeFile('pro-version.json', '{"proVersion":"2.0.0"}\n'); + fs.mkdirSync(path.join(targetDir, 'pro'), { recursive: true }); + fs.mkdirSync(path.join(targetDir, '.claude'), { recursive: true }); + fs.mkdirSync(path.join(targetDir, '.codex'), { recursive: true }); + writeFile('bin/aiox.js', 'console.log(JSON.stringify({ summary: { fail: 0 } }));\n'); + writeFile('scripts/hub-sync.js', "module.exports = 'target';\n"); + writeFile('workspace/_templates/existing.yaml', 'owner: target\n'); +} + +function setupEnterpriseFixture() { + fs.mkdirSync(enterpriseSource, { recursive: true }); + writeFile('enterprise-config.yaml', [ + 'enterprise:', + ' product: AIOX Enterprise', + ' version: 1.0.0', + ' source: local-fixture', + '', + ].join('\n'), enterpriseSource); + writeFile('.aiox-sync.yaml', 'active_ides:\n - claude\n - codex\n', enterpriseSource); + writeFile('package.json', '{"name":"aiox-enterprise","version":"1.0.0"}\n', enterpriseSource); + writeFile('scripts/hub-sync.js', "module.exports = 'enterprise';\n", enterpriseSource); + writeFile('scripts/enterprise-sync.js', "module.exports = 'enterprise-sync';\n", enterpriseSource); + writeFile('scripts/enterprise-sanitize.js', "module.exports = 'enterprise-sanitize';\n", enterpriseSource); + writeFile('workspace/_templates/existing.yaml', 'owner: enterprise\n', enterpriseSource); + writeFile('workspace/_templates/new-template.yaml', 'owner: enterprise\n', enterpriseSource); + writeFile('workspace/_templates/secret-template.yaml', 'secret: true\n', enterpriseSource); + writeFile('services/service-catalog.yaml', 'services:\n - registry-engine\n', enterpriseSource); + writeFile('services/registry-engine/index.js', "module.exports = 'registry';\n", enterpriseSource); + writeFile('squads/repo-ops/config.yaml', 'name: repo-ops\n', enterpriseSource); + writeFile('.claude/agents/dev.md', '# Dev Agent\n', enterpriseSource); + writeFile('.codex/skills/migrate-pro-to-enterprise/SKILL.md', '# Migrate Pro To Enterprise\n', enterpriseSource); + writeFile( + '.claude/skills/migrate-pro-to-enterprise/SKILL.md', + [ + '---', + 'name: migrate-pro-to-enterprise', + 'description: Sanitized Enterprise fixture skill for Pro to Enterprise E2E.', + '---', + '', + '# Migrate Pro To Enterprise', + '', + 'Run dry-run before apply. The CLI is the source of truth.', + '', + ].join('\n'), + enterpriseSource, + ); +} + +function cleanup() { + if (keepTemp) { + log(`Keeping temp root: ${tempRoot}`); + return; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); +} + +function main() { + try { + log(`Temp root: ${tempRoot}`); + setupTarget(); + setupEnterpriseFixture(); + + const planPath = path.join(targetDir, 'outputs', 'enterprise-upgrade-plan.yaml'); + log('Running dry-run plan'); + runCli([ + 'enterprise', + 'upgrade', + '--target', + targetDir, + '--enterprise-source', + enterpriseSource, + '--dry-run', + '--plan', + planPath, + ]); + + const plan = readYaml(planPath); + if (plan.mode !== 'dry-run') fail('Dry-run plan did not include mode: dry-run'); + if (!plan.candidateOps.find(op => op.path === 'enterprise-config.yaml')) { + fail('Dry-run plan missing enterprise-config.yaml candidate'); + } + + log('Applying Enterprise fixture'); + runCli([ + 'enterprise', + 'upgrade', + '--target', + targetDir, + '--enterprise-source', + enterpriseSource, + '--apply', + ], { + env: { AIOX_ENTERPRISE_TEST_FIXTURE: '1' }, + }); + + const executionManifestPath = path.join(targetDir, '.aiox', 'enterprise-upgrade-manifest.yaml'); + const executionManifest = readYaml(executionManifestPath); + if (executionManifest.status !== 'success') { + fail('Execution manifest did not finish with success'); + } + if (!executionManifest.doctor || executionManifest.doctor.skipped) { + fail('Execution manifest did not capture doctor --json output'); + } + for (const key of ['copied', 'merged', 'preserved', 'denied', 'backedUp', 'validated', 'errors']) { + if (!Array.isArray(executionManifest[key])) { + fail(`Execution manifest missing array: ${key}`); + } + } + + assertContains(assertPath('workspace/_templates/existing.yaml'), 'owner: target'); + assertPath('workspace/_templates/new-template.yaml'); + assertNotPath('workspace/_templates/secret-template.yaml'); + assertPath('pro'); + assertPath('pro-installed-manifest.yaml'); + assertPath('pro-version.json'); + assertPath('.claude/skills/migrate-pro-to-enterprise/SKILL.md'); + assertPath('.claude/agents/dev.md'); + assertPath('.codex/skills/migrate-pro-to-enterprise/SKILL.md'); + + log('Running rollback smoke'); + runCli([ + 'enterprise', + 'upgrade', + 'rollback', + '--manifest', + executionManifestPath, + ]); + assertContains(assertPath('scripts/hub-sync.js'), 'target'); + + log('PASS'); + } finally { + cleanup(); + } +} + +try { + main(); +} catch (error) { + console.error(`[pro-enterprise-e2e] FAIL: ${error.message}`); + process.exit(1); +}