diff --git a/package-lock.json b/package-lock.json index 09e2056b..2be02cf8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@lokalise/healthcheck-utils": "^1.4.0", "@lokalise/id-utils": "^2.2.0", "@lokalise/node-core": "^13.4.0", + "@lokalise/universal-ts-utils": "^3.0.0", "@lokalise/zod-extras": "^2.1.0", "@message-queue-toolkit/amqp": "^17.1.0", "@message-queue-toolkit/core": "^18.1.0", @@ -4467,6 +4468,15 @@ "zod": "^3.24.1" } }, + "node_modules/@lokalise/universal-ts-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@lokalise/universal-ts-utils/-/universal-ts-utils-3.1.0.tgz", + "integrity": "sha512-sYc+Bl/2LDp5J50302nXXazJdZsLbSYmUSNZUjYbyABDZrMXnKb69BAWlnYQ9hCrVmNwhYF+tAemcJDzIT8aTw==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.24.1" + } + }, "node_modules/@lokalise/zod-extras": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@lokalise/zod-extras/-/zod-extras-2.1.0.tgz", diff --git a/package.json b/package.json index 9d638b1b..e55891bc 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@lokalise/healthcheck-utils": "^1.4.0", "@lokalise/id-utils": "^2.2.0", "@lokalise/node-core": "^13.4.0", + "@lokalise/universal-ts-utils": "^3.0.0", "@lokalise/zod-extras": "^2.1.0", "@message-queue-toolkit/amqp": "^17.1.0", "@message-queue-toolkit/core": "^18.1.0", diff --git a/scripts/cmd/getUserImportJobs.ts b/scripts/cmd/getUserImportJobs.ts index 940d6473..a557034d 100644 --- a/scripts/cmd/getUserImportJobs.ts +++ b/scripts/cmd/getUserImportJobs.ts @@ -1,5 +1,7 @@ +import type { RequestContext } from '@lokalise/fastify-extras' import z from 'zod' -import { createCliContext, destroyCliContext } from '../utils/cliContextUtils.js' +import type { Dependencies } from '../../src/infrastructure/parentDiConfig.js' +import { cliCommandWrapper } from '../utils/cliCommandWrapper.js' const origin = 'getUserImportJobsCommand' const ARGUMENTS_SCHEMA = z.object({ @@ -7,14 +9,11 @@ const ARGUMENTS_SCHEMA = z.object({ }) type Arguments = z.infer -async function run() { - const { app, logger, args } = await createCliContext(ARGUMENTS_SCHEMA, origin) - const userImportJob = app.diContainer.cradle.userImportJob +const command = async (deps: Dependencies, reqContext: RequestContext, args: Arguments) => { + const userImportJob = deps.userImportJob const jobs = await userImportJob.getJobsInQueue([args.queue]) - logger.info(jobs, `${args.queue} jobs`) - - await destroyCliContext(app) + reqContext.logger.info(jobs, `${args.queue} jobs`) } -void run() +void cliCommandWrapper(origin, command, ARGUMENTS_SCHEMA) diff --git a/scripts/utils/cliCommandWrapper.spec.ts b/scripts/utils/cliCommandWrapper.spec.ts new file mode 100644 index 00000000..1658b90d --- /dev/null +++ b/scripts/utils/cliCommandWrapper.spec.ts @@ -0,0 +1,68 @@ +import { type MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import z from 'zod' +import { cliCommandWrapper } from './cliCommandWrapper.js' + +describe('cliCommandWrapper', () => { + let exitSpy: MockInstance + + beforeEach(() => { + // Mock process.exit before each test + exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it.each([ + { inputArgs: ['--key=value'], schema: undefined, expected: undefined }, + { + inputArgs: ['--key=value'], + schema: z.object({ key: z.string() }), + expected: { key: 'value' }, + }, + { inputArgs: ['--flag'], schema: z.object({ flag: z.boolean() }), expected: { flag: true } }, + { + inputArgs: ['--key=value'], + schema: z.object({ flag: z.boolean().optional(), key: z.string() }), + expected: { key: 'value' }, + }, + { + inputArgs: ['--key=value', '-abc', '--flag'], + schema: z.object({ + key: z.string(), + flag: z.boolean(), + a: z.boolean(), + b: z.boolean(), + c: z.boolean(), + }), + expected: { key: 'value', flag: true, a: true, b: true, c: true }, + }, + ])('should parse arguments', async ({ inputArgs, schema, expected }) => { + process.argv = ['node', 'script.js', ...inputArgs] + await cliCommandWrapper( + 'command', + (dependencies, requestContext, args) => { + expect(dependencies).toBeDefined() + expect(requestContext).toBeDefined() + expect(args).toEqual(expected) + }, + schema, + ) + expect(exitSpy).toHaveBeenCalledWith(0) + }) + + it('should fail if arguments are not valid', async () => { + process.argv = ['node', 'script.js', '--key=value'] + await cliCommandWrapper('command', () => undefined, z.object({ key: z.number() })) + expect(exitSpy).toHaveBeenCalledWith(1) + }) + + it('should fail if cli command fail', async () => { + process.argv = ['node', 'script.js', '--key=value'] + await cliCommandWrapper('command', () => { + throw new Error() + }) + expect(exitSpy).toHaveBeenCalledWith(1) + }) +}) diff --git a/scripts/utils/cliCommandWrapper.ts b/scripts/utils/cliCommandWrapper.ts new file mode 100644 index 00000000..e49601e9 --- /dev/null +++ b/scripts/utils/cliCommandWrapper.ts @@ -0,0 +1,77 @@ +import { parseArgs } from 'node:util' +import type { RequestContext } from '@lokalise/fastify-extras' +import { generateMonotonicUuid } from '@lokalise/id-utils' +import { isError } from '@lokalise/universal-ts-utils/node' +import pino from 'pino' +import type z from 'zod' +import { getApp } from '../../src/app.js' +import type { Dependencies } from '../../src/infrastructure/parentDiConfig.js' + +const getArgs = () => { + const { values } = parseArgs({ + args: process.argv, + strict: false, + }) + + return values +} + +export type CliCommand< + ArgsSchema extends z.Schema | undefined, + Args = ArgsSchema extends z.Schema ? z.infer : undefined, +> = (dependencies: Dependencies, requestContext: RequestContext, args: Args) => Promise | void + +export const cliCommandWrapper = async ( + cliCommandName: string, + command: CliCommand, + argsSchema?: ArgsSchema, +): Promise => { + const app = await getApp({ + queuesEnabled: false, + jobsEnabled: false, + healthchecksEnabled: false, + monitoringEnabled: false, + }) + + const requestId = generateMonotonicUuid() + const reqContext: RequestContext = { + reqId: requestId, + logger: app.diContainer.cradle.logger.child({ + origin: cliCommandName, + 'x-request-id': requestId, + }), + } + + let args = undefined + if (argsSchema) { + const parseResult = argsSchema.safeParse(getArgs()) + if (!parseResult.success) { + reqContext.logger.error( + { + errors: JSON.stringify(parseResult.error.errors), + }, + 'Invalid arguments', + ) + await app.close() + process.exit(1) + } + + args = parseResult.data + } + + let isSuccess = true + try { + await command(app.diContainer.cradle, reqContext, args) + } catch (err) { + isSuccess = false + reqContext.logger.error( + { + error: JSON.stringify(isError(err) ? pino.stdSerializers.err(err) : err), + }, + 'Error running command', + ) + } + + await app.close() + process.exit(isSuccess ? 0 : 1) +} diff --git a/scripts/utils/cliContextUtils.spec.ts b/scripts/utils/cliContextUtils.spec.ts deleted file mode 100644 index e112a91f..00000000 --- a/scripts/utils/cliContextUtils.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getArgs } from './cliContextUtils.js' - -describe('getArgs', () => { - it('parses long arguments with values', () => { - process.argv = ['node', 'script.js', '--key=value'] - const args = getArgs() - expect(args).toEqual({ key: 'value' }) - }) - - it('parses long arguments without values', () => { - process.argv = ['node', 'script.js', '--flag'] - const args = getArgs() - expect(args).toEqual({ flag: true }) - }) - - it('parses short arguments', () => { - process.argv = ['node', 'script.js', '-abc'] - const args = getArgs() - expect(args).toEqual({ a: true, b: true, c: true }) - }) - - it('parses mixed arguments', () => { - process.argv = ['node', 'script.js', '--key=value', '-abc', '--flag'] - const args = getArgs() - expect(args).toEqual({ key: 'value', a: true, b: true, c: true, flag: true }) - }) - - it('handles no arguments', () => { - process.argv = ['node', 'script.js'] - const args = getArgs() - expect(args).toEqual({}) - }) - - it('handles empty long argument', () => { - process.argv = ['node', 'script.js', '--'] - const args = getArgs() - console.info(args) - expect(args).toEqual({}) - }) - - it('handles empty short argument', () => { - process.argv = ['node', 'script.js', '-'] - const args = getArgs() - expect(args).toEqual({}) - }) -}) diff --git a/scripts/utils/cliContextUtils.ts b/scripts/utils/cliContextUtils.ts deleted file mode 100644 index 82fbadce..00000000 --- a/scripts/utils/cliContextUtils.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { type ParseArgsConfig, parseArgs } from 'node:util' -import type { RequestContext } from '@lokalise/fastify-extras' -import { generateMonotonicUuid } from '@lokalise/id-utils' -import type { CommonLogger } from '@lokalise/node-core' -import type z from 'zod' -import { type AppInstance, getApp } from '../../src/app.js' - -export const getArgs = (config: Partial = {}) => { - const { values } = parseArgs({ - ...config, - args: process.argv, - strict: false, - } satisfies ParseArgsConfig) - - return values -} - -export const createCliContext = async ( - // biome-ignore lint/suspicious/noExplicitAny: This is a generic schema - ARGUMENTS_SCHEMA: z.ZodObject, - origin: string, -): Promise<{ - app: AppInstance - logger: CommonLogger - args: Arguments -}> => { - const app = await getApp({ - queuesEnabled: false, - jobsEnabled: false, - healthchecksEnabled: false, - monitoringEnabled: false, - }) - - const requestId = generateMonotonicUuid() - const reqContext: RequestContext = { - reqId: requestId, - logger: (app.diContainer.cradle.logger as CommonLogger).child({ - origin, - 'x-request-id': requestId, - }), - } - - const res = ARGUMENTS_SCHEMA.safeParse(getArgs()) - if (!res.success) { - reqContext.logger.error(res.error.errors, 'Invalid arguments') - await app.close() - process.exit(1) - } - const args = res.data as Arguments - - return { app, logger: reqContext.logger, args } -} - -export const destroyCliContext = async (app: AppInstance, failure = false) => { - await app.close() - process.exit(failure ? 1 : 0) -}