Skip to content

Commit ffe910f

Browse files
committed
fix: address final pro updater review blockers [Story 122.3]
1 parent 01e4ec0 commit ffe910f

3 files changed

Lines changed: 202 additions & 28 deletions

File tree

.aiox-core/core/pro/pro-updater.js

Lines changed: 98 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const semver = require('semver');
2121
const { execSync } = require('child_process');
2222

2323
const PRO_PACKAGES = ['@aiox-fullstack/pro', '@aios-fullstack/pro'];
24+
const CORE_PACKAGES = ['@synkra/aiox-core', 'aiox-core'];
25+
const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
2426
const CORE_PACKAGE_ROOT = path.resolve(__dirname, '..', '..', '..');
2527
const INSTALLER_PACKAGE_ROOT = path.join(CORE_PACKAGE_ROOT, 'packages', 'installer');
2628

@@ -95,6 +97,71 @@ function resolveInstalledPro(projectRoot) {
9597
return null;
9698
}
9799

100+
function readProjectPackageJson(projectRoot) {
101+
const packageJsonPath = path.join(projectRoot, 'package.json');
102+
if (!fs.existsSync(packageJsonPath)) {
103+
return null;
104+
}
105+
106+
try {
107+
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
108+
} catch {
109+
return null;
110+
}
111+
}
112+
113+
function buildNodeModulesPackageJsonPath(projectRoot, packageName) {
114+
if (packageName.startsWith('@')) {
115+
const [scope, name] = packageName.slice(1).split('/');
116+
return path.join(projectRoot, 'node_modules', scope, name, 'package.json');
117+
}
118+
119+
return path.join(projectRoot, 'node_modules', packageName, 'package.json');
120+
}
121+
122+
function detectCorePackageName(projectRoot) {
123+
const packageJson = readProjectPackageJson(projectRoot);
124+
if (!packageJson) {
125+
return null;
126+
}
127+
128+
if (CORE_PACKAGES.includes(packageJson.name)) {
129+
return packageJson.name;
130+
}
131+
132+
for (const field of DEPENDENCY_FIELDS) {
133+
const dependencies = packageJson[field] || {};
134+
for (const packageName of CORE_PACKAGES) {
135+
if (typeof dependencies[packageName] === 'string') {
136+
return packageName;
137+
}
138+
}
139+
}
140+
141+
return null;
142+
}
143+
144+
function assertValidProjectRoot(projectRoot) {
145+
if (!projectRoot || typeof projectRoot !== 'string') {
146+
throw new TypeError('updatePro(projectRoot): projectRoot must be a non-empty string.');
147+
}
148+
149+
const resolvedProjectRoot = path.resolve(projectRoot);
150+
151+
let stats;
152+
try {
153+
stats = fs.statSync(resolvedProjectRoot);
154+
} catch {
155+
throw new Error(`updatePro(projectRoot): projectRoot does not exist or is not a directory: ${resolvedProjectRoot}`);
156+
}
157+
158+
if (!stats.isDirectory()) {
159+
throw new Error(`updatePro(projectRoot): projectRoot does not exist or is not a directory: ${resolvedProjectRoot}`);
160+
}
161+
162+
return resolvedProjectRoot;
163+
}
164+
98165
/**
99166
* Get the installed aiox-core version.
100167
* @param {string} projectRoot
@@ -111,32 +178,34 @@ function getCoreVersion(projectRoot) {
111178
} catch { /* skip */ }
112179
}
113180

114-
const packageJsonPath = path.join(projectRoot, 'node_modules', 'aiox-core', 'package.json');
115-
if (fs.existsSync(packageJsonPath)) {
116-
try {
117-
const data = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
118-
return data.version || null;
119-
} catch { /* skip */ }
181+
for (const packageName of CORE_PACKAGES) {
182+
const packageJsonPath = buildNodeModulesPackageJsonPath(projectRoot, packageName);
183+
if (fs.existsSync(packageJsonPath)) {
184+
try {
185+
const data = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
186+
return data.version || null;
187+
} catch { /* skip */ }
188+
}
120189
}
121190

122-
const localPackageJsonPath = path.join(projectRoot, 'package.json');
123-
if (fs.existsSync(localPackageJsonPath)) {
124-
try {
125-
const data = JSON.parse(fs.readFileSync(localPackageJsonPath, 'utf8'));
126-
if (data.name === '@synkra/aiox-core' || data.name === 'aiox-core') {
127-
return data.version || null;
128-
}
191+
const projectPackageJson = readProjectPackageJson(projectRoot);
192+
if (projectPackageJson) {
193+
if (CORE_PACKAGES.includes(projectPackageJson.name)) {
194+
return projectPackageJson.version || null;
195+
}
129196

130-
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
131-
const declaredVersion = data[field]?.['aiox-core'];
197+
const declaredCorePackage = detectCorePackageName(projectRoot);
198+
if (declaredCorePackage) {
199+
for (const field of DEPENDENCY_FIELDS) {
200+
const declaredVersion = projectPackageJson[field]?.[declaredCorePackage];
132201
if (typeof declaredVersion === 'string') {
133202
const parsed = semver.coerce(declaredVersion);
134203
if (parsed) {
135204
return parsed.version;
136205
}
137206
}
138207
}
139-
} catch { /* skip */ }
208+
}
140209
}
141210

142211
return null;
@@ -231,6 +300,7 @@ function buildInstallCmd(pm, packageName) {
231300
* @returns {Promise<Object>} Update result
232301
*/
233302
async function updatePro(projectRoot, options = {}) {
303+
const resolvedProjectRoot = assertValidProjectRoot(projectRoot);
234304
const {
235305
check = false,
236306
dryRun = false,
@@ -254,7 +324,7 @@ async function updatePro(projectRoot, options = {}) {
254324

255325
// 1. Detect installed Pro
256326
onProgress('detect', 'Detecting installed Pro...');
257-
const installed = resolveInstalledPro(projectRoot);
327+
const installed = resolveInstalledPro(resolvedProjectRoot);
258328

259329
if (!installed) {
260330
result.error = 'AIOX Pro is not installed. Run: aiox pro setup';
@@ -266,7 +336,7 @@ async function updatePro(projectRoot, options = {}) {
266336
result.packageName = installed.packageName;
267337

268338
// 2. Detect package manager
269-
const pm = detectPackageManager(projectRoot);
339+
const pm = detectPackageManager(resolvedProjectRoot);
270340
result.packageManager = pm;
271341

272342
// 3. Query npm for latest version
@@ -317,7 +387,7 @@ async function updatePro(projectRoot, options = {}) {
317387
});
318388

319389
// 5. Check compatibility with aiox-core
320-
const coreVersion = getCoreVersion(projectRoot);
390+
const coreVersion = getCoreVersion(resolvedProjectRoot);
321391
const requiredCore = latest.peerDependencies?.['aiox-core'];
322392

323393
if (requiredCore && coreVersion && !satisfiesPeer(coreVersion, requiredCore)) {
@@ -337,7 +407,8 @@ async function updatePro(projectRoot, options = {}) {
337407
result.success = true;
338408
result.actions.push({ action: 'update', status: 'dry_run', command: buildInstallCmd(pm, installed.packageName) });
339409
if (includeCoreUpdate) {
340-
result.actions.push({ action: 'core_update', status: 'dry_run', command: buildInstallCmd(pm, 'aiox-core') });
410+
const corePackageName = detectCorePackageName(resolvedProjectRoot) || 'aiox-core';
411+
result.actions.push({ action: 'core_update', status: 'dry_run', command: buildInstallCmd(pm, corePackageName) });
341412
}
342413
if (!skipScaffold) {
343414
result.actions.push({ action: 'scaffold', status: 'dry_run' });
@@ -349,8 +420,9 @@ async function updatePro(projectRoot, options = {}) {
349420
if (includeCoreUpdate) {
350421
onProgress('core', 'Updating aiox-core...');
351422
try {
352-
const coreCmd = buildInstallCmd(pm, 'aiox-core');
353-
execSync(coreCmd, { cwd: projectRoot, stdio: 'pipe', timeout: 120000 });
423+
const corePackageName = detectCorePackageName(resolvedProjectRoot) || 'aiox-core';
424+
const coreCmd = buildInstallCmd(pm, corePackageName);
425+
execSync(coreCmd, { cwd: resolvedProjectRoot, stdio: 'pipe', timeout: 120000 });
354426
result.coreUpdated = true;
355427
result.actions.push({ action: 'core_update', status: 'done' });
356428
} catch (err) {
@@ -364,7 +436,7 @@ async function updatePro(projectRoot, options = {}) {
364436
onProgress('update', `Updating ${installed.packageName} to ${latest.version}...`);
365437
try {
366438
const cmd = buildInstallCmd(pm, installed.packageName);
367-
execSync(cmd, { cwd: projectRoot, stdio: 'pipe', timeout: 120000 });
439+
execSync(cmd, { cwd: resolvedProjectRoot, stdio: 'pipe', timeout: 120000 });
368440
result.actions.push({ action: 'update', status: 'done', from: installed.version, to: latest.version });
369441
} catch (err) {
370442
result.error = `Failed to update ${installed.packageName}: ${err.message}`;
@@ -373,7 +445,7 @@ async function updatePro(projectRoot, options = {}) {
373445
}
374446

375447
// Re-read version after update
376-
const updatedPro = resolveInstalledPro(projectRoot);
448+
const updatedPro = resolveInstalledPro(resolvedProjectRoot);
377449
if (updatedPro) {
378450
result.newVersion = updatedPro.version;
379451
}
@@ -382,7 +454,7 @@ async function updatePro(projectRoot, options = {}) {
382454
if (!skipScaffold) {
383455
const proPath = updatedPro ? updatedPro.packagePath : installed.packagePath;
384456
const scaffolded = await applyScaffoldStep(
385-
projectRoot,
457+
resolvedProjectRoot,
386458
proPath,
387459
result,
388460
onProgress,
@@ -502,6 +574,7 @@ module.exports = {
502574
detectPackageManager,
503575
fetchLatestFromNpm,
504576
getCoreVersion,
577+
detectCorePackageName,
505578
satisfiesPeer,
506579
PRO_PACKAGES,
507580
};

.aiox-core/install-manifest.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
# - File types for categorization
99
#
1010
version: 5.0.3
11-
generated_at: "2026-04-11T00:12:57.885Z"
11+
generated_at: "2026-04-11T00:43:45.131Z"
1212
generator: scripts/generate-install-manifest.js
1313
file_count: 1091
1414
files:
@@ -969,9 +969,9 @@ files:
969969
type: core
970970
size: 7193
971971
- path: core/pro/pro-updater.js
972-
hash: sha256:a2ebd6e30062c8f3e8d78b67b84f0a0a03f7f90cce88f6293c72c25eea5e7d5e
972+
hash: sha256:feddcf498a0299a14af91f98d4db8946bed5650cdc4356963a29a222b56de22e
973973
type: core
974-
size: 15325
974+
size: 17565
975975
- path: core/quality-gates/base-layer.js
976976
hash: sha256:9a9a3921da08176b0bd44f338a59abc1f5107f3b1ee56571e840bf4e8ed233f4
977977
type: core

tests/pro/pro-updater.test.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const {
1616
updatePro,
1717
fetchLatestFromNpm,
1818
getCoreVersion,
19+
detectCorePackageName,
1920
satisfiesPeer,
2021
} = require('../../.aiox-core/core/pro/pro-updater');
2122

@@ -81,6 +82,48 @@ describe('pro-updater', () => {
8182

8283
expect(getCoreVersion(projectRoot)).toBe('5.4.0');
8384
});
85+
86+
it('should read declared scoped aiox-core dependency from the project manifest', () => {
87+
const projectRoot = '/tmp/aiox-project';
88+
const packageJsonPath = path.join(projectRoot, 'package.json');
89+
90+
fs.existsSync.mockImplementation((targetPath) => targetPath === packageJsonPath);
91+
fs.readFileSync.mockImplementation((targetPath) => {
92+
if (targetPath === packageJsonPath) {
93+
return JSON.stringify({
94+
name: 'my-app',
95+
devDependencies: {
96+
'@synkra/aiox-core': '^5.5.0',
97+
},
98+
});
99+
}
100+
throw new Error(`Unexpected read: ${targetPath}`);
101+
});
102+
103+
expect(getCoreVersion(projectRoot)).toBe('5.5.0');
104+
});
105+
});
106+
107+
describe('detectCorePackageName()', () => {
108+
it('should detect the scoped core package from project dependencies', () => {
109+
const projectRoot = '/tmp/aiox-project';
110+
const packageJsonPath = path.join(projectRoot, 'package.json');
111+
112+
fs.existsSync.mockImplementation((targetPath) => targetPath === packageJsonPath);
113+
fs.readFileSync.mockImplementation((targetPath) => {
114+
if (targetPath === packageJsonPath) {
115+
return JSON.stringify({
116+
name: 'workspace-app',
117+
dependencies: {
118+
'@synkra/aiox-core': '^5.5.0',
119+
},
120+
});
121+
}
122+
throw new Error(`Unexpected read: ${targetPath}`);
123+
});
124+
125+
expect(detectCorePackageName(projectRoot)).toBe('@synkra/aiox-core');
126+
});
84127
});
85128

86129
describe('satisfiesPeer()', () => {
@@ -101,12 +144,70 @@ describe('pro-updater', () => {
101144
});
102145

103146
describe('updatePro()', () => {
147+
it('should reject an invalid projectRoot before doing any update work', async () => {
148+
fs.statSync.mockImplementation(() => {
149+
throw new Error('ENOENT');
150+
});
151+
152+
await expect(updatePro('/tmp/missing-project', {}))
153+
.rejects
154+
.toThrow('updatePro(projectRoot): projectRoot does not exist or is not a directory');
155+
156+
expect(https.get).not.toHaveBeenCalled();
157+
expect(execSync).not.toHaveBeenCalled();
158+
});
159+
160+
it('should use the detected scoped core package when includeCoreUpdate is requested in dry-run mode', async () => {
161+
const projectRoot = '/tmp/aiox-project';
162+
const installedPackageJson = path.join(projectRoot, 'node_modules', '@aiox-fullstack', 'pro', 'package.json');
163+
const packageJsonPath = path.join(projectRoot, 'package.json');
164+
165+
fs.statSync.mockReturnValue({ isDirectory: () => true });
166+
fs.existsSync.mockImplementation((targetPath) => (
167+
targetPath === installedPackageJson
168+
|| targetPath === packageJsonPath
169+
));
170+
fs.readFileSync.mockImplementation((targetPath) => {
171+
if (targetPath === installedPackageJson) {
172+
return JSON.stringify({ version: '0.3.0' });
173+
}
174+
if (targetPath === packageJsonPath) {
175+
return JSON.stringify({
176+
name: 'workspace-app',
177+
dependencies: {
178+
'@synkra/aiox-core': '^5.5.0',
179+
},
180+
});
181+
}
182+
throw new Error(`Unexpected read: ${targetPath}`);
183+
});
184+
185+
mockRegistryResponse({
186+
version: '0.4.0',
187+
peerDependencies: {
188+
'aiox-core': '>=5.0.0',
189+
},
190+
});
191+
192+
const result = await updatePro(projectRoot, { dryRun: true, includeCoreUpdate: true });
193+
194+
expect(result.success).toBe(true);
195+
expect(result.actions).toEqual(expect.arrayContaining([
196+
expect.objectContaining({
197+
action: 'core_update',
198+
status: 'dry_run',
199+
command: 'npm install @synkra/aiox-core@latest',
200+
}),
201+
]));
202+
});
203+
104204
it('should fail when the package update succeeds but re-scaffolding fails', async () => {
105205
const projectRoot = '/tmp/aiox-project';
106206
const installedPackageJson = path.join(projectRoot, 'node_modules', '@aiox-fullstack', 'pro', 'package.json');
107207
const versionJsonPath = path.join(projectRoot, '.aiox-core', 'version.json');
108208
const scaffolderPath = require.resolve('../../packages/installer/src/pro/pro-scaffolder');
109209

210+
fs.statSync.mockReturnValue({ isDirectory: () => true });
110211
fs.existsSync.mockImplementation((targetPath) => (
111212
targetPath === installedPackageJson
112213
|| targetPath === versionJsonPath

0 commit comments

Comments
 (0)