diff --git a/lib/command.ts b/lib/command.ts index 1755a76b..de9b6979 100644 --- a/lib/command.ts +++ b/lib/command.ts @@ -21,6 +21,7 @@ import { NotAvailableInOfflineModeError, } from './errors'; import { stripIndent } from './utils/lazy'; +import * as output from './framework/output'; export default abstract class BalenaCommand extends Command { /** @@ -167,4 +168,7 @@ export default abstract class BalenaCommand extends Command { await this.getStdin(); } } + + protected outputMessage = output.outputMessage; + protected outputData = output.outputData; } diff --git a/lib/commands/app/index.ts b/lib/commands/app/index.ts index 5d2687f5..fd9d5136 100644 --- a/lib/commands/app/index.ts +++ b/lib/commands/app/index.ts @@ -28,8 +28,10 @@ import { appToFleetCmdMsg, warnify, } from '../../utils/messages'; +import { isV13 } from '../../utils/version'; +import type { DataOutputOptions } from '../../framework'; -interface FlagsDef { +interface FlagsDef extends DataOutputOptions { help: void; } @@ -56,13 +58,14 @@ export class FleetCmd extends Command { public static flags: flags.Input = { help: cf.help, + ...(isV13() ? cf.dataOutputFlags : {}), }; public static authenticated = true; public static primary = true; public async run(parserOutput?: ParserOutput) { - const { args: params } = + const { args: params, flags: options } = parserOutput || this.parse(FleetCmd); const { getApplication } = await import('../../utils/sdk'); @@ -82,16 +85,24 @@ export class FleetCmd extends Command { application.device_type = application.is_for__device_type[0].slug; application.commit = application.should_be_running__release[0]?.commit; - // Emulate table.vertical title output, but avoid uppercasing and inserting spaces - console.log(`== ${application.app_name}`); - console.log( - getVisuals().table.vertical(application, [ - 'id', - 'device_type', - 'slug', - 'commit', - ]), - ); + if (isV13()) { + await this.outputData( + application, + ['app_name', 'id', 'device_type', 'slug', 'commit'], + options, + ); + } else { + // Emulate table.vertical title output, but avoid uppercasing and inserting spaces + console.log(`== ${application.app_name}`); + console.log( + getVisuals().table.vertical(application, [ + 'id', + 'device_type', + 'slug', + 'commit', + ]), + ); + } } } diff --git a/lib/commands/apps.ts b/lib/commands/apps.ts index 14b76cca..04b3e177 100644 --- a/lib/commands/apps.ts +++ b/lib/commands/apps.ts @@ -17,18 +17,20 @@ import { flags } from '@oclif/command'; import type { Output as ParserOutput } from '@oclif/parser'; - import Command from '../command'; import * as cf from '../utils/common-flags'; import { getBalenaSdk, getVisuals, stripIndent } from '../utils/lazy'; import { appToFleetCmdMsg, warnify } from '../utils/messages'; +import { isV13 } from '../utils/version'; +import type { DataSetOutputOptions } from '../framework'; interface ExtendedApplication extends ApplicationWithDeviceType { - device_count?: number; - online_devices?: number; + device_count: number; + online_devices: number; + device_type?: string; } -interface FlagsDef { +interface FlagsDef extends DataSetOutputOptions { help: void; verbose?: boolean; } @@ -48,12 +50,17 @@ export class FleetsCmd extends Command { public static usage = 'fleets'; public static flags: flags.Input = { + ...(isV13() + ? {} + : { + verbose: flags.boolean({ + default: false, + char: 'v', + description: 'No-op since release v12.0.0', + }), + }), + ...(isV13() ? cf.dataSetOutputFlags : {}), help: cf.help, - verbose: flags.boolean({ - default: false, - char: 'v', - description: 'No-op since release v12.0.0', - }), }; public static authenticated = true; @@ -75,28 +82,39 @@ export class FleetsCmd extends Command { }, })) as ExtendedApplication[]; - const _ = await import('lodash'); // Add extended properties applications.forEach((application) => { application.device_count = application.owns__device?.length ?? 0; - application.online_devices = _.sumBy(application.owns__device, (d) => - d.is_online === true ? 1 : 0, - ); - // @ts-expect-error + application.online_devices = + application.owns__device?.filter((d) => d.is_online).length || 0; application.device_type = application.is_for__device_type[0].slug; }); - // Display - console.log( - getVisuals().table.horizontal(applications, [ - 'id', - this.useAppWord ? 'app_name' : 'app_name => NAME', - 'slug', - 'device_type', - 'online_devices', - 'device_count', - ]), - ); + if (isV13()) { + await this.outputData( + applications, + [ + 'id', + 'app_name', + 'slug', + 'device_type', + 'device_count', + 'online_devices', + ], + _parserOutput.flags, + ); + } else { + console.log( + getVisuals().table.horizontal(applications, [ + 'id', + this.useAppWord ? 'app_name' : 'app_name => NAME', + 'slug', + 'device_type', + 'online_devices', + 'device_count', + ]), + ); + } } } diff --git a/lib/framework/index.ts b/lib/framework/index.ts new file mode 100644 index 00000000..24097f7b --- /dev/null +++ b/lib/framework/index.ts @@ -0,0 +1,19 @@ +/* +Copyright 2020 Balena + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { DataOutputOptions, DataSetOutputOptions } from './output'; + +export { DataOutputOptions, DataSetOutputOptions }; diff --git a/lib/framework/output.ts b/lib/framework/output.ts new file mode 100644 index 00000000..723601ec --- /dev/null +++ b/lib/framework/output.ts @@ -0,0 +1,158 @@ +/* +Copyright 2020 Balena + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { getCliUx, getChalk } from '../utils/lazy'; + +export interface DataOutputOptions { + fields?: string; + json?: boolean; +} + +export interface DataSetOutputOptions extends DataOutputOptions { + filter?: string; + 'no-header'?: boolean; + 'no-truncate'?: boolean; + sort?: string; +} + +/** + * Output message to STDERR + */ +export function outputMessage(msg: string) { + // Messages go to STDERR + console.error(msg); +} + +/** + * Output result data to STDOUT + * Supports: + * - arrays of items (displayed in a tabular way), + * - single items (displayed in a field per row format). + * + * @param data Array of data objects to output + * @param fields Array of fieldnames, specifying the fields and display order + * @param options Output options + */ +export async function outputData( + data: any[] | {}, + fields: string[], + options: DataOutputOptions | DataSetOutputOptions, +) { + if (Array.isArray(data)) { + await outputDataSet(data, fields, options as DataSetOutputOptions); + } else { + await outputDataItem(data, fields, options as DataOutputOptions); + } +} + +/** + * Wraps the cli.ux table implementation, to output tabular data + * + * @param data Array of data objects to output + * @param fields Array of fieldnames, specifying the fields and display order + * @param options Output options + */ +async function outputDataSet( + data: any[], + fields: string[], + options: DataSetOutputOptions, +) { + // Oclif expects fields to be specified in the format used in table headers (though lowercase) + // By replacing underscores with spaces here, we can support both header format and actual field name + // (e.g. as seen in json output). + options.fields = options.fields?.replace(/_/g, ' '); + options.filter = options.filter?.replace(/_/g, ' '); + options.sort = options.sort?.replace(/_/g, ' '); + + getCliUx().table( + data, + // Convert fields array to column object keys + // that cli.ux expects. We can later add support + // for both formats if beneficial + fields.reduce((ac, a) => ({ ...ac, [a]: {} }), {}), + { + ...options, + ...(options.json + ? { + output: 'json', + } + : {}), + columns: options.fields, + printLine, + }, + ); +} + +/** + * Outputs a single data object (like `resin-cli-visuals table.vertical`), + * but supporting a subset of options from `cli-ux table` (--json and --fields) + * + * @param data Array of data objects to output + * @param fields Array of fieldnames, specifying the fields and display order + * @param options Output options + */ +async function outputDataItem( + data: any, + fields: string[], + options: DataOutputOptions, +) { + const outData: typeof data = {}; + + // Convert comma separated list of fields in `options.fields` to array of correct format. + // Note, user may have specified the true field name (e.g. `some_field`), + // or the format displayed in headers (e.g. `Some field`, case insensitive). + const userSelectedFields = options.fields?.split(',').map((f) => { + return f.toLowerCase().trim().replace(/ /g, '_'); + }); + + // Order and filter the fields based on `fields` parameter and `options.fields` + (userSelectedFields || fields).forEach((fieldName) => { + if (fields.includes(fieldName)) { + outData[fieldName] = data[fieldName]; + } + }); + + if (options.json) { + printLine(JSON.stringify(outData, undefined, 2)); + } else { + const chalk = getChalk(); + const { capitalize } = await import('lodash'); + + // Find longest key, so we can align results + const longestKeyLength = getLongestObjectKeyLength(outData); + + // Output one field per line + for (const [k, v] of Object.entries(outData)) { + const shim = ' '.repeat(longestKeyLength - k.length); + const kDisplay = capitalize(k.replace(/_/g, ' ')); + printLine(`${chalk.bold(kDisplay) + shim} : ${v}`); + } + } +} + +function getLongestObjectKeyLength(o: any): number { + return Object.keys(o).length >= 1 + ? Object.keys(o).reduce((a, b) => { + return a.length > b.length ? a : b; + }).length + : 0; +} + +function printLine(s: any) { + // Duplicating oclif cli-ux's default implementation here, + // but using this one explicitly for ease of testing + process.stdout.write(s + '\n'); +} diff --git a/lib/utils/common-flags.ts b/lib/utils/common-flags.ts index 33338ccb..024ad41f 100644 --- a/lib/utils/common-flags.ts +++ b/lib/utils/common-flags.ts @@ -16,11 +16,12 @@ */ import { flags } from '@oclif/command'; - -import type { IBooleanFlag } from '@oclif/parser/lib/flags'; import { stripIndent } from './lazy'; import { lowercaseIfSlug } from './normalization'; + import { isV13 } from './version'; +import type { IBooleanFlag } from '@oclif/parser/lib/flags'; +import type { DataOutputOptions, DataSetOutputOptions } from '../framework'; export const v13: IBooleanFlag = flags.boolean({ description: stripIndent`\ @@ -125,3 +126,37 @@ export const json: IBooleanFlag = flags.boolean({ description: 'produce JSON output instead of tabular output', default: false, }); + +export const dataOutputFlags: flags.Input = { + fields: flags.string({ + description: 'only show provided fields (comma-separated)', + }), + json: flags.boolean({ + char: 'j', + exclusive: ['no-truncate'], + description: 'output in json format', + default: false, + }), +}; + +export const dataSetOutputFlags: flags.Input & + flags.Input = { + ...dataOutputFlags, + filter: flags.string({ + description: + 'filter results by substring matching of a given field, eg: --filter field=foo', + }), + 'no-header': flags.boolean({ + exclusive: ['json'], + description: 'hide table header from output', + default: false, + }), + 'no-truncate': flags.boolean({ + exclusive: ['json'], + description: 'do not truncate output to fit screen', + default: false, + }), + sort: flags.string({ + description: `field to sort by (prepend '-' for descending order)`, + }), +}; diff --git a/package.json b/package.json index 3a291d42..2e576e92 100644 --- a/package.json +++ b/package.json @@ -220,6 +220,7 @@ "chalk": "^3.0.0", "chokidar": "^3.4.3", "cli-truncate": "^2.1.0", + "cli-ux": "^5.5.1", "color-hash": "^1.0.3", "columnify": "^1.5.2", "common-tags": "^1.7.2", diff --git a/tests/framework/output.spec.ts b/tests/framework/output.spec.ts new file mode 100644 index 00000000..975534fc --- /dev/null +++ b/tests/framework/output.spec.ts @@ -0,0 +1,255 @@ +/** + * @license + * Copyright 2020-2021 Balena Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* tslint:disable: prefer-const no-empty */ + +import rewire = require('rewire'); +import sinon = require('sinon'); +import { expect } from 'chai'; + +const dataItem = { + name: 'item1', + id: 1, + thing_color: 'blue', + thing_shape: 'square', +}; + +const dataSet = [ + { + name: 'item1', + id: 1, + thing_color: 'red', + thing_shape: 'square', + }, + { + name: 'item2', + id: 2, + thing_color: 'blue', + thing_shape: 'round', + }, +]; + +describe('outputData', function () { + let outputData: any; + let outputDataSetSpy: any; + let outputDataItemSpy: any; + + this.beforeEach(() => { + const output = rewire('../../build/framework/output'); + + outputDataSetSpy = sinon.spy(); + outputDataItemSpy = sinon.spy(); + + output.__set__('outputDataSet', outputDataSetSpy); + output.__set__('outputDataItem', outputDataItemSpy); + + outputData = output.__get__('outputData'); + }); + + it('should call outputDataSet function when data param is an array', async () => { + await outputData(dataSet); + expect(outputDataSetSpy.called).to.be.true; + expect(outputDataItemSpy.called).to.be.false; + }); + + it('should call outputDataItem function when data param is an object', async () => { + await outputData(dataItem); + expect(outputDataSetSpy.called).to.be.false; + expect(outputDataItemSpy.called).to.be.true; + }); +}); + +describe('outputDataSet', function () { + let outputDataSet: any; + let printLineSpy: any; + + this.beforeEach(() => { + const output = rewire('../../build/framework/output'); + printLineSpy = sinon.spy(); + output.__set__('printLine', printLineSpy); + outputDataSet = output.__get__('outputDataSet'); + }); + + it('should only output fields specified in `fields` param, in that order', async () => { + const fields = ['id', 'name', 'thing_color']; + const options = {}; + + await outputDataSet(dataSet, fields, options); + + // check correct number of rows (2 data, 2 header) + expect(printLineSpy.callCount).to.equal(4); + const headerLine = printLineSpy.firstCall.firstArg.toLowerCase(); + // check we have fields we specified + fields.forEach((f) => { + expect(headerLine).to.include(f.replace(/_/g, ' ')); + }); + // check we don't have fields we didn't specify + expect(headerLine).to.not.include('thing_shape'); + // check order + // split header using the `name` column as delimiter + const splitHeader = headerLine.split('name'); + expect(splitHeader[0]).to.include('id'); + expect(splitHeader[1]).to.include('thing'); + }); + + /* + it('should output fields in the order specified in `fields` param', async () => { + const fields = ['thing_color', 'id', 'name']; + const options = {}; + + await outputDataSet(dataSet, fields, options); + + const headerLine = printLineSpy.firstCall.firstArg.toLowerCase(); + // split header using the `it` column as delimiter + const splitHeader = headerLine.split('id'); + expect(splitHeader[0]).to.include('thing'); + expect(splitHeader[1]).to.include('name'); + }); + */ + + it('should only output fields specified in `options.fields` if present', async () => { + const fields = ['name', 'id', 'thing_color', 'thing_shape']; + const options = { + // test all formats + fields: 'Name,thing_color,Thing shape', + }; + + await outputDataSet(dataSet, fields, options); + + const headerLine = printLineSpy.firstCall.firstArg.toLowerCase(); + // check we have fields we specified + expect(headerLine).to.include('name'); + expect(headerLine).to.include('thing color'); + expect(headerLine).to.include('thing shape'); + // check we don't have fields we didn't specify + expect(headerLine).to.not.include('id'); + }); + + it('should output records in order specified by `options.sort` if present', async () => { + const fields = ['name', 'id', 'thing_color', 'thing_shape']; + const options = { + sort: 'thing shape', + 'no-header': true, + }; + + await outputDataSet(dataSet, fields, options); + + // blue should come before red + expect(printLineSpy.getCall(0).firstArg).to.include('blue'); + expect(printLineSpy.getCall(1).firstArg).to.include('red'); + }); + + it('should only output records that match filter specified by `options.filter` if present', async () => { + const fields = ['name', 'id', 'thing_color', 'thing_shape']; + const options = { + filter: 'thing color=red', + 'no-header': true, + }; + + await outputDataSet(dataSet, fields, options); + + // check correct number of rows (1 matched data, no-header) + expect(printLineSpy.callCount).to.equal(1); + expect(printLineSpy.getCall(0).firstArg).to.include('red'); + }); + + it('should output data in json format, if `options.json` true', async () => { + const fields = ['name', 'thing_color', 'thing_shape']; + const options = { + json: true, + }; + + // TODO: I've run into an oclif cli-ux bug, where numbers are output as strings in json + // (this can be seen by including 'id' in the fields list above). + // Issue opened: https://github.com/oclif/cli-ux/issues/309 + // For now removing id for this test. + const clonedDataSet = JSON.parse(JSON.stringify(dataSet)); + clonedDataSet.forEach((d: any) => { + delete d.id; + }); + + const expectedJson = JSON.stringify(clonedDataSet, undefined, 2); + + await outputDataSet(dataSet, fields, options); + + expect(printLineSpy.callCount).to.equal(1); + expect(printLineSpy.getCall(0).firstArg).to.equal(expectedJson); + }); +}); + +describe('outputDataItem', function () { + let outputDataItem: any; + let printLineSpy: any; + + this.beforeEach(() => { + const output = rewire('../../build/framework/output'); + printLineSpy = sinon.spy(); + output.__set__('printLine', printLineSpy); + outputDataItem = output.__get__('outputDataItem'); + }); + + it('should only output fields specified in `fields` param, in that order', async () => { + const fields = ['id', 'name', 'thing_color']; + const options = {}; + + await outputDataItem(dataItem, fields, options); + + // check correct number of rows (3 fields) + expect(printLineSpy.callCount).to.equal(3); + // check we have fields we specified + fields.forEach((f, index) => { + const kvPair = printLineSpy.getCall(index).firstArg.split(':'); + expect(kvPair[0].toLowerCase()).to.include(f.replace(/_/g, ' ')); + expect(kvPair[1]).to.include((dataItem as any)[f]); + }); + }); + + it('should only output fields specified in `options.fields` if present', async () => { + const fields = ['name', 'id', 'thing_color', 'thing_shape']; + const options = { + // test all formats + fields: 'Name,thing_color,Thing shape', + }; + + const expectedFields = ['name', 'thing_color', 'thing_shape']; + + await outputDataItem(dataItem, fields, options); + + // check correct number of rows (3 fields) + expect(printLineSpy.callCount).to.equal(3); + // check we have fields we specified + expectedFields.forEach((f, index) => { + const kvPair = printLineSpy.getCall(index).firstArg.split(':'); + expect(kvPair[0].toLowerCase()).to.include(f.replace(/_/g, ' ')); + expect(kvPair[1]).to.include((dataItem as any)[f]); + }); + }); + + it('should output data in json format, if `options.json` true', async () => { + const fields = ['name', 'id', 'thing_color', 'thing_shape']; + const options = { + json: true, + }; + + const expectedJson = JSON.stringify(dataItem, undefined, 2); + + await outputDataItem(dataItem, fields, options); + + expect(printLineSpy.callCount).to.equal(1); + expect(printLineSpy.getCall(0).firstArg).to.equal(expectedJson); + }); +});