diff --git a/src/identities/CommandPermissions.ts b/src/identities/CommandPermissions.ts index 813afe7b..10622ebf 100644 --- a/src/identities/CommandPermissions.ts +++ b/src/identities/CommandPermissions.ts @@ -52,7 +52,7 @@ class CommandPermissions extends CommandPolykey { logger: this.logger.getChild(PolykeyClient.name), }); const [type, id] = gestaltId; - let actions: string[] = []; + let actions: Array = []; switch (type) { case 'node': { diff --git a/src/keys/CommandCert.ts b/src/keys/CommandCert.ts index 68c39dfa..8c94ebee 100644 --- a/src/keys/CommandCert.ts +++ b/src/keys/CommandCert.ts @@ -54,7 +54,7 @@ class CommandCert extends CommandPolykey { }; let output: any = result; if (options.format === 'human') { - output = [`Root certificate:\t\t${result.cert}`]; + output = ['Root certificate:', result.cert]; } process.stdout.write( binUtils.outputFormatter({ diff --git a/src/keys/CommandCertchain.ts b/src/keys/CommandCertchain.ts index 046e47e1..ff212622 100644 --- a/src/keys/CommandCertchain.ts +++ b/src/keys/CommandCertchain.ts @@ -57,7 +57,7 @@ class CommandsCertchain extends CommandPolykey { }; let output: any = result; if (options.format === 'human') { - output = [`Root Certificate Chain:\t\t${result.certchain}`]; + output = ['Root Certificate Chain:', ...result.certchain]; } process.stdout.write( binUtils.outputFormatter({ diff --git a/src/keys/CommandDecrypt.ts b/src/keys/CommandDecrypt.ts index d6760ea2..3aeec793 100644 --- a/src/keys/CommandDecrypt.ts +++ b/src/keys/CommandDecrypt.ts @@ -76,7 +76,7 @@ class CommandDecrypt extends CommandPolykey { }; let output: any = result; if (options.format === 'human') { - output = [`Decrypted data:\t\t${result.decryptedData}`]; + output = [`Decrypted data:${' '.repeat(4)}${result.decryptedData}`]; } process.stdout.write( binUtils.outputFormatter({ diff --git a/src/keys/CommandEncrypt.ts b/src/keys/CommandEncrypt.ts index c8e1ca8a..39c50ca4 100644 --- a/src/keys/CommandEncrypt.ts +++ b/src/keys/CommandEncrypt.ts @@ -103,7 +103,7 @@ class CommandEncypt extends CommandPolykey { }; let output: any = result; if (options.format === 'human') { - output = [`Encrypted data:\t\t${result.encryptedData}`]; + output = [`Encrypted data:${' '.repeat(4)}${result.encryptedData}`]; } process.stdout.write( binUtils.outputFormatter({ diff --git a/src/keys/CommandSign.ts b/src/keys/CommandSign.ts index 7a419be2..44f4c0f7 100644 --- a/src/keys/CommandSign.ts +++ b/src/keys/CommandSign.ts @@ -76,7 +76,7 @@ class CommandSign extends CommandPolykey { }; let output: any = result; if (options.format === 'human') { - output = [`Signature:\t\t${result.signature}`]; + output = [`Signature:${' '.repeat(4)}${result.signature}`]; } process.stdout.write( binUtils.outputFormatter({ diff --git a/src/keys/CommandVerify.ts b/src/keys/CommandVerify.ts index f2f3690d..c93ac84a 100644 --- a/src/keys/CommandVerify.ts +++ b/src/keys/CommandVerify.ts @@ -112,7 +112,9 @@ class CommandVerify extends CommandPolykey { }; let output: any = result; if (options.format === 'human') { - output = [`Signature verified:\t\t${result.signatureVerified}`]; + output = [ + `Signature verified:${' '.repeat(4)}${result.signatureVerified}`, + ]; } process.stdout.write( binUtils.outputFormatter({ diff --git a/src/nodes/CommandClaim.ts b/src/nodes/CommandClaim.ts index 4847fc36..1d16b4c4 100644 --- a/src/nodes/CommandClaim.ts +++ b/src/nodes/CommandClaim.ts @@ -65,7 +65,7 @@ class CommandClaim extends CommandPolykey { ); const claimed = response.success; if (claimed) { - const formattedOutput = await binUtils.outputFormatter({ + const outputFormatted = binUtils.outputFormatter({ type: options.format === 'json' ? 'json' : 'list', data: [ `Successfully generated a cryptolink claim on Keynode with ID ${nodesUtils.encodeNodeId( @@ -73,9 +73,9 @@ class CommandClaim extends CommandPolykey { )}`, ], }); - process.stdout.write(formattedOutput); + process.stdout.write(outputFormatted); } else { - const formattedOutput = await binUtils.outputFormatter({ + const outputFormatted = binUtils.outputFormatter({ type: options.format === 'json' ? 'json' : 'list', data: [ `Successfully sent Gestalt Invite notification to Keynode with ID ${nodesUtils.encodeNodeId( @@ -83,7 +83,7 @@ class CommandClaim extends CommandPolykey { )}`, ], }); - process.stdout.write(formattedOutput); + process.stdout.write(outputFormatted); } } finally { if (pkClient! != null) await pkClient.stop(); diff --git a/src/nodes/CommandConnections.ts b/src/nodes/CommandConnections.ts index f67e8060..62ddece0 100644 --- a/src/nodes/CommandConnections.ts +++ b/src/nodes/CommandConnections.ts @@ -59,11 +59,11 @@ class CommandAdd extends CommandPolykey { }, auth); if (options.format === 'human') { // Wait for outputFormatter to complete and then write to stdout - const formattedOutput = await binUtils.outputFormatter({ + const outputFormatted = binUtils.outputFormatter({ type: 'table', data: connections, options: { - headers: [ + columns: [ 'host', 'hostname', 'nodeIdEncoded', @@ -71,16 +71,17 @@ class CommandAdd extends CommandPolykey { 'timeout', 'usageCount', ], + includeHeaders: true, }, }); - process.stdout.write(formattedOutput); + process.stdout.write(outputFormatted); } else { // Wait for outputFormatter to complete and then write to stdout - const formattedOutput = await binUtils.outputFormatter({ + const outputFormatted = binUtils.outputFormatter({ type: 'json', data: connections, }); - process.stdout.write(formattedOutput); + process.stdout.write(outputFormatted); } } finally { if (pkClient! != null) await pkClient.stop(); diff --git a/src/nodes/CommandFind.ts b/src/nodes/CommandFind.ts index 33d7230c..e2737636 100644 --- a/src/nodes/CommandFind.ts +++ b/src/nodes/CommandFind.ts @@ -90,11 +90,11 @@ class CommandFind extends CommandPolykey { } let output: any = result; if (options.format === 'human') output = [result.message]; - const formattedOutput = await binUtils.outputFormatter({ + const outputFormatted = binUtils.outputFormatter({ type: options.format === 'json' ? 'json' : 'list', data: output, }); - process.stdout.write(formattedOutput); + process.stdout.write(outputFormatted); // Like ping it should error when failing to find node for automation reasons. if (!result.success) { throw new errors.ErrorPolykeyCLINodeFindFailed(result.message); diff --git a/src/nodes/CommandGetAll.ts b/src/nodes/CommandGetAll.ts index 21b0da47..8f71006c 100644 --- a/src/nodes/CommandGetAll.ts +++ b/src/nodes/CommandGetAll.ts @@ -60,11 +60,11 @@ class CommandGetAll extends CommandPolykey { `NodeId ${value.nodeIdEncoded}, Address ${value.host}:${value.port}, bucketIndex ${value.bucketIndex}`, ); } - const formattedOutput = await binUtils.outputFormatter({ + const outputFormatted = binUtils.outputFormatter({ type: options.format === 'json' ? 'json' : 'list', data: output, }); - process.stdout.write(formattedOutput); + process.stdout.write(outputFormatted); } finally { if (pkClient! != null) await pkClient.stop(); } diff --git a/src/nodes/CommandPing.ts b/src/nodes/CommandPing.ts index 49478e43..410e1cf3 100644 --- a/src/nodes/CommandPing.ts +++ b/src/nodes/CommandPing.ts @@ -68,11 +68,11 @@ class CommandPing extends CommandPolykey { else status.message = error.message; const output: any = options.format === 'json' ? status : [status.message]; - const formattedOutput = await binUtils.outputFormatter({ + const outputFormatted = binUtils.outputFormatter({ type: options.format === 'json' ? 'json' : 'list', data: output, }); - process.stdout.write(formattedOutput); + process.stdout.write(outputFormatted); if (error != null) throw error; } finally { diff --git a/src/notifications/CommandRead.ts b/src/notifications/CommandRead.ts index 543f33e2..05ed1e3f 100644 --- a/src/notifications/CommandRead.ts +++ b/src/notifications/CommandRead.ts @@ -79,11 +79,11 @@ class CommandRead extends CommandPolykey { notifications.push(notification); } for (const notification of notifications) { - const formattedOutput = await binUtils.outputFormatter({ + const outputFormatted = binUtils.outputFormatter({ type: options.format === 'json' ? 'json' : 'dict', data: notification, }); - process.stdout.write(formattedOutput); + process.stdout.write(outputFormatted); } } finally { if (pkClient! != null) await pkClient.stop(); diff --git a/src/secrets/CommandGet.ts b/src/secrets/CommandGet.ts index 6b56281f..917edd88 100644 --- a/src/secrets/CommandGet.ts +++ b/src/secrets/CommandGet.ts @@ -58,12 +58,14 @@ class CommandGet extends CommandPolykey { }), meta, ); - const secretContent = Buffer.from(response.secretContent, 'binary'); - const formattedOutput = await binUtils.outputFormatter({ + const secretContent = response.secretContent; + const outputFormatted = binUtils.outputFormatter({ type: 'raw', - data: secretContent, + data: binUtils.encodeEscapedWrapped(secretContent) + ? binUtils.encodeEscaped(secretContent) + : secretContent, }); - process.stdout.write(formattedOutput); + process.stdout.write(outputFormatted); } finally { if (pkClient! != null) await pkClient.stop(); } diff --git a/src/secrets/CommandList.ts b/src/secrets/CommandList.ts index 193c2061..0dc2d3a7 100644 --- a/src/secrets/CommandList.ts +++ b/src/secrets/CommandList.ts @@ -57,12 +57,12 @@ class CommandList extends CommandPolykey { return data; }, auth); - const formattedOutput = await binUtils.outputFormatter({ + const outputFormatted = binUtils.outputFormatter({ type: options.format === 'json' ? 'json' : 'list', data: data, }); - process.stdout.write(formattedOutput); + process.stdout.write(outputFormatted); } finally { if (pkClient! != null) await pkClient.stop(); } diff --git a/src/secrets/CommandStat.ts b/src/secrets/CommandStat.ts index 31880a86..5cc6934d 100644 --- a/src/secrets/CommandStat.ts +++ b/src/secrets/CommandStat.ts @@ -60,18 +60,18 @@ class CommandStat extends CommandPolykey { meta, ); - const data: string[] = [`Stats for "${secretPath[1]}"`]; + const data: Array = [`Stats for "${secretPath[1]}"`]; for (const [key, value] of Object.entries(response.stat)) { data.push(`${key}: ${value}`); } // Assuming the surrounding function is async - const formattedOutput = await binUtils.outputFormatter({ + const outputFormatted = binUtils.outputFormatter({ type: options.format === 'json' ? 'json' : 'list', data, }); - process.stdout.write(formattedOutput); + process.stdout.write(outputFormatted); } finally { if (pkClient! != null) await pkClient.stop(); } diff --git a/src/types.ts b/src/types.ts index f29f3189..a0377f61 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,14 @@ import type { RecoveryCode } from 'polykey/dist/keys/types'; import type { StatusLive } from 'polykey/dist/status/types'; import type { NodeIdEncoded } from 'polykey/dist/ids/types'; +type TableRow = Record; + +interface TableOptions { + columns?: Array | Record; + includeHeaders?: boolean; + includeRowCount?: boolean; +} + type AgentStatusLiveData = Omit & { nodeId: NodeIdEncoded; }; @@ -40,17 +48,10 @@ type AgentChildProcessOutput = error: POJO; }; -type TableRow = Record; - -interface TableOptions { - headers?: string[]; - includeRowCount?: boolean; -} - export type { + TableRow, + TableOptions, AgentStatusLiveData, AgentChildProcessInput, AgentChildProcessOutput, - TableRow, - TableOptions, }; diff --git a/src/utils/parsers.ts b/src/utils/parsers.ts index 73e98348..3e29646b 100644 --- a/src/utils/parsers.ts +++ b/src/utils/parsers.ts @@ -98,8 +98,7 @@ const parseProviderId: (data: string) => ids.ProviderId = const parseIdentityId: (data: string) => ids.IdentityId = validateParserToArgParser(ids.parseIdentityId); - -const parseProviderIdList: (data: string) => ids.ProviderId[] = +const parseProviderIdList: (data: string) => Array = validateParserToArgListParser(ids.parseProviderId); const parseGestaltAction: (data: string) => 'notify' | 'scan' | 'claim' = diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 1d1b79aa..4854d1c1 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -50,7 +50,7 @@ type OutputObject = data: Error; }; -function standardErrorReplacer(key: string, value: any) { +function standardErrorReplacer(_key: string, value: any) { if (value instanceof Error && !(value instanceof ErrorPolykey)) { return { type: value.name, @@ -64,195 +64,356 @@ function standardErrorReplacer(key: string, value: any) { return value; } -async function* arrayToAsyncIterable(array: T[]): AsyncIterable { - for (const item of array) { - yield item; +function encodeEscapedReplacer(_key: string, value: any) { + if (typeof value === 'string') { + return encodeEscaped(value); } + return value; } -// Function to handle 'table' type output -const outputTableFormatter = async ( - rowStream: TableRow[] | AsyncIterable, - options?: TableOptions, -): Promise => { - let output = ''; - const maxColumnLengths: Record = {}; - let rowCount = 0; +/** + * This function: + * + * 1. Keeps regular spaces, only ' ', as they are. + * 2. Converts \\n \\r \\t to escaped versions, \\\\n \\\\r and \\\\t. + * 3. Converts other control characters to their Unicode escape sequences. + * 4. Converts ' \` " to escaped versions, \\\\' \\\\\` and \\\\" + * 5. Wraps the whole thing in `""` if any characters have been encoded. + */ +function encodeEscapedWrapped(str: string): string { + if (!encodeEscapedRegex.test(str)) { + return str; + } + return `"${encodeEscaped(str)}"`; +} - // Initialize maxColumnLengths with header lengths if headers are provided - if (options?.headers) { - for (const header of options.headers) { - maxColumnLengths[header] = header.length; +/** + * This function: + * + * 1. Keeps regular spaces, only ' ', as they are. + * 2. Converts \\\\n \\\\r and \\\\t to unescaped versions, \\n \\r \\t. + * 3. Converts Unicode escape sequences to their control characters. + * 4. Converts \\\\' \\\\\` and \\\\" to their unescaped versions, ' \` ". + * 5. If it is wrapped in "" double quotes, the double quotes will be trimmed. + */ +function decodeEscapedWrapped(str: string): string { + if (!decodeEscapedRegex.test(str)) { + return str; + } + return decodeEscaped(str.substring(1, str.length - 1)); +} + +// We want to actually match control codes here! +// eslint-disable-next-line no-control-regex +const encodeEscapedRegex = /[\x00-\x1F\x7F-\x9F"'`]/g; + +/** + * This function: + * + * 1. Keeps regular spaces, only ' ', as they are. + * 2. Converts \\n \\r \\t to escaped versions, \\\\n \\\\r and \\\\t. + * 3. Converts other control characters to their Unicode escape sequences.\ + * 4. Converts ' \` " to escaped versions, \\\\' \\\\\` and \\\\" + * + * Unless you're using this in a `JSON.stringify` replacer, you probably want to use {@link encodeEscapedWrapped} instead. + */ +function encodeEscaped(str: string): string { + return str.replace(encodeEscapedRegex, (char) => { + switch (char) { + case ' ': + return char; + case '\n': + return '\\n'; // Encode newline + case '\r': + return '\\r'; // Encode carriage return + case '\t': + return '\\t'; // Encode tab + case '\v': + return '\\v'; // Encode tab + case '\f': + return '\\f'; // Encode tab + case '"': + case "'": + case '`': + return '\\' + char; + // Add cases for other whitespace characters if needed + default: + // Return the Unicode escape sequence for control characters + return `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`; + } + }); +} + +const decodeEscapedRegex = /\\([nrtvf"'`]|u[0-9a-fA-F]{4})/g; + +/** + * This function: + * + * 1. Keeps regular spaces, only ' ', as they are. + * 2. Converts \\\\n \\\\r and \\\\t to unescaped versions, \\n \\r \\t. + * 3. Converts Unicode escape sequences to their control characters. + * 4. Converts \\\\' \\\\\` and \\\\" to their unescaped versions, ' \` ". + * + * Unless you're using this in a `JSON.parse` reviver, you probably want to use {@link decodeEscapedWrapped} instead. + */ +function decodeEscaped(str: string): string { + return str.replace(decodeEscapedRegex, (substr) => { + // Unicode escape sequence must be characters (e.g. `\u0000`) + if (substr.length === 6 && substr.at(1) === 'u') { + return String.fromCharCode(parseInt(substr.substring(2), 16)); + } + // Length of substr will always be at least 1 + const lastChar = substr.at(-1); + if (lastChar == null) { + utils.never(); } + switch (lastChar) { + case 'n': + return '\n'; + case 'r': + return '\r'; + case 't': + return '\t'; + case 'v': + return '\v'; + case 'f': + return '\f'; + case '"': + case "'": + case '`': + return lastChar; + } + utils.never(); + }); +} + +/** + * Formats a message suitable for output. + * + * @param msg - The msg that needs to be formatted. + * @see {@link outputFormatterTable} for information regarding usage where `msg.type === 'table'`. + * @see {@link encodeWrappedStrings} for information regarding wrapping strings with `""` for encoding escaped characters + * @returns + */ +function outputFormatter(msg: OutputObject): string | Uint8Array { + switch (msg.type) { + case 'raw': + return msg.data; + case 'list': + return outputFormatterList(msg.data); + case 'table': + return outputFormatterTable(msg.data, msg.options); + case 'dict': + return outputFormatterDict(msg.data); + case 'json': + return outputFormatterJson(msg.data); + case 'error': + return outputFormatterError(msg.data); } +} - let iterableStream = Array.isArray(rowStream) - ? arrayToAsyncIterable(rowStream) - : rowStream; +function outputFormatterList(items: Array): string { + let output = ''; + for (const elem of items) { + // Convert null or undefined to empty string + output += `${elem ?? ''}\n`; + } + return output; +} - const updateMaxColumnLengths = (row: TableRow) => { - for (const [key, value] of Object.entries(row)) { - const cellValue = - value === null || value === '' || value === undefined ? 'N/A' : value; - maxColumnLengths[key] = Math.max( - maxColumnLengths[key] || 0, - cellValue.toString().length, +/** + * Function to handle the `table` output format. + * + * @param rows + * @param options + * @param options.columns - Can either be an `Array` or `Record`. + * If it is `Record`, the `number` values will be used as the initial padding lengths. + * The object is also mutated if any cells exceed the inital padding lengths. + * This parameter can also be supplied to filter the columns that will be displayed. + * @param options.includeHeaders - Defaults to `True` + * @param options.includeRowCount - Defaults to `False`. + * @returns + */ +function outputFormatterTable( + rows: Array, + options: TableOptions = { + includeHeaders: true, + includeRowCount: false, + }, +): string { + let output = ''; + let rowCount = 0; + // Default includeHeaders to true + const includeHeaders = options.includeHeaders ?? true; + const maxColumnLengths: Record = {}; + + const optionColumns = + options?.columns != null + ? Array.isArray(options.columns) + ? options.columns + : Object.keys(options.columns) + : undefined; + + // Initialize maxColumnLengths with header lengths if headers are provided + if (optionColumns != null) { + for (const column of optionColumns) { + maxColumnLengths[column] = Math.max( + options?.columns?.[column] ?? 0, + column.length, ); } - }; - - // Precompute max column lengths by iterating over the rows first - for await (const row of iterableStream) { - updateMaxColumnLengths(row); } - // Reset the iterableStream if it's an array so we can iterate over it again - if (Array.isArray(rowStream)) { - iterableStream = arrayToAsyncIterable(rowStream); + // Precompute max column lengths by iterating over the rows first + for (const row of rows) { + for (const column in options?.columns ?? row) { + if (row[column] != null) { + if (typeof row[column] === 'string') { + row[column] = encodeEscapedWrapped(row[column]); + } else { + row[column] = JSON.stringify(row[column], encodeEscapedReplacer); + } + } + // Null or '' will both cause cellLength to be 3 + const cellLength = + row[column] == null || row[column] === '""' ? 3 : row[column].length; // 3 is length of 'N/A' + maxColumnLengths[column] = Math.max( + maxColumnLengths[column] || 0, + cellLength, // Use the length of the encoded value + ); + } } + // After this point, maxColumnLengths will have been filled with all the necessary keys. + // Thus, the column keys can be derived from it. + const columns = Object.keys(maxColumnLengths); // If headers are provided, add them to your output first - if (options?.headers) { - const headerRow = options.headers - .map((header) => header.padEnd(maxColumnLengths[header])) - .join('\t'); - output += headerRow + '\n'; + if (optionColumns != null) { + for (let i = 0; i < optionColumns.length; i++) { + const column = optionColumns[i]; + const maxColumnLength = maxColumnLengths[column]; + // Options.headers is definitely defined as optionHeaders != null + if (!Array.isArray(options!.columns)) { + options!.columns![column] = maxColumnLength; + } + if (includeHeaders) { + output += column.padEnd(maxColumnLength); + if (i !== optionColumns.length - 1) { + output += '\t'; + } else { + output += '\n'; + } + } + } } - // Function to format a single row - const formatRow = (row: TableRow) => { + for (const row of rows) { let formattedRow = ''; - - if (options?.includeRowCount) { + if (options.includeRowCount) { formattedRow += `${++rowCount}\t`; } - - const keysToUse = options?.headers ?? Object.keys(maxColumnLengths); - - for (const key of keysToUse) { - const cellValue = Object.prototype.hasOwnProperty.call(row, key) - ? row[key] - : 'N/A'; - formattedRow += `${cellValue - ?.toString() - .padEnd(maxColumnLengths[key] || 0)}\t`; + for (const column of columns) { + // Assume row[key] has been already encoded as a string or null + const cellValue = + row[column] == null || row[column].length === 0 ? 'N/A' : row[column]; + formattedRow += `${cellValue.padEnd(maxColumnLengths[column] || 0)}\t`; } - - return formattedRow.trimEnd(); - }; - - for await (const row of iterableStream) { - output += formatRow(row) + '\n'; + output += formattedRow.trimEnd() + '\n'; } return output; -}; +} -function outputFormatter( - msg: OutputObject, -): string | Uint8Array | Promise { +function outputFormatterDict(data: POJO): string { let output = ''; - if (msg.type === 'raw') { - return msg.data; - } else if (msg.type === 'list') { - for (let elem in msg.data) { - // Empty string for null or undefined values - if (elem == null) { - elem = ''; - } - output += `${msg.data[elem]}\n`; + let maxKeyLength = 0; + for (const key in data) { + if (key.length > maxKeyLength) { + maxKeyLength = key.length; } - } else if (msg.type === 'table') { - return outputTableFormatter(msg.data, msg.options); - } else if (msg.type === 'dict') { - let maxKeyLength = 0; - for (const key in msg.data) { - if (key.length > maxKeyLength) { - maxKeyLength = key.length; - } + } + for (const key in data) { + let value = data[key]; + if (value == null) { + value = ''; } - for (const key in msg.data) { - let value = msg.data[key]; - if (value == null) { - value = ''; - } + if (typeof value === 'string') { + value = encodeEscapedWrapped(value); + } else { + value = JSON.stringify(value, encodeEscapedReplacer); + } - // Only trim starting and ending quotes if value is a string - if (typeof value === 'string') { - value = JSON.stringify(value).replace(/^"|"$/g, ''); - } else { - value = JSON.stringify(value); - } + value = value.replace(/(?:\r\n|\n)$/, ''); + value = value.replace(/(\r\n|\n)/g, '$1\t'); - // Re-introduce value.replace logic from old code - value = value.replace(/(?:\r\n|\n)$/, ''); - value = value.replace(/(\r\n|\n)/g, '$1\t'); + const padding = ' '.repeat(maxKeyLength - key.length); + output += `${key}${padding}\t${value}\n`; + } + return output; +} - const padding = ' '.repeat(maxKeyLength - key.length); - output += `${key}${padding}\t${value}\n`; - } - } else if (msg.type === 'json') { - output = JSON.stringify(msg.data, standardErrorReplacer); - output += '\n'; - } else if (msg.type === 'error') { - let currError = msg.data; - let indent = ' '; - while (currError != null) { - if (currError instanceof networkErrors.ErrorPolykeyRemote) { - output += `${currError.name}: ${currError.description}`; - if (currError.message && currError.message !== '') { - output += ` - ${currError.message}`; - } - if (currError.metadata != null) { - output += '\n'; - for (const [key, value] of Object.entries(currError.metadata)) { - output += `${indent}${key}\t${value}\n`; - } - } - output += `${indent}timestamp\t${currError.timestamp}\n`; - output += `${indent}cause: `; - currError = currError.cause; - } else if (currError instanceof ErrorPolykey) { - output += `${currError.name}: ${currError.description}`; - if (currError.message && currError.message !== '') { - output += ` - ${currError.message}`; - } +function outputFormatterJson(json: string): string { + return `${JSON.stringify(json, standardErrorReplacer)}\n`; +} + +function outputFormatterError(err: Error): string { + let output = ''; + let indent = ' '; + while (err != null) { + if (err instanceof networkErrors.ErrorPolykeyRemote) { + output += `${err.name}: ${err.description}`; + if (err.message && err.message !== '') { + output += ` - ${err.message}`; + } + if (err.metadata != null) { output += '\n'; - // Disabled to streamline output - // output += `${indent}exitCode\t${currError.exitCode}\n`; - // output += `${indent}timestamp\t${currError.timestamp}\n`; - if (currError.data && !utils.isEmptyObject(currError.data)) { - output += `${indent}data\t${JSON.stringify(currError.data)}\n`; + for (const [key, value] of Object.entries(err.metadata)) { + output += `${indent}${key}\t${value}\n`; } - if (currError.cause) { - output += `${indent}cause: `; - if (currError.cause instanceof ErrorPolykey) { - currError = currError.cause; - } else if (currError.cause instanceof Error) { - output += `${currError.cause.name}`; - if (currError.cause.message && currError.cause.message !== '') { - output += `: ${currError.cause.message}`; - } - output += '\n'; - break; - } else { - output += `${JSON.stringify(currError.cause)}\n`; - break; + } + output += `${indent}timestamp\t${err.timestamp}\n`; + output += `${indent}cause: `; + err = err.cause; + } else if (err instanceof ErrorPolykey) { + output += `${err.name}: ${err.description}`; + if (err.message && err.message !== '') { + output += ` - ${err.message}`; + } + output += '\n'; + // Disabled to streamline output + // output += `${indent}exitCode\t${currError.exitCode}\n`; + // output += `${indent}timestamp\t${currError.timestamp}\n`; + if (err.data && !utils.isEmptyObject(err.data)) { + output += `${indent}data\t${JSON.stringify(err.data)}\n`; + } + if (err.cause) { + output += `${indent}cause: `; + if (err.cause instanceof ErrorPolykey) { + err = err.cause; + } else if (err.cause instanceof Error) { + output += `${err.cause.name}`; + if (err.cause.message && err.cause.message !== '') { + output += `: ${err.cause.message}`; } + output += '\n'; + break; } else { + output += `${JSON.stringify(err.cause)}\n`; break; } } else { - output += `${currError.name}`; - if (currError.message && currError.message !== '') { - output += `: ${currError.message}`; - } - output += '\n'; break; } - indent = indent + ' '; + } else { + output += `${err.name}`; + if (err.message && err.message !== '') { + output += `: ${err.message}`; + } + output += '\n'; + break; } + indent = indent + ' '; } return output; } @@ -320,9 +481,21 @@ function remoteErrorCause(e: any): [any, number] { export { verboseToLogLevel, standardErrorReplacer, + encodeEscapedReplacer, outputFormatter, + outputFormatterList, + outputFormatterTable, + outputFormatterDict, + outputFormatterJson, + outputFormatterError, retryAuthentication, remoteErrorCause, + encodeEscapedWrapped, + encodeEscaped, + encodeEscapedRegex, + decodeEscapedWrapped, + decodeEscaped, + decodeEscapedRegex, }; export type { OutputObject }; diff --git a/src/vaults/CommandCreate.ts b/src/vaults/CommandCreate.ts index bf89c439..456cb886 100644 --- a/src/vaults/CommandCreate.ts +++ b/src/vaults/CommandCreate.ts @@ -54,11 +54,11 @@ class CommandCreate extends CommandPolykey { }), meta, ); - const formattedOutput = await binUtils.outputFormatter({ + const outputFormatted = binUtils.outputFormatter({ type: options.format === 'json' ? 'json' : 'list', data: [`Vault ${response.vaultIdEncoded} created successfully`], }); - process.stdout.write(formattedOutput); + process.stdout.write(outputFormatted); } finally { if (pkClient! != null) await pkClient.stop(); } diff --git a/src/vaults/CommandList.ts b/src/vaults/CommandList.ts index b42d0d21..8fa098fa 100644 --- a/src/vaults/CommandList.ts +++ b/src/vaults/CommandList.ts @@ -51,16 +51,18 @@ class CommandList extends CommandPolykey { }); for await (const vaultListMessage of stream) { data.push( - `${vaultListMessage.vaultName}:\t\t${vaultListMessage.vaultIdEncoded}`, + `${vaultListMessage.vaultName}:${' '.repeat(4)}${ + vaultListMessage.vaultIdEncoded + }`, ); } return data; }, meta); - const formattedOutput = await binUtils.outputFormatter({ + const outputFormatted = binUtils.outputFormatter({ type: options.format === 'json' ? 'json' : 'list', data: data, }); - process.stdout.write(formattedOutput); + process.stdout.write(outputFormatted); } finally { if (pkClient! != null) await pkClient.stop(); } diff --git a/src/vaults/CommandLog.ts b/src/vaults/CommandLog.ts index 460b82f0..51852ce4 100644 --- a/src/vaults/CommandLog.ts +++ b/src/vaults/CommandLog.ts @@ -63,11 +63,11 @@ class CommandLog extends CommandPolykey { } return data; }, meta); - const formattedOutput = await binUtils.outputFormatter({ + const outputFormatted = binUtils.outputFormatter({ type: options.format === 'json' ? 'json' : 'list', data: data, }); - process.stdout.write(formattedOutput); + process.stdout.write(outputFormatted); } finally { if (pkClient! != null) await pkClient.stop(); } diff --git a/src/vaults/CommandPermissions.ts b/src/vaults/CommandPermissions.ts index 6a45ec24..69251bd2 100644 --- a/src/vaults/CommandPermissions.ts +++ b/src/vaults/CommandPermissions.ts @@ -61,11 +61,11 @@ class CommandPermissions extends CommandPolykey { }, meta); if (data.length === 0) data.push('No permissions were found'); - const formattedOutput = await binUtils.outputFormatter({ + const outputFormatted = binUtils.outputFormatter({ type: options.format === 'json' ? 'json' : 'list', data: data, }); - process.stdout.write(formattedOutput); + process.stdout.write(outputFormatted); } finally { if (pkClient! != null) await pkClient.stop(); } diff --git a/src/vaults/CommandScan.ts b/src/vaults/CommandScan.ts index 7a769210..ab017da8 100644 --- a/src/vaults/CommandScan.ts +++ b/src/vaults/CommandScan.ts @@ -54,15 +54,19 @@ class CommandScan extends CommandPolykey { const vaultName = vault.vaultName; const vaultIdEncoded = vault.vaultIdEncoded; const permissions = vault.permissions.join(','); - data.push(`${vaultName}\t\t${vaultIdEncoded}\t\t${permissions}`); + data.push( + `${vaultName}${' '.repeat(4)}${vaultIdEncoded}${' '.repeat( + 4, + )}${permissions}`, + ); } return data; }, meta); - const formattedOutput = await binUtils.outputFormatter({ + const outputFormatted = binUtils.outputFormatter({ type: options.format === 'json' ? 'json' : 'list', data: data, }); - process.stdout.write(formattedOutput); + process.stdout.write(outputFormatted); } finally { if (pkClient! != null) await pkClient.stop(); } diff --git a/tests/keys/cert.test.ts b/tests/keys/cert.test.ts index 47c01d53..fade65f5 100644 --- a/tests/keys/cert.test.ts +++ b/tests/keys/cert.test.ts @@ -16,7 +16,7 @@ describe('cert', () => { testUtils.testIf( testUtils.isTestPlatformEmpty || testUtils.isTestPlatformDocker, )('cert gets the certificate', async () => { - let { exitCode, stdout } = await testUtils.pkExec( + const { exitCode, stdout } = await testUtils.pkExec( ['keys', 'cert', '--format', 'json'], { env: { @@ -31,20 +31,5 @@ describe('cert', () => { expect(JSON.parse(stdout)).toEqual({ cert: expect.any(String), }); - const certCommand = JSON.parse(stdout).cert; - ({ exitCode, stdout } = await testUtils.pkExec( - ['keys', 'cert', '--format', 'json'], - { - env: { - PK_NODE_PATH: agentDir, - PK_PASSWORD: agentPassword, - }, - cwd: agentDir, - command: globalThis.testCmd, - }, - )); - expect(exitCode).toBe(0); - const certStatus = JSON.parse(stdout).cert; - expect(certCommand).toBe(certStatus); }); }); diff --git a/tests/keys/certchain.test.ts b/tests/keys/certchain.test.ts index c6de7022..16e492f2 100644 --- a/tests/keys/certchain.test.ts +++ b/tests/keys/certchain.test.ts @@ -18,7 +18,7 @@ describe('certchain', () => { testUtils.testIf( testUtils.isTestPlatformEmpty || testUtils.isTestPlatformDocker, )('certchain gets the certificate chain', async () => { - let { exitCode, stdout } = await testUtils.pkExec( + const { exitCode, stdout } = await testUtils.pkExec( ['keys', 'certchain', '--format', 'json'], { env: { @@ -33,20 +33,5 @@ describe('certchain', () => { expect(JSON.parse(stdout)).toEqual({ certchain: expect.any(Array), }); - const certChainCommand = JSON.parse(stdout).certchain.join('\n'); - ({ exitCode, stdout } = await testUtils.pkExec( - ['agent', 'status', '--format', 'json'], - { - env: { - PK_NODE_PATH: agentDir, - PK_PASSWORD: agentPassword, - }, - cwd: agentDir, - command: globalThis.testCmd, - }, - )); - expect(exitCode).toBe(0); - const certChainStatus = JSON.parse(stdout).rootCertChainPem; - expect(certChainCommand.rootPublicKeyPem).toBe(certChainStatus); }); }); diff --git a/tests/nodes/connections.test.ts b/tests/nodes/connections.test.ts index d2a59ef9..fe41375d 100644 --- a/tests/nodes/connections.test.ts +++ b/tests/nodes/connections.test.ts @@ -31,8 +31,8 @@ describe('connections', () => { nodePath, agentServiceHost: '127.0.0.1', clientServiceHost: '127.0.0.1', - agentServicePort: 55555, - clientServicePort: 55554, + agentServicePort: 0, + clientServicePort: 0, keys: { passwordOpsLimit: keysUtils.passwordOpsLimits.min, passwordMemLimit: keysUtils.passwordMemLimits.min, @@ -50,8 +50,8 @@ describe('connections', () => { nodePath: path.join(dataDir, 'remoteNode'), agentServiceHost: '127.0.0.1', clientServiceHost: '127.0.0.1', - agentServicePort: 55553, - clientServicePort: 55552, + agentServicePort: 0, + clientServicePort: 0, keys: { passwordOpsLimit: keysUtils.passwordOpsLimits.min, passwordMemLimit: keysUtils.passwordMemLimits.min, diff --git a/tests/utils.test.ts b/tests/utils.test.ts index b2c590f4..7751dbe1 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -3,9 +3,21 @@ import ErrorPolykey from 'polykey/dist/ErrorPolykey'; import * as ids from 'polykey/dist/ids'; import * as nodesUtils from 'polykey/dist/nodes/utils'; import * as polykeyErrors from 'polykey/dist/errors'; +import * as fc from 'fast-check'; import * as binUtils from '@/utils/utils'; import * as testUtils from './utils'; +const nonPrintableCharArb = fc + .oneof( + fc.integer({ min: 0, max: 0x1f }), + fc.integer({ min: 0x7f, max: 0x9f }), + ) + .map((code) => String.fromCharCode(code)); + +const stringWithNonPrintableCharsArb = fc.stringOf( + fc.oneof(fc.char(), nonPrintableCharArb), +); + describe('bin/utils', () => { testUtils.testIf(testUtils.isTestPlatformEmpty)( 'list in human and json format', @@ -29,24 +41,21 @@ describe('bin/utils', () => { testUtils.testIf(testUtils.isTestPlatformEmpty)( 'table in human and in json format', async () => { - // Note the async here - // Table - const tableOutput = await binUtils.outputFormatter({ - // And the await here + const tableOutput = binUtils.outputFormatter({ type: 'table', data: [ { key1: 'value1', key2: 'value2' }, { key1: 'data1', key2: 'data2' }, { key1: null, key2: undefined }, ], + options: { + includeHeaders: true, + }, }); - expect(tableOutput).toBe( - 'value1\tvalue2\ndata1 \tdata2\nundefined\tundefined\n', - ); + expect(tableOutput).toBe('value1\tvalue2\ndata1 \tdata2\nN/A \tN/A\n'); // JSON - const jsonOutput = await binUtils.outputFormatter({ - // And the await here + const jsonOutput = binUtils.outputFormatter({ type: 'json', data: [ { key1: 'value1', key2: 'value2' }, @@ -58,6 +67,40 @@ describe('bin/utils', () => { ); }, ); + testUtils.testIf(testUtils.isTestPlatformEmpty)( + 'table in human format for streaming usage', + async () => { + let tableOutput = ''; + const keys = { + key1: 10, + key2: 4, + }; + const generator = function* () { + yield [{ key1: 'value1', key2: 'value2' }]; + yield [{ key1: 'data1', key2: 'data2' }]; + yield [{ key1: null, key2: undefined }]; + }; + let i = 0; + for (const data of generator()) { + tableOutput += binUtils.outputFormatter({ + type: 'table', + data: data, + options: { + columns: keys, + includeHeaders: i === 0, + }, + }); + i++; + } + expect(keys).toStrictEqual({ + key1: 10, + key2: 6, + }); + expect(tableOutput).toBe( + 'key1 \tkey2 \nvalue1 \tvalue2\ndata1 \tdata2\nN/A \tN/A\n', + ); + }, + ); testUtils.testIf(testUtils.isTestPlatformEmpty)( 'dict in human and in json format', () => { @@ -73,7 +116,7 @@ describe('bin/utils', () => { type: 'dict', data: { key1: 'first\nsecond', key2: 'first\nsecond\n' }, }), - ).toBe('key1\tfirst\\nsecond\nkey2\tfirst\\nsecond\\n\n'); + ).toBe('key1\t"first\\nsecond"\nkey2\t"first\\nsecond\\n"\n'); expect( binUtils.outputFormatter({ type: 'dict', @@ -89,6 +132,38 @@ describe('bin/utils', () => { ).toBe('{"key1":"value1","key2":"value2"}\n'); }, ); + testUtils.testIf(testUtils.isTestPlatformEmpty)( + 'outputFormatter should encode non-printable characters within a dict', + () => { + fc.assert( + fc.property( + stringWithNonPrintableCharsArb, + stringWithNonPrintableCharsArb, + (key, value) => { + const formattedOutput = binUtils.outputFormatter({ + type: 'dict', + data: { [key]: value }, + }); + + // Construct the expected output + let expectedValue = value; + expectedValue = binUtils.encodeEscapedWrapped(expectedValue); + expectedValue = expectedValue.replace(/(?:\r\n|\n)$/, ''); + expectedValue = expectedValue.replace(/(\r\n|\n)/g, '$1\t'); + + const maxKeyLength = Math.max( + ...Object.keys({ [key]: value }).map((k) => k.length), + ); + const padding = ' '.repeat(maxKeyLength - key.length); + const expectedOutput = `${key}${padding}\t${expectedValue}\n`; + // Assert that the formatted output matches the expected output + expect(formattedOutput).toBe(expectedOutput); + }, + ), + { numRuns: 100 }, // Number of times to run the test + ); + }, + ); testUtils.testIf(testUtils.isTestPlatformEmpty)( 'errors in human and json format', () => { @@ -203,4 +278,17 @@ describe('bin/utils', () => { ); }, ); + testUtils.testIf(testUtils.isTestPlatformEmpty)( + 'encodeEscaped should encode all escapable characters', + () => { + fc.assert( + fc.property(stringWithNonPrintableCharsArb, (value) => { + expect(binUtils.decodeEscaped(binUtils.encodeEscaped(value))).toBe( + value, + ); + }), + { numRuns: 100 }, // Number of times to run the test + ); + }, + ); }); diff --git a/tests/vaults/vaults.test.ts b/tests/vaults/vaults.test.ts index 663d4c8a..4f3d04b7 100644 --- a/tests/vaults/vaults.test.ts +++ b/tests/vaults/vaults.test.ts @@ -907,14 +907,18 @@ describe('CLI vaults', () => { cwd: dataDir, }); expect(result3.exitCode).toBe(0); + expect(result3.stdout).toMatch(/Vault1 {4}.* {4}clone/); expect(result3.stdout).toContain( - `Vault1\t\t${vaultsUtils.encodeVaultId(vault1Id)}\t\tclone`, - ); - expect(result3.stdout).toContain( - `Vault2\t\t${vaultsUtils.encodeVaultId(vault2Id)}\t\tpull,clone`, + `Vault1${' '.repeat(4)}${vaultsUtils.encodeVaultId( + vault1Id, + )}${' '.repeat(4)}clone\nVault2${' '.repeat( + 4, + )}${vaultsUtils.encodeVaultId(vault2Id)}${' '.repeat( + 4, + )}pull,clone\n`, ); expect(result3.stdout).not.toContain( - `Vault3\t\t${vaultsUtils.encodeVaultId(vault3Id)}`, + `Vault3${' '.repeat(4)}${vaultsUtils.encodeVaultId(vault3Id)}`, ); } finally { await remoteOnline?.stop();