mirror of
https://github.com/PretendoNetwork/BOSS.git
synced 2026-04-22 06:57:38 -05:00
Merge pull request #36 from PretendoNetwork/feat/cli-output-format
Add output format to CLI
This commit is contained in:
commit
91bc93ed93
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<void> => {
|
||||
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('<id>', 'BOSS app ID to lookup')
|
||||
.action(async (id: string) => {
|
||||
.action(commandHandler<[string]>(async (cmd): Promise<void> => {
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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('<app_id>', 'BOSS app to search in')
|
||||
.argument('<task_id>', 'Task to search in')
|
||||
.action(async (appId: string, taskId: string) => {
|
||||
.action(commandHandler<[string, string]>(async (cmd): Promise<void> => {
|
||||
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('<app_id>', 'BOSS app that contains the task')
|
||||
.argument('<task_id>', 'Task that contains the task file')
|
||||
.argument('<id>', 'Task file ID to lookup', BigInt)
|
||||
.action(async (appId: string, taskId: string, dataId: bigint) => {
|
||||
.action(commandHandler<[string, string, bigint]>(async (cmd): Promise<void> => {
|
||||
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_id>', 'Task that contains the task file')
|
||||
.argument('<id>', '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<void> => {
|
||||
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 <type...>', '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<void> => {
|
||||
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('<app_id>', 'BOSS app that contains the task')
|
||||
.argument('<task_id>', 'Task that contains the task file')
|
||||
.argument('<id>', 'Task file ID to delete', BigInt)
|
||||
.action(async (appId: string, taskId: string, dataId: bigint) => {
|
||||
.action(commandHandler<[string, string, bigint]>(async (cmd): Promise<void> => {
|
||||
// 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')
|
||||
|
|
|
|||
61
src/cli/output.ts
Normal file
61
src/cli/output.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
export type FormattableObject = Record<string, any>;
|
||||
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<T extends FormattableObject = any> = {
|
||||
format: FormatOption;
|
||||
onlyIncludeKeys?: Array<keyof T>;
|
||||
mapping?: Partial<Record<keyof T, string>>;
|
||||
prettify?: (key: string, value: any) => any;
|
||||
};
|
||||
|
||||
export function logOutputList<T extends FormattableObject>(items: T[], ops: FormattedOutputOptions<T>): 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<T extends FormattableObject>(obj: T, ops: FormattedOutputOptions<T>): void {
|
||||
const processedObj = preprocessObject(obj, ops);
|
||||
if (ops.format === 'json') {
|
||||
console.log(JSON.stringify(processedObj, jsonReplacer, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(makePrettyOutputObject(processedObj, ops));
|
||||
}
|
||||
|
|
@ -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('<app_id>', 'BOSS app to search in')
|
||||
.action(async (appId: string) => {
|
||||
.action(commandHandler<[string]>(async (cmd): Promise<void> => {
|
||||
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('<app_id>', 'BOSS app ID that contains the task')
|
||||
.argument('<id>', 'Task ID to lookup')
|
||||
.action(async (appId: string, taskId: string) => {
|
||||
.action(commandHandler<[string, string]>(async (cmd): Promise<void> => {
|
||||
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>', 'Id of the task')
|
||||
.requiredOption('--title-id <titleId>', '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<void> => {
|
||||
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('<app_id>', 'BOSS app ID that contains the task')
|
||||
.argument('<id>', 'Task ID to delete')
|
||||
.action(async (appId: string, taskId: string) => {
|
||||
.action(commandHandler<[string, string]>(async (cmd): Promise<void> => {
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -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<T extends any[]> = {
|
||||
opts: <T = Record<string, any>>() => T;
|
||||
globalOpts: {
|
||||
json?: boolean;
|
||||
};
|
||||
format: FormatOption;
|
||||
args: T;
|
||||
};
|
||||
|
||||
export function commandHandler<T extends any[]>(cb: (ctx: CommandHandlerCtx<T>) => Promise<void>) {
|
||||
return (...args: any[]): Promise<void> => {
|
||||
const cmd: Command = args[args.length - 1];
|
||||
|
||||
let topCmd = cmd;
|
||||
while (topCmd.parent) {
|
||||
topCmd = topCmd.parent;
|
||||
}
|
||||
const globalOpts = topCmd.opts() ?? {};
|
||||
|
||||
const ctx: CommandHandlerCtx<T> = {
|
||||
args: args as T,
|
||||
globalOpts,
|
||||
format: globalOpts.json ? 'json' : 'pretty',
|
||||
opts: () => cmd.opts() as any
|
||||
};
|
||||
|
||||
return cb(ctx);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user