abapgit-agent 1.7.2 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +26 -8
  2. package/abap/CLAUDE.md +146 -26
  3. package/abap/guidelines/00_index.md +8 -0
  4. package/abap/guidelines/01_sql.md +28 -0
  5. package/abap/guidelines/02_exceptions.md +8 -0
  6. package/abap/guidelines/03_testing.md +8 -0
  7. package/abap/guidelines/04_cds.md +151 -36
  8. package/abap/guidelines/05_classes.md +8 -0
  9. package/abap/guidelines/06_objects.md +8 -0
  10. package/abap/guidelines/07_json.md +8 -0
  11. package/abap/guidelines/08_abapgit.md +52 -3
  12. package/abap/guidelines/09_unit_testable_code.md +8 -0
  13. package/abap/guidelines/10_common_errors.md +95 -0
  14. package/bin/abapgit-agent +61 -2852
  15. package/package.json +21 -5
  16. package/src/agent.js +205 -16
  17. package/src/commands/create.js +102 -0
  18. package/src/commands/delete.js +72 -0
  19. package/src/commands/health.js +24 -0
  20. package/src/commands/help.js +111 -0
  21. package/src/commands/import.js +99 -0
  22. package/src/commands/init.js +321 -0
  23. package/src/commands/inspect.js +184 -0
  24. package/src/commands/list.js +143 -0
  25. package/src/commands/preview.js +277 -0
  26. package/src/commands/pull.js +278 -0
  27. package/src/commands/ref.js +96 -0
  28. package/src/commands/status.js +52 -0
  29. package/src/commands/syntax.js +340 -0
  30. package/src/commands/tree.js +209 -0
  31. package/src/commands/unit.js +133 -0
  32. package/src/commands/view.js +215 -0
  33. package/src/commands/where.js +138 -0
  34. package/src/config.js +11 -1
  35. package/src/utils/abap-http.js +347 -0
  36. package/src/utils/git-utils.js +58 -0
  37. package/src/utils/validators.js +72 -0
  38. package/src/utils/version-check.js +80 -0
  39. package/src/abap-client.js +0 -526
  40. /package/src/{ref-search.js → utils/abap-reference.js} +0 -0
@@ -0,0 +1,347 @@
1
+ /**
2
+ * ABAP HTTP request wrapper with CSRF token and session management
3
+ */
4
+ const https = require('https');
5
+ const http = require('http');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
9
+ const crypto = require('crypto');
10
+
11
+ /**
12
+ * ABAP HTTP client with CSRF token, cookie, and session caching
13
+ */
14
+ class AbapHttp {
15
+ constructor(config) {
16
+ this.config = config;
17
+ this.csrfToken = null;
18
+ this.cookies = null;
19
+
20
+ // Session cache file path
21
+ const configHash = crypto.createHash('md5')
22
+ .update(`${config.host}:${config.user}:${config.client}`)
23
+ .digest('hex')
24
+ .substring(0, 8);
25
+
26
+ this.sessionFile = path.join(os.tmpdir(), `abapgit-session-${configHash}.json`);
27
+
28
+ // Try to load cached session
29
+ this.loadSession();
30
+ }
31
+
32
+ /**
33
+ * Load session from cache file if valid
34
+ */
35
+ loadSession() {
36
+ if (!fs.existsSync(this.sessionFile)) {
37
+ return;
38
+ }
39
+
40
+ try {
41
+ const session = JSON.parse(fs.readFileSync(this.sessionFile, 'utf8'));
42
+
43
+ // Check if expired (with 2-minute safety margin)
44
+ const now = Date.now();
45
+ const safetyMargin = 2 * 60 * 1000; // 2 minutes
46
+
47
+ if (session.expiresAt > now + safetyMargin) {
48
+ this.csrfToken = session.csrfToken;
49
+ this.cookies = session.cookies;
50
+ // Silent - no console output for cached session
51
+ } else {
52
+ // Session expired
53
+ this.clearSession();
54
+ }
55
+ } catch (e) {
56
+ // Corrupted cache file - clear it
57
+ this.clearSession();
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Save session to cache file
63
+ */
64
+ saveSession() {
65
+ // Conservative expiration: 15 minutes
66
+ // (ABAP default session timeout is typically 20 minutes)
67
+ const expiresAt = Date.now() + (15 * 60 * 1000);
68
+
69
+ try {
70
+ fs.writeFileSync(this.sessionFile, JSON.stringify({
71
+ csrfToken: this.csrfToken,
72
+ cookies: this.cookies,
73
+ expiresAt,
74
+ savedAt: Date.now()
75
+ }));
76
+ } catch (e) {
77
+ // Ignore write errors - session caching is optional
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Clear cached session
83
+ */
84
+ clearSession() {
85
+ this.csrfToken = null;
86
+ this.cookies = null;
87
+
88
+ try {
89
+ if (fs.existsSync(this.sessionFile)) {
90
+ fs.unlinkSync(this.sessionFile);
91
+ }
92
+ } catch (e) {
93
+ // Ignore file deletion errors
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Detect if error is due to expired/invalid session
99
+ * @param {Error|object} error - Error object or response
100
+ * @returns {boolean} True if auth error
101
+ */
102
+ isAuthError(error) {
103
+ // HTTP status codes
104
+ if (error.statusCode === 401) return true; // Unauthorized
105
+ if (error.statusCode === 403) return true; // Forbidden
106
+
107
+ // Error message patterns
108
+ const message = (error.message || error.error || '').toLowerCase();
109
+ if (message.includes('csrf')) return true;
110
+ if (message.includes('token')) return true;
111
+ if (message.includes('session')) return true;
112
+ if (message.includes('expired')) return true;
113
+ if (message.includes('unauthorized')) return true;
114
+ if (message.includes('forbidden')) return true;
115
+
116
+ // Response body patterns
117
+ if (error.body) {
118
+ const bodyStr = JSON.stringify(error.body).toLowerCase();
119
+ if (bodyStr.includes('csrf')) return true;
120
+ if (bodyStr.includes('session')) return true;
121
+ if (bodyStr.includes('expired')) return true;
122
+ }
123
+
124
+ return false;
125
+ }
126
+
127
+ /**
128
+ * Fetch CSRF token using GET /health with X-CSRF-Token: fetch
129
+ * @returns {Promise<string>} CSRF token
130
+ */
131
+ async fetchCsrfToken() {
132
+ const url = new URL(`/sap/bc/z_abapgit_agent/health`, `https://${this.config.host}:${this.config.sapport}`);
133
+
134
+ return new Promise((resolve, reject) => {
135
+ const options = {
136
+ hostname: url.hostname,
137
+ port: url.port,
138
+ path: url.pathname,
139
+ method: 'GET',
140
+ headers: {
141
+ 'Authorization': `Basic ${Buffer.from(`${this.config.user}:${this.config.password}`).toString('base64')}`,
142
+ 'sap-client': this.config.client,
143
+ 'sap-language': this.config.language || 'EN',
144
+ 'X-CSRF-Token': 'fetch',
145
+ 'Content-Type': 'application/json'
146
+ },
147
+ agent: new https.Agent({ rejectUnauthorized: false })
148
+ };
149
+
150
+ const req = https.request(options, (res) => {
151
+ const csrfToken = res.headers['x-csrf-token'];
152
+
153
+ // Save cookies from response
154
+ const setCookie = res.headers['set-cookie'];
155
+ if (setCookie) {
156
+ this.cookies = Array.isArray(setCookie)
157
+ ? setCookie.map(c => c.split(';')[0]).join('; ')
158
+ : setCookie.split(';')[0];
159
+ }
160
+
161
+ let body = '';
162
+ res.on('data', chunk => body += chunk);
163
+ res.on('end', () => {
164
+ this.csrfToken = csrfToken;
165
+
166
+ // Save session to cache
167
+ this.saveSession();
168
+
169
+ resolve(csrfToken);
170
+ });
171
+ });
172
+
173
+ req.on('error', reject);
174
+ req.end();
175
+ });
176
+ }
177
+
178
+ /**
179
+ * Make HTTP request to ABAP REST endpoint with automatic retry on auth failure
180
+ * @param {string} method - HTTP method (GET, POST, DELETE)
181
+ * @param {string} urlPath - URL path
182
+ * @param {object} data - Request body (for POST)
183
+ * @param {object} options - Additional options (headers, csrfToken, isRetry)
184
+ * @returns {Promise<object>} Response JSON
185
+ */
186
+ async request(method, urlPath, data = null, options = {}) {
187
+ try {
188
+ // Try with current session (cached or fresh)
189
+ return await this._makeRequest(method, urlPath, data, options);
190
+
191
+ } catch (error) {
192
+ // Check if it's an authentication/authorization failure
193
+ if (this.isAuthError(error) && !options.isRetry) {
194
+ // Session expired - refresh and retry once
195
+ console.error('⚠️ Session expired, refreshing...');
196
+
197
+ // Clear stale session
198
+ this.clearSession();
199
+
200
+ // Fetch fresh token/cookies
201
+ await this.fetchCsrfToken();
202
+
203
+ // Retry ONCE with fresh session
204
+ return await this._makeRequest(method, urlPath, data, {
205
+ ...options,
206
+ isRetry: true // Prevent infinite loop
207
+ });
208
+ }
209
+
210
+ // Not an auth error or already retried - propagate
211
+ throw error;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Internal request implementation (no retry logic)
217
+ * @param {string} method - HTTP method
218
+ * @param {string} urlPath - URL path
219
+ * @param {object} data - Request body
220
+ * @param {object} options - Additional options
221
+ * @returns {Promise<object>} Response JSON
222
+ */
223
+ async _makeRequest(method, urlPath, data = null, options = {}) {
224
+ return new Promise((resolve, reject) => {
225
+ const url = new URL(urlPath, `https://${this.config.host}:${this.config.sapport}`);
226
+
227
+ const headers = {
228
+ 'Content-Type': 'application/json',
229
+ 'sap-client': this.config.client,
230
+ 'sap-language': this.config.language || 'EN',
231
+ ...options.headers
232
+ };
233
+
234
+ // Add authorization
235
+ headers['Authorization'] = `Basic ${Buffer.from(`${this.config.user}:${this.config.password}`).toString('base64')}`;
236
+
237
+ // Add CSRF token for POST
238
+ if (method === 'POST' && options.csrfToken) {
239
+ headers['X-CSRF-Token'] = options.csrfToken;
240
+ }
241
+
242
+ // Add cookies if available
243
+ if (this.cookies) {
244
+ headers['Cookie'] = this.cookies;
245
+ }
246
+
247
+ const reqOptions = {
248
+ hostname: url.hostname,
249
+ port: url.port,
250
+ path: url.pathname + url.search,
251
+ method,
252
+ headers,
253
+ agent: new https.Agent({ rejectUnauthorized: false })
254
+ };
255
+
256
+ const req = (url.protocol === 'https:' ? https : http).request(reqOptions, (res) => {
257
+ // Check for auth errors
258
+ if (res.statusCode === 401 || res.statusCode === 403) {
259
+ reject({
260
+ statusCode: res.statusCode,
261
+ message: `Authentication failed: ${res.statusCode}`,
262
+ isAuthError: true
263
+ });
264
+ return;
265
+ }
266
+
267
+ let body = '';
268
+ res.on('data', chunk => body += chunk);
269
+ res.on('end', () => {
270
+ try {
271
+ // Handle unescaped newlines from ABAP - replace actual newlines with \n
272
+ const cleanedBody = body.replace(/\n/g, '\\n');
273
+ const parsed = JSON.parse(cleanedBody);
274
+
275
+ // Check for CSRF/session errors in response body
276
+ if (this.isAuthError(parsed)) {
277
+ reject({
278
+ statusCode: res.statusCode,
279
+ message: 'CSRF token or session error',
280
+ body: parsed,
281
+ isAuthError: true
282
+ });
283
+ return;
284
+ }
285
+
286
+ resolve(parsed);
287
+ } catch (e) {
288
+ // Fallback: try to extract JSON from response
289
+ const jsonMatch = body.match(/\{[\s\S]*\}/);
290
+ if (jsonMatch) {
291
+ try {
292
+ const parsed = JSON.parse(jsonMatch[0].replace(/\n/g, '\\n'));
293
+ resolve(parsed);
294
+ } catch (e2) {
295
+ resolve({ raw: body, error: e2.message });
296
+ }
297
+ } else {
298
+ resolve({ raw: body, error: e.message });
299
+ }
300
+ }
301
+ });
302
+ });
303
+
304
+ req.on('error', reject);
305
+
306
+ if (data) {
307
+ req.write(JSON.stringify(data));
308
+ }
309
+ req.end();
310
+ });
311
+ }
312
+
313
+ /**
314
+ * Convenience method for GET requests
315
+ * @param {string} urlPath - URL path
316
+ * @param {object} options - Additional options
317
+ * @returns {Promise<object>} Response JSON
318
+ */
319
+ async get(urlPath, options = {}) {
320
+ return this.request('GET', urlPath, null, options);
321
+ }
322
+
323
+ /**
324
+ * Convenience method for POST requests
325
+ * @param {string} urlPath - URL path
326
+ * @param {object} data - Request body
327
+ * @param {object} options - Additional options (must include csrfToken)
328
+ * @returns {Promise<object>} Response JSON
329
+ */
330
+ async post(urlPath, data, options = {}) {
331
+ return this.request('POST', urlPath, data, options);
332
+ }
333
+
334
+ /**
335
+ * Convenience method for DELETE requests
336
+ * @param {string} urlPath - URL path
337
+ * @param {object} options - Additional options
338
+ * @returns {Promise<object>} Response JSON
339
+ */
340
+ async delete(urlPath, options = {}) {
341
+ return this.request('DELETE', urlPath, null, options);
342
+ }
343
+ }
344
+
345
+ module.exports = {
346
+ AbapHttp
347
+ };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Git-related utilities
3
+ */
4
+ const pathModule = require('path');
5
+ const fs = require('fs');
6
+
7
+ /**
8
+ * Get git remote URL from .git/config
9
+ * @returns {string|null} Git remote URL or null if not found
10
+ */
11
+ function getRemoteUrl() {
12
+ const gitConfigPath = pathModule.join(process.cwd(), '.git', 'config');
13
+
14
+ if (!fs.existsSync(gitConfigPath)) {
15
+ return null;
16
+ }
17
+
18
+ const content = fs.readFileSync(gitConfigPath, 'utf8');
19
+ const remoteMatch = content.match(/\[remote "origin"\]/);
20
+
21
+ if (!remoteMatch) {
22
+ return null;
23
+ }
24
+
25
+ const urlMatch = content.match(/ url = (.+)/);
26
+ return urlMatch ? urlMatch[1].trim() : null;
27
+ }
28
+
29
+ /**
30
+ * Get current git branch from .git/HEAD
31
+ * @returns {string} Current branch name or 'main' as default
32
+ */
33
+ function getBranch() {
34
+ const headPath = pathModule.join(process.cwd(), '.git', 'HEAD');
35
+
36
+ if (!fs.existsSync(headPath)) {
37
+ return 'main';
38
+ }
39
+
40
+ const content = fs.readFileSync(headPath, 'utf8').trim();
41
+ const match = content.match(/ref: refs\/heads\/(.+)/);
42
+ return match ? match[1] : 'main';
43
+ }
44
+
45
+ /**
46
+ * Check if current directory is a git repository
47
+ * @returns {boolean} True if .git directory exists
48
+ */
49
+ function isGitRepo() {
50
+ const gitPath = pathModule.join(process.cwd(), '.git');
51
+ return fs.existsSync(gitPath);
52
+ }
53
+
54
+ module.exports = {
55
+ getRemoteUrl,
56
+ getBranch,
57
+ isGitRepo
58
+ };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Input validation utilities
3
+ */
4
+
5
+ /**
6
+ * Convert ISO date formats (YYYY-MM-DD) to ABAP DATS format (YYYYMMDD) in WHERE clause
7
+ * This allows users to use familiar ISO date formats while ensuring compatibility with ABAP SQL
8
+ * @param {string} whereClause - SQL WHERE clause
9
+ * @returns {string} - WHERE clause with dates converted to YYYYMMDD format
10
+ */
11
+ function convertDatesInWhereClause(whereClause) {
12
+ if (!whereClause) return whereClause;
13
+
14
+ // Pattern to match ISO date format: 'YYYY-MM-DD'
15
+ const isoDatePattern = /'\d{4}-\d{2}-\d{2}'/g;
16
+
17
+ return whereClause.replace(isoDatePattern, (match) => {
18
+ // Extract YYYY, MM, DD from 'YYYY-MM-DD'
19
+ const dateContent = match.slice(1, -1); // Remove quotes: YYYY-MM-DD
20
+ const [year, month, day] = dateContent.split('-');
21
+ // Return in ABAP format: 'YYYYMMDD'
22
+ return `'${year}${month}${day}'`;
23
+ });
24
+ }
25
+
26
+ /**
27
+ * Validate package name format
28
+ * @param {string} packageName - ABAP package name
29
+ * @returns {boolean} True if valid
30
+ */
31
+ function isValidPackageName(packageName) {
32
+ if (!packageName) return false;
33
+ // Package names: $PACKAGE or ZPACKAGE (can contain _ and alphanumeric)
34
+ return /^(\$|Z|Y)[A-Z0-9_]{0,29}$/i.test(packageName);
35
+ }
36
+
37
+ /**
38
+ * Validate object name format
39
+ * @param {string} objectName - ABAP object name
40
+ * @returns {boolean} True if valid
41
+ */
42
+ function isValidObjectName(objectName) {
43
+ if (!objectName) return false;
44
+ // Object names: up to 30 characters, alphanumeric + underscore
45
+ return /^[A-Z0-9_/]{1,30}$/i.test(objectName);
46
+ }
47
+
48
+ /**
49
+ * Parse file path to extract object type and name
50
+ * @param {string} filePath - File path (e.g., "src/zcl_my_class.clas.abap")
51
+ * @returns {object|null} {type, name} or null if invalid
52
+ */
53
+ function parseObjectFromFile(filePath) {
54
+ const fileName = filePath.split('/').pop();
55
+
56
+ // Match patterns: zcl_class.clas.abap, zif_intf.intf.abap, etc.
57
+ const match = fileName.match(/^([a-z0-9_]+)\.(clas|intf|prog|fugr|ddls|tabl|dtel|ttyp|stru)\..*$/i);
58
+
59
+ if (!match) return null;
60
+
61
+ return {
62
+ name: match[1].toUpperCase(),
63
+ type: match[2].toUpperCase()
64
+ };
65
+ }
66
+
67
+ module.exports = {
68
+ convertDatesInWhereClause,
69
+ isValidPackageName,
70
+ isValidObjectName,
71
+ parseObjectFromFile
72
+ };
@@ -0,0 +1,80 @@
1
+ /**
2
+ * CLI <> ABAP version compatibility check
3
+ */
4
+ const pathModule = require('path');
5
+ const fs = require('fs');
6
+ const https = require('https');
7
+
8
+ /**
9
+ * Get CLI version from package.json
10
+ * @returns {string} CLI version or '1.0.0' as default
11
+ */
12
+ function getCliVersion() {
13
+ const packageJsonPath = pathModule.join(__dirname, '..', '..', 'package.json');
14
+ if (fs.existsSync(packageJsonPath)) {
15
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
16
+ return pkg.version || '1.0.0';
17
+ }
18
+ return '1.0.0';
19
+ }
20
+
21
+ /**
22
+ * Check version compatibility between CLI and ABAP API
23
+ * @param {object} config - ABAP connection config
24
+ * @returns {Promise<object>} Version compatibility result
25
+ */
26
+ async function checkCompatibility(config) {
27
+ const cliVersion = getCliVersion();
28
+
29
+ try {
30
+ const url = new URL(`/sap/bc/z_abapgit_agent/health`, `https://${config.host}:${config.sapport}`);
31
+
32
+ return new Promise((resolve) => {
33
+ const options = {
34
+ hostname: url.hostname,
35
+ port: url.port,
36
+ path: url.pathname,
37
+ method: 'GET',
38
+ headers: {
39
+ 'Authorization': `Basic ${Buffer.from(`${config.user}:${config.password}`).toString('base64')}`,
40
+ 'sap-client': config.client,
41
+ 'sap-language': config.language || 'EN',
42
+ 'Content-Type': 'application/json'
43
+ },
44
+ agent: new https.Agent({ rejectUnauthorized: false })
45
+ };
46
+
47
+ const req = https.request(options, (res) => {
48
+ let body = '';
49
+ res.on('data', chunk => body += chunk);
50
+ res.on('end', () => {
51
+ try {
52
+ const result = JSON.parse(body);
53
+ const apiVersion = result.version || '1.0.0';
54
+
55
+ if (cliVersion !== apiVersion) {
56
+ console.log(`\n⚠️ Version mismatch: CLI ${cliVersion}, ABAP API ${apiVersion}`);
57
+ console.log(' Some commands may not work correctly.');
58
+ console.log(' Update ABAP code: abapgit-agent pull\n');
59
+ }
60
+ resolve({ cliVersion, apiVersion, compatible: cliVersion === apiVersion });
61
+ } catch (e) {
62
+ resolve({ cliVersion, apiVersion: null, compatible: false, error: e.message });
63
+ }
64
+ });
65
+ });
66
+
67
+ req.on('error', (e) => {
68
+ resolve({ cliVersion, apiVersion: null, compatible: false, error: e.message });
69
+ });
70
+ req.end();
71
+ });
72
+ } catch (error) {
73
+ return { cliVersion, apiVersion: null, compatible: false, error: error.message };
74
+ }
75
+ }
76
+
77
+ module.exports = {
78
+ getCliVersion,
79
+ checkCompatibility
80
+ };