mirror of
https://github.com/PretendoNetwork/BOSS.git
synced 2026-07-15 16:31:18 -05:00
feat(cli): Implement 3DS support
Also add attributes support for Wii U.
This commit is contained in:
parent
dd8ef4dde6
commit
90f02ac286
|
|
@ -1,6 +1,7 @@
|
|||
import { program as baseProgram } from 'commander';
|
||||
import { taskCmd } from './tasks.cmd';
|
||||
import { fileCmd } from './files.cmd';
|
||||
import { file3DSCmd } from './files-3ds.cmd';
|
||||
import { appCmd } from './apps.cmd';
|
||||
import { importCmd } from './import.cmd';
|
||||
|
||||
|
|
@ -11,7 +12,8 @@ const program = baseProgram
|
|||
.addCommand(appCmd)
|
||||
.addCommand(taskCmd)
|
||||
.addCommand(importCmd)
|
||||
.addCommand(fileCmd);
|
||||
.addCommand(fileCmd)
|
||||
.addCommand(file3DSCmd);
|
||||
|
||||
program.parseAsync(process.argv)
|
||||
.catch(console.error)
|
||||
|
|
|
|||
193
src/cli/files-3ds.cmd.ts
Normal file
193
src/cli/files-3ds.cmd.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
import { request } from 'undici';
|
||||
import { Command } from 'commander';
|
||||
import { decrypt3DS } from '@pretendonetwork/boss-crypto';
|
||||
import { PlatformType } from '@pretendonetwork/grpc/boss/v2/platform_type';
|
||||
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(commandHandler<[string, string]>(async (cmd): Promise<void> => {
|
||||
const [appId, taskId] = cmd.args;
|
||||
const ctx = getCliContext();
|
||||
const { files } = await ctx.grpc.listFilesCTR({
|
||||
bossAppId: appId,
|
||||
taskId: taskId
|
||||
});
|
||||
logOutputList(files, {
|
||||
format: cmd.format,
|
||||
onlyIncludeKeys: ['dataId', 'name', 'size'],
|
||||
mapping: {
|
||||
dataId: 'Data ID',
|
||||
name: 'Name',
|
||||
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(commandHandler<[string, string, bigint]>(async (cmd): Promise<void> => {
|
||||
const [appId, taskId, dataId] = cmd.args;
|
||||
const ctx = getCliContext();
|
||||
const { files } = await ctx.grpc.listFilesCTR({
|
||||
bossAppId: appId,
|
||||
taskId: taskId
|
||||
});
|
||||
const file = files.find(v => v.dataId === dataId);
|
||||
if (!file) {
|
||||
console.log(`Could not find task file with data ID ${dataId} in task ${taskId}`);
|
||||
return;
|
||||
}
|
||||
logOutputObject({
|
||||
dataId: file.dataId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
hash: file.hash,
|
||||
supportedCountries: file.supportedCountries,
|
||||
supportedLanguages: file.supportedLanguages,
|
||||
attributes: file.attributes,
|
||||
creatorPid: file.creatorPid,
|
||||
payloadContents: file.payloadContents,
|
||||
flags: file.flags,
|
||||
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')
|
||||
.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)
|
||||
.option('-d, --decrypt', 'Decrypt the file before return')
|
||||
.action(commandHandler<[string, string, bigint]>(async (cmd): Promise<void> => {
|
||||
const [appId, taskId, dataId] = cmd.args;
|
||||
const ctx = getCliContext();
|
||||
const { files } = await ctx.grpc.listFilesCTR({
|
||||
bossAppId: appId,
|
||||
taskId: taskId
|
||||
});
|
||||
const file = files.find(v => v.dataId === dataId);
|
||||
if (!file) {
|
||||
console.error(`Could not find task file with data ID ${dataId} in task ${taskId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const npdl = ctx.getNpdlUrl();
|
||||
const country = file.supportedCountries.length > 0 ? '/' + file.supportedCountries[0] : '';
|
||||
const language = file.supportedLanguages.length > 0 ? '/' + file.supportedCountries[0] : '';
|
||||
const { body, statusCode } = await request(`${npdl.url}/p01/nsa/${file.bossAppId}/${file.taskId}${country}${language}/${file.name}`, {
|
||||
headers: {
|
||||
Host: npdl.host
|
||||
}
|
||||
});
|
||||
if (statusCode > 299) {
|
||||
console.error(`Failed to download: invalid status code (${statusCode})`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of body) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
|
||||
let buffer: Buffer = Buffer.concat(chunks);
|
||||
|
||||
if (cmd.opts().decrypt) {
|
||||
const keys = ctx.get3DSKeys();
|
||||
const decrypted = decrypt3DS(buffer, keys.aesKey);
|
||||
// TODO - Handle multiple payloads
|
||||
buffer = decrypted.payload_contents[0].content;
|
||||
}
|
||||
|
||||
await pipeline(Readable.from(buffer), process.stdout);
|
||||
}));
|
||||
|
||||
const createCmd = new Command('create')
|
||||
.description('Create a new task file')
|
||||
.argument('<app_id>', 'BOSS app to store the task file in')
|
||||
.argument('<task_id>', 'Task to store the task file in')
|
||||
.requiredOption('--name <name>', 'Name of the task file')
|
||||
.requiredOption('--title-id <titleId>', 'Target title ID of the payload')
|
||||
.requiredOption('--content-datatype <contentDatatype>', 'Content datatype of the payload')
|
||||
.requiredOption('--ns-data-id <nsDataId>', 'NS Data ID of the payload')
|
||||
.requiredOption('--version <version>', 'Version of the payload')
|
||||
.requiredOption('--file <file>', 'Path of the file to upload')
|
||||
.option('--country <country...>', 'Countries for this task file')
|
||||
.option('--lang <language...>', 'Languages for this task file')
|
||||
.option('--attribute1 [attribute1]', 'Attribute 1 for this task file')
|
||||
.option('--attribute2 [attribute2]', 'Attribute 2 for this task file')
|
||||
.option('--attribute3 [attribute3]', 'Attribute 3 for this task file')
|
||||
.option('--desc [desc]', 'Description for this task file')
|
||||
.option('-m, --mark-arrived-privileged', 'Only notify of new content to privileged titles')
|
||||
.action(commandHandler<[string, string]>(async (cmd): Promise<void> => {
|
||||
const [appId, taskId] = cmd.args;
|
||||
// TODO - Handle multiple payload contents
|
||||
const opts = cmd.opts<{ name: string; titleId: string; contentDatatype: string; nsDataId: string; version: string; file: string; country: string[]; lang: string[]; attribute1?: string; attribute2?: string; attribute3?: string; desc?: string; markArrivedPrivileged: boolean }>();
|
||||
const fileBuf = await fs.readFile(opts.file);
|
||||
const ctx = getCliContext();
|
||||
const { file } = await ctx.grpc.uploadFileCTR({
|
||||
taskId: taskId,
|
||||
bossAppId: appId,
|
||||
supportedCountries: opts.country,
|
||||
supportedLanguages: opts.lang,
|
||||
attributes: {
|
||||
attribute1: opts.attribute1,
|
||||
attribute2: opts.attribute2,
|
||||
attribute3: opts.attribute3,
|
||||
description: opts.desc
|
||||
},
|
||||
name: opts.name,
|
||||
payloadContents: [{
|
||||
titleId: BigInt(parseInt(opts.titleId, 16)),
|
||||
contentDatatype: Number(opts.contentDatatype),
|
||||
nsDataId: Number(opts.nsDataId),
|
||||
version: Number(opts.version),
|
||||
content: fileBuf
|
||||
}],
|
||||
flags: {
|
||||
markArrivedPrivileged: opts.markArrivedPrivileged
|
||||
}
|
||||
});
|
||||
if (!file) {
|
||||
console.log(`Failed to create file!`);
|
||||
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(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,
|
||||
platformType: PlatformType.PLATFORM_TYPE_CTR
|
||||
});
|
||||
console.log(`Deleted task file with ID ${dataId}`);
|
||||
}));
|
||||
|
||||
export const file3DSCmd = new Command('file-3ds')
|
||||
.description('Manage all the 3DS task files in BOSS')
|
||||
.addCommand(listCmd)
|
||||
.addCommand(createCmd)
|
||||
.addCommand(deleteCmd)
|
||||
.addCommand(downloadCmd)
|
||||
.addCommand(viewCmd);
|
||||
|
|
@ -123,12 +123,16 @@ const createCmd = new Command('create')
|
|||
.requiredOption('--file <file>', 'Path of the file to upload')
|
||||
.option('--country <country...>', 'Countries for this task file')
|
||||
.option('--lang <language...>', 'Languages for this task file')
|
||||
.option('--attribute1 [attribute1]', 'Attribute 1 for this task file')
|
||||
.option('--attribute2 [attribute2]', 'Attribute 2 for this task file')
|
||||
.option('--attribute3 [attribute3]', 'Attribute 3 for this task file')
|
||||
.option('--desc [desc]', 'Description for this task file')
|
||||
.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(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 opts = cmd.opts<{ name: string; country: string[]; notifyNew: string[]; notifyLed: boolean; lang: string[]; attribute1?: string; attribute2?: string; attribute3?: string; desc?: string; nameAsId?: boolean; type: string; file: string }>();
|
||||
const fileBuf = await fs.readFile(opts.file);
|
||||
const ctx = getCliContext();
|
||||
const { file } = await ctx.grpc.uploadFileWUP({
|
||||
|
|
@ -137,6 +141,12 @@ const createCmd = new Command('create')
|
|||
name: opts.name,
|
||||
supportedCountries: opts.country,
|
||||
supportedLanguages: opts.lang,
|
||||
attributes: {
|
||||
attribute1: opts.attribute1,
|
||||
attribute2: opts.attribute2,
|
||||
attribute3: opts.attribute3,
|
||||
description: opts.desc
|
||||
},
|
||||
type: opts.type,
|
||||
nameEqualsDataId: opts.nameAsId ?? false,
|
||||
data: fileBuf,
|
||||
|
|
@ -168,7 +178,7 @@ const deleteCmd = new Command('delete')
|
|||
}));
|
||||
|
||||
export const fileCmd = new Command('file')
|
||||
.description('Manage all the task files in BOSS')
|
||||
.description('Manage all the Wii U task files in BOSS')
|
||||
.addCommand(listCmd)
|
||||
.addCommand(createCmd)
|
||||
.addCommand(deleteCmd)
|
||||
|
|
|
|||
|
|
@ -6,15 +6,22 @@ import type { Command } from 'commander';
|
|||
import type { FormatOption } from './output';
|
||||
|
||||
export type WiiUKeys = { aesKey: string; hmacKey: string };
|
||||
export type CtrKeys = { aesKey: string };
|
||||
export type NpdiUrl = {
|
||||
host: string;
|
||||
url: string;
|
||||
};
|
||||
export type NpdlUrl = {
|
||||
host: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type CliContext = {
|
||||
grpc: BossServiceClient;
|
||||
getNpdiUrl: () => NpdiUrl;
|
||||
getNpdlUrl: () => NpdlUrl;
|
||||
getWiiUKeys: () => WiiUKeys;
|
||||
get3DSKeys: () => CtrKeys;
|
||||
};
|
||||
|
||||
export function getCliContext(): CliContext {
|
||||
|
|
@ -49,6 +56,15 @@ export function getCliContext(): CliContext {
|
|||
host: npdiHost
|
||||
};
|
||||
},
|
||||
getNpdlUrl(): NpdiUrl {
|
||||
const npdlUrl = process.env.PN_BOSS_CLI_NPDL_URL ?? 'https://npdl.cdn.pretendo.cc';
|
||||
const npdlHost = process.env.PN_BOSS_CLI_NPDL_HOST ?? new URL(npdlUrl).host;
|
||||
|
||||
return {
|
||||
url: npdlUrl,
|
||||
host: npdlHost
|
||||
};
|
||||
},
|
||||
getWiiUKeys(): WiiUKeys {
|
||||
const aesKey = process.env.PN_BOSS_CLI_WIIU_AES_KEY ?? '';
|
||||
const hmacKey = process.env.PN_BOSS_CLI_WIIU_HMAC_KEY ?? '';
|
||||
|
|
@ -63,6 +79,16 @@ export function getCliContext(): CliContext {
|
|||
aesKey,
|
||||
hmacKey
|
||||
};
|
||||
},
|
||||
get3DSKeys(): CtrKeys {
|
||||
const aesKey = process.env.PN_BOSS_CLI_3DS_AES_KEY ?? '';
|
||||
|
||||
if (!aesKey) {
|
||||
throw new Error('Missing env variable PN_BOSS_CLI_3DS_AES_KEY - needed for decryption');
|
||||
}
|
||||
return {
|
||||
aesKey
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user