diff --git a/.aiox-core/cli/commands/pro/index.js b/.aiox-core/cli/commands/pro/index.js index f7bd06723..adbefde1e 100644 --- a/.aiox-core/cli/commands/pro/index.js +++ b/.aiox-core/cli/commands/pro/index.js @@ -9,7 +9,7 @@ * aiox pro deactivate Deactivate the current license * aiox pro features List all pro features * aiox pro validate Force online revalidation - * aiox pro setup Configure GitHub Packages access (AC-12) + * aiox pro setup Install or verify the AIOX Pro package * * @module cli/commands/pro * @version 1.1.0 @@ -25,7 +25,7 @@ const readline = require('readline'); // BUG-6 fix (INS-1): Dynamic licensePath resolution // In framework-dev: __dirname = aiox-core/.aiox-core/cli/commands/pro → ../../../../pro/license -// In project-dev: pro is installed via npm as @aiox-fullstack/pro +// In project-dev: pro is installed via npm as @aiox-fullstack/pro or @aios-fullstack/pro function resolveLicensePath() { // 1. Try relative path (framework-dev mode) const relativePath = path.resolve(__dirname, '..', '..', '..', '..', 'pro', 'license'); @@ -33,23 +33,36 @@ function resolveLicensePath() { return relativePath; } - // 2. Try node_modules/@aiox-fullstack/pro/license (project-dev mode) - try { - const proPkg = require.resolve('@aiox-fullstack/pro/package.json'); - const proDir = path.dirname(proPkg); - const npmPath = path.join(proDir, 'license'); - if (fs.existsSync(npmPath)) { - return npmPath; + // 2. Try npm packages — canonical then fallback + const npmCandidates = [ + '@aiox-fullstack/pro', + '@aios-fullstack/pro', + ]; + + for (const pkgName of npmCandidates) { + try { + const proPkg = require.resolve(`${pkgName}/package.json`); + const proDir = path.dirname(proPkg); + const npmPath = path.join(proDir, 'license'); + if (fs.existsSync(npmPath)) { + return npmPath; + } + } catch { + // package not installed } - } catch { - // @aiox-fullstack/pro not installed via npm } - // 3. Try project root node_modules (fallback) + // 3. Try project root node_modules (both scopes) const projectRoot = process.cwd(); - const cwdPath = path.join(projectRoot, 'node_modules', '@aiox-fullstack', 'pro', 'license'); - if (fs.existsSync(cwdPath)) { - return cwdPath; + const scopePaths = [ + path.join(projectRoot, 'node_modules', '@aiox-fullstack', 'pro', 'license'), + path.join(projectRoot, 'node_modules', '@aios-fullstack', 'pro', 'license'), + ]; + + for (const cwdPath of scopePaths) { + if (fs.existsSync(cwdPath)) { + return cwdPath; + } } // Return relative path as default (will fail gracefully in loadLicenseModules) @@ -97,7 +110,8 @@ function loadLicenseModules() { }; } catch (error) { console.error('AIOX Pro license module not available.'); - console.error('Install AIOX Pro: npm install @aiox-fullstack/pro'); + console.error('Install AIOX Pro: aiox pro setup'); + console.error('Or via wrapper: npx aiox-pro install'); process.exit(1); } } @@ -217,9 +231,13 @@ async function activateAction(options) { // Scaffold pro content into project (Story INS-3.1) // Lazy-load to avoid crashing if pro-scaffolder or js-yaml is unavailable const projectRoot = path.resolve(__dirname, '..', '..', '..', '..'); - const proSourceDir = path.join(projectRoot, 'node_modules', '@aiox-fullstack', 'pro'); + // Try canonical then fallback package path + const proSourceDir = [ + path.join(projectRoot, 'node_modules', '@aiox-fullstack', 'pro'), + path.join(projectRoot, 'node_modules', '@aios-fullstack', 'pro'), + ].find(p => fs.existsSync(p)); - if (fs.existsSync(proSourceDir)) { + if (proSourceDir) { let scaffoldProContent; try { ({ scaffoldProContent } = require('../../../../packages/installer/src/pro/pro-scaffolder')); @@ -261,7 +279,7 @@ async function activateAction(options) { console.log(''); } } else { - console.log('Note: @aiox-fullstack/pro package not found in node_modules.'); + console.log('Note: AIOX Pro package not found in node_modules.'); console.log('Pro content will be scaffolded when the package is installed.'); console.log(''); } @@ -571,64 +589,108 @@ async function validateAction() { // --------------------------------------------------------------------------- /** - * Setup and verify @aiox-fullstack/pro installation. + * Setup and verify AIOX Pro installation. * - * Since @aiox-fullstack/pro is published on the public npm registry, - * no special token or .npmrc configuration is needed. This command - * installs the package and verifies it's working. + * Tries canonical @aiox-fullstack/pro first, falls back to @aios-fullstack/pro. * * @param {object} options - Command options * @param {boolean} options.verify - Only verify without installing */ async function setupAction(options) { + const PRO_PACKAGES = ['@aiox-fullstack/pro', '@aios-fullstack/pro']; + console.log('\nAIOX Pro - Setup\n'); if (options.verify) { - // Verify-only mode - console.log('Verifying @aiox-fullstack/pro installation...\n'); + console.log('Verifying AIOX Pro installation...\n'); try { const { execSync } = require('child_process'); - const result = execSync('npm ls @aiox-fullstack/pro --json', { - stdio: 'pipe', - timeout: 15000, - }); - const parsed = JSON.parse(result.toString()); - const deps = parsed.dependencies || {}; - if (deps['@aiox-fullstack/pro']) { - console.log(`✅ @aiox-fullstack/pro@${deps['@aiox-fullstack/pro'].version} is installed`); - } else { - console.log('❌ @aiox-fullstack/pro is not installed'); + let found = false; + for (const pkg of PRO_PACKAGES) { + try { + const result = execSync(`npm ls ${pkg} --json`, { stdio: 'pipe', timeout: 15000 }); + const parsed = JSON.parse(result.toString()); + const deps = parsed.dependencies || {}; + if (deps[pkg]) { + console.log(`✅ ${pkg}@${deps[pkg].version} is installed`); + found = true; + break; + } + } catch { /* try next */ } + } + if (!found) { + console.log('❌ AIOX Pro is not installed'); console.log(''); console.log('Install with:'); - console.log(' npm install @aiox-fullstack/pro'); + console.log(' aiox pro setup'); + console.log(' # or npx aiox-pro install'); } } catch { - console.log('❌ @aiox-fullstack/pro is not installed'); + console.log('❌ AIOX Pro is not installed'); console.log(''); console.log('Install with:'); - console.log(' npm install @aiox-fullstack/pro'); + console.log(' aiox pro setup'); + console.log(' # or npx aiox-pro install'); } return; } - // Install mode - console.log('@aiox-fullstack/pro is available on the public npm registry.'); + // Install mode — try canonical first, fallback second + console.log('AIOX Pro is available on the public npm registry.'); console.log('No special tokens or configuration needed.\n'); - console.log('Installing @aiox-fullstack/pro...\n'); + const { execSync } = require('child_process'); + let installedPackage = null; - try { - const { execSync } = require('child_process'); - execSync('npm install @aiox-fullstack/pro', { - stdio: 'inherit', - timeout: 120000, - }); - console.log('\n✅ @aiox-fullstack/pro installed successfully!'); - } catch (error) { - console.error(`\n❌ Installation failed: ${error.message}`); + function getInstallErrorOutput(error) { + return [ + error?.message, + error?.stderr?.toString?.(), + error?.stdout?.toString?.(), + ].filter(Boolean).join('\n'); + } + + function isPackageNotFoundError(error, pkg) { + const output = getInstallErrorOutput(error).toLowerCase(); + const packageName = pkg.toLowerCase(); + + if (!output.includes(packageName)) { + return false; + } + + return output.includes('e404') + || output.includes('npm err! 404') + || output.includes(' is not in this registry') + || output.includes(' not found'); + } + + for (const pkg of PRO_PACKAGES) { + try { + console.log(`Installing ${pkg}...\n`); + execSync(`npm install ${pkg}`, { stdio: 'inherit', timeout: 120000 }); + console.log(`\n✅ ${pkg} installed successfully!`); + installedPackage = pkg; + break; + } catch (error) { + if (isPackageNotFoundError(error, pkg)) { + continue; + } + + console.error(`\n❌ Failed to install ${pkg}.`); + const details = getInstallErrorOutput(error); + if (details) { + console.error(details); + } + process.exit(1); + } + } + + if (!installedPackage) { + console.error('\n❌ Installation failed.'); console.log('\nTry manually:'); - console.log(' npm install @aiox-fullstack/pro'); + console.log(' aiox pro setup'); + console.log(' # or npx aiox-pro install'); process.exit(1); } @@ -644,6 +706,66 @@ async function setupAction(options) { console.log(''); } +// --------------------------------------------------------------------------- +// aiox pro update (Story 122.3) +// --------------------------------------------------------------------------- + +async function updateAction(options) { + const proUpdaterPath = path.resolve(__dirname, '..', '..', '..', 'core', 'pro', 'pro-updater'); + let updatePro, formatUpdateResult; + + try { + ({ updatePro, formatUpdateResult } = require(proUpdaterPath)); + } catch { + console.error('❌ Pro updater module not found.'); + console.error('Please ensure aiox-core is installed correctly.'); + process.exit(1); + } + + const projectRoot = process.cwd(); + + // Validate license before updating (unless --check) + if (!options.check && !options.dryRun) { + try { + const { featureGate } = loadLicenseModules(); + const state = featureGate.getLicenseState(); + if (state !== 'Active' && state !== 'Grace') { + console.error('\n❌ AIOX Pro license is not active.'); + console.error('Activate your license first: aiox pro activate --key PRO-XXXX-XXXX-XXXX-XXXX'); + process.exit(1); + } + } catch { + // License modules not available — proceed anyway (first update scenario) + } + } + + try { + const result = await updatePro(projectRoot, { + check: options.check || false, + dryRun: options.dryRun || false, + force: options.force || false, + includeCoreUpdate: options.includeCore || false, + skipScaffold: options.skipScaffold || false, + onProgress: (phase, message) => { + if (phase === 'detect') console.log(` 🔍 ${message}`); + else if (phase === 'check') console.log(` 📡 ${message}`); + else if (phase === 'core') console.log(` 📦 ${message}`); + else if (phase === 'update') console.log(` ⬆️ ${message}`); + else if (phase === 'scaffold') console.log(` 🔧 ${message}`); + }, + }); + + console.log(formatUpdateResult(result)); + + if (!result.success) { + process.exit(1); + } + } catch (error) { + console.error(`\n❌ ${error.message}`); + process.exit(1); + } +} + // --------------------------------------------------------------------------- // Command builder // --------------------------------------------------------------------------- @@ -691,10 +813,21 @@ function createProCommand() { // aiox pro setup (AC-12: Install-gate) proCmd .command('setup') - .description('Install and verify @aiox-fullstack/pro') + .description('Install and verify AIOX Pro') .option('--verify', 'Only verify installation without installing') .action(setupAction); + // aiox pro update (Story 122.3) + proCmd + .command('update') + .description('Update AIOX Pro to latest version and sync assets') + .option('--check', 'Check for updates without applying') + .option('--dry-run', 'Show update plan without executing') + .option('-f, --force', 'Force reinstall even if up-to-date') + .option('--include-core', 'Also update aiox-core') + .option('--skip-scaffold', 'Skip re-scaffolding assets after update') + .action(updateAction); + return proCmd; } diff --git a/.aiox-core/core/pro/pro-updater.js b/.aiox-core/core/pro/pro-updater.js new file mode 100644 index 000000000..ab340b2c6 --- /dev/null +++ b/.aiox-core/core/pro/pro-updater.js @@ -0,0 +1,578 @@ +/** + * Pro Updater — update @aiox-fullstack/pro (or fallback @aios-fullstack/pro) + * + * Handles: + * - Detecting installed Pro version and source + * - Querying npm for latest version + * - Checking compatibility with installed aiox-core + * - Updating the package via the project's package manager + * - Re-scaffolding Pro assets after update + * + * @module .aiox-core/core/pro/pro-updater + * @story 122.3 — Implementar aiox pro update + */ + +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const https = require('https'); +const { createRequire } = require('module'); +const semver = require('semver'); +const { execSync } = require('child_process'); + +const PRO_PACKAGES = ['@aiox-fullstack/pro', '@aios-fullstack/pro']; +const CORE_PACKAGES = ['@synkra/aiox-core', 'aiox-core']; +const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; +const CORE_PACKAGE_ROOT = path.resolve(__dirname, '..', '..', '..'); +const CORE_PACKAGE_REQUIRE = createRequire(path.join(CORE_PACKAGE_ROOT, 'package.json')); +const INSTALLER_SCAFFOLDER_EXPORT = 'aiox-core/installer/pro-scaffolder'; + +/** + * Detect which package manager the project uses. + * @param {string} projectRoot + * @returns {'bun'|'pnpm'|'yarn'|'npm'} + */ +function detectPackageManager(projectRoot) { + if (fs.existsSync(path.join(projectRoot, 'bun.lockb'))) return 'bun'; + if (fs.existsSync(path.join(projectRoot, 'pnpm-lock.yaml'))) return 'pnpm'; + if (fs.existsSync(path.join(projectRoot, 'yarn.lock'))) return 'yarn'; + return 'npm'; +} + +/** + * Fetch latest version of a package from npm registry. + * @param {string} packageName + * @param {number} [timeout=15000] + * @returns {Promise<{version:string, peerDependencies:Object}|null>} + */ +function fetchLatestFromNpm(packageName, timeout = 15000) { + return new Promise((resolve) => { + const encoded = encodeURIComponent(packageName).replace('%40', '@'); + const url = `https://registry.npmjs.org/${encoded}/latest`; + + const req = https.get(url, { timeout }, (res) => { + if (res.statusCode < 200 || res.statusCode >= 300) { + res.resume(); + resolve(null); + return; + } + + let data = ''; + res.on('data', (c) => { data += c; }); + res.on('end', () => { + try { + const json = JSON.parse(data); + resolve({ + version: json.version || null, + peerDependencies: json.peerDependencies || {}, + }); + } catch { + resolve(null); + } + }); + }); + + req.on('error', () => resolve(null)); + req.on('timeout', () => { req.destroy(); resolve(null); }); + }); +} + +/** + * Resolve which Pro package is installed and where. + * @param {string} projectRoot + * @returns {{ packageName:string, packagePath:string, version:string }|null} + */ +function resolveInstalledPro(projectRoot) { + for (const pkg of PRO_PACKAGES) { + const scope = pkg.split('/')[0].replace('@', ''); + const pkgPath = path.join(projectRoot, 'node_modules', `@${scope}`, 'pro'); + const pkgJson = path.join(pkgPath, 'package.json'); + + if (fs.existsSync(pkgJson)) { + try { + const data = JSON.parse(fs.readFileSync(pkgJson, 'utf8')); + return { packageName: pkg, packagePath: pkgPath, version: data.version || '0.0.0' }; + } catch { /* corrupt, try next */ } + } + } + return null; +} + +function readProjectPackageJson(projectRoot) { + const packageJsonPath = path.join(projectRoot, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return null; + } + + try { + return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + } catch { + return null; + } +} + +function buildNodeModulesPackageJsonPath(projectRoot, packageName) { + if (packageName.startsWith('@')) { + const [scope, name] = packageName.slice(1).split('/'); + return path.join(projectRoot, 'node_modules', scope, name, 'package.json'); + } + + return path.join(projectRoot, 'node_modules', packageName, 'package.json'); +} + +function detectCorePackageName(projectRoot) { + const packageJson = readProjectPackageJson(projectRoot); + if (!packageJson) { + return null; + } + + if (CORE_PACKAGES.includes(packageJson.name)) { + return packageJson.name; + } + + for (const field of DEPENDENCY_FIELDS) { + const dependencies = packageJson[field] || {}; + for (const packageName of CORE_PACKAGES) { + if (typeof dependencies[packageName] === 'string') { + return packageName; + } + } + } + + return null; +} + +function assertValidProjectRoot(projectRoot) { + if (!projectRoot || typeof projectRoot !== 'string') { + throw new TypeError('updatePro(projectRoot): projectRoot must be a non-empty string.'); + } + + const resolvedProjectRoot = path.resolve(projectRoot); + + let stats; + try { + stats = fs.statSync(resolvedProjectRoot); + } catch { + throw new Error(`updatePro(projectRoot): projectRoot does not exist or is not a directory: ${resolvedProjectRoot}`); + } + + if (!stats.isDirectory()) { + throw new Error(`updatePro(projectRoot): projectRoot does not exist or is not a directory: ${resolvedProjectRoot}`); + } + + return resolvedProjectRoot; +} + +/** + * Get the installed aiox-core version. + * @param {string} projectRoot + * @returns {string|null} + */ +function getCoreVersion(projectRoot) { + const versionJsonPath = path.join(projectRoot, '.aiox-core', 'version.json'); + if (fs.existsSync(versionJsonPath)) { + try { + const versionInfo = JSON.parse(fs.readFileSync(versionJsonPath, 'utf8')); + if (versionInfo.version) { + return versionInfo.version; + } + } catch { /* skip */ } + } + + for (const packageName of CORE_PACKAGES) { + const packageJsonPath = buildNodeModulesPackageJsonPath(projectRoot, packageName); + if (fs.existsSync(packageJsonPath)) { + try { + const data = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + return data.version || null; + } catch { /* skip */ } + } + } + + const projectPackageJson = readProjectPackageJson(projectRoot); + if (projectPackageJson) { + if (CORE_PACKAGES.includes(projectPackageJson.name)) { + return projectPackageJson.version || null; + } + + const declaredCorePackage = detectCorePackageName(projectRoot); + if (declaredCorePackage) { + for (const field of DEPENDENCY_FIELDS) { + const declaredVersion = projectPackageJson[field]?.[declaredCorePackage]; + if (typeof declaredVersion === 'string') { + const parsed = semver.coerce(declaredVersion); + if (parsed) { + return parsed.version; + } + } + } + } + } + + return null; +} + +/** + * Simple semver satisfies check: does installed >= required minimum? + * @param {string} installed - e.g. '5.0.4' + * @param {string} range - e.g. '>=5.0.0' + * @returns {boolean} + */ +function satisfiesPeer(installed, range) { + if (!installed || !range) return true; + + const installedVersion = semver.coerce(installed); + if (!installedVersion) { + return false; + } + + try { + return semver.satisfies(installedVersion, range, { includePrerelease: true }); + } catch { + return true; + } +} + +function loadInstallerScaffolder() { + return CORE_PACKAGE_REQUIRE(INSTALLER_SCAFFOLDER_EXPORT); +} + +async function applyScaffoldStep(projectRoot, proPath, result, onProgress, errorMessage) { + try { + const scaffoldResult = await runScaffold(projectRoot, proPath, onProgress); + result.scaffoldResult = scaffoldResult; + result.actions.push({ action: 'scaffold', status: scaffoldResult.success ? 'done' : 'failed' }); + + if (!scaffoldResult.success) { + result.success = false; + result.error = errorMessage; + return false; + } + + return true; + } catch (error) { + result.scaffoldResult = { + success: false, + errors: [error.message], + copiedFiles: [], + skippedFiles: [], + warnings: [], + }; + result.actions.push({ action: 'scaffold', status: 'failed', error: error.message }); + result.success = false; + result.error = errorMessage; + return false; + } +} + +/** + * Build the install command for the detected package manager. + * @param {'npm'|'pnpm'|'yarn'|'bun'} pm + * @param {string} packageName + * @returns {string} + */ +function buildInstallCmd(pm, packageName) { + const spec = `${packageName}@latest`; + switch (pm) { + case 'pnpm': return `pnpm add ${spec}`; + case 'yarn': return `yarn add ${spec}`; + case 'bun': return `bun add ${spec}`; + default: return `npm install ${spec}`; + } +} + +/** + * Run the Pro update flow. + * + * @param {string} projectRoot + * @param {Object} [options] + * @param {boolean} [options.check=false] - Only check, don't update + * @param {boolean} [options.dryRun=false] - Show plan without executing + * @param {boolean} [options.force=false] - Force reinstall even if up-to-date + * @param {boolean} [options.includeCoreUpdate=false] - Also update aiox-core + * @param {boolean} [options.skipScaffold=false] - Skip re-scaffold after update + * @param {Function} [options.onProgress] - Progress callback + * @returns {Promise} Update result + */ +async function updatePro(projectRoot, options = {}) { + const resolvedProjectRoot = assertValidProjectRoot(projectRoot); + const { + check = false, + dryRun = false, + force = false, + includeCoreUpdate = false, + skipScaffold = false, + onProgress = () => {}, + } = options; + + const result = { + success: false, + previousVersion: null, + newVersion: null, + packageName: null, + packageManager: null, + coreUpdated: false, + scaffoldResult: null, + actions: [], + error: null, + }; + + // 1. Detect installed Pro + onProgress('detect', 'Detecting installed Pro...'); + const installed = resolveInstalledPro(resolvedProjectRoot); + + if (!installed) { + result.error = 'AIOX Pro is not installed. Run: aiox pro setup'; + result.actions.push({ action: 'detect', status: 'not_found' }); + return result; + } + + result.previousVersion = installed.version; + result.packageName = installed.packageName; + + // 2. Detect package manager + const pm = detectPackageManager(resolvedProjectRoot); + result.packageManager = pm; + + // 3. Query npm for latest version + onProgress('check', `Checking latest version of ${installed.packageName}...`); + const latest = await fetchLatestFromNpm(installed.packageName); + + if (!latest || !latest.version) { + result.error = `Could not reach npm registry for ${installed.packageName}. Check your internet connection.`; + result.actions.push({ action: 'check', status: 'offline' }); + return result; + } + + result.newVersion = latest.version; + + // 4. Check if update is needed + const isUpToDate = installed.version === latest.version; + + if (isUpToDate && !force) { + result.success = true; + result.actions.push({ action: 'check', status: 'up_to_date', version: installed.version }); + + if (check) { + return result; + } + + // Even if up to date, re-scaffold if not skipped (new assets might exist) + if (!skipScaffold && !dryRun) { + const scaffolded = await applyScaffoldStep( + resolvedProjectRoot, + installed.packagePath, + result, + onProgress, + 'AIOX Pro is up to date, but re-scaffolding failed.', + ); + if (!scaffolded) { + return result; + } + } + + return result; + } + + result.actions.push({ + action: 'check', + status: 'update_available', + from: installed.version, + to: latest.version, + }); + + // 5. Check compatibility with aiox-core + const coreVersion = getCoreVersion(resolvedProjectRoot); + const requiredCore = CORE_PACKAGES + .map((packageName) => latest.peerDependencies?.[packageName]) + .find(Boolean); + + if (requiredCore && coreVersion && !satisfiesPeer(coreVersion, requiredCore)) { + if (!includeCoreUpdate) { + result.error = `Pro ${latest.version} requires aiox-core ${requiredCore}, but ${coreVersion} is installed. Run: aiox pro update --include-core`; + result.actions.push({ action: 'compat', status: 'incompatible', required: requiredCore, installed: coreVersion }); + return result; + } + } + + if (check) { + result.success = true; + return result; + } + + if (dryRun) { + result.success = true; + result.actions.push({ action: 'update', status: 'dry_run', command: buildInstallCmd(pm, installed.packageName) }); + if (includeCoreUpdate) { + const corePackageName = detectCorePackageName(resolvedProjectRoot) || 'aiox-core'; + result.actions.push({ action: 'core_update', status: 'dry_run', command: buildInstallCmd(pm, corePackageName) }); + } + if (!skipScaffold) { + result.actions.push({ action: 'scaffold', status: 'dry_run' }); + } + return result; + } + + // 6. Update core first if requested + if (includeCoreUpdate) { + onProgress('core', 'Updating aiox-core...'); + try { + const corePackageName = detectCorePackageName(resolvedProjectRoot) || 'aiox-core'; + const coreCmd = buildInstallCmd(pm, corePackageName); + execSync(coreCmd, { cwd: resolvedProjectRoot, stdio: 'pipe', timeout: 120000 }); + result.coreUpdated = true; + result.actions.push({ action: 'core_update', status: 'done' }); + } catch (err) { + result.error = `Failed to update aiox-core: ${err.message}`; + result.actions.push({ action: 'core_update', status: 'failed', error: err.message }); + return result; + } + } + + // 7. Update Pro package + onProgress('update', `Updating ${installed.packageName} to ${latest.version}...`); + try { + const cmd = buildInstallCmd(pm, installed.packageName); + execSync(cmd, { cwd: resolvedProjectRoot, stdio: 'pipe', timeout: 120000 }); + result.actions.push({ action: 'update', status: 'done', from: installed.version, to: latest.version }); + } catch (err) { + result.error = `Failed to update ${installed.packageName}: ${err.message}`; + result.actions.push({ action: 'update', status: 'failed', error: err.message }); + return result; + } + + // Re-read version after update + const updatedPro = resolveInstalledPro(resolvedProjectRoot); + if (updatedPro) { + result.newVersion = updatedPro.version; + } + + // 8. Re-scaffold assets + if (!skipScaffold) { + const proPath = updatedPro ? updatedPro.packagePath : installed.packagePath; + const scaffolded = await applyScaffoldStep( + resolvedProjectRoot, + proPath, + result, + onProgress, + 'AIOX Pro package updated, but re-scaffolding failed.', + ); + if (!scaffolded) { + return result; + } + } + + result.success = true; + return result; +} + +/** + * Run the Pro scaffolder after update. + * @param {string} projectRoot + * @param {string} proSourceDir + * @param {Function} onProgress + * @returns {Promise} + */ +async function runScaffold(projectRoot, proSourceDir, onProgress) { + onProgress('scaffold', 'Scaffolding Pro content...'); + + try { + const { scaffoldProContent } = loadInstallerScaffolder(); + + return await scaffoldProContent(projectRoot, proSourceDir, { + onProgress: (progress) => { + onProgress('scaffold', progress.message); + }, + }); + } catch (err) { + return { success: false, errors: [err.message], copiedFiles: [], skippedFiles: [], warnings: [] }; + } +} + +/** + * Format update result for CLI output. + * @param {Object} result - from updatePro() + * @returns {string} + */ +function formatUpdateResult(result) { + const lines = []; + + if (result.error) { + lines.push(`\n ❌ ${result.error}\n`); + return lines.join('\n'); + } + + const checkAction = result.actions.find(a => a.action === 'check'); + + if (checkAction?.status === 'up_to_date') { + lines.push(`\n ✅ AIOX Pro is up to date (v${result.previousVersion})`); + + if (result.scaffoldResult) { + const sr = result.scaffoldResult; + if (sr.copiedFiles?.length > 0) { + lines.push(` 📦 ${sr.copiedFiles.length} files synced`); + } + if (sr.skippedFiles?.length > 0) { + lines.push(` ⏭️ ${sr.skippedFiles.length} files unchanged`); + } + } + + lines.push(''); + return lines.join('\n'); + } + + lines.push('\n 🔄 AIOX Pro Update Summary'); + lines.push(' ─────────────────────────'); + lines.push(` Package: ${result.packageName}`); + lines.push(` Previous: v${result.previousVersion}`); + lines.push(` Updated to: v${result.newVersion}`); + lines.push(` PM: ${result.packageManager}`); + + if (result.coreUpdated) { + lines.push(' Core: Updated'); + } + + if (result.scaffoldResult) { + const sr = result.scaffoldResult; + if (sr.copiedFiles?.length > 0) { + lines.push(` Files synced: ${sr.copiedFiles.length}`); + } + if (sr.skippedFiles?.length > 0) { + lines.push(` Unchanged: ${sr.skippedFiles.length}`); + } + if (sr.warnings?.length > 0) { + for (const w of sr.warnings) { + lines.push(` ⚠️ ${w}`); + } + } + } + + // Dry-run summary + const dryActions = result.actions.filter(a => a.status === 'dry_run'); + if (dryActions.length > 0) { + lines.push('\n 📋 Dry-run plan:'); + for (const a of dryActions) { + if (a.command) { + lines.push(` ${a.action}: ${a.command}`); + } else { + lines.push(` ${a.action}: would execute`); + } + } + } + + lines.push(''); + return lines.join('\n'); +} + +module.exports = { + updatePro, + formatUpdateResult, + resolveInstalledPro, + detectPackageManager, + fetchLatestFromNpm, + getCoreVersion, + detectCorePackageName, + satisfiesPeer, + PRO_PACKAGES, +}; diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 05816fa4f..d4432dd18 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,9 +8,9 @@ # - File types for categorization # version: 5.0.3 -generated_at: "2026-03-11T15:04:09.395Z" +generated_at: "2026-04-11T20:15:06.396Z" generator: scripts/generate-install-manifest.js -file_count: 1090 +file_count: 1091 files: - path: cli/commands/config/index.js hash: sha256:25c4b9bf4e0241abf7754b55153f49f1a214f1fb5fe904a576675634cb7b3da9 @@ -101,9 +101,9 @@ files: type: cli size: 12326 - path: cli/commands/pro/index.js - hash: sha256:3e26f15119719a7be374f3db1935e6bd35fc2c14a66a2a30175979b5731bb29b + hash: sha256:a75533c1528987f7bea08dbf3614f2a061540991867496eed49a088162fc5a08 type: cli - size: 21940 + size: 26038 - path: cli/commands/qa/index.js hash: sha256:3a9e30419a66e56781f9b5dcddc8f4dd0ed24dabf8fe8c3005cd26f5cb02558f type: cli @@ -968,6 +968,10 @@ files: hash: sha256:84f09067c7154d97cb2252b9a7def00562acf569cfc3b035d6d4e39fb40d4033 type: core size: 7193 + - path: core/pro/pro-updater.js + hash: sha256:f4f2ec9bfe06921f559003e8e21a79d2ed9e9283488e3cceab7c6eb199f7f5d0 + type: core + size: 17473 - path: core/quality-gates/base-layer.js hash: sha256:9a9a3921da08176b0bd44f338a59abc1f5107f3b1ee56571e840bf4e8ed233f4 type: core diff --git a/.github/workflows/publish-pro.yml b/.github/workflows/publish-pro.yml index cc84fa3dc..8c6656973 100644 --- a/.github/workflows/publish-pro.yml +++ b/.github/workflows/publish-pro.yml @@ -117,7 +117,7 @@ jobs: ### Installation \`\`\`bash - npm install @aiox-fullstack/pro + npx aiox-pro install aiox pro activate --key PRO-XXXX-XXXX-XXXX-XXXX \`\`\` diff --git a/.github/workflows/sync-pro-submodule.yml b/.github/workflows/sync-pro-submodule.yml new file mode 100644 index 000000000..f3c2e145b --- /dev/null +++ b/.github/workflows/sync-pro-submodule.yml @@ -0,0 +1,156 @@ +name: Sync Pro Submodule Fallback + +on: + workflow_dispatch: + inputs: + target_sha: + description: 'Optional aiox-pro commit SHA to sync into the pro submodule' + required: false + type: string + schedule: + - cron: '0 9 * * *' + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + name: Reconcile pro submodule drift + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + PRO_REMOTE_FALLBACK_URL: https://github.com/SynkraAI/aiox-pro.git + SYNC_BRANCH: bot/sync-pro-submodule + steps: + - name: Checkout aiox-core + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ github.token }} + + - name: Configure git identity + run: | + git config user.name "aiox-sync-bot" + git config user.email "actions@github.com" + + - name: Resolve pro remote + id: pro-remote + env: + PRO_REMOTE_FALLBACK_URL: ${{ env.PRO_REMOTE_FALLBACK_URL }} + PRO_SUBMODULE_TOKEN: ${{ secrets.PRO_SUBMODULE_TOKEN }} + run: | + PRO_REPO_URL=$(git config -f .gitmodules --get submodule.pro.url || true) + if [ -z "$PRO_REPO_URL" ]; then + PRO_REPO_URL="$PRO_REMOTE_FALLBACK_URL" + fi + + AUTH_URL="$PRO_REPO_URL" + if [ -n "$PRO_SUBMODULE_TOKEN" ] && [[ "$PRO_REPO_URL" == https://github.com/* ]]; then + echo "::add-mask::$PRO_SUBMODULE_TOKEN" + AUTH_URL="${PRO_REPO_URL/https:\/\/github.com\//https:\/\/x-access-token:${PRO_SUBMODULE_TOKEN}@github.com\/}" + fi + + echo "repo_url=$PRO_REPO_URL" >> "$GITHUB_OUTPUT" + echo "auth_url=$AUTH_URL" >> "$GITHUB_OUTPUT" + + - name: Resolve upstream target + id: resolve + env: + INPUT_SHA: ${{ github.event.inputs.target_sha }} + PRO_REPO_URL: ${{ steps.pro-remote.outputs.auth_url }} + run: | + TARGET_SHA="$INPUT_SHA" + if [ -z "$TARGET_SHA" ]; then + TARGET_SHA=$(git ls-remote "$PRO_REPO_URL" refs/heads/main | awk '{print $1}') + fi + + CURRENT_SHA=$(git ls-tree HEAD pro | awk '{print $3}') + + if [ -z "$TARGET_SHA" ]; then + echo "::error::Unable to resolve target SHA from the pro remote." + exit 1 + fi + + if [ -z "$CURRENT_SHA" ]; then + echo "::error::Unable to resolve the current pro submodule SHA from aiox-core." + exit 1 + fi + + echo "target_sha=$TARGET_SHA" >> "$GITHUB_OUTPUT" + echo "short_sha=${TARGET_SHA:0:7}" >> "$GITHUB_OUTPUT" + echo "current_sha=$CURRENT_SHA" >> "$GITHUB_OUTPUT" + + if [ "$CURRENT_SHA" = "$TARGET_SHA" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Update pro submodule pointer + if: steps.resolve.outputs.changed == 'true' + env: + PRO_REPO_URL: ${{ steps.pro-remote.outputs.auth_url }} + TARGET_SHA: ${{ steps.resolve.outputs.target_sha }} + run: | + rm -rf pro + git clone "$PRO_REPO_URL" pro + git -C pro fetch origin main + if ! git -C pro cat-file -e "$TARGET_SHA^{commit}" 2>/dev/null; then + git -C pro fetch origin "$TARGET_SHA" + fi + git -C pro checkout "$TARGET_SHA" + git add pro + + - name: Commit sync branch + if: steps.resolve.outputs.changed == 'true' + env: + SHORT_SHA: ${{ steps.resolve.outputs.short_sha }} + SYNC_BRANCH: ${{ env.SYNC_BRANCH }} + run: | + git checkout -B "$SYNC_BRANCH" + git commit -m "chore: sync pro submodule to $SHORT_SHA" + git push --force-with-lease origin "$SYNC_BRANCH" + + - name: Create or update PR + if: steps.resolve.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + SHORT_SHA: ${{ steps.resolve.outputs.short_sha }} + TARGET_SHA: ${{ steps.resolve.outputs.target_sha }} + CURRENT_SHA: ${{ steps.resolve.outputs.current_sha }} + SYNC_BRANCH: ${{ env.SYNC_BRANCH }} + run: | + PR_URL=$(gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --head "$SYNC_BRANCH" \ + --base main \ + --state open \ + --json url \ + --jq '.[0].url') + + BODY=$(cat < **Restricted availability:** AIOX Pro is available exclusively to members of the **AIOX Cohort Advanced**. [Learn more about the program](https://aioxsquad.ai). ### Installation ```bash -npm install @aiox-fullstack/pro +npx aiox-pro install ``` ### Premium Features diff --git a/README.md b/README.md index c9ab07951..6e54eecf4 100644 --- a/README.md +++ b/README.md @@ -604,14 +604,14 @@ Squads são equipes modulares de agentes IA. Veja a [Visão Geral de Squads](doc ## AIOX Pro -O **AIOX Pro** (`@aiox-fullstack/pro`) é o módulo premium do AIOX, oferecendo funcionalidades avançadas para equipes e projetos de maior escala. +O **AIOX Pro** é o módulo premium do AIOX, oferecendo funcionalidades avançadas para equipes e projetos de maior escala. > **Disponibilidade restrita:** O AIOX Pro está disponível exclusivamente para membros do **AIOX Cohort Advanced**. [Saiba mais sobre o programa](https://aioxsquad.ai). ### Instalação ```bash -npm install @aiox-fullstack/pro +npx aiox-pro install ``` ### Features Premium diff --git a/bin/aiox.js b/bin/aiox.js index 80265a2c8..8ac133d45 100755 --- a/bin/aiox.js +++ b/bin/aiox.js @@ -342,6 +342,37 @@ async function runUpdate() { process.exit(1); } } + + // --include-pro: also update Pro after core (Story 122.5) + if (updateArgs.includes('--include-pro')) { + try { + const proUpdaterPath = path.join(__dirname, '..', '.aiox-core', 'core', 'pro', 'pro-updater'); + const { updatePro, formatUpdateResult: formatProResult } = require(proUpdaterPath); + + console.log('\n🔄 Updating AIOX Pro...\n'); + + const proResult = await updatePro(process.cwd(), { + check: isCheck, + dryRun: isDryRun, + force: isForce, + onProgress: (phase, message) => { + if (isVerbose) console.log(`[pro:${phase}] ${message}`); + }, + }); + + console.log(formatProResult(proResult)); + + if (!proResult.success) { + process.exit(1); + } + } catch (proError) { + console.error(`❌ Pro update failed: ${proError.message}`); + if (proError.stack) { + console.error(proError.stack); + } + process.exit(1); + } + } } catch (error) { console.error(`❌ Update error: ${error.message}`); if (args.includes('--verbose') || args.includes('-v')) { diff --git a/bin/utils/pro-detector.js b/bin/utils/pro-detector.js index c984f113e..488f7265a 100644 --- a/bin/utils/pro-detector.js +++ b/bin/utils/pro-detector.js @@ -31,15 +31,50 @@ const PRO_DIR = path.join(PROJECT_ROOT, 'pro'); const PRO_PACKAGE_PATH = path.join(PRO_DIR, 'package.json'); /** - * Check if the AIOX Pro submodule is available. + * Canonical npm package name (future, after org rename). + */ +const PRO_PACKAGE_CANONICAL = '@aiox-fullstack/pro'; + +/** + * Fallback npm package name (active until org rename). + */ +const PRO_PACKAGE_FALLBACK = '@aios-fullstack/pro'; + +/** + * Resolve the installed npm Pro package path. + * Tries canonical name first, then fallback. + * + * @returns {{ packagePath: string, packageName: string } | null} + */ +function resolveNpmProPackage() { + const candidates = [PRO_PACKAGE_CANONICAL, PRO_PACKAGE_FALLBACK]; + + for (const packageName of candidates) { + try { + const pkgJson = require.resolve(`${packageName}/package.json`, { + paths: [process.cwd()], + }); + return { packagePath: path.dirname(pkgJson), packageName }; + } catch { + // try next package + } + } + + return null; +} + +/** + * Check if the AIOX Pro is available via any source. * - * Detection is based on the existence of pro/package.json. - * An empty pro/ directory (uninitialized submodule) returns false. + * Detection priority: + * 1. npm package (canonical @aiox-fullstack/pro or fallback @aios-fullstack/pro) + * 2. pro/ submodule directory * - * @returns {boolean} true if pro/package.json exists + * @returns {boolean} true if Pro is available */ function isProAvailable() { try { + if (resolveNpmProPackage()) return true; return fs.existsSync(PRO_PACKAGE_PATH); } catch { return false; @@ -47,58 +82,99 @@ function isProAvailable() { } /** - * Safely load a module from the pro/ directory. + * Safely load a module from the pro package. * - * Returns null if: - * - Pro submodule is not available - * - The requested module does not exist - * - The module throws during loading + * Resolution order: + * 1. npm package (canonical or fallback) + * 2. pro/ submodule directory * * @param {string} moduleName - Relative path within pro/ (e.g., 'squads/squad-creator-pro') * @returns {*|null} The loaded module or null */ function loadProModule(moduleName) { - if (!isProAvailable()) { - return null; + // 1. Try npm package + const npmPro = resolveNpmProPackage(); + if (npmPro) { + try { + return require(path.join(npmPro.packagePath, moduleName)); + } catch { /* fall through */ } } - try { - const modulePath = path.join(PRO_DIR, moduleName); - return require(modulePath); - } catch { - return null; + // 2. Try submodule + if (fs.existsSync(PRO_PACKAGE_PATH)) { + try { + return require(path.join(PRO_DIR, moduleName)); + } catch { /* not available */ } } + + return null; } /** * Get the version of the installed AIOX Pro package. * - * @returns {string|null} The version string (e.g., '0.1.0') or null if not available + * @returns {string|null} The version string (e.g., '0.3.0') or null if not available */ function getProVersion() { - if (!isProAvailable()) { - return null; + // 1. Try npm package + const npmPro = resolveNpmProPackage(); + if (npmPro) { + try { + const packageData = JSON.parse(fs.readFileSync(path.join(npmPro.packagePath, 'package.json'), 'utf8')); + return packageData.version || null; + } catch { /* fall through */ } } - try { - const packageData = JSON.parse(fs.readFileSync(PRO_PACKAGE_PATH, 'utf8')); - return packageData.version || null; - } catch { - return null; + // 2. Try submodule + if (fs.existsSync(PRO_PACKAGE_PATH)) { + try { + const packageData = JSON.parse(fs.readFileSync(PRO_PACKAGE_PATH, 'utf8')); + return packageData.version || null; + } catch { /* not available */ } } + + return null; } /** * Get metadata about the AIOX Pro installation. * - * @returns {{ available: boolean, version: string|null, path: string }} Pro status info + * @returns {{ available: boolean, version: string|null, path: string, source: string, packageName: string|null }} Pro status info */ function getProInfo() { - const available = isProAvailable(); + const npmPro = resolveNpmProPackage(); + if (npmPro) { + try { + const packageData = JSON.parse(fs.readFileSync(path.join(npmPro.packagePath, 'package.json'), 'utf8')); + return { + available: true, + version: packageData.version || null, + path: npmPro.packagePath, + source: 'npm', + packageName: npmPro.packageName, + }; + } catch { /* fall through */ } + } + + if (fs.existsSync(PRO_PACKAGE_PATH)) { + try { + const packageData = JSON.parse(fs.readFileSync(PRO_PACKAGE_PATH, 'utf8')); + return { + available: true, + version: packageData.version || null, + path: PRO_DIR, + source: 'submodule', + packageName: null, + }; + } catch { /* fall through */ } + } + return { - available, - version: available ? getProVersion() : null, + available: false, + version: null, path: PRO_DIR, + source: 'none', + packageName: null, }; } @@ -107,6 +183,9 @@ module.exports = { loadProModule, getProVersion, getProInfo, + resolveNpmProPackage, + PRO_PACKAGE_CANONICAL, + PRO_PACKAGE_FALLBACK, // Exported for testing _PRO_DIR: PRO_DIR, _PRO_PACKAGE_PATH: PRO_PACKAGE_PATH, diff --git a/docs/guides/pro/install-gate-setup.md b/docs/guides/pro/install-gate-setup.md index 94b13b9ab..caf195a53 100644 --- a/docs/guides/pro/install-gate-setup.md +++ b/docs/guides/pro/install-gate-setup.md @@ -19,14 +19,14 @@ Comprar Licenca → Instalar → Ativar → Usar Features Pro | Pacote | Tipo | Proposito | |--------|------|-----------| | `aiox-pro` | CLI (1.8 KB) | Comandos de instalacao e gerenciamento | -| `@aiox-fullstack/pro` | Core (10 MB) | Features premium (squads, memory, metrics, integrations) | +| `@aiox-fullstack/pro` | Core (10 MB) | Nome canônico do pacote premium (com fallback legado durante a transição) | --- ## Instalacao Rapida ```bash -# Instalar AIOX Pro (instala @aiox-fullstack/pro automaticamente) +# Instalar AIOX Pro (instala o pacote Pro compatível automaticamente) npx aiox-pro install # Ativar sua licenca @@ -51,12 +51,18 @@ npx aiox-pro status npx aiox-pro install ``` -Isso executa `npm install @aiox-fullstack/pro` no seu projeto. +Isso instala o pacote Pro compatível no seu projeto, priorizando o nome canônico e caindo para o legado quando necessário. -**Alternativa** (instalacao manual): +**Alternativa** (instalação manual): ```bash -npm install @aiox-fullstack/pro +npm install @aiox-fullstack/pro@latest +``` + +Depois da instalação manual, rode o bootstrap do conteúdo Pro no projeto: + +```bash +npx aiox-pro install ``` ### Passo 2: Ativar Licenca @@ -88,7 +94,7 @@ npx aiox-pro features | Comando | Descricao | |---------|-----------| -| `npx aiox-pro install` | Instala `@aiox-fullstack/pro` no projeto | +| `npx aiox-pro install` | Instala o pacote AIOX Pro compatível no projeto | | `npx aiox-pro activate --key KEY` | Ativa uma chave de licenca | | `npx aiox-pro status` | Mostra status da licenca atual | | `npx aiox-pro features` | Lista todas as features pro e disponibilidade | diff --git a/docs/stories/epic-123/STORY-123.1-pro-sync-automation.md b/docs/stories/epic-123/STORY-123.1-pro-sync-automation.md new file mode 100644 index 000000000..674a05baf --- /dev/null +++ b/docs/stories/epic-123/STORY-123.1-pro-sync-automation.md @@ -0,0 +1,49 @@ +# Story 123.1: Automação de sync do Pro entre `aiox-pro` e `aiox-core` + +## Status + +- [x] Rascunho +- [x] Em revisão +- [ ] Concluída + +## Contexto + +O fluxo atual permite que novos squads entrem no repositório `aiox-pro` sem serem propagados com previsibilidade para o bundle `pro/` consumido pelo `aiox-core`. O resultado é drift silencioso: o conteúdo existe no GitHub, mas não necessariamente chega ao instalador guiado do Pro. + +## Objetivo + +Estabelecer um fluxo de sync por fonte única, com `aiox-pro` como origem dos squads Pro e `aiox-core/pro` como espelho controlado por PR automática. + +## Acceptance Criteria + +- [x] AC1. O `aiox-pro` valida automaticamente que todo squad top-level publicado está presente em `package.json` e em `squads/README.md`. +- [x] AC2. Mudanças compatíveis em `aiox-pro` abrem ou atualizam automaticamente uma PR no `aiox-core` avançando o submódulo `pro`. +- [x] AC3. O `aiox-core` possui um workflow manual/agendado de fallback para reconciliar drift do submódulo `pro`. +- [x] AC4. O plano operacional documenta segredos necessários, branch de sync, e lista dos arquivos alterados. + +## Tasks + +- [x] Criar validação de publish surface no `aiox-pro` +- [x] Atualizar README/package surface do `aiox-pro` +- [x] Adicionar workflow de sync `aiox-pro` -> `aiox-core` +- [x] Adicionar fallback workflow no `aiox-core` +- [x] Executar validações locais + +## Notas de Implementação + +- Fonte de verdade: `SynkraAI/aiox-pro` +- Espelho controlado: submódulo `pro` em `SynkraAI/aiox-core` +- Branch de sync: `bot/sync-pro-submodule` +- Segredo esperado no `aiox-pro`: `AIOX_CORE_SYNC_TOKEN` +- Segredo opcional no `aiox-core`: `PRO_SUBMODULE_TOKEN` (necessário se o remoto `pro` for privado) + +## File List + +- [docs/stories/epic-123/STORY-123.1-pro-sync-automation.md](./STORY-123.1-pro-sync-automation.md) +- [.github/workflows/sync-pro-submodule.yml](../../../.github/workflows/sync-pro-submodule.yml) +- `package.json` (`aiox-pro`) +- `squads/README.md` (`aiox-pro`) +- `scripts/validate-publish-surface.js` (`aiox-pro`) +- `.github/workflows/ci.yml` (`aiox-pro`) +- `.github/workflows/publish.yml` (`aiox-pro`) +- `.github/workflows/sync-aiox-core.yml` (`aiox-pro`) diff --git a/docs/stories/epic-123/STORY-123.2-aiox-pro-alignment-and-update-flow.md b/docs/stories/epic-123/STORY-123.2-aiox-pro-alignment-and-update-flow.md new file mode 100644 index 000000000..021a67379 --- /dev/null +++ b/docs/stories/epic-123/STORY-123.2-aiox-pro-alignment-and-update-flow.md @@ -0,0 +1,72 @@ +# Story 123.2: Alinhamento definitivo de `aiox-pro` e fluxo explícito de atualização + +## Status + +- [x] Rascunho +- [x] Em revisão +- [ ] Concluída + +## Contexto + +Após o rename do repositório Pro para `aiox-pro`, ainda existe drift entre: + +- URL do submódulo e workflows de sync +- nome do pacote npm (`@aios-fullstack/pro` publicado vs `@aiox-fullstack/pro` desejado) +- comandos de instalação e atualização do Pro +- documentação e referências internas + +Sem um caminho explícito de atualização, o reinstall do `aiox-core` pode reaplicar o bundle embutido sem buscar a versão Pro mais recente publicada. + +## Objetivo + +Padronizar a superfície do Pro em torno de `aiox-pro`, manter compatibilidade transitória com o pacote legado `@aios-fullstack/pro`, e oferecer um comando explícito para atualizar o Pro via npm com re-scaffold dos assets. + +## Acceptance Criteria + +- [x] AC1. `aiox-core` e `aiox-pro` referenciam o repositório `SynkraAI/aiox-pro` em workflows, metadados e documentação operacional. +- [x] AC2. O carregamento, instalação e verificação do Pro aceitam `@aiox-fullstack/pro` como nome canônico e `@aios-fullstack/pro` como fallback de compatibilidade. +- [x] AC3. Existe um comando explícito de atualização do Pro que instala a versão mais recente disponível no npm e re-scaffolda os assets sem exigir reinstall do `aiox-core`. +- [x] AC4. A story documenta os arquivos alterados e o estado de transição entre o pacote legado e o canônico. + +## Tasks + +- [x] Integrar a implementação existente de `aiox pro update` no `aiox-core` +- [x] Ajustar workflows e referências de repo para `aiox-pro` +- [x] Padronizar o pacote e a documentação do repositório Pro renomeado +- [x] Validar instalação, update e sync localmente + +## Notas de Implementação + +- Repositório canônico: `SynkraAI/aiox-pro` +- Pacote canônico desejado: `@aiox-fullstack/pro` +- Pacote legado ainda publicado: `@aios-fullstack/pro` +- Estratégia de migração: canônico + fallback, sem quebrar installs existentes +- Publicação npm: o código já aceita o nome canônico; a publicação de `@aiox-fullstack/pro` depende da permissão correta no org mantenedor do npm. + +## File List + +- [.aiox-core/cli/commands/pro/index.js](../../../.aiox-core/cli/commands/pro/index.js) +- [.aiox-core/core/pro/pro-updater.js](../../../.aiox-core/core/pro/pro-updater.js) +- [.aiox-core/install-manifest.yaml](../../../.aiox-core/install-manifest.yaml) +- [.github/workflows/pro-integration.yml](../../../.github/workflows/pro-integration.yml) +- [.github/workflows/publish-pro.yml](../../../.github/workflows/publish-pro.yml) +- [.github/workflows/sync-pro-submodule.yml](../../../.github/workflows/sync-pro-submodule.yml) +- [.gitmodules](../../../.gitmodules) +- [README.md](../../../README.md) +- [README.en.md](../../../README.en.md) +- [bin/aiox.js](../../../bin/aiox.js) +- [bin/utils/pro-detector.js](../../../bin/utils/pro-detector.js) +- [docs/guides/pro/install-gate-setup.md](../../../docs/guides/pro/install-gate-setup.md) +- [docs/stories/epic-123/STORY-123.1-pro-sync-automation.md](./STORY-123.1-pro-sync-automation.md) +- [docs/stories/epic-123/STORY-123.2-aiox-pro-alignment-and-update-flow.md](./STORY-123.2-aiox-pro-alignment-and-update-flow.md) +- [packages/aiox-pro-cli/bin/aiox-pro.js](../../../packages/aiox-pro-cli/bin/aiox-pro.js) +- [packages/installer/src/pro/pro-scaffolder.js](../../../packages/installer/src/pro/pro-scaffolder.js) +- [packages/installer/src/wizard/i18n.js](../../../packages/installer/src/wizard/i18n.js) +- [packages/installer/src/wizard/pro-setup.js](../../../packages/installer/src/wizard/pro-setup.js) +- [pro](../../../pro) +- [tests/pro/pro-detector.test.js](../../../tests/pro/pro-detector.test.js) +- [tests/pro/pro-updater.test.js](../../../tests/pro/pro-updater.test.js) + +## Evidências Externas + +- PR mergeada do repositório Pro renomeado: `SynkraAI/aiox-pro#7` diff --git a/package.json b/package.json index 69341eca0..8f38543bc 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,10 @@ "README.md", "LICENSE" ], + "exports": { + "./installer/pro-scaffolder": "./packages/installer/src/pro/pro-scaffolder.js", + "./package.json": "./package.json" + }, "scripts": { "format": "prettier --write \"**/*.md\"", "test": "jest", diff --git a/packages/aiox-pro-cli/bin/aiox-pro.js b/packages/aiox-pro-cli/bin/aiox-pro.js index 9d26ac083..f753f24cd 100755 --- a/packages/aiox-pro-cli/bin/aiox-pro.js +++ b/packages/aiox-pro-cli/bin/aiox-pro.js @@ -3,11 +3,12 @@ /** * aiox-pro CLI * - * Thin CLI wrapper for @aiox-fullstack/pro. + * Thin CLI wrapper for AIOX Pro packages. * Provides a clean npx interface: npx aiox-pro install * * Commands: - * install Install @aiox-fullstack/pro in the current project + * install Install AIOX Pro in the current project + * update Update AIOX Pro and re-sync assets * activate --key X Activate a license key * deactivate Deactivate the current license * status Show license status @@ -22,7 +23,9 @@ const path = require('path'); const fs = require('fs'); const { recoverLicense } = require('../src/recover'); -const PRO_PACKAGE = '@aiox-fullstack/pro'; +const PRO_PACKAGE_CANONICAL = '@aiox-fullstack/pro'; +const PRO_PACKAGE_FALLBACK = '@aios-fullstack/pro'; +const PRO_PACKAGES = [PRO_PACKAGE_CANONICAL, PRO_PACKAGE_FALLBACK]; const VERSION = require('../package.json').version; const args = process.argv.slice(2); @@ -42,8 +45,11 @@ function run(cmd, options = {}) { function isProInstalled() { try { - const pkgPath = path.join(process.cwd(), 'node_modules', '@aiox-fullstack', 'pro', 'package.json'); - return fs.existsSync(pkgPath); + return PRO_PACKAGES.some((packageName) => { + const scopeDir = packageName.split('/')[0]; + const packageJson = path.join(process.cwd(), 'node_modules', scopeDir, 'pro', 'package.json'); + return fs.existsSync(packageJson); + }); } catch { return false; } @@ -133,7 +139,8 @@ Usage: npx aiox-pro [options] Commands: - install Install ${PRO_PACKAGE} in the current project + install Install AIOX Pro in the current project + update Update AIOX Pro and re-sync assets install --wizard Install and run the setup wizard setup, wizard Run Pro setup wizard (license gate + scaffold + verify) activate --key KEY Activate a license key @@ -147,6 +154,7 @@ Commands: Examples: npx aiox-pro install + npx aiox-pro update npx aiox-pro setup npx aiox-pro wizard --key PRO-XXXX-XXXX-XXXX-XXXX npx aiox-pro activate --key PRO-XXXX-XXXX-XXXX-XXXX @@ -158,16 +166,27 @@ Documentation: https://synkra.ai/pro/docs } function installPro() { - console.log(`\nInstalling ${PRO_PACKAGE}...\n`); + console.log('\nInstalling AIOX Pro...\n'); + + let installedPackage = null; - const exitCode = run(`npm install ${PRO_PACKAGE}`); + for (const packageName of PRO_PACKAGES) { + console.log(`Trying ${packageName}...`); + const exitCode = run(`npm install ${packageName}`); + if (exitCode === 0) { + installedPackage = packageName; + break; + } + console.log(''); + } - if (exitCode !== 0) { - console.error(`\nFailed to install ${PRO_PACKAGE}`); + if (!installedPackage) { + console.error('\nFailed to install AIOX Pro.'); + console.error(`Tried: ${PRO_PACKAGES.join(', ')}`); process.exit(1); } - console.log(`\n✅ ${PRO_PACKAGE} installed successfully!\n`); + console.log(`\n✅ ${installedPackage} installed successfully!\n`); console.log('Next steps:'); console.log(' npx aiox-pro activate --key PRO-XXXX-XXXX-XXXX-XXXX'); console.log(' npx aiox-pro status'); @@ -218,8 +237,9 @@ switch (command) { case 'status': case 'features': case 'validate': + case 'update': if (!isProInstalled()) { - console.error(`${PRO_PACKAGE} is not installed.`); + console.error('AIOX Pro is not installed.'); console.error('Run first: npx aiox-pro install\n'); process.exit(1); } diff --git a/packages/installer/src/pro/pro-scaffolder.js b/packages/installer/src/pro/pro-scaffolder.js index 390881c51..5af37e27f 100644 --- a/packages/installer/src/pro/pro-scaffolder.js +++ b/packages/installer/src/pro/pro-scaffolder.js @@ -2,7 +2,7 @@ * Pro Content Scaffolder * * Copies premium content (squads, configs, feature registry) from - * node_modules/@aiox-fullstack/pro/ into the user's project after + * node_modules/@aiox-fullstack/pro/ (or @aios-fullstack/pro/) into the user's project after * license activation. * * @module packages/installer/src/pro/pro-scaffolder @@ -55,7 +55,7 @@ const SCAFFOLD_ITEMS = [ * Scaffold pro content into user project. * * @param {string} targetDir - Project root directory - * @param {string} proSourceDir - Path to pro package content (node_modules/@aiox-fullstack/pro) + * @param {string} proSourceDir - Path to pro package content (node_modules/@aiox-fullstack/pro or @aios-fullstack/pro) * @param {Object} [options={}] - Scaffold options * @param {Function} [options.onProgress] - Progress callback ({item, status, message}) * @param {boolean} [options.force=false] - Force overwrite even if content exists @@ -80,7 +80,7 @@ async function scaffoldProContent(targetDir, proSourceDir, options = {}) { // Validate pro source exists if (!await fs.pathExists(proSourceDir)) { result.errors.push( - `Pro package not found at ${proSourceDir}. Run "npm install @aiox-fullstack/pro" first.` + `Pro package not found at ${proSourceDir}. Run "npx aiox-pro install" or "aiox pro setup" first.` ); return result; } diff --git a/packages/installer/src/wizard/i18n.js b/packages/installer/src/wizard/i18n.js index 6195812a8..d1d6de23e 100644 --- a/packages/installer/src/wizard/i18n.js +++ b/packages/installer/src/wizard/i18n.js @@ -121,8 +121,8 @@ const TRANSLATIONS = { proKeyRequired: 'License key is required', proKeyInvalid: 'Invalid format. Expected: PRO-XXXX-XXXX-XXXX-XXXX', proKeyValidated: 'License validated: {key}', - proModuleNotAvailable: 'Pro license module not available. Ensure @aiox-fullstack/pro is installed.', - proModuleBootstrap: 'Pro license module not found locally. Installing @aiox-fullstack/pro to bootstrap...', + proModuleNotAvailable: 'Pro license module not available. Install AIOX Pro with `npx aiox-pro install`.', + proModuleBootstrap: 'Pro license module not found locally. Installing AIOX Pro to bootstrap...', proServerUnreachable: 'License server is unreachable. Check your internet connection and try again.', proVerifyingAccessShort: 'Verifying access...', proAccessConfirmed: 'Pro access confirmed.', @@ -146,10 +146,10 @@ const TRANSLATIONS = { proInitPackageJson: 'Initializing package.json...', proPackageJsonCreated: 'package.json created', proPackageJsonFailed: 'Failed to create package.json', - proInstallingPackage: 'Installing @aiox-fullstack/pro...', + proInstallingPackage: 'Installing AIOX Pro package...', proPackageInstalled: 'Pro package installed', proPackageInstallFailed: 'Failed to install Pro package', - proScaffolderNotAvailable: 'Pro scaffolder not available. Ensure @aiox-fullstack/pro is installed.', + proScaffolderNotAvailable: 'Pro scaffolder not available. Install AIOX Pro with `npx aiox-pro install`.', proFilesInstalled: 'Files installed: {count}', proSquads: 'Squads: {names}', proConfigs: 'Configs: {count} files', @@ -161,7 +161,7 @@ const TRANSLATIONS = { proPackageNotFound: 'Pro package not found after npm install. Check npm output.', proScaffolderNotFound: 'Pro scaffolder module not found.', proNpmInitFailed: 'npm init failed: {message}', - proNpmInstallFailed: 'npm install @aiox-fullstack/pro failed: {message}. Try manually: npm install @aiox-fullstack/pro', + proNpmInstallFailed: 'AIOX Pro package install failed: {message}. Try manually: npx aiox-pro install', }, pt: { @@ -279,8 +279,8 @@ const TRANSLATIONS = { proKeyRequired: 'Chave de licença é obrigatória', proKeyInvalid: 'Formato inválido. Esperado: PRO-XXXX-XXXX-XXXX-XXXX', proKeyValidated: 'Licença validada: {key}', - proModuleNotAvailable: 'Módulo de licença Pro não disponível. Certifique-se de que @aiox-fullstack/pro está instalado.', - proModuleBootstrap: 'Módulo de licença Pro não encontrado localmente. Instalando @aiox-fullstack/pro...', + proModuleNotAvailable: 'Módulo de licença Pro não disponível. Instale o AIOX Pro com `npx aiox-pro install`.', + proModuleBootstrap: 'Módulo de licença Pro não encontrado localmente. Instalando o AIOX Pro...', proServerUnreachable: 'Servidor de licenças inacessível. Verifique sua conexão com a internet e tente novamente.', proVerifyingAccessShort: 'Verificando acesso...', proAccessConfirmed: 'Acesso Pro confirmado.', @@ -304,10 +304,10 @@ const TRANSLATIONS = { proInitPackageJson: 'Inicializando package.json...', proPackageJsonCreated: 'package.json criado', proPackageJsonFailed: 'Falha ao criar package.json', - proInstallingPackage: 'Instalando @aiox-fullstack/pro...', + proInstallingPackage: 'Instalando pacote AIOX Pro...', proPackageInstalled: 'Pacote Pro instalado', proPackageInstallFailed: 'Falha ao instalar pacote Pro', - proScaffolderNotAvailable: 'Scaffolder Pro não disponível. Certifique-se de que @aiox-fullstack/pro está instalado.', + proScaffolderNotAvailable: 'Scaffolder Pro não disponível. Instale o AIOX Pro com `npx aiox-pro install`.', proFilesInstalled: 'Arquivos instalados: {count}', proSquads: 'Squads: {names}', proConfigs: 'Configs: {count} arquivos', @@ -319,7 +319,7 @@ const TRANSLATIONS = { proPackageNotFound: 'Pacote Pro não encontrado após npm install. Verifique a saída do npm.', proScaffolderNotFound: 'Módulo scaffolder Pro não encontrado.', proNpmInitFailed: 'npm init falhou: {message}', - proNpmInstallFailed: 'npm install @aiox-fullstack/pro falhou: {message}. Tente manualmente: npm install @aiox-fullstack/pro', + proNpmInstallFailed: 'A instalação do pacote AIOX Pro falhou: {message}. Tente manualmente: npx aiox-pro install', }, es: { @@ -436,8 +436,8 @@ const TRANSLATIONS = { proKeyRequired: 'Clave de licencia es obligatoria', proKeyInvalid: 'Formato inválido. Esperado: PRO-XXXX-XXXX-XXXX-XXXX', proKeyValidated: 'Licencia validada: {key}', - proModuleNotAvailable: 'Módulo de licencia Pro no disponible. Asegúrese de que @aiox-fullstack/pro esté instalado.', - proModuleBootstrap: 'Módulo de licencia Pro no encontrado localmente. Instalando @aiox-fullstack/pro...', + proModuleNotAvailable: 'Módulo de licencia Pro no disponible. Instale AIOX Pro con `npx aiox-pro install`.', + proModuleBootstrap: 'Módulo de licencia Pro no encontrado localmente. Instalando AIOX Pro...', proServerUnreachable: 'Servidor de licencias inaccesible. Verifique su conexión a internet e intente nuevamente.', proVerifyingAccessShort: 'Verificando acceso...', proAccessConfirmed: 'Acceso Pro confirmado.', @@ -461,10 +461,10 @@ const TRANSLATIONS = { proInitPackageJson: 'Inicializando package.json...', proPackageJsonCreated: 'package.json creado', proPackageJsonFailed: 'Error al crear package.json', - proInstallingPackage: 'Instalando @aiox-fullstack/pro...', + proInstallingPackage: 'Instalando paquete AIOX Pro...', proPackageInstalled: 'Paquete Pro instalado', proPackageInstallFailed: 'Error al instalar paquete Pro', - proScaffolderNotAvailable: 'Scaffolder Pro no disponible. Asegúrese de que @aiox-fullstack/pro esté instalado.', + proScaffolderNotAvailable: 'Scaffolder Pro no disponible. Instale AIOX Pro con `npx aiox-pro install`.', proFilesInstalled: 'Archivos instalados: {count}', proSquads: 'Squads: {names}', proConfigs: 'Configs: {count} archivos', @@ -476,7 +476,7 @@ const TRANSLATIONS = { proPackageNotFound: 'Paquete Pro no encontrado después de npm install. Verifique la salida de npm.', proScaffolderNotFound: 'Módulo scaffolder Pro no encontrado.', proNpmInitFailed: 'npm init falló: {message}', - proNpmInstallFailed: 'npm install @aiox-fullstack/pro falló: {message}. Intente manualmente: npm install @aiox-fullstack/pro', + proNpmInstallFailed: 'La instalación del paquete AIOX Pro falló: {message}. Intente manualmente: npx aiox-pro install', }, }; diff --git a/packages/installer/src/wizard/pro-setup.js b/packages/installer/src/wizard/pro-setup.js index 829253ffb..f122f9036 100644 --- a/packages/installer/src/wizard/pro-setup.js +++ b/packages/installer/src/wizard/pro-setup.js @@ -331,29 +331,55 @@ function showStep(current, total, label) { */ function loadProModule(moduleName) { const path = require('path'); + const tryRequire = (requestPath) => { + try { + return require(requestPath); + } catch (error) { + if ( + error?.code === 'MODULE_NOT_FOUND' + && typeof error.message === 'string' + && error.message.includes(requestPath) + ) { + return null; + } + throw error; + } + }; // 1. Framework-dev mode (cloned repo with pro/ submodule) - try { - return require(`../../../../pro/license/${moduleName}`); - } catch { /* not available */ } + const frameworkPath = `../../../../pro/license/${moduleName}`; + const frameworkModule = tryRequire(frameworkPath); + if (frameworkModule) { + return frameworkModule; + } - // 2. @aiox-fullstack/pro package (works when aiox-core is a local dependency) - try { - return require(`@aiox-fullstack/pro/license/${moduleName}`); - } catch { /* not available */ } + // 2. npm packages — try canonical then fallback + const npmScopes = ['@aiox-fullstack/pro', '@aios-fullstack/pro']; + for (const scope of npmScopes) { + const requestPath = `${scope}/license/${moduleName}`; + const loadedModule = tryRequire(requestPath); + if (loadedModule) { + return loadedModule; + } + } // 3. aiox-core in node_modules (brownfield upgrade from >= v4.2.15) - try { - const absPath = path.join(process.cwd(), 'node_modules', 'aiox-core', 'pro', 'license', moduleName); - return require(absPath); - } catch { /* not available */ } + const aioxCorePath = path.join(process.cwd(), 'node_modules', 'aiox-core', 'pro', 'license', moduleName); + const aioxCoreModule = tryRequire(aioxCorePath); + if (aioxCoreModule) { + return aioxCoreModule; + } - // 4. @aiox-fullstack/pro in user project (npx context — require resolves from + // 4. npm package in user project via absolute path (npx context — require resolves from // temp dir, so we need absolute path to where bootstrap installed the package) - try { - const absPath = path.join(process.cwd(), 'node_modules', '@aiox-fullstack', 'pro', 'license', moduleName); - return require(absPath); - } catch { /* not available */ } + const absScopeDirs = ['@aiox-fullstack', '@aios-fullstack']; + for (const scopeDir of absScopeDirs) { + const absPath = path.join(process.cwd(), 'node_modules', scopeDir, 'pro', 'license', moduleName); + const loadedModule = tryRequire(absPath); + if (loadedModule) { + return loadedModule; + } + } return null; } @@ -1193,16 +1219,24 @@ async function stepInstallScaffold(targetDir, options = {}) { // Resolve pro source directory from multiple locations: // 1. Bundled in aiox-core package (pro/ submodule — npx and local dev) - // 2. @aiox-fullstack/pro in node_modules (legacy brownfield) + // 2. npm package — canonical @aiox-fullstack/pro or fallback @aios-fullstack/pro const bundledProDir = path.resolve(__dirname, '..', '..', '..', '..', 'pro'); - const npmProDir = path.join(targetDir, 'node_modules', '@aiox-fullstack', 'pro'); + const npmProCandidates = [ + path.join(targetDir, 'node_modules', '@aiox-fullstack', 'pro'), + path.join(targetDir, 'node_modules', '@aios-fullstack', 'pro'), + ]; let proSourceDir; if (fs.existsSync(bundledProDir) && fs.existsSync(path.join(bundledProDir, 'squads'))) { proSourceDir = bundledProDir; - } else if (fs.existsSync(npmProDir)) { - proSourceDir = npmProDir; } else { + proSourceDir = npmProCandidates.find((candidate) => ( + fs.existsSync(path.join(candidate, 'package.json')) + && fs.existsSync(path.join(candidate, 'squads')) + )); + } + + if (!proSourceDir) { return { success: false, error: t('proPackageNotFound'), diff --git a/pro b/pro index c90d421f1..8f16e8e4c 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit c90d421f165dc037eeccf13d276300def12c1cf2 +Subproject commit 8f16e8e4c9624b91882f05ca66bc9ea9beedbde2 diff --git a/tests/pro/pro-detector.test.js b/tests/pro/pro-detector.test.js index b114dacc6..c499319fe 100644 --- a/tests/pro/pro-detector.test.js +++ b/tests/pro/pro-detector.test.js @@ -8,7 +8,10 @@ 'use strict'; const fs = require('fs'); +const os = require('os'); const path = require('path'); +const realFs = jest.requireActual('fs'); +const originalCwd = process.cwd(); // Module under test const { @@ -16,6 +19,9 @@ const { loadProModule, getProVersion, getProInfo, + resolveNpmProPackage, + PRO_PACKAGE_CANONICAL, + PRO_PACKAGE_FALLBACK, _PRO_DIR, _PRO_PACKAGE_PATH, } = require('../../bin/utils/pro-detector'); @@ -29,6 +35,7 @@ const originalRequire = jest.requireActual; describe('pro-detector', () => { beforeEach(() => { jest.clearAllMocks(); + process.chdir(originalCwd); // Clear require cache for pro modules to prevent stale state Object.keys(require.cache).forEach((key) => { if (key.includes('pro-detector')) return; // Don't clear the module itself @@ -55,14 +62,32 @@ describe('pro-detector', () => { }); describe('isProAvailable()', () => { - it('should return true when pro/package.json exists', () => { - fs.existsSync.mockReturnValue(true); + it('should return true when pro/package.json exists (submodule)', () => { + // npm paths return false, submodule path returns true + fs.existsSync.mockImplementation((p) => p === _PRO_PACKAGE_PATH); expect(isProAvailable()).toBe(true); - expect(fs.existsSync).toHaveBeenCalledWith(_PRO_PACKAGE_PATH); }); - it('should return false when pro/package.json does not exist', () => { + it('should return true when npm package exists (canonical or fallback)', () => { + const tmpDir = realFs.mkdtempSync(path.join(os.tmpdir(), 'aiox-pro-detector-')); + const canonicalDir = path.join(tmpDir, 'node_modules', '@aiox-fullstack', 'pro'); + realFs.mkdirSync(canonicalDir, { recursive: true }); + realFs.writeFileSync( + path.join(canonicalDir, 'package.json'), + JSON.stringify({ name: PRO_PACKAGE_CANONICAL, version: '0.4.0' }), + ); + process.chdir(tmpDir); + + try { + expect(isProAvailable()).toBe(true); + } finally { + process.chdir(originalCwd); + realFs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should return false when nothing is available', () => { fs.existsSync.mockReturnValue(false); expect(isProAvailable()).toBe(false); @@ -75,13 +100,60 @@ describe('pro-detector', () => { expect(isProAvailable()).toBe(false); }); + }); - it('should check the correct path', () => { - fs.existsSync.mockReturnValue(false); - isProAvailable(); + describe('resolveNpmProPackage()', () => { + it('should export canonical and fallback package names', () => { + expect(PRO_PACKAGE_CANONICAL).toBe('@aiox-fullstack/pro'); + expect(PRO_PACKAGE_FALLBACK).toBe('@aios-fullstack/pro'); + }); - const calledPath = fs.existsSync.mock.calls[0][0]; - expect(calledPath).toMatch(/pro[/\\]package\.json$/); + it('should prefer the canonical package when both scopes resolve', () => { + const tmpDir = realFs.mkdtempSync(path.join(os.tmpdir(), 'aiox-pro-detector-')); + const canonicalDir = path.join(tmpDir, 'node_modules', '@aiox-fullstack', 'pro'); + const fallbackDir = path.join(tmpDir, 'node_modules', '@aios-fullstack', 'pro'); + realFs.mkdirSync(canonicalDir, { recursive: true }); + realFs.mkdirSync(fallbackDir, { recursive: true }); + realFs.writeFileSync( + path.join(canonicalDir, 'package.json'), + JSON.stringify({ name: PRO_PACKAGE_CANONICAL, version: '0.4.0' }), + ); + realFs.writeFileSync( + path.join(fallbackDir, 'package.json'), + JSON.stringify({ name: PRO_PACKAGE_FALLBACK, version: '0.3.0' }), + ); + process.chdir(tmpDir); + + try { + expect(resolveNpmProPackage()).toEqual({ + packagePath: realFs.realpathSync(canonicalDir), + packageName: PRO_PACKAGE_CANONICAL, + }); + } finally { + process.chdir(originalCwd); + realFs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should fall back when the canonical package cannot be resolved', () => { + const tmpDir = realFs.mkdtempSync(path.join(os.tmpdir(), 'aiox-pro-detector-')); + const fallbackDir = path.join(tmpDir, 'node_modules', '@aios-fullstack', 'pro'); + realFs.mkdirSync(fallbackDir, { recursive: true }); + realFs.writeFileSync( + path.join(fallbackDir, 'package.json'), + JSON.stringify({ name: PRO_PACKAGE_FALLBACK, version: '0.3.0' }), + ); + process.chdir(tmpDir); + + try { + expect(resolveNpmProPackage()).toEqual({ + packagePath: realFs.realpathSync(fallbackDir), + packageName: PRO_PACKAGE_FALLBACK, + }); + } finally { + process.chdir(originalCwd); + realFs.rmSync(tmpDir, { recursive: true, force: true }); + } }); }); @@ -134,32 +206,32 @@ describe('pro-detector', () => { expect(getProVersion()).toBeNull(); }); - it('should return version from pro/package.json', () => { - fs.existsSync.mockReturnValue(true); + it('should return version from submodule pro/package.json', () => { + // Only submodule path exists + fs.existsSync.mockImplementation((p) => p === _PRO_PACKAGE_PATH); fs.readFileSync.mockReturnValue( - JSON.stringify({ name: '@aiox-fullstack/pro', version: '0.1.0' }), + JSON.stringify({ name: '@aios-fullstack/pro', version: '0.3.0' }), ); - expect(getProVersion()).toBe('0.1.0'); - expect(fs.readFileSync).toHaveBeenCalledWith(_PRO_PACKAGE_PATH, 'utf8'); + expect(getProVersion()).toBe('0.3.0'); }); it('should return null when package.json has no version field', () => { - fs.existsSync.mockReturnValue(true); - fs.readFileSync.mockReturnValue(JSON.stringify({ name: '@aiox-fullstack/pro' })); + fs.existsSync.mockImplementation((p) => p === _PRO_PACKAGE_PATH); + fs.readFileSync.mockReturnValue(JSON.stringify({ name: '@aios-fullstack/pro' })); expect(getProVersion()).toBeNull(); }); it('should return null when package.json is corrupted', () => { - fs.existsSync.mockReturnValue(true); + fs.existsSync.mockImplementation((p) => p === _PRO_PACKAGE_PATH); fs.readFileSync.mockReturnValue('not valid json {{{'); expect(getProVersion()).toBeNull(); }); it('should return null when readFileSync throws', () => { - fs.existsSync.mockReturnValue(true); + fs.existsSync.mockImplementation((p) => p === _PRO_PACKAGE_PATH); fs.readFileSync.mockImplementation(() => { throw new Error('EACCES: permission denied'); }); @@ -173,35 +245,31 @@ describe('pro-detector', () => { fs.existsSync.mockReturnValue(false); const info = getProInfo(); - expect(info).toEqual({ - available: false, - version: null, - path: _PRO_DIR, - }); + expect(info.available).toBe(false); + expect(info.version).toBeNull(); + expect(info.source).toBe('none'); }); - it('should return full info when pro is available', () => { - fs.existsSync.mockReturnValue(true); + it('should return full info when pro submodule is available', () => { + fs.existsSync.mockImplementation((p) => p === _PRO_PACKAGE_PATH); fs.readFileSync.mockReturnValue( - JSON.stringify({ name: '@aiox-fullstack/pro', version: '0.1.0' }), + JSON.stringify({ name: '@aios-fullstack/pro', version: '0.3.0' }), ); const info = getProInfo(); - expect(info).toEqual({ - available: true, - version: '0.1.0', - path: _PRO_DIR, - }); + expect(info.available).toBe(true); + expect(info.version).toBe('0.3.0'); + expect(info.source).toBe('submodule'); + expect(info.path).toBe(_PRO_DIR); }); - it('should return available=true but version=null when package.json is corrupt', () => { - fs.existsSync.mockReturnValue(true); + it('should return available=false when package.json is corrupt and only submodule exists', () => { + fs.existsSync.mockImplementation((p) => p === _PRO_PACKAGE_PATH); fs.readFileSync.mockReturnValue('invalid json'); const info = getProInfo(); - expect(info.available).toBe(true); + // Corrupt JSON means we can't parse it, falls through expect(info.version).toBeNull(); - expect(info.path).toBe(_PRO_DIR); }); }); diff --git a/tests/pro/pro-updater.test.js b/tests/pro/pro-updater.test.js new file mode 100644 index 000000000..1b97b847f --- /dev/null +++ b/tests/pro/pro-updater.test.js @@ -0,0 +1,296 @@ +'use strict'; + +const { EventEmitter } = require('events'); +const path = require('path'); +const fs = require('fs'); +const https = require('https'); +const { execSync } = require('child_process'); + +jest.mock('fs'); +jest.mock('https'); +jest.mock('child_process', () => ({ + execSync: jest.fn(), +})); + +const { + updatePro, + fetchLatestFromNpm, + getCoreVersion, + detectCorePackageName, + satisfiesPeer, +} = require('../../.aiox-core/core/pro/pro-updater'); + +function mockRegistryResponse(payload, statusCode = 200) { + https.get.mockImplementation((url, options, callback) => { + const response = new EventEmitter(); + response.statusCode = statusCode; + response.resume = jest.fn(); + const request = { + on: jest.fn().mockReturnThis(), + destroy: jest.fn(), + }; + + process.nextTick(() => { + callback(response); + if (statusCode >= 200 && statusCode < 300) { + response.emit('data', JSON.stringify(payload)); + response.emit('end'); + } + }); + + return request; + }); +} + +describe('pro-updater', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getCoreVersion()', () => { + it('should prefer .aiox-core/version.json when available', () => { + const projectRoot = '/tmp/aiox-project'; + const versionJsonPath = path.join(projectRoot, '.aiox-core', 'version.json'); + + fs.existsSync.mockImplementation((targetPath) => targetPath === versionJsonPath); + fs.readFileSync.mockImplementation((targetPath) => { + if (targetPath === versionJsonPath) { + return JSON.stringify({ version: '5.1.2' }); + } + throw new Error(`Unexpected read: ${targetPath}`); + }); + + expect(getCoreVersion(projectRoot)).toBe('5.1.2'); + }); + + it('should read declared aiox-core dependency from the project manifest', () => { + const projectRoot = '/tmp/aiox-project'; + const packageJsonPath = path.join(projectRoot, 'package.json'); + + fs.existsSync.mockImplementation((targetPath) => targetPath === packageJsonPath); + fs.readFileSync.mockImplementation((targetPath) => { + if (targetPath === packageJsonPath) { + return JSON.stringify({ + name: 'my-app', + dependencies: { + 'aiox-core': '^5.4.0', + }, + }); + } + throw new Error(`Unexpected read: ${targetPath}`); + }); + + expect(getCoreVersion(projectRoot)).toBe('5.4.0'); + }); + + it('should read declared scoped aiox-core dependency from the project manifest', () => { + const projectRoot = '/tmp/aiox-project'; + const packageJsonPath = path.join(projectRoot, 'package.json'); + + fs.existsSync.mockImplementation((targetPath) => targetPath === packageJsonPath); + fs.readFileSync.mockImplementation((targetPath) => { + if (targetPath === packageJsonPath) { + return JSON.stringify({ + name: 'my-app', + devDependencies: { + '@synkra/aiox-core': '^5.5.0', + }, + }); + } + throw new Error(`Unexpected read: ${targetPath}`); + }); + + expect(getCoreVersion(projectRoot)).toBe('5.5.0'); + }); + }); + + describe('detectCorePackageName()', () => { + it('should detect the scoped core package from project dependencies', () => { + const projectRoot = '/tmp/aiox-project'; + const packageJsonPath = path.join(projectRoot, 'package.json'); + + fs.existsSync.mockImplementation((targetPath) => targetPath === packageJsonPath); + fs.readFileSync.mockImplementation((targetPath) => { + if (targetPath === packageJsonPath) { + return JSON.stringify({ + name: 'workspace-app', + dependencies: { + '@synkra/aiox-core': '^5.5.0', + }, + }); + } + throw new Error(`Unexpected read: ${targetPath}`); + }); + + expect(detectCorePackageName(projectRoot)).toBe('@synkra/aiox-core'); + }); + }); + + describe('satisfiesPeer()', () => { + it('should evaluate real semver ranges instead of a numeric minimum', () => { + expect(satisfiesPeer('5.4.0', '>=5 <7')).toBe(true); + expect(satisfiesPeer('6.1.0', '^5 || ^6')).toBe(true); + expect(satisfiesPeer('5.9.1', '5.x')).toBe(true); + expect(satisfiesPeer('7.0.0', '5.x')).toBe(false); + }); + }); + + describe('fetchLatestFromNpm()', () => { + it('should return null when the registry responds with a non-2xx status', async () => { + mockRegistryResponse({ error: 'not found' }, 404); + + await expect(fetchLatestFromNpm('@aiox-fullstack/pro')).resolves.toBeNull(); + }); + }); + + describe('updatePro()', () => { + it('should reject an invalid projectRoot before doing any update work', async () => { + fs.statSync.mockImplementation(() => { + throw new Error('ENOENT'); + }); + + await expect(updatePro('/tmp/missing-project', {})) + .rejects + .toThrow('updatePro(projectRoot): projectRoot does not exist or is not a directory'); + + expect(https.get).not.toHaveBeenCalled(); + expect(execSync).not.toHaveBeenCalled(); + }); + + it('should use the detected scoped core package when includeCoreUpdate is requested in dry-run mode', async () => { + const projectRoot = '/tmp/aiox-project'; + const installedPackageJson = path.join(projectRoot, 'node_modules', '@aiox-fullstack', 'pro', 'package.json'); + const packageJsonPath = path.join(projectRoot, 'package.json'); + + fs.statSync.mockReturnValue({ isDirectory: () => true }); + fs.existsSync.mockImplementation((targetPath) => ( + targetPath === installedPackageJson + || targetPath === packageJsonPath + )); + fs.readFileSync.mockImplementation((targetPath) => { + if (targetPath === installedPackageJson) { + return JSON.stringify({ version: '0.3.0' }); + } + if (targetPath === packageJsonPath) { + return JSON.stringify({ + name: 'workspace-app', + dependencies: { + '@synkra/aiox-core': '^5.5.0', + }, + }); + } + throw new Error(`Unexpected read: ${targetPath}`); + }); + + mockRegistryResponse({ + version: '0.4.0', + peerDependencies: { + 'aiox-core': '>=5.0.0', + }, + }); + + const result = await updatePro(projectRoot, { dryRun: true, includeCoreUpdate: true }); + + expect(result.success).toBe(true); + expect(result.actions).toEqual(expect.arrayContaining([ + expect.objectContaining({ + action: 'core_update', + status: 'dry_run', + command: 'npm install @synkra/aiox-core@latest', + }), + ])); + }); + + it('should honor scoped aiox-core peer dependencies when checking compatibility', async () => { + const projectRoot = '/tmp/aiox-project'; + const installedPackageJson = path.join(projectRoot, 'node_modules', '@aiox-fullstack', 'pro', 'package.json'); + const versionJsonPath = path.join(projectRoot, '.aiox-core', 'version.json'); + + fs.statSync.mockReturnValue({ isDirectory: () => true }); + fs.existsSync.mockImplementation((targetPath) => ( + targetPath === installedPackageJson + || targetPath === versionJsonPath + )); + fs.readFileSync.mockImplementation((targetPath) => { + if (targetPath === installedPackageJson) { + return JSON.stringify({ version: '0.3.0' }); + } + if (targetPath === versionJsonPath) { + return JSON.stringify({ version: '5.0.4' }); + } + throw new Error(`Unexpected read: ${targetPath}`); + }); + + mockRegistryResponse({ + version: '0.4.0', + peerDependencies: { + '@synkra/aiox-core': '>=6.0.0', + }, + }); + + const result = await updatePro(projectRoot, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain('requires aiox-core >=6.0.0'); + expect(result.actions).toEqual(expect.arrayContaining([ + expect.objectContaining({ + action: 'compat', + status: 'incompatible', + required: '>=6.0.0', + installed: '5.0.4', + }), + ])); + }); + + it('should fail when the package update succeeds but re-scaffolding fails', async () => { + const projectRoot = '/tmp/aiox-project'; + const installedPackageJson = path.join(projectRoot, 'node_modules', '@aiox-fullstack', 'pro', 'package.json'); + const versionJsonPath = path.join(projectRoot, '.aiox-core', 'version.json'); + const scaffolderPath = 'aiox-core/installer/pro-scaffolder'; + + fs.statSync.mockReturnValue({ isDirectory: () => true }); + fs.existsSync.mockImplementation((targetPath) => ( + targetPath === installedPackageJson + || targetPath === versionJsonPath + )); + fs.readFileSync.mockImplementation((targetPath) => { + if (targetPath === installedPackageJson) { + return JSON.stringify({ version: '0.3.0' }); + } + if (targetPath === versionJsonPath) { + return JSON.stringify({ version: '5.0.4' }); + } + throw new Error(`Unexpected read: ${targetPath}`); + }); + + mockRegistryResponse({ + version: '0.4.0', + peerDependencies: { + 'aiox-core': '>=5.0.0', + }, + }); + execSync.mockReturnValue(Buffer.from('ok')); + + jest.doMock(scaffolderPath, () => ({ + scaffoldProContent: jest.fn().mockResolvedValue({ + success: false, + errors: ['sync failed'], + copiedFiles: [], + skippedFiles: [], + warnings: [], + }), + })); + + const result = await updatePro(projectRoot, {}); + + expect(result.success).toBe(false); + expect(result.error).toContain('re-scaffolding failed'); + expect(result.actions).toEqual(expect.arrayContaining([ + expect.objectContaining({ action: 'update', status: 'done' }), + expect.objectContaining({ action: 'scaffold', status: 'failed' }), + ])); + + jest.dontMock(scaffolderPath); + }); + }); +});