705 lines
19 KiB
JavaScript
705 lines
19 KiB
JavaScript
const fs = require('fs').promises;
|
|
const fsSync = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
|
|
/**
|
|
* 工具函数:安全执行异步操作,忽略错误
|
|
*
|
|
* @template T
|
|
* @param {() => Promise<T>} fn - 要执行的异步函数
|
|
* @param {T} defaultValue - 出错时的默认值
|
|
* @returns {Promise<T>} 函数执行结果或默认值
|
|
*/
|
|
async function safeExecute(fn, defaultValue = undefined) {
|
|
try {
|
|
return await fn();
|
|
} catch (error) {
|
|
return defaultValue;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 工具函数:安全执行同步操作,忽略错误
|
|
*
|
|
* @template T
|
|
* @param {() => T} fn - 要执行的同步函数
|
|
* @param {T} defaultValue - 出错时的默认值
|
|
* @returns {T} 函数执行结果或默认值
|
|
*/
|
|
|
|
/**
|
|
* 错误类型常量.
|
|
*
|
|
* @readonly
|
|
* @enum {string}
|
|
*/
|
|
const ErrorType = {
|
|
/**
|
|
* 文件未找到错误.
|
|
*/
|
|
FILE_NOT_FOUND: 'FILE_NOT_FOUND',
|
|
/**
|
|
* 权限被拒绝错误.
|
|
*/
|
|
PERMISSION_DENIED: 'PERMISSION_DENIED',
|
|
/**
|
|
* 无效的哈希算法错误.
|
|
*/
|
|
INVALID_ALGORITHM: 'INVALID_ALGORITHM',
|
|
/**
|
|
* 哈希计算失败错误.
|
|
*/
|
|
HASH_CALCULATION_FAILED: 'HASH_CALCULATION_FAILED',
|
|
};
|
|
|
|
/**
|
|
* 自定义错误类,用于处理 GitHub Action 执行过程中的各种错误.
|
|
*
|
|
* @augments Error
|
|
*/
|
|
class ActionError extends Error {
|
|
/**
|
|
* 创建一个新的 ActionError 实例
|
|
*
|
|
* @param {string} type - 错误类型,应该是 ErrorType 中的一个值
|
|
* @param {string} message - 错误消息
|
|
* @param {string|null} [file] - 相关的文件路径(可选)
|
|
* @param {string|null} [details] - 额外的错误详情(可选)
|
|
*/
|
|
constructor(type, message, file = null, details = null) {
|
|
super(message);
|
|
this.type = type;
|
|
this.file = file;
|
|
this.details = details;
|
|
this.name = 'ActionError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GitHub Actions 输入处理类
|
|
* 提供从环境变量中读取 GitHub Actions 输入参数的静态方法.
|
|
*/
|
|
class ActionInputs {
|
|
/**
|
|
* 获取字符串类型的输入参数
|
|
*
|
|
* @param {string} name - 输入参数名称
|
|
* @param {boolean} [required] - 是否为必需参数
|
|
* @returns {string} 输入参数的值
|
|
* @throws {ActionError} 当必需参数未提供时抛出错误
|
|
*/
|
|
static getInput(name, required = false) {
|
|
const envName = `INPUT_${name.toUpperCase().replace(/-/g, '_')}`;
|
|
const value = process.env[envName] || '';
|
|
|
|
if (required && !value) {
|
|
throw new ActionError(ErrorType.INVALID_INPUT, `Input required and not supplied: ${name}`);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* 获取布尔类型的输入参数
|
|
*
|
|
* @param {string} name - 输入参数名称
|
|
* @param {boolean} [required] - 是否为必需参数
|
|
* @returns {boolean} 转换后的布尔值
|
|
* @throws {ActionError} 当必需参数未提供时抛出错误
|
|
*/
|
|
static getBooleanInput(name, required = false) {
|
|
const value = this.getInput(name, required).toLowerCase();
|
|
return value === 'true' || value === '1';
|
|
}
|
|
|
|
/**
|
|
* 获取多行字符串类型的输入参数
|
|
*
|
|
* @param {string} name - 输入参数名称
|
|
* @param {boolean} [required] - 是否为必需参数
|
|
* @returns {string[]} 按行分割并过滤空行后的字符串数组
|
|
* @throws {ActionError} 当必需参数未提供时抛出错误
|
|
*/
|
|
static getMultilineInput(name, required = false) {
|
|
const value = this.getInput(name, required);
|
|
return value.split('\n').filter(line => line.trim());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GitHub Actions 输出处理类
|
|
* 提供设置 GitHub Actions 输出和日志记录的静态方法.
|
|
*/
|
|
class ActionOutputs {
|
|
/**
|
|
* 设置 GitHub Actions 输出变量
|
|
*
|
|
* @param {string} name - 输出变量名称
|
|
* @param {string} value - 输出变量值
|
|
*/
|
|
static setOutput(name, value) {
|
|
const outputFile = process.env.GITHUB_OUTPUT;
|
|
if (outputFile) {
|
|
const fs = require('fs');
|
|
fs.appendFileSync(outputFile, `${name}=${value}\n`);
|
|
} else {
|
|
console.log(`::set-output name=${name}::${value}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 设置 Action 失败状态并退出
|
|
*
|
|
* @param {string} message - 失败消息
|
|
*/
|
|
static setFailed(message) {
|
|
console.log(`::error::${message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
/**
|
|
* 输出信息日志
|
|
*
|
|
* @param {string} message - 信息内容
|
|
*/
|
|
static info(message) {
|
|
console.log(message);
|
|
}
|
|
|
|
/**
|
|
* 输出警告日志
|
|
*
|
|
* @param {string} message - 警告内容
|
|
*/
|
|
static warning(message) {
|
|
console.log(`::warning::${message}`);
|
|
}
|
|
|
|
/**
|
|
* 输出错误日志
|
|
*
|
|
* @param {string} message - 错误内容
|
|
*/
|
|
static error(message) {
|
|
console.log(`::error::${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 文件发现引擎:提供文件模式解析、文件查找和验证功能,支持 glob 模式匹配
|
|
*/
|
|
class FileDiscovery {
|
|
/**
|
|
* 解析输入的文件路径模式
|
|
*
|
|
* @param {string|string[]} input - 文件路径模式,可以是字符串或字符串数组
|
|
* @returns {string[]} 解析后的文件路径模式数组
|
|
*/
|
|
parseFilePatterns(input) {
|
|
if (Array.isArray(input)) {
|
|
return input.map(line => line.trim()).filter(line => line);
|
|
}
|
|
return input
|
|
.split('\n')
|
|
.map(line => line.trim())
|
|
.filter(line => line);
|
|
}
|
|
|
|
/**
|
|
* 查找匹配的文件
|
|
*
|
|
* @param {string[]} patterns - 文件路径模式数组
|
|
* @returns {Promise<string[]>} 匹配的文件路径数组(已排序且去重)
|
|
*/
|
|
async findFiles(patterns) {
|
|
const files = new Set();
|
|
|
|
for (const pattern of patterns) {
|
|
await safeExecute(async () => {
|
|
const matchedFiles = await this.expandGlob(pattern.trim());
|
|
matchedFiles.forEach(file => files.add(file));
|
|
});
|
|
}
|
|
|
|
return Array.from(files).sort();
|
|
}
|
|
|
|
/**
|
|
* 验证文件存在性和可读性
|
|
*
|
|
* @param {string[]} files - 文件路径数组
|
|
* @returns {Promise<object[]>} 验证结果数组,包含文件状态信息
|
|
*/
|
|
async validateFiles(files) {
|
|
const results = [];
|
|
|
|
for (const file of files) {
|
|
try {
|
|
const stats = await fs.stat(file);
|
|
if (stats.isFile()) {
|
|
results.push({
|
|
file,
|
|
exists: true,
|
|
readable: true,
|
|
size: stats.size,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
results.push({
|
|
file,
|
|
exists: false,
|
|
readable: false,
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* 自实现的 glob 模式匹配
|
|
*
|
|
* @param {string} pattern - Glob 模式字符串
|
|
* @returns {Promise<string[]>} 匹配的文件路径数组
|
|
*/
|
|
async expandGlob(pattern) {
|
|
// 如果是普通文件路径,直接返回
|
|
if (!pattern.includes('*') && !pattern.includes('?')) {
|
|
const stats = await safeExecute(() => fs.stat(pattern));
|
|
if (stats) {
|
|
if (stats.isFile()) {
|
|
return [pattern];
|
|
} else if (stats.isDirectory()) {
|
|
// 如果是目录,返回目录下所有文件
|
|
return await this.getAllFilesInDirectory(pattern);
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
// 处理 glob 模式
|
|
return await this.matchGlobPattern(pattern);
|
|
}
|
|
|
|
/**
|
|
* 获取目录下所有文件(递归)
|
|
*
|
|
* @param {string} dirPath - 目录路径
|
|
* @param {boolean} [includeHidden] - 是否包含隐藏文件
|
|
* @returns {Promise<string[]>} 目录下所有文件的路径数组
|
|
*/
|
|
async getAllFilesInDirectory(dirPath, includeHidden = false) {
|
|
const files = [];
|
|
|
|
const entries = await safeExecute(() => fs.readdir(dirPath, { withFileTypes: true }), []);
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dirPath, entry.name);
|
|
|
|
// 默认忽略隐藏文件(以.开头的文件和目录)
|
|
if (!includeHidden && entry.name.startsWith('.')) {
|
|
continue;
|
|
}
|
|
|
|
if (entry.isFile()) {
|
|
files.push(fullPath);
|
|
} else if (entry.isDirectory()) {
|
|
const subFiles = await this.getAllFilesInDirectory(fullPath, includeHidden);
|
|
files.push(...subFiles);
|
|
}
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
/**
|
|
* 匹配 glob 模式
|
|
*
|
|
* @param {string} pattern - Glob 模式字符串
|
|
* @returns {Promise<string[]>} 匹配的文件路径数组
|
|
* @private
|
|
*/
|
|
async matchGlobPattern(pattern) {
|
|
const files = [];
|
|
|
|
// 分离目录部分和文件名部分
|
|
const dir = path.dirname(pattern);
|
|
const filename = path.basename(pattern);
|
|
|
|
// 如果目录部分包含通配符,需要递归处理
|
|
if (dir.includes('*')) {
|
|
return await this.matchGlobPatternRecursive(pattern);
|
|
}
|
|
|
|
// 处理简单的文件名通配符
|
|
const entries = await safeExecute(() => fs.readdir(dir, { withFileTypes: true }), []);
|
|
|
|
for (const entry of entries) {
|
|
// 默认忽略隐藏文件(以.开头的文件)
|
|
if (entry.name.startsWith('.')) {
|
|
continue;
|
|
}
|
|
|
|
if (entry.isFile() && this.matchPattern(entry.name, filename)) {
|
|
files.push(path.join(dir, entry.name));
|
|
}
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
/**
|
|
* 递归匹配 glob 模式
|
|
*
|
|
* @param {string} pattern - Glob 模式字符串
|
|
* @returns {Promise<string[]>} 匹配的文件路径数组
|
|
* @private
|
|
*/
|
|
async matchGlobPatternRecursive(pattern) {
|
|
const files = [];
|
|
|
|
// 处理 ** 通配符
|
|
if (pattern.includes('**')) {
|
|
const parts = pattern.split('**');
|
|
const basePath = parts[0].replace(/\*+$/, '').replace(/\/$/, '') || '.';
|
|
const remainingPattern = parts[1].replace(/^\//, '');
|
|
|
|
const allFiles = await safeExecute(() => this.getAllFilesInDirectory(basePath), []);
|
|
|
|
for (const file of allFiles) {
|
|
const relativePath = path.relative(basePath, file);
|
|
// 对于 **/pattern 模式,匹配相对路径的末尾部分
|
|
if (remainingPattern && (relativePath === remainingPattern || relativePath.endsWith('/' + remainingPattern))) {
|
|
files.push(file);
|
|
} else if (this.matchPattern(relativePath, remainingPattern)) {
|
|
// 对于包含通配符的模式,使用 matchPattern
|
|
files.push(file);
|
|
}
|
|
}
|
|
} else {
|
|
// 处理单层通配符
|
|
const parts = pattern.split('/');
|
|
files.push(...(await this.matchPatternParts(parts, '.')));
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
/**
|
|
* 匹配模式部分
|
|
*
|
|
* @param {string[]} parts - 模式部分数组
|
|
* @param {string} currentPath - 当前路径
|
|
* @returns {Promise<string[]>} 匹配的文件路径数组
|
|
* @private
|
|
*/
|
|
async matchPatternParts(parts, currentPath) {
|
|
if (parts.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const [currentPart, ...remainingParts] = parts;
|
|
const files = [];
|
|
|
|
const entries = await safeExecute(() => fs.readdir(currentPath, { withFileTypes: true }), []);
|
|
|
|
for (const entry of entries) {
|
|
// 默认忽略隐藏文件和目录(以.开头的)
|
|
if (entry.name.startsWith('.')) {
|
|
continue;
|
|
}
|
|
|
|
const fullPath = path.join(currentPath, entry.name);
|
|
|
|
if (this.matchPattern(entry.name, currentPart)) {
|
|
if (remainingParts.length === 0) {
|
|
// 最后一部分,检查是否为文件
|
|
if (entry.isFile()) {
|
|
files.push(fullPath);
|
|
}
|
|
} else {
|
|
// 还有更多部分,递归处理
|
|
if (entry.isDirectory()) {
|
|
const subFiles = await this.matchPatternParts(remainingParts, fullPath);
|
|
files.push(...subFiles);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
/**
|
|
* 简单的模式匹配函数
|
|
*
|
|
* @param {string} text - 要匹配的文本
|
|
* @param {string} pattern - Glob 模式
|
|
* @returns {boolean} 是否匹配
|
|
* @private
|
|
*/
|
|
matchPattern(text, pattern) {
|
|
// 将 glob 模式转换为正则表达式
|
|
const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
|
|
const regex = new RegExp(`^${regexPattern}$`);
|
|
return regex.test(text);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 哈希计算器
|
|
* 提供文件哈希计算功能,支持多种哈希算法
|
|
*/
|
|
class HashCalculator {
|
|
/**
|
|
* 构造函数
|
|
* 初始化支持的哈希算法列表
|
|
*/
|
|
constructor() {
|
|
this.supportedAlgorithms = ['md5', 'sha1', 'sha256', 'sha512'];
|
|
}
|
|
|
|
/**
|
|
* 验证算法是否支持
|
|
*
|
|
* @param {string} algorithm - 哈希算法名称
|
|
* @throws {ActionError} 当算法不支持时抛出错误
|
|
*/
|
|
validateAlgorithm(algorithm) {
|
|
if (!this.supportedAlgorithms.includes(algorithm.toLowerCase())) {
|
|
throw new ActionError(
|
|
ErrorType.INVALID_ALGORITHM,
|
|
`Unsupported algorithm: ${algorithm}. Supported algorithms: ${this.supportedAlgorithms.join(', ')}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 计算单个文件的哈希
|
|
*
|
|
* @param {string} filePath - 文件路径
|
|
* @param {string} algorithm - 哈希算法
|
|
* @returns {Promise<string>} 文件的哈希值(十六进制字符串)
|
|
* @throws {ActionError} 当文件读取或哈希计算失败时抛出错误
|
|
*/
|
|
async calculateFileHash(filePath, algorithm) {
|
|
return new Promise((resolve, reject) => {
|
|
const hash = crypto.createHash(algorithm);
|
|
const stream = fsSync.createReadStream(filePath);
|
|
|
|
stream.on('data', data => hash.update(data));
|
|
stream.on('end', () => resolve(hash.digest('hex')));
|
|
stream.on('error', error =>
|
|
reject(
|
|
new ActionError(
|
|
ErrorType.HASH_CALCULATION_FAILED,
|
|
`Failed to calculate hash for ${filePath}: ${error.message}`,
|
|
filePath,
|
|
error
|
|
)
|
|
)
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 计算多个文件的组合哈希
|
|
*
|
|
* @param {string[]} files - 文件路径数组
|
|
* @param {string} algorithm - 哈希算法
|
|
* @returns {Promise<string>} 组合哈希值(十六进制字符串)
|
|
* @throws {ActionError} 当算法不支持或文件哈希计算失败时抛出错误
|
|
*/
|
|
async calculateCombinedHash(files, algorithm) {
|
|
this.validateAlgorithm(algorithm);
|
|
|
|
const hash = crypto.createHash(algorithm);
|
|
|
|
// 按文件路径排序确保一致性
|
|
const sortedFiles = [...files].sort();
|
|
|
|
ActionOutputs.info(`Processing ${sortedFiles.length} files with ${algorithm.toUpperCase()} algorithm...`);
|
|
|
|
for (const file of sortedFiles) {
|
|
try {
|
|
const fileHash = await this.calculateFileHash(file, algorithm);
|
|
hash.update(fileHash);
|
|
ActionOutputs.info(`✓ ${file}`);
|
|
} catch (error) {
|
|
ActionOutputs.warning(`✗ ${file}: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const combinedHash = hash.digest('hex');
|
|
ActionOutputs.info(`Combined hash: ${combinedHash}`);
|
|
|
|
return combinedHash;
|
|
}
|
|
|
|
/**
|
|
* 获取支持的算法列表
|
|
*
|
|
* @returns {string[]} 支持的哈希算法数组的副本
|
|
*/
|
|
getSupportedAlgorithms() {
|
|
return [...this.supportedAlgorithms];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 输出格式化器
|
|
* 提供结果格式化和 GitHub Actions 输出设置功能
|
|
*/
|
|
class OutputFormatter {
|
|
/**
|
|
* 格式化输出
|
|
*
|
|
* @param {string|string[]} files - 文件列表或组合哈希值
|
|
* @param {string} algorithm - 哈希算法
|
|
* @param {string} combinedHash - 组合哈希值
|
|
* @returns {string|object} 格式化后的输出字符串或对象
|
|
*/
|
|
formatOutput(files, algorithm, combinedHash) {
|
|
// 如果第一个参数是数组,生成格式化的字符串输出
|
|
if (Array.isArray(files)) {
|
|
const fileCount = files.length;
|
|
let output = '';
|
|
|
|
if (fileCount === 0) {
|
|
output += 'No files found\n';
|
|
} else if (fileCount === 1) {
|
|
output += `Processing 1 file\n`;
|
|
output += `File: ${files[0]}\n`;
|
|
} else {
|
|
output += `Total files: ${fileCount}\n`;
|
|
files.forEach(file => {
|
|
output += ` ${file}\n`;
|
|
});
|
|
}
|
|
|
|
output += `Algorithm: ${algorithm.toLowerCase()}\n`;
|
|
output += `Combined Hash: ${combinedHash}`;
|
|
|
|
return output;
|
|
}
|
|
|
|
// 否则使用原来的对象格式(兼容模式)
|
|
return {
|
|
hash: files,
|
|
fileCount: algorithm,
|
|
algorithm: combinedHash.toUpperCase(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 设置 GitHub Actions 输出
|
|
*
|
|
* @param {string} hash - 哈希值
|
|
* @param {number} fileCount - 文件数量
|
|
*/
|
|
setGitHubOutput(hash, fileCount) {
|
|
ActionOutputs.setOutput('hash', hash);
|
|
ActionOutputs.setOutput('file-count', fileCount);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 主要的 Action 类;协调文件发现、哈希计算和输出格式化的完整工作流程
|
|
*/
|
|
class FilesHashAction {
|
|
/**
|
|
* 构造函数。初始化所需的组件实例
|
|
*/
|
|
constructor() {
|
|
this.fileDiscovery = new FileDiscovery();
|
|
this.hashCalculator = new HashCalculator();
|
|
this.outputFormatter = new OutputFormatter();
|
|
}
|
|
|
|
/**
|
|
* 运行 Action 的主要方法。执行完整的文件哈希计算工作流程
|
|
*
|
|
* @throws {ActionError} 当输入无效、文件不存在或哈希计算失败时抛出错误
|
|
*/
|
|
async run() {
|
|
try {
|
|
// 读取输入参数
|
|
const filesInput = ActionInputs.getMultilineInput('files', true);
|
|
const algorithm = ActionInputs.getInput('algorithm') || 'sha256';
|
|
|
|
ActionOutputs.info(`Files Hash Action started`);
|
|
ActionOutputs.info(`Algorithm: ${algorithm.toUpperCase()}`);
|
|
|
|
// 解析文件模式
|
|
const patterns = this.fileDiscovery.parseFilePatterns(filesInput);
|
|
ActionOutputs.info(`File patterns: ${patterns.join(', ')}`);
|
|
|
|
// 查找文件
|
|
const foundFiles = await this.fileDiscovery.findFiles(patterns);
|
|
ActionOutputs.info(`Found ${foundFiles.length} potential files`);
|
|
|
|
// 验证文件
|
|
const validationResults = await this.fileDiscovery.validateFiles(foundFiles);
|
|
const validFiles = validationResults
|
|
.filter(result => result.exists && result.readable)
|
|
.map(result => result.file);
|
|
|
|
const missingFiles = validationResults.filter(result => !result.exists).map(result => result.file);
|
|
|
|
// 处理缺失文件 - 直接抛出错误
|
|
if (missingFiles.length > 0) {
|
|
const message = `Missing files: ${missingFiles.join(', ')}`;
|
|
throw new ActionError(ErrorType.FILE_NOT_FOUND, message);
|
|
}
|
|
|
|
// 检查是否有有效文件 - 直接抛出错误
|
|
if (validFiles.length === 0) {
|
|
throw new ActionError(ErrorType.FILE_NOT_FOUND, 'No valid files found to process');
|
|
}
|
|
|
|
ActionOutputs.info(`Processing ${validFiles.length} valid files`);
|
|
|
|
// 计算组合哈希
|
|
const combinedHash = await this.hashCalculator.calculateCombinedHash(validFiles, algorithm);
|
|
|
|
// 格式化输出
|
|
const result = this.outputFormatter.formatOutput(combinedHash, validFiles.length, algorithm);
|
|
|
|
// 设置 GitHub Actions 输出
|
|
this.outputFormatter.setGitHubOutput(result.hash, result.fileCount);
|
|
|
|
ActionOutputs.info(`Action completed successfully`);
|
|
ActionOutputs.info(`Hash: ${result.hash}`);
|
|
ActionOutputs.info(`File count: ${result.fileCount}`);
|
|
} catch (error) {
|
|
if (error instanceof ActionError) {
|
|
ActionOutputs.setFailed(`${error.type}: ${error.message}`);
|
|
} else {
|
|
ActionOutputs.setFailed(`Unexpected error: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 运行 Action
|
|
if (require.main === module) {
|
|
const action = new FilesHashAction();
|
|
action.run();
|
|
}
|
|
|
|
module.exports = {
|
|
FilesHashAction,
|
|
FileDiscovery,
|
|
HashCalculator,
|
|
OutputFormatter,
|
|
ActionInputs,
|
|
ActionOutputs,
|
|
ActionError,
|
|
ErrorType,
|
|
safeExecute,
|
|
};
|