diff --git a/.aiox-core/cli/commands/metrics/cleanup.js b/.aiox-core/cli/commands/metrics/cleanup.js index a32284bfc9..fbd4a0d8e2 100644 --- a/.aiox-core/cli/commands/metrics/cleanup.js +++ b/.aiox-core/cli/commands/metrics/cleanup.js @@ -9,7 +9,7 @@ */ const { Command } = require('commander'); -const { MetricsCollector } = require('../../../quality/metrics-collector'); +const { loadMetricsCollector } = require('./runtime'); /** * Create the cleanup subcommand @@ -26,6 +26,7 @@ function createCleanupCommand() { .action(async (options) => { try { const retentionDays = parseInt(options.retention, 10); + const { MetricsCollector } = loadMetricsCollector(); const collector = new MetricsCollector({ retentionDays }); const metrics = await collector.getMetrics(); diff --git a/.aiox-core/cli/commands/metrics/record.js b/.aiox-core/cli/commands/metrics/record.js index 9eef2a4e5b..d15789cc30 100644 --- a/.aiox-core/cli/commands/metrics/record.js +++ b/.aiox-core/cli/commands/metrics/record.js @@ -9,7 +9,7 @@ */ const { Command } = require('commander'); -const { MetricsCollector } = require('../../../quality/metrics-collector'); +const { loadMetricsCollector } = require('./runtime'); /** * Create the record subcommand @@ -39,6 +39,7 @@ function createRecordCommand() { .option('-v, --verbose', 'Show detailed output', false) .action(async (options) => { try { + const { MetricsCollector } = loadMetricsCollector(); const collector = new MetricsCollector(); const layerNum = parseInt(options.layer, 10); diff --git a/.aiox-core/cli/commands/metrics/runtime.js b/.aiox-core/cli/commands/metrics/runtime.js new file mode 100644 index 0000000000..40fe04cc43 --- /dev/null +++ b/.aiox-core/cli/commands/metrics/runtime.js @@ -0,0 +1,36 @@ +/** + * Metrics runtime loader helpers. + * + * Keeps the CLI boot path independent from the optional metrics runtime files. + */ + +function wrapMissingRuntime(error, requestPath) { + if (error && error.code === 'MODULE_NOT_FOUND' && error.message.includes(requestPath)) { + throw new Error( + 'Quality metrics runtime is unavailable in this installation. Reinstall or update the published package before using `aiox metrics`.', + ); + } + + throw error; +} + +function loadMetricsCollector() { + try { + return require('../../../quality/metrics-collector'); + } catch (error) { + return wrapMissingRuntime(error, '../../../quality/metrics-collector'); + } +} + +function loadSeedMetricsModule() { + try { + return require('../../../quality/seed-metrics'); + } catch (error) { + return wrapMissingRuntime(error, '../../../quality/seed-metrics'); + } +} + +module.exports = { + loadMetricsCollector, + loadSeedMetricsModule, +}; diff --git a/.aiox-core/cli/commands/metrics/seed.js b/.aiox-core/cli/commands/metrics/seed.js index 54a6162896..08ae44c4ea 100644 --- a/.aiox-core/cli/commands/metrics/seed.js +++ b/.aiox-core/cli/commands/metrics/seed.js @@ -9,7 +9,7 @@ */ const { Command } = require('commander'); -const { seedMetrics } = require('../../../quality/seed-metrics'); +const { loadSeedMetricsModule } = require('./runtime'); /** * Create the seed subcommand @@ -27,6 +27,7 @@ function createSeedCommand() { .option('-v, --verbose', 'Show detailed output', false) .action(async (options) => { try { + const { seedMetrics, generateSeedData } = loadSeedMetricsModule(); const seedOptions = { days: parseInt(options.days, 10), runsPerDay: parseInt(options.runs, 10), @@ -40,8 +41,6 @@ function createSeedCommand() { console.log(`Weekend Reduction: ${seedOptions.weekendReduction ? 'Yes' : 'No'}`); if (options.dryRun) { - // Generate but don't save - const { generateSeedData } = require('../../../quality/seed-metrics'); const metrics = generateSeedData(seedOptions); console.log('\nšŸ“Š Generated Data Preview (dry run)'); diff --git a/.aiox-core/cli/commands/metrics/show.js b/.aiox-core/cli/commands/metrics/show.js index 71bb616ec2..0a742299d4 100644 --- a/.aiox-core/cli/commands/metrics/show.js +++ b/.aiox-core/cli/commands/metrics/show.js @@ -9,7 +9,7 @@ */ const { Command } = require('commander'); -const { MetricsCollector } = require('../../../quality/metrics-collector'); +const { loadMetricsCollector } = require('./runtime'); /** * Format percentage for display @@ -67,6 +67,7 @@ function createShowCommand() { .option('-v, --verbose', 'Show detailed output', false) .action(async (options) => { try { + const { MetricsCollector } = loadMetricsCollector(); const collector = new MetricsCollector(); const metrics = await collector.getMetrics(); diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 508289fe6d..f5c362fac8 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,9 +8,9 @@ # - File types for categorization # version: 5.2.7 -generated_at: "2026-05-31T23:32:34.066Z" +generated_at: "2026-06-01T17:32:32.831Z" generator: scripts/generate-install-manifest.js -file_count: 1129 +file_count: 1130 files: - path: cli/commands/config/index.js hash: sha256:25c4b9bf4e0241abf7754b55153f49f1a214f1fb5fe904a576675634cb7b3da9 @@ -53,25 +53,29 @@ files: type: cli size: 5354 - path: cli/commands/metrics/cleanup.js - hash: sha256:bd1670e7d17e5fd8f8c710d6c1ceb813e59143cf833b86f5f192b550d1dd6472 + hash: sha256:e5f097a1e6988034c72da9597426eb4c959528e2cf43f4f209951ded2f70093a type: cli - size: 3064 + size: 3104 - path: cli/commands/metrics/index.js hash: sha256:3dcf7408f56478c76e7ad36778d8fa4421265724da2386ec9cd9947e2f8dadb6 type: cli size: 1868 - path: cli/commands/metrics/record.js - hash: sha256:84234cb023bc96f22c3fcc90aa3e2275df9c9798111892a85e9d2893fff36013 + hash: sha256:28bccd8909ebe9c6debce425c114cc0ecd5e3389d0f12ebee0d709c618d28957 type: cli - size: 5666 + size: 5706 + - path: cli/commands/metrics/runtime.js + hash: sha256:4ed5f4d548c76a0b2b3d15f90a88b8e5a2e654353a2f6d14e7d06f8d7da997cb + type: cli + size: 928 - path: cli/commands/metrics/seed.js - hash: sha256:e569a6a7245615d8eafd404e48eead41cf8a0a1499bdad31549fe68a5a7e7556 + hash: sha256:5d09a14257c9d7d8eab016d82058f907d4271cfe47860fb71bdff56a4aec4cda type: cli - size: 4984 + size: 4931 - path: cli/commands/metrics/show.js - hash: sha256:c2c1257ebddacdf6d15dc8b45a9cb3c3d2940a0cd3460fba94ab6fd8eeafd9dc + hash: sha256:7d0f94616be6d418d45cb7318f7d92dfcfa8c98281817b66185c64fb309cdde7 type: cli - size: 7182 + size: 7222 - path: cli/commands/migrate/analyze.js hash: sha256:fad3740d9af0a4e9aa8b41a3ef4c6ff224e60aad810dc1ecf257a604a1c17c39 type: cli diff --git a/docs/stories/epic-123/STORY-123.22-issue-782-785-update-and-cli-boot-hardening.md b/docs/stories/epic-123/STORY-123.22-issue-782-785-update-and-cli-boot-hardening.md new file mode 100644 index 0000000000..1c441069d0 --- /dev/null +++ b/docs/stories/epic-123/STORY-123.22-issue-782-785-update-and-cli-boot-hardening.md @@ -0,0 +1,55 @@ +# Story 123.22: Fix Issues #782 and #785 - CLI Boot and Updater Package Manager Hardening + +## Status + +Ready for Review + +## Story + +As an AIOX user installing or updating the framework in real projects, +I want the CLI boot path and updater flow to degrade safely across published package drift and non-npm workspaces, +so that `aiox` remains usable and `aiox update` respects the project's package manager. + +## Acceptance Criteria + +- [x] Issue `#782`: CLI bootstrap does not eagerly require `quality/*`, so missing metrics runtime files only affect the `metrics` command surface. +- [x] Issue `#785`: updater install/uninstall operations use the detected project package manager instead of hardcoded `npm`. +- [x] Package manager detection honors project metadata or lockfiles well enough for updater execution in npm/pnpm/yarn/bun projects. +- [x] Regression tests cover lazy metrics loading and updater package manager selection. +- [x] Quality gates for the touched surfaces pass locally. + +## Tasks + +- [x] Refactor metrics command modules to load `quality/*` only inside command execution paths. +- [x] Add a regression test that proves CLI/bootstrap does not load metrics runtime eagerly. +- [x] Reuse installer package manager detection in updater install/uninstall flow. +- [x] Add updater/package-manager regression coverage for exact-version install/uninstall command selection. +- [x] Run targeted tests for updater and CLI metrics changes. +- [x] Run repo quality gates required for this patch and record outcomes. + +## File List + +- `docs/stories/epic-123/STORY-123.22-issue-782-785-update-and-cli-boot-hardening.md` +- `.aiox-core/cli/commands/metrics/runtime.js` +- `.aiox-core/cli/commands/metrics/record.js` +- `.aiox-core/cli/commands/metrics/show.js` +- `.aiox-core/cli/commands/metrics/cleanup.js` +- `.aiox-core/cli/commands/metrics/seed.js` +- `packages/installer/src/installer/dependency-installer.js` +- `packages/installer/src/updater/index.js` +- `tests/cli/metrics-bootstrap.test.js` +- `tests/installer/dependency-installer.test.js` +- `tests/updater/aiox-updater.test.js` + +## Dev Notes + +### Root Causes + +- `#782`: `.aiox-core/cli/index.js` registers the metrics command during boot, and the metrics modules currently import `quality/metrics-collector` and `quality/seed-metrics` at module load time. If the publish payload is incomplete, the whole CLI dies before dispatch. +- `#785`: `packages/installer/src/updater/index.js` hardcodes `npm uninstall` and `npm install --save-exact`, even though the installer already has package-manager detection helpers. + +### Non-Goals + +- Redesign the updater to download tarballs into an isolated temp directory. +- Solve issue `#773` in the same patch. +- Add any new product features or workflow surfaces. diff --git a/packages/installer/src/installer/dependency-installer.js b/packages/installer/src/installer/dependency-installer.js index fae7c2b2c0..f1ad1a5de6 100644 --- a/packages/installer/src/installer/dependency-installer.js +++ b/packages/installer/src/installer/dependency-installer.js @@ -31,6 +31,29 @@ const LOCK_FILES = { 'package-lock.json': 'npm', }; +function parsePackageManagerField(projectPath) { + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return null; + } + + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const packageManagerField = typeof packageJson.packageManager === 'string' + ? packageJson.packageManager.trim() + : ''; + + if (!packageManagerField) { + return null; + } + + const [packageManager] = packageManagerField.split('@'); + return ALLOWED_PACKAGE_MANAGERS.includes(packageManager) ? packageManager : null; + } catch { + return null; + } +} + /** * Detect package manager from lock files * @@ -42,6 +65,11 @@ const LOCK_FILES = { * console.log(pm); // 'npm' */ function detectPackageManager(projectPath = process.cwd()) { + const packageManagerFromManifest = parsePackageManagerField(projectPath); + if (packageManagerFromManifest) { + return packageManagerFromManifest; + } + // Check for lock files in priority order for (const [lockFile, packageManager] of Object.entries(LOCK_FILES)) { const lockPath = path.join(projectPath, lockFile); @@ -332,6 +360,7 @@ module.exports = { validatePackageManager, hasExistingDependencies, installDependencies, + parsePackageManagerField, // Export for testing executeInstall, categorizeError, diff --git a/packages/installer/src/updater/index.js b/packages/installer/src/updater/index.js index 00b6fe1640..d3ab68d9c0 100644 --- a/packages/installer/src/updater/index.js +++ b/packages/installer/src/updater/index.js @@ -22,6 +22,10 @@ const https = require('https'); const { execFileSync } = require('child_process'); const installerDir = path.join(__dirname, '..', 'installer'); const { hashFile, hashesMatch } = require(path.join(installerDir, 'file-hasher')); +const { + detectPackageManager, + validatePackageManager, +} = require(path.join(installerDir, 'dependency-installer')); const { PostInstallValidator, formatReport: formatValidationReport } = require( path.join(installerDir, 'post-install-validator'), ); @@ -130,6 +134,42 @@ function selectInstalledManifest(projectManifest, packageManifest) { }; } +function getPackageManagerCommand(packageManager, operation, packageSpecifier = null) { + validatePackageManager(packageManager); + + if (operation === 'uninstall') { + if (!packageSpecifier) { + throw new Error('packageSpecifier is required for uninstall operations'); + } + + const uninstallCommands = { + npm: ['uninstall', packageSpecifier], + pnpm: ['remove', packageSpecifier], + yarn: ['remove', packageSpecifier], + bun: ['remove', packageSpecifier], + }; + + return uninstallCommands[packageManager]; + } + + if (operation === 'install') { + if (!packageSpecifier) { + throw new Error('packageSpecifier is required for install operations'); + } + + const installCommands = { + npm: ['install', packageSpecifier, '--save-exact'], + pnpm: ['add', packageSpecifier, '--save-exact'], + yarn: ['add', packageSpecifier, '--exact'], + bun: ['add', packageSpecifier, '--exact'], + }; + + return installCommands[packageManager]; + } + + throw new Error(`Unsupported package manager operation: ${operation}`); +} + /** * Update status types * @enum {string} @@ -705,6 +745,7 @@ class AIOXUpdater { const previousSourceManifest = previousPackageRoot ? loadSourceManifest(path.join(previousPackageRoot, '.aiox-core')) : null; + const packageManager = detectPackageManager(this.projectRoot); const npmOptions = { cwd: this.projectRoot, @@ -713,13 +754,19 @@ class AIOXUpdater { }; if (previousCorePackage && previousCorePackage.packageName !== CORE_PACKAGE_NAME) { - this.log(`Running: npm uninstall ${previousCorePackage.packageName}`); - execFileSync('npm', ['uninstall', previousCorePackage.packageName], npmOptions); + const uninstallArgs = getPackageManagerCommand( + packageManager, + 'uninstall', + previousCorePackage.packageName, + ); + this.log(`Running: ${packageManager} ${uninstallArgs.join(' ')}`); + execFileSync(packageManager, uninstallArgs, npmOptions); } const packageSpecifier = `${CORE_PACKAGE_NAME}@${targetVersion}`; - this.log(`Running: npm install ${packageSpecifier} --save-exact`); - execFileSync('npm', ['install', packageSpecifier, '--save-exact'], npmOptions); + const installArgs = getPackageManagerCommand(packageManager, 'install', packageSpecifier); + this.log(`Running: ${packageManager} ${installArgs.join(' ')}`); + execFileSync(packageManager, installArgs, npmOptions); const sourcePackageRoot = getPackageRoot(this.projectRoot, CORE_PACKAGE_NAME); const sourceAioxCore = path.join(sourcePackageRoot, '.aiox-core'); @@ -982,4 +1029,5 @@ module.exports = { formatCheckResult, formatUpdateResult, selectInstalledManifest, + getPackageManagerCommand, }; diff --git a/tests/cli/metrics-bootstrap.test.js b/tests/cli/metrics-bootstrap.test.js new file mode 100644 index 0000000000..8c4d8a3832 --- /dev/null +++ b/tests/cli/metrics-bootstrap.test.js @@ -0,0 +1,41 @@ +const Module = require('module'); + +describe('CLI metrics bootstrap', () => { + it('does not load metrics runtime modules during CLI creation', () => { + const originalLoad = Module._load; + const loads = { + collector: 0, + seed: 0, + }; + + Module._load = function patchedLoad(request, parent, isMain) { + if (request === '../../../quality/metrics-collector') { + loads.collector += 1; + return { MetricsCollector: class MetricsCollector {} }; + } + + if (request === '../../../quality/seed-metrics') { + loads.seed += 1; + return { + seedMetrics: jest.fn(), + generateSeedData: jest.fn(), + }; + } + + return originalLoad.call(this, request, parent, isMain); + }; + + try { + jest.isolateModules(() => { + const { createProgram } = require('../../.aiox-core/cli'); + const program = createProgram(); + expect(program.commands.some((command) => command.name() === 'metrics')).toBe(true); + }); + } finally { + Module._load = originalLoad; + } + + expect(loads.collector).toBe(0); + expect(loads.seed).toBe(0); + }); +}); diff --git a/tests/installer/dependency-installer.test.js b/tests/installer/dependency-installer.test.js index 15b32a66a1..6c27435dae 100644 --- a/tests/installer/dependency-installer.test.js +++ b/tests/installer/dependency-installer.test.js @@ -16,6 +16,7 @@ const { executeInstall, categorizeError, installWithRetry, + parsePackageManagerField, } = require('../../packages/installer/src/installer/dependency-installer'); // Mock dependencies @@ -78,6 +79,21 @@ describe('Dependency Installer', () => { expect(pm).toBe('npm'); }); + it('should prefer packageManager from package.json when present', () => { + fs.existsSync.mockImplementation((filePath) => { + return filePath.endsWith('package.json') || filePath.endsWith('package-lock.json'); + }); + fs.readFileSync.mockReturnValue( + JSON.stringify({ + name: 'sample-project', + packageManager: 'pnpm@11.1.3', + }), + ); + + const pm = detectPackageManager('/test/project'); + expect(pm).toBe('pnpm'); + }); + it('should fallback to npm when no lock file exists', () => { fs.existsSync.mockReturnValue(false); @@ -87,12 +103,35 @@ describe('Dependency Installer', () => { it('should respect priority order (bun > pnpm > yarn > npm)', () => { fs.existsSync.mockImplementation((filePath) => { - // Both pnpm and npm lock files exist return filePath.endsWith('pnpm-lock.yaml') || filePath.endsWith('package-lock.json'); }); const pm = detectPackageManager('/test/project'); - expect(pm).toBe('pnpm'); // pnpm has higher priority + expect(pm).toBe('pnpm'); + }); + }); + + describe('parsePackageManagerField', () => { + it('should parse supported packageManager strings', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue( + JSON.stringify({ + packageManager: 'yarn@4.6.0', + }), + ); + + expect(parsePackageManagerField('/test/project')).toBe('yarn'); + }); + + it('should return null for unsupported packageManager strings', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue( + JSON.stringify({ + packageManager: 'custompm@1.0.0', + }), + ); + + expect(parsePackageManagerField('/test/project')).toBeNull(); }); }); @@ -176,13 +215,11 @@ describe('Dependency Installer', () => { await executeInstall('npm', '/test/project'); - // Windows requires shell: true because npm is actually npm.cmd - // Unix can use shell: false for better security const isWindows = process.platform === 'win32'; expect(spawn).toHaveBeenCalledWith('npm', ['install'], { cwd: '/test/project', stdio: 'inherit', - shell: isWindows, // Windows needs shell, Unix doesn't + shell: isWindows, }); }); @@ -281,27 +318,22 @@ describe('Dependency Installer', () => { return { on: jest.fn((event, callback) => { if (event === 'close') { - // First attempt fails, second succeeds setTimeout(() => callback(attempts < 2 ? 1 : 0), 10); } }), }; }); - // Use fake timers for faster test jest.useFakeTimers(); const promise = installWithRetry('npm', '/test/project', 3, 1); - // Run all timers and wait for promises jest.runAllTimers(); - - // Restore real timers before awaiting jest.useRealTimers(); const result = await promise; - expect(spawn).toHaveBeenCalledTimes(2); // First fail, then success + expect(spawn).toHaveBeenCalledTimes(2); expect(result.success).toBe(true); }, 15000); @@ -366,7 +398,7 @@ describe('Dependency Installer', () => { it('should skip installation in offline mode (AC6)', async () => { fs.existsSync.mockImplementation(() => { - return true; // Both lock file and node_modules exist + return true; }); fs.readdirSync.mockReturnValue(['lodash', 'express']); diff --git a/tests/updater/aiox-updater.test.js b/tests/updater/aiox-updater.test.js index c3cbcd7e2b..a7578e3365 100644 --- a/tests/updater/aiox-updater.test.js +++ b/tests/updater/aiox-updater.test.js @@ -14,6 +14,7 @@ const { formatCheckResult, formatUpdateResult, selectInstalledManifest, + getPackageManagerCommand, } = require('../../packages/installer/src/updater'); describe('AIOXUpdater', () => { @@ -468,3 +469,47 @@ describe('formatUpdateResult', () => { expect(output).toContain('Connection timeout'); }); }); + +describe('getPackageManagerCommand', () => { + it('uses exact-version install semantics for supported package managers', () => { + expect(getPackageManagerCommand('npm', 'install', '@aiox-squads/core@5.2.9')).toEqual([ + 'install', + '@aiox-squads/core@5.2.9', + '--save-exact', + ]); + expect(getPackageManagerCommand('pnpm', 'install', '@aiox-squads/core@5.2.9')).toEqual([ + 'add', + '@aiox-squads/core@5.2.9', + '--save-exact', + ]); + expect(getPackageManagerCommand('yarn', 'install', '@aiox-squads/core@5.2.9')).toEqual([ + 'add', + '@aiox-squads/core@5.2.9', + '--exact', + ]); + expect(getPackageManagerCommand('bun', 'install', '@aiox-squads/core@5.2.9')).toEqual([ + 'add', + '@aiox-squads/core@5.2.9', + '--exact', + ]); + }); + + it('uses the matching uninstall command for supported package managers', () => { + expect(getPackageManagerCommand('npm', 'uninstall', 'aiox-core')).toEqual([ + 'uninstall', + 'aiox-core', + ]); + expect(getPackageManagerCommand('pnpm', 'uninstall', 'aiox-core')).toEqual([ + 'remove', + 'aiox-core', + ]); + expect(getPackageManagerCommand('yarn', 'uninstall', 'aiox-core')).toEqual([ + 'remove', + 'aiox-core', + ]); + expect(getPackageManagerCommand('bun', 'uninstall', 'aiox-core')).toEqual([ + 'remove', + 'aiox-core', + ]); + }); +});