fnos-cli 0.1.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,69 @@
1
+ /**
2
+ * Command mapping configuration
3
+ */
4
+
5
+ const COMMAND_MAPPING = {
6
+ resmon: {
7
+ className: 'ResourceMonitor',
8
+ commands: {
9
+ cpu: { method: 'cpu', description: 'Get CPU resource monitoring information' },
10
+ gpu: { method: 'gpu', description: 'Get GPU resource monitoring information' },
11
+ mem: { method: 'memory', description: 'Get memory resource monitoring information' },
12
+ disk: { method: 'disk', description: 'Get disk resource monitoring information' },
13
+ net: { method: 'net', description: 'Get network resource monitoring information' },
14
+ gen: { method: 'general', description: 'Get general resource monitoring information', params: ['items'] }
15
+ }
16
+ },
17
+ store: {
18
+ className: 'Store',
19
+ commands: {
20
+ general: { method: 'general', description: 'Get storage general information' },
21
+ calcSpace: { method: 'calculateSpace', description: 'Calculate storage space information' },
22
+ listDisk: { method: 'listDisks', description: 'List disk information', params: ['noHotSpare'] },
23
+ diskSmart: { method: 'getDiskSmart', description: 'Get disk SMART information', params: ['disk'] },
24
+ state: { method: 'getState', description: 'Get storage state information', params: ['name', 'uuid'] }
25
+ }
26
+ },
27
+ sysinfo: {
28
+ className: 'SystemInfo',
29
+ commands: {
30
+ getHostName: { method: 'getHostName', description: 'Get host name information' },
31
+ getTrimVersion: { method: 'getTrimVersion', description: 'Get Trim version information' },
32
+ getMachineId: { method: 'getMachineId', description: 'Get machine ID information' },
33
+ getHardwareInfo: { method: 'getHardwareInfo', description: 'Get hardware information' },
34
+ getUptime: { method: 'getUptime', description: 'Get system uptime information' }
35
+ }
36
+ },
37
+ user: {
38
+ className: 'User',
39
+ commands: {
40
+ info: { method: 'getInfo', description: 'Get user information' },
41
+ listUG: { method: 'listUserGroups', description: 'List users and groups' },
42
+ groupUsers: { method: 'groupUsers', description: 'Get user grouping information' },
43
+ isAdmin: { method: 'isAdmin', description: 'Check if current user is admin' }
44
+ }
45
+ },
46
+ network: {
47
+ className: 'Network',
48
+ commands: {
49
+ list: { method: 'list', description: 'List network information', params: ['type'] },
50
+ detect: { method: 'detect', description: 'Detect network interface', params: ['ifName'] }
51
+ }
52
+ },
53
+ file: {
54
+ className: 'File',
55
+ commands: {
56
+ ls: { method: 'list', description: 'List files and directories', params: ['path'] },
57
+ mkdir: { method: 'mkdir', description: 'Create directory', params: ['path'] },
58
+ rm: { method: 'remove', description: 'Remove files or directories', params: ['files', 'moveToTrashbin', 'details'] }
59
+ }
60
+ },
61
+ sac: {
62
+ className: 'SAC',
63
+ commands: {
64
+ upsStatus: { method: 'upsStatus', description: 'Get UPS status information' }
65
+ }
66
+ }
67
+ };
68
+
69
+ module.exports = { COMMAND_MAPPING };
package/src/index.js ADDED
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * fnos-cli - CLI client for 飞牛 fnOS system
5
+ */
6
+
7
+ const { Command } = require('commander');
8
+ const { registerLoginCommand, registerLogoutCommand } = require('./commands/auth');
9
+ const { registerCommands } = require('./commands');
10
+ const { setLogLevel } = require('./utils/logger');
11
+
12
+ // Create program
13
+ const program = new Command();
14
+
15
+ // Configure program
16
+ program
17
+ .name('fnos')
18
+ .description('CLI client for 飞牛 fnOS system')
19
+ .version('1.0.0');
20
+
21
+ // Global options
22
+ program
23
+ .option('--raw', 'Output raw JSON response')
24
+ .option('-v, --verbose', 'Verbose output (info level)')
25
+ .option('-vv, --debug', 'Debug output (debug level)')
26
+ .option('-vvv, --silly', 'Silly output (silly level)');
27
+
28
+ // Register auth commands
29
+ registerLoginCommand(program);
30
+ registerLogoutCommand(program);
31
+
32
+ // Register dynamic commands
33
+ registerCommands(program);
34
+
35
+ // Hook into parse to set log level before command execution
36
+ program.hook('preAction', (thisCommand, actionCommand) => {
37
+ const options = thisCommand.opts();
38
+ let verboseLevel = 0;
39
+ if (options.verbose) verboseLevel = 1;
40
+ if (options.debug) verboseLevel = 2;
41
+ if (options.silly) verboseLevel = 3;
42
+
43
+ // Set log level for both CLI and SDK logger
44
+ setLogLevel(verboseLevel);
45
+
46
+ // Set environment variable to control SDK logger level
47
+ const sdkLogLevels = ['error', 'info', 'debug', 'silly'];
48
+ process.env.LOG_LEVEL = sdkLogLevels[verboseLevel];
49
+ });
50
+
51
+ // Parse
52
+ program.parse(process.argv);
53
+
54
+ // Show help if no command provided
55
+ if (!process.argv.slice(2).length) {
56
+ program.outputHelp();
57
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * FnosClient wrapper with authentication
3
+ */
4
+
5
+ const { FnosClient, ResourceMonitor, Store, SystemInfo, User, Network, File, SAC } = require('fnos');
6
+ const settings = require('./settings');
7
+ const { logger } = require('./logger');
8
+
9
+ /**
10
+ * Create authenticated FnosClient
11
+ * @param {Object} options - Options object
12
+ * @param {string} options.endpoint - Server endpoint
13
+ * @param {string} options.username - Username
14
+ * @param {string} options.password - Password
15
+ * @param {number} options.timeout - Connection timeout in seconds
16
+ * @returns {Promise<FnosClient>} Authenticated client
17
+ */
18
+ async function createClient(options = {}) {
19
+ const { endpoint, username, password, timeout = 60 } = options;
20
+
21
+ // Get credentials from settings if not provided
22
+ const savedCredentials = settings.getCredentials();
23
+ const finalEndpoint = endpoint || savedCredentials?.endpoint;
24
+ const finalUsername = username || savedCredentials?.username;
25
+ const finalPassword = password || savedCredentials?.password;
26
+
27
+ if (!finalEndpoint || !finalUsername) {
28
+ throw new Error('Missing credentials. Please run "fnos login" first or provide -e, -u, -p parameters.');
29
+ }
30
+
31
+ // Create client
32
+ const client = new FnosClient();
33
+ logger.info(`Connecting to ${finalEndpoint}...`);
34
+
35
+ // Connect
36
+ await client.connect(finalEndpoint, timeout * 1000);
37
+ logger.info('Connected successfully');
38
+
39
+ // Try to login with token first
40
+ // if (savedCredentials?.token && savedCredentials?.secret && !endpoint && !username && !password) {
41
+ // try {
42
+ // logger.info('Logging in with saved token...');
43
+ // await client.loginViaToken(savedCredentials.token, savedCredentials.longToken, savedCredentials.secret);
44
+ // logger.info('Logged in successfully with token');
45
+ // return client;
46
+ // } catch (error) {
47
+ // logger.warn('Token login failed, falling back to password login:', error.message);
48
+ // }
49
+ // }
50
+
51
+ // Login with password
52
+ if (!finalPassword) {
53
+ throw new Error('Password required. Please run "fnos login" first or provide -p parameter.');
54
+ }
55
+
56
+ logger.info('Logging in...');
57
+ const loginResult = await client.login(finalUsername, finalPassword);
58
+ logger.info('Logged in successfully');
59
+
60
+ // Save credentials if they were provided via command line
61
+ if (endpoint || username || password) {
62
+ settings.saveCredentials({
63
+ endpoint: finalEndpoint,
64
+ username: finalUsername,
65
+ password: finalPassword,
66
+ token: loginResult.token,
67
+ longToken: loginResult.longToken,
68
+ secret: loginResult.secret
69
+ });
70
+ logger.info('Credentials saved');
71
+ }
72
+
73
+ return client;
74
+ }
75
+
76
+ /**
77
+ * Execute a command with auto-retry
78
+ * @param {FnosClient} client - FnosClient instance
79
+ * @param {Function} commandFn - Command function to execute
80
+ * @returns {Promise<*>} Command result
81
+ */
82
+ async function executeCommand(client, commandFn) {
83
+ try {
84
+ return await commandFn();
85
+ } catch (error) {
86
+ if (error.message.includes('token') || error.message.includes('auth')) {
87
+ logger.warn('Authentication failed, trying to re-login...');
88
+ // Re-login logic could be added here
89
+ }
90
+ throw error;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Get SDK class instance
96
+ * @param {FnosClient} client - FnosClient instance
97
+ * @param {string} className - Name of the SDK class
98
+ * @returns {*} SDK class instance
99
+ */
100
+ function getSDKInstance(client, className) {
101
+ const instances = {
102
+ ResourceMonitor: new ResourceMonitor(client),
103
+ Store: new Store(client),
104
+ SystemInfo: new SystemInfo(client),
105
+ User: new User(client),
106
+ Network: new Network(client),
107
+ File: new File(client),
108
+ SAC: new SAC(client)
109
+ };
110
+
111
+ const instance = instances[className];
112
+ if (!instance) {
113
+ throw new Error(`Unknown SDK class: ${className}`);
114
+ }
115
+
116
+ return instance;
117
+ }
118
+
119
+ module.exports = { createClient, executeCommand, getSDKInstance };
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Output formatter utility
3
+ */
4
+
5
+ /**
6
+ * Format output based on data type
7
+ * @param {*} data - Data to format
8
+ * @param {boolean} raw - If true, output raw JSON
9
+ * @returns {string} Formatted output
10
+ */
11
+ function formatOutput(data, raw = false) {
12
+ if (raw) {
13
+ return JSON.stringify(data, null, 2);
14
+ }
15
+
16
+ if (data === null || data === undefined) {
17
+ return 'No data returned';
18
+ }
19
+
20
+ if (Array.isArray(data)) {
21
+ return formatArray(data);
22
+ }
23
+
24
+ if (typeof data === 'object') {
25
+ return formatObject(data);
26
+ }
27
+
28
+ return String(data);
29
+ }
30
+
31
+ /**
32
+ * Format array as table with borders
33
+ * @param {Array} arr - Array to format
34
+ * @returns {string} Formatted table
35
+ */
36
+ function formatArray(arr) {
37
+ if (arr.length === 0) {
38
+ return 'Empty array';
39
+ }
40
+
41
+ // Get all keys from objects
42
+ const keys = Object.keys(arr[0]);
43
+ const columnWidths = keys.map(key => {
44
+ const headerWidth = key.length;
45
+ const maxValWidth = Math.max(...arr.map(item => {
46
+ const val = item[key];
47
+ return val ? String(val).length : 0;
48
+ }));
49
+ return Math.max(headerWidth, maxValWidth);
50
+ });
51
+
52
+ // Helper to format value with units
53
+ const formatValue = (key, val) => {
54
+ if (val === null || val === undefined) return '';
55
+ if (key === 'temp') return `${val}°C`;
56
+ if (key === 'read' || key === 'write') return val.toString();
57
+ if (key === 'busy') return `${val}%`;
58
+ if (key === 'standby') return val ? '✓' : '✗';
59
+ return String(val);
60
+ };
61
+
62
+ // Build top border
63
+ let output = '┌';
64
+ columnWidths.forEach((width, i) => {
65
+ output += '─'.repeat(width + 2);
66
+ if (i < columnWidths.length - 1) output += '┬';
67
+ });
68
+ output += '┐\n';
69
+
70
+ // Build header
71
+ output += '│';
72
+ keys.forEach((key, i) => {
73
+ output += ' ' + key.padEnd(columnWidths[i]) + ' │';
74
+ });
75
+ output += '\n';
76
+
77
+ // Build separator
78
+ output += '├';
79
+ columnWidths.forEach((width, i) => {
80
+ output += '─'.repeat(width + 2);
81
+ if (i < columnWidths.length - 1) output += '┼';
82
+ });
83
+ output += '┤\n';
84
+
85
+ // Build rows
86
+ arr.forEach(item => {
87
+ output += '│';
88
+ keys.forEach((key, i) => {
89
+ const val = item[key];
90
+ const formattedVal = formatValue(key, val);
91
+ output += ' ' + formattedVal.padEnd(columnWidths[i]) + ' │';
92
+ });
93
+ output += '\n';
94
+ });
95
+
96
+ // Build bottom border
97
+ output += '└';
98
+ columnWidths.forEach((width, i) => {
99
+ output += '─'.repeat(width + 2);
100
+ if (i < columnWidths.length - 1) output += '┴';
101
+ });
102
+ output += '┘\n';
103
+
104
+ return output;
105
+ }
106
+
107
+ /**
108
+ * Format object as pretty-printed JSON
109
+ * @param {Object} obj - Object to format
110
+ * @returns {string} Formatted output
111
+ */
112
+ function formatObject(obj) {
113
+ const result = [];
114
+
115
+ // Helper to format bytes to GB
116
+ const formatBytes = (bytes) => {
117
+ if (!bytes || bytes === 0) return '0 GB';
118
+ const gb = (bytes / (1024 * 1024 * 1024)).toFixed(2);
119
+ return `${gb} GB`;
120
+ };
121
+
122
+ // Helper to format speed to MB/s
123
+ const formatSpeed = (speed) => {
124
+ if (!speed || speed === 0) return '0 MB/s';
125
+ const mb = (speed / (1024 * 1024)).toFixed(2);
126
+ return `${mb} MB/s`;
127
+ };
128
+
129
+ // Special handling for resmon.gen output (has 'item' field)
130
+ if (obj.item !== undefined) {
131
+ let items;
132
+ if (typeof obj.item === 'string') {
133
+ items = obj.item.split(',').map(s => s.trim());
134
+ } else if (Array.isArray(obj.item)) {
135
+ items = obj.item;
136
+ } else if (typeof obj.item === 'object') {
137
+ // item is an object containing the actual data (storeSpeed, netSpeed, cpuBusy, memPercent)
138
+ const resultItems = [];
139
+
140
+ if (obj.item.memPercent !== undefined) {
141
+ resultItems.push(`Memory: ${obj.item.memPercent}%`);
142
+ }
143
+ if (obj.item.cpuBusy !== undefined) {
144
+ resultItems.push(`CPU: ${obj.item.cpuBusy}%`);
145
+ }
146
+ if (obj.item.storeSpeed) {
147
+ const read = formatSpeed(obj.item.storeSpeed.read);
148
+ const write = formatSpeed(obj.item.storeSpeed.write);
149
+ resultItems.push(`Storage: ↑ ${write} | ↓ ${read}`);
150
+ }
151
+ if (obj.item.netSpeed) {
152
+ const receive = formatSpeed(obj.item.netSpeed.receive);
153
+ const transmit = formatSpeed(obj.item.netSpeed.transmit);
154
+ resultItems.push(`Network: ↑ ${transmit} | ↓ ${receive}`);
155
+ }
156
+
157
+ if (resultItems.length > 0) {
158
+ result.push(resultItems.join(' | '));
159
+ }
160
+
161
+ // Add metadata at the end if present
162
+ const metadata = {};
163
+ if (obj.reqid) metadata.reqid = obj.reqid;
164
+ if (obj.result) metadata.result = obj.result;
165
+ if (obj.rev) metadata.rev = obj.rev;
166
+ if (obj.req) metadata.req = obj.req;
167
+
168
+ if (Object.keys(metadata).length > 0) {
169
+ result.push('');
170
+ result.push('─'.repeat(40));
171
+ for (const [key, value] of Object.entries(metadata)) {
172
+ result.push(`${key}: ${value}`);
173
+ }
174
+ }
175
+
176
+ return result.join('\n');
177
+ } else {
178
+ // If item is neither string nor array nor object, skip special handling
179
+ items = [];
180
+ }
181
+
182
+ const resultItems = [];
183
+
184
+ if (items.includes('memPercent') && obj.memPercent !== undefined) {
185
+ resultItems.push(`Memory: ${obj.memPercent}%`);
186
+ }
187
+ if (items.includes('cpuBusy') && obj.cpuBusy !== undefined) {
188
+ resultItems.push(`CPU: ${obj.cpuBusy}%`);
189
+ }
190
+ if (items.includes('storeSpeed') && obj.storeSpeed) {
191
+ const read = formatSpeed(obj.storeSpeed.read);
192
+ const write = formatSpeed(obj.storeSpeed.write);
193
+ resultItems.push(`Storage: ↑ ${write} | ↓ ${read}`);
194
+ }
195
+ if (items.includes('netSpeed') && obj.netSpeed) {
196
+ const receive = formatSpeed(obj.netSpeed.receive);
197
+ const transmit = formatSpeed(obj.netSpeed.transmit);
198
+ resultItems.push(`Network: ↑ ${transmit} | ↓ ${receive}`);
199
+ }
200
+
201
+ if (resultItems.length > 0) {
202
+ result.push(resultItems.join(' | '));
203
+ }
204
+
205
+ // Add metadata at the end if present
206
+ const metadata = {};
207
+ if (obj.reqid) metadata.reqid = obj.reqid;
208
+ if (obj.result) metadata.result = obj.result;
209
+ if (obj.rev) metadata.rev = obj.rev;
210
+ if (obj.req) metadata.req = obj.req;
211
+
212
+ if (Object.keys(metadata).length > 0) {
213
+ result.push('');
214
+ result.push('─'.repeat(40));
215
+ for (const [key, value] of Object.entries(metadata)) {
216
+ result.push(`${key}: ${value}`);
217
+ }
218
+ }
219
+
220
+ return result.join('\n');
221
+ }
222
+
223
+ // Handle special fields first
224
+ const metadata = {};
225
+ const dataFields = {};
226
+ let hasMainData = false;
227
+
228
+ for (const [key, value] of Object.entries(obj)) {
229
+ // Skip certain metadata fields or handle them separately
230
+ if (['reqid', 'result', 'rev', 'req'].includes(key)) {
231
+ metadata[key] = value;
232
+ } else if (key === 'data' && typeof value === 'object' && value !== null) {
233
+ // Handle 'data' field specially - don't show 'data:' prefix
234
+ hasMainData = true;
235
+ // Recursively format the data object
236
+ const formattedData = formatObject(value);
237
+ if (formattedData) {
238
+ result.push(formattedData);
239
+ }
240
+ } else if (typeof value === 'object' && value !== null) {
241
+ if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'object') {
242
+ // Format array as table with header
243
+ const title = key.charAt(0).toUpperCase() + key.slice(1);
244
+ result.push(`${title} (${value.length} items)`);
245
+ result.push(formatArray(value));
246
+ } else if (key === 'cpu' && typeof value === 'object') {
247
+ // Format CPU info as compact lines
248
+ const lines = [];
249
+ if (value.name) lines.push(`CPU: ${value.name}`);
250
+ if (value.core !== undefined) lines.push(`Cores: ${value.core}`);
251
+ if (value.thread !== undefined) lines.push(`Threads: ${value.thread}`);
252
+ if (value.maxFreq) lines.push(`Max Frequency: ${value.maxFreq} MHz`);
253
+ if (value.num !== undefined) lines.push(`CPU Count: ${value.num}`);
254
+ result.push(lines.join(' | '));
255
+
256
+ // Handle nested fields within cpu object
257
+ if (value.temp) {
258
+ if (Array.isArray(value.temp) && value.temp.length > 0) {
259
+ result.push(`Temperature: ${value.temp[0]}°C`);
260
+ } else if (typeof value.temp === 'object') {
261
+ const temps = Object.entries(value.temp).map(([k, v]) => {
262
+ const core = k === '0' ? '' : `Core${k}: `;
263
+ return `${core}${v}°C`;
264
+ });
265
+ result.push(`Temperature: ${temps.join(', ')}`);
266
+ }
267
+ }
268
+
269
+ if (value.busy && value.busy.user !== undefined) {
270
+ const user = value.busy.user || 0;
271
+ const system = value.busy.system || 0;
272
+ const iowait = value.busy.iowait || 0;
273
+ const other = value.busy.other !== undefined ? value.busy.other : 0;
274
+ const all = value.busy.all !== undefined ? value.busy.all : (user + system + iowait + other);
275
+ result.push(`CPU Usage: user ${user}%, system ${system}%, iowait ${iowait}%, total ${all}%`);
276
+ }
277
+
278
+ if (value.loadavg && value.loadavg.avg1min !== undefined) {
279
+ const avg1 = value.loadavg.avg1min.toFixed(2);
280
+ const avg5 = value.loadavg.avg5min ? value.loadavg.avg5min.toFixed(2) : avg1;
281
+ const avg15 = value.loadavg.avg15min ? value.loadavg.avg15min.toFixed(2) : avg1;
282
+ result.push(`Load Average: ${avg1} (1m), ${avg5} (5m), ${avg15} (15m)`);
283
+ }
284
+ } else if (key === 'mem' && typeof value === 'object' && value.total !== undefined) {
285
+ // Format memory info
286
+ const total = formatBytes(value.total);
287
+ const used = formatBytes(value.used);
288
+ const free = formatBytes(value.free);
289
+ const available = formatBytes(value.available);
290
+ const cached = formatBytes(value.cached);
291
+ const buffers = formatBytes(value.buffers);
292
+ const percent = value.total ? ((value.used / value.total) * 100).toFixed(1) : 0;
293
+ result.push(`Memory: ${used} / ${total} (${percent}%) | Free: ${free} | Available: ${available} | Cached: ${cached} | Buffers: ${buffers}`);
294
+ } else if (key === 'swap' && typeof value === 'object' && value.total !== undefined) {
295
+ // Format swap info
296
+ const total = formatBytes(value.total);
297
+ const used = formatBytes(value.used);
298
+ const free = formatBytes(value.free);
299
+ const percent = value.total ? ((value.used / value.total) * 100).toFixed(1) : 0;
300
+ result.push(`Swap: ${used} / ${total} (${percent}%) | Free: ${free}`);
301
+ } else if (key === 'storeSpeed' && typeof value === 'object') {
302
+ // Format storage speed
303
+ const read = formatSpeed(value.read);
304
+ const write = formatSpeed(value.write);
305
+ result.push(`Storage Speed: ↑ ${write} | ↓ ${read}`);
306
+ } else if (key === 'netSpeed' && typeof value === 'object') {
307
+ // Format network speed
308
+ const receive = formatSpeed(value.receive);
309
+ const transmit = formatSpeed(value.transmit);
310
+ result.push(`Network Speed: ↑ ${transmit} | ↓ ${receive}`);
311
+ } else if (key === 'busy' && value.user !== undefined) {
312
+ // Format CPU busy usage as compact line
313
+ const user = value.user || 0;
314
+ const system = value.system || 0;
315
+ const iowait = value.iowait || 0;
316
+ const other = value.other !== undefined ? value.other : 0;
317
+ const all = value.all !== undefined ? value.all : (user + system + iowait + other);
318
+ result.push(`CPU Usage: user ${user}%, system ${system}%, iowait ${iowait}%, total ${all}%`);
319
+ } else if (key === 'loadavg' && value.avg1min !== undefined) {
320
+ // Format load average as compact line
321
+ const avg1 = value.avg1min.toFixed(2);
322
+ const avg5 = value.avg5min ? value.avg5min.toFixed(2) : avg1;
323
+ const avg15 = value.avg15min ? value.avg15min.toFixed(2) : avg1;
324
+ result.push(`Load Average: ${avg1} (1m), ${avg5} (5m), ${avg15} (15m)`);
325
+ } else if (key === 'temp' && typeof value === 'object') {
326
+ // Format temperature for CPU (might have core indices)
327
+ const temps = Object.entries(value).map(([k, v]) => {
328
+ const core = k === '0' ? '' : `Core${k}: `;
329
+ return `${core}${v}°C`;
330
+ });
331
+ result.push(`Temperature: ${temps.join(', ')}`);
332
+ } else {
333
+ // Handle nested objects
334
+ result.push(`${key}:`);
335
+ result.push(formatObject(value));
336
+ }
337
+ } else {
338
+ dataFields[key] = value;
339
+ }
340
+ }
341
+
342
+ // Add data fields at the beginning
343
+ if (Object.keys(dataFields).length > 0) {
344
+ for (const [key, value] of Object.entries(dataFields)) {
345
+ result.unshift(`${key}: ${value}`);
346
+ }
347
+ if (!hasMainData) {
348
+ result.push('');
349
+ }
350
+ }
351
+
352
+ // Add metadata at the end if present
353
+ if (Object.keys(metadata).length > 0) {
354
+ if (result.length > 0) {
355
+ result.push('');
356
+ }
357
+ result.push('─'.repeat(40));
358
+ for (const [key, value] of Object.entries(metadata)) {
359
+ result.push(`${key}: ${value}`);
360
+ }
361
+ }
362
+
363
+ return result.join('\n');
364
+ }
365
+
366
+ module.exports = { formatOutput };