diff --git a/docs/balena-cli.md b/docs/balena-cli.md index 8dd1ae8f..28a46c79 100644 --- a/docs/balena-cli.md +++ b/docs/balena-cli.md @@ -2168,13 +2168,9 @@ fleet name or slug (preferred) open fleet dashboard page -#### --fields FIELDS - -only show provided fields (comma-separated) - #### -j, --json -output in json format +produce JSON output instead of tabular output ## fleet pin <slug> [releaseToPinTo] @@ -2353,29 +2349,9 @@ Examples: ### Options -#### --fields FIELDS - -only show provided fields (comma-separated) - #### -j, --json -output in json format - -#### --filter FILTER - -filter results by substring matching of a given field, eg: --filter field=foo - -#### --no-header - -hide table header from output - -#### --no-truncate - -do not truncate output to fit screen - -#### --sort SORT - -field to sort by (prepend '-' for descending order) +produce JSON output instead of tabular output # Local diff --git a/lib/command.ts b/lib/command.ts index 1b260a35..4c5d66bd 100644 --- a/lib/command.ts +++ b/lib/command.ts @@ -21,7 +21,6 @@ import { NotAvailableInOfflineModeError, } from './errors'; import { stripIndent } from './utils/lazy'; -import * as output from './framework/output'; export default abstract class BalenaCommand extends Command { /** @@ -168,7 +167,4 @@ export default abstract class BalenaCommand extends Command { await this.getStdin(); } } - - protected outputMessage = output.outputMessage; - protected outputData = output.outputData; } diff --git a/lib/commands/fleet/index.ts b/lib/commands/fleet/index.ts index b59c135a..b34be44b 100644 --- a/lib/commands/fleet/index.ts +++ b/lib/commands/fleet/index.ts @@ -20,7 +20,7 @@ import { Flags } from '@oclif/core'; import Command from '../../command'; import * as cf from '../../utils/common-flags'; import * as ca from '../../utils/common-args'; -import { getBalenaSdk, stripIndent } from '../../utils/lazy'; +import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy'; import { applicationIdInfo } from '../../utils/messages'; export default class FleetCmd extends Command { @@ -49,7 +49,7 @@ export default class FleetCmd extends Command { default: false, description: 'open fleet dashboard page', }), - ...cf.dataOutputFlags, + json: cf.json, }; public static authenticated = true; @@ -78,16 +78,28 @@ export default class FleetCmd extends Command { return; } - const outputApplication = { - ...application, + const applicationToDisplay = { + id: application.id, + app_name: application.app_name, + slug: application.slug, device_type: application.is_for__device_type[0].slug, commit: application.should_be_running__release[0]?.commit, }; - await this.outputData( - outputApplication, - ['app_name', 'id', 'device_type', 'slug', 'commit'], - options, + if (options.json) { + console.log(JSON.stringify(applicationToDisplay, null, 4)); + return; + } + + // Emulate table.vertical title output, but avoid uppercasing and inserting spaces + console.log(`== ${applicationToDisplay.app_name}`); + console.log( + getVisuals().table.vertical(applicationToDisplay, [ + 'id', + 'device_type', + 'slug', + 'commit', + ]), ); } } diff --git a/lib/commands/fleets/index.ts b/lib/commands/fleets/index.ts index fe649c18..01afa724 100644 --- a/lib/commands/fleets/index.ts +++ b/lib/commands/fleets/index.ts @@ -19,7 +19,7 @@ import type * as BalenaSdk from 'balena-sdk'; import Command from '../../command'; import * as cf from '../../utils/common-flags'; -import { getBalenaSdk, stripIndent } from '../../utils/lazy'; +import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy'; interface ExtendedApplication extends ApplicationWithDeviceTypeSlug { device_count: number; @@ -42,8 +42,8 @@ export default class FleetsCmd extends Command { public static usage = 'fleets'; public static flags = { - ...cf.dataSetOutputFlags, help: cf.help, + json: cf.json, }; public static authenticated = true; @@ -77,17 +77,29 @@ export default class FleetsCmd extends Command { application.device_type = application.is_for__device_type[0].slug; }); - await this.outputData( - applications, - [ + const applicationsToDisplay = applications.map((application) => ({ + id: application.id, + app_name: application.app_name, + slug: application.slug, + device_type: application.device_type, + online_devices: application.online_devices, + device_count: application.device_count, + })); + + if (options.json) { + console.log(JSON.stringify(applicationsToDisplay, null, 4)); + return; + } + + console.log( + getVisuals().table.horizontal(applicationsToDisplay, [ 'id', - 'app_name', + 'app_name => NAME', 'slug', 'device_type', - 'device_count', 'online_devices', - ], - options, + 'device_count', + ]), ); } } diff --git a/lib/framework/index.ts b/lib/framework/index.ts deleted file mode 100644 index 24097f7b..00000000 --- a/lib/framework/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* -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 deleted file mode 100644 index ae0e5132..00000000 --- a/lib/framework/output.ts +++ /dev/null @@ -1,158 +0,0 @@ -/* -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[] | object, - 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 0941ed43..7707e6c9 100644 --- a/lib/utils/common-flags.ts +++ b/lib/utils/common-flags.ts @@ -104,36 +104,3 @@ export const json = Flags.boolean({ description: 'produce JSON output instead of tabular output', default: false, }); - -export const dataOutputFlags = { - 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 = { - ...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/tests/framework/output.spec.ts b/tests/framework/output.spec.ts deleted file mode 100644 index 975534fc..00000000 --- a/tests/framework/output.spec.ts +++ /dev/null @@ -1,255 +0,0 @@ -/** - * @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); - }); -});