From 7b52f5f75c4d67e8f911a387bf22b7819e2a40f1 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 1 Jun 2026 15:10:36 +0100 Subject: [PATCH 1/2] feat(cli): localize event timestamps and make --show/--exclude/--show-only global MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the last_event_at column of `events json-layouts`/`json-values` as a locale- and timezone-localized date/time (via the existing formatDateTime helper), matching how created_at/updated_at are shown elsewhere. --raw keeps the original epoch-ms value. Promote --show, --exclude and --show-only to global options on the root program (inherited like --raw/--output), instead of being declared per-command in 11 places. The mutual-exclusivity check is centralized in getGlobalOptions. Summarized commands keep their raw-backed applyShowExclude behavior (so --show can still add fields from the raw response) but now source the field lists from globalOptions. A new generic projectFields() runs in printFormatted and applies --exclude/--show-only to any printed data — arrays of objects, single objects, and the columnar {columnNames, rows} shape — so every command (events included) honors them, including under --raw. The projection is idempotent for commands that already projected at the summary layer. A one-line note on each read command's --help surfaces the now-global flags. --- src/commands/customfields/index.ts | 18 +--- src/commands/events/events.test.ts | 7 +- src/commands/events/index.ts | 18 +++- src/commands/experiments/custom-fields.ts | 19 +--- src/commands/experiments/get.test.ts | 75 +++++++-------- src/commands/experiments/get.ts | 24 ++--- src/commands/experiments/index.ts | 5 + src/commands/experiments/list.ts | 18 +--- src/commands/goals/index.ts | 18 +--- src/commands/metrics/index.ts | 18 +--- src/commands/segments/index.ts | 18 +--- src/commands/teams/index.ts | 18 +--- src/commands/users/index.ts | 31 +----- src/core/events/events.ts | 20 ++++ src/index.ts | 9 ++ src/lib/output/project-fields.test.ts | 109 ++++++++++++++++++++++ src/lib/output/project-fields.ts | 94 +++++++++++++++++++ src/lib/utils/api-helper.test.ts | 65 ++++++++++++- src/lib/utils/api-helper.ts | 38 +++++++- src/lib/utils/list-command.test.ts | 6 +- src/lib/utils/list-command.ts | 17 +--- 21 files changed, 436 insertions(+), 209 deletions(-) create mode 100644 src/lib/output/project-fields.test.ts create mode 100644 src/lib/output/project-fields.ts diff --git a/src/commands/customfields/index.ts b/src/commands/customfields/index.ts index 1545ac3..077dba6 100644 --- a/src/commands/customfields/index.ts +++ b/src/commands/customfields/index.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { getAPIClientFromOptions, + addFieldProjectionHelp, getGlobalOptions, printFormatted, printResult, @@ -40,22 +41,11 @@ const listCommand = createListCommand({ const getCommand = new Command('get') .description('Get custom section field details') .argument('', 'field ID', parseCustomSectionFieldId) - .option('--show ', 'include additional fields from API response') - .option('--exclude ', 'hide fields from summary') - .option( - '--show-only ', - 'show only these fields (mutually exclusive with --show and --exclude)' - ) .action( - withErrorHandling(async (id: CustomSectionFieldId, options) => { + withErrorHandling(async (id: CustomSectionFieldId) => { const globalOptions = getGlobalOptions(getCommand); const client = await getAPIClientFromOptions(globalOptions); - const show = (options.show as string[] | undefined) ?? []; - const exclude = (options.exclude as string[] | undefined) ?? []; - const showOnly = options.showOnly as string[] | undefined; - if (showOnly && (show.length > 0 || exclude.length > 0)) { - throw new Error('--show-only is mutually exclusive with --show and --exclude'); - } + const { show = [], exclude = [], showOnly } = globalOptions; const result = await getCustomField(client, { id, show, @@ -138,7 +128,7 @@ const archiveCommand = new Command('archive') ); customFieldsCommand.addCommand(listCommand); -customFieldsCommand.addCommand(getCommand); +customFieldsCommand.addCommand(addFieldProjectionHelp(getCommand)); customFieldsCommand.addCommand(createCommand); customFieldsCommand.addCommand(updateCommand); customFieldsCommand.addCommand(archiveCommand); diff --git a/src/commands/events/events.test.ts b/src/commands/events/events.test.ts index b8e32d6..839e5e2 100644 --- a/src/commands/events/events.test.ts +++ b/src/commands/events/events.test.ts @@ -6,6 +6,7 @@ import { printFormatted, } from '../../lib/utils/api-helper.js'; import { resetCommand } from '../../test/helpers/command-reset.js'; +import { formatDateTime } from '../../api-client/format-helpers.js'; vi.mock('../../lib/utils/api-helper.js', async (importOriginal) => { const actual = await importOriginal(); @@ -337,7 +338,11 @@ describe('events command', () => { Record >; expect(Array.isArray(printed)).toBe(true); - expect(printed[0]).toEqual({ key: 'currency', value_type: 'string', last_event_at: 1 }); + expect(printed[0]).toEqual({ + key: 'currency', + value_type: 'string', + last_event_at: formatDateTime(1), + }); }); it('keeps the columnar shape for json-layouts with --raw', async () => { diff --git a/src/commands/events/index.ts b/src/commands/events/index.ts index 4e302c8..717a941 100644 --- a/src/commands/events/index.ts +++ b/src/commands/events/index.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import chalk from 'chalk'; import { getAPIClientFromOptions, + addFieldProjectionHelp, getGlobalOptions, printFormatted, printResult, @@ -10,6 +11,7 @@ import { import { parseDateFlagOrUndefined } from '../../lib/utils/date-parser.js'; import { columnarToRows, + formatEventRowTimestamps, listEvents as coreListEvents, listEventsHistory as coreListEventsHistory, getEventUnitData as coreGetEventUnitData, @@ -202,7 +204,10 @@ const jsonValuesCommand = new Command('json-values') const filtered = filterColumnarRows(result.data, 'value', { match: options.match as string | undefined, }); - printFormatted(globalOptions.raw ? filtered : columnarToRows(filtered), globalOptions); + printFormatted( + globalOptions.raw ? filtered : formatEventRowTimestamps(columnarToRows(filtered)), + globalOptions + ); }) ); @@ -239,14 +244,17 @@ const jsonLayoutsCommand = new Command('json-layouts') topLevel: options.topLevel as boolean | undefined, maxDepth: options.maxDepth as number | undefined, }); - printFormatted(globalOptions.raw ? filtered : columnarToRows(filtered), globalOptions); + printFormatted( + globalOptions.raw ? filtered : formatEventRowTimestamps(columnarToRows(filtered)), + globalOptions + ); }) ); -eventsCommand.addCommand(listCommand); +eventsCommand.addCommand(addFieldProjectionHelp(listCommand)); eventsCommand.addCommand(historyCommand); eventsCommand.addCommand(unitDataCommand); eventsCommand.addCommand(deleteUnitDataCommand); -eventsCommand.addCommand(jsonValuesCommand); -eventsCommand.addCommand(jsonLayoutsCommand); +eventsCommand.addCommand(addFieldProjectionHelp(jsonValuesCommand)); +eventsCommand.addCommand(addFieldProjectionHelp(jsonLayoutsCommand)); eventsCommand.addCommand(summaryCommand); diff --git a/src/commands/experiments/custom-fields.ts b/src/commands/experiments/custom-fields.ts index 6be3677..8ee1dae 100644 --- a/src/commands/experiments/custom-fields.ts +++ b/src/commands/experiments/custom-fields.ts @@ -49,26 +49,15 @@ const listCommand = addPaginationOptions( const getCommand = new Command('get') .description('Get custom section field details') .argument('', 'field ID', parseCustomSectionFieldId) - .option('--show ', 'include additional fields from API response') - .option('--exclude ', 'hide fields from summary') - .option( - '--show-only ', - 'show only these fields (mutually exclusive with --show and --exclude)' - ) .action( - withErrorHandling(async (id: CustomSectionFieldId, options) => { + withErrorHandling(async (id: CustomSectionFieldId) => { const globalOptions = getGlobalOptions(getCommand); const client = await getAPIClientFromOptions(globalOptions); - const showOnly = options.showOnly as string[] | undefined; - if (showOnly && (options.show || options.exclude)) { - throw new Error('--show-only is mutually exclusive with --show and --exclude'); - } - const result = await getCustomField(client, { id, - show: options.show, - exclude: options.exclude, - showOnly, + show: globalOptions.show, + exclude: globalOptions.exclude, + showOnly: globalOptions.showOnly, raw: globalOptions.raw, }); printFormatted(result.data, globalOptions); diff --git a/src/commands/experiments/get.test.ts b/src/commands/experiments/get.test.ts index ffa976c..388fa35 100644 --- a/src/commands/experiments/get.test.ts +++ b/src/commands/experiments/get.test.ts @@ -144,7 +144,9 @@ describe('get command', () => { }); it('should include extra fields with --show', async () => { - await getCommand.parseAsync(['node', 'test', '42', '--show', 'audience']); + vi.mocked(getGlobalOptions).mockReturnValue({ output: 'table', show: ['audience'] } as any); + + await getCommand.parseAsync(['node', 'test', '42']); const data = vi.mocked(printFormatted).mock.calls[0]![0] as Record; expect(data.id).toBe(42); @@ -152,7 +154,9 @@ describe('get command', () => { }); it('should include custom fields by title with --show', async () => { - await getCommand.parseAsync(['node', 'test', '42', '--show', 'Hypothesis']); + vi.mocked(getGlobalOptions).mockReturnValue({ output: 'table', show: ['Hypothesis'] } as any); + + await getCommand.parseAsync(['node', 'test', '42']); const data = vi.mocked(printFormatted).mock.calls[0]![0] as Record; expect(data.Hypothesis).toBe('Red button converts better'); @@ -191,45 +195,18 @@ describe('get command', () => { }); it('forwards --show-only to getExperiment and outputs only those fields', async () => { - await getCommand.parseAsync(['node', 'test', '42', '--show-only', 'id', 'audience']); + vi.mocked(getGlobalOptions).mockReturnValue({ + output: 'table', + showOnly: ['id', 'audience'], + } as any); + + await getCommand.parseAsync(['node', 'test', '42']); const data = vi.mocked(printFormatted).mock.calls[0]![0] as Record; expect(Object.keys(data)).toEqual(['id', 'audience']); expect(data.audience).toEqual({ filter: [] }); }); - it('rejects --show-only combined with --show', async () => { - try { - await getCommand.parseAsync([ - 'node', - 'test', - '42', - '--show-only', - 'id', - '--show', - 'audience', - ]); - } catch (_e) { - // process.exit threw a sentinel - } - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Error:', - '--show-only is mutually exclusive with --show and --exclude' - ); - }); - - it('rejects --show-only combined with --exclude', async () => { - try { - await getCommand.parseAsync(['node', 'test', '42', '--show-only', 'id', '--exclude', 'tags']); - } catch (_e) { - // process.exit threw a sentinel - } - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Error:', - '--show-only is mutually exclusive with --show and --exclude' - ); - }); - it('renders default sections when no filters are passed', async () => { vi.mocked(getGlobalOptions).mockReturnValue({ output: 'rendered' } as any); @@ -245,9 +222,12 @@ describe('get command', () => { }); it('renders --exclude audience without ## Audience section', async () => { - vi.mocked(getGlobalOptions).mockReturnValue({ output: 'rendered' } as any); + vi.mocked(getGlobalOptions).mockReturnValue({ + output: 'rendered', + exclude: ['audience'], + } as any); - await getCommand.parseAsync(['node', 'test', '42', '--exclude', 'audience']); + await getCommand.parseAsync(['node', 'test', '42']); const output = consoleSpy.mock.calls.flat().join(''); expect(output).not.toContain('## Audience'); @@ -255,9 +235,12 @@ describe('get command', () => { }); it('renders --exclude Hypothesis without that custom field section', async () => { - vi.mocked(getGlobalOptions).mockReturnValue({ output: 'rendered' } as any); + vi.mocked(getGlobalOptions).mockReturnValue({ + output: 'rendered', + exclude: ['Hypothesis'], + } as any); - await getCommand.parseAsync(['node', 'test', '42', '--exclude', 'Hypothesis']); + await getCommand.parseAsync(['node', 'test', '42']); const output = consoleSpy.mock.calls.flat().join(''); expect(output).not.toContain('### Hypothesis'); @@ -265,9 +248,12 @@ describe('get command', () => { }); it('renders --show-only id name audience minimally', async () => { - vi.mocked(getGlobalOptions).mockReturnValue({ output: 'rendered' } as any); + vi.mocked(getGlobalOptions).mockReturnValue({ + output: 'rendered', + showOnly: ['id', 'name', 'audience'], + } as any); - await getCommand.parseAsync(['node', 'test', '42', '--show-only', 'id', 'name', 'audience']); + await getCommand.parseAsync(['node', 'test', '42']); const output = consoleSpy.mock.calls.flat().join(''); expect(output).toContain('42'); @@ -283,9 +269,12 @@ describe('get command', () => { ...fullExperiment, description: 'long-form description', }); - vi.mocked(getGlobalOptions).mockReturnValue({ output: 'rendered' } as any); + vi.mocked(getGlobalOptions).mockReturnValue({ + output: 'rendered', + show: ['description'], + } as any); - await getCommand.parseAsync(['node', 'test', '42', '--show', 'description']); + await getCommand.parseAsync(['node', 'test', '42']); const output = consoleSpy.mock.calls.flat().join(''); expect(output).toContain('long-form description'); diff --git a/src/commands/experiments/get.ts b/src/commands/experiments/get.ts index 5d3f1b2..8b62a4e 100644 --- a/src/commands/experiments/get.ts +++ b/src/commands/experiments/get.ts @@ -20,15 +20,6 @@ export const getCommand = new Command('get') .description('Get experiment details') .argument('', 'experiment ID or name', parseExperimentIdOrName) .option('--activity', 'include activity notes in the output') - .option( - '--show ', - 'include additional fields in summary (e.g. --show audience archived)' - ) - .option('--exclude ', 'hide fields from summary (e.g. --exclude owners tags)') - .option( - '--show-only ', - 'show only these fields (mutually exclusive with --show and --exclude)' - ) .option('--embed-screenshots', 'embed screenshots as base64 data URIs in template output') .option('--screenshots-dir ', 'save screenshots to directory in template output') .option( @@ -42,10 +33,9 @@ export const getCommand = new Command('get') const client = await getAPIClientFromOptions(globalOptions); const id = await client.resolveExperimentId(nameOrId); - const showOnly = options.showOnly as string[] | undefined; - if (showOnly && (options.show || options.exclude)) { - throw new Error('--show-only is mutually exclusive with --show and --exclude'); - } + const show = globalOptions.show ?? []; + const exclude = globalOptions.exclude ?? []; + const showOnly = globalOptions.showOnly; // Template output mode - stays in wrapper (complex formatting) if (globalOptions.output === 'template') { @@ -67,8 +57,8 @@ export const getCommand = new Command('get') const experiment = await client.getExperiment(id); const exp = experiment as Record; - const userShow = (options.show as string[] | undefined) ?? []; - const userExclude = (options.exclude as string[] | undefined) ?? []; + const userShow = show; + const userExclude = exclude; const customFieldEntries = (exp.custom_section_field_values as Array> | undefined) ?? []; @@ -321,8 +311,8 @@ export const getCommand = new Command('get') const result = await getExperiment(client, { experimentId: id, activity: options.activity, - show: options.show, - exclude: options.exclude, + show, + exclude, showOnly, raw: globalOptions.raw, }); diff --git a/src/commands/experiments/index.ts b/src/commands/experiments/index.ts index 82876b7..eb86fa8 100644 --- a/src/commands/experiments/index.ts +++ b/src/commands/experiments/index.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; +import { addFieldProjectionHelp } from '../../lib/utils/api-helper.js'; import { listCommand } from './list.js'; import { getCommand } from './get.js'; import { analyzeCommand } from './analyze.js'; @@ -73,4 +74,8 @@ export const experimentsCommand = new Command('experiments') .alias('feature') .description('Experiment and feature flag commands'); +// These read commands honor the global --show/--exclude/--show-only flags; +// surface that on their --help (the flags live on the root program). +for (const cmd of [listCommand, getCommand, customFieldsCommand]) addFieldProjectionHelp(cmd); + for (const cmd of subcommands) experimentsCommand.addCommand(cmd); diff --git a/src/commands/experiments/list.ts b/src/commands/experiments/list.ts index 7056d82..9831668 100644 --- a/src/commands/experiments/list.ts +++ b/src/commands/experiments/list.ts @@ -57,30 +57,18 @@ export const listCommand = new Command('list') ) .option('--asc', 'sort in ascending order') .option('--desc', 'sort in descending order') - .option( - '--show ', - 'include additional fields (e.g. --show experiment_report archived)' - ) - .option('--exclude ', 'hide fields (e.g. --exclude primary_metric owner)') - .option( - '--show-only ', - 'show only these fields (mutually exclusive with --show and --exclude)' - ) .action( withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const showOnly = options.showOnly as string[] | undefined; - if (showOnly && (options.show || options.exclude)) { - throw new Error('--show-only is mutually exclusive with --show and --exclude'); - } - const result = await listExperiments(client, { ...options, type: options.type || getDefaultType(), raw: globalOptions.raw, - showOnly, + show: globalOptions.show, + exclude: globalOptions.exclude, + showOnly: globalOptions.showOnly, }); if (shouldOutputIdsOnly(globalOptions)) { diff --git a/src/commands/goals/index.ts b/src/commands/goals/index.ts index 7273fea..f2a36ae 100644 --- a/src/commands/goals/index.ts +++ b/src/commands/goals/index.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { getAPIClientFromOptions, + addFieldProjectionHelp, getGlobalOptions, printFormatted, printResult, @@ -40,22 +41,11 @@ const listCommand = createListCommand({ const getCommand = new Command('get') .description('Get goal details') .argument('', 'goal ID', parseGoalId) - .option('--show ', 'include additional fields from API response') - .option('--exclude ', 'hide fields from summary') - .option( - '--show-only ', - 'show only these fields (mutually exclusive with --show and --exclude)' - ) .action( - withErrorHandling(async (id: GoalId, options) => { + withErrorHandling(async (id: GoalId) => { const globalOptions = getGlobalOptions(getCommand); const client = await getAPIClientFromOptions(globalOptions); - const show = (options.show as string[] | undefined) ?? []; - const exclude = (options.exclude as string[] | undefined) ?? []; - const showOnly = options.showOnly as string[] | undefined; - if (showOnly && (show.length > 0 || exclude.length > 0)) { - throw new Error('--show-only is mutually exclusive with --show and --exclude'); - } + const { show = [], exclude = [], showOnly } = globalOptions; const result = await getGoal(client, { id }); const data = globalOptions.raw @@ -111,7 +101,7 @@ const updateCommand = new Command('update') ); goalsCommand.addCommand(listCommand); -goalsCommand.addCommand(getCommand); +goalsCommand.addCommand(addFieldProjectionHelp(getCommand)); goalsCommand.addCommand(createCommand); goalsCommand.addCommand(updateCommand); goalsCommand.addCommand(accessCommand); diff --git a/src/commands/metrics/index.ts b/src/commands/metrics/index.ts index 5a9e106..857cbf9 100644 --- a/src/commands/metrics/index.ts +++ b/src/commands/metrics/index.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { getAPIClientFromOptions, + addFieldProjectionHelp, getGlobalOptions, printFormatted, printResult, @@ -112,22 +113,11 @@ const listCommand = createListCommand({ const getCommand = new Command('get') .description('Get metric details') .argument('', 'metric ID', parseMetricId) - .option('--show ', 'include additional fields from API response') - .option('--exclude ', 'hide fields from summary') - .option( - '--show-only ', - 'show only these fields (mutually exclusive with --show and --exclude)' - ) .action( - withErrorHandling(async (id: MetricId, options) => { + withErrorHandling(async (id: MetricId) => { const globalOptions = getGlobalOptions(getCommand); const client = await getAPIClientFromOptions(globalOptions); - const show = (options.show as string[] | undefined) ?? []; - const exclude = (options.exclude as string[] | undefined) ?? []; - const showOnly = options.showOnly as string[] | undefined; - if (showOnly && (show.length > 0 || exclude.length > 0)) { - throw new Error('--show-only is mutually exclusive with --show and --exclude'); - } + const { show = [], exclude = [], showOnly } = globalOptions; const result = await getMetric(client, { id }); const data = globalOptions.raw @@ -499,7 +489,7 @@ const versionCommand = addMetricFieldOptions( ); metricsCommand.addCommand(listCommand); -metricsCommand.addCommand(getCommand); +metricsCommand.addCommand(addFieldProjectionHelp(getCommand)); metricsCommand.addCommand(createCommand); metricsCommand.addCommand(updateCommand); metricsCommand.addCommand(archiveCommand); diff --git a/src/commands/segments/index.ts b/src/commands/segments/index.ts index b9e5490..2a1aae1 100644 --- a/src/commands/segments/index.ts +++ b/src/commands/segments/index.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { getAPIClientFromOptions, + addFieldProjectionHelp, getGlobalOptions, printFormatted, printResult, @@ -40,22 +41,11 @@ const listCommand = createListCommand({ const getCommand = new Command('get') .description('Get segment details') .argument('', 'segment ID', parseSegmentId) - .option('--show ', 'include additional fields from API response') - .option('--exclude ', 'hide fields from summary') - .option( - '--show-only ', - 'show only these fields (mutually exclusive with --show and --exclude)' - ) .action( - withErrorHandling(async (id: SegmentId, options) => { + withErrorHandling(async (id: SegmentId) => { const globalOptions = getGlobalOptions(getCommand); const client = await getAPIClientFromOptions(globalOptions); - const show = (options.show as string[] | undefined) ?? []; - const exclude = (options.exclude as string[] | undefined) ?? []; - const showOnly = options.showOnly as string[] | undefined; - if (showOnly && (show.length > 0 || exclude.length > 0)) { - throw new Error('--show-only is mutually exclusive with --show and --exclude'); - } + const { show = [], exclude = [], showOnly } = globalOptions; const result = await getSegment(client, { id, show, @@ -121,7 +111,7 @@ const deleteCommand = new Command('delete') ); segmentsCommand.addCommand(listCommand); -segmentsCommand.addCommand(getCommand); +segmentsCommand.addCommand(addFieldProjectionHelp(getCommand)); segmentsCommand.addCommand(createCommand); segmentsCommand.addCommand(updateCommand); segmentsCommand.addCommand(deleteCommand); diff --git a/src/commands/teams/index.ts b/src/commands/teams/index.ts index 8872b94..a2c0416 100644 --- a/src/commands/teams/index.ts +++ b/src/commands/teams/index.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; import { getAPIClientFromOptions, + addFieldProjectionHelp, getGlobalOptions, printFormatted, printResult, @@ -37,22 +38,11 @@ const listCommand = createListCommand({ const getCommand = new Command('get') .description('Get team details') .argument('', 'team ID', parseTeamId) - .option('--show ', 'include additional fields from API response') - .option('--exclude ', 'hide fields from summary') - .option( - '--show-only ', - 'show only these fields (mutually exclusive with --show and --exclude)' - ) .action( - withErrorHandling(async (id: TeamId, options) => { + withErrorHandling(async (id: TeamId) => { const globalOptions = getGlobalOptions(getCommand); const client = await getAPIClientFromOptions(globalOptions); - const show = (options.show as string[] | undefined) ?? []; - const exclude = (options.exclude as string[] | undefined) ?? []; - const showOnly = options.showOnly as string[] | undefined; - if (showOnly && (show.length > 0 || exclude.length > 0)) { - throw new Error('--show-only is mutually exclusive with --show and --exclude'); - } + const { show = [], exclude = [], showOnly } = globalOptions; const result = await getTeam(client, { id, show, exclude, showOnly, raw: globalOptions.raw }); printFormatted(result.data, globalOptions); }) @@ -109,7 +99,7 @@ const archiveCommand = new Command('archive') import { membersCommand } from './members.js'; teamsCommand.addCommand(listCommand); -teamsCommand.addCommand(getCommand); +teamsCommand.addCommand(addFieldProjectionHelp(getCommand)); teamsCommand.addCommand(createCommand); teamsCommand.addCommand(updateCommand); teamsCommand.addCommand(archiveCommand); diff --git a/src/commands/users/index.ts b/src/commands/users/index.ts index 4b4b056..e279143 100644 --- a/src/commands/users/index.ts +++ b/src/commands/users/index.ts @@ -3,6 +3,7 @@ import Table from 'cli-table3'; import chalk from 'chalk'; import { getAPIClientFromOptions, + addFieldProjectionHelp, getGlobalOptions, printFormatted, printResult, @@ -70,12 +71,6 @@ const listCommand = addPaginationOptions( new Command('list') .description('List all users') .option('--include-archived', 'include archived users') - .option('--show ', 'include additional fields from API response') - .option('--exclude ', 'hide fields from summary') - .option( - '--show-only ', - 'show only these fields (mutually exclusive with --show and --exclude)' - ) .option( '--show-avatars [cols]', 'display avatars inline, optional width in columns (default: 3)', @@ -85,12 +80,7 @@ const listCommand = addPaginationOptions( withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const show = (options.show as string[] | undefined) ?? []; - const exclude = (options.exclude as string[] | undefined) ?? []; - const showOnly = options.showOnly as string[] | undefined; - if (showOnly && (show.length > 0 || exclude.length > 0)) { - throw new Error('--show-only is mutually exclusive with --show and --exclude'); - } + const { show = [], exclude = [], showOnly } = globalOptions; const result = await coreListUsers(client, { includeArchived: options.includeArchived, @@ -205,12 +195,6 @@ const listCommand = addPaginationOptions( const getCommand = new Command('get') .description('Get user details') .argument('', 'user ID', parseUserId) - .option('--show ', 'include additional fields from API response') - .option('--exclude ', 'hide fields from summary') - .option( - '--show-only ', - 'show only these fields (mutually exclusive with --show and --exclude)' - ) .option( '--show-avatars [cols]', 'display avatar inline, optional width in columns (default: 15)', @@ -220,12 +204,7 @@ const getCommand = new Command('get') withErrorHandling(async (id: UserId, options) => { const globalOptions = getGlobalOptions(getCommand); const client = await getAPIClientFromOptions(globalOptions); - const show = (options.show as string[] | undefined) ?? []; - const exclude = (options.exclude as string[] | undefined) ?? []; - const showOnly = options.showOnly as string[] | undefined; - if (showOnly && (show.length > 0 || exclude.length > 0)) { - throw new Error('--show-only is mutually exclusive with --show and --exclude'); - } + const { show = [], exclude = [], showOnly } = globalOptions; const result = await coreGetUser(client, { id }); const user = result.data; @@ -305,8 +284,8 @@ const archiveCommand = new Command('archive') }) ); -usersCommand.addCommand(listCommand); -usersCommand.addCommand(getCommand); +usersCommand.addCommand(addFieldProjectionHelp(listCommand)); +usersCommand.addCommand(addFieldProjectionHelp(getCommand)); usersCommand.addCommand(createCommand); usersCommand.addCommand(updateCommand); usersCommand.addCommand(archiveCommand); diff --git a/src/core/events/events.ts b/src/core/events/events.ts index 8ef1b35..322a43c 100644 --- a/src/core/events/events.ts +++ b/src/core/events/events.ts @@ -1,4 +1,5 @@ import type { APIClient } from '../../api-client/api-client.js'; +import { formatDateTime } from '../../api-client/format-helpers.js'; import type { CommandResult } from '../types.js'; export function parseUnits(units: string[]): Array<{ unit_type_id: number; uid: string }> { @@ -35,6 +36,25 @@ export function columnarToRows(data: unknown): Record[] { }); } +// Timestamp columns returned by the json-layouts/json-values endpoints as epoch +// milliseconds. Rendered as locale-/timezone-localized strings for the table, +// matching how created_at/updated_at are shown elsewhere (see entity-summary.ts). +const TIMESTAMP_COLUMNS = ['last_event_at', 'first_event_at']; + +export function formatEventRowTimestamps( + rows: Record[] +): Record[] { + return rows.map((row) => { + const out = { ...row }; + for (const col of TIMESTAMP_COLUMNS) { + if (col in out && out[col] !== null && out[col] !== undefined) { + out[col] = formatDateTime(out[col]); + } + } + return out; + }); +} + export interface ListEventsParams { from?: number | undefined; to?: number | undefined; diff --git a/src/index.ts b/src/index.ts index e194d7e..dcb56f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,6 +66,15 @@ program .option('--terse', 'show compact format with truncation') .option('--full', 'show full text without truncation') .option('--raw', 'show raw API response without summarizing or transforming') + .option( + '--show ', + 'include additional fields in the output (where a summarized view exists)' + ) + .option('--exclude ', 'hide fields from the output') + .option( + '--show-only ', + 'show only these fields (mutually exclusive with --show and --exclude)' + ) .option('--show-request', 'print outgoing HTTP requests to stderr') .option('--show-response', 'print HTTP responses to stderr (success and error)') .option('--curl', 'print outgoing requests as curl commands to stderr') diff --git a/src/lib/output/project-fields.test.ts b/src/lib/output/project-fields.test.ts new file mode 100644 index 0000000..2f6ccb0 --- /dev/null +++ b/src/lib/output/project-fields.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import { projectFields } from './project-fields.js'; + +describe('projectFields', () => { + it('returns data unchanged when no exclude/show-only given', () => { + const data = [{ a: 1, b: 2 }]; + expect(projectFields(data, {})).toBe(data); + expect(projectFields(data, { exclude: [] })).toBe(data); + expect(projectFields(data, { showOnly: [] })).toBe(data); + }); + + describe('array of objects', () => { + const rows = [ + { id: 1, name: 'a', secret: 'x' }, + { id: 2, name: 'b', secret: 'y' }, + ]; + + it('excludes fields from every row', () => { + expect(projectFields(rows, { exclude: ['secret'] })).toEqual([ + { id: 1, name: 'a' }, + { id: 2, name: 'b' }, + ]); + }); + + it('keeps only requested fields, in requested order', () => { + expect(projectFields(rows, { showOnly: ['name', 'id'] })).toEqual([ + { name: 'a', id: 1 }, + { name: 'b', id: 2 }, + ]); + }); + + it('ignores show-only fields that are absent', () => { + expect(projectFields(rows, { showOnly: ['name', 'missing'] })).toEqual([ + { name: 'a' }, + { name: 'b' }, + ]); + }); + + it('leaves non-object array elements untouched', () => { + expect(projectFields(['a', 'b'], { exclude: ['x'] })).toEqual(['a', 'b']); + }); + }); + + describe('single object', () => { + it('excludes fields', () => { + expect(projectFields({ id: 1, name: 'a', secret: 'x' }, { exclude: ['secret'] })).toEqual({ + id: 1, + name: 'a', + }); + }); + + it('keeps only requested fields', () => { + expect(projectFields({ id: 1, name: 'a', secret: 'x' }, { showOnly: ['id'] })).toEqual({ + id: 1, + }); + }); + }); + + describe('columnar { columnNames, rows }', () => { + const columnar = { + columnNames: ['key', 'value_type', 'last_event_at'], + columnTypes: ['String', 'String', 'Int64'], + rows: [ + ['currency', 'string', 1], + ['items', 'array', 2], + ], + }; + + it('excludes a column and its values + type', () => { + expect(projectFields(columnar, { exclude: ['value_type'] })).toEqual({ + columnNames: ['key', 'last_event_at'], + columnTypes: ['String', 'Int64'], + rows: [ + ['currency', 1], + ['items', 2], + ], + }); + }); + + it('keeps only requested columns, in requested order', () => { + expect(projectFields(columnar, { showOnly: ['last_event_at', 'key'] })).toEqual({ + columnNames: ['last_event_at', 'key'], + columnTypes: ['Int64', 'String'], + rows: [ + [1, 'currency'], + [2, 'items'], + ], + }); + }); + + it('works without columnTypes', () => { + expect( + projectFields({ columnNames: ['a', 'b'], rows: [[1, 2]] }, { exclude: ['a'] }) + ).toEqual({ columnNames: ['b'], rows: [[2]] }); + }); + }); + + it('is idempotent: re-projecting already-projected data is a no-op', () => { + const once = projectFields([{ id: 1, name: 'a', secret: 'x' }], { exclude: ['secret'] }); + const twice = projectFields(once, { exclude: ['secret'] }); + expect(twice).toEqual(once); + }); + + it('leaves primitives untouched', () => { + expect(projectFields('hello', { exclude: ['x'] })).toBe('hello'); + expect(projectFields(42, { showOnly: ['x'] })).toBe(42); + expect(projectFields(null, { exclude: ['x'] })).toBe(null); + }); +}); diff --git a/src/lib/output/project-fields.ts b/src/lib/output/project-fields.ts new file mode 100644 index 0000000..5db3996 --- /dev/null +++ b/src/lib/output/project-fields.ts @@ -0,0 +1,94 @@ +// Generic, output-layer column projection driven by the global --exclude and +// --show-only flags. Unlike applyShowExclude (which works at the entity-summary +// layer with access to the raw response), this operates on whatever data is +// about to be printed, so every command — including ones with no summarized +// view — honors --exclude/--show-only. +// +// It is intentionally key-based (exact top-level keys / column names) and +// idempotent: re-projecting data that was already projected at the summary +// layer is a no-op. --show is NOT handled here; adding fields requires the raw +// response and only makes sense at the summary layer. + +interface Columnar { + columnNames: string[]; + columnTypes?: unknown[]; + rows: unknown[][]; +} + +function isColumnar(data: unknown): data is Columnar { + if (data === null || typeof data !== 'object') return false; + const d = data as Record; + return Array.isArray(d.columnNames) && Array.isArray(d.rows); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function projectObject( + obj: Record, + exclude: string[], + showOnly: string[] | undefined +): Record { + if (showOnly && showOnly.length > 0) { + const result: Record = {}; + for (const field of showOnly) { + if (field in obj) result[field] = obj[field]; + } + return result; + } + if (exclude.length === 0) return obj; + const result = { ...obj }; + for (const field of exclude) delete result[field]; + return result; +} + +function projectColumnar( + data: Columnar, + exclude: string[], + showOnly: string[] | undefined +): Columnar { + let keep: number[]; + if (showOnly && showOnly.length > 0) { + // Preserve the order the user requested. + keep = showOnly.map((name) => data.columnNames.indexOf(name)).filter((i) => i >= 0); + } else if (exclude.length > 0) { + const drop = new Set(exclude); + keep = data.columnNames.map((_, i) => i).filter((i) => !drop.has(data.columnNames[i]!)); + } else { + return data; + } + + const result: Columnar = { + ...data, + columnNames: keep.map((i) => data.columnNames[i]!), + rows: data.rows.map((row) => keep.map((i) => row[i])), + }; + if (Array.isArray(data.columnTypes)) { + result.columnTypes = keep.map((i) => data.columnTypes![i]); + } + return result; +} + +export interface ProjectFieldsOptions { + exclude?: string[] | undefined; + showOnly?: string[] | undefined; +} + +export function projectFields(data: unknown, options: ProjectFieldsOptions): unknown { + const exclude = options.exclude ?? []; + const showOnly = options.showOnly; + if (exclude.length === 0 && (!showOnly || showOnly.length === 0)) return data; + + if (isColumnar(data)) return projectColumnar(data, exclude, showOnly); + + if (Array.isArray(data)) { + return data.map((item) => + isPlainObject(item) ? projectObject(item, exclude, showOnly) : item + ); + } + + if (isPlainObject(data)) return projectObject(data, exclude, showOnly); + + return data; +} diff --git a/src/lib/utils/api-helper.test.ts b/src/lib/utils/api-helper.test.ts index 240c1e0..7bf1cf6 100644 --- a/src/lib/utils/api-helper.test.ts +++ b/src/lib/utils/api-helper.test.ts @@ -197,7 +197,10 @@ describe('API Helper', () => { .option('-v, --verbose', 'verbose') .option('--profile ', 'profile') .option('--terse', 'terse') - .option('--full', 'full'); + .option('--full', 'full') + .option('--show ', 'show') + .option('--exclude ', 'exclude') + .option('--show-only ', 'show only'); }); it('should parse options with defaults', () => { @@ -250,6 +253,35 @@ describe('API Helper', () => { expect(options.showResponse).toBe(false); }); + it('parses show/exclude/show-only into arrays (defaulting show/exclude to [])', () => { + const options = getGlobalOptions(mockCommand); + expect(options.show).toEqual([]); + expect(options.exclude).toEqual([]); + expect(options.showOnly).toBeUndefined(); + + mockCommand.setOptionValue('show', ['a', 'b']); + mockCommand.setOptionValue('exclude', ['c']); + const parsed = getGlobalOptions(mockCommand); + expect(parsed.show).toEqual(['a', 'b']); + expect(parsed.exclude).toEqual(['c']); + }); + + it('rejects --show-only combined with --show', () => { + mockCommand.setOptionValue('showOnly', ['id']); + mockCommand.setOptionValue('show', ['audience']); + expect(() => getGlobalOptions(mockCommand)).toThrow( + '--show-only is mutually exclusive with --show and --exclude' + ); + }); + + it('rejects --show-only combined with --exclude', () => { + mockCommand.setOptionValue('showOnly', ['id']); + mockCommand.setOptionValue('exclude', ['tags']); + expect(() => getGlobalOptions(mockCommand)).toThrow( + '--show-only is mutually exclusive with --show and --exclude' + ); + }); + it('should handle --no-color flag', () => { mockCommand.parse(['node', 'test', '--no-color']); @@ -355,6 +387,37 @@ describe('API Helper', () => { }); }); + describe('printFormatted', () => { + let logSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it('applies global --exclude to the data before formatting', () => { + printFormatted([{ id: 1, secret: 'x' }], { output: 'json', exclude: ['secret'] }); + expect(formatOutput).toHaveBeenLastCalledWith([{ id: 1 }], 'json', expect.anything()); + }); + + it('applies global --show-only to the data before formatting', () => { + printFormatted([{ id: 1, name: 'a', secret: 'x' }], { + output: 'json', + showOnly: ['name'], + }); + expect(formatOutput).toHaveBeenLastCalledWith([{ name: 'a' }], 'json', expect.anything()); + }); + + it('leaves data untouched when no projection flags are set', () => { + const data = [{ id: 1, secret: 'x' }]; + printFormatted(data, { output: 'json' }); + expect(formatOutput).toHaveBeenLastCalledWith(data, 'json', expect.anything()); + }); + }); + describe('shouldOutputIdsOnly', () => { afterEach(() => { setTTYOverride({ stdin: true, stdout: true }); diff --git a/src/lib/utils/api-helper.ts b/src/lib/utils/api-helper.ts index 86cd82a..e8cb6f1 100644 --- a/src/lib/utils/api-helper.ts +++ b/src/lib/utils/api-helper.ts @@ -5,6 +5,7 @@ import type { AuthConfig } from '../api/axios-adapter.js'; import { Command } from 'commander'; import chalk from 'chalk'; import { formatOutput, type OutputFormat } from '../output/formatter.js'; +import { projectFields } from '../output/project-fields.js'; import { isStdoutPiped } from './stdin.js'; export async function resolveAPIKey(options: GlobalOptions): Promise { @@ -118,6 +119,13 @@ export interface GlobalOptions { terse?: boolean; full?: boolean; raw?: boolean; + // Column-projection flags, global across all commands. `show` adds extra + // fields where a summarized view exists (no-op otherwise); `exclude` hides + // fields; `showOnly` keeps only the listed fields. showOnly is mutually + // exclusive with show/exclude (validated in getGlobalOptions). + show?: string[] | undefined; + exclude?: string[] | undefined; + showOnly?: string[] | undefined; showRequest?: boolean; showResponse?: boolean; curl?: boolean; @@ -156,6 +164,13 @@ export function getGlobalOptions(cmd: Command): GlobalOptions { const statusOnly = opts.statusOnly || false; const showResponse = opts.showResponse || statusOnly || false; + const show = (opts.show as string[] | undefined) ?? []; + const exclude = (opts.exclude as string[] | undefined) ?? []; + const showOnly = opts.showOnly as string[] | undefined; + if (showOnly && (show.length > 0 || exclude.length > 0)) { + throw new Error('--show-only is mutually exclusive with --show and --exclude'); + } + return { config: opts.config, apiKey: opts.apiKey, @@ -172,6 +187,9 @@ export function getGlobalOptions(cmd: Command): GlobalOptions { terse: opts.terse || false, full: opts.full || false, raw: opts.raw || false, + show, + exclude, + showOnly, showRequest: opts.showRequest || false, showResponse, curl: opts.curl || false, @@ -188,8 +206,26 @@ export function shouldOutputIdsOnly(globalOptions: GlobalOptions): boolean { return globalOptions.output === 'ids' || (isStdoutPiped() && !globalOptions.outputExplicit); } +// Appends a one-line reminder to a read command's `--help` that the global +// field-projection flags are available. They live on the root program (so they +// don't appear under each subcommand's Options), and this keeps them +// discoverable where they're useful. Returns the command for chaining. +export function addFieldProjectionHelp(cmd: Command): Command { + return cmd.addHelpText( + 'after', + '\nField selection (global): --show , --exclude , --show-only \nSee `abs --help` for details.' + ); +} + export function printFormatted(data: unknown, globalOptions: GlobalOptions): void { - const output = formatOutput(data, globalOptions.output, { + // Apply the global --exclude / --show-only key projection. This is idempotent + // for commands that already projected at the summary layer and is the only + // place projection happens for commands without a summarized view. + const projected = projectFields(data, { + exclude: globalOptions.exclude, + showOnly: globalOptions.showOnly, + }); + const output = formatOutput(projected, globalOptions.output, { noColor: globalOptions.noColor ?? false, full: globalOptions.full ?? false, terse: globalOptions.terse ?? false, diff --git a/src/lib/utils/list-command.test.ts b/src/lib/utils/list-command.test.ts index 9626b81..3f8522b 100644 --- a/src/lib/utils/list-command.test.ts +++ b/src/lib/utils/list-command.test.ts @@ -54,8 +54,10 @@ describe('createListCommand', () => { const optionNames = cmd.options.map((o) => o.long); expect(optionNames).toContain('--items'); expect(optionNames).toContain('--page'); - expect(optionNames).toContain('--show'); - expect(optionNames).toContain('--exclude'); + // --show/--exclude/--show-only are now global (declared on the root program), + // so they are no longer per-command options here. + expect(optionNames).not.toContain('--show'); + expect(optionNames).not.toContain('--exclude'); }); it('should call fetch and printFormatted when action runs', async () => { diff --git a/src/lib/utils/list-command.ts b/src/lib/utils/list-command.ts index 5f9971e..bfdd44f 100644 --- a/src/lib/utils/list-command.ts +++ b/src/lib/utils/list-command.ts @@ -5,6 +5,7 @@ import { printFormatted, shouldOutputIdsOnly, withErrorHandling, + addFieldProjectionHelp, } from './api-helper.js'; import { addPaginationOptions, printPaginationFooter, printFilteredFooter } from './pagination.js'; import { applyShowExclude } from '../../api-client/entity-summary.js'; @@ -35,24 +36,13 @@ export function createListCommand(opts: ListCommandOptions): Command { .option('--asc', 'sort in ascending order') .option('--desc', 'sort in descending order') .option('--archived', 'include archived items') - .option('--ids ', 'filter by IDs (comma-separated)') - .option('--show ', 'include additional fields from API response') - .option('--exclude ', 'hide fields from summary') - .option( - '--show-only ', - 'show only these fields (mutually exclusive with --show and --exclude)' - ); + .option('--ids ', 'filter by IDs (comma-separated)'); cmd.action( withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(cmd); const client = await getAPIClientFromOptions(globalOptions); - const show = (options.show as string[] | undefined) ?? []; - const exclude = (options.exclude as string[] | undefined) ?? []; - const showOnly = options.showOnly as string[] | undefined; - if (showOnly && (show.length > 0 || exclude.length > 0)) { - throw new Error('--show-only is mutually exclusive with --show and --exclude'); - } + const { show = [], exclude = [], showOnly } = globalOptions; const items = await opts.fetch(client, options); @@ -84,5 +74,6 @@ export function createListCommand(opts: ListCommandOptions): Command { }) ); + addFieldProjectionHelp(cmd); return cmd; } From 1750b07a46774d10c19b74f30aed61cb723fc08f Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 1 Jun 2026 15:10:36 +0100 Subject: [PATCH 2/2] chore(release): bump cli to 1.11.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f1ba37a..338583d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@absmartly/cli", - "version": "1.10.0", + "version": "1.11.0", "description": "ABSmartly CLI - A/B Testing and Feature Flags command-line tool for AI agents and humans", "type": "module", "main": "./dist/index.js",