diff --git a/Dockerfile b/Dockerfile index 4b3d7d5..5e8c313 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ FROM node:20-alpine AS base ARG app_dir WORKDIR ${app_dir} +RUN apk update && apk add libc6-compat # * Installing production dependencies FROM base AS dependencies diff --git a/src/cli/apps.cmd.ts b/src/cli/apps.cmd.ts index 62a0289..365ce72 100644 --- a/src/cli/apps.cmd.ts +++ b/src/cli/apps.cmd.ts @@ -1,23 +1,35 @@ import { Command } from 'commander'; -import { getCliContext, prettyTrunc } from './utils'; +import { commandHandler, getCliContext, prettyTrunc } from './utils'; +import { logOutputList, logOutputObject } from './output'; const listCmd = new Command('ls') .description('List all apps in BOSS') - .action(async () => { + .action(commandHandler<[]>(async (cmd): Promise => { const ctx = getCliContext(); const { apps } = await ctx.grpc.listKnownBOSSApps({}); - console.table(apps.map(v => ({ - 'App ID': v.bossAppId, - 'Name': prettyTrunc(v.name, 20), - 'Title ID': v.titleId, - 'Title region': v.titleRegion - }))); - }); + logOutputList(apps, { + format: cmd.format, + onlyIncludeKeys: ['bossAppId', 'name', 'titleId', 'titleRegion'], + mapping: { + bossAppId: 'App ID', + name: 'Name', + titleId: 'Title ID', + titleRegion: 'Title region' + }, + prettify(key, value) { + if (key === 'name') { + return prettyTrunc(value, 20); + } + return value; + } + }); + })); const viewCmd = new Command('view') .description('Look up a specific BOSS app') .argument('', 'BOSS app ID to lookup') - .action(async (id: string) => { + .action(commandHandler<[string]>(async (cmd): Promise => { + const [id] = cmd.args; const ctx = getCliContext(); const { apps } = await ctx.grpc.listKnownBOSSApps({}); const app = apps.find(v => v.bossAppId === id); @@ -26,14 +38,17 @@ const viewCmd = new Command('view') return; } - console.log({ + const obj = { appId: app.bossAppId, name: app.name, titleId: app.titleId, titleRegion: app.titleRegion, knownTasks: app.tasks + }; + logOutputObject(obj, { + format: cmd.format }); - }); + })); export const appCmd = new Command('app') .description('Manage all the apps in BOSS') diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 03c0e11..b2a3ec9 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -7,6 +7,7 @@ import { importCmd } from './import.cmd'; const program = baseProgram .name('BOSS') .description('CLI to manage and view BOSS data') + .option('--json', 'Output as JSON') .addCommand(appCmd) .addCommand(taskCmd) .addCommand(importCmd) diff --git a/src/cli/files.cmd.ts b/src/cli/files.cmd.ts index 51ac755..5e51b0f 100644 --- a/src/cli/files.cmd.ts +++ b/src/cli/files.cmd.ts @@ -4,32 +4,39 @@ import { Readable } from 'node:stream'; import { request } from 'undici'; import { Command } from 'commander'; import { decryptWiiU } from '@pretendonetwork/boss-crypto'; -import { getCliContext } from './utils'; +import { commandHandler, getCliContext } from './utils'; +import { logOutputList, logOutputObject } from './output'; const listCmd = new Command('ls') .description('List all task files in BOSS') .argument('', 'BOSS app to search in') .argument('', 'Task to search in') - .action(async (appId: string, taskId: string) => { + .action(commandHandler<[string, string]>(async (cmd): Promise => { + const [appId, taskId] = cmd.args; const ctx = getCliContext(); const { files } = await ctx.grpc.listFiles({ bossAppId: appId, taskId: taskId }); - console.table(files.map(v => ({ - 'Data ID': Number(v.dataId), - 'Name': v.name, - 'Type': v.type, - 'Size (bytes)': Number(v.size) - }))); - }); + logOutputList(files, { + format: cmd.format, + onlyIncludeKeys: ['dataId', 'name', 'type', 'size'], + mapping: { + dataId: 'Data ID', + name: 'Name', + type: 'Type', + size: 'Size (bytes)' + } + }); + })); const viewCmd = new Command('view') .description('Look up a specific task file') .argument('', 'BOSS app that contains the task') .argument('', 'Task that contains the task file') .argument('', 'Task file ID to lookup', BigInt) - .action(async (appId: string, taskId: string, dataId: bigint) => { + .action(commandHandler<[string, string, bigint]>(async (cmd): Promise => { + const [appId, taskId, dataId] = cmd.args; const ctx = getCliContext(); const { files } = await ctx.grpc.listFiles({ bossAppId: appId, @@ -40,11 +47,11 @@ const viewCmd = new Command('view') console.log(`Could not find task file with data ID ${dataId} in task ${taskId}`); return; } - console.log({ - dataId: Number(file.dataId), + logOutputObject({ + dataId: file.dataId, name: file.name, type: file.type, - size: Number(file.size), + size: file.size, hash: file.hash, supportedCountries: file.supportedCountries, supportedLanguages: file.supportedLanguages, @@ -55,8 +62,10 @@ const viewCmd = new Command('view') }, createdAt: new Date(Number(file.createdTimestamp)), updatedAt: new Date(Number(file.updatedTimestamp)) + }, { + format: cmd.format }); - }); + })); const downloadCmd = new Command('download') .description('Download a task file') @@ -64,7 +73,8 @@ const downloadCmd = new Command('download') .argument('', 'Task that contains the task file') .argument('', 'Task file ID to lookup', BigInt) .option('-d, --decrypt', 'Decrypt the file before return') - .action(async (appId: string, taskId: string, dataId: bigint, ops: { decrypt: boolean }) => { + .action(commandHandler<[string, string, bigint]>(async (cmd): Promise => { + const [appId, taskId, dataId] = cmd.args; const ctx = getCliContext(); const { files } = await ctx.grpc.listFiles({ bossAppId: appId, @@ -94,14 +104,14 @@ const downloadCmd = new Command('download') let buffer: Buffer = Buffer.concat(chunks); - if (ops.decrypt) { + if (cmd.opts().decrypt) { const keys = ctx.getWiiUKeys(); const decrypted = decryptWiiU(buffer, keys.aesKey, keys.hmacKey); buffer = decrypted.content; } await pipeline(Readable.from(buffer), process.stdout); - }); + })); const createCmd = new Command('create') .description('Create a new task file') @@ -115,7 +125,9 @@ const createCmd = new Command('create') .option('--name-as-id', 'Force the name as the data ID') .option('--notify-new ', 'Add entry to NotifyNew') .option('--notify-led', 'Enable NotifyLED') - .action(async (appId: string, taskId: string, opts: { name: string; country: string[]; notifyNew: string[]; notifyLed: boolean; lang: string[]; nameAsId?: boolean; type: string; file: string }) => { + .action(commandHandler<[string, string]>(async (cmd): Promise => { + const [appId, taskId] = cmd.args; + const opts = cmd.opts<{ name: string; country: string[]; notifyNew: string[]; notifyLed: boolean; lang: string[]; nameAsId?: boolean; type: string; file: string }>(); const fileBuf = await fs.readFile(opts.file); const ctx = getCliContext(); const { file } = await ctx.grpc.uploadFile({ @@ -135,21 +147,23 @@ const createCmd = new Command('create') return; } console.log(`Created file with ID ${file.dataId}`); - }); + })); const deleteCmd = new Command('delete') .description('Delete a task file') .argument('', 'BOSS app that contains the task') .argument('', 'Task that contains the task file') .argument('', 'Task file ID to delete', BigInt) - .action(async (appId: string, taskId: string, dataId: bigint) => { + .action(commandHandler<[string, string, bigint]>(async (cmd): Promise => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- I want to use destructuring + const [appId, _taskId, dataId] = cmd.args; const ctx = getCliContext(); await ctx.grpc.deleteFile({ bossAppId: appId, dataId: dataId }); console.log(`Deleted task file with ID ${dataId}`); - }); + })); export const fileCmd = new Command('file') .description('Manage all the task files in BOSS') diff --git a/src/cli/output.ts b/src/cli/output.ts new file mode 100644 index 0000000..0342157 --- /dev/null +++ b/src/cli/output.ts @@ -0,0 +1,61 @@ +export type FormattableObject = Record; +export type FormatOption = 'json' | 'pretty'; + +function preprocessObject(obj: FormattableObject, ops: FormattedOutputOptions): FormattableObject { + const onlyIncludeKeys = ops.onlyIncludeKeys; + if (!onlyIncludeKeys) { + return obj; + } + + const entries = Object.entries(obj); + const filteredEntries = entries.filter(v => onlyIncludeKeys.includes(v[0])); + return Object.fromEntries(filteredEntries); +} + +function makePrettyOutputObject(obj: FormattableObject, ops: FormattedOutputOptions): FormattableObject { + const entries = Object.entries(obj); + const prettifiedEntries = entries.map((v) => { + const value = ops.prettify ? ops.prettify(v[0], v[1]) : v[1]; + return [v[0], value]; + }); + const mappedEntries = prettifiedEntries.map((v) => { + const newKey = ops.mapping?.[v[0]] ?? v[0]; + return [newKey, v[1]] as const; + }); + return Object.fromEntries(mappedEntries); +} + +function jsonReplacer(key: string, value: any): any { + if (typeof value === 'bigint') { + return Number(value); + } + return value; +} + +export type FormattedOutputOptions = { + format: FormatOption; + onlyIncludeKeys?: Array; + mapping?: Partial>; + prettify?: (key: string, value: any) => any; +}; + +export function logOutputList(items: T[], ops: FormattedOutputOptions): void { + const processedItems = items.map(v => preprocessObject(v, ops)); + if (ops.format === 'json') { + console.log(JSON.stringify(processedItems, jsonReplacer, 2)); + return; + } + + const mappedItems = processedItems.map(item => makePrettyOutputObject(item, ops)); + console.table(mappedItems); +} + +export function logOutputObject(obj: T, ops: FormattedOutputOptions): void { + const processedObj = preprocessObject(obj, ops); + if (ops.format === 'json') { + console.log(JSON.stringify(processedObj, jsonReplacer, 2)); + return; + } + + console.log(makePrettyOutputObject(processedObj, ops)); +} diff --git a/src/cli/tasks.cmd.ts b/src/cli/tasks.cmd.ts index bc70229..ef41894 100644 --- a/src/cli/tasks.cmd.ts +++ b/src/cli/tasks.cmd.ts @@ -1,25 +1,32 @@ import { Command } from 'commander'; -import { getCliContext } from './utils'; +import { commandHandler, getCliContext } from './utils'; +import { logOutputList, logOutputObject } from './output'; const listCmd = new Command('ls') .description('List all tasks in BOSS') .argument('', 'BOSS app to search in') - .action(async (appId: string) => { + .action(commandHandler<[string]>(async (cmd): Promise => { + const [appId] = cmd.args; const ctx = getCliContext(); const { tasks } = await ctx.grpc.listTasks({}); const filteredTasks = tasks.filter(v => v.bossAppId === appId); - console.table(filteredTasks.map(v => ({ - 'Task ID': v.id, - 'Description': v.description, - 'Status': v.status - }))); - }); + logOutputList(filteredTasks, { + format: cmd.format, + mapping: { + id: 'Task ID', + description: 'Description', + status: 'Status' + }, + onlyIncludeKeys: ['id', 'description', 'status'] + }); + })); const viewCmd = new Command('view') .description('Look up a specific task') .argument('', 'BOSS app ID that contains the task') .argument('', 'Task ID to lookup') - .action(async (appId: string, taskId: string) => { + .action(commandHandler<[string, string]>(async (cmd): Promise => { + const [appId, taskId] = cmd.args; const ctx = getCliContext(); const { tasks } = await ctx.grpc.listTasks({}); const task = tasks.find(v => v.bossAppId === appId && v.id === taskId); @@ -27,7 +34,7 @@ const viewCmd = new Command('view') console.log(`Could not find task with ID ${taskId} in app ${appId}`); return; } - console.log({ + logOutputObject({ taskId: task.id, inGameId: task.inGameId, description: task.description, @@ -37,8 +44,10 @@ const viewCmd = new Command('view') status: task.status, createdAt: new Date(Number(task.createdTimestamp)), updatedAt: new Date(Number(task.updatedTimestamp)) + }, { + format: cmd.format }); - }); + })); const createCmd = new Command('create') .description('Create a new task') @@ -46,8 +55,10 @@ const createCmd = new Command('create') .requiredOption('--id ', 'Id of the task') .requiredOption('--title-id ', 'Title ID for the task') .option('--desc [desc]', 'Description of the task') - .action(async (appId: string, opts: { id: string; titleId: string; desc?: string }) => { + .action(commandHandler<[string]>(async (cmd): Promise => { + const [appId] = cmd.args; const ctx = getCliContext(); + const opts = cmd.opts<{ id: string; titleId: string; desc?: string }>(); const { task } = await ctx.grpc.registerTask({ bossAppId: appId, id: opts.id, @@ -60,20 +71,21 @@ const createCmd = new Command('create') return; } console.log(`Created task with ID ${task.id}`); - }); + })); const deleteCmd = new Command('delete') .description('Delete a task') .argument('', 'BOSS app ID that contains the task') .argument('', 'Task ID to delete') - .action(async (appId: string, taskId: string) => { + .action(commandHandler<[string, string]>(async (cmd): Promise => { + const [appId, taskId] = cmd.args; const ctx = getCliContext(); await ctx.grpc.deleteTask({ bossAppId: appId, id: taskId }); console.log(`Deleted task with ID ${taskId}`); - }); + })); export const taskCmd = new Command('task') .description('Manage all the tasks in BOSS') diff --git a/src/cli/utils.ts b/src/cli/utils.ts index c82a3ee..6d7ab63 100644 --- a/src/cli/utils.ts +++ b/src/cli/utils.ts @@ -2,6 +2,8 @@ import { BOSSDefinition } from '@pretendonetwork/grpc/boss/boss_service'; import { createChannel, createClient, Metadata } from 'nice-grpc'; import dotenv from 'dotenv'; import type { BOSSClient } from '@pretendonetwork/grpc/boss/boss_service'; +import type { Command } from 'commander'; +import type { FormatOption } from './output'; export type WiiUKeys = { aesKey: string; hmacKey: string }; export type NpdiUrl = { @@ -71,3 +73,33 @@ export function prettyTrunc(str: string, len: number): string { } return str; } + +export type CommandHandlerCtx = { + opts: >() => T; + globalOpts: { + json?: boolean; + }; + format: FormatOption; + args: T; +}; + +export function commandHandler(cb: (ctx: CommandHandlerCtx) => Promise) { + return (...args: any[]): Promise => { + const cmd: Command = args[args.length - 1]; + + let topCmd = cmd; + while (topCmd.parent) { + topCmd = topCmd.parent; + } + const globalOpts = topCmd.opts() ?? {}; + + const ctx: CommandHandlerCtx = { + args: args as T, + globalOpts, + format: globalOpts.json ? 'json' : 'pretty', + opts: () => cmd.opts() as any + }; + + return cb(ctx); + }; +}