Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
34 changes: 34 additions & 0 deletions extractors/cds/tools/dist/cds-extractor.bundle.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions extractors/cds/tools/dist/cds-extractor.bundle.js.map

Large diffs are not rendered by default.

65 changes: 63 additions & 2 deletions extractors/cds/tools/src/packageManager/cacheInstaller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { execFileSync } from 'child_process';
import { createHash } from 'crypto';
import { existsSync, mkdirSync, writeFileSync } from 'fs';
import { join, resolve } from 'path';
import { copyFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
import { dirname, join, resolve } from 'path';

import type { CdsDependencyCombination } from './types';
import { CdsDependencyGraph, CdsProject } from '../cds/parser/types';
Expand Down Expand Up @@ -48,6 +48,61 @@ function addDependencyVersionWarning(
}
}

/**
* Find the nearest `.npmrc` file by searching the given directory and its
* ancestors up to (and including) the filesystem root. npm itself walks the
* directory tree when looking for project-level `.npmrc` files, so we mirror
* that behaviour here.
*
* @param startDir The directory from which to start the upward search.
* @returns The absolute path to the nearest `.npmrc`, or `undefined` if none is found.
*/
export function findNearestNpmrc(startDir: string): string | undefined {
let current = resolve(startDir);

// Walk up the directory tree until we find an .npmrc or reach the root

while (true) {
const candidate = join(current, '.npmrc');
if (existsSync(candidate)) {
return candidate;
}
const parent = dirname(current);
if (parent === current) {
// Reached filesystem root without finding .npmrc
return undefined;
}
current = parent;
}
}

/**
* Copy the project's `.npmrc` file (if any) into the cache directory so that
* `npm install` inside the cache respects custom registry configuration such
* as scoped registries (`@sap:registry=...`), authentication tokens, and
* `strict-ssl` settings.
*
* @param cacheDir The cache directory where dependencies will be installed.
* @param projectDir Absolute path to the project directory whose `.npmrc` should be used.
*/
export function copyNpmrcToCache(cacheDir: string, projectDir: string): void {
const npmrcPath = findNearestNpmrc(projectDir);
if (!npmrcPath) {
return;
}

const dest = join(cacheDir, '.npmrc');
try {
copyFileSync(npmrcPath, dest);
cdsExtractorLog('info', `Copied .npmrc from '${npmrcPath}' to cache directory '${cacheDir}'`);
} catch (err) {
cdsExtractorLog(
'warn',
`Failed to copy .npmrc to cache directory: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
Comment thread
data-douser marked this conversation as resolved.

/**
* Install dependencies for CDS projects using a robust cache strategy with fallback logic
* @param dependencyGraph The dependency graph of the project
Expand Down Expand Up @@ -204,6 +259,12 @@ export function cacheInstallDependencies(
);
continue;
}

// Copy the project's .npmrc (if any) so npm respects custom registries
const firstProjectDir = Array.from(dependencyGraph.projects.keys())[0];
if (firstProjectDir) {
copyNpmrcToCache(cacheDir, join(sourceRoot, firstProjectDir));
}
}

Comment thread
data-douser marked this conversation as resolved.
Outdated
// Try to install dependencies in the cache directory
Expand Down
2 changes: 1 addition & 1 deletion extractors/cds/tools/src/packageManager/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Export the new robust installer functionality (preferred)
export { cacheInstallDependencies } from './cacheInstaller';
export { cacheInstallDependencies, copyNpmrcToCache, findNearestNpmrc } from './cacheInstaller';
export { needsFullDependencyInstallation, projectInstallDependencies } from './projectInstaller';
export type { CdsDependencyCombination } from './types';
export {
Expand Down
176 changes: 174 additions & 2 deletions extractors/cds/tools/test/src/packageManager/cacheInstaller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ import * as fs from 'fs';
import * as path from 'path';

import { CdsDependencyGraph, CdsProject } from '../../../src/cds/parser/types';
import { cacheInstallDependencies } from '../../../src/packageManager';
import {
cacheInstallDependencies,
copyNpmrcToCache,
findNearestNpmrc,
} from '../../../src/packageManager';

// Mock dependencies
jest.mock('fs', () => ({
copyFileSync: jest.fn(),
existsSync: jest.fn(),
readFileSync: jest.fn(),
mkdirSync: jest.fn(),
readFileSync: jest.fn(),
writeFileSync: jest.fn(),
}));

Expand Down Expand Up @@ -638,4 +643,171 @@ describe('installer', () => {
expect(result.size).toBe(0);
});
});

describe('findNearestNpmrc', () => {
beforeEach(() => {
// Use actual path.dirname so the directory-walking loop works correctly
(path.dirname as jest.Mock).mockImplementation(jest.requireActual('path').dirname);
(fs.existsSync as jest.Mock).mockReturnValue(false);
});

it('should return the .npmrc path when it exists in the start directory', () => {
(path.resolve as jest.Mock).mockReturnValue('/project/src');
(fs.existsSync as jest.Mock).mockImplementation((p: string) => p === '/project/src/.npmrc');

const result = findNearestNpmrc('/project/src');

expect(result).toBe('/project/src/.npmrc');
});

it('should return the .npmrc path when it exists in a parent directory', () => {
(path.resolve as jest.Mock).mockReturnValue('/project/src');
(fs.existsSync as jest.Mock).mockImplementation((p: string) => p === '/project/.npmrc');

const result = findNearestNpmrc('/project/src');

expect(result).toBe('/project/.npmrc');
});

it('should return undefined when no .npmrc exists in the directory tree', () => {
(path.resolve as jest.Mock).mockReturnValue('/project/src');
(fs.existsSync as jest.Mock).mockReturnValue(false);

const result = findNearestNpmrc('/project/src');

expect(result).toBeUndefined();
});
});

describe('copyNpmrcToCache', () => {
beforeEach(() => {
(path.dirname as jest.Mock).mockImplementation(jest.requireActual('path').dirname);
(fs.existsSync as jest.Mock).mockReturnValue(false);
(fs.copyFileSync as jest.Mock).mockReturnValue(undefined);
});

it('should copy .npmrc to the cache directory when found', () => {
(path.resolve as jest.Mock).mockReturnValue('/project');
(fs.existsSync as jest.Mock).mockImplementation((p: string) => p === '/project/.npmrc');

copyNpmrcToCache('/cache/dir', '/project');

expect(fs.copyFileSync).toHaveBeenCalledWith('/project/.npmrc', '/cache/dir/.npmrc');
});

it('should do nothing when no .npmrc is found', () => {
(path.resolve as jest.Mock).mockReturnValue('/project');
(fs.existsSync as jest.Mock).mockReturnValue(false);

copyNpmrcToCache('/cache/dir', '/project');

expect(fs.copyFileSync).not.toHaveBeenCalled();
});

it('should log a warning and not throw when copyFileSync fails', () => {
(path.resolve as jest.Mock).mockReturnValue('/project');
(fs.existsSync as jest.Mock).mockImplementation((p: string) => p === '/project/.npmrc');
(fs.copyFileSync as jest.Mock).mockImplementation(() => {
throw new Error('Permission denied');
});

expect(() => copyNpmrcToCache('/cache/dir', '/project')).not.toThrow();
});
});

describe('cacheInstallDependencies .npmrc propagation', () => {
beforeEach(() => {
(path.dirname as jest.Mock).mockImplementation(jest.requireActual('path').dirname);
(fs.existsSync as jest.Mock).mockReturnValue(false);
(fs.mkdirSync as jest.Mock).mockReturnValue(undefined);
(fs.writeFileSync as jest.Mock).mockReturnValue(undefined);
(fs.copyFileSync as jest.Mock).mockReturnValue(undefined);
(childProcess.execFileSync as jest.Mock).mockReturnValue('');

const mockResolveCdsVersions = jest.mocked(
jest.requireMock('../../../src/packageManager/versionResolver').resolveCdsVersions,
);
mockResolveCdsVersions.mockReturnValue({
resolvedCdsVersion: '6.1.3',
resolvedCdsDkVersion: '6.0.0',
cdsExactMatch: true,
cdsDkExactMatch: true,
});
});

it('should copy .npmrc to the cache directory before running npm install', () => {
// Simulate an .npmrc in the project directory
(fs.existsSync as jest.Mock).mockImplementation(
(p: string) => p === '/source/project1/.npmrc',
);

const dependencyGraph = createMockDependencyGraph([
{
projectDir: 'project1',
packageJson: {
name: 'project1',
dependencies: { '@sap/cds': '6.1.3' },
devDependencies: { '@sap/cds-dk': '6.0.0' },
},
},
]);

cacheInstallDependencies(dependencyGraph, '/source', '/codeql');

expect(fs.copyFileSync).toHaveBeenCalledWith(
'/source/project1/.npmrc',
expect.stringContaining('.npmrc'),
);
});

Comment thread
data-douser marked this conversation as resolved.
it('should succeed even when .npmrc copy fails', () => {
// Simulate an .npmrc in the project directory
(fs.existsSync as jest.Mock).mockImplementation(
(p: string) => p === '/source/project1/.npmrc',
);
(fs.copyFileSync as jest.Mock).mockImplementation(() => {
throw new Error('Permission denied');
});

const dependencyGraph = createMockDependencyGraph([
{
projectDir: 'project1',
packageJson: {
name: 'project1',
dependencies: { '@sap/cds': '6.1.3' },
devDependencies: { '@sap/cds-dk': '6.0.0' },
},
},
]);

// Should not throw and should still attempt npm install
const result = cacheInstallDependencies(dependencyGraph, '/source', '/codeql');

expect(childProcess.execFileSync).toHaveBeenCalledWith(
'npm',
['install', '--quiet', '--no-audit', '--no-fund'],
expect.any(Object),
);
expect(result.size).toBe(1);
});

it('should not copy .npmrc when no .npmrc file is found', () => {
(fs.existsSync as jest.Mock).mockReturnValue(false);

const dependencyGraph = createMockDependencyGraph([
{
projectDir: 'project1',
packageJson: {
name: 'project1',
dependencies: { '@sap/cds': '6.1.3' },
devDependencies: { '@sap/cds-dk': '6.0.0' },
},
},
]);

cacheInstallDependencies(dependencyGraph, '/source', '/codeql');

expect(fs.copyFileSync).not.toHaveBeenCalled();
});
});
});
34 changes: 34 additions & 0 deletions extractors/javascript/tools/pre-finalize.cmd
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
@echo off

if not defined CODEQL_EXTRACTOR_CDS_SKIP_EXTRACTION (
echo Running database index-files for CDS (.cds) files ...

type NUL && "%CODEQL_DIST%\codeql.exe" database index-files ^
--include-extension=.cds ^
--language cds ^
Expand All @@ -9,6 +11,38 @@ if not defined CODEQL_EXTRACTOR_CDS_SKIP_EXTRACTION (
--total-size-limit=10m ^
-- ^
"%CODEQL_EXTRACTOR_JAVASCRIPT_WIP_DATABASE%"

if %ERRORLEVEL% neq 0 (
echo database index-files for CDS (.cds) files failed with exit code %ERRORLEVEL%.
exit /b %ERRORLEVEL%
)
Comment thread
data-douser marked this conversation as resolved.
Outdated

echo Finished running database index-files for CDS (.cds) files.
)

echo Running database index-files for UI5 (.view.xml and .fragment.xml) files ...

type NUL && "%CODEQL_DIST%\codeql.exe" database index-files ^
--include-extension=.view.xml ^
--include-extension=.fragment.xml ^
Comment thread
data-douser marked this conversation as resolved.
--language xml ^
--prune **\node_modules\**\* ^
--prune **\.eslint\**\* ^
--total-size-limit=10m ^
-- ^
"%CODEQL_EXTRACTOR_JAVASCRIPT_WIP_DATABASE%"

if %ERRORLEVEL% neq 0 (
echo database index-files for UI5 (.view.xml and .fragment.xml) files failed with exit code %ERRORLEVEL%.
exit /b %ERRORLEVEL%
)

echo Finished running database index-files for UI5 (.view.xml and .fragment.xml) files.

REM UI5 also requires *.view.json files and *.view.html files be indexed, but these are indexed by
REM default by CodeQL.

REM XSJS also requires indexing of *.xsaccess files, *.xsjs files and xs-app.json files, but these
REM are indexed by default by CodeQL.

exit /b %ERRORLEVEL%
Loading