Skip to content

Commit c98786a

Browse files
committed
feat(cli): add multiple version detection
Signed-off-by: Cory Rylan <crylan@nvidia.com>
1 parent 41eb7c9 commit c98786a

4 files changed

Lines changed: 212 additions & 11 deletions

File tree

projects/internals/tools/src/internal/node.test.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ vi.mock('node:child_process', () => ({
99
}));
1010

1111
vi.mock('node:fs', () => ({
12+
accessSync: vi.fn(),
13+
constants: { X_OK: 1 },
1214
existsSync: vi.fn(),
15+
realpathSync: vi.fn(),
16+
statSync: vi.fn(),
1317
readFileSync: vi.fn()
1418
}));
1519

@@ -37,7 +41,7 @@ describe('internal/node', () => {
3741
describe('isCommandAvailable', () => {
3842
it('should return true when command exits with code 0', async () => {
3943
const { spawn } = await import('node:child_process');
40-
vi.mocked(spawn).mockReturnValue(createMockChild(0) as ReturnType<typeof spawn>);
44+
vi.mocked(spawn).mockImplementation(() => createMockChild(0) as ReturnType<typeof spawn>);
4145

4246
const { isCommandAvailable } = await import('./node.js');
4347
const result = await isCommandAvailable('pnpm');
@@ -46,7 +50,7 @@ describe('internal/node', () => {
4650

4751
it('should return false when command exits with non-zero code', async () => {
4852
const { spawn } = await import('node:child_process');
49-
vi.mocked(spawn).mockReturnValue(createMockChild(1) as ReturnType<typeof spawn>);
53+
vi.mocked(spawn).mockImplementation(() => createMockChild(1) as ReturnType<typeof spawn>);
5054

5155
const { isCommandAvailable } = await import('./node.js');
5256
const result = await isCommandAvailable('missing-tool');
@@ -55,7 +59,7 @@ describe('internal/node', () => {
5559

5660
it('should return false when spawn errors', async () => {
5761
const { spawn } = await import('node:child_process');
58-
vi.mocked(spawn).mockReturnValue(createMockChild(0, true) as ReturnType<typeof spawn>);
62+
vi.mocked(spawn).mockImplementation(() => createMockChild(0, true) as ReturnType<typeof spawn>);
5963

6064
const { isCommandAvailable } = await import('./node.js');
6165
const result = await isCommandAvailable('not-found');
@@ -118,4 +122,45 @@ describe('internal/node', () => {
118122
expect(result.version).toBe('1.0.0');
119123
});
120124
});
125+
126+
describe('findExecutablesOnPath', () => {
127+
it('should find and dedupe executables on PATH by real path', async () => {
128+
const { existsSync, realpathSync, statSync } = await import('node:fs');
129+
vi.mocked(existsSync).mockReturnValue(true);
130+
vi.mocked(statSync).mockReturnValue({ isFile: () => true } as ReturnType<typeof statSync>);
131+
vi.mocked(realpathSync).mockImplementation(path => {
132+
return path.toString().startsWith('/b/') ? '/a/nve' : path.toString();
133+
});
134+
135+
const { findExecutablesOnPath } = await import('./node.js');
136+
const result = findExecutablesOnPath('nve', { envPath: '/a:/b' });
137+
138+
expect(result).toEqual(['/a/nve']);
139+
});
140+
141+
it('should ignore PATH entries that do not contain the command', async () => {
142+
const { existsSync, statSync } = await import('node:fs');
143+
vi.mocked(existsSync).mockReturnValue(false);
144+
vi.mocked(statSync).mockReturnValue({ isFile: () => true } as ReturnType<typeof statSync>);
145+
146+
const { findExecutablesOnPath } = await import('./node.js');
147+
const result = findExecutablesOnPath('nve', { envPath: '/a:/b' });
148+
149+
expect(result).toEqual([]);
150+
});
151+
152+
it('should ignore files that are not executable on POSIX platforms', async () => {
153+
const { accessSync, existsSync, statSync } = await import('node:fs');
154+
vi.mocked(existsSync).mockReturnValue(true);
155+
vi.mocked(statSync).mockReturnValue({ isFile: () => true } as ReturnType<typeof statSync>);
156+
vi.mocked(accessSync).mockImplementation(() => {
157+
throw new Error('not executable');
158+
});
159+
160+
const { findExecutablesOnPath } = await import('./node.js');
161+
const result = findExecutablesOnPath('nve', { envPath: '/a' });
162+
163+
expect(result).toEqual([]);
164+
});
165+
});
121166
});

projects/internals/tools/src/internal/node.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,14 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { spawn } from 'node:child_process';
5-
import { existsSync, readFileSync } from 'node:fs';
6-
import { join, resolve } from 'node:path';
5+
import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
6+
import { delimiter, join, resolve } from 'node:path';
7+
8+
interface FindExecutablesOptions {
9+
envPath?: string;
10+
platform?: NodeJS.Platform;
11+
pathExt?: string;
12+
}
713

814
export async function getNPMClient() {
915
const hasNPM = await isCommandAvailable('npm');
@@ -28,3 +34,68 @@ export function getPackageJson(cwd: string) {
2834

2935
return JSON.parse(readFileSync(packageJsonPath, 'utf8'));
3036
}
37+
38+
export function findExecutablesOnPath(command: string, options: FindExecutablesOptions = {}) {
39+
const envPath = options.envPath ?? process.env.PATH ?? '';
40+
const commandNames = getExecutableNames(command, options);
41+
const executables = new Map<string, string>();
42+
43+
envPath
44+
.split(delimiter)
45+
.filter(Boolean)
46+
.forEach(directory => {
47+
commandNames.forEach(commandName => {
48+
const commandPath = resolve(directory, commandName);
49+
50+
if (!existsSync(commandPath)) {
51+
return;
52+
}
53+
54+
try {
55+
if (!isExecutableFile(commandPath, options.platform ?? process.platform)) {
56+
return;
57+
}
58+
59+
const realpath = realpathSync(commandPath);
60+
if (!executables.has(realpath)) {
61+
executables.set(realpath, commandPath);
62+
}
63+
} catch {
64+
// Ignore PATH entries this process cannot inspect.
65+
}
66+
});
67+
});
68+
69+
return [...executables.values()];
70+
}
71+
72+
function getExecutableNames(command: string, options: FindExecutablesOptions) {
73+
if ((options.platform ?? process.platform) !== 'win32') {
74+
return [command];
75+
}
76+
77+
const pathExt = options.pathExt ?? process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD;.PS1';
78+
const extensions = pathExt
79+
.split(';')
80+
.filter(Boolean)
81+
.map(extension => extension.toLowerCase());
82+
83+
return [command, ...extensions.map(extension => `${command}${extension}`)];
84+
}
85+
86+
function isExecutableFile(commandPath: string, platform: NodeJS.Platform) {
87+
if (!statSync(commandPath).isFile()) {
88+
return false;
89+
}
90+
91+
if (platform === 'win32') {
92+
return true;
93+
}
94+
95+
try {
96+
accessSync(commandPath, constants.X_OK);
97+
return true;
98+
} catch {
99+
return false;
100+
}
101+
}

projects/internals/tools/src/project/health.test.ts

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest';
55
import type { Project } from '@internals/metadata';
66
import type { Result } from 'publint';
77
import {
8+
checkCliInstallations,
89
checkDependencies,
910
checkPeerDependencies,
1011
checkSemanticDependencies,
@@ -15,6 +16,7 @@ import {
1516
} from './health.js';
1617
import type { ElementVersions } from '../api/utils.js';
1718
import type { PackageData } from '../internal/types.js';
19+
import { findExecutablesOnPath } from '../internal/node.js';
1820

1921
// Mock the publint module
2022
vi.mock('publint', () => ({
@@ -23,6 +25,7 @@ vi.mock('publint', () => ({
2325

2426
// Mock the internal node module
2527
vi.mock('../internal/node.js', () => ({
28+
findExecutablesOnPath: vi.fn(() => []),
2629
getPackageJson: vi.fn()
2730
}));
2831

@@ -39,6 +42,11 @@ vi.mock('../api/utils.js', () => ({
3942
getPublishedProjects: vi.fn((projects: { name: string }[]) => projects)
4043
}));
4144

45+
beforeEach(() => {
46+
vi.clearAllMocks();
47+
vi.mocked(findExecutablesOnPath).mockReturnValue([]);
48+
});
49+
4250
describe('getVersionNum', () => {
4351
it('should return the correct version number', () => {
4452
expect(getVersionNum('1.0.0')).toEqual({ major: 1, minor: 0, patch: 0 });
@@ -121,6 +129,41 @@ describe('getVersionStatus', () => {
121129
});
122130
});
123131

132+
describe('checkCliInstallations', () => {
133+
it('should return success when a single nve CLI is found on PATH', () => {
134+
vi.mocked(findExecutablesOnPath).mockReturnValue(['/usr/local/bin/nve']);
135+
136+
const result = checkCliInstallations();
137+
138+
expect(result).toEqual({
139+
message: 'One nve CLI executable found on PATH: /usr/local/bin/nve',
140+
status: 'success'
141+
});
142+
});
143+
144+
it('should return danger when multiple nve CLIs are found on PATH', () => {
145+
vi.mocked(findExecutablesOnPath).mockReturnValue(['/usr/local/bin/nve', '/Users/test/Library/pnpm/nve']);
146+
147+
const result = checkCliInstallations();
148+
149+
expect(result.status).toBe('danger');
150+
expect(result.message).toBe(
151+
'Multiple nve CLI executables found on PATH: /usr/local/bin/nve, /Users/test/Library/pnpm/nve'
152+
);
153+
});
154+
155+
it('should return warning when no nve CLI executable is found on PATH', () => {
156+
vi.mocked(findExecutablesOnPath).mockReturnValue([]);
157+
158+
const result = checkCliInstallations();
159+
160+
expect(result).toEqual({
161+
message: 'No nve CLI executable found on PATH',
162+
status: 'warning'
163+
});
164+
});
165+
});
166+
124167
describe('getVersionHealth', () => {
125168
it('should return the correct version health', async () => {
126169
expect(
@@ -599,10 +642,10 @@ describe('checkSemanticDependencies', () => {
599642

600643
describe('getHealthReport', () => {
601644
beforeEach(() => {
602-
vi.clearAllMocks();
645+
vi.mocked(findExecutablesOnPath).mockReturnValue(['/usr/local/bin/nve']);
603646
});
604647

605-
it('should return health report for application type with only dependencies check', async () => {
648+
it('should return health report for application type with dependencies and CLI checks', async () => {
606649
const { getPackageJson } = await import('../internal/node.js');
607650
const { ProjectsService } = await import('@internals/metadata');
608651
const { getLatestPublishedVersions } = await import('../api/utils.js');
@@ -611,7 +654,10 @@ describe('getHealthReport', () => {
611654
created: string;
612655
data: Project[];
613656
});
614-
vi.mocked(getLatestPublishedVersions).mockResolvedValue({ '@nvidia-elements/core': '1.0.0' } as ElementVersions);
657+
vi.mocked(getLatestPublishedVersions).mockResolvedValue({
658+
'@nvidia-elements/core': '1.0.0',
659+
'@nvidia-elements/cli': '1.0.0'
660+
} as ElementVersions);
615661
vi.mocked(getPackageJson).mockReturnValue({
616662
dependencies: { '@nvidia-elements/core': '^1.0.0' },
617663
devDependencies: {},
@@ -620,11 +666,16 @@ describe('getHealthReport', () => {
620666

621667
const result = await getHealthReport('/test/path', 'application');
622668
expect(result).toHaveProperty('dependencies');
669+
expect(result).toHaveProperty('cliInstallations');
623670
expect(result).not.toHaveProperty('peerDependencies');
624671
expect(result).not.toHaveProperty('semanticDependencies');
625672
expect(result.dependencies).toHaveProperty('versions');
626673
expect(result.dependencies).toHaveProperty('status');
627674
expect(result.dependencies).toHaveProperty('message');
675+
expect(result.cliInstallations).toEqual({
676+
message: 'One nve CLI executable found on PATH: /usr/local/bin/nve',
677+
status: 'success'
678+
});
628679
});
629680

630681
it('should return health report for library type with all checks', async () => {
@@ -637,7 +688,10 @@ describe('getHealthReport', () => {
637688
created: string;
638689
data: Project[];
639690
});
640-
vi.mocked(getLatestPublishedVersions).mockResolvedValue({ '@nvidia-elements/core': '1.0.0' } as ElementVersions);
691+
vi.mocked(getLatestPublishedVersions).mockResolvedValue({
692+
'@nvidia-elements/core': '1.0.0',
693+
'@nvidia-elements/cli': '1.0.0'
694+
} as ElementVersions);
641695
vi.mocked(publint).mockResolvedValue({ messages: [] } as Result);
642696
vi.mocked(getPackageJson).mockReturnValue({
643697
dependencies: {},
@@ -647,6 +701,7 @@ describe('getHealthReport', () => {
647701

648702
const result = await getHealthReport('/test/path', 'library');
649703
expect(result).toHaveProperty('dependencies');
704+
expect(result).toHaveProperty('cliInstallations');
650705
expect(result).toHaveProperty('peerDependencies');
651706
expect(result).toHaveProperty('semanticDependencies');
652707
expect(result.peerDependencies).toHaveProperty('status');

projects/internals/tools/src/project/health.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@ import { publint as publintFn } from 'publint';
55
import { ProjectsService } from '@internals/metadata';
66
import { type ElementVersions, getLatestPublishedVersions, getPublishedProjects } from '../api/utils.js';
77
import type { ReportCheck, Report, PackageData } from '../internal/types.js';
8-
import { getPackageJson } from '../internal/node.js';
8+
import { findExecutablesOnPath, getPackageJson } from '../internal/node.js';
99

1010
export async function getHealthReport(cwd: string, type: 'application' | 'library') {
1111
const packageJson = getPackageJson(cwd);
1212
const projects = getPublishedProjects((await ProjectsService.getData()).data);
1313
const currentVersions = await getLatestPublishedVersions(projects);
1414

1515
let report: Report = {
16-
dependencies: await checkDependencies(packageJson, currentVersions)
16+
dependencies: await checkDependencies(packageJson, currentVersions),
17+
cliInstallations: checkCliInstallations()
1718
};
1819

1920
if (type === 'library') {
@@ -77,6 +78,35 @@ export function getVersionNum(value: string): { major: number; minor: number; pa
7778
};
7879
}
7980

81+
export function checkCliInstallations(): ReportCheck {
82+
const paths = findExecutablesOnPath('nve');
83+
84+
if (!paths.length) {
85+
return {
86+
message: 'No nve CLI executable found on PATH',
87+
status: 'warning'
88+
};
89+
}
90+
91+
const summary = formatCliInstallations(paths);
92+
93+
if (paths.length > 1) {
94+
return {
95+
message: `Multiple nve CLI executables found on PATH: ${summary}`,
96+
status: 'danger'
97+
};
98+
}
99+
100+
return {
101+
message: `One nve CLI executable found on PATH: ${summary}`,
102+
status: 'success'
103+
};
104+
}
105+
106+
function formatCliInstallations(paths: string[]) {
107+
return paths.join(', ');
108+
}
109+
80110
async function checkPublint(cwd: string): Promise<Report> {
81111
const { messages } = await publintFn({
82112
pkgDir: cwd,

0 commit comments

Comments
 (0)