diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index d5f93cbd..426b4076 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -6,6 +6,7 @@ COPY --from=docker/mcp-gateway /docker-mcp /usr/libexec/docker/cli-plugins/docke # Install necessary libraries for Chromium RUN apt-get update && apt-get install -y \ + build-essential \ ca-certificates \ fonts-liberation \ libasound2 \ @@ -47,8 +48,12 @@ RUN apt-get update && apt-get install -y \ # RUN npm install -g npm@latest USER node -# Install uv Python package manager -RUN curl -LsSf https://astral.sh/uv/install.sh | sh -# Install bun -RUN curl -fsSL https://bun.sh/install | bash +# Install Homebrew +RUN /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + +# Make Homebrew available without eval +ENV PATH="/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:${PATH}" + +# Install toolchain and CLI tools via Homebrew +RUN brew install gcc uv oven-sh/bun/bun diff --git a/.gitignore b/.gitignore index 551af477..ec058fa0 100644 --- a/.gitignore +++ b/.gitignore @@ -143,4 +143,7 @@ vite.config.*.timestamp* vitest.config.*.timestamp* secrets -.adt \ No newline at end of file +.adt +package-lock.json + +.skills diff --git a/README.md b/README.md index c7540b26..c2cc3cf8 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,21 @@ npx nx test npx nx typecheck ``` +## Service Key Drop-In + +For local BTP authentication, you can drop service key JSON files into `.adt/destinations/` in the workspace or `~/.adt/destinations/` in your home directory. + +Example: + +```bash +mkdir -p .adt/destinations +cp ./service-key.json .adt/destinations/TRL.json + +npx adt auth login --sid TRL +``` + +If exactly one such destination exists and no default SID has been stored yet, the CLI uses that SID as the default fallback. + ### Common Commands ```bash diff --git a/packages/adk/src/base/adt.ts b/packages/adk/src/base/adt.ts index 87e8f4d1..c65e618d 100644 --- a/packages/adk/src/base/adt.ts +++ b/packages/adk/src/base/adt.ts @@ -35,6 +35,8 @@ export type { InterfaceResponse as InterfaceResponseUnion, PackageResponse as PackageResponseUnion, TransportGetResponse, + ProgramResponse as ProgramResponseUnion, + FunctionGroupResponse as FunctionGroupResponseUnion, } from '@abapify/adt-client'; // CRUD contract types for typed ADK base model @@ -54,6 +56,8 @@ import type { ClassResponse as _ClassResponse, InterfaceResponse as _InterfaceResponse, PackageResponse as _PackageResponse, + ProgramResponse as _ProgramResponse, + FunctionGroupResponse as _FunctionGroupResponse, } from '@abapify/adt-client'; /** @@ -78,6 +82,16 @@ export type InterfaceResponse = Extract< */ export type PackageResponse = Extract<_PackageResponse, { package: unknown }>; +/** + * Program response type - single root element (no union needed) + */ +export type ProgramResponse = _ProgramResponse; + +/** + * Function group response type - single root element (no union needed) + */ +export type FunctionGroupResponse = _FunctionGroupResponse; + // ============================================ // ADK Contract Proxy // Wraps adt-client contract with ADK-specific interface @@ -110,6 +124,10 @@ export interface AdkContract { readonly core: AdtContracts['core']; /** Repository contracts (search) */ readonly repository: AdtContracts['repository']; + /** Programs contracts */ + readonly programs: AdtContracts['programs']; + /** Functions contracts (function groups) */ + readonly functions: AdtContracts['functions']; } /** @@ -125,5 +143,7 @@ export function createAdkContract(client: AdtClient): AdkContract { cts: client.adt.cts, core: client.adt.core, repository: client.adt.repository, + programs: client.adt.programs, + functions: client.adt.functions, }; } diff --git a/packages/adk/src/base/kinds.ts b/packages/adk/src/base/kinds.ts index 5c4b9537..fe5e54d5 100644 --- a/packages/adk/src/base/kinds.ts +++ b/packages/adk/src/base/kinds.ts @@ -60,6 +60,8 @@ export type AdkKind = import type { AdkClass } from '../objects/repository/clas/clas.model'; import type { AdkInterface } from '../objects/repository/intf/intf.model'; import type { AdkPackage } from '../objects/repository/devc/devc.model'; +import type { AdkProgram } from '../objects/repository/prog/prog.model'; +import type { AdkFunctionGroup } from '../objects/repository/fugr/fugr.model'; import type { AdkTransportRequest, AdkTransportTask, @@ -81,9 +83,13 @@ export type AdkObjectForKind = K extends typeof Class ? AdkInterface : K extends typeof Package ? AdkPackage - : K extends typeof TransportRequest - ? AdkTransportRequest - : K extends typeof TransportTask - ? AdkTransportTask - : // Add more mappings as types are implemented - AdkObject; // fallback + : K extends typeof Program + ? AdkProgram + : K extends typeof FunctionGroup + ? AdkFunctionGroup + : K extends typeof TransportRequest + ? AdkTransportRequest + : K extends typeof TransportTask + ? AdkTransportTask + : // Add more mappings as types are implemented + AdkObject; // fallback diff --git a/packages/adk/src/index.ts b/packages/adk/src/index.ts index 3276fe3f..a93f8236 100644 --- a/packages/adk/src/index.ts +++ b/packages/adk/src/index.ts @@ -42,6 +42,8 @@ export type { ClassResponse, InterfaceResponse, PackageResponse, + ProgramResponse, + FunctionGroupResponse, TransportGetResponse, } from './base/adt'; export { createAdkContract } from './base/adt'; @@ -87,6 +89,20 @@ export type { } from './objects/repository/intf'; export { AdkInterface } from './objects/repository/intf'; +// Program types and class +export type { + AbapProgram, + ProgramXml, // Raw API response type +} from './objects/repository/prog'; +export { AdkProgram } from './objects/repository/prog'; + +// Function group types and class +export type { + AbapFunctionGroup, + FunctionGroupXml, // Raw API response type +} from './objects/repository/fugr'; +export { AdkFunctionGroup } from './objects/repository/fugr'; + // CTS types (legacy complex transport) export type { TransportData, diff --git a/packages/adk/src/objects/repository/fugr/fugr.model.ts b/packages/adk/src/objects/repository/fugr/fugr.model.ts new file mode 100644 index 00000000..9f184e37 --- /dev/null +++ b/packages/adk/src/objects/repository/fugr/fugr.model.ts @@ -0,0 +1,132 @@ +/** + * FUGR - ABAP Function Group + * + * ADK object for ABAP function groups (FUGR). + */ + +import { AdkMainObject } from '../../../base/model'; +import { FunctionGroup as FunctionGroupKind } from '../../../base/kinds'; +import { getGlobalContext } from '../../../base/global-context'; +import type { AdkContext } from '../../../base/context'; + +// Import response type from ADT integration layer +import type { FunctionGroupResponse } from '../../../base/adt'; + +/** + * Function group data type - unwrap from root element + * + * The schema wraps everything in an 'abapFunctionGroup' element, so we unwrap it here + * to provide a flat structure for ADK consumers. + */ +export type FunctionGroupXml = FunctionGroupResponse['abapFunctionGroup']; + +/** + * ADK Function Group object + * + * Inherits from AdkMainObject which provides: + * - AdkObject: name, type, description, version, language, changedBy/At, createdBy/At, links + * - AdkMainObject: package, packageRef, responsible, masterLanguage, masterSystem, abapLanguageVersion + * + * Access function group-specific properties via `data`: + * - data.sourceUri, data.fixPointArithmetic, data.activeUnicodeCheck + */ +export class AdkFunctionGroup extends AdkMainObject< + typeof FunctionGroupKind, + FunctionGroupXml +> { + static readonly kind = FunctionGroupKind; + readonly kind = AdkFunctionGroup.kind; + + // ADT object URI (computed - not in data) + get objectUri(): string { + return `/sap/bc/adt/functions/groups/${encodeURIComponent(this.name.toLowerCase())}`; + } + + // Lazy segments - source code + + async getSource(): Promise { + return this.lazy('source', async () => { + return this.ctx.client.adt.functions.groups.source.main.get(this.name); + }); + } + + // ============================================ + // Source Code Save Methods + // ============================================ + + /** + * Save main source code (top-include) + * Requires object to be locked first + */ + async saveMainSource( + source: string, + options?: { lockHandle?: string; transport?: string }, + ): Promise { + const params = new URLSearchParams(); + if (options?.lockHandle) params.set('lockHandle', options.lockHandle); + if (options?.transport) params.set('corrNr', options.transport); + + await this.ctx.client.fetch( + `/sap/bc/adt/functions/groups/${this.name.toLowerCase()}/source/main${params.toString() ? '?' + params.toString() : ''}`, + { + method: 'PUT', + headers: { 'Content-Type': 'text/plain' }, + body: source, + }, + ); + } + + /** + * Save pending source (set via _pendingSource) + * Used by export workflow after deserialization from abapGit + * Overrides base class method + */ + protected override async savePendingSources(options?: { + lockHandle?: string; + transport?: string; + }): Promise { + const pendingSource = (this as unknown as { _pendingSource?: string }) + ._pendingSource; + if (!pendingSource) return; + + await this.saveMainSource(pendingSource, options); + + // Clear pending source after save + delete (this as unknown as { _pendingSource?: string })._pendingSource; + } + + /** + * Check if object has pending sources to save + * Overrides base class method + */ + protected override hasPendingSources(): boolean { + return !!(this as unknown as { _pendingSource?: string })._pendingSource; + } + + // ============================================ + // CRUD contract config - enables save() + // ============================================ + + protected override get wrapperKey() { + return 'abapFunctionGroup'; + } + // Note: `any` return type is intentional here — this is an established pattern + // in the ADK codebase (see intf.model.ts). The base class defines + // crudContract as `any` to support different contract structures per object type. + protected override get crudContract(): any { + return this.ctx.client.adt.functions.groups; + } + + // ============================================ + // Static Factory Method + // ============================================ + + static async get(name: string, ctx?: AdkContext): Promise { + const context = ctx ?? getGlobalContext(); + return new AdkFunctionGroup(context, name).load(); + } +} + +// Self-register with ADK registry +import { registerObjectType } from '../../../base/registry'; +registerObjectType('FUGR', FunctionGroupKind, AdkFunctionGroup); diff --git a/packages/adk/src/objects/repository/fugr/fugr.types.ts b/packages/adk/src/objects/repository/fugr/fugr.types.ts new file mode 100644 index 00000000..5d177c38 --- /dev/null +++ b/packages/adk/src/objects/repository/fugr/fugr.types.ts @@ -0,0 +1,44 @@ +/** + * FUGR - ABAP Function Group + * + * Public interface for ABAP Function Group objects. + * Based on ADT fugr:abapFunctionGroup XML structure. + */ + +import type { AbapObject } from '../../../base/types'; +import type { AdtObjectReference } from '../../../base/model'; + +/** + * ABAP Function Group interface + * + * Plugins work with this interface - implementation is internal. + * Mirrors ADT fugr:abapFunctionGroup structure. + */ +export interface AbapFunctionGroup extends AbapObject { + readonly kind: 'FunctionGroup'; + + // Core attributes (from adtcore:*) + readonly responsible: string; + readonly masterLanguage: string; + readonly language: string; + readonly version: string; + readonly createdAt: Date; + readonly createdBy: string; + readonly changedAt: Date; + readonly changedBy: string; + + // Source attributes (from abapsource:*) + readonly sourceUri: string; + readonly fixPointArithmetic: boolean; + readonly activeUnicodeCheck: boolean; + + // References + readonly packageRef?: AdtObjectReference; + + // Lazy segments - fetched on demand + + /** + * Get function group top-include source code + */ + getSource(): Promise; +} diff --git a/packages/adk/src/objects/repository/fugr/index.ts b/packages/adk/src/objects/repository/fugr/index.ts new file mode 100644 index 00000000..6ddd1aa6 --- /dev/null +++ b/packages/adk/src/objects/repository/fugr/index.ts @@ -0,0 +1,12 @@ +/** + * FUGR - ABAP Function Group + */ + +// Public types +export type { AbapFunctionGroup } from './fugr.types'; + +// ADK object (internal implementation) +export { AdkFunctionGroup } from './fugr.model'; + +// Schema-inferred type for raw API response +export type { FunctionGroupXml } from './fugr.model'; diff --git a/packages/adk/src/objects/repository/prog/index.ts b/packages/adk/src/objects/repository/prog/index.ts new file mode 100644 index 00000000..6181e3e2 --- /dev/null +++ b/packages/adk/src/objects/repository/prog/index.ts @@ -0,0 +1,12 @@ +/** + * PROG - ABAP Program + */ + +// Public types +export type { AbapProgram } from './prog.types'; + +// ADK object (internal implementation) +export { AdkProgram } from './prog.model'; + +// Schema-inferred type for raw API response +export type { ProgramXml } from './prog.model'; diff --git a/packages/adk/src/objects/repository/prog/prog.model.ts b/packages/adk/src/objects/repository/prog/prog.model.ts new file mode 100644 index 00000000..abe32b3f --- /dev/null +++ b/packages/adk/src/objects/repository/prog/prog.model.ts @@ -0,0 +1,129 @@ +/** + * PROG - ABAP Program + * + * ADK object for ABAP programs (PROG). + */ + +import { AdkMainObject } from '../../../base/model'; +import { Program as ProgramKind } from '../../../base/kinds'; +import { getGlobalContext } from '../../../base/global-context'; +import type { AdkContext } from '../../../base/context'; + +// Import response type from ADT integration layer +import type { ProgramResponse } from '../../../base/adt'; + +/** + * Program data type - unwrap from root element + * + * The schema wraps everything in an 'abapProgram' element, so we unwrap it here + * to provide a flat structure for ADK consumers. + */ +export type ProgramXml = ProgramResponse['abapProgram']; + +/** + * ADK Program object + * + * Inherits from AdkMainObject which provides: + * - AdkObject: name, type, description, version, language, changedBy/At, createdBy/At, links + * - AdkMainObject: package, packageRef, responsible, masterLanguage, masterSystem, abapLanguageVersion + * + * Access program-specific properties via `data`: + * - data.programType, data.sourceUri, data.fixPointArithmetic, data.activeUnicodeCheck + */ +export class AdkProgram extends AdkMainObject { + static readonly kind = ProgramKind; + readonly kind = AdkProgram.kind; + + // ADT object URI (computed - not in data) + get objectUri(): string { + return `/sap/bc/adt/programs/programs/${encodeURIComponent(this.name.toLowerCase())}`; + } + + // Lazy segments - source code + + async getSource(): Promise { + return this.lazy('source', async () => { + return this.ctx.client.adt.programs.programs.source.main.get(this.name); + }); + } + + // ============================================ + // Source Code Save Methods + // ============================================ + + /** + * Save main source code + * Requires object to be locked first + */ + async saveMainSource( + source: string, + options?: { lockHandle?: string; transport?: string }, + ): Promise { + const params = new URLSearchParams(); + if (options?.lockHandle) params.set('lockHandle', options.lockHandle); + if (options?.transport) params.set('corrNr', options.transport); + + await this.ctx.client.fetch( + `/sap/bc/adt/programs/programs/${this.name.toLowerCase()}/source/main${params.toString() ? '?' + params.toString() : ''}`, + { + method: 'PUT', + headers: { 'Content-Type': 'text/plain' }, + body: source, + }, + ); + } + + /** + * Save pending source (set via _pendingSource) + * Used by export workflow after deserialization from abapGit + * Overrides base class method + */ + protected override async savePendingSources(options?: { + lockHandle?: string; + transport?: string; + }): Promise { + const pendingSource = (this as unknown as { _pendingSource?: string }) + ._pendingSource; + if (!pendingSource) return; + + await this.saveMainSource(pendingSource, options); + + // Clear pending source after save + delete (this as unknown as { _pendingSource?: string })._pendingSource; + } + + /** + * Check if object has pending sources to save + * Overrides base class method + */ + protected override hasPendingSources(): boolean { + return !!(this as unknown as { _pendingSource?: string })._pendingSource; + } + + // ============================================ + // CRUD contract config - enables save() + // ============================================ + + protected override get wrapperKey() { + return 'abapProgram'; + } + // Note: `any` return type is intentional here — this is an established pattern + // in the ADK codebase (see intf.model.ts). The base class defines + // crudContract as `any` to support different contract structures per object type. + protected override get crudContract(): any { + return this.ctx.client.adt.programs.programs; + } + + // ============================================ + // Static Factory Method + // ============================================ + + static async get(name: string, ctx?: AdkContext): Promise { + const context = ctx ?? getGlobalContext(); + return new AdkProgram(context, name).load(); + } +} + +// Self-register with ADK registry +import { registerObjectType } from '../../../base/registry'; +registerObjectType('PROG', ProgramKind, AdkProgram); diff --git a/packages/adk/src/objects/repository/prog/prog.types.ts b/packages/adk/src/objects/repository/prog/prog.types.ts new file mode 100644 index 00000000..a770554e --- /dev/null +++ b/packages/adk/src/objects/repository/prog/prog.types.ts @@ -0,0 +1,47 @@ +/** + * PROG - ABAP Program + * + * Public interface for ABAP Program objects. + * Based on ADT prog:abapProgram XML structure. + */ + +import type { AbapObject } from '../../../base/types'; +import type { AdtObjectReference } from '../../../base/model'; + +/** + * ABAP Program interface + * + * Plugins work with this interface - implementation is internal. + * Mirrors ADT prog:abapProgram structure. + */ +export interface AbapProgram extends AbapObject { + readonly kind: 'Program'; + + // Core attributes (from adtcore:*) + readonly responsible: string; + readonly masterLanguage: string; + readonly language: string; + readonly version: string; + readonly createdAt: Date; + readonly createdBy: string; + readonly changedAt: Date; + readonly changedBy: string; + + // Source attributes (from abapsource:*) + readonly sourceUri: string; + readonly fixPointArithmetic: boolean; + readonly activeUnicodeCheck: boolean; + + // PROG specific + readonly programType?: string; + + // References + readonly packageRef?: AdtObjectReference; + + // Lazy segments - fetched on demand + + /** + * Get program source code + */ + getSource(): Promise; +} diff --git a/packages/adt-auth/src/plugins/service-key.ts b/packages/adt-auth/src/plugins/service-key.ts index 80d73d2f..0d0f6b05 100644 --- a/packages/adt-auth/src/plugins/service-key.ts +++ b/packages/adt-auth/src/plugins/service-key.ts @@ -11,6 +11,7 @@ import { generateCodeChallenge, generateState, } from '../utils/pkce'; +import { getCallbackBaseUrl } from '../utils/codespaces'; const DEFAULT_PORT = 3000; const DEFAULT_REDIRECT_PATH = '/callback'; @@ -84,7 +85,7 @@ async function performPkceFlow( const codeChallenge = generateCodeChallenge(codeVerifier); const state = generateState(); - const redirectUri = `http://localhost:${port}${DEFAULT_REDIRECT_PATH}`; + const redirectUri = `${getCallbackBaseUrl(port)}${DEFAULT_REDIRECT_PATH}`; // Build XSUAA authorize URL const authUrl = new URL(`${serviceKey.uaa.url}/oauth/authorize`); diff --git a/packages/adt-auth/src/utils/codespaces.ts b/packages/adt-auth/src/utils/codespaces.ts new file mode 100644 index 00000000..a737dce6 --- /dev/null +++ b/packages/adt-auth/src/utils/codespaces.ts @@ -0,0 +1,18 @@ +/** + * Detects whether the process is running inside a GitHub Codespace. + * When it is, ports cannot be reached at `localhost` from the browser; + * they must be accessed via the forwarded-port URL provided by Codespaces. + * + * @see https://docs.github.com/en/codespaces/developing-in-a-codespace/forwarding-ports-in-your-codespace + */ +export function getCallbackBaseUrl(port: number): string { + const codespaceName = process.env['CODESPACE_NAME']; + const forwardingDomain = + process.env['GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN']; + + if (codespaceName && forwardingDomain) { + return `https://${codespaceName}-${port}.${forwardingDomain}`; + } + + return `http://localhost:${port}`; +} diff --git a/packages/adt-cli/README.md b/packages/adt-cli/README.md index a283273f..e665d628 100644 --- a/packages/adt-cli/README.md +++ b/packages/adt-cli/README.md @@ -21,8 +21,12 @@ npx @abapify/adt-cli ## Quick Start ```bash -# Authenticate with a BTP service key -adt auth login --file ./service-key.json +# Drop a BTP service key into the workspace +mkdir -p .adt/destinations +cp ./service-key.json .adt/destinations/TRL.json + +# Authenticate using the discovered destination +adt auth login --sid TRL # Discover available ADT services adt discovery @@ -47,12 +51,45 @@ adt export package ZTEST_PKG ./oat-ztest_pkg --create --transport NPLK900123 ### Authentication -#### `adt auth login --file ` +#### Auto-discovered service keys + +The CLI automatically scans these folders for BTP service key JSON files: + +- `.adt/destinations/*.json` in the current workspace +- `.adt/service-keys/*.json` in the current workspace +- `.adt/keys/*.json` in the current workspace +- `~/.adt/destinations/*.json` +- `~/.adt/service-keys/*.json` +- `~/.adt/keys/*.json` + +The recommended drop-in location is `.adt/destinations/.json`. + +Example: + +```bash +mkdir -p .adt/destinations +cp ./service-key.json .adt/destinations/TRL.json + +# Explicitly use the discovered destination +adt auth login --sid TRL + +# Or run without --sid and pick TRL from the prompt +adt auth login +``` + +Behavior: + +- The file must be a valid BTP service key JSON containing `systemid`. +- The discovered `systemid` becomes the destination name. +- Configured destinations from `adt.config.ts` still win if the same SID exists in both places. +- If no default SID is stored and exactly one service-key destination is discovered, that SID is used as the default SID fallback. + +#### `adt --service-key auth ` -Authenticate using a BTP service key file (OAuth 2.0 + PKCE). +Authenticate from an explicit BTP service key file for a single command invocation. ```bash -adt auth login --file ./secrets/service-key.json +adt --service-key ./secrets/service-key.json auth status ``` Service key format: diff --git a/packages/adt-cli/project.json b/packages/adt-cli/project.json index bfb5b98b..4f850c49 100644 --- a/packages/adt-cli/project.json +++ b/packages/adt-cli/project.json @@ -10,6 +10,16 @@ }, "tags": [], "targets": { + "test": { + "executor": "nx:run-commands", + "options": { + "command": "vitest run --config vitest.config.ts", + "cwd": "{projectRoot}" + }, + "cache": true, + "inputs": ["default", "^default"], + "outputs": [] + }, "bundle": { "executor": "nx:run-commands", "options": { diff --git a/packages/adt-cli/src/lib/commands/fetch.ts b/packages/adt-cli/src/lib/commands/fetch.ts index 5f68913f..9ffb9c5b 100644 --- a/packages/adt-cli/src/lib/commands/fetch.ts +++ b/packages/adt-cli/src/lib/commands/fetch.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { writeFileSync } from 'fs'; import { getAdtClientV2 } from '../utils/adt-client-v2'; +import { getErrorMessage, printErrorStack } from '../utils/command-helpers'; export const fetchCommand = new Command('fetch') .description('Fetch a URL with authentication (like curl but authenticated)') @@ -74,13 +75,8 @@ export const fetchCommand = new Command('fetch') console.log('\n✅ Done!'); } catch (error) { - console.error( - '❌ Request failed:', - error instanceof Error ? error.message : String(error), - ); - if (error instanceof Error && error.stack) { - console.error('\nStack trace:', error.stack); - } + console.error('❌ Request failed:', getErrorMessage(error)); + printErrorStack(error); process.exit(1); } }); diff --git a/packages/adt-cli/src/lib/commands/import/package.ts b/packages/adt-cli/src/lib/commands/import/package.ts index 2b681680..7012acee 100644 --- a/packages/adt-cli/src/lib/commands/import/package.ts +++ b/packages/adt-cli/src/lib/commands/import/package.ts @@ -2,6 +2,12 @@ import { Command } from 'commander'; import { ImportService } from '../../services/import/service'; import { IconRegistry } from '../../utils/icon-registry'; import { getAdtClientV2 } from '../../utils/adt-client-v2'; +import { + getErrorCode, + getErrorMessage, + getErrorStatus, + printErrorStack, +} from '../../utils/command-helpers'; export const importPackageCommand = new Command('package') .argument('', 'ABAP package name to import') @@ -72,15 +78,9 @@ export const importPackageCommand = new Command('package') console.log(`\n✨ Files written to: ${result.outputPath}`); } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - const errorCode = - error instanceof Error && 'code' in error - ? (error as any).code - : 'UNKNOWN'; - const errorStatus = - error instanceof Error && 'status' in error - ? (error as any).status - : ''; + const errorMsg = getErrorMessage(error); + const errorCode = getErrorCode(error) ?? 'UNKNOWN'; + const errorStatus = getErrorStatus(error) ?? ''; const cause = error instanceof Error && 'cause' in error ? (error as any).cause @@ -95,15 +95,12 @@ export const importPackageCommand = new Command('package') } if (cause) { const causeMsg = cause instanceof Error ? cause.message : String(cause); - const causeCode = - cause instanceof Error && 'code' in cause ? (cause as any).code : ''; + const causeCode = getErrorCode(cause) ?? ''; console.error( ` Cause: ${causeMsg}${causeCode ? ` (${causeCode})` : ''}`, ); } - if (error instanceof Error && error.stack) { - console.error(` Stack: ${error.stack}`); - } + printErrorStack(error); process.exit(1); } }); diff --git a/packages/adt-cli/src/lib/commands/info.ts b/packages/adt-cli/src/lib/commands/info.ts index 14d799e8..d5982a3d 100644 --- a/packages/adt-cli/src/lib/commands/info.ts +++ b/packages/adt-cli/src/lib/commands/info.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { writeFileSync } from 'fs'; import { getAdtClientV2 } from '../utils/adt-client-v2'; +import { getErrorMessage, printErrorStack } from '../utils/command-helpers'; export const infoCommand = new Command('info') .description('Get SAP system and session information') @@ -114,13 +115,8 @@ export const infoCommand = new Command('info') console.log('\n✅ Done!'); } catch (error) { - console.error( - '❌ Failed to fetch information:', - error instanceof Error ? error.message : String(error), - ); - if (error instanceof Error && error.stack) { - console.error('\nStack trace:', error.stack); - } + console.error('❌ Failed to fetch information:', getErrorMessage(error)); + printErrorStack(error); process.exit(1); } }); diff --git a/packages/adt-cli/src/lib/commands/search.ts b/packages/adt-cli/src/lib/commands/search.ts index 4a57a4d6..8a9bf6a6 100644 --- a/packages/adt-cli/src/lib/commands/search.ts +++ b/packages/adt-cli/src/lib/commands/search.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; import { getAdtClientV2 } from '../utils/adt-client-v2'; +import { getErrorMessage, printErrorStack } from '../utils/command-helpers'; export const searchCommand = new Command('search') .description('Search for ABAP objects in the repository') @@ -77,13 +78,8 @@ export const searchCommand = new Command('search') console.log('✅ Search complete!'); } catch (error) { - console.error( - '❌ Search failed:', - error instanceof Error ? error.message : String(error), - ); - if (error instanceof Error && error.stack) { - console.error('\nStack trace:', error.stack); - } + console.error('❌ Search failed:', getErrorMessage(error)); + printErrorStack(error); process.exit(1); } }); diff --git a/packages/adt-cli/src/lib/plugin-loader.ts b/packages/adt-cli/src/lib/plugin-loader.ts index 74de55d2..b9ea4fea 100644 --- a/packages/adt-cli/src/lib/plugin-loader.ts +++ b/packages/adt-cli/src/lib/plugin-loader.ts @@ -8,6 +8,7 @@ import { Command } from 'commander'; import { resolve, dirname } from 'path'; import { existsSync } from 'fs'; +import { getErrorMessage, printErrorStack } from './utils/command-helpers'; import type { CliCommandPlugin, CliContext, @@ -32,7 +33,10 @@ export async function loadCliConfig( const module = await import(configPath); return module.default ?? module; } catch (err) { - console.error(`Failed to load config from ${configPath}:`, err); + console.error( + `Failed to load config from ${configPath}: ${getErrorMessage(err)}`, + ); + printErrorStack(err); return null; } } else { @@ -53,7 +57,10 @@ export async function loadCliConfig( const module = await import(configPath); return module.default ?? module; } catch (err) { - console.error(`Failed to load config from ${configPath}:`, err); + console.error( + `Failed to load config from ${configPath}: ${getErrorMessage(err)}`, + ); + printErrorStack(err); return null; } } @@ -148,7 +155,8 @@ function pluginToCommand( try { await plugin.execute!(args, ctx); } catch (err) { - console.error('Command failed:', err); + console.error(`Command failed: ${getErrorMessage(err)}`); + printErrorStack(err); process.exit(1); } }); @@ -182,7 +190,10 @@ async function loadCommandPlugin( return plugin as CliCommandPlugin; } catch (err) { - console.warn(`Failed to load command plugin: ${modulePath}`, err); + console.warn( + `Failed to load command plugin: ${modulePath} ${getErrorMessage(err)}`, + ); + printErrorStack(err); return null; } } diff --git a/packages/adt-cli/src/lib/utils/auth.ts b/packages/adt-cli/src/lib/utils/auth.ts index cb7c9cf6..82e522bd 100644 --- a/packages/adt-cli/src/lib/utils/auth.ts +++ b/packages/adt-cli/src/lib/utils/auth.ts @@ -6,6 +6,7 @@ */ import { AuthManager } from '@abapify/adt-auth'; +import { listAutoServiceKeyDestinationNames } from './destinations'; // Re-export types from adt-auth export type { @@ -54,11 +55,29 @@ export function listAvailableSids() { // Default SID Management // ============================================================================= +export function resolveDefaultSid( + authenticatedDefaultSid: string | null, + discoveredServiceKeySids: string[], +): string | undefined { + if (authenticatedDefaultSid) { + return authenticatedDefaultSid; + } + + if (discoveredServiceKeySids.length === 1) { + return discoveredServiceKeySids[0]; + } + + return undefined; +} + /** * Get default SID */ export function getDefaultSid() { - return authManager.getDefaultSid() ?? undefined; + return resolveDefaultSid( + authManager.getDefaultSid(), + listAutoServiceKeyDestinationNames(), + ); } /** diff --git a/packages/adt-cli/src/lib/utils/auto-service-keys.test.ts b/packages/adt-cli/src/lib/utils/auto-service-keys.test.ts new file mode 100644 index 00000000..dbc28a07 --- /dev/null +++ b/packages/adt-cli/src/lib/utils/auto-service-keys.test.ts @@ -0,0 +1,109 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + clearConfigCache, + getDestination, + listDestinations, +} from './destinations'; +import { resolveDefaultSid } from './auth'; +import { resetCliContext } from '../shared/adt-client'; + +const SERVICE_KEY_FIXTURE = { + uaa: { + tenantmode: 'dedicated', + sburl: 'https://example.authentication.us10.hana.ondemand.com', + subaccountid: 'subaccount-id', + 'credential-type': 'binding-secret', + clientid: 'client-id', + xsappname: 'xsappname', + clientsecret: 'client-secret', + serviceInstanceId: 'service-instance-id', + url: 'https://example.authentication.us10.hana.ondemand.com', + uaadomain: 'authentication.us10.hana.ondemand.com', + verificationkey: + '-----BEGIN PUBLIC KEY-----\nMIIB\n-----END PUBLIC KEY-----', + apiurl: 'https://api.authentication.us10.hana.ondemand.com', + identityzone: 'example', + identityzoneid: 'identity-zone-id', + tenantid: 'tenant-id', + zoneid: 'zone-id', + }, + url: 'https://example.abap.us10.hana.ondemand.com', + 'sap.cloud.service': 'com.sap.cloud.abap', + systemid: 'TRL', + endpoints: { + abap: 'https://example.abap.us10.hana.ondemand.com', + }, + catalogs: { + abap: { + path: '/sap/opu/odata/IWFND/CATALOGSERVICE;v=2', + type: 'sap_abap_catalog_v1', + }, + }, + binding: { + env: 'cf', + version: '1.0.1.1', + type: 'oauth', + id: 'binding-id', + }, + preserve_host_header: true, +}; + +const tempDirs: string[] = []; +const originalCwd = process.cwd(); + +afterEach(() => { + clearConfigCache(); + resetCliContext(); + delete process.env['ADT_SERVICE_KEY_DIR']; + process.chdir(originalCwd); + + while (tempDirs.length > 0) { + const dirPath = tempDirs.pop(); + if (dirPath) { + rmSync(dirPath, { recursive: true, force: true }); + } + } +}); + +function createWorkspaceWithDiscoveredDestination(): string { + const workspaceDir = mkdtempSync(join(tmpdir(), 'adt-cli-auto-key-')); + tempDirs.push(workspaceDir); + + const destinationsDir = join(workspaceDir, '.adt', 'destinations'); + mkdirSync(destinationsDir, { recursive: true }); + writeFileSync( + join(destinationsDir, 'TRL.json'), + JSON.stringify(SERVICE_KEY_FIXTURE, null, 2), + 'utf8', + ); + + process.chdir(workspaceDir); + return workspaceDir; +} + +describe('auto service key discovery', () => { + it('discovers service-key destinations from .adt/destinations', async () => { + createWorkspaceWithDiscoveredDestination(); + + await expect(listDestinations()).resolves.toContain('TRL'); + + const destination = await getDestination('trl'); + expect(destination).toBeDefined(); + expect(destination?.type).toBe('@abapify/adt-auth/plugins/service-key'); + expect(destination?.options).toMatchObject({ + url: SERVICE_KEY_FIXTURE.url, + serviceKey: { + systemid: 'TRL', + }, + }); + }); + + it('uses a single discovered destination as the default SID fallback', () => { + expect(resolveDefaultSid(null, ['TRL'])).toBe('TRL'); + expect(resolveDefaultSid('BHF', ['TRL'])).toBe('BHF'); + expect(resolveDefaultSid(null, ['TRL', 'QAS'])).toBeUndefined(); + }); +}); diff --git a/packages/adt-cli/src/lib/utils/auto-service-keys.ts b/packages/adt-cli/src/lib/utils/auto-service-keys.ts new file mode 100644 index 00000000..5e379651 --- /dev/null +++ b/packages/adt-cli/src/lib/utils/auto-service-keys.ts @@ -0,0 +1,96 @@ +import { existsSync, readdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { resolve, join } from 'node:path'; +import { readServiceKey, type BTPServiceKey } from '@abapify/adt-auth'; +import type { Destination } from '@abapify/adt-config'; + +export interface ServiceKeyDiscoveryResult { + sid: string; + keyFilePath: string; + serviceKey: BTPServiceKey; +} + +function getCandidateDirectories(): string[] { + const cwd = process.cwd(); + const home = homedir(); + const envDir = process.env['ADT_SERVICE_KEY_DIR']?.trim(); + + const candidates = [ + envDir, + resolve(cwd, '.adt', 'destinations'), + resolve(cwd, '.adt', 'service-keys'), + resolve(cwd, '.adt', 'keys'), + resolve(home, '.adt', 'destinations'), + resolve(home, '.adt', 'service-keys'), + resolve(home, '.adt', 'keys'), + ].filter((value): value is string => Boolean(value)); + + return [...new Set(candidates)]; +} + +function getCandidateFilesFromDir(dirPath: string): string[] { + if (!existsSync(dirPath)) { + return []; + } + + try { + const entries = readdirSync(dirPath, { withFileTypes: true }); + return entries + .filter( + (entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.json'), + ) + .map((entry) => join(dirPath, entry.name)) + .sort((a, b) => a.localeCompare(b)); + } catch { + return []; + } +} + +export function discoverServiceKeys(): ServiceKeyDiscoveryResult[] { + const discoveredBySid = new Map(); + + for (const dirPath of getCandidateDirectories()) { + const files = getCandidateFilesFromDir(dirPath); + + for (const filePath of files) { + try { + const serviceKey = readServiceKey(filePath); + const sid = serviceKey.systemid?.toUpperCase(); + + if (!sid || discoveredBySid.has(sid)) { + continue; + } + + discoveredBySid.set(sid, { + sid, + keyFilePath: filePath, + serviceKey, + }); + } catch { + // Ignore invalid/non-service-key JSON files in drop-in folders. + } + } + } + + return [...discoveredBySid.values()]; +} + +export function discoverServiceKeyDestinations(): Map { + const destinations = new Map(); + + for (const discovered of discoverServiceKeys()) { + destinations.set(discovered.sid, { + type: '@abapify/adt-auth/plugins/service-key', + options: { + url: discovered.serviceKey.url, + serviceKey: discovered.serviceKey, + }, + }); + } + + return destinations; +} + +export function listDiscoveredServiceKeySids(): string[] { + return discoverServiceKeys().map((entry) => entry.sid); +} diff --git a/packages/adt-cli/src/lib/utils/command-helpers.ts b/packages/adt-cli/src/lib/utils/command-helpers.ts index 9ceb32f8..9af269a9 100644 --- a/packages/adt-cli/src/lib/utils/command-helpers.ts +++ b/packages/adt-cli/src/lib/utils/command-helpers.ts @@ -2,6 +2,14 @@ import { Command } from 'commander'; import { createCliLogger } from './logger-config'; import type { Logger } from '@abapify/logger'; +interface ErrorWithCode { + code?: unknown; +} + +interface ErrorWithStatus { + status?: unknown; +} + /** * Extract global options from a command by traversing up to the root */ @@ -36,9 +44,62 @@ export function createComponentLogger( * Standard error handler for commands */ export function handleCommandError(error: unknown, operation: string): never { - console.error( - `❌ ${operation} failed:`, - error instanceof Error ? error.message : String(error), - ); + console.error(`❌ ${operation} failed:`, getErrorMessage(error)); + printErrorStack(error); process.exit(1); } + +/** + * Extract a user-facing error message from unknown error values. + */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Extract error code if present. + */ +export function getErrorCode(error: unknown): string | undefined { + if (!(error instanceof Error)) { + return undefined; + } + + const code = (error as ErrorWithCode).code; + if (typeof code === 'string' && code.length > 0) { + return code; + } + + return undefined; +} + +/** + * Extract HTTP status if present. + */ +export function getErrorStatus(error: unknown): string | undefined { + if (!(error instanceof Error)) { + return undefined; + } + + const status = (error as ErrorWithStatus).status; + if (typeof status === 'string' && status.length > 0) { + return status; + } + if (typeof status === 'number') { + return String(status); + } + + return undefined; +} + +/** + * Print stack trace only when explicitly requested. + */ +export function printErrorStack(error: unknown): void { + if (process.env['ADT_CLI_SHOW_STACK'] !== '1') { + return; + } + + if (error instanceof Error && error.stack) { + console.error('\nStack trace:', error.stack); + } +} diff --git a/packages/adt-cli/src/lib/utils/destinations.ts b/packages/adt-cli/src/lib/utils/destinations.ts index 53c236c5..c60b61e0 100644 --- a/packages/adt-cli/src/lib/utils/destinations.ts +++ b/packages/adt-cli/src/lib/utils/destinations.ts @@ -10,6 +10,7 @@ import { type Destination, } from '@abapify/adt-config'; import { getCliContext } from '../shared/adt-client'; +import { discoverServiceKeyDestinations } from './auto-service-keys'; // Cached config instance (keyed by configPath to handle different configs) let cachedConfig: LoadedConfig | null = null; @@ -43,7 +44,13 @@ export async function getDestination( name: string, ): Promise { const config = await getConfig(); - return config.getDestination(name); + const configured = config.getDestination(name); + if (configured) { + return configured; + } + + const upperName = name.toUpperCase(); + return discoverServiceKeyDestinations().get(upperName); } /** @@ -51,7 +58,17 @@ export async function getDestination( */ export async function listDestinations(): Promise { const config = await getConfig(); - return config.listDestinations(); + const configured = config.listDestinations(); + const discovered = listAutoServiceKeyDestinationNames(); + const configuredUpper = new Set(configured.map((name) => name.toUpperCase())); + + for (const sid of discovered) { + if (!configuredUpper.has(sid)) { + configured.push(sid); + } + } + + return configured; } /** @@ -59,7 +76,18 @@ export async function listDestinations(): Promise { */ export async function hasDestination(name: string): Promise { const config = await getConfig(); - return config.hasDestination(name); + if (config.hasDestination(name)) { + return true; + } + + return discoverServiceKeyDestinations().has(name.toUpperCase()); +} + +/** + * List SID names for auto-discovered service-key destinations. + */ +export function listAutoServiceKeyDestinationNames(): string[] { + return [...discoverServiceKeyDestinations().keys()]; } /** diff --git a/packages/adt-cli/vitest.config.ts b/packages/adt-cli/vitest.config.ts new file mode 100644 index 00000000..f9e5eb4b --- /dev/null +++ b/packages/adt-cli/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + globals: true, + environment: 'node', + }, +}); diff --git a/packages/adt-client/src/index.ts b/packages/adt-client/src/index.ts index 347b44f7..9bb17fd3 100644 --- a/packages/adt-client/src/index.ts +++ b/packages/adt-client/src/index.ts @@ -67,6 +67,8 @@ export type { // This allows ADK to depend only on adt-client, not adt-contracts directly export type { ClassResponse, InterfaceResponse } from '@abapify/adt-contracts'; export type { Package as PackageResponse } from '@abapify/adt-contracts'; +export type { ProgramResponse } from '@abapify/adt-contracts'; +export type { FunctionGroupResponse } from '@abapify/adt-contracts'; // Transport response type - exported directly from contracts // Note: Transport business logic has moved to @abapify/adk (AdkTransportRequest) diff --git a/packages/adt-contracts/src/adt/functions/groups.ts b/packages/adt-contracts/src/adt/functions/groups.ts new file mode 100644 index 00000000..99a1ec7f --- /dev/null +++ b/packages/adt-contracts/src/adt/functions/groups.ts @@ -0,0 +1,37 @@ +/** + * ADT Functions Groups Contract + * + * Endpoint: /sap/bc/adt/functions/groups + * Full CRUD operations for ABAP function groups including source code management. + */ + +import { crud } from '../../base'; +import { abapFunctionGroup, type InferTypedSchema } from '../../schemas'; + +/** + * Function group response type - exported for consumers (ADK, etc.) + * + * This is the canonical type for function group metadata. + * Uses pre-generated type from adt-schemas. + */ +export type FunctionGroupResponse = InferTypedSchema; + +/** + * /sap/bc/adt/functions/groups + * Full CRUD operations for ABAP function groups + * + * Includes: + * - Basic CRUD: get, post, put, delete + * - Lock/Unlock: lock, unlock + * - Object structure: objectstructure + * - Source code: source.main.get/put + */ +export const functionGroupsContract = crud({ + basePath: '/sap/bc/adt/functions/groups', + schema: abapFunctionGroup, + contentType: 'application/vnd.sap.adt.functions.groups.v3+xml', + sources: ['main'] as const, +}); + +/** Type alias for the function groups contract */ +export type FunctionGroupsContract = typeof functionGroupsContract; diff --git a/packages/adt-contracts/src/adt/functions/index.ts b/packages/adt-contracts/src/adt/functions/index.ts new file mode 100644 index 00000000..b40d8c4f --- /dev/null +++ b/packages/adt-contracts/src/adt/functions/index.ts @@ -0,0 +1,25 @@ +/** + * ADT Functions Contracts + * + * Structure mirrors URL tree: + * - /sap/bc/adt/functions/groups → functions.groups + */ + +export { + functionGroupsContract, + type FunctionGroupsContract, + type FunctionGroupResponse, +} from './groups'; + +import { functionGroupsContract } from './groups'; + +/** + * Functions Contract type definition + */ +export interface FunctionsContract { + groups: typeof functionGroupsContract; +} + +export const functionsContract: FunctionsContract = { + groups: functionGroupsContract, +}; diff --git a/packages/adt-contracts/src/adt/index.ts b/packages/adt-contracts/src/adt/index.ts index 67f2c0a1..275a1ff4 100644 --- a/packages/adt-contracts/src/adt/index.ts +++ b/packages/adt-contracts/src/adt/index.ts @@ -10,6 +10,8 @@ export * from './discovery'; export * from './packages'; export * from './core'; export * from './repository'; +export * from './programs'; +export * from './functions'; /** * Complete ADT Contract @@ -22,6 +24,11 @@ import { discoveryContract, type DiscoveryContract } from './discovery'; import { packagesContract, type PackagesContract } from './packages'; import { coreContract, type CoreContract } from './core'; import { repositoryContract, type RepositoryContract } from './repository'; +import { + programsModuleContract, + type ProgramsModuleContract, +} from './programs'; +import { functionsContract, type FunctionsContract } from './functions'; /** * Explicit type to avoid TS7056 "inferred type exceeds maximum length" @@ -35,6 +42,8 @@ export interface AdtContract { packages: PackagesContract; core: CoreContract; repository: RepositoryContract; + programs: ProgramsModuleContract; + functions: FunctionsContract; } export const adtContract: AdtContract = { @@ -46,6 +55,8 @@ export const adtContract: AdtContract = { packages: packagesContract, core: coreContract, repository: repositoryContract, + programs: programsModuleContract, + functions: functionsContract, }; // Import RestClient from base for client type definition diff --git a/packages/adt-contracts/src/adt/programs/index.ts b/packages/adt-contracts/src/adt/programs/index.ts new file mode 100644 index 00000000..364bc3aa --- /dev/null +++ b/packages/adt-contracts/src/adt/programs/index.ts @@ -0,0 +1,25 @@ +/** + * ADT Programs Contracts + * + * Structure mirrors URL tree: + * - /sap/bc/adt/programs/programs → programs.programs + */ + +export { + programsContract, + type ProgramsContract, + type ProgramResponse, +} from './programs'; + +import { programsContract } from './programs'; + +/** + * Programs Contract type definition + */ +export interface ProgramsModuleContract { + programs: typeof programsContract; +} + +export const programsModuleContract: ProgramsModuleContract = { + programs: programsContract, +}; diff --git a/packages/adt-contracts/src/adt/programs/programs.ts b/packages/adt-contracts/src/adt/programs/programs.ts new file mode 100644 index 00000000..4ec46e06 --- /dev/null +++ b/packages/adt-contracts/src/adt/programs/programs.ts @@ -0,0 +1,37 @@ +/** + * ADT Programs Contract + * + * Endpoint: /sap/bc/adt/programs/programs + * Full CRUD operations for ABAP programs including source code management. + */ + +import { crud } from '../../base'; +import { abapProgram, type InferTypedSchema } from '../../schemas'; + +/** + * Program response type - exported for consumers (ADK, etc.) + * + * This is the canonical type for program metadata. + * Uses pre-generated type from adt-schemas. + */ +export type ProgramResponse = InferTypedSchema; + +/** + * /sap/bc/adt/programs/programs + * Full CRUD operations for ABAP programs + * + * Includes: + * - Basic CRUD: get, post, put, delete + * - Lock/Unlock: lock, unlock + * - Object structure: objectstructure + * - Source code: source.main.get/put + */ +export const programsContract = crud({ + basePath: '/sap/bc/adt/programs/programs', + schema: abapProgram, + contentType: 'application/vnd.sap.adt.programs.programs.v2+xml', + sources: ['main'] as const, +}); + +/** Type alias for the programs contract */ +export type ProgramsContract = typeof programsContract; diff --git a/packages/adt-contracts/src/generated/schemas.ts b/packages/adt-contracts/src/generated/schemas.ts index fb4819d2..25c90b67 100644 --- a/packages/adt-contracts/src/generated/schemas.ts +++ b/packages/adt-contracts/src/generated/schemas.ts @@ -53,6 +53,8 @@ export const transportmanagmentSingle = toSpeciSchema( export const transportsearch = toSpeciSchema(adtSchemas.transportsearch); export const aunitRun = toSpeciSchema(adtSchemas.aunitRun); export const aunitResult = toSpeciSchema(adtSchemas.aunitResult); +export const abapProgram = toSpeciSchema(adtSchemas.abapProgram); +export const abapFunctionGroup = toSpeciSchema(adtSchemas.abapFunctionGroup); // ============================================================================ // JSON Schemas (re-exported directly - they use zod, not ts-xsd) diff --git a/packages/adt-fixtures/src/fixtures/functions/functionGroup.xml b/packages/adt-fixtures/src/fixtures/functions/functionGroup.xml new file mode 100644 index 00000000..bcc7c98e --- /dev/null +++ b/packages/adt-fixtures/src/fixtures/functions/functionGroup.xml @@ -0,0 +1,40 @@ + + + + + + + diff --git a/packages/adt-fixtures/src/fixtures/programs/program.xml b/packages/adt-fixtures/src/fixtures/programs/program.xml new file mode 100644 index 00000000..2491dd75 --- /dev/null +++ b/packages/adt-fixtures/src/fixtures/programs/program.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/packages/adt-fixtures/src/fixtures/registry.ts b/packages/adt-fixtures/src/fixtures/registry.ts index cb1a9d2f..cd889822 100644 --- a/packages/adt-fixtures/src/fixtures/registry.ts +++ b/packages/adt-fixtures/src/fixtures/registry.ts @@ -27,6 +27,12 @@ export const registry = { class: 'oo/class.xml', interface: 'oo/interface.xml', }, + programs: { + program: 'programs/program.xml', + }, + functions: { + functionGroup: 'functions/functionGroup.xml', + }, core: { http: { session: 'core/http/session.xml', diff --git a/packages/adt-plugin-abapgit/src/lib/handlers/adk.ts b/packages/adt-plugin-abapgit/src/lib/handlers/adk.ts index 0281aad6..ee2d13d4 100644 --- a/packages/adt-plugin-abapgit/src/lib/handlers/adk.ts +++ b/packages/adt-plugin-abapgit/src/lib/handlers/adk.ts @@ -5,7 +5,13 @@ */ // Classes (used as values in createHandler) -export { AdkClass, AdkInterface, AdkPackage } from '@abapify/adk'; +export { + AdkClass, + AdkInterface, + AdkPackage, + AdkProgram, + AdkFunctionGroup, +} from '@abapify/adk'; // Types export type { AdkObject, ClassIncludeType } from '@abapify/adk'; diff --git a/packages/adt-plugin-abapgit/src/lib/handlers/objects/fugr.ts b/packages/adt-plugin-abapgit/src/lib/handlers/objects/fugr.ts new file mode 100644 index 00000000..3b5d6e91 --- /dev/null +++ b/packages/adt-plugin-abapgit/src/lib/handlers/objects/fugr.ts @@ -0,0 +1,41 @@ +/** + * Function Group (FUGR) object handler for abapGit format + */ + +import { AdkFunctionGroup } from '../adk'; +import { fugr } from '../../../schemas/generated'; +import { createHandler } from '../base'; + +export const functionGroupHandler = createHandler(AdkFunctionGroup, { + schema: fugr, + version: 'v1.0.0', + serializer: 'LCL_OBJECT_FUGR', + serializer_version: 'v1.0.0', + + // SAP → Git: Map ADK object to abapGit values + toAbapGit: (obj) => ({ + AREAT: obj.description ?? '', + }), + + // Single source file (top-include) + getSource: (obj) => obj.getSource(), + + // Git → SAP: Map abapGit values to ADK data + // Note: FUGR doesn't have the group name in AREAT field; name comes from filename + fromAbapGit: ({ AREAT }) => ({ + name: '', // Function group name must be set by deserializer from filename + type: 'FUGR/F', + description: AREAT, + }), + + // Git → SAP: Set source files on ADK object + // Note: `_pendingSource` is not declared on the public ADK interface — + // it is an implementation detail for deferred source saving. + // The `as unknown as` double cast is the established pattern in this codebase. + setSources: (obj, sources) => { + if (sources.main) { + (obj as unknown as { _pendingSource: string })._pendingSource = + sources.main; + } + }, +}); diff --git a/packages/adt-plugin-abapgit/src/lib/handlers/objects/index.ts b/packages/adt-plugin-abapgit/src/lib/handlers/objects/index.ts index 145391f1..77757277 100644 --- a/packages/adt-plugin-abapgit/src/lib/handlers/objects/index.ts +++ b/packages/adt-plugin-abapgit/src/lib/handlers/objects/index.ts @@ -7,4 +7,5 @@ export { classHandler } from './clas'; export { interfaceHandler } from './intf'; export { packageHandler } from './devc'; -// NOTE: Add domainHandler and dataElementHandler when ADK v2 support is ready +export { programHandler } from './prog'; +export { functionGroupHandler } from './fugr'; diff --git a/packages/adt-plugin-abapgit/src/lib/handlers/objects/prog.ts b/packages/adt-plugin-abapgit/src/lib/handlers/objects/prog.ts new file mode 100644 index 00000000..afd645fb --- /dev/null +++ b/packages/adt-plugin-abapgit/src/lib/handlers/objects/prog.ts @@ -0,0 +1,45 @@ +/** + * Program (PROG) object handler for abapGit format + */ + +import { AdkProgram } from '../adk'; +import { prog } from '../../../schemas/generated'; +import { createHandler } from '../base'; + +export const programHandler = createHandler(AdkProgram, { + schema: prog, + version: 'v1.0.0', + serializer: 'LCL_OBJECT_PROG', + serializer_version: 'v1.0.0', + + // SAP → Git: Map ADK object to abapGit values + toAbapGit: (obj) => ({ + TRDIR: { + NAME: obj.name ?? '', + SECU: 'S', + EDTX: 'X', + SUBC: '1', + }, + }), + + // Single source file + getSource: (obj) => obj.getSource(), + + // Git → SAP: Map abapGit values to ADK data + fromAbapGit: ({ TRDIR }) => ({ + name: (TRDIR?.NAME ?? '').toUpperCase(), + type: 'PROG/P', + description: undefined, + }), + + // Git → SAP: Set source files on ADK object + // Note: `_pendingSource` is not declared on the public ADK interface — + // it is an implementation detail for deferred source saving. + // The `as unknown as` double cast is the established pattern in this codebase. + setSources: (obj, sources) => { + if (sources.main) { + (obj as unknown as { _pendingSource: string })._pendingSource = + sources.main; + } + }, +}); diff --git a/packages/adt-plugin-abapgit/src/schemas/generated/index.ts b/packages/adt-plugin-abapgit/src/schemas/generated/index.ts index 2e29e858..b7e47d7c 100644 --- a/packages/adt-plugin-abapgit/src/schemas/generated/index.ts +++ b/packages/adt-plugin-abapgit/src/schemas/generated/index.ts @@ -18,7 +18,9 @@ import _clas from './schemas/clas'; import _devc from './schemas/devc'; import _doma from './schemas/doma'; import _dtel from './schemas/dtel'; +import _fugr from './schemas/fugr'; import _intf from './schemas/intf'; +import _prog from './schemas/prog'; // Full AbapGit types - using flattened root types // Note: Generated types may be unions, we import the raw schema type @@ -26,14 +28,18 @@ import type { ClasSchema as _ClasSchema } from './types/clas'; import type { DevcSchema as _DevcSchema } from './types/devc'; import type { DomaSchema as _DomaSchema } from './types/doma'; import type { DtelSchema as _DtelSchema } from './types/dtel'; +import type { FugrSchema as _FugrSchema } from './types/fugr'; import type { IntfSchema as _IntfSchema } from './types/intf'; +import type { ProgSchema as _ProgSchema } from './types/prog'; // Extract the abapGit variant from union types (generated types may be unions) type ClasAbapGitType = Extract<_ClasSchema, { abapGit: unknown }>; type DevcAbapGitType = Extract<_DevcSchema, { abapGit: unknown }>; type DomaAbapGitType = Extract<_DomaSchema, { abapGit: unknown }>; type DtelAbapGitType = Extract<_DtelSchema, { abapGit: unknown }>; +type FugrAbapGitType = Extract<_FugrSchema, { abapGit: unknown }>; type IntfAbapGitType = Extract<_IntfSchema, { abapGit: unknown }>; +type ProgAbapGitType = Extract<_ProgSchema, { abapGit: unknown }>; // AbapGit schema instances - using flattened types with values extracted from abapGit.abap.values export const clas = abapGitSchema< @@ -52,10 +58,18 @@ export const dtel = abapGitSchema< DtelAbapGitType, DtelAbapGitType['abapGit']['abap']['values'] >(_dtel); +export const fugr = abapGitSchema< + FugrAbapGitType, + FugrAbapGitType['abapGit']['abap']['values'] +>(_fugr); export const intf = abapGitSchema< IntfAbapGitType, IntfAbapGitType['abapGit']['abap']['values'] >(_intf); +export const prog = abapGitSchema< + ProgAbapGitType, + ProgAbapGitType['abapGit']['abap']['values'] +>(_prog); // Re-export types and utilities export { diff --git a/packages/adt-plugin-abapgit/src/schemas/generated/schemas/fugr.ts b/packages/adt-plugin-abapgit/src/schemas/generated/schemas/fugr.ts new file mode 100644 index 00000000..f1bc3623 --- /dev/null +++ b/packages/adt-plugin-abapgit/src/schemas/generated/schemas/fugr.ts @@ -0,0 +1,86 @@ +/** + * Auto-generated schema from XSD + * + * DO NOT EDIT - Generated by ts-xsd codegen + * Source: abapgit/fugr.xsd + */ + +export default { + $xmlns: { + xs: 'http://www.w3.org/2001/XMLSchema', + asx: 'http://www.sap.com/abapxml', + }, + targetNamespace: 'http://www.sap.com/abapxml', + elementFormDefault: 'unqualified', + element: [ + { + name: 'abapGit', + complexType: { + sequence: { + element: [ + { + ref: 'asx:abap', + }, + ], + }, + attribute: [ + { + name: 'version', + type: 'xs:string', + use: 'required', + }, + { + name: 'serializer', + type: 'xs:string', + use: 'required', + }, + { + name: 'serializer_version', + type: 'xs:string', + use: 'required', + }, + ], + }, + }, + { + name: 'Schema', + abstract: true, + }, + { + name: 'abap', + type: 'asx:AbapType', + }, + ], + complexType: [ + { + name: 'AbapValuesType', + all: { + element: [ + { + name: 'AREAT', + type: 'xs:string', + minOccurs: '0', + }, + ], + }, + }, + { + name: 'AbapType', + sequence: { + element: [ + { + name: 'values', + type: 'asx:AbapValuesType', + }, + ], + }, + attribute: [ + { + name: 'version', + type: 'xs:string', + default: '1.0', + }, + ], + }, + ], +} as const; diff --git a/packages/adt-plugin-abapgit/src/schemas/generated/schemas/prog.ts b/packages/adt-plugin-abapgit/src/schemas/generated/schemas/prog.ts new file mode 100644 index 00000000..31b1a6a0 --- /dev/null +++ b/packages/adt-plugin-abapgit/src/schemas/generated/schemas/prog.ts @@ -0,0 +1,147 @@ +/** + * Auto-generated schema from XSD + * + * DO NOT EDIT - Generated by ts-xsd codegen + * Source: abapgit/prog.xsd + */ + +export default { + $xmlns: { + xs: 'http://www.w3.org/2001/XMLSchema', + asx: 'http://www.sap.com/abapxml', + }, + targetNamespace: 'http://www.sap.com/abapxml', + elementFormDefault: 'unqualified', + element: [ + { + name: 'abapGit', + complexType: { + sequence: { + element: [ + { + ref: 'asx:abap', + }, + ], + }, + attribute: [ + { + name: 'version', + type: 'xs:string', + use: 'required', + }, + { + name: 'serializer', + type: 'xs:string', + use: 'required', + }, + { + name: 'serializer_version', + type: 'xs:string', + use: 'required', + }, + ], + }, + }, + { + name: 'Schema', + abstract: true, + }, + { + name: 'abap', + type: 'asx:AbapType', + }, + ], + complexType: [ + { + name: 'AbapValuesType', + all: { + element: [ + { + name: 'TRDIR', + type: 'asx:TrdirType', + minOccurs: '0', + }, + ], + }, + }, + { + name: 'TrdirType', + all: { + element: [ + { + name: 'NAME', + type: 'xs:string', + }, + { + name: 'SECU', + type: 'xs:string', + minOccurs: '0', + }, + { + name: 'EDTX', + type: 'xs:string', + minOccurs: '0', + }, + { + name: 'SUBC', + type: 'xs:string', + minOccurs: '0', + }, + { + name: 'APPL', + type: 'xs:string', + minOccurs: '0', + }, + { + name: 'RSTAT', + type: 'xs:string', + minOccurs: '0', + }, + { + name: 'RMAND', + type: 'xs:string', + minOccurs: '0', + }, + { + name: 'RLOAD', + type: 'xs:string', + minOccurs: '0', + }, + { + name: 'FIXPT', + type: 'xs:string', + minOccurs: '0', + }, + { + name: 'UCCHECK', + type: 'xs:string', + minOccurs: '0', + }, + { + name: 'ABAP_LANGUAGE_VERSION', + type: 'xs:string', + minOccurs: '0', + }, + ], + }, + }, + { + name: 'AbapType', + sequence: { + element: [ + { + name: 'values', + type: 'asx:AbapValuesType', + }, + ], + }, + attribute: [ + { + name: 'version', + type: 'xs:string', + default: '1.0', + }, + ], + }, + ], +} as const; diff --git a/packages/adt-plugin-abapgit/src/schemas/generated/types/fugr.ts b/packages/adt-plugin-abapgit/src/schemas/generated/types/fugr.ts new file mode 100644 index 00000000..4e4066bd --- /dev/null +++ b/packages/adt-plugin-abapgit/src/schemas/generated/types/fugr.ts @@ -0,0 +1,29 @@ +/** + * Auto-generated TypeScript interfaces from XSD + * DO NOT EDIT - Generated by ts-xsd codegen + * Source: abapgit/fugr.xsd + * Mode: Flattened + */ + +export type FugrSchema = + | { + abapGit: { + abap: { + values: { + AREAT?: string; + }; + version?: string; + }; + version: string; + serializer: string; + serializer_version: string; + }; + } + | { + abap: { + values: { + AREAT?: string; + }; + version?: string; + }; + }; diff --git a/packages/adt-plugin-abapgit/src/schemas/generated/types/prog.ts b/packages/adt-plugin-abapgit/src/schemas/generated/types/prog.ts new file mode 100644 index 00000000..01c246e4 --- /dev/null +++ b/packages/adt-plugin-abapgit/src/schemas/generated/types/prog.ts @@ -0,0 +1,53 @@ +/** + * Auto-generated TypeScript interfaces from XSD + * DO NOT EDIT - Generated by ts-xsd codegen + * Source: abapgit/prog.xsd + * Mode: Flattened + */ + +export type ProgSchema = + | { + abapGit: { + abap: { + values: { + TRDIR?: { + NAME: string; + SECU?: string; + EDTX?: string; + SUBC?: string; + APPL?: string; + RSTAT?: string; + RMAND?: string; + RLOAD?: string; + FIXPT?: string; + UCCHECK?: string; + ABAP_LANGUAGE_VERSION?: string; + }; + }; + version?: string; + }; + version: string; + serializer: string; + serializer_version: string; + }; + } + | { + abap: { + values: { + TRDIR?: { + NAME: string; + SECU?: string; + EDTX?: string; + SUBC?: string; + APPL?: string; + RSTAT?: string; + RMAND?: string; + RLOAD?: string; + FIXPT?: string; + UCCHECK?: string; + ABAP_LANGUAGE_VERSION?: string; + }; + }; + version?: string; + }; + }; diff --git a/packages/adt-schemas/src/schemas/generated/schemas/sap/abapFunctionGroup.ts b/packages/adt-schemas/src/schemas/generated/schemas/sap/abapFunctionGroup.ts new file mode 100644 index 00000000..ef97461d --- /dev/null +++ b/packages/adt-schemas/src/schemas/generated/schemas/sap/abapFunctionGroup.ts @@ -0,0 +1,38 @@ +/** + * Auto-generated schema from XSD + * + * DO NOT EDIT - Generated by ts-xsd codegen + * Source: xsd/sap/abapFunctionGroup.xsd + */ + +import adtcore from './adtcore'; +import abapsource from './abapsource'; + +export default { + $xmlns: { + adtcore: 'http://www.sap.com/adt/core', + abapsource: 'http://www.sap.com/adt/abapsource', + fugr: 'http://www.sap.com/adt/functions', + xsd: 'http://www.w3.org/2001/XMLSchema', + }, + $imports: [adtcore, abapsource], + targetNamespace: 'http://www.sap.com/adt/functions', + attributeFormDefault: 'qualified', + elementFormDefault: 'qualified', + element: [ + { + name: 'abapFunctionGroup', + type: 'fugr:AbapFunctionGroup', + }, + ], + complexType: [ + { + name: 'AbapFunctionGroup', + complexContent: { + extension: { + base: 'abapsource:AbapSourceMainObject', + }, + }, + }, + ], +} as const; diff --git a/packages/adt-schemas/src/schemas/generated/schemas/sap/abapProgram.ts b/packages/adt-schemas/src/schemas/generated/schemas/sap/abapProgram.ts new file mode 100644 index 00000000..b6258bcb --- /dev/null +++ b/packages/adt-schemas/src/schemas/generated/schemas/sap/abapProgram.ts @@ -0,0 +1,44 @@ +/** + * Auto-generated schema from XSD + * + * DO NOT EDIT - Generated by ts-xsd codegen + * Source: xsd/sap/abapProgram.xsd + */ + +import adtcore from './adtcore'; +import abapsource from './abapsource'; + +export default { + $xmlns: { + adtcore: 'http://www.sap.com/adt/core', + abapsource: 'http://www.sap.com/adt/abapsource', + prog: 'http://www.sap.com/adt/programs', + xsd: 'http://www.w3.org/2001/XMLSchema', + }, + $imports: [adtcore, abapsource], + targetNamespace: 'http://www.sap.com/adt/programs', + attributeFormDefault: 'qualified', + elementFormDefault: 'qualified', + element: [ + { + name: 'abapProgram', + type: 'prog:AbapProgram', + }, + ], + complexType: [ + { + name: 'AbapProgram', + complexContent: { + extension: { + base: 'abapsource:AbapSourceMainObject', + attribute: [ + { + name: 'programType', + type: 'xsd:string', + }, + ], + }, + }, + }, + ], +} as const; diff --git a/packages/adt-schemas/src/schemas/generated/typed.ts b/packages/adt-schemas/src/schemas/generated/typed.ts index 4e81aeeb..5412f5e3 100644 --- a/packages/adt-schemas/src/schemas/generated/typed.ts +++ b/packages/adt-schemas/src/schemas/generated/typed.ts @@ -41,6 +41,8 @@ import type { TracesSchema } from './types/sap/traces.types'; import type { QuickfixesSchema } from './types/sap/quickfixes.types'; import type { LogSchema } from './types/sap/log.types'; import type { TemplatelinkSchema } from './types/sap/templatelink.types'; +import type { AbapProgramSchema } from './types/sap/abapProgram.types'; +import type { AbapFunctionGroupSchema } from './types/sap/abapFunctionGroup.types'; import type { AtomExtendedSchema } from './types/custom/atomExtended.types'; import type { DiscoverySchema } from './types/custom/discovery.types'; import type { HttpSchema } from './types/custom/http.types'; @@ -131,6 +133,12 @@ export const log: TypedSchema = typedSchema(_log); import _templatelink from './schemas/sap/templatelink'; export const templatelink: TypedSchema = typedSchema(_templatelink); +import _abapProgram from './schemas/sap/abapProgram'; +export const abapProgram: TypedSchema = + typedSchema(_abapProgram); +import _abapFunctionGroup from './schemas/sap/abapFunctionGroup'; +export const abapFunctionGroup: TypedSchema = + typedSchema(_abapFunctionGroup); // Custom schemas import _atomExtended from './schemas/custom/atomExtended'; diff --git a/packages/adt-schemas/src/schemas/generated/types/sap/abapFunctionGroup.types.ts b/packages/adt-schemas/src/schemas/generated/types/sap/abapFunctionGroup.types.ts new file mode 100644 index 00000000..370c893b --- /dev/null +++ b/packages/adt-schemas/src/schemas/generated/types/sap/abapFunctionGroup.types.ts @@ -0,0 +1,81 @@ +/** + * Auto-generated TypeScript interfaces from XSD + * DO NOT EDIT - Generated by ts-xsd codegen + * Source: xsd/sap/abapFunctionGroup.xsd + * Mode: Flattened + */ + +export type AbapFunctionGroupSchema = { + abapFunctionGroup: { + containerRef?: { + extension?: unknown; + uri?: string; + parentUri?: string; + type?: string; + name?: string; + packageName?: string; + description?: string; + }; + adtTemplate?: { + adtProperty?: { + $value?: string; + key?: string; + }[]; + name?: string; + }; + packageRef?: { + extension?: unknown; + uri?: string; + parentUri?: string; + type?: string; + name?: string; + packageName?: string; + description?: string; + }; + template?: { + property?: { + $value?: string; + key?: string; + }[]; + name?: string; + }; + syntaxConfiguration?: { + language?: { + version?: string; + description?: string; + }; + objectUsage?: { + restricted?: boolean; + }; + }; + name: string; + type: string; + changedBy?: string; + changedAt?: string; + createdAt?: string; + createdBy?: string; + version?: + | '' + | 'active' + | 'inactive' + | 'workingArea' + | 'new' + | 'partlyActive' + | 'activeWithInactiveVersion'; + description?: string; + descriptionTextLimit?: number; + language?: string; + masterSystem?: string; + masterLanguage?: string; + responsible?: string; + abapLanguageVersion?: string; + sourceUri?: string; + sourceObjectStatus?: + | 'SAPStandardProduction' + | 'customerProduction' + | 'system' + | 'test'; + fixPointArithmetic?: boolean; + activeUnicodeCheck?: boolean; + }; +}; diff --git a/packages/adt-schemas/src/schemas/generated/types/sap/abapProgram.types.ts b/packages/adt-schemas/src/schemas/generated/types/sap/abapProgram.types.ts new file mode 100644 index 00000000..455a627d --- /dev/null +++ b/packages/adt-schemas/src/schemas/generated/types/sap/abapProgram.types.ts @@ -0,0 +1,82 @@ +/** + * Auto-generated TypeScript interfaces from XSD + * DO NOT EDIT - Generated by ts-xsd codegen + * Source: xsd/sap/abapProgram.xsd + * Mode: Flattened + */ + +export type AbapProgramSchema = { + abapProgram: { + containerRef?: { + extension?: unknown; + uri?: string; + parentUri?: string; + type?: string; + name?: string; + packageName?: string; + description?: string; + }; + adtTemplate?: { + adtProperty?: { + $value?: string; + key?: string; + }[]; + name?: string; + }; + packageRef?: { + extension?: unknown; + uri?: string; + parentUri?: string; + type?: string; + name?: string; + packageName?: string; + description?: string; + }; + template?: { + property?: { + $value?: string; + key?: string; + }[]; + name?: string; + }; + syntaxConfiguration?: { + language?: { + version?: string; + description?: string; + }; + objectUsage?: { + restricted?: boolean; + }; + }; + name: string; + type: string; + changedBy?: string; + changedAt?: string; + createdAt?: string; + createdBy?: string; + version?: + | '' + | 'active' + | 'inactive' + | 'workingArea' + | 'new' + | 'partlyActive' + | 'activeWithInactiveVersion'; + description?: string; + descriptionTextLimit?: number; + language?: string; + masterSystem?: string; + masterLanguage?: string; + responsible?: string; + abapLanguageVersion?: string; + sourceUri?: string; + sourceObjectStatus?: + | 'SAPStandardProduction' + | 'customerProduction' + | 'system' + | 'test'; + fixPointArithmetic?: boolean; + activeUnicodeCheck?: boolean; + programType?: string; + }; +};