diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 8d3ddfc5..00000000 --- a/.eslintrc +++ /dev/null @@ -1,22 +0,0 @@ -{ - "extends": "@adobe/eslint-config-aio-lib-config", - "globals": { - "fixtureFile": true, - "fixtureFileWithTimeZoneAdjustment": true, - "fixtureJson": true, - "fixtureZip": true, - "fakeFileSystem": true, - "createTestBaseFlagsFunction": true, - "createTestFlagsFunction": true - }, - "rules": { - "jsdoc/tag-lines": [ - // The Error level should be `error`, `warn`, or `off` (or 2, 1, or 0) - "error", - "never", - { - "startLines": null - } - ] - } -} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 039af9ff..b53c3908 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ junit.xml oclif.manifest.json .vscode .idea +.claude diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..6ab09b46 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,46 @@ +/* +Copyright 2019 Adobe Inc. All rights reserved. +This file is licensed to you 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 REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const aioConfig = require('@adobe/eslint-config-aio-lib-config') +const jestPlugin = require('eslint-plugin-jest') + +const testGlobals = { + fixtureFile: 'readonly', + fixtureFileWithTimeZoneAdjustment: 'readonly', + fixtureJson: 'readonly', + fixtureZip: 'readonly', + fakeFileSystem: 'readonly', + createTestBaseFlagsFunction: 'readonly', + createTestFlagsFunction: 'readonly' +} + +module.exports = [ + ...aioConfig, + { + ignores: ['node_modules/**', 'coverage/**'] + }, + { + rules: { + 'jsdoc/tag-lines': ['error', 'never', { startLines: null }] + } + }, + { + files: ['test/**/*.js', 'e2e/**/*.js'], + ...jestPlugin.configs['flat/recommended'], + languageOptions: { + globals: { + ...jestPlugin.configs['flat/recommended'].languageOptions.globals, + ...testGlobals + } + } + } +] diff --git a/package.json b/package.json index f7f41d4c..e092a261 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@adobe/aio-lib-env": "^3.0.1", "@adobe/aio-lib-ims": "^8.0.1", "@adobe/aio-lib-runtime": "^7.1.0", - "@oclif/core": "^2.8.12", + "@oclif/core": "^4.0.0", "@types/jest": "^29.5.3", "chalk": "^4.1.2", "dayjs": "^1.10.4", @@ -27,26 +27,21 @@ "sha1": "^1.1.1" }, "devDependencies": { - "@adobe/eslint-config-aio-lib-config": "^4.0.0", + "@adobe/eslint-config-aio-lib-config": "5.0.0", "@babel/core": "^7.16.12", "@babel/preset-env": "^7.16.11", "babel-jest": "^29.5.0", "babel-runtime": "^6.26.0", "dedent-js": "^1.0.1", "eol": "^0.10.0", - "eslint": "^8.57.1", - "eslint-config-oclif": "^5.2.2", - "eslint-config-standard": "^17.1.0", - "eslint-plugin-import": "^2.31.0", - "eslint-plugin-jest": "^27.9.0", + "eslint": "^9.0.0", + "eslint-plugin-jest": "^29.0.0", "eslint-plugin-jsdoc": "^48.11.0", - "eslint-plugin-n": "^15.7.0", - "eslint-plugin-node": "^11.1.0", - "eslint-plugin-promise": "^6.6.0", "execa": "^4.0.0", "jest": "^29.6.2", "jest-junit": "^16.0.0", - "oclif": "^3.2.0", + "neostandard": "^0", + "oclif": "^4.0.0", "stdout-stderr": "^0.1.9" }, "engines": { diff --git a/src/commands/runtime/action/list.js b/src/commands/runtime/action/list.js index 6c2b90e3..47430b63 100644 --- a/src/commands/runtime/action/list.js +++ b/src/commands/runtime/action/list.js @@ -13,7 +13,8 @@ governing permissions and limitations under the License. const moment = require('dayjs') const RuntimeBaseCommand = require('../../../RuntimeBaseCommand') const { parsePackageName } = require('@adobe/aio-lib-runtime').utils -const { Args, Flags, ux } = require('@oclif/core') +const { Args, Flags } = require('@oclif/core') +const { table } = require('../../../ux-table') const decorators = require('../../../decorators').decorators() class ActionList extends RuntimeBaseCommand { @@ -86,7 +87,7 @@ class ActionList extends RuntimeBaseCommand { } } } - ux.table(result, columns) + table(result, columns) } } catch (err) { await this.handleError('failed to list the actions', err) diff --git a/src/commands/runtime/activation/list.js b/src/commands/runtime/activation/list.js index 1777f981..5f025d88 100644 --- a/src/commands/runtime/activation/list.js +++ b/src/commands/runtime/activation/list.js @@ -12,7 +12,8 @@ governing permissions and limitations under the License. const moment = require('dayjs') const RuntimeBaseCommand = require('../../../RuntimeBaseCommand') -const { Args, Flags, ux } = require('@oclif/core') +const { Args, Flags } = require('@oclif/core') +const { table } = require('../../../ux-table') const decorators = require('../../../decorators').decorators() const statusStrings = ['success', 'app error', 'dev error', 'sys error'] @@ -114,6 +115,7 @@ class ActivationList extends RuntimeBaseCommand { }, Topmost: { header: '', + minWidth: 2, maxWidth: 2, get: row => { if (row.annotations && row.annotations.length) { @@ -187,7 +189,7 @@ class ActivationList extends RuntimeBaseCommand { } } if (listActivation) { - ux.table(listActivation, columns, { + table(listActivation, columns, { 'no-truncate': true }) } diff --git a/src/commands/runtime/api/list.js b/src/commands/runtime/api/list.js index 3907e254..646c9f78 100644 --- a/src/commands/runtime/api/list.js +++ b/src/commands/runtime/api/list.js @@ -10,7 +10,8 @@ governing permissions and limitations under the License. */ const RuntimeBaseCommand = require('../../../RuntimeBaseCommand') -const { Args, Flags, ux } = require('@oclif/core') +const { Args, Flags } = require('@oclif/core') +const { table } = require('../../../ux-table') /** @private */ function processApi (api) { @@ -40,13 +41,8 @@ function processApi (api) { class ApiList extends RuntimeBaseCommand { async run () { - // Workaround for oclif v2 parsing issue: capture argv before parse() when multiple optional args are present - // oclif v2 doesn't properly parse --json flag when command has 3+ optional positional arguments - // Related: https://github.com/oclif/core/issues/854 (workaround: search argv directly) - const argvBeforeParse = [...(this.argv ?? [])] const { args, flags } = await this.parse(ApiList) - const hasJsonInArgv = argvBeforeParse.includes('--json') - const shouldOutputJson = flags.json || hasJsonInArgv + const shouldOutputJson = flags.json try { const ow = await this.wsk() @@ -74,7 +70,7 @@ class ApiList extends RuntimeBaseCommand { }, data) }) - ux.table(data, { + table(data, { Action: { minWidth: 10 }, Verb: { minWidth: 10 }, APIName: { header: 'API Name', minWidth: 10 }, diff --git a/src/commands/runtime/namespace/get.js b/src/commands/runtime/namespace/get.js index 613bf475..1fd79334 100644 --- a/src/commands/runtime/namespace/get.js +++ b/src/commands/runtime/namespace/get.js @@ -11,7 +11,8 @@ governing permissions and limitations under the License. */ const RuntimeBaseCommand = require('../../../RuntimeBaseCommand') -const { Flags, ux } = require('@oclif/core') +const { Flags } = require('@oclif/core') +const { table } = require('../../../ux-table') /** @private */ function createColumns (columnName) { @@ -79,10 +80,10 @@ class NamespaceGet extends RuntimeBaseCommand { } else { this.log('Entities in namespace:') - ux.table(data.packages, createColumns('packages')) - ux.table(data.actions, createColumns('actions')) - ux.table(data.triggers, createColumns('triggers')) - ux.table(data.rules, createColumns('rules')) + table(data.packages, createColumns('packages')) + table(data.actions, createColumns('actions')) + table(data.triggers, createColumns('triggers')) + table(data.rules, createColumns('rules')) } } catch (err) { await this.handleError('failed to get the data for a namespace', err) diff --git a/src/commands/runtime/namespace/list.js b/src/commands/runtime/namespace/list.js index 1cd804a2..c224987a 100644 --- a/src/commands/runtime/namespace/list.js +++ b/src/commands/runtime/namespace/list.js @@ -11,7 +11,8 @@ governing permissions and limitations under the License. */ const RuntimeBaseCommand = require('../../../RuntimeBaseCommand') -const { Flags, ux } = require('@oclif/core') +const { Flags } = require('@oclif/core') +const { table } = require('../../../ux-table') class NamespaceList extends RuntimeBaseCommand { async run () { @@ -29,7 +30,7 @@ class NamespaceList extends RuntimeBaseCommand { get: row => row } } - ux.table(result, columns) + table(result, columns) } } catch (err) { await this.handleError('failed to list namespaces', err) diff --git a/src/commands/runtime/package/list.js b/src/commands/runtime/package/list.js index d9449c88..0bacf4b1 100644 --- a/src/commands/runtime/package/list.js +++ b/src/commands/runtime/package/list.js @@ -12,7 +12,8 @@ governing permissions and limitations under the License. const moment = require('dayjs') const RuntimeBaseCommand = require('../../../RuntimeBaseCommand') -const { Args, Flags, ux } = require('@oclif/core') +const { Args, Flags } = require('@oclif/core') +const { table } = require('../../../ux-table') class PackageList extends RuntimeBaseCommand { async run () { @@ -78,7 +79,7 @@ class PackageList extends RuntimeBaseCommand { get: row => row.name } } - ux.table(result, columns) + table(result, columns) } } catch (err) { await this.handleError('failed to list the packages', err) diff --git a/src/commands/runtime/property/get.js b/src/commands/runtime/property/get.js index a041de77..8887c939 100644 --- a/src/commands/runtime/property/get.js +++ b/src/commands/runtime/property/get.js @@ -10,7 +10,8 @@ governing permissions and limitations under the License. */ const RuntimeBaseCommand = require('../../../RuntimeBaseCommand') -const { Flags, ux } = require('@oclif/core') +const { Flags } = require('@oclif/core') +const { table } = require('../../../ux-table') const { createFetch } = require('@adobe/aio-lib-core-networking') const { PropertyKey, PropertyDefault, propertiesFile, PropertyEnv } = require('../../../properties') const debug = require('debug')('aio-cli-plugin-runtime/property') @@ -90,7 +91,7 @@ class PropertyGet extends RuntimeBaseCommand { data.push({ Property: PropertyGet.flags.apibuildno.description, Value: result.buildno }) } } - ux.table(data, + table(data, { Property: { minWidth: 10 }, Value: { minWidth: 20 } diff --git a/src/commands/runtime/rule/list.js b/src/commands/runtime/rule/list.js index cbcf4580..d24cb852 100644 --- a/src/commands/runtime/rule/list.js +++ b/src/commands/runtime/rule/list.js @@ -11,7 +11,8 @@ governing permissions and limitations under the License. const moment = require('dayjs') const RuntimeBaseCommand = require('../../../RuntimeBaseCommand') -const { Flags, ux } = require('@oclif/core') +const { Flags } = require('@oclif/core') +const { table } = require('../../../ux-table') class RuleList extends RuntimeBaseCommand { async run () { @@ -68,7 +69,7 @@ class RuleList extends RuntimeBaseCommand { get: row => row.name } } - ux.table(resultsWithStatus, columns) + table(resultsWithStatus, columns) } }) return p diff --git a/src/commands/runtime/trigger/list.js b/src/commands/runtime/trigger/list.js index 81179072..aef3b03a 100644 --- a/src/commands/runtime/trigger/list.js +++ b/src/commands/runtime/trigger/list.js @@ -12,7 +12,8 @@ governing permissions and limitations under the License. const moment = require('dayjs') const RuntimeBaseCommand = require('../../../RuntimeBaseCommand') -const { Flags, ux } = require('@oclif/core') +const { Flags } = require('@oclif/core') +const { table } = require('../../../ux-table') class TriggerList extends RuntimeBaseCommand { async run () { @@ -84,7 +85,7 @@ class TriggerList extends RuntimeBaseCommand { get: row => row.name } } - ux.table(resultsWithStatus, columns) + table(resultsWithStatus, columns) } }) } catch (err) { diff --git a/src/ux-table.js b/src/ux-table.js new file mode 100644 index 00000000..e86b138d --- /dev/null +++ b/src/ux-table.js @@ -0,0 +1,92 @@ +/* +Copyright 2019 Adobe Inc. All rights reserved. +This file is licensed to you 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 REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +/** + * Print a simple text table to stdout. + * + * Replaces ux.table from @oclif/core v1-v3 (removed in v4). + * + * @param {Array} data - Array of data objects + * @param {object} columns - Column definitions. Each key maps to a column. Supports: + * - header {string}: column header (defaults to key, capitalized) + * - get {function}: accessor function (defaults to row[key]) + * - minWidth {number}: minimum column width + * - maxWidth {number}: maximum column width (values exceeding this are truncated) + * @param {object} [options] - Options: + * - printLine {function}: function to print a line (defaults to process.stdout.write) + * - 'no-header' {boolean}: skip printing the header row + * - 'no-truncate' {boolean}: ignored (included for API compatibility) + */ +function table (data, columns, options = {}) { + const printLine = options.printLine || ((s) => process.stdout.write(s + '\n')) + + const cols = Object.keys(columns).map(key => { + const col = columns[key] + const header = typeof col.header === 'string' ? col.header : key.charAt(0).toUpperCase() + key.slice(1) + const getValue = col.get || ((row) => row[key]) + const minWidth = Math.max(col.minWidth || 0, header.length + 1) + const maxWidth = col.maxWidth || null + return { key, header, getValue, minWidth, maxWidth } + }) + + // Compute string values for all rows + const rows = data.map(row => { + const result = {} + for (const col of cols) { + const val = col.getValue(row) + result[col.key] = val != null ? String(val) : '' + } + return result + }) + + // Compute column widths: max of minWidth and widest value + 1, capped by maxWidth + const widths = {} + for (const col of cols) { + const maxDataWidth = rows.length > 0 ? Math.max(...rows.map(r => r[col.key].length)) : 0 + let width = Math.max(col.minWidth, maxDataWidth + 1) + if (col.maxWidth !== null) { + width = Math.min(width, col.maxWidth) + } + widths[col.key] = width + } + + const rowStart = ' ' + + const noHeader = options['no-header'] + + // Print header and divider (unless suppressed) + if (!noHeader) { + let header = rowStart + let divider = rowStart + for (const col of cols) { + const w = widths[col.key] + const headerText = col.header.length > w - 1 ? col.header.slice(0, w - 1) : col.header + header += headerText.padEnd(w) + divider += ''.padEnd(w - 1, '─') + ' ' + } + printLine(header) + printLine(divider) + } + + // Print rows + for (const row of rows) { + let line = rowStart + for (const col of cols) { + const w = widths[col.key] + const val = row[col.key].length > w - 1 ? row[col.key].slice(0, w - 1) : row[col.key] + line += val.padEnd(w) + } + printLine(line) + } +} + +module.exports = { table } diff --git a/test/commands/runtime/api/list.test.js b/test/commands/runtime/api/list.test.js index 52ba263f..e716991b 100644 --- a/test/commands/runtime/api/list.test.js +++ b/test/commands/runtime/api/list.test.js @@ -111,24 +111,6 @@ describe('instance methods', () => { }) }) - test('handles falsy argv gracefully', async () => { - rtLib.mockResolvedFixture(rtAction, 'api/list.json') - const cmd = new TheCommand([]) - const originalArgv = cmd.argv - let argvAccessCount = 0 - Object.defineProperty(cmd, 'argv', { - get: function () { - argvAccessCount++ - return argvAccessCount === 1 ? undefined : originalArgv - }, - configurable: true - }) - return cmd.run() - .then(() => { - expect(argvAccessCount).toBeGreaterThan(0) - }) - }) - test('error, throws exception', async () => { rtLib.mockRejected(rtAction, new Error('an error')) const error = ['failed to list the api', new Error('an error')] diff --git a/test/jest.setup.js b/test/jest.setup.js index 4664a302..b23383a1 100644 --- a/test/jest.setup.js +++ b/test/jest.setup.js @@ -16,6 +16,19 @@ const eol = require('eol') jest.setTimeout(30000) +// oclif v4 requires this.config.runHook in Command.parse(). +// Patch the prototype so unit tests that instantiate commands directly still work. +const { Command } = require('@oclif/core') +const _originalParse = Command.prototype.parse +Command.prototype.parse = async function (options, argv) { + if (!this.config) { + this.config = { runHook: async () => ({ successes: [], failures: [] }), bin: 'aio', userAgent: 'test/0.0.0', findCommand: () => null, pjson: { oclif: {} } } + } else if (!this.config.runHook) { + this.config.runHook = async () => ({ successes: [], failures: [] }) + } + return _originalParse.call(this, options, argv) +} + global.__mockFs = {} jest.mock('fs', () => { const actualFs = jest.requireActual('fs') @@ -240,7 +253,8 @@ global.createTestFlagsFunction = (TheCommand, Flags) => { return () => { // every command needs to override .flags (for global flags) // eslint: see https://eslint.org/docs/rules/no-prototype-builtins - expect(Object.prototype.hasOwnProperty.call(TheCommand, '_flags')).toBeTruthy() + // In oclif v4, flags are stored as 'flags' (not '_flags' as in v1) + expect(Object.prototype.hasOwnProperty.call(TheCommand, 'flags')).toBeTruthy() const flagsKeys = Object.keys(Flags) const theCommandFlagKeys = Object.keys(TheCommand.flags) diff --git a/test/ux-table.test.js b/test/ux-table.test.js new file mode 100644 index 00000000..030bc81d --- /dev/null +++ b/test/ux-table.test.js @@ -0,0 +1,126 @@ +/* +Copyright 2019 Adobe Inc. All rights reserved. +This file is licensed to you 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 REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const { table } = require('../src/ux-table') + +describe('table', () => { + let lines + + beforeEach(() => { + lines = [] + }) + + const printLine = (s) => lines.push(s) + + test('prints header and data rows', () => { + table( + [{ name: 'Alice', age: '30' }], + { name: { minWidth: 6 }, age: { minWidth: 5 } }, + { printLine } + ) + expect(lines[0]).toMatch(/Name/) + expect(lines[1]).toMatch(/─/) + expect(lines[2]).toMatch(/Alice/) + }) + + test('suppresses header when no-header option is set', () => { + table( + [{ name: 'Alice' }], + { name: { minWidth: 6 } }, + { printLine, 'no-header': true } + ) + // Only one line (the data row), no header or divider + expect(lines.length).toBe(1) + expect(lines[0]).toMatch(/Alice/) + }) + + test('enforces maxWidth as upper bound and truncates values', () => { + table( + [{ code: 'toolongvalue' }], + { code: { maxWidth: 6 } }, + { printLine } + ) + // Column width capped at 6; content area is 5 chars (w - 1) + const dataRow = lines[2] + // 'toolo' (5 chars) + 1 space = 6 chars for the column + expect(dataRow).toContain('toolo') + expect(dataRow).not.toContain('toolongvalue') + }) + + test('truncates header when maxWidth is smaller than header length', () => { + table( + [{ status: 'ok' }], + { status: { header: 'LongHeader', maxWidth: 6 } }, + { printLine } + ) + // Header 'LongHeader' (10 chars) truncated to fit width 6 → 'LongH' + expect(lines[0]).toContain('LongH') + expect(lines[0]).not.toContain('LongHeader') + }) + + test('uses custom header when provided', () => { + table( + [{ id: '123' }], + { id: { header: 'Identifier' } }, + { printLine } + ) + expect(lines[0]).toContain('Identifier') + }) + + test('uses key-derived header when header is not provided', () => { + table( + [{ myKey: 'val' }], + { myKey: {} }, + { printLine } + ) + expect(lines[0]).toContain('MyKey') + }) + + test('uses custom get accessor', () => { + table( + [{ raw: 'hello' }], + { raw: { get: row => row.raw.toUpperCase() } }, + { printLine } + ) + expect(lines[2]).toContain('HELLO') + }) + + test('handles null/undefined values as empty strings', () => { + table( + [{ val: null }, { val: undefined }], + { val: {} }, + { printLine } + ) + // header + divider + 2 data rows + expect(lines.length).toBe(4) + expect(lines[2].trim()).toBe('') + expect(lines[3].trim()).toBe('') + }) + + test('handles empty data array', () => { + table([], { name: { minWidth: 6 } }, { printLine }) + // Header and divider still printed, no data rows + expect(lines.length).toBe(2) + }) + + test('defaults to stdout when printLine is not provided', () => { + const originalWrite = process.stdout.write.bind(process.stdout) + const captured = [] + process.stdout.write = (s) => { captured.push(s); return true } + try { + table([{ x: '1' }], { x: {} }) + expect(captured.length).toBeGreaterThan(0) + } finally { + process.stdout.write = originalWrite + } + }) +})