From 1155c8d1e8df39d0ad94d6fef2c0202fa94b4506 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 21 May 2026 09:53:07 +0100 Subject: [PATCH 01/10] fix(output): summarize nested objects in formatValue instead of JSON.stringify Nested objects (created_by, updated_by, owner, etc.) rendered in -o table / -o vertical / -o markdown / -o plain are now rendered as a short summary ('First Last', email, name, title, or [object]) instead of a JSON blob. -o json / -o yaml are unchanged. (FT-1942) --- src/lib/output/formatter.test.ts | 65 ++++++++++++++++++++++++++++++++ src/lib/output/formatter.ts | 21 ++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/lib/output/formatter.test.ts b/src/lib/output/formatter.test.ts index 87c6eb4..af17f30 100644 --- a/src/lib/output/formatter.test.ts +++ b/src/lib/output/formatter.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { formatOutput, formatValue, truncateText } from './formatter.js'; +import { summarizeObjectValue, formatUserSummary } from './formatter.js'; describe('Output Formatter', () => { describe('formatValue', () => { @@ -40,6 +41,70 @@ describe('Output Formatter', () => { }); }); + describe('formatUserSummary', () => { + it('returns "First Last" when both names are present', () => { + expect(formatUserSummary({ first_name: 'Joe', last_name: 'Bloggs' })).toBe('Joe Bloggs'); + }); + + it('returns the email when names are missing', () => { + expect(formatUserSummary({ email: 'joe@example.com' })).toBe('joe@example.com'); + }); + + it('prefers full name over email when both are present', () => { + expect( + formatUserSummary({ first_name: 'Joe', last_name: 'Bloggs', email: 'joe@example.com' }) + ).toBe('Joe Bloggs'); + }); + + it('returns just the first name if last is missing', () => { + expect(formatUserSummary({ first_name: 'Joe' })).toBe('Joe'); + }); + + it('returns null when the value is not user-shaped', () => { + expect(formatUserSummary({ id: 1, name: 'thing' })).toBe(null); + expect(formatUserSummary({})).toBe(null); + expect(formatUserSummary(null)).toBe(null); + }); + }); + + describe('summarizeObjectValue', () => { + it('summarizes user-shaped objects via formatUserSummary', () => { + expect(summarizeObjectValue({ first_name: 'Joe', last_name: 'Bloggs', id: 4 })).toBe( + 'Joe Bloggs' + ); + }); + + it('falls back to the name field when no user shape', () => { + expect(summarizeObjectValue({ id: 7, name: 'Revenue' })).toBe('Revenue'); + }); + + it('falls back to the title field when there is no name', () => { + expect(summarizeObjectValue({ id: 9, title: 'Owner' })).toBe('Owner'); + }); + + it('renders an empty object as [object]', () => { + expect(summarizeObjectValue({})).toBe('[object]'); + }); + + it('renders an object with only opaque fields as [object]', () => { + expect(summarizeObjectValue({ foo: 1, bar: 2 })).toBe('[object]'); + }); + }); + + describe('formatValue (nested object handling)', () => { + it('summarizes nested user objects instead of JSON.stringify-ing them', () => { + expect(formatValue({ first_name: 'Joe', last_name: 'Bloggs' })).toBe('Joe Bloggs'); + }); + + it('summarizes nested named objects instead of JSON.stringify-ing them', () => { + expect(formatValue({ id: 1, name: 'Revenue' })).toBe('Revenue'); + }); + + it('renders other nested objects as [object] instead of JSON', () => { + expect(formatValue({ foo: 1, bar: 2 })).toBe('[object]'); + }); + }); + describe('truncateText', () => { it('should not truncate short text', () => { const text = 'short'; diff --git a/src/lib/output/formatter.ts b/src/lib/output/formatter.ts index 09c9750..8c741e0 100644 --- a/src/lib/output/formatter.ts +++ b/src/lib/output/formatter.ts @@ -237,6 +237,25 @@ function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +export function formatUserSummary(value: unknown): string | null { + if (!isObject(value)) return null; + const first = typeof value.first_name === 'string' ? value.first_name : ''; + const last = typeof value.last_name === 'string' ? value.last_name : ''; + const fullName = [first, last].filter(Boolean).join(' '); + if (fullName) return fullName; + if (typeof value.email === 'string' && value.email) return value.email; + return null; +} + +export function summarizeObjectValue(value: unknown): string { + if (!isObject(value)) return ''; + const user = formatUserSummary(value); + if (user !== null) return user; + if (typeof value.name === 'string' && value.name) return value.name; + if (typeof value.title === 'string' && value.title) return value.title; + return '[object]'; +} + export function formatValue(value: unknown, options: OutputOptions = {}): string { if (value === null || value === undefined) return ''; if (typeof value === 'boolean') return String(value); @@ -249,7 +268,7 @@ export function formatValue(value: unknown, options: OutputOptions = {}): string return text; } if (Array.isArray(value)) return value.map((v) => formatValue(v, options)).join(', '); - if (isObject(value)) return JSON.stringify(value); + if (isObject(value)) return summarizeObjectValue(value); return String(value); } From 5706d120bc823be07cf2e5feb4752ebdd0ffd657 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 21 May 2026 09:58:59 +0100 Subject: [PATCH 02/10] refactor(entity-summary): reuse formatUserSummary for nested owner fields (FT-1942) --- src/api-client/entity-summary.test.ts | 25 +++++++++++++++++++++++++ src/api-client/entity-summary.ts | 5 ++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/api-client/entity-summary.test.ts b/src/api-client/entity-summary.test.ts index 2fc24f2..69ea266 100644 --- a/src/api-client/entity-summary.test.ts +++ b/src/api-client/entity-summary.test.ts @@ -190,3 +190,28 @@ describe('summarizeSegmentRow', () => { expect(result).toHaveProperty('attribute', 'device'); }); }); + +describe('summarizeGoal created_by field', () => { + it('returns full name when first and last are present', () => { + const result = summarizeGoal({ + id: 1, + name: 'g', + created_by: { first_name: 'Joe', last_name: 'Bloggs' }, + }); + expect(result.created_by).toBe('Joe Bloggs'); + }); + + it('returns email when name parts are missing', () => { + const result = summarizeGoal({ + id: 1, + name: 'g', + created_by: { email: 'joe@example.com' }, + }); + expect(result.created_by).toBe('joe@example.com'); + }); + + it('returns empty string for null created_by', () => { + const result = summarizeGoal({ id: 1, name: 'g', created_by: null }); + expect(result.created_by).toBe(''); + }); +}); diff --git a/src/api-client/entity-summary.ts b/src/api-client/entity-summary.ts index 8a2f72f..7dc6f8c 100644 --- a/src/api-client/entity-summary.ts +++ b/src/api-client/entity-summary.ts @@ -1,4 +1,5 @@ import { formatDate } from './format-helpers.js'; +import { formatUserSummary } from '../lib/output/formatter.js'; export function applyShowExclude( summary: Record, @@ -19,9 +20,7 @@ export function applyShowExclude( function formatOwner(obj: Record | undefined): string { if (!obj) return ''; - const first = (obj.first_name as string) ?? ''; - const last = (obj.last_name as string) ?? ''; - return [first, last].filter(Boolean).join(' ') || (obj.email as string) || ''; + return formatUserSummary(obj) ?? ''; } export function summarizeMetric(m: Record): Record { From 573e3932301eb902e0601113d46d5488663bd756 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 21 May 2026 10:03:51 +0100 Subject: [PATCH 03/10] fix(apikeys): curate columns and summarize created_by/updated_by in list -o table (FT-1942) --- src/commands/apikeys/apikeys.test.ts | 48 +++++++++++++++++++++++++++- src/commands/apikeys/index.ts | 18 +++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/commands/apikeys/apikeys.test.ts b/src/commands/apikeys/apikeys.test.ts index 902662c..d6c1c63 100644 --- a/src/commands/apikeys/apikeys.test.ts +++ b/src/commands/apikeys/apikeys.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { apiKeysCommand } from './index.js'; +import { apiKeysCommand, summarizeApiKeyRow } from './index.js'; import { getAPIClientFromOptions, getGlobalOptions, @@ -105,3 +105,49 @@ describe('apikeys command', () => { expect(mockClient.deleteApiKey).toHaveBeenCalledWith(5); }); }); + +describe('summarizeApiKeyRow', () => { + it('picks curated columns and flattens created_by / updated_by', () => { + const raw = { + id: 36, + name: 'test new key', + description: 'new key', + hashed_key: 'X99upHMkKKM2XUpRuZ53YWCDx5G93SgB043EXx3LG5k=', + key_ending: 'ouNy', + permissions: '', + used_at: null, + created_at: '2024-05-22T10:29:34.375Z', + updated_at: null, + created_by: { id: 4, first_name: 'Márcio', last_name: 'Martins', email: 'm@x' }, + updated_by: null, + }; + expect(summarizeApiKeyRow(raw)).toEqual({ + id: 36, + name: 'test new key', + description: 'new key', + key_ending: 'ouNy', + permissions: '', + used_at: '', + created_at: '2024-05-22T10:29:34.375Z', + created_by: 'Márcio Martins', + updated_at: '', + updated_by: '', + }); + }); + + it('returns empty strings for missing optional fields', () => { + const raw = { id: 1, name: 'k', key_ending: 'xxxx' }; + expect(summarizeApiKeyRow(raw)).toEqual({ + id: 1, + name: 'k', + description: '', + key_ending: 'xxxx', + permissions: '', + used_at: '', + created_at: '', + created_by: '', + updated_at: '', + updated_by: '', + }); + }); +}); diff --git a/src/commands/apikeys/index.ts b/src/commands/apikeys/index.ts index af8437e..ae4d1f4 100644 --- a/src/commands/apikeys/index.ts +++ b/src/commands/apikeys/index.ts @@ -9,6 +9,7 @@ import { } from '../../lib/utils/api-helper.js'; import { parseApiKeyId } from '../../lib/utils/validators.js'; import { createListCommand } from '../../lib/utils/list-command.js'; +import { formatUserSummary } from '../../lib/output/formatter.js'; import type { ApiKeyId } from '../../lib/api/branded-types.js'; import { getApiKey, createApiKey, updateApiKey, deleteApiKey } from '../../core/apikeys/index.js'; @@ -16,6 +17,22 @@ export const apiKeysCommand = new Command('api-keys') .aliases(['apikeys', 'apikey', 'api-key']) .description('API key commands'); +export function summarizeApiKeyRow(item: Record): Record { + const summarizeUser = (v: unknown) => formatUserSummary(v) ?? ''; + return { + id: item.id, + name: item.name ?? '', + description: item.description ?? '', + key_ending: item.key_ending ?? '', + permissions: item.permissions ?? '', + used_at: item.used_at ?? '', + created_at: item.created_at ?? '', + created_by: summarizeUser(item.created_by), + updated_at: item.updated_at ?? '', + updated_by: summarizeUser(item.updated_by), + }; +} + const listCommand = createListCommand({ description: 'List all API keys', fetch: (client, options) => @@ -28,6 +45,7 @@ const listCommand = createListCommand({ archived: options.archived as boolean, ids: options.ids as string | undefined, }), + summarizeRow: summarizeApiKeyRow, }); const getCommand = new Command('get') From 493a3fe46a42dcbd9d4660004cdb01a1d623baee Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 21 May 2026 10:08:15 +0100 Subject: [PATCH 04/10] fix(tags,goaltags,metrictags): add summarizeTagRow for clean list -o table (FT-1942) --- src/api-client/entity-summary.test.ts | 29 +++++++++++++++++++++++++++ src/api-client/entity-summary.ts | 12 +++++++++++ src/commands/goaltags/index.ts | 2 ++ src/commands/metrictags/index.ts | 2 ++ src/commands/tags/index.ts | 2 ++ 5 files changed, 47 insertions(+) diff --git a/src/api-client/entity-summary.test.ts b/src/api-client/entity-summary.test.ts index 69ea266..e27b0bf 100644 --- a/src/api-client/entity-summary.test.ts +++ b/src/api-client/entity-summary.test.ts @@ -11,6 +11,7 @@ import { summarizeUserDetail, summarizeSegment, summarizeSegmentRow, + summarizeTagRow, } from './entity-summary.js'; describe('applyShowExclude', () => { @@ -215,3 +216,31 @@ describe('summarizeGoal created_by field', () => { expect(result.created_by).toBe(''); }); }); + +describe('summarizeTagRow', () => { + it('curates the simple tag columns and summarizes user fields', () => { + expect( + summarizeTagRow({ + id: 7, + tag: 'top-priority', + archived: false, + created_at: '2024-05-22T10:29:34.375Z', + created_by: { first_name: 'Joe', last_name: 'Bloggs' }, + updated_at: null, + updated_by: null, + }) + ).toEqual({ + id: 7, + tag: 'top-priority', + archived: false, + created_at: '2024-05-22T10:29:34.375Z', + created_by: 'Joe Bloggs', + updated_at: '', + updated_by: '', + }); + }); + + it('defaults archived to false when missing', () => { + expect(summarizeTagRow({ id: 1, tag: 't' }).archived).toBe(false); + }); +}); diff --git a/src/api-client/entity-summary.ts b/src/api-client/entity-summary.ts index 7dc6f8c..75e96c3 100644 --- a/src/api-client/entity-summary.ts +++ b/src/api-client/entity-summary.ts @@ -203,3 +203,15 @@ export function summarizeCustomField(f: Record): Record): Record { + return { + id: t.id, + tag: t.tag ?? '', + archived: t.archived ?? false, + created_at: t.created_at ?? '', + created_by: formatOwner(t.created_by as Record | undefined), + updated_at: t.updated_at ?? '', + updated_by: formatOwner(t.updated_by as Record | undefined), + }; +} diff --git a/src/commands/goaltags/index.ts b/src/commands/goaltags/index.ts index 81f4f3e..e6aca1e 100644 --- a/src/commands/goaltags/index.ts +++ b/src/commands/goaltags/index.ts @@ -8,6 +8,7 @@ import { } from '../../lib/utils/api-helper.js'; import { parseTagId } from '../../lib/utils/validators.js'; import { createListCommand } from '../../lib/utils/list-command.js'; +import { summarizeTagRow } from '../../api-client/entity-summary.js'; import type { TagId } from '../../lib/api/branded-types.js'; import { getGoalTag, @@ -34,6 +35,7 @@ const listCommand = createListCommand({ archived: options.archived as boolean, ids: options.ids as string | undefined, }), + summarizeRow: summarizeTagRow, }); const getCommand = new Command('get') diff --git a/src/commands/metrictags/index.ts b/src/commands/metrictags/index.ts index 0e20273..21fdd68 100644 --- a/src/commands/metrictags/index.ts +++ b/src/commands/metrictags/index.ts @@ -8,6 +8,7 @@ import { } from '../../lib/utils/api-helper.js'; import { parseTagId } from '../../lib/utils/validators.js'; import { createListCommand } from '../../lib/utils/list-command.js'; +import { summarizeTagRow } from '../../api-client/entity-summary.js'; import type { TagId } from '../../lib/api/branded-types.js'; import { getMetricTag, @@ -34,6 +35,7 @@ const listCommand = createListCommand({ archived: options.archived as boolean, ids: options.ids as string | undefined, }), + summarizeRow: summarizeTagRow, }); const getCommand = new Command('get') diff --git a/src/commands/tags/index.ts b/src/commands/tags/index.ts index 9abc3b3..09504f9 100644 --- a/src/commands/tags/index.ts +++ b/src/commands/tags/index.ts @@ -8,6 +8,7 @@ import { } from '../../lib/utils/api-helper.js'; import { parseTagId } from '../../lib/utils/validators.js'; import { createListCommand } from '../../lib/utils/list-command.js'; +import { summarizeTagRow } from '../../api-client/entity-summary.js'; import type { TagId } from '../../lib/api/branded-types.js'; import { getTag, createTag, updateTag, deleteTag } from '../../core/tags/index.js'; @@ -28,6 +29,7 @@ const listCommand = createListCommand({ archived: options.archived as boolean, ids: options.ids as string | undefined, }), + summarizeRow: summarizeTagRow, }); const getCommand = new Command('get') From 76022d4d34f23191512f259c511264b381fc2b47 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 21 May 2026 10:15:14 +0100 Subject: [PATCH 05/10] fix(metriccategories): curate columns for list -o table (FT-1942) --- src/api-client/entity-summary.test.ts | 29 ++++++++++++++++++++++++++ src/api-client/entity-summary.ts | 16 ++++++++++++++ src/commands/metriccategories/index.ts | 2 ++ 3 files changed, 47 insertions(+) diff --git a/src/api-client/entity-summary.test.ts b/src/api-client/entity-summary.test.ts index e27b0bf..1ba291f 100644 --- a/src/api-client/entity-summary.test.ts +++ b/src/api-client/entity-summary.test.ts @@ -12,6 +12,7 @@ import { summarizeSegment, summarizeSegmentRow, summarizeTagRow, + summarizeMetricCategoryRow, } from './entity-summary.js'; describe('applyShowExclude', () => { @@ -244,3 +245,31 @@ describe('summarizeTagRow', () => { expect(summarizeTagRow({ id: 1, tag: 't' }).archived).toBe(false); }); }); + +describe('summarizeMetricCategoryRow', () => { + it('picks the useful columns and summarizes user fields', () => { + expect( + summarizeMetricCategoryRow({ + id: 3, + name: 'Revenue', + description: 'all revenue metrics', + color: '#ff5733', + archived: false, + created_at: '2024-05-22T10:29:34.375Z', + created_by: { first_name: 'Joe', last_name: 'Bloggs' }, + updated_at: null, + updated_by: null, + }) + ).toEqual({ + id: 3, + name: 'Revenue', + description: 'all revenue metrics', + color: '#ff5733', + archived: false, + created_at: '2024-05-22T10:29:34.375Z', + created_by: 'Joe Bloggs', + updated_at: '', + updated_by: '', + }); + }); +}); diff --git a/src/api-client/entity-summary.ts b/src/api-client/entity-summary.ts index 75e96c3..b996748 100644 --- a/src/api-client/entity-summary.ts +++ b/src/api-client/entity-summary.ts @@ -215,3 +215,19 @@ export function summarizeTagRow(t: Record): Record | undefined), }; } + +export function summarizeMetricCategoryRow( + c: Record +): Record { + return { + id: c.id, + name: c.name ?? '', + description: c.description ?? '', + color: c.color ?? '', + archived: c.archived ?? false, + created_at: c.created_at ?? '', + created_by: formatOwner(c.created_by as Record | undefined), + updated_at: c.updated_at ?? '', + updated_by: formatOwner(c.updated_by as Record | undefined), + }; +} diff --git a/src/commands/metriccategories/index.ts b/src/commands/metriccategories/index.ts index 0c68324..df633f2 100644 --- a/src/commands/metriccategories/index.ts +++ b/src/commands/metriccategories/index.ts @@ -8,6 +8,7 @@ import { } from '../../lib/utils/api-helper.js'; import { parseTagId } from '../../lib/utils/validators.js'; import { createListCommand } from '../../lib/utils/list-command.js'; +import { summarizeMetricCategoryRow } from '../../api-client/entity-summary.js'; import type { TagId } from '../../lib/api/branded-types.js'; import { getMetricCategory, @@ -35,6 +36,7 @@ const listCommand = createListCommand({ archived: options.archived as boolean, ids: options.ids as string | undefined, }), + summarizeRow: summarizeMetricCategoryRow, }); const getCommand = new Command('get') From 87cf1637d813e930f80ff0d43e587f4ce2aa07f1 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 21 May 2026 10:20:09 +0100 Subject: [PATCH 06/10] fix(envs,units): curate columns for list -o table (FT-1942) --- src/api-client/entity-summary.test.ts | 31 +++++++++++++++++++++++++++ src/api-client/entity-summary.ts | 15 +++++++++++++ src/commands/envs/envs.test.ts | 16 +++++++++++++- src/commands/envs/index.ts | 2 ++ src/commands/units/index.ts | 2 ++ src/commands/units/units.test.ts | 16 +++++++++++++- 6 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/api-client/entity-summary.test.ts b/src/api-client/entity-summary.test.ts index 1ba291f..1ae936c 100644 --- a/src/api-client/entity-summary.test.ts +++ b/src/api-client/entity-summary.test.ts @@ -13,6 +13,7 @@ import { summarizeSegmentRow, summarizeTagRow, summarizeMetricCategoryRow, + summarizeNamedEntityRow, } from './entity-summary.js'; describe('applyShowExclude', () => { @@ -273,3 +274,33 @@ describe('summarizeMetricCategoryRow', () => { }); }); }); + +describe('summarizeNamedEntityRow', () => { + it('curates id/name/description/archived plus user/time audit fields', () => { + expect( + summarizeNamedEntityRow({ + id: 4, + name: 'production', + description: 'production env', + archived: false, + created_at: '2024-05-22T10:29:34.375Z', + created_by: { first_name: 'Joe', last_name: 'Bloggs' }, + updated_at: '2024-05-23T10:29:34.375Z', + updated_by: { email: 'admin@x' }, + }) + ).toEqual({ + id: 4, + name: 'production', + description: 'production env', + archived: false, + created_at: '2024-05-22T10:29:34.375Z', + created_by: 'Joe Bloggs', + updated_at: '2024-05-23T10:29:34.375Z', + updated_by: 'admin@x', + }); + }); + + it('keeps description empty if missing', () => { + expect(summarizeNamedEntityRow({ id: 1, name: 'x' }).description).toBe(''); + }); +}); diff --git a/src/api-client/entity-summary.ts b/src/api-client/entity-summary.ts index b996748..ffe2264 100644 --- a/src/api-client/entity-summary.ts +++ b/src/api-client/entity-summary.ts @@ -231,3 +231,18 @@ export function summarizeMetricCategoryRow( updated_by: formatOwner(c.updated_by as Record | undefined), }; } + +export function summarizeNamedEntityRow( + e: Record +): Record { + return { + id: e.id, + name: e.name ?? '', + description: e.description ?? '', + archived: e.archived ?? false, + created_at: e.created_at ?? '', + created_by: formatOwner(e.created_by as Record | undefined), + updated_at: e.updated_at ?? '', + updated_by: formatOwner(e.updated_by as Record | undefined), + }; +} diff --git a/src/commands/envs/envs.test.ts b/src/commands/envs/envs.test.ts index 771f7ec..67570bc 100644 --- a/src/commands/envs/envs.test.ts +++ b/src/commands/envs/envs.test.ts @@ -53,7 +53,21 @@ describe('envs command', () => { await envsCommand.parseAsync(['node', 'test', 'list']); expect(mockClient.listEnvironments).toHaveBeenCalled(); - expect(printFormatted).toHaveBeenCalledWith([{ id: 1, name: 'production' }], expect.anything()); + expect(printFormatted).toHaveBeenCalledWith( + [ + { + id: 1, + name: 'production', + description: '', + archived: false, + created_at: '', + created_by: '', + updated_at: '', + updated_by: '', + }, + ], + expect.anything() + ); }); it('should get environment by id', async () => { diff --git a/src/commands/envs/index.ts b/src/commands/envs/index.ts index 7114e9e..3ea4111 100644 --- a/src/commands/envs/index.ts +++ b/src/commands/envs/index.ts @@ -8,6 +8,7 @@ import { } from '../../lib/utils/api-helper.js'; import { parseEnvironmentId } from '../../lib/utils/validators.js'; import { createListCommand } from '../../lib/utils/list-command.js'; +import { summarizeNamedEntityRow } from '../../api-client/entity-summary.js'; import type { EnvironmentId } from '../../lib/api/branded-types.js'; import { getEnv, createEnv, updateEnv, archiveEnv } from '../../core/envs/index.js'; @@ -29,6 +30,7 @@ const listCommand = createListCommand({ archived: options.archived as boolean, ids: options.ids as string | undefined, }), + summarizeRow: summarizeNamedEntityRow, }); const getCommand = new Command('get') diff --git a/src/commands/units/index.ts b/src/commands/units/index.ts index fcade15..a41c07b 100644 --- a/src/commands/units/index.ts +++ b/src/commands/units/index.ts @@ -8,6 +8,7 @@ import { } from '../../lib/utils/api-helper.js'; import { parseUnitTypeId } from '../../lib/utils/validators.js'; import { createListCommand } from '../../lib/utils/list-command.js'; +import { summarizeNamedEntityRow } from '../../api-client/entity-summary.js'; import type { UnitTypeId } from '../../lib/api/branded-types.js'; import { getUnit, createUnit, updateUnit, archiveUnit } from '../../core/units/index.js'; @@ -26,6 +27,7 @@ const listCommand = createListCommand({ archived: options.archived as boolean, ids: options.ids as string | undefined, }), + summarizeRow: summarizeNamedEntityRow, }); const getCommand = new Command('get') diff --git a/src/commands/units/units.test.ts b/src/commands/units/units.test.ts index 3dd1d5b..0837c5b 100644 --- a/src/commands/units/units.test.ts +++ b/src/commands/units/units.test.ts @@ -53,7 +53,21 @@ describe('units command', () => { await unitsCommand.parseAsync(['node', 'test', 'list']); expect(mockClient.listUnitTypes).toHaveBeenCalled(); - expect(printFormatted).toHaveBeenCalledWith([{ id: 1, name: 'user_id' }], expect.anything()); + expect(printFormatted).toHaveBeenCalledWith( + [ + { + id: 1, + name: 'user_id', + description: '', + archived: false, + created_at: '', + created_by: '', + updated_at: '', + updated_by: '', + }, + ], + expect.anything() + ); }); it('should get unit type by id', async () => { From bbf2c357a73c60ee5e11889c8d62416ae1d20b7e Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 21 May 2026 10:24:23 +0100 Subject: [PATCH 07/10] fix(roles): curate columns for list -o table (FT-1942) --- src/commands/roles/index.ts | 2 ++ src/commands/roles/roles.test.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/commands/roles/index.ts b/src/commands/roles/index.ts index 9677a50..094f308 100644 --- a/src/commands/roles/index.ts +++ b/src/commands/roles/index.ts @@ -10,6 +10,7 @@ import { parseRoleId } from '../../lib/utils/validators.js'; import { createListCommand } from '../../lib/utils/list-command.js'; import type { RoleId } from '../../lib/api/branded-types.js'; import { getRole, createRole, updateRole, deleteRole } from '../../core/roles/index.js'; +import { summarizeNamedEntityRow } from '../../api-client/entity-summary.js'; export const rolesCommand = new Command('roles').alias('role').description('Role commands'); @@ -25,6 +26,7 @@ const listCommand = createListCommand({ archived: options.archived as boolean, ids: options.ids as string | undefined, }), + summarizeRow: summarizeNamedEntityRow, }); const getCommand = new Command('get') diff --git a/src/commands/roles/roles.test.ts b/src/commands/roles/roles.test.ts index fe72676..287cf38 100644 --- a/src/commands/roles/roles.test.ts +++ b/src/commands/roles/roles.test.ts @@ -6,6 +6,7 @@ import { printFormatted, } from '../../lib/utils/api-helper.js'; import { resetCommand } from '../../test/helpers/command-reset.js'; +import { summarizeNamedEntityRow } from '../../api-client/entity-summary.js'; vi.mock('../../lib/utils/api-helper.js', async (importOriginal) => { const actual = await importOriginal(); @@ -88,3 +89,17 @@ describe('roles command', () => { expect(output).toContain('Role 1 deleted'); }); }); + +describe('roles list summarizer', () => { + it('uses summarizeNamedEntityRow for clean columns', () => { + expect( + summarizeNamedEntityRow({ + id: 1, + name: 'admin', + description: 'all powerful', + archived: false, + created_by: { email: 'root@x' }, + }).created_by + ).toBe('root@x'); + }); +}); From 3181cdcfbf3a0bc9ebaa3bb001f6e83709d823e2 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 21 May 2026 10:26:51 +0100 Subject: [PATCH 08/10] fix(webhooks): curate columns for list -o table (FT-1942) --- src/api-client/entity-summary.test.ts | 33 +++++++++++++++++++++++++++ src/api-client/entity-summary.ts | 16 +++++++++++++ src/commands/webhooks/index.ts | 2 ++ 3 files changed, 51 insertions(+) diff --git a/src/api-client/entity-summary.test.ts b/src/api-client/entity-summary.test.ts index 1ae936c..305225b 100644 --- a/src/api-client/entity-summary.test.ts +++ b/src/api-client/entity-summary.test.ts @@ -14,6 +14,7 @@ import { summarizeTagRow, summarizeMetricCategoryRow, summarizeNamedEntityRow, + summarizeWebhookRow, } from './entity-summary.js'; describe('applyShowExclude', () => { @@ -304,3 +305,35 @@ describe('summarizeNamedEntityRow', () => { expect(summarizeNamedEntityRow({ id: 1, name: 'x' }).description).toBe(''); }); }); + +describe('summarizeWebhookRow', () => { + it('curates webhook-specific columns and summarizes user fields', () => { + expect( + summarizeWebhookRow({ + id: 2, + name: 'metrics-sync', + url: 'https://example.com/hooks/metrics', + enabled: true, + ordered: false, + max_retries: 5, + archived: false, + created_at: '2024-05-22T10:29:34.375Z', + created_by: { first_name: 'Joe', last_name: 'Bloggs' }, + updated_at: null, + updated_by: null, + }) + ).toEqual({ + id: 2, + name: 'metrics-sync', + url: 'https://example.com/hooks/metrics', + enabled: true, + ordered: false, + max_retries: 5, + archived: false, + created_at: '2024-05-22T10:29:34.375Z', + created_by: 'Joe Bloggs', + updated_at: '', + updated_by: '', + }); + }); +}); diff --git a/src/api-client/entity-summary.ts b/src/api-client/entity-summary.ts index ffe2264..e21f7aa 100644 --- a/src/api-client/entity-summary.ts +++ b/src/api-client/entity-summary.ts @@ -246,3 +246,19 @@ export function summarizeNamedEntityRow( updated_by: formatOwner(e.updated_by as Record | undefined), }; } + +export function summarizeWebhookRow(w: Record): Record { + return { + id: w.id, + name: w.name ?? '', + url: w.url ?? '', + enabled: w.enabled ?? false, + ordered: w.ordered ?? false, + max_retries: w.max_retries ?? 0, + archived: w.archived ?? false, + created_at: w.created_at ?? '', + created_by: formatOwner(w.created_by as Record | undefined), + updated_at: w.updated_at ?? '', + updated_by: formatOwner(w.updated_by as Record | undefined), + }; +} diff --git a/src/commands/webhooks/index.ts b/src/commands/webhooks/index.ts index 4d78a9b..2381867 100644 --- a/src/commands/webhooks/index.ts +++ b/src/commands/webhooks/index.ts @@ -8,6 +8,7 @@ import { } from '../../lib/utils/api-helper.js'; import { parseWebhookId } from '../../lib/utils/validators.js'; import { createListCommand } from '../../lib/utils/list-command.js'; +import { summarizeWebhookRow } from '../../api-client/entity-summary.js'; import type { WebhookId } from '../../lib/api/branded-types.js'; import { getWebhook } from '../../core/webhooks/get.js'; import { createWebhook } from '../../core/webhooks/create.js'; @@ -31,6 +32,7 @@ const listCommand = createListCommand({ archived: options.archived as boolean, ids: options.ids as string | undefined, }), + summarizeRow: summarizeWebhookRow, }); const getCommand = new Command('get') From 7c545141355f89e180ac56fbfad78782b84fed89 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 21 May 2026 10:29:35 +0100 Subject: [PATCH 09/10] refactor(apps): use formatUserSummary helper in list summarizeRow (FT-1942) --- src/commands/apps/apps.test.ts | 5 ++++- src/commands/apps/index.ts | 11 ++--------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/commands/apps/apps.test.ts b/src/commands/apps/apps.test.ts index 2876ece..fe0d602 100644 --- a/src/commands/apps/apps.test.ts +++ b/src/commands/apps/apps.test.ts @@ -53,7 +53,10 @@ describe('apps command', () => { await appsCommand.parseAsync(['node', 'test', 'list']); expect(mockClient.listApplications).toHaveBeenCalled(); - expect(printFormatted).toHaveBeenCalledWith([{ id: 1, name: 'web' }], expect.anything()); + expect(printFormatted).toHaveBeenCalledWith( + [expect.objectContaining({ id: 1, name: 'web' })], + expect.anything() + ); }); it('should get application by id', async () => { diff --git a/src/commands/apps/index.ts b/src/commands/apps/index.ts index d277de0..3854c80 100644 --- a/src/commands/apps/index.ts +++ b/src/commands/apps/index.ts @@ -8,6 +8,7 @@ import { } from '../../lib/utils/api-helper.js'; import { parseApplicationId } from '../../lib/utils/validators.js'; import { createListCommand } from '../../lib/utils/list-command.js'; +import { formatUserSummary } from '../../lib/output/formatter.js'; import type { ApplicationId } from '../../lib/api/branded-types.js'; import { getApp, createApp, updateApp, archiveApp } from '../../core/apps/index.js'; @@ -35,15 +36,7 @@ const listCommand = createListCommand({ description: item.description, archived: item.archived, created_at: item.created_at, - created_by: - item.created_by && typeof item.created_by === 'object' - ? [ - (item.created_by as Record).first_name ?? '', - (item.created_by as Record).last_name ?? '', - ] - .filter(Boolean) - .join(' ') - : item.created_by, + created_by: formatUserSummary(item.created_by) ?? '', }), }); From 93bdf9dcf699311665dda72cc5b31c6e9f7689e7 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 21 May 2026 10:33:44 +0100 Subject: [PATCH 10/10] style: apply prettier to entity-summary.ts (FT-1942) --- src/api-client/entity-summary.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/api-client/entity-summary.ts b/src/api-client/entity-summary.ts index e21f7aa..7f922fe 100644 --- a/src/api-client/entity-summary.ts +++ b/src/api-client/entity-summary.ts @@ -216,9 +216,7 @@ export function summarizeTagRow(t: Record): Record -): Record { +export function summarizeMetricCategoryRow(c: Record): Record { return { id: c.id, name: c.name ?? '', @@ -232,9 +230,7 @@ export function summarizeMetricCategoryRow( }; } -export function summarizeNamedEntityRow( - e: Record -): Record { +export function summarizeNamedEntityRow(e: Record): Record { return { id: e.id, name: e.name ?? '',