Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
50989af
feat: implement automatic detection of local CAP roots and enhance pa…
mikicvi-SAP Dec 29, 2025
4e2fe0b
Linting auto fix commit
github-actions[bot] Dec 29, 2025
76ff09d
fix: path sep for windows test
mikicvi-SAP Dec 30, 2025
b662ee8
feat: questions filter tests for cap path
mikicvi-SAP Dec 30, 2025
1de0015
fix: test under windows scenario
mikicvi-SAP Dec 30, 2025
4eb618f
fix: platform agnostic path
mikicvi-SAP Dec 30, 2025
a1be19b
tbi: refactor to address sonar issues
mikicvi-SAP Dec 31, 2025
94a19f2
Merge branch 'main' into tbi/3959/cap-root-detection-cli
mikicvi-SAP Dec 31, 2025
14d12bc
tbi: fix lint issue
mikicvi-SAP Dec 31, 2025
1635d6c
tbi: refactor to use existing project access implementation
mikicvi-SAP Dec 31, 2025
6f9bdd6
tbi: translations feedback
mikicvi-SAP Jan 2, 2026
973313a
tbi: replace strings with translations
mikicvi-SAP Jan 2, 2026
84a652d
refactor: simplify implementation, handle relative paths
mikicvi-SAP Jan 8, 2026
1de7c72
tbi: revert relative path support, add autocomplete for cap project s…
mikicvi-SAP Jan 9, 2026
7b4bc99
Merge branch 'main' into tbi/3959/cap-root-detection-cli
mikicvi-SAP Jan 9, 2026
f564eb9
tbi: use separator for test
mikicvi-SAP Jan 9, 2026
a7f28a2
tbi: address feedback, simplify detection
mikicvi-SAP Jan 9, 2026
7ccab1f
tbi: add more tests
mikicvi-SAP Jan 10, 2026
e4123bd
Linting auto fix commit
github-actions[bot] Jan 10, 2026
7c5b03e
Merge branch 'main' into tbi/3959/cap-root-detection-cli
mikicvi-SAP Jan 12, 2026
b2c9659
Merge branch 'main' into tbi/3959/cap-root-detection-cli
mikicvi-SAP Jan 12, 2026
0238e5f
Merge branch 'main' into tbi/3959/cap-root-detection-cli
mikicvi-SAP Jan 12, 2026
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
5 changes: 5 additions & 0 deletions .changeset/kind-impalas-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sap-ux/odata-service-inquirer': minor
---

Automatic detection of local CAP roots, allow relative path usage for manual selection
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { prompt as promptUI5App, promptNames as ui5AppInquirerPromptNames } from
import { getSapSystemUI5Version, getUI5Versions, latestVersionString } from '@sap-ux/ui5-info';
import type { Question } from 'inquirer';
import merge from 'lodash/merge';
import { join } from 'node:path';
import { join, sep } from 'node:path';
import type { Adapter } from 'yeoman-environment';
import type { Floorplan, Project, Service, YeomanUiStepConfig } from '../types';
import { Features, defaultPromptValues } from '../types';
Expand Down Expand Up @@ -388,6 +388,7 @@ export interface OdataServiceInquirerOptions {
function createOdataServicePromptOptions(options: OdataServiceInquirerOptions): OdataServicePromptOptions {
let defaultDatasourceSelection;
const isYUI = getHostEnvironment() !== hostEnvironment.cli;
const wsCwd = options.workspaceFolders?.[0] ?? process.cwd();

if (options.capService) {
defaultDatasourceSelection = DatasourceType.capProject;
Expand All @@ -406,7 +407,7 @@ function createOdataServicePromptOptions(options: OdataServiceInquirerOptions):
...options.promptOptions?.metadataFilePath
},
[odataServiceInquirerPromptNames.capProject]: {
capSearchPaths: options.workspaceFolders ?? [],
capSearchPaths: (!isYUI ? [join(wsCwd, `..${sep}..`)] : options.workspaceFolders) ?? [],
defaultChoice: options.capService?.projectPath,
...options.promptOptions?.capProject
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
getCdsRoots,
readCapServiceMetadataEdmx
} from '@sap-ux/project-access';
import { basename, isAbsolute, relative } from 'node:path';
import { basename, isAbsolute, relative, resolve } from 'node:path';
import { t } from '../../../i18n';
import type { CapServiceChoice } from '../../../types';
import type { CapService } from '@sap-ux/cap-config-writer';
Expand All @@ -15,19 +15,37 @@ import { errorHandler } from '../../prompt-helpers';
import type { CapProjectChoice, CapProjectPaths, CapProjectRootPath } from './types';
import { ERROR_TYPE } from '@sap-ux/inquirer-common';
import { realpath } from 'node:fs/promises';
import { getHostEnvironment, hostEnvironment } from '@sap-ux/fiori-generator-shared';

export const enterCapPathChoiceValue = 'enterCapPath';

/**
* Resolve relative paths to absolute paths for CLI environment.
*
* @param projectPath - The path to resolve
* @returns The resolved absolute path, or the original path if already absolute or not in CLI
*/
export function resolveRelativeCliPath(projectPath: string): string {
const isCli = getHostEnvironment() === hostEnvironment.cli;
if (isCli && !isAbsolute(projectPath)) {
return resolve(process.cwd(), projectPath.trim());
}
return projectPath;
}

/**
* Search for CAP projects in the specified paths.
* Resolves relative paths to absolute paths before searching.
*
* @param paths - The paths used to search for CAP projects
* @param paths - The paths used to search for CAP projects (can be relative or absolute)
* @returns The CAP project paths and the number of folders with the same name
*/
async function getCapProjectPaths(
paths: string[]
): Promise<{ capProjectPaths: CapProjectRootPath[]; folderCounts: Map<string, number> }> {
const capProjectRoots = await findCapProjects({ wsFolders: paths });
// Resolve relative paths to absolute paths
const absolutePaths = paths.map((path) => (isAbsolute(path) ? path : resolveRelativeCliPath(path)));
const capProjectRoots = await findCapProjects({ wsFolders: absolutePaths });
const capRootPaths: CapProjectRootPath[] = [];
// Keep track of duplicate folder names to append the path to the name when displaying the choices
const folderNameCount = new Map<string, number>();
Expand All @@ -51,11 +69,11 @@ async function getCapProjectPaths(
* The resulting choices will include an additional entry to enter a custom path.
*
* @param paths - The paths used to search for CAP projects
* @param workspaceContext - Optional workspace context for auto-detection
* @returns The CAP project prompt choices
*/
export async function getCapProjectChoices(paths: string[]): Promise<CapProjectChoice[]> {
export async function getCapProjectChoices(paths: string[], searchSubFolders?: boolean): Promise<CapProjectChoice[]> {
const { capProjectPaths, folderCounts } = await getCapProjectPaths(paths);

const capChoices: CapProjectChoice[] = [];

for (const capProjectPath of capProjectPaths) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,19 @@ import { OdataVersion } from '@sap-ux/odata-service-writer';
import { getCapCustomPaths } from '@sap-ux/project-access';
import { hostEnvironment } from '@sap-ux/fiori-generator-shared';
import type { Question } from 'inquirer';
import { isAbsolute, join } from 'node:path';
import { t } from '../../../i18n';
import type { CapServiceChoice, OdataServicePromptOptions } from '../../../types';
import { promptNames } from '../../../types';
import { PromptState, getPromptHostEnvironment } from '../../../utils';
import { errorHandler } from '../../prompt-helpers';
import { enterCapPathChoiceValue, getCapEdmx, getCapProjectChoices, getCapServiceChoices } from './cap-helpers';
import {
enterCapPathChoiceValue,
getCapEdmx,
getCapProjectChoices,
getCapServiceChoices,
resolveRelativeCliPath
} from './cap-helpers';
import {
capInternalPromptNames,
type CapProjectChoice,
Expand Down Expand Up @@ -42,6 +49,7 @@ function getDefaultCapChoice(
}
return -1;
}

/**
* Get the prompts for selecting a CAP project from local path discovery.
* Two prompts are returned, one for selecting a CAP project from a list of discovered projects and
Expand Down Expand Up @@ -94,7 +102,17 @@ export function getLocalCapProjectPrompts(
}
},
guiOptions: { mandatory: true, breadcrumb: t('prompts.capProject.breadcrumb') },
transformer: (projectPath: string): string => {
// maybe check here if platform is cli, but i don't see transformer doing anything for gui(yet)
if (projectPath && !isAbsolute(projectPath)) {
return join(process.cwd(), projectPath);
}
return '';
},
validate: async (projectPath: string): Promise<string | boolean> => {
// Resolve relative paths to absolute before validation
projectPath = resolveRelativeCliPath(projectPath);

validCapPath = await validateCapPath(projectPath);
// Load the cap paths if the path is valid
if (validCapPath === true) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,45 @@
import { getCapProjectType } from '@sap-ux/project-access';
import { t } from '../../../i18n';
import { resolveRelativeCliPath } from './cap-helpers';

/**
* Ensure the path specified is a valid CAP project.
* Now supports relative paths like "../my-cap-project" by resolving them properly.
*
* @param capProjectPath - The path to the CAP project
* @param capProjectPath - The path to the CAP project (can be relative or absolute)
* @returns A boolean indicating if the path is a valid CAP project or an error message
*/
export async function validateCapPath(capProjectPath: string): Promise<boolean | string> {
if (capProjectPath) {
try {
return !!(await getCapProjectType(capProjectPath)) || t('prompts.validationMessages.capProjectNotFound');
} catch (err) {
// Handle undefined/null case (when validate is called without parameters) - return false for backwards compatibility
if (capProjectPath === undefined || capProjectPath === null) {
return false;
}

// Empty path after filter means auto-detection failed or not in CLI mode
if (capProjectPath.trim() === '') {
return t('prompts.validationMessages.capProjectNotFound');
}

try {
// Validate the resolved path
const resolvedPath = resolveRelativeCliPath(capProjectPath);
const capProjectType = await getCapProjectType(resolvedPath);

if (capProjectType) {
return true;
} else {
return t('prompts.validationMessages.capProjectNotFound');
}
} catch (err) {
// Provide more specific error message for common issues
const errorMessage = err instanceof Error ? err.message : String(err);

if (errorMessage.includes('ENOENT') || errorMessage.includes('no such file or directory')) {
return t('prompts.validationMessages.capProjectNotFound');
} else if (errorMessage.includes('EACCES') || errorMessage.includes('permission denied')) {
return t('prompts.validationMessages.permissionDenied');
} else {
return t('prompts.validationMessages.capProjectNotFound');
}
}
return false;
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"metadataInvalid": "The service metadata is invalid.",
"metadataFilePathNotValid": "The metadata file does not exist or is not accessible. Please specify a valid file path.",
"capProjectNotFound": "The folder you have selected does not contain a valid CAP project. Please check and try again.",
"permissionDenied": "Permission denied when accessing the specified path. Please check the file permissions.",
"warningCertificateValidationDisabled": "Certificate validation has been disabled by the user.",
"annotationsNotFound": "Annotations not found for the specified service.",
"backendSystemExistsWarning": "A saved system connection entry: \"{{- backendName}}\" already exists with the same URL and client. Please reuse the existing entry or remove it, using the systems panel, before adding a new connection.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ exports[`API tests getPrompts, i18n is loaded 1`] = `
"guiType": "folder-browser",
"message": "CAP Project Folder Path",
"name": "capProjectPath",
"transformer": [Function],
"type": "input",
"validate": [Function],
"when": [Function],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ exports[`getQuestions getQuestions 1`] = `
"guiType": "folder-browser",
"message": "CAP Project Folder Path",
"name": "capProjectPath",
"transformer": [Function],
"type": "input",
"validate": [Function],
"when": [Function],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ exports[`getLocalCapProjectPrompts getLocalCapProjectPrompts, returns expected p
"guiType": "folder-browser",
"message": "CAP Project Folder Path",
"name": "capProjectPath",
"transformer": [Function],
"type": "input",
"validate": [Function],
"when": [Function],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,75 @@ describe('cap-helper', () => {
);
});

test('getCapProjectChoices: no auto-detection when projects are found in search paths', async () => {
// Clear all previous spy calls before this test
jest.clearAllMocks();

const findCapProjectsSpy = jest
.spyOn(sapuxProjectAccess, 'findCapProjects')
.mockResolvedValue(['/found/project']);
const findCapProjectRootSpy = jest.spyOn(sapuxProjectAccess, 'findCapProjectRoot');

if (os.platform() === 'win32') {
jest.spyOn(fsPromises, 'realpath').mockImplementation(async (path: PathLike) => path as string);
}

await getCapProjectChoices(['/search/path']);

// Auto-detection should not be triggered when projects are found
expect(findCapProjectRootSpy).not.toHaveBeenCalled();
});

test('getCapServiceChoices: enhanced path resolution with relative service paths', async () => {
// Test the enhanced path resolution in createCapServiceChoice
currentMockCapModelAndServices = {
model: {
definitions: {
TestService: {
$location: {
file: '../srv/test-service.cds'
},
kind: 'service',
name: 'TestService'
}
},
$sources: ['/project/root/srv/test-service.cds']
},
services: [
{
name: 'TestService',
urlPath: '/test/',
runtime: 'Node.js'
}
]
};

const capProjectPaths: CapProjectPaths = {
app: 'app/',
db: 'db/',
folderName: 'testproject',
path: '/project/root',
srv: 'srv/'
};

const choices = await getCapServiceChoices(capProjectPaths);

expect(choices).toEqual([
{
name: 'TestService (Node.js)',
value: {
appPath: 'app/',
capType: 'Node.js',
cdsVersionInfo: undefined,
projectPath: '/project/root',
serviceCdsPath: `srv${sep}test-service`,
serviceName: 'TestService',
urlPath: '/test/'
}
}
]);
});

/**
* Tests the service path resolution using both Windows and Unix implementations, so this critical functionality can be easily tested on non-Windows platforms by developers.
*/
Expand Down
Loading
Loading