Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions packages/adt-cli/src/lib/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
lockCommand,
locksCommand,
checkCommand,
userCommand,
} from './commands';
import { refreshCommand } from './commands/auth/refresh';
// Deploy command moved to @abapify/adt-export plugin
Expand Down Expand Up @@ -217,6 +218,9 @@ export async function createCLI(options?: {
// Check command (syntax check / checkruns)
program.addCommand(checkCommand);

// User lookup command
program.addCommand(userCommand);

Comment on lines 217 to +223
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createCLI() now registers the new user command, but cli.test.ts only asserts the program name. Since there is already a CLI-level test suite, add an assertion that the user command is present (e.g., program.commands includes it) to prevent accidental removal/regression.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added a test in cli.test.ts that asserts user is in program.commands. Note: the CLI package has no vitest config or Nx test target yet, but the test is ready for when one is added.

// REPL - Interactive hypermedia navigator
program.addCommand(createReplCommand());

Expand Down
1 change: 1 addition & 0 deletions packages/adt-cli/src/lib/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ export { unlockCommand } from './unlock';
export { lockCommand } from './lock';
export { locksCommand } from './locks';
export { checkCommand } from './check';
export { userCommand } from './user';
136 changes: 136 additions & 0 deletions packages/adt-cli/src/lib/commands/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { Command } from 'commander';
import { getAdtClientV2 } from '../utils/adt-client-v2';

/** Extract entries from atomFeed parsed response */
function extractEntries(
data: unknown,
): { id?: string; title?: string; link?: { href: string }[] }[] {
const feed = data as Record<string, unknown>;
const feedData = feed.feed as Record<string, unknown> | undefined;
if (!feedData) return [];

const rawEntries = feedData.entry;
if (!rawEntries) return [];
return Array.isArray(rawEntries) ? rawEntries : [rawEntries];
}

export const userCommand = new Command('user')
.description('Look up SAP system users')
.argument('[query]', 'Username or search query (supports wildcards like *)')
.option('-m, --max <number>', 'Maximum number of results', '50')
.option('--json', 'Output results as JSON')
.action(async (query: string | undefined, options) => {

Check failure on line 22 in packages/adt-cli/src/lib/commands/user.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 40 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=abapify_adt-cli&issues=AZ1QKYaEDjkrgjePtr10&open=AZ1QKYaEDjkrgjePtr10&pullRequest=91
try {
const adtClient = await getAdtClientV2();

if (!query) {
// No query: show current user via systeminformation
console.log('🔄 Fetching current user...\n');
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Outdated
const sysInfo =
(await adtClient.adt.core.http.systeminformation.getSystemInfo()) as Record<
string,
unknown
>;

if (options.json) {
console.log(
JSON.stringify(
{
userName: sysInfo.userName,
userFullName: sysInfo.userFullName,
systemID: sysInfo.systemID,
client: sysInfo.client,
},
null,
2,
),
);
} else {
console.log(`👤 Current User: ${sysInfo.userName}`);
if (sysInfo.userFullName) {
console.log(` Full Name: ${sysInfo.userFullName}`);

Check warning on line 51 in packages/adt-cli/src/lib/commands/user.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'sysInfo.userFullName' will use Object's default stringification format ('[object Object]') when stringified.

See more on https://sonarcloud.io/project/issues?id=abapify_adt-cli&issues=AZ1QKYaEDjkrgjePtr11&open=AZ1QKYaEDjkrgjePtr11&pullRequest=91
}
console.log(
` System: ${sysInfo.systemID} (client ${sysInfo.client})`,
);
}

console.log('\n✅ Done!');
return;
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated

// Check if query looks like an exact username (no wildcards)
const isExactLookup = !query.includes('*') && !query.includes('?');

if (isExactLookup) {
// Get specific user
console.log(`🔍 Looking up user: ${query.toUpperCase()}...\n`);
const result = await adtClient.adt.system.users.get(
query.toUpperCase(),
);

const entries = extractEntries(result);
if (entries.length === 0) {
console.log('No user found.');
return;
}

if (options.json) {
console.log(
JSON.stringify(
entries.map((e) => ({ username: e.id, fullName: e.title })),
null,
2,
),
);
} else {
for (const entry of entries) {
console.log(`👤 ${entry.id}`);
if (entry.title) console.log(` Full Name: ${entry.title}`);
if (entry.link?.[0]?.href)
console.log(` URI: ${entry.link[0].href}`);
}
}
} else {
// Search users
const maxcount = parseInt(options.max, 10);

Check warning on line 96 in packages/adt-cli/src/lib/commands/user.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=abapify_adt-cli&issues=AZ1QKYaEDjkrgjePtr12&open=AZ1QKYaEDjkrgjePtr12&pullRequest=91
console.log(`🔍 Searching users: "${query}" (max: ${maxcount})...\n`);
const result = await adtClient.adt.system.users.search({
querystring: query,
maxcount,
});

const entries = extractEntries(result);
if (entries.length === 0) {
console.log('No users found matching your query.');
return;
}

if (options.json) {
console.log(
JSON.stringify(
entries.map((e) => ({ username: e.id, fullName: e.title })),
null,
2,
),
);
} else {
console.log(`Found ${entries.length} user(s):\n`);
for (const entry of entries) {
console.log(` ${entry.id?.padEnd(12)} ${entry.title || ''}`);
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the non-JSON search output, entry.id?.padEnd(12) will interpolate to the literal string "undefined" when id is absent (it is optional per the Atom schema). Consider defaulting to an empty string/placeholder before padding (or filtering out entries without id) to avoid confusing CLI output.

Suggested change
console.log(` ${entry.id?.padEnd(12)} ${entry.title || ''}`);
console.log(` ${(entry.id ?? '').padEnd(12)} ${entry.title || ''}`);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The extractEntries helper now lives in the UserService and toUserInfoList() normalizes entries with username: e.id ?? '' before returning. The command display helper also uses (user.username ?? '').padEnd(12) so undefined IDs produce empty strings.

}
}
}

console.log('\n✅ Done!');
} catch (error) {
console.error(
'❌ User lookup failed:',
error instanceof Error ? error.message : String(error),
);
if (error instanceof Error && error.stack) {
console.error('\nStack trace:', error.stack);
}
process.exit(1);
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
4 changes: 4 additions & 0 deletions packages/adt-contracts/src/adt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export * from './repository';
export * from './programs';
export * from './functions';
export * from './ddic';
export * from './system';

/**
* Complete ADT Contract
Expand All @@ -31,6 +32,7 @@ import {
} from './programs';
import { functionsContract, type FunctionsContract } from './functions';
import { ddicContract, type DdicContract } from './ddic';
import { systemContract, type SystemContract } from './system';

/**
* Explicit type to avoid TS7056 "inferred type exceeds maximum length"
Expand All @@ -47,6 +49,7 @@ export interface AdtContract {
programs: ProgramsModuleContract;
functions: FunctionsContract;
ddic: DdicContract;
system: SystemContract;
}

export const adtContract: AdtContract = {
Expand All @@ -61,6 +64,7 @@ export const adtContract: AdtContract = {
programs: programsModuleContract,
functions: functionsContract,
ddic: ddicContract,
system: systemContract,
};

// Import RestClient from base for client type definition
Expand Down
15 changes: 15 additions & 0 deletions packages/adt-contracts/src/adt/system/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* ADT System Contracts - Aggregated
*/

export * from './users';

import { usersContract, type UsersContract } from './users';

export interface SystemContract {
users: UsersContract;
}

export const systemContract: SystemContract = {
users: usersContract,
};
56 changes: 56 additions & 0 deletions packages/adt-contracts/src/adt/system/users.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* ADT System Users Contract
*
* User lookup and search operations.
* Path: /sap/bc/adt/system/users
*/

import { http } from '../../base';
import { atomFeed } from '../../schemas';

export const usersContract = {
/**
* Search for users by query string
*
* @param options.querystring - Search query (supports wildcards like *)
* @param options.maxcount - Maximum number of results
* @returns Atom feed with matching user entries (id = username, title = full name)
*
* @example
* const results = await client.system.users.search({ querystring: 'DEV*', maxcount: 10 });
*/
search: (options: { querystring: string; maxcount?: number }) =>
http.get('/sap/bc/adt/system/users', {
query: {
querystring: options.querystring,
...(options.maxcount !== undefined && { maxcount: options.maxcount }),
},
responses: {
200: atomFeed,
},
headers: {
Accept: 'application/atom+xml;type=feed',
},
}),

/**
* Get a specific user by username
*
* @param username - SAP username (e.g., 'DEVELOPER')
* @returns Atom feed with a single user entry
*
* @example
* const user = await client.system.users.get('DEVELOPER');
*/
get: (username: string) =>
http.get(`/sap/bc/adt/system/users/${encodeURIComponent(username)}`, {
responses: {
200: atomFeed,
},
headers: {
Accept: 'application/atom+xml;type=feed',
},
}),
};

export type UsersContract = typeof usersContract;
26 changes: 14 additions & 12 deletions packages/adt-contracts/src/generated/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,33 @@ export const atctagdescription = toSpeciSchema(adtSchemas.atctagdescription);
export const atcworklist = toSpeciSchema(adtSchemas.atcworklist);
export const atom = toSpeciSchema(adtSchemas.atom);
export const atomExtended = toSpeciSchema(adtSchemas.atomExtended);
export const atomFeed = toSpeciSchema(adtSchemas.atomFeed);
export const aunitResult = toSpeciSchema(adtSchemas.aunitResult);
export const aunitRun = toSpeciSchema(adtSchemas.aunitRun);
export const blueSource = toSpeciSchema(adtSchemas.blueSource);
export const checklist = toSpeciSchema(adtSchemas.checklist);
export const checkrun = toSpeciSchema(adtSchemas.checkrun);
export const classes = toSpeciSchema(adtSchemas.classes);
export const configuration = toSpeciSchema(adtSchemas.configuration);
export const configurations = toSpeciSchema(adtSchemas.configurations);
export const dataelementWrapper = toSpeciSchema(adtSchemas.dataelementWrapper);
export const dataelements = toSpeciSchema(adtSchemas.dataelements);
export const debuggerSchema = toSpeciSchema(adtSchemas.debuggerSchema);
export const discovery = toSpeciSchema(adtSchemas.discovery);
export const domain = toSpeciSchema(adtSchemas.domain);
export const exception = toSpeciSchema(adtSchemas.exception);
export const fincludes = toSpeciSchema(adtSchemas.fincludes);
export const fmodules = toSpeciSchema(adtSchemas.fmodules);
export const groups = toSpeciSchema(adtSchemas.groups);
export const http = toSpeciSchema(adtSchemas.http);
export const interfaces = toSpeciSchema(adtSchemas.interfaces);
export const log = toSpeciSchema(adtSchemas.log);
export const logpoint = toSpeciSchema(adtSchemas.logpoint);
export const packagesV1 = toSpeciSchema(adtSchemas.packagesV1);
export const programs = toSpeciSchema(adtSchemas.programs);
export const quickfixes = toSpeciSchema(adtSchemas.quickfixes);
export const tablesettings = toSpeciSchema(adtSchemas.tablesettings);
export const tabletype = toSpeciSchema(adtSchemas.tabletype);
export const templatelink = toSpeciSchema(adtSchemas.templatelink);
export const templatelinkExtended = toSpeciSchema(
adtSchemas.templatelinkExtended,
Expand All @@ -51,18 +65,6 @@ export const transportmanagmentSingle = toSpeciSchema(
adtSchemas.transportmanagmentSingle,
);
export const transportsearch = toSpeciSchema(adtSchemas.transportsearch);
export const aunitRun = toSpeciSchema(adtSchemas.aunitRun);
export const aunitResult = toSpeciSchema(adtSchemas.aunitResult);
export const programs = toSpeciSchema(adtSchemas.programs);
export const groups = toSpeciSchema(adtSchemas.groups);
export const fmodules = toSpeciSchema(adtSchemas.fmodules);
export const fincludes = toSpeciSchema(adtSchemas.fincludes);
export const domain = toSpeciSchema(adtSchemas.domain);
export const dataelements = toSpeciSchema(adtSchemas.dataelements);
export const dataelementWrapper = toSpeciSchema(adtSchemas.dataelementWrapper);
export const tabletype = toSpeciSchema(adtSchemas.tabletype);
export const tablesettings = toSpeciSchema(adtSchemas.tablesettings);
export const blueSource = toSpeciSchema(adtSchemas.blueSource);

// ============================================================================
// JSON Schemas (re-exported directly - they use zod, not ts-xsd)
Expand Down
47 changes: 47 additions & 0 deletions packages/adt-contracts/tests/contracts/system.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* System Users Contract Scenarios
*/

import { fixtures } from '@abapify/adt-fixtures';
import { atomFeed } from '../../src/schemas';
import { ContractScenario, runScenario, type ContractOperation } from './base';
import { usersContract } from '../../src/adt/system/users';

class UsersScenario extends ContractScenario {
readonly name = 'System Users';

readonly operations: ContractOperation[] = [
{
name: 'search users',
contract: () =>
usersContract.search({ querystring: 'DEV*', maxcount: 10 }),
method: 'GET',
path: '/sap/bc/adt/system/users',
query: { querystring: 'DEV*', maxcount: 10 },
headers: {
Accept: 'application/atom+xml;type=feed',
},
response: {
status: 200,
schema: atomFeed,
fixture: fixtures.system.users.search,
},
},
{
name: 'get single user',
contract: () => usersContract.get('DEVELOPER'),
method: 'GET',
path: '/sap/bc/adt/system/users/DEVELOPER',
headers: {
Accept: 'application/atom+xml;type=feed',
},
response: {
status: 200,
schema: atomFeed,
fixture: fixtures.system.users.single,
},
},
];
}

runScenario(new UsersScenario());
6 changes: 6 additions & 0 deletions packages/adt-fixtures/src/fixtures/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ export const registry = {
quickSearch: 'repository/search/quickSearch.xml',
},
},
system: {
users: {
single: 'system/users/single.xml',
search: 'system/users/search.xml',
},
},
ddic: {
tabl: {
structure: 'ddic/tabl/structure.tabl.xml',
Expand Down
Loading
Loading