Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .aiox-core/cli/commands/metrics/cleanup.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/

const { Command } = require('commander');
const { MetricsCollector } = require('../../../quality/metrics-collector');
const { loadMetricsCollector } = require('./runtime');

/**
* Create the cleanup subcommand
Expand All @@ -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();

Expand Down
3 changes: 2 additions & 1 deletion .aiox-core/cli/commands/metrics/record.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/

const { Command } = require('commander');
const { MetricsCollector } = require('../../../quality/metrics-collector');
const { loadMetricsCollector } = require('./runtime');

/**
* Create the record subcommand
Expand Down Expand Up @@ -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);
Expand Down
36 changes: 36 additions & 0 deletions .aiox-core/cli/commands/metrics/runtime.js
Original file line number Diff line number Diff line change
@@ -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,
};
5 changes: 2 additions & 3 deletions .aiox-core/cli/commands/metrics/seed.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/

const { Command } = require('commander');
const { seedMetrics } = require('../../../quality/seed-metrics');
const { loadSeedMetricsModule } = require('./runtime');

/**
* Create the seed subcommand
Expand All @@ -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),
Expand All @@ -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)');
Expand Down
3 changes: 2 additions & 1 deletion .aiox-core/cli/commands/metrics/show.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/

const { Command } = require('commander');
const { MetricsCollector } = require('../../../quality/metrics-collector');
const { loadMetricsCollector } = require('./runtime');

/**
* Format percentage for display
Expand Down Expand Up @@ -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();

Expand Down
24 changes: 14 additions & 10 deletions .aiox-core/install-manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 29 additions & 0 deletions packages/installer/src/installer/dependency-installer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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);
Expand Down Expand Up @@ -332,6 +360,7 @@ module.exports = {
validatePackageManager,
hasExistingDependencies,
installDependencies,
parsePackageManagerField,
// Export for testing
executeInstall,
categorizeError,
Expand Down
56 changes: 52 additions & 4 deletions packages/installer/src/updater/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
);
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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,
Expand All @@ -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');
Expand Down Expand Up @@ -982,4 +1029,5 @@ module.exports = {
formatCheckResult,
formatUpdateResult,
selectInstalledManifest,
getPackageManagerCommand,
};
Loading