@ktmcp-cli/nordigen 1.0.0

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.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Configuration Management
3
+ *
4
+ * @fileoverview Manages CLI configuration and credentials storage
5
+ * @module lib/config
6
+ */
7
+
8
+ import Conf from 'conf';
9
+ import { homedir } from 'os';
10
+ import { join } from 'path';
11
+
12
+ /**
13
+ * Configuration schema
14
+ */
15
+ const schema = {
16
+ auth: {
17
+ type: 'object',
18
+ properties: {
19
+ secret_id: { type: 'string' },
20
+ secret_key: { type: 'string' },
21
+ access_token: { type: 'string' },
22
+ refresh_token: { type: 'string' },
23
+ access_expires: { type: 'number' },
24
+ refresh_expires: { type: 'number' },
25
+ },
26
+ },
27
+ defaults: {
28
+ type: 'object',
29
+ properties: {
30
+ country: { type: 'string' },
31
+ institution_id: { type: 'string' },
32
+ },
33
+ },
34
+ };
35
+
36
+ let configInstance = null;
37
+
38
+ /**
39
+ * Get or create config instance
40
+ *
41
+ * @returns {Conf}
42
+ */
43
+ export function getConfig() {
44
+ if (!configInstance) {
45
+ configInstance = new Conf({
46
+ projectName: 'nordigen-cli',
47
+ schema,
48
+ configFileMode: 0o600, // Read/write for owner only
49
+ });
50
+ }
51
+ return configInstance;
52
+ }
53
+
54
+ /**
55
+ * Get configuration value
56
+ *
57
+ * @param {string} key - Configuration key (dot notation supported)
58
+ * @param {*} [defaultValue] - Default value if key doesn't exist
59
+ * @returns {*}
60
+ */
61
+ export function get(key, defaultValue) {
62
+ return getConfig().get(key, defaultValue);
63
+ }
64
+
65
+ /**
66
+ * Set configuration value
67
+ *
68
+ * @param {string} key - Configuration key (dot notation supported)
69
+ * @param {*} value - Value to set
70
+ */
71
+ export function set(key, value) {
72
+ getConfig().set(key, value);
73
+ }
74
+
75
+ /**
76
+ * Delete configuration value
77
+ *
78
+ * @param {string} key - Configuration key
79
+ */
80
+ export function del(key) {
81
+ getConfig().delete(key);
82
+ }
83
+
84
+ /**
85
+ * Clear all configuration
86
+ */
87
+ export function clear() {
88
+ getConfig().clear();
89
+ }
90
+
91
+ /**
92
+ * Get all configuration
93
+ *
94
+ * @returns {Object}
95
+ */
96
+ export function getAll() {
97
+ return getConfig().store;
98
+ }
99
+
100
+ /**
101
+ * Check if access token is valid (not expired)
102
+ *
103
+ * @returns {boolean}
104
+ */
105
+ export function isAccessTokenValid() {
106
+ const config = getConfig();
107
+ const token = config.get('auth.access_token');
108
+ const expires = config.get('auth.access_expires');
109
+
110
+ if (!token || !expires) {
111
+ return false;
112
+ }
113
+
114
+ // Check if token expires in more than 60 seconds
115
+ const now = Math.floor(Date.now() / 1000);
116
+ return expires > (now + 60);
117
+ }
118
+
119
+ /**
120
+ * Check if refresh token is valid (not expired)
121
+ *
122
+ * @returns {boolean}
123
+ */
124
+ export function isRefreshTokenValid() {
125
+ const config = getConfig();
126
+ const token = config.get('auth.refresh_token');
127
+ const expires = config.get('auth.refresh_expires');
128
+
129
+ if (!token || !expires) {
130
+ return false;
131
+ }
132
+
133
+ // Check if token expires in more than 60 seconds
134
+ const now = Math.floor(Date.now() / 1000);
135
+ return expires > (now + 60);
136
+ }
137
+
138
+ /**
139
+ * Get config file path
140
+ *
141
+ * @returns {string}
142
+ */
143
+ export function getConfigPath() {
144
+ return getConfig().path;
145
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Output Formatting
3
+ *
4
+ * @fileoverview Utilities for formatting and displaying CLI output
5
+ * @module lib/output
6
+ */
7
+
8
+ import chalk from 'chalk';
9
+ import { format as formatDate } from 'date-fns';
10
+
11
+ /**
12
+ * Output formats
13
+ */
14
+ export const OutputFormat = {
15
+ JSON: 'json',
16
+ TABLE: 'table',
17
+ LIST: 'list',
18
+ };
19
+
20
+ /**
21
+ * Print JSON output
22
+ *
23
+ * @param {*} data - Data to output
24
+ * @param {boolean} [pretty=true] - Pretty print
25
+ */
26
+ export function printJSON(data, pretty = true) {
27
+ if (pretty) {
28
+ console.log(JSON.stringify(data, null, 2));
29
+ } else {
30
+ console.log(JSON.stringify(data));
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Print success message
36
+ *
37
+ * @param {string} message - Success message
38
+ */
39
+ export function printSuccess(message) {
40
+ console.log(chalk.green('✓'), message);
41
+ }
42
+
43
+ /**
44
+ * Print error message
45
+ *
46
+ * @param {string} message - Error message
47
+ * @param {Error} [error] - Error object
48
+ */
49
+ export function printError(message, error = null) {
50
+ console.error(chalk.red('✗'), message);
51
+ if (error && process.env.DEBUG) {
52
+ console.error(chalk.gray(error.stack));
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Print warning message
58
+ *
59
+ * @param {string} message - Warning message
60
+ */
61
+ export function printWarning(message) {
62
+ console.log(chalk.yellow('⚠'), message);
63
+ }
64
+
65
+ /**
66
+ * Print info message
67
+ *
68
+ * @param {string} message - Info message
69
+ */
70
+ export function printInfo(message) {
71
+ console.log(chalk.blue('ℹ'), message);
72
+ }
73
+
74
+ /**
75
+ * Print table row
76
+ *
77
+ * @param {string} label - Row label
78
+ * @param {string} value - Row value
79
+ * @param {number} [labelWidth=20] - Label column width
80
+ */
81
+ export function printRow(label, value, labelWidth = 20) {
82
+ const paddedLabel = label.padEnd(labelWidth);
83
+ console.log(`${chalk.cyan(paddedLabel)} ${value}`);
84
+ }
85
+
86
+ /**
87
+ * Print section header
88
+ *
89
+ * @param {string} title - Section title
90
+ */
91
+ export function printSection(title) {
92
+ console.log();
93
+ console.log(chalk.bold.underline(title));
94
+ console.log();
95
+ }
96
+
97
+ /**
98
+ * Format currency amount
99
+ *
100
+ * @param {number|string} amount - Amount
101
+ * @param {string} [currency='EUR'] - Currency code
102
+ * @returns {string}
103
+ */
104
+ export function formatCurrency(amount, currency = 'EUR') {
105
+ const num = typeof amount === 'string' ? parseFloat(amount) : amount;
106
+ return new Intl.NumberFormat('en-US', {
107
+ style: 'currency',
108
+ currency: currency,
109
+ }).format(num);
110
+ }
111
+
112
+ /**
113
+ * Format date
114
+ *
115
+ * @param {string|Date} date - Date to format
116
+ * @param {string} [format='yyyy-MM-dd'] - Date format
117
+ * @returns {string}
118
+ */
119
+ export function formatDateString(date, format = 'yyyy-MM-dd') {
120
+ if (!date) return 'N/A';
121
+ const d = typeof date === 'string' ? new Date(date) : date;
122
+ return formatDate(d, format);
123
+ }
124
+
125
+ /**
126
+ * Format relative time
127
+ *
128
+ * @param {string|Date} date - Date to format
129
+ * @returns {string}
130
+ */
131
+ export function formatRelativeTime(date) {
132
+ if (!date) return 'N/A';
133
+
134
+ const d = typeof date === 'string' ? new Date(date) : date;
135
+ const now = new Date();
136
+ const diffMs = now - d;
137
+ const diffSec = Math.floor(diffMs / 1000);
138
+ const diffMin = Math.floor(diffSec / 60);
139
+ const diffHour = Math.floor(diffMin / 60);
140
+ const diffDay = Math.floor(diffHour / 24);
141
+
142
+ if (diffSec < 60) return 'just now';
143
+ if (diffMin < 60) return `${diffMin} minute${diffMin > 1 ? 's' : ''} ago`;
144
+ if (diffHour < 24) return `${diffHour} hour${diffHour > 1 ? 's' : ''} ago`;
145
+ if (diffDay < 7) return `${diffDay} day${diffDay > 1 ? 's' : ''} ago`;
146
+
147
+ return formatDate(d, 'yyyy-MM-dd');
148
+ }
149
+
150
+ /**
151
+ * Format seconds to human-readable duration
152
+ *
153
+ * @param {number} seconds - Duration in seconds
154
+ * @returns {string}
155
+ */
156
+ export function formatDuration(seconds) {
157
+ if (seconds < 60) return `${seconds} second${seconds !== 1 ? 's' : ''}`;
158
+ if (seconds < 3600) {
159
+ const mins = Math.floor(seconds / 60);
160
+ return `${mins} minute${mins !== 1 ? 's' : ''}`;
161
+ }
162
+ if (seconds < 86400) {
163
+ const hours = Math.floor(seconds / 3600);
164
+ return `${hours} hour${hours !== 1 ? 's' : ''}`;
165
+ }
166
+ const days = Math.floor(seconds / 86400);
167
+ return `${days} day${days !== 1 ? 's' : ''}`;
168
+ }
169
+
170
+ /**
171
+ * Print account summary
172
+ *
173
+ * @param {Object} account - Account data
174
+ */
175
+ export function printAccountSummary(account) {
176
+ printSection('Account Details');
177
+ printRow('Account ID', account.id);
178
+ printRow('IBAN', account.iban || 'N/A');
179
+ printRow('Status', formatStatus(account.status));
180
+ printRow('Created', formatDateString(account.created));
181
+ printRow('Last Accessed', formatRelativeTime(account.last_accessed));
182
+ if (account.institution_id) {
183
+ printRow('Institution ID', account.institution_id);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Format status with color
189
+ *
190
+ * @param {string} status - Status string
191
+ * @returns {string}
192
+ */
193
+ export function formatStatus(status) {
194
+ switch (status?.toUpperCase()) {
195
+ case 'READY':
196
+ case 'ACTIVE':
197
+ case 'COMPLETED':
198
+ return chalk.green(status);
199
+ case 'PROCESSING':
200
+ case 'PENDING':
201
+ return chalk.yellow(status);
202
+ case 'ERROR':
203
+ case 'SUSPENDED':
204
+ case 'EXPIRED':
205
+ return chalk.red(status);
206
+ default:
207
+ return status || 'N/A';
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Print transaction
213
+ *
214
+ * @param {Object} transaction - Transaction data
215
+ */
216
+ export function printTransaction(transaction) {
217
+ const amount = transaction.transactionAmount || {};
218
+ const date = transaction.bookingDate || transaction.valueDate;
219
+
220
+ console.log(
221
+ chalk.cyan(formatDateString(date)),
222
+ formatCurrency(amount.amount, amount.currency),
223
+ transaction.remittanceInformationUnstructured || transaction.debtorName || 'N/A'
224
+ );
225
+ }
226
+
227
+ /**
228
+ * Print list of items
229
+ *
230
+ * @param {Array} items - Items to print
231
+ * @param {Function} formatter - Function to format each item
232
+ */
233
+ export function printList(items, formatter) {
234
+ if (!items || items.length === 0) {
235
+ printWarning('No items found');
236
+ return;
237
+ }
238
+
239
+ items.forEach((item, index) => {
240
+ formatter(item, index);
241
+ });
242
+ }
243
+
244
+ /**
245
+ * Truncate string
246
+ *
247
+ * @param {string} str - String to truncate
248
+ * @param {number} [maxLength=50] - Maximum length
249
+ * @returns {string}
250
+ */
251
+ export function truncate(str, maxLength = 50) {
252
+ if (!str) return '';
253
+ if (str.length <= maxLength) return str;
254
+ return str.substring(0, maxLength - 3) + '...';
255
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * API Client Tests
3
+ */
4
+
5
+ import { test } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { NordigenAPI } from '../src/lib/api.js';
8
+
9
+ test('NordigenAPI - initialization', () => {
10
+ const api = new NordigenAPI();
11
+ assert.ok(api);
12
+ assert.equal(api.baseURL, 'https://ob.nordigen.com');
13
+ });
14
+
15
+ test('NordigenAPI - request URL building', async () => {
16
+ const api = new NordigenAPI('test-token');
17
+
18
+ // Mock fetch for testing
19
+ const originalFetch = global.fetch;
20
+ global.fetch = async (url) => {
21
+ assert.ok(url.includes('/api/v2/'));
22
+ return {
23
+ ok: true,
24
+ headers: new Map([['content-type', 'application/json']]),
25
+ json: async () => ({ test: 'data' })
26
+ };
27
+ };
28
+
29
+ try {
30
+ const result = await api.get('/api/v2/test');
31
+ assert.deepEqual(result, { test: 'data' });
32
+ } finally {
33
+ global.fetch = originalFetch;
34
+ }
35
+ });
36
+
37
+ test('NordigenAPI - query parameters', async () => {
38
+ const api = new NordigenAPI('test-token');
39
+
40
+ const originalFetch = global.fetch;
41
+ global.fetch = async (url) => {
42
+ assert.ok(url.includes('country=GB'));
43
+ assert.ok(url.includes('limit=10'));
44
+ return {
45
+ ok: true,
46
+ headers: new Map([['content-type', 'application/json']]),
47
+ json: async () => ([])
48
+ };
49
+ };
50
+
51
+ try {
52
+ await api.get('/api/v2/institutions/', {
53
+ query: { country: 'GB', limit: 10 }
54
+ });
55
+ } finally {
56
+ global.fetch = originalFetch;
57
+ }
58
+ });
59
+
60
+ test('NordigenAPI - error handling', async () => {
61
+ const api = new NordigenAPI('test-token');
62
+
63
+ const originalFetch = global.fetch;
64
+ global.fetch = async () => ({
65
+ ok: false,
66
+ status: 401,
67
+ statusText: 'Unauthorized',
68
+ headers: new Map([['content-type', 'application/json']]),
69
+ json: async () => ({
70
+ status_code: 401,
71
+ summary: 'Invalid token',
72
+ detail: 'Token is invalid or expired'
73
+ })
74
+ });
75
+
76
+ try {
77
+ await assert.rejects(
78
+ async () => await api.get('/api/v2/accounts/test'),
79
+ (error) => {
80
+ assert.equal(error.status, 401);
81
+ assert.ok(error.message.includes('Invalid token'));
82
+ return true;
83
+ }
84
+ );
85
+ } finally {
86
+ global.fetch = originalFetch;
87
+ }
88
+ });