From a21725d765ec12b03f3b56ad9ff2ad10526c1666 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Tue, 12 Aug 2025 22:35:53 -0400 Subject: [PATCH 1/3] feat: add positional arguments support to get commands --- oclif.manifest.json | 46 +++++++++++++--- src/commands/environments/get.test.ts | 77 +++++++++++++++++++++++++++ src/commands/environments/get.ts | 31 +++++++++-- src/commands/features/get.test.ts | 51 ++++++++++++++++++ src/commands/features/get.ts | 22 ++++++-- src/commands/variables/get.test.ts | 33 ++++++++++++ src/commands/variables/get.ts | 26 +++++++-- src/utils/parseKeysFromArgs.test.ts | 18 +++++++ src/utils/parseKeysFromArgs.ts | 27 ++++++++++ 9 files changed, 312 insertions(+), 19 deletions(-) create mode 100644 src/commands/environments/get.test.ts create mode 100644 src/utils/parseKeysFromArgs.test.ts create mode 100644 src/utils/parseKeysFromArgs.ts diff --git a/oclif.manifest.json b/oclif.manifest.json index cf17bc076..7b1ce8a71 100644 --- a/oclif.manifest.json +++ b/oclif.manifest.json @@ -1,5 +1,5 @@ { - "version": "5.21.2", + "version": "6.0.0", "commands": { "authCommand": { "id": "authCommand", @@ -1194,7 +1194,7 @@ "environments:get": { "id": "environments:get", "description": "Retrieve Environments from the management API", - "strict": true, + "strict": false, "pluginName": "@devcycle/cli", "pluginAlias": "@devcycle/cli", "pluginType": "core", @@ -1203,6 +1203,9 @@ "hiddenAliases": [], "examples": [ "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> environment-one", + "<%= config.bin %> <%= command.id %> environment-one environment-two", + "<%= config.bin %> <%= command.id %> environment-one,environment-two", "<%= config.bin %> <%= command.id %> --keys=environment-one,environment-two" ], "flags": { @@ -1295,7 +1298,13 @@ "multiple": false } }, - "args": {} + "args": { + "keys": { + "name": "keys", + "description": "Environment keys to fetch (space-separated or comma-separated)", + "required": false + } + } }, "environments:list": { "id": "environments:list", @@ -1761,7 +1770,7 @@ "features:get": { "id": "features:get", "description": "Retrieve Features from the Management API", - "strict": true, + "strict": false, "pluginName": "@devcycle/cli", "pluginAlias": "@devcycle/cli", "pluginType": "core", @@ -1770,6 +1779,9 @@ "hiddenAliases": [], "examples": [ "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> feature-one", + "<%= config.bin %> <%= command.id %> feature-one feature-two", + "<%= config.bin %> <%= command.id %> feature-one,feature-two", "<%= config.bin %> <%= command.id %> --keys=feature-one,feature-two" ], "flags": { @@ -1880,7 +1892,13 @@ "multiple": false } }, - "args": {} + "args": { + "keys": { + "name": "keys", + "description": "Feature keys to fetch (space-separated or comma-separated)", + "required": false + } + } }, "features:list": { "id": "features:list", @@ -5198,13 +5216,21 @@ }, "variables:get": { "id": "variables:get", - "strict": true, + "description": "Retrieve Variables from the Management API", + "strict": false, "pluginName": "@devcycle/cli", "pluginAlias": "@devcycle/cli", "pluginType": "core", "hidden": false, "aliases": [], "hiddenAliases": [], + "examples": [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> var-one", + "<%= config.bin %> <%= command.id %> var-one var-two", + "<%= config.bin %> <%= command.id %> var-one,var-two", + "<%= config.bin %> <%= command.id %> --keys=var-one,var-two" + ], "flags": { "config-path": { "name": "config-path", @@ -5313,7 +5339,13 @@ "multiple": false } }, - "args": {} + "args": { + "keys": { + "name": "keys", + "description": "Variable keys to fetch (space-separated or comma-separated)", + "required": false + } + } }, "variables:list": { "id": "variables:list", diff --git a/src/commands/environments/get.test.ts b/src/commands/environments/get.test.ts new file mode 100644 index 000000000..581085e8c --- /dev/null +++ b/src/commands/environments/get.test.ts @@ -0,0 +1,77 @@ +import { expect } from '@oclif/test' +import { dvcTest } from '../../../test-utils' +import { BASE_URL } from '../../api/common' + +describe('environments get', () => { + const projectKey = 'test-project' + const authFlags = [ + '--client-id', + 'test-client-id', + '--client-secret', + 'test-client-secret', + ] + + const mockEnvironments = [ + { + key: 'development', + name: 'Development', + _id: '61450f3daec96f5cf4a49960', + }, + { + key: 'production', + name: 'Production', + _id: '61450f3daec96f5cf4a49961', + }, + ] + + const mockSingleEnvironment = { + key: 'test-env', + name: 'Test Environment', + _id: '61450f3daec96f5cf4a49962', + } + + dvcTest() + .nock(BASE_URL, (api) => + api + .get(`/v1/projects/${projectKey}/environments`) + .reply(200, mockEnvironments), + ) + .stdout() + .command([ + 'environments get', + '--project', + projectKey, + '--headless', + ...authFlags, + ]) + .it('returns a list of environment objects in headless mode', (ctx) => { + expect(ctx.stdout).to.contain(JSON.stringify(mockEnvironments)) + }) + + // Test positional arguments functionality + dvcTest() + .nock(BASE_URL, (api) => + api + .get(`/v1/projects/${projectKey}/environments/development`) + .reply(200, mockEnvironments[0]) + .get(`/v1/projects/${projectKey}/environments/production`) + .reply(200, mockEnvironments[1]), + ) + .stdout() + .command([ + 'environments get', + 'development', + 'production', + '--project', + projectKey, + ...authFlags, + ]) + .it( + 'fetches multiple environments by space-separated positional arguments', + (ctx) => { + expect(ctx.stdout).to.contain( + JSON.stringify(mockEnvironments, null, 2), + ) + }, + ) +}) diff --git a/src/commands/environments/get.ts b/src/commands/environments/get.ts index 3d90c1de1..51ef459ed 100644 --- a/src/commands/environments/get.ts +++ b/src/commands/environments/get.ts @@ -1,4 +1,4 @@ -import { Flags } from '@oclif/core' +import { Args, Flags } from '@oclif/core' import inquirer from '../../ui/autocomplete' import { fetchEnvironments, @@ -13,8 +13,19 @@ export default class DetailedEnvironments extends Base { static description = 'Retrieve Environments from the management API' static examples = [ '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> environment-one', + '<%= config.bin %> <%= command.id %> environment-one environment-two', + '<%= config.bin %> <%= command.id %> environment-one,environment-two', '<%= config.bin %> <%= command.id %> --keys=environment-one,environment-two', ] + static args = { + keys: Args.string({ + description: + 'Environment keys to fetch (space-separated or comma-separated)', + required: false, + }), + } + static strict = false static flags = { ...Base.flags, keys: Flags.string({ @@ -25,12 +36,24 @@ export default class DetailedEnvironments extends Base { authRequired = true public async run(): Promise { - const { flags } = await this.parse(DetailedEnvironments) - const keys = flags['keys']?.split(',') + const { args, argv, flags } = await this.parse(DetailedEnvironments) const { headless, project } = flags await this.requireProject(project, headless) - if (keys) { + // Handle positional arguments - they take precedence over --keys flag + let keys: string[] | undefined + if (argv && argv.length > 0) { + // Collect all positional arguments and split any comma-separated values + keys = argv.flatMap((arg) => String(arg).split(',')) + } else if (args.keys) { + // Handle the first positional argument if provided + keys = args.keys.split(',') + } else if (flags.keys) { + // Fall back to --keys flag + keys = flags.keys.split(',') + } + + if (keys && keys.length > 0) { const environments = await batchRequests(keys, (key) => fetchEnvironmentByKey(this.authToken, this.projectKey, key), ) diff --git a/src/commands/features/get.test.ts b/src/commands/features/get.test.ts index 6723c202c..e68973e2a 100644 --- a/src/commands/features/get.test.ts +++ b/src/commands/features/get.test.ts @@ -11,6 +11,29 @@ describe('features get', () => { 'test-client-secret', ] + const mockSingleFeature = { + key: 'test-feature', + name: 'Test Feature', + _id: '61450f3daec96f5cf4a49950', + _project: 'test-project', + source: 'api', + _createdBy: 'test-user', + createdAt: '2021-09-15T12:00:00Z', + updatedAt: '2021-09-15T12:00:00Z', + variations: [], + variables: [], + tags: [], + configurations: [], + sdkVisibility: { + mobile: true, + client: true, + server: true, + }, + settings: {}, + readonly: false, + controlVariation: 'control', + } + const verifyOutput = (output: any[]) => { output.forEach((feature: any, index: number) => { expect(feature).to.deep.equal(mockFeatures[index]) @@ -70,4 +93,32 @@ describe('features get', () => { .it('passes search param to api', (ctx) => { verifyOutput(JSON.parse(ctx.stdout)) }) + + // Test positional arguments functionality + dvcTest() + .nock(BASE_URL, (api) => + api + .get(`/v2/projects/${projectKey}/features/feature-1`) + .reply(200, mockFeatures[0]) + .get(`/v2/projects/${projectKey}/features/feature-2`) + .reply(200, mockFeatures[1]), + ) + .stdout() + .command([ + 'features get', + 'feature-1', + 'feature-2', + '--project', + projectKey, + ...authFlags, + ]) + .it( + 'fetches multiple features by space-separated positional arguments', + (ctx) => { + expect(JSON.parse(ctx.stdout)).to.deep.equal([ + mockFeatures[0], + mockFeatures[1], + ]) + }, + ) }) diff --git a/src/commands/features/get.ts b/src/commands/features/get.ts index bb21de97b..8eef704f1 100644 --- a/src/commands/features/get.ts +++ b/src/commands/features/get.ts @@ -1,15 +1,27 @@ -import { Flags } from '@oclif/core' +import { Args, Flags } from '@oclif/core' import { fetchFeatures, fetchFeatureByKey } from '../../api/features' import Base from '../base' import { batchRequests } from '../../utils/batchRequests' +import { parseKeysFromArgs } from '../../utils/parseKeysFromArgs' export default class DetailedFeatures extends Base { static hidden = false static description = 'Retrieve Features from the Management API' static examples = [ '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> feature-one', + '<%= config.bin %> <%= command.id %> feature-one feature-two', + '<%= config.bin %> <%= command.id %> feature-one,feature-two', '<%= config.bin %> <%= command.id %> --keys=feature-one,feature-two', ] + static args = { + keys: Args.string({ + description: + 'Feature keys to fetch (space-separated or comma-separated)', + required: false, + }), + } + static strict = false static flags = { ...Base.flags, keys: Flags.string({ @@ -29,12 +41,14 @@ export default class DetailedFeatures extends Base { authRequired = true public async run(): Promise { - const { flags } = await this.parse(DetailedFeatures) - const keys = flags['keys']?.split(',') + const { args, argv, flags } = await this.parse(DetailedFeatures) const { project, headless } = flags await this.requireProject(project, headless) + + const keys = parseKeysFromArgs(args, argv, flags) + let features - if (keys) { + if (keys && keys.length > 0) { features = await batchRequests(keys, (key) => fetchFeatureByKey(this.authToken, this.projectKey, key), ) diff --git a/src/commands/variables/get.test.ts b/src/commands/variables/get.test.ts index 80d0297dc..ba99bdc6a 100644 --- a/src/commands/variables/get.test.ts +++ b/src/commands/variables/get.test.ts @@ -24,6 +24,12 @@ describe('variables get', () => { }, ] + const mockSingleVariable = { + key: 'test-var', + name: 'Test Variable', + _id: '61450f3daec96f5cf4a49948', + } + dvcTest() .nock(BASE_URL, (api) => api @@ -83,4 +89,31 @@ describe('variables get', () => { JSON.stringify(mockVariables, null, 2), ) }) + + // Test positional arguments functionality + dvcTest() + .nock(BASE_URL, (api) => + api + .get(`/v1/projects/${projectKey}/variables/variable-1`) + .reply(200, mockVariables[0]) + .get(`/v1/projects/${projectKey}/variables/variable-2`) + .reply(200, mockVariables[1]), + ) + .stdout() + .command([ + 'variables get', + 'variable-1', + 'variable-2', + '--project', + projectKey, + ...authFlags, + ]) + .it( + 'fetches multiple variables by space-separated positional arguments', + (ctx) => { + expect(ctx.stdout).to.contain( + JSON.stringify(mockVariables, null, 2), + ) + }, + ) }) diff --git a/src/commands/variables/get.ts b/src/commands/variables/get.ts index f395da6a3..e82fafb78 100644 --- a/src/commands/variables/get.ts +++ b/src/commands/variables/get.ts @@ -1,10 +1,27 @@ -import { Flags } from '@oclif/core' +import { Args, Flags } from '@oclif/core' import { fetchVariables, fetchVariableByKey } from '../../api/variables' import Base from '../base' import { batchRequests } from '../../utils/batchRequests' +import { parseKeysFromArgs } from '../../utils/parseKeysFromArgs' export default class DetailedVariables extends Base { static hidden = false + static description = 'Retrieve Variables from the Management API' + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> var-one', + '<%= config.bin %> <%= command.id %> var-one var-two', + '<%= config.bin %> <%= command.id %> var-one,var-two', + '<%= config.bin %> <%= command.id %> --keys=var-one,var-two', + ] + static args = { + keys: Args.string({ + description: + 'Variable keys to fetch (space-separated or comma-separated)', + required: false, + }), + } + static strict = false static flags = { ...Base.flags, keys: Flags.string({ @@ -24,13 +41,14 @@ export default class DetailedVariables extends Base { authRequired = true public async run(): Promise { - const { flags } = await this.parse(DetailedVariables) - const keys = flags['keys']?.split(',') + const { args, argv, flags } = await this.parse(DetailedVariables) const { project, headless } = flags await this.requireProject(project, headless) + const keys = parseKeysFromArgs(args, argv, flags) + let variables - if (keys) { + if (keys && keys.length > 0) { variables = await batchRequests(keys, (key) => fetchVariableByKey(this.authToken, this.projectKey, key), ) diff --git a/src/utils/parseKeysFromArgs.test.ts b/src/utils/parseKeysFromArgs.test.ts new file mode 100644 index 000000000..b66ebc8e5 --- /dev/null +++ b/src/utils/parseKeysFromArgs.test.ts @@ -0,0 +1,18 @@ +import { expect } from '@oclif/test' +import { parseKeysFromArgs } from './parseKeysFromArgs' + +describe('parseKeysFromArgs', () => { + it('should parse positional arguments with proper precedence over flags', () => { + // Test multiple scenarios in one comprehensive test + const result1 = parseKeysFromArgs({}, ['key1', 'key2'], { + keys: 'ignored', + }) + expect(result1).to.deep.equal(['key1', 'key2']) + + const result2 = parseKeysFromArgs({}, ['key1,key2'], {}) + expect(result2).to.deep.equal(['key1', 'key2']) + + const result3 = parseKeysFromArgs({}, [], { keys: 'key1,key2' }) + expect(result3).to.deep.equal(['key1', 'key2']) + }) +}) diff --git a/src/utils/parseKeysFromArgs.ts b/src/utils/parseKeysFromArgs.ts new file mode 100644 index 000000000..56aa13084 --- /dev/null +++ b/src/utils/parseKeysFromArgs.ts @@ -0,0 +1,27 @@ +/** + * Parse keys from command arguments with proper precedence handling + * Supports positional arguments, named args, and flags with comma/space separation + */ +export function parseKeysFromArgs( + args: { keys?: string }, + argv: unknown[], + flags: { keys?: string }, +): string[] | undefined { + // Handle positional arguments - they take precedence over --keys flag + if (argv && argv.length > 0) { + // Collect all positional arguments and split any comma-separated values + return argv.flatMap((arg) => String(arg).split(',')) + } + + if (args.keys) { + // Handle the first positional argument if provided + return args.keys.split(',') + } + + if (flags.keys) { + // Fall back to --keys flag + return flags.keys.split(',') + } + + return undefined +} From 2dd0759335c48776c01ac74a735bd824869caa3d Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Thu, 14 Aug 2025 11:11:32 -0400 Subject: [PATCH 2/3] fix: dedupe environments get by using shared parseKeysFromArgs --- src/commands/environments/get.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/commands/environments/get.ts b/src/commands/environments/get.ts index 51ef459ed..a5f9160e4 100644 --- a/src/commands/environments/get.ts +++ b/src/commands/environments/get.ts @@ -7,6 +7,7 @@ import { import { EnvironmentPromptResult, environmentPrompt } from '../../ui/prompts' import Base from '../base' import { batchRequests } from '../../utils/batchRequests' +import { parseKeysFromArgs } from '../../utils/parseKeysFromArgs' export default class DetailedEnvironments extends Base { static hidden = false @@ -40,18 +41,7 @@ export default class DetailedEnvironments extends Base { const { headless, project } = flags await this.requireProject(project, headless) - // Handle positional arguments - they take precedence over --keys flag - let keys: string[] | undefined - if (argv && argv.length > 0) { - // Collect all positional arguments and split any comma-separated values - keys = argv.flatMap((arg) => String(arg).split(',')) - } else if (args.keys) { - // Handle the first positional argument if provided - keys = args.keys.split(',') - } else if (flags.keys) { - // Fall back to --keys flag - keys = flags.keys.split(',') - } + const keys = parseKeysFromArgs(args, argv, flags) if (keys && keys.length > 0) { const environments = await batchRequests(keys, (key) => From bb32f979ffb2321aec5fa4f78f0cdc640495e402 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Thu, 14 Aug 2025 11:22:45 -0400 Subject: [PATCH 3/3] test: remove unused mocks from get command tests --- src/commands/environments/get.test.ts | 6 ------ src/commands/features/get.test.ts | 23 ----------------------- src/commands/variables/get.test.ts | 6 ------ 3 files changed, 35 deletions(-) diff --git a/src/commands/environments/get.test.ts b/src/commands/environments/get.test.ts index 581085e8c..99cb5f5d8 100644 --- a/src/commands/environments/get.test.ts +++ b/src/commands/environments/get.test.ts @@ -24,12 +24,6 @@ describe('environments get', () => { }, ] - const mockSingleEnvironment = { - key: 'test-env', - name: 'Test Environment', - _id: '61450f3daec96f5cf4a49962', - } - dvcTest() .nock(BASE_URL, (api) => api diff --git a/src/commands/features/get.test.ts b/src/commands/features/get.test.ts index e68973e2a..f2ca2cb5a 100644 --- a/src/commands/features/get.test.ts +++ b/src/commands/features/get.test.ts @@ -11,29 +11,6 @@ describe('features get', () => { 'test-client-secret', ] - const mockSingleFeature = { - key: 'test-feature', - name: 'Test Feature', - _id: '61450f3daec96f5cf4a49950', - _project: 'test-project', - source: 'api', - _createdBy: 'test-user', - createdAt: '2021-09-15T12:00:00Z', - updatedAt: '2021-09-15T12:00:00Z', - variations: [], - variables: [], - tags: [], - configurations: [], - sdkVisibility: { - mobile: true, - client: true, - server: true, - }, - settings: {}, - readonly: false, - controlVariation: 'control', - } - const verifyOutput = (output: any[]) => { output.forEach((feature: any, index: number) => { expect(feature).to.deep.equal(mockFeatures[index]) diff --git a/src/commands/variables/get.test.ts b/src/commands/variables/get.test.ts index ba99bdc6a..e401800d7 100644 --- a/src/commands/variables/get.test.ts +++ b/src/commands/variables/get.test.ts @@ -24,12 +24,6 @@ describe('variables get', () => { }, ] - const mockSingleVariable = { - key: 'test-var', - name: 'Test Variable', - _id: '61450f3daec96f5cf4a49948', - } - dvcTest() .nock(BASE_URL, (api) => api