diff --git a/.changeset/serious-apricots-compare.md b/.changeset/serious-apricots-compare.md new file mode 100644 index 0000000000000..103fa6644a6a2 --- /dev/null +++ b/.changeset/serious-apricots-compare.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/apps-engine': minor +'@rocket.chat/meteor': minor +--- + +Adds an endpoint to export apps logs as files diff --git a/apps/meteor/ee/server/apps/communication/endpoints/appGeneralLogsHandler.ts b/apps/meteor/ee/server/apps/communication/endpoints/appGeneralLogsHandler.ts index 58dcd0abe9226..e6db29256b6ce 100644 --- a/apps/meteor/ee/server/apps/communication/endpoints/appGeneralLogsHandler.ts +++ b/apps/meteor/ee/server/apps/communication/endpoints/appGeneralLogsHandler.ts @@ -27,7 +27,7 @@ export const registerAppGeneralLogsHandler = ({ api, _orch }: AppsRestApi) => return api.failure({ error: error instanceof Error ? error.message : 'Unknown error' }); } - const result = await _orch.getLogStorage().find(query, options); + const result = await _orch.getLogStorage().findPaginated(query, options); return api.success({ offset, logs: result.logs, count: result.logs.length, total: result.total }); }, diff --git a/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts b/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts new file mode 100644 index 0000000000000..f9c1e9bcacbda --- /dev/null +++ b/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts @@ -0,0 +1,126 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { isAppLogsExportProps } from '@rocket.chat/rest-typings'; +import { ajv } from '@rocket.chat/rest-typings/src/v1/Ajv'; +import { parse } from 'cookie'; +import { json2csv } from 'json-2-csv'; + +import { getPaginationItems } from '../../../../../app/api/server/helpers/getPaginationItems'; +import type { AppsRestApi } from '../rest'; +import { makeAppLogsQuery } from './lib/makeAppLogsQuery'; +import { APIClass } from '../../../../../app/api/server/ApiClass'; + +const isErrorResponse = ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [false], + }, + error: { + type: 'string', + }, + }, +}); + +class ExportHandlerAPI extends APIClass { + protected async authenticatedRoute(req: Request): Promise { + const { rc_uid, rc_token } = parse(req.headers.get('cookie') || ''); + + if (rc_uid) { + req.headers.set('x-user-id', rc_uid); + } + + if (rc_token) { + req.headers.set('x-auth-token', rc_token); + } + + return super.authenticatedRoute(req); + } +} + +const adhocApi = new ExportHandlerAPI({ + useDefaultAuth: false, + prettyJson: process.env.NODE_ENV !== 'development', +}); + +export const registerAppLogsExportHandler = ({ api, _manager, _orch }: AppsRestApi) => { + adhocApi.get( + ':id/export-logs', + { + authRequired: true, + permissionsRequired: ['manage-apps'], + query: isAppLogsExportProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + body: { + type: 'string', + format: 'binary', + description: 'The content of the exported logs file, either in JSON or CSV format.', + }, + }, + }), + 400: isErrorResponse, + 401: isErrorResponse, + 404: isErrorResponse, + }, + }, + + async function () { + const proxiedApp = _manager.getOneById(this.urlParams.id); + + if (!proxiedApp) { + return api.notFound(`No App found by the id of: ${this.urlParams.id}`); + } + + const { count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + const options = { + sort: sort || { _updatedAt: -1 }, + skip: 0, + limit: Math.min(count || 100, 2000), + }; + + let query: ReturnType; + + try { + query = makeAppLogsQuery({ appId: this.urlParams.id, ...this.queryParams }); + } catch (error) { + return api.failure({ error: error instanceof Error ? error.message : 'Unknown error' }); + } + + const result = await _orch.getLogStorage().find(query, options); + + if (!result.length) { + return api.failure({ error: 'No logs found for the specified criteria' }); + } + + let fileContent: Buffer; + let filename: string; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + + if (this.queryParams.type === 'json') { + fileContent = Buffer.from(JSON.stringify(result, null, 2), 'utf8'); + filename = `app-logs-${this.urlParams.id}-${timestamp}.json`; + } else { + fileContent = Buffer.from(json2csv(result, { expandArrayObjects: true }), 'utf8'); + filename = `app-logs-${this.urlParams.id}-${timestamp}.csv`; + } + + return { + body: fileContent, + statusCode: 200, + headers: { + // 'application/json' here creates problems down the line with the router + 'Content-Type': 'text/plain', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Length': fileContent.length.toString(), + }, + }; + }, + ); + + api.router.use(adhocApi.router); +}; diff --git a/apps/meteor/ee/server/apps/communication/endpoints/appLogsHandler.ts b/apps/meteor/ee/server/apps/communication/endpoints/appLogsHandler.ts index df30b4a1b28c7..b1e0103c13fa6 100644 --- a/apps/meteor/ee/server/apps/communication/endpoints/appLogsHandler.ts +++ b/apps/meteor/ee/server/apps/communication/endpoints/appLogsHandler.ts @@ -37,7 +37,7 @@ export const registerAppLogsHandler = ({ api, _manager, _orch }: AppsRestApi) => return api.failure({ error: error instanceof Error ? error.message : 'Unknown error' }); } - const result = await _orch.getLogStorage().find(query, options); + const result = await _orch.getLogStorage().findPaginated(query, options); return api.success({ offset, logs: result.logs, count: result.logs.length, total: result.total }); }, diff --git a/apps/meteor/ee/server/apps/communication/rest.ts b/apps/meteor/ee/server/apps/communication/rest.ts index 76ef4bc3b7d8e..428b132c7abbd 100644 --- a/apps/meteor/ee/server/apps/communication/rest.ts +++ b/apps/meteor/ee/server/apps/communication/rest.ts @@ -13,6 +13,7 @@ import { ZodError } from 'zod'; import { registerActionButtonsHandler } from './endpoints/actionButtonsHandler'; import { registerAppGeneralLogsHandler } from './endpoints/appGeneralLogsHandler'; +import { registerAppLogsExportHandler } from './endpoints/appLogsExportHandler'; import { registerAppLogsHandler } from './endpoints/appLogsHandler'; import { registerAppsCountHandler } from './endpoints/appsCountHandler'; import { API } from '../../../../app/api/server'; @@ -108,6 +109,7 @@ export class AppsRestApi { registerAppsCountHandler(this); registerAppLogsHandler(this); + registerAppLogsExportHandler(this); registerAppGeneralLogsHandler(this); this.api.addRoute( diff --git a/apps/meteor/ee/server/apps/storage/AppRealLogStorage.ts b/apps/meteor/ee/server/apps/storage/AppRealLogStorage.ts index 87a009608e175..82fbf49a5ebc6 100644 --- a/apps/meteor/ee/server/apps/storage/AppRealLogStorage.ts +++ b/apps/meteor/ee/server/apps/storage/AppRealLogStorage.ts @@ -14,6 +14,15 @@ export class AppRealLogStorage extends AppLogStorage { [field: string]: any; }, options: IAppLogStorageFindOptions, + ) { + return this.db.find(query, options).toArray(); + } + + async findPaginated( + query: { + [field: string]: any; + }, + options: IAppLogStorageFindOptions, ) { const { cursor, totalCount } = this.db.findPaginated(query, options); diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 1eaa640632191..e766577896d7f 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -371,6 +371,7 @@ "isolated-vm": "5.0.4", "jschardet": "^3.1.4", "jsdom": "^26.1.0", + "json-2-csv": "^5.5.9", "jsrsasign": "^11.1.0", "juice": "^8.1.0", "katex": "~0.16.22", diff --git a/apps/meteor/tests/end-to-end/apps/app-logs-export.ts b/apps/meteor/tests/end-to-end/apps/app-logs-export.ts new file mode 100644 index 0000000000000..59ed69ff591b4 --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/app-logs-export.ts @@ -0,0 +1,380 @@ +import type { ILoggerStorageEntry } from '@rocket.chat/apps-engine/server/logging'; +import type { App } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request, credentials } from '../../data/api-data'; +import { apps } from '../../data/apps/apps-data'; +import { installTestApp, cleanupApps } from '../../data/apps/helper'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Apps - Logs Export', () => { + let app: App; + + before((done) => getCredentials(done)); + + before(async () => { + await cleanupApps(); + app = await installTestApp(); + }); + + after(() => cleanupApps()); + + it('should throw an error when trying to export logs for an invalid app', (done) => { + void request + .get(apps('/invalid-id/export-logs')) + .query({ type: 'json' }) + .set(credentials) + .expect(404) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body.error).to.be.equal('No App found by the id of: invalid-id'); + }) + .end(done); + }); + + it('should export app logs as JSON successfully', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + expect(res.headers).to.have.a.property('content-disposition'); + expect(res.headers['content-disposition']).to.include('attachment'); + expect(res.headers['content-disposition']).to.include(`app-logs-${app.id}`); + expect(res.headers['content-disposition']).to.include('.json'); + expect(res.headers).to.have.a.property('content-length'); + + // Verify the content is valid JSON + expect(() => JSON.parse(res.text)).to.not.throw(); + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + expect(logs[0]).to.have.a.property('_id'); + expect(logs[0]).to.have.a.property('appId', app.id); + }) + .end(done); + }); + + it('should export app logs as CSV successfully', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'csv' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + expect(res.headers).to.have.a.property('content-disposition'); + expect(res.headers['content-disposition']).to.include('attachment'); + expect(res.headers['content-disposition']).to.include(`app-logs-${app.id}`); + expect(res.headers['content-disposition']).to.include('.csv'); + expect(res.headers).to.have.a.property('content-length'); + + // Verify the content is valid CSV (should have headers and at least one row) + const lines = res.text.split('\n'); + expect(lines.length).to.be.at.least(1); // At least header row + if (lines.length > 1 && lines[0]) { + // Check that headers contain expected fields + expect(lines[0]).to.include('_id'); + expect(lines[0]).to.include('appId'); + } + }) + .end(done); + }); + + it('should throw error when type is not specified', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .set(credentials) + .expect(400) + .end(done); + }); + + it('should export app logs with pagination parameters', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ count: 5, type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + expect(logs.length).to.be.at.most(5); + }) + .end(done); + }); + + it('should export app logs with sorting parameters', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ + sort: JSON.stringify({ _updatedAt: 1 }), + type: 'json', + }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + // Verify sorting by checking if timestamps are in ascending order + if (logs.length > 1) { + for (let i = 1; i < logs.length; i++) { + const prevTime = new Date(logs[i - 1]._updatedAt).getTime(); + const currTime = new Date(logs[i]._updatedAt).getTime(); + expect(currTime).to.be.at.least(prevTime); + } + } + }) + .end(done); + }); + + it('should export app logs filtered by logLevel', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ logLevel: '2', type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + + logs.forEach((log: ILoggerStorageEntry) => { + const entry = log.entries.find((entry) => ['error', 'warn', 'info', 'log', 'debug', 'success'].includes(entry.severity)); + expect(entry).to.exist; + }); + }) + .end(done); + }); + + it('should export app logs filtered by method', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ method: 'app:construct', type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + + logs.forEach((log: ILoggerStorageEntry) => { + expect(log.method).to.equal('app:construct'); + }); + }) + .end(done); + }); + + it('should export app logs filtered by date range', (done) => { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 1); // 1 day ago + const endDate = new Date(); + + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + type: 'json', + }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + + // Verify that all returned logs are within the date range + logs.forEach((log: ILoggerStorageEntry) => { + const logDate = new Date(log._createdAt); + expect(logDate).to.be.above(startDate).and.below(endDate); + }); + }) + .end(done); + }); + + it('should export app logs with combined filters', (done) => { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 1); // 1 day ago + const endDate = new Date(); + + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ + logLevel: '2', + method: 'app:construct', + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + type: 'json', + count: 10, + }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + expect(logs.length).to.be.at.most(10); + + // Verify that all returned logs match all filter criteria + logs.forEach((log: ILoggerStorageEntry) => { + expect(log.method).to.equal('app:construct'); + + const logDate = new Date(log._createdAt); + expect(logDate >= startDate && logDate <= endDate).to.be.true; + + const entry = log.entries.find((entry) => ['error', 'warn', 'info', 'log', 'debug', 'success'].includes(entry.severity)); + expect(entry).to.exist; + }); + }) + .end(done); + }); + + it('should return an error when no logs are found for the specified criteria', (done) => { + const futureDate = new Date(); + futureDate.setFullYear(futureDate.getFullYear() + 1); + + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ + startDate: futureDate.toISOString(), + type: 'json', + }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body.error).to.be.equal('No logs found for the specified criteria'); + }) + .end(done); + }); + + it('should respect the maximum limit of 2000 logs', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ count: 5000, type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + expect(logs.length).to.be.at.most(2000); + }) + .end(done); + }); + + it('should reject invalid logLevel value', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ logLevel: 'debug' }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should reject invalid date format', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ startDate: 'invalid-date' }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should reject invalid date range', (done) => { + const startDate = new Date(); + const endDate = new Date(); + endDate.setDate(endDate.getDate() - 1); // endDate before startDate + + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should reject invalid additional properties', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ invalidProperty: 'value' }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should handle authentication requirement', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'json' }) + .expect(401) + .end(done); + }); + + it('should handle authentication via cookie', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'json' }) + .set({ cookie: `rc_uid=${credentials['X-User-Id']}; rc_token=${credentials['X-Auth-Token']}` }) + .expect(200) + .end(done); + }); + + it('should include proper filename with timestamp in Content-Disposition header', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'json' }) + .set(credentials) + .expect(200) + .expect((res) => { + const contentDisposition = res.headers['content-disposition']; + expect(contentDisposition).to.include('attachment'); + expect(contentDisposition).to.include(`app-logs-${app.id}`); + expect(contentDisposition).to.include('.json'); + + // Check that timestamp is included (format: YYYY-MM-DDTHH-MM-SS) + const timestampRegex = /\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/; + expect(contentDisposition).to.match(timestampRegex); + }) + .end(done); + }); + + it('should include Content-Length header', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'json' }) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.headers).to.have.a.property('content-length'); + const contentLength = parseInt(res.headers['content-length'], 10); + expect(contentLength).to.be.a('number'); + expect(contentLength).to.be.greaterThan(0); + expect(contentLength).to.equal(Buffer.byteLength(res.text, 'utf8')); + }) + .end(done); + }); +}); diff --git a/packages/apps-engine/src/server/storage/AppLogStorage.ts b/packages/apps-engine/src/server/storage/AppLogStorage.ts index b7a8f355cbcd0..8a3487a5587be 100644 --- a/packages/apps-engine/src/server/storage/AppLogStorage.ts +++ b/packages/apps-engine/src/server/storage/AppLogStorage.ts @@ -14,7 +14,7 @@ export abstract class AppLogStorage { return this.engine; } - public abstract find( + public abstract findPaginated( query: { [field: string]: any }, options?: IAppLogStorageFindOptions, ): Promise<{ logs: ILoggerStorageEntry[]; total: number }>; diff --git a/packages/apps-engine/tests/test-data/storage/logStorage.ts b/packages/apps-engine/tests/test-data/storage/logStorage.ts index 898a8f6f2f0d2..5841d02385c44 100644 --- a/packages/apps-engine/tests/test-data/storage/logStorage.ts +++ b/packages/apps-engine/tests/test-data/storage/logStorage.ts @@ -7,7 +7,7 @@ export class TestsAppLogStorage extends AppLogStorage { super('nothing'); } - public find( + public findPaginated( query: { [field: string]: any }, options?: IAppLogStorageFindOptions, ): Promise<{ logs: ILoggerStorageEntry[]; total: number }> { diff --git a/packages/rest-typings/src/apps/appLogsExportProps.ts b/packages/rest-typings/src/apps/appLogsExportProps.ts new file mode 100644 index 0000000000000..9c1ecd6c9a33e --- /dev/null +++ b/packages/rest-typings/src/apps/appLogsExportProps.ts @@ -0,0 +1,23 @@ +import type { AppLogsProps } from './appLogsProps'; +import { ajv } from '../v1/Ajv'; + +export type AppLogsExportProps = Omit & { + type: 'json' | 'csv'; +}; + +const AppLogsExportPropsSchema = { + type: 'object', + properties: { + logLevel: { type: 'string', enum: ['0', '1', '2'], nullable: true }, + method: { type: 'string', nullable: true }, + startDate: { type: 'string', format: 'date-time', nullable: true }, + endDate: { type: 'string', format: 'date-time', nullable: true }, + type: { type: 'string', enum: ['json', 'csv'] }, + count: { type: 'number', minimum: 1, nullable: true }, + sort: { type: 'string', nullable: true }, + }, + required: ['type'], + additionalProperties: false, +}; + +export const isAppLogsExportProps = ajv.compile(AppLogsExportPropsSchema); diff --git a/packages/rest-typings/src/apps/index.ts b/packages/rest-typings/src/apps/index.ts index 5bcfec5ecddcc..d6000eae61e91 100644 --- a/packages/rest-typings/src/apps/index.ts +++ b/packages/rest-typings/src/apps/index.ts @@ -16,9 +16,11 @@ import type { } from '@rocket.chat/core-typings'; import type * as UiKit from '@rocket.chat/ui-kit'; +import type { AppLogsExportProps } from './appLogsExportProps'; import type { AppLogsProps } from './appLogsProps'; import type { PaginatedResult } from '../helpers/PaginatedResult'; +export * from './appLogsExportProps'; export * from './appLogsProps'; export type AppsEndpoints = { @@ -106,6 +108,10 @@ export type AppsEndpoints = { }>; }; + '/apps/:id/export-logs': { + GET: (params: AppLogsExportProps) => Buffer; + }; + '/apps/:id/apis': { GET: () => { apis: IApiEndpointMetadata[]; diff --git a/yarn.lock b/yarn.lock index e9f7a8c3873ff..bfacca94cfcbf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8093,6 +8093,7 @@ __metadata: jschardet: "npm:^3.1.4" jsdom: "npm:^26.1.0" jsdom-global: "npm:^3.0.2" + json-2-csv: "npm:^5.5.9" jsrsasign: "npm:^11.1.0" juice: "npm:^8.1.0" katex: "npm:~0.16.22" @@ -17592,6 +17593,13 @@ __metadata: languageName: node linkType: hard +"deeks@npm:3.1.0": + version: 3.1.0 + resolution: "deeks@npm:3.1.0" + checksum: 10/4297893cb792bc207b1fdac6090094c8c666669227b385bc69d98ea751294614948288a39a38faa0a44d7045d1bf05eacd188eea7516a1a7d0fee9a7faa34e7d + languageName: node + linkType: hard + "deep-eql@npm:^4.1.3": version: 4.1.3 resolution: "deep-eql@npm:4.1.3" @@ -17938,6 +17946,13 @@ __metadata: languageName: node linkType: hard +"doc-path@npm:4.1.1": + version: 4.1.1 + resolution: "doc-path@npm:4.1.1" + checksum: 10/455dd7458d4fa9ec0662fc307c4c3a7112bd0d554b8a6e0e86a6c6554f125727b4f80af430c1038f5d471ed5586c95354260928a31b80e632519480d745af713 + languageName: node + linkType: hard + "docker-compose@npm:^0.24.8": version: 0.24.8 resolution: "docker-compose@npm:0.24.8" @@ -24480,6 +24495,16 @@ __metadata: languageName: node linkType: hard +"json-2-csv@npm:^5.5.9": + version: 5.5.9 + resolution: "json-2-csv@npm:5.5.9" + dependencies: + deeks: "npm:3.1.0" + doc-path: "npm:4.1.1" + checksum: 10/fa80a1a99e9c7e6844e6e13a48e3d0f11e6e98e90dc46e47fdc46b0fde2ecef324f8e13c68e11dbd0645f714c5089dc46e605a22f25c659294347a3426506947 + languageName: node + linkType: hard + "json-bigint@npm:^1.0.0": version: 1.0.0 resolution: "json-bigint@npm:1.0.0"