appcenter-cli
Version:
Command line tool for Visual Studio App Center
626 lines (558 loc) • 19.1 kB
text/typescript
// Functions to support outputting stuff to the user
import { inspect } from "util";
import { isQuiet, formatIsJson, formatIsCsv, formatIsParsingCompatible } from "./io-options";
import * as os from "os";
import * as wrap from "wordwrap";
import * as tty from "tty";
const Table = require("cli-table3");
const Spinner = require("cli-spinner").Spinner;
import { terminal } from "./terminal";
import * as _ from "lodash";
//
// Display a progress spinner while waiting for the provided promise
// to complete.
//
export function progress<T>(title: string, action: Promise<T>): Promise<T> {
const stdoutIsTerminal = tty.isatty(1);
if (!formatIsParsingCompatible() && !isQuiet() && stdoutIsTerminal) {
const spinner = new Spinner(title);
spinner.start();
return action.then((result) => {
spinner.stop(true);
return result;
})
.catch((ex) => {
spinner.stop(true);
throw ex;
});
} else {
return action;
}
}
//
// Output an array of items, passing each item through a formatting
// function.
//
export function list<T>(formatter: {(item: T): string}, items: T[]): void {
console.assert(!formatIsCsv(), "this function doesn't support CSV mode");
if (!items || Object.keys(items).length === 0) { return; }
if (!formatIsJson()) {
items.map(formatter).forEach((text) => console.log(text));
} else {
console.log(JSON.stringify(items));
}
}
//
// Output a line of help text
//
export function help(t: string): void;
export function help(): void;
export function help(...args: any[]) : void {
console.assert(!formatIsCsv(), "this function doesn't support CSV mode");
let t: string;
if (args.length === 0) {
t = "";
} else {
t = args[0];
}
console.log(t);
}
//
// Output a line of plain text. Only outputs if the format is regular text.
// If passing a converter, then the raw data is output in json format instead.
//
export function text<T>(converter: {(data: T): string}, data: T): void;
export function text(t: string): void;
export function text(...args: any[]): void {
console.assert(!formatIsCsv(), "this function doesn't support CSV mode");
let converter: {(data: any): string};
let data: any;
if (args.length === 1) {
converter = null;
data = args[0];
} else {
[converter, data] = args;
}
if (formatIsJson()) {
if (converter) {
console.log(JSON.stringify(data));
}
} else {
converter = converter || ((s) => s);
console.log(converter(data));
}
}
//
// Output tabular data.
// By default, does a simple default table using cli-table3.
// If you want to, you can pass in explicit table initialization
// options. See https://github.com/jamestalmage/cli-table3 for docs
// on the module.
//
export function table(options: any, data: any[]): void;
export function table(data: any[]): void;
export function table(...args: any[]): void {
console.assert(!formatIsCsv(), "this function doesn't support CSV mode");
let options: any;
let data: any[];
[options, data] = args;
if (!data) {
data = options;
options = undefined;
}
if (!formatIsJson()) {
const cliTable = new Table(options);
data.forEach((item) => cliTable.push(item));
console.log(cliTable.toString());
} else {
console.log(JSON.stringify(data));
}
}
// Formatting helper for cli-table3 - default command output table style
export function getCommandOutputTableOptions(header: string[]): object {
return {
head: header,
style: {
head: []
}
};
}
//
// Formatting helper for cli-table3 - two columns with no table outlines. Used by
// help commands for formatting lists of options, commands, etc.
//
export function getOptionsForTwoColumnTableWithNoBorders(firstColumnWidth: number) {
const consoleWidth = terminal.columns();
// There will be a single whitespace to the right from the each column, count it as unavailable
const availableWidth = consoleWidth - 2;
const secondColumnWidth = availableWidth - firstColumnWidth;
return {
chars: {
top: "", "top-mid": "", "top-left": "", "top-right": "",
bottom: "", "bottom-mid": "", "bottom-left": "", "bottom-right": "",
left: "", "left-mid": "", mid: "", "mid-mid": "",
right: "", "right-mid": "", middle: ""
},
style: { "padding-left": 0, "padding-right": 0 },
colWidths: [firstColumnWidth, secondColumnWidth],
wordWrap: true
};
}
//
// Output a "report", which is a formatted output of a single object
// with ability to control naming of fields in the output, lets you
// output subobjects formatted nicely, and aligns everything for you.
//
// Usage looks like:
// out.report([
// // Report format here, one array entry per field to output
// [ "Field name to display", "path.to.property.to.display.in.data", optionalFormatter ],
// [ "Second field name", "second.path.to.display", /* No formatter on this one */ ]
// ],
// "Optional string to print if no data is available",
// theDataToFormat);
//
// The paths to properties are simple dotted property names like you'd use in javascript.
// For example, in the profile list command, there's this line to display some of the
// current profile properties:
//
// out.report([
// ["Username", "userName" ],
// [ "Display Name", "displayName" ],
// [ "Email", "email"]
// ], "No logged in user. Use 'appcenter login' command to log in.",
// user);
//
// "userName", "displayName", and "email" are names of properties on the user object being
// passed in. If there were subobjects, for example if the input object looked like this:
//
// let user = {
// name: {
// userName: "chris",
// displayName: "christav"
// },
// email: "not.giving@real.email.here"
// };
//
// This format could be displayed in a report like so:
//
// out.report([
// [ "Username", "name.userName" ],
// [ "Display Name", "name.displayName" ],
// [ "Email", "email"]
// ], "No logged in user. Use 'appcenter login' command to log in.",
// user);
//
// Each report format entry can have a formatter supplied with it. This is a function that
// takes the field's value and returns the appropriate string for display. By default
// report just calls 'toString' on the value, but you can use a formatter to customize
// to whatever you like.
//
// There are a few supplied formatters you can use out of the box attached to the report
// function. They are:
//
// out.report.asDate: takes an input string, parses it as a Date object, then outputs the result.
// out.report.inspect: takes any input object and returns the result of calling util.inspect on it.
// out.report.allProperties: Takes an object with properties itself, and runs report
// recursively on that object. This results in a nicely indented subreport
// in the final output.
//
// In addition, if the formatter is itself an array, it becomes the report format for the subobjects.
// So you can nest arbitrary reports. For exmaple, asssuming the same user field, then using this:
//
// out.report(
// [
// [ "Email", "email" ],
// // Nested subobject
// [ "Names", "name",
// [
// // report format for each of the subobject's fields
// [ "User Name", "userName" ],
// [ "Display Name", "displayName" ]
// ]
// ]
// ],
// {
// // reformat our user to show subobjects
// name: {
// displayName: user.displayName,
// userName: user.userName
// },
// email: user.email
// });
//
// The resulting output looks like this:
//
// Email: not.giving@real.email.here
// Names:
// User Name: christav-yngr
// Display Name: christav
//
//
// Support functions for "report" output
//
function spaces(num: number): string {
if (num > 0) {
return new Array(num + 1).join(" ");
}
return "";
}
function toWidth(s: string, width: number): string {
const pad = width - s.length;
return s + spaces(pad);
}
function defaultFormat(data: any): string {
if (typeof data === "undefined" || data === null) {
return "";
}
if (data instanceof Array) {
if (data.length === 0) {
return "[]";
}
return data.join(", ");
}
return data.toString();
}
function getProperty(value: any, propertyName: string): any {
if (typeof value === "undefined" || value === null) {
return "";
}
if (!propertyName) {
return value;
}
const first = propertyName.split(".")[0];
const rest = propertyName.slice(first.length + 1);
return getProperty(value[first], rest);
}
function doReport(indentation: number, reportFormat: any[], data: any, outfn: {(message: string): void}): void {
if (reportFormat.length === 0) {
return;
}
let maxWidth = 80;
if ((process.stdout as any).isTTY) {
maxWidth = (process.stdout as any).columns;
}
const headerWidth = Math.max.apply(null,
reportFormat.map(function (item) { return item[0].length; })
) + 2;
reportFormat.forEach(function (item) {
const title = item[0] + ":";
const field = item[1];
const formatter = item[2] || defaultFormat;
const value = getProperty(data, field);
if (formatter instanceof Array) {
outfn(spaces(indentation) + toWidth(title, headerWidth));
doReport(indentation + headerWidth, formatter, value, outfn);
} else {
const leftIndentation = "verbose: ".length + indentation + headerWidth;
let formatted = wrap.hard(leftIndentation, maxWidth)(formatter(value));
formatted = spaces(indentation) + toWidth(title, headerWidth) +
formatted.slice(leftIndentation);
outfn(formatted);
}
});
}
interface ReportFunc {
(reportFormat: any, nullMessage: string, data: any): void;
(reportFormat: any, data: any): void;
allProperties: {(data: any): string };
asDate: {(data: any): string };
inspect: {(data: any): string };
}
function makeReport(reportFormat: any, nullMessage: string, data: any): void;
function makeReport(reportFormat: any, data: any): void;
function makeReport(...args: any[]): void {
console.assert(!formatIsCsv(), "this function doesn't support CSV mode");
let reportFormat: any;
let nullMessage: string;
let data: any;
if (args.length === 3) {
[reportFormat, nullMessage, data] = args;
} else {
[reportFormat, data] = args;
nullMessage = "No data available";
}
if (!formatIsJson()) {
if (data === null || data === undefined) {
console.log(nullMessage);
} else {
doReport(0, reportFormat, data, console.log);
}
} else {
console.log(JSON.stringify(data));
}
}
export const report = makeReport as ReportFunc;
report.allProperties = function (data: any): any {
if (typeof data === "undefined" || data === null || data === "") {
return "[]";
}
const subreport = Object.keys(data).map(function (key) {
return [key, key];
});
const result: string[] = [];
doReport(0, subreport, data, function (o) { result.push(o); });
result.push("");
return result.join(os.EOL);
};
report.asDate = function (data: any): string {
const date = new Date(data);
if (formatIsJson()) {
return date.toJSON();
} else {
return date.toString();
}
};
report.inspect = function (data: any): string {
return inspect(data, {depth: null});
};
export function reportNewLineSeparatedArray(reportFormat: any, data: any[]) {
console.assert(!formatIsCsv(), "this function doesn't support CSV mode");
if (!formatIsJson()) {
data.forEach((item, index) => {
if (index) {
console.log("");
}
report(reportFormat, item);
});
} else {
console.log(JSON.stringify(data));
}
}
export function reportTitledGroupsOfTables(dataGroups: Array<{title: string, reportFormat: any, tables: any[]}>) {
console.assert(!formatIsCsv(), "this function doesn't support CSV mode");
if (!formatIsJson()) {
dataGroups.forEach((dataGroup, index) => {
if (index) {
console.log("");
}
console.log(dataGroup.title);
console.log("");
reportNewLineSeparatedArray(dataGroup.reportFormat, dataGroup.tables);
});
} else {
console.log(JSON.stringify(dataGroups));
}
}
function getMarginStringFromLevel(level: number) {
return _.repeat(" ", level * 4);
}
//
// Formatting helper for cli-table3 - table with borders which can be moved to the right
// It is used to show sub-tables
//
function getTableWithLeftMarginOptions(leftMargin: string) {
return {
chars: {
top: "─"
, "top-mid": "┬"
, "top-left": leftMargin + "┌"
, "top-right": "┐"
, bottom: "─"
, "bottom-mid": "┴"
, "bottom-left": leftMargin + "└"
, "bottom-right": "┘"
, left: leftMargin + "│"
, "left-mid": leftMargin + "├"
, mid: "─"
, "mid-mid": "┼"
, right: "│"
, "right-mid": "┤"
, middle: "│"
},
style: { "padding-left": 0, "padding-right": 0 },
wordWrap: true
};
}
function convertNamedTablesToCsvString(stringTables: NamedTables): string {
const columnsCount = calculateNumberOfColumns(stringTables);
const delimitersCount = columnsCount - 1;
const delimitersString = _.repeat(",", delimitersCount);
function outputTable(table: INamedTable): string {
let tableOutput = "";
// table name
tableOutput += table.name + delimitersString + os.EOL;
// table contents
const contents = _.cloneDeep(table.content);
contents.forEach((row, index) => {
if (index) {
tableOutput += os.EOL;
}
if (isINamedTable(row)) {
tableOutput += outputTable(row);
} else {
row.length = columnsCount;
tableOutput += row.join(",");
}
});
return tableOutput;
}
return stringTables.map((table) => outputTable(table)).join(os.EOL + delimitersString + os.EOL);
}
function convertNamedTablesToListString(stringTables: NamedTables): string {
function outputTable(table: INamedTable, level: number): string {
const paddedTable = padTableCells(table);
let tableOutput = "";
const marginString = getMarginStringFromLevel(level);
// table name
tableOutput += marginString + paddedTable.name + os.EOL;
// table contents
const tableWithMergedStringArrays: any[] = [];
// merging continuous string[] chains into Table objects
for (const row of paddedTable.content) {
if (isINamedTable(row)) {
tableWithMergedStringArrays.push(row);
} else {
const lastElement = _.last(tableWithMergedStringArrays);
let tableObject: any;
if (_.isUndefined(lastElement) || isINamedTable(lastElement)) {
tableObject = new Table(getTableWithLeftMarginOptions(marginString));
tableWithMergedStringArrays.push(tableObject);
} else {
tableObject = lastElement;
}
tableObject.push(row);
}
}
tableWithMergedStringArrays.forEach((rowObject, rowIndex) => {
if (rowIndex) {
tableOutput += os.EOL;
}
if (isINamedTable(rowObject)) {
tableOutput += outputTable(rowObject, level + 2);
} else {
tableOutput += rowObject.toString();
}
});
return tableOutput;
}
return stringTables.map((table) => outputTable(table, 0)).join(os.EOL + os.EOL);
}
function getMapKey(level: number, cellIndex: number): string {
return [level, cellIndex].join();
}
// returns map of max table cell width across tables on the same level - it is used to nicely align table columns vertically
// key is [level, cellIndex].join()
function calculateTableCellsMaxWidthAcrossLevels(wholeTable: INamedTable): Map<string, number> {
function calculate(table: INamedTable, level: number, levelAndCellIndexToMaxWidth: Map<string, number>): Map<string, number> {
for (const entry of table.content) {
if (entry instanceof Array) {
// row
entry.forEach((cell, cellIndex) => {
const key = getMapKey(level, cellIndex);
levelAndCellIndexToMaxWidth.set(key, _.max([levelAndCellIndexToMaxWidth.get(key), cell.length]));
});
} else {
// inner table
calculate(entry, level + 1, levelAndCellIndexToMaxWidth);
}
}
return levelAndCellIndexToMaxWidth;
}
return calculate(wholeTable, 0, new Map<string, number>());
}
function padTableCells(wholeTable: INamedTable): INamedTable {
// calculating max widths for the cells
const levelAndCellIndexToMaxWidth = calculateTableCellsMaxWidthAcrossLevels(wholeTable);
// recursively pad content
function pad(table: INamedTable, level: number): INamedTable {
const paddedContent = table.content.map((entry) => {
if (entry instanceof Array) {
// row
return entry.map((cellContent, cellIndex) => _.padEnd(cellContent, levelAndCellIndexToMaxWidth.get(getMapKey(level, cellIndex))));
} else {
// inner table
return pad(entry, level + 1);
}
});
return {
name: table.name,
content: paddedContent
};
}
return pad(wholeTable, 0);
}
function calculateNumberOfColumns(tables: Array<INamedTable | string[]>): number {
if (tables.length) {
return _.max(tables.map((table) => {
if (table instanceof Array) {
return table.length || 1;
} else {
return calculateNumberOfColumns(table.content);
}
}));
} else {
return 1;
}
}
export interface INamedTable {
name: string;
content: Array<INamedTable | string[]>;
}
function isINamedTable(object: any): object is INamedTable {
return object != null
&& typeof(object.name) === "string"
&& object.content instanceof Array
&& object.content.every((item: any) => isINamedTable(item) || (item instanceof Array && item.every((itemComponent) => typeof(itemComponent) === "string")));
}
// number - level of the table (controls left padding of the table and name)
export type NamedTables = INamedTable[];
type ObjectToNamedTablesConvertor<T> = (object: T,
numberFormatter: (num: number) => string,
dateFormatter: (date: Date) => string,
percentageFormatter: (percentage: number) => string) => NamedTables;
export function reportObjectAsTitledTables<T>(toNamedTables: ObjectToNamedTablesConvertor<T>, object: T) {
if (formatIsJson()) {
console.log(JSON.stringify(object));
} else {
let output: string;
if (formatIsCsv()) {
const stringTables = toNamedTables(object, (num) => num.toString(), (date) => date.toISOString(), (percentage) => percentage.toString());
output = convertNamedTablesToCsvString(stringTables);
} else {
const stringTables = toNamedTables(object, (num) => _.round(num, 2).toString(), (date) => date.toString(), (percentage) => _.round(percentage, 2).toString() + "%");
output = convertNamedTablesToListString(stringTables);
}
console.log(output);
}
}