From 2fbec7283025a68b08caf9e29da948d7bb098cbc Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 13:13:49 +0100 Subject: [PATCH 01/35] fix(cli): summarize metrics list/get responses by default --- src/commands/metrics/index.ts | 25 +++++------- src/core/metrics/get.ts | 19 +++++++++- src/core/metrics/list.ts | 2 + src/core/metrics/metrics.test.ts | 65 +++++++++++++++++++++++++++++++- 4 files changed, 93 insertions(+), 18 deletions(-) diff --git a/src/commands/metrics/index.ts b/src/commands/metrics/index.ts index 857cbf9..25b6bc9 100644 --- a/src/commands/metrics/index.ts +++ b/src/commands/metrics/index.ts @@ -9,11 +9,7 @@ import { } from '../../lib/utils/api-helper.js'; import { parseMetricId } from '../../lib/utils/validators.js'; import type { MetricId } from '../../lib/api/branded-types.js'; -import { - applyShowExclude, - summarizeMetric, - summarizeMetricRow, -} from '../../api-client/entity-summary.js'; +import { summarizeMetricRow } from '../../api-client/entity-summary.js'; import { createListCommand } from '../../lib/utils/list-command.js'; import { listMetrics as coreListMetrics, listAllMetrics } from '../../core/metrics/list.js'; import { @@ -119,17 +115,14 @@ const getCommand = new Command('get') const client = await getAPIClientFromOptions(globalOptions); const { show = [], exclude = [], showOnly } = globalOptions; - const result = await getMetric(client, { id }); - const data = globalOptions.raw - ? result.data - : applyShowExclude( - summarizeMetric(result.data as Record), - result.data as Record, - show, - exclude, - showOnly - ); - printFormatted(data, globalOptions); + const result = await getMetric(client, { + id, + show, + exclude, + showOnly, + raw: globalOptions.raw, + }); + printFormatted(result.data, globalOptions); }) ); diff --git a/src/core/metrics/get.ts b/src/core/metrics/get.ts index 328db54..bd04fa2 100644 --- a/src/core/metrics/get.ts +++ b/src/core/metrics/get.ts @@ -1,15 +1,32 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { MetricId } from '../../lib/api/branded-types.js'; import type { CommandResult } from '../types.js'; +import { applyShowExclude, summarizeMetric } from '../../api-client/entity-summary.js'; export interface GetMetricParams { id: MetricId; + show?: string[] | undefined; + exclude?: string[] | undefined; + showOnly?: string[] | undefined; + raw?: boolean | undefined; } export async function getMetric( client: APIClient, params: GetMetricParams ): Promise> { - const data = await client.getMetric(params.id); + const metric = await client.getMetric(params.id); + const show = params.show ?? []; + const exclude = params.exclude ?? []; + + const data = params.raw + ? metric + : applyShowExclude( + summarizeMetric(metric as Record), + metric as Record, + show, + exclude, + params.showOnly + ); return { data }; } diff --git a/src/core/metrics/list.ts b/src/core/metrics/list.ts index 26f6865..bb2d81d 100644 --- a/src/core/metrics/list.ts +++ b/src/core/metrics/list.ts @@ -1,6 +1,7 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CommandResult } from '../types.js'; import { resolveOwnerIds, resolveTeamIds } from '../resolve.js'; +import { summarizeMetricRow } from '../../api-client/entity-summary.js'; export { resolveOwnerIds, resolveTeamIds }; @@ -41,6 +42,7 @@ export async function listMetrics( return { data, + rows: (data as Array>).map(summarizeMetricRow), pagination: { page: params.page, items: params.items, hasMore: data.length >= params.items }, }; } diff --git a/src/core/metrics/metrics.test.ts b/src/core/metrics/metrics.test.ts index eb5ffe6..a45d0fa 100644 --- a/src/core/metrics/metrics.test.ts +++ b/src/core/metrics/metrics.test.ts @@ -164,7 +164,7 @@ describe('getMetric', () => { const result = await getMetric(mockClient, { id: 5 as any }); expect(mockClient.getMetric).toHaveBeenCalledWith(5); - expect(result).toEqual({ data: metric }); + expect(result.data).toBeDefined(); }); }); @@ -812,6 +812,69 @@ describe('validateMetricFields', () => { }); }); +describe('listMetrics rows', () => { + it('should include summarized rows', async () => { + const metrics = [ + { id: 1, name: 'M1', type: 'conversion', lifecycle_status: 'active', goal_id: 5 }, + { id: 2, name: 'M2', type: 'revenue', lifecycle_status: 'draft', goal: { name: 'Goal B' } }, + ]; + mockClient.listMetrics.mockResolvedValue(metrics); + + const result = await listMetrics(mockClient, { items: 10, page: 1 }); + + expect(result.rows).toEqual([ + { + id: 1, + name: 'M1', + type: 'conversion', + effect: '', + status: 'active', + goal: 5, + category: '', + }, + { + id: 2, + name: 'M2', + type: 'revenue', + effect: '', + status: 'draft', + goal: 'Goal B', + category: '', + }, + ]); + expect(result.data).toEqual(metrics); + }); +}); + +describe('getMetric summarization', () => { + const metric = { + id: 5, + name: 'Signup Rate', + type: 'conversion', + lifecycle_status: 'active', + definition: { some: 'large blob' }, + }; + + it('returns summarized data by default', async () => { + mockClient.getMetric.mockResolvedValue(metric); + const result = await getMetric(mockClient, { id: 5 as any }); + expect(result.data).not.toHaveProperty('definition'); + expect(result.data).toMatchObject({ id: 5, name: 'Signup Rate', type: 'conversion' }); + }); + + it('returns raw data when raw=true', async () => { + mockClient.getMetric.mockResolvedValue(metric); + const result = await getMetric(mockClient, { id: 5 as any, raw: true }); + expect(result.data).toEqual(metric); + }); + + it('includes an extra field via show', async () => { + mockClient.getMetric.mockResolvedValue(metric); + const result = await getMetric(mockClient, { id: 5 as any, show: ['definition'] }); + expect(result.data).toHaveProperty('definition', { some: 'large blob' }); + }); +}); + describe('listAllMetrics', () => { it('pages through results until a short page is returned', async () => { const client = { From f5caef8a790bc215ff22637bb86d48a737c19864 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 13:18:38 +0100 Subject: [PATCH 02/35] fix(cli): summarize goals list/get responses by default --- src/commands/goals/index.ts | 19 +++---------------- src/core/goals/get.ts | 19 ++++++++++++++++++- src/core/goals/goals.test.ts | 29 ++++++++++++++++++++++++++++- src/core/goals/list.ts | 2 ++ 4 files changed, 51 insertions(+), 18 deletions(-) diff --git a/src/commands/goals/index.ts b/src/commands/goals/index.ts index f2a36ae..fc9495a 100644 --- a/src/commands/goals/index.ts +++ b/src/commands/goals/index.ts @@ -9,11 +9,7 @@ import { } from '../../lib/utils/api-helper.js'; import { parseGoalId } from '../../lib/utils/validators.js'; import type { GoalId } from '../../lib/api/branded-types.js'; -import { - applyShowExclude, - summarizeGoal, - summarizeGoalRow, -} from '../../api-client/entity-summary.js'; +import { summarizeGoalRow } from '../../api-client/entity-summary.js'; import { createListCommand } from '../../lib/utils/list-command.js'; import { getGoal } from '../../core/goals/get.js'; import { createGoal } from '../../core/goals/create.js'; @@ -47,17 +43,8 @@ const getCommand = new Command('get') const client = await getAPIClientFromOptions(globalOptions); const { show = [], exclude = [], showOnly } = globalOptions; - const result = await getGoal(client, { id }); - const data = globalOptions.raw - ? result.data - : applyShowExclude( - summarizeGoal(result.data as Record), - result.data as Record, - show, - exclude, - showOnly - ); - printFormatted(data, globalOptions); + const result = await getGoal(client, { id, show, exclude, showOnly, raw: globalOptions.raw }); + printFormatted(result.data, globalOptions); }) ); diff --git a/src/core/goals/get.ts b/src/core/goals/get.ts index ebac4a9..dc7f5f1 100644 --- a/src/core/goals/get.ts +++ b/src/core/goals/get.ts @@ -1,15 +1,32 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { GoalId } from '../../lib/api/branded-types.js'; import type { CommandResult } from '../types.js'; +import { applyShowExclude, summarizeGoal } from '../../api-client/entity-summary.js'; export interface GetGoalParams { id: GoalId; + show?: string[] | undefined; + exclude?: string[] | undefined; + showOnly?: string[] | undefined; + raw?: boolean | undefined; } export async function getGoal( client: APIClient, params: GetGoalParams ): Promise> { - const data = await client.getGoal(params.id); + const goal = await client.getGoal(params.id); + const show = params.show ?? []; + const exclude = params.exclude ?? []; + + const data = params.raw + ? goal + : applyShowExclude( + summarizeGoal(goal as Record), + goal as Record, + show, + exclude, + params.showOnly + ); return { data }; } diff --git a/src/core/goals/goals.test.ts b/src/core/goals/goals.test.ts index b51f663..9147072 100644 --- a/src/core/goals/goals.test.ts +++ b/src/core/goals/goals.test.ts @@ -50,6 +50,21 @@ describe('listGoals', () => { expect(result.pagination!.hasMore).toBe(true); }); + + it('should include summarized rows', async () => { + const goals = [ + { id: 1, name: 'G1', archived: false, tags: [{ goal_tag: { tag: 'core' } }] }, + { id: 2, name: 'G2', archived: true, tags: [] }, + ]; + mockClient.listGoals.mockResolvedValue(goals); + + const result = await listGoals(mockClient, { items: 10, page: 1 }); + + expect(result.rows).toEqual([ + { id: 1, name: 'G1', tags: 'core', archived: false }, + { id: 2, name: 'G2', tags: '', archived: true }, + ]); + }); }); describe('getGoal', () => { @@ -60,7 +75,19 @@ describe('getGoal', () => { const result = await getGoal(mockClient, { id: 5 as any }); expect(mockClient.getGoal).toHaveBeenCalledWith(5); - expect(result).toEqual({ data: goal }); + expect(result.data).toMatchObject({ id: 5, name: 'Goal' }); + }); + + it('should summarize by default and honor raw', async () => { + const goal = { id: 5, name: 'Goal', archived: false, big_field: 'lots of data' }; + mockClient.getGoal.mockResolvedValue(goal); + + const summarized = await getGoal(mockClient, { id: 5 as any }); + expect(summarized.data).not.toHaveProperty('big_field'); + expect(summarized.data).toMatchObject({ id: 5, name: 'Goal' }); + + const raw = await getGoal(mockClient, { id: 5 as any, raw: true }); + expect(raw.data).toEqual(goal); }); }); diff --git a/src/core/goals/list.ts b/src/core/goals/list.ts index 496b19e..c770d59 100644 --- a/src/core/goals/list.ts +++ b/src/core/goals/list.ts @@ -1,5 +1,6 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CommandResult } from '../types.js'; +import { summarizeGoalRow } from '../../api-client/entity-summary.js'; export interface ListGoalsParams { items: number; @@ -13,6 +14,7 @@ export async function listGoals( const data = await client.listGoals({ items: params.items, page: params.page }); return { data, + rows: (data as Array>).map(summarizeGoalRow), pagination: { page: params.page, items: params.items, hasMore: data.length >= params.items }, }; } From 23d3b87a235b97c1cb3c1ae489fbb111a7005b5f Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 13:27:16 +0100 Subject: [PATCH 03/35] fix(cli): summarize users list/get responses by default --- src/commands/users/index.ts | 2 +- src/commands/users/users.test.ts | 21 ++++++++++++ src/core/users/get.ts | 19 +++++++++- src/core/users/list.ts | 2 ++ src/core/users/users.test.ts | 59 +++++++++++++++++++++++++++++++- 5 files changed, 100 insertions(+), 3 deletions(-) diff --git a/src/commands/users/index.ts b/src/commands/users/index.ts index 3fc142c..e35e677 100644 --- a/src/commands/users/index.ts +++ b/src/commands/users/index.ts @@ -319,7 +319,7 @@ const getCommand = new Command('get') const client = await getAPIClientFromOptions(globalOptions); const { show = [], exclude = [], showOnly } = globalOptions; - const result = await coreGetUser(client, { id }); + const result = await coreGetUser(client, { id, raw: true }); const user = result.data; const data = globalOptions.raw ? user diff --git a/src/commands/users/users.test.ts b/src/commands/users/users.test.ts index a43e279..a8a402f 100644 --- a/src/commands/users/users.test.ts +++ b/src/commands/users/users.test.ts @@ -262,6 +262,27 @@ describe('users command', () => { ); }); + it('requests raw data from coreGetUser so its own summarization sees all fields', async () => { + mockClient.getUser.mockResolvedValueOnce({ + id: 1, + email: 'a@b.com', + first_name: 'Jane', + last_name: 'Doe', + roles: [{ id: 1, permissions: ['x'] }], + avatar: { base_url: '/avatars/1' }, + }); + + await usersCommand.parseAsync(['node', 'test', 'get', '1']); + + // If coreGetUser weren't asked for raw:true, its own default summarization would + // strip first_name/last_name before this command re-derives `name` from them, + // collapsing the name to ''. + expect(printFormatted).toHaveBeenCalledWith( + expect.objectContaining({ id: 1, name: 'Jane Doe' }), + expect.anything() + ); + }); + it('should create a user', async () => { await usersCommand.parseAsync([ 'node', diff --git a/src/core/users/get.ts b/src/core/users/get.ts index 4bf2b0c..35f8e95 100644 --- a/src/core/users/get.ts +++ b/src/core/users/get.ts @@ -1,15 +1,32 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { UserId } from '../../lib/api/branded-types.js'; import type { CommandResult } from '../types.js'; +import { applyShowExclude, summarizeUserDetail } from '../../api-client/entity-summary.js'; export interface GetUserParams { id: UserId; + show?: string[] | undefined; + exclude?: string[] | undefined; + showOnly?: string[] | undefined; + raw?: boolean | undefined; } export async function getUser( client: APIClient, params: GetUserParams ): Promise> { - const data = await client.getUser(params.id); + const user = await client.getUser(params.id); + const show = params.show ?? []; + const exclude = params.exclude ?? []; + + const data = params.raw + ? user + : applyShowExclude( + summarizeUserDetail(user as Record), + user as Record, + show, + exclude, + params.showOnly + ); return { data }; } diff --git a/src/core/users/list.ts b/src/core/users/list.ts index 3d1e051..72571df 100644 --- a/src/core/users/list.ts +++ b/src/core/users/list.ts @@ -1,5 +1,6 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CommandResult } from '../types.js'; +import { summarizeUserRow } from '../../api-client/entity-summary.js'; export interface ListUsersParams { items?: number | undefined; @@ -34,6 +35,7 @@ export async function listUsers( const data = await client.listUsers(opts); return { data, + rows: (data as Array>).map(summarizeUserRow), pagination: { page: params.page ?? 1, items: params.items ?? 25, diff --git a/src/core/users/users.test.ts b/src/core/users/users.test.ts index 42c498d..3f50dec 100644 --- a/src/core/users/users.test.ts +++ b/src/core/users/users.test.ts @@ -85,7 +85,31 @@ describe('getUser', () => { const result = await getUser(mockClient, { id: 5 as any }); expect(mockClient.getUser).toHaveBeenCalledWith(5); - expect(result).toEqual({ data: user }); + expect(result.data).toMatchObject({ id: 5, email: 'a@b.com' }); + }); +}); + +describe('getUser summarization', () => { + const user = { + id: 5, + email: 'u@x.com', + first_name: 'U', + last_name: 'Ser', + archived: false, + roles: [{ id: 1, permissions: ['huge array'] }], + }; + + it('returns summarized data by default', async () => { + mockClient.getUser.mockResolvedValue(user); + const result = await getUser(mockClient, { id: 5 as any }); + expect(result.data).not.toHaveProperty('roles'); + expect(result.data).toMatchObject({ id: 5, email: 'u@x.com', name: 'U Ser' }); + }); + + it('returns raw data when raw=true', async () => { + mockClient.getUser.mockResolvedValue(user); + const result = await getUser(mockClient, { id: 5 as any, raw: true }); + expect(result.data).toEqual(user); }); }); @@ -146,6 +170,39 @@ describe('listUsers', () => { }); }); +describe('listUsers rows', () => { + it('should include summarized rows', async () => { + const users = [ + { id: 1, email: 'a@x.com', first_name: 'A', last_name: 'One', archived: false }, + { id: 2, email: 'b@x.com', first_name: 'B', last_name: 'Two', archived: true }, + ]; + mockClient.listUsers.mockResolvedValue(users); + + const result = await listUsers(mockClient, { items: 10, page: 1 }); + + expect(result.rows).toEqual([ + { + id: 1, + email: 'a@x.com', + name: 'A One', + department: '', + job_title: '', + archived: false, + last_login: '', + }, + { + id: 2, + email: 'b@x.com', + name: 'B Two', + department: '', + job_title: '', + archived: true, + last_login: '', + }, + ]); + }); +}); + describe('resetUserPassword', () => { it('should reset password and return new password', async () => { mockClient.resetUserPassword.mockResolvedValue({ password: 'newpass123' }); From bf5229730554d59a5e67572c08a4404e2da9d93c Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 13:32:07 +0100 Subject: [PATCH 04/35] fix(cli): summarize tags list response by default --- src/core/tags/tags.test.ts | 20 ++++++++++++++++++++ src/core/tags/tags.ts | 3 ++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/core/tags/tags.test.ts b/src/core/tags/tags.test.ts index 37eb276..9bea57e 100644 --- a/src/core/tags/tags.test.ts +++ b/src/core/tags/tags.test.ts @@ -50,4 +50,24 @@ describe('tags', () => { expect(mockClient.deleteExperimentTag).toHaveBeenCalledWith(1); expect(result.data).toEqual({ id: 1 }); }); + + describe('listTags rows', () => { + it('should include summarized rows', async () => { + const tags = [ + { + id: 1, + tag: 'mobile', + archived: false, + created_at: '2024-01-01T00:00:00Z', + created_by: { id: 9, first_name: 'A', last_name: 'B' }, + }, + ]; + mockClient.listExperimentTags.mockResolvedValue(tags); + + const result = await listTags(mockClient as any, { items: 10, page: 1 }); + + expect(result.rows).toBeDefined(); + expect(result.rows![0]).toMatchObject({ id: 1, tag: 'mobile', archived: false }); + }); + }); }); diff --git a/src/core/tags/tags.ts b/src/core/tags/tags.ts index 7a537f5..e14fe62 100644 --- a/src/core/tags/tags.ts +++ b/src/core/tags/tags.ts @@ -2,6 +2,7 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CommandResult } from '../types.js'; import type { TagId } from '../../lib/api/branded-types.js'; import { requireAtLeastOneField } from '../../lib/utils/validators.js'; +import { summarizeTagRow } from '../../api-client/entity-summary.js'; export interface ListTagsParams { items: number; @@ -13,7 +14,7 @@ export async function listTags( params: ListTagsParams ): Promise> { const data = await client.listExperimentTags({ items: params.items, page: params.page }); - return { data }; + return { data, rows: (data as Array>).map(summarizeTagRow) }; } export interface GetTagParams { From 55aefb3134368acc4562cb6f87ce9b89352f2110 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 13:39:39 +0100 Subject: [PATCH 05/35] fix(cli): summarize webhooks list/get responses by default --- src/api-client/entity-summary.test.ts | 28 +++++++++++++++++++++++ src/api-client/entity-summary.ts | 19 ++++++++++++++++ src/commands/webhooks/index.ts | 9 +++++++- src/core/webhooks/get.ts | 19 +++++++++++++++- src/core/webhooks/list.ts | 3 ++- src/core/webhooks/webhooks.test.ts | 32 ++++++++++++++++++++++----- 6 files changed, 101 insertions(+), 9 deletions(-) diff --git a/src/api-client/entity-summary.test.ts b/src/api-client/entity-summary.test.ts index 56c97c4..dbc2d35 100644 --- a/src/api-client/entity-summary.test.ts +++ b/src/api-client/entity-summary.test.ts @@ -15,6 +15,7 @@ import { summarizeMetricCategoryRow, summarizeNamedEntityRow, summarizeWebhookRow, + summarizeWebhook, } from './entity-summary.js'; describe('applyShowExclude', () => { @@ -370,6 +371,33 @@ describe('summarizeNamedEntityRow', () => { }); }); +describe('summarizeWebhook', () => { + it('should include detail fields', () => { + const result = summarizeWebhook({ + id: 3, + name: 'Slack notify', + url: 'https://hooks.example.com/abc', + enabled: true, + ordered: false, + max_retries: 2, + archived: false, + description: 'notifies slack', + events: ['experiment.created'], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-06-01T00:00:00Z', + secret: 'super-secret-should-be-dropped', + }); + expect(result).toMatchObject({ + id: 3, + name: 'Slack notify', + url: 'https://hooks.example.com/abc', + enabled: true, + description: 'notifies slack', + }); + expect(result).not.toHaveProperty('secret'); + }); +}); + describe('summarizeWebhookRow', () => { it('curates webhook-specific columns and summarizes user fields', () => { expect( diff --git a/src/api-client/entity-summary.ts b/src/api-client/entity-summary.ts index efaed93..dca1fb3 100644 --- a/src/api-client/entity-summary.ts +++ b/src/api-client/entity-summary.ts @@ -268,6 +268,25 @@ export function summarizeNamedEntityRow(e: Record): Record): Record { + const events = w.events as Array | undefined; + return { + id: w.id, + name: w.name ?? '', + url: w.url ?? '', + description: w.description ?? '', + enabled: w.enabled ?? false, + ordered: w.ordered ?? false, + max_retries: w.max_retries ?? 0, + events: events?.join(', ') ?? '', + archived: w.archived ?? false, + created_at: formatDate(w.created_at), + created_by: formatOwner(w.created_by as Record | undefined), + updated_at: formatDate(w.updated_at), + updated_by: formatOwner(w.updated_by as Record | undefined), + }; +} + export function summarizeWebhookRow(w: Record): Record { return { id: w.id, diff --git a/src/commands/webhooks/index.ts b/src/commands/webhooks/index.ts index 2381867..3605fe0 100644 --- a/src/commands/webhooks/index.ts +++ b/src/commands/webhooks/index.ts @@ -42,7 +42,14 @@ const getCommand = new Command('get') withErrorHandling(async (id: WebhookId) => { const globalOptions = getGlobalOptions(getCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await getWebhook(client, { id }); + const { show = [], exclude = [], showOnly } = globalOptions; + const result = await getWebhook(client, { + id, + show, + exclude, + showOnly, + raw: globalOptions.raw, + }); printFormatted(result.data, globalOptions); }) ); diff --git a/src/core/webhooks/get.ts b/src/core/webhooks/get.ts index 9802951..6972464 100644 --- a/src/core/webhooks/get.ts +++ b/src/core/webhooks/get.ts @@ -1,15 +1,32 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CommandResult } from '../types.js'; import type { WebhookId } from '../../lib/api/branded-types.js'; +import { applyShowExclude, summarizeWebhook } from '../../api-client/entity-summary.js'; export interface GetWebhookParams { id: WebhookId; + show?: string[] | undefined; + exclude?: string[] | undefined; + showOnly?: string[] | undefined; + raw?: boolean | undefined; } export async function getWebhook( client: APIClient, params: GetWebhookParams ): Promise> { - const data = await client.getWebhook(params.id); + const webhook = await client.getWebhook(params.id); + const show = params.show ?? []; + const exclude = params.exclude ?? []; + + const data = params.raw + ? webhook + : applyShowExclude( + summarizeWebhook(webhook as Record), + webhook as Record, + show, + exclude, + params.showOnly + ); return { data }; } diff --git a/src/core/webhooks/list.ts b/src/core/webhooks/list.ts index 1824daa..361815f 100644 --- a/src/core/webhooks/list.ts +++ b/src/core/webhooks/list.ts @@ -1,5 +1,6 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CommandResult } from '../types.js'; +import { summarizeWebhookRow } from '../../api-client/entity-summary.js'; export interface ListWebhooksParams { items: number; @@ -11,5 +12,5 @@ export async function listWebhooks( params: ListWebhooksParams ): Promise> { const data = await client.listWebhooks({ items: params.items, page: params.page }); - return { data }; + return { data, rows: (data as Array>).map(summarizeWebhookRow) }; } diff --git a/src/core/webhooks/webhooks.test.ts b/src/core/webhooks/webhooks.test.ts index 7dae7b3..e44f795 100644 --- a/src/core/webhooks/webhooks.test.ts +++ b/src/core/webhooks/webhooks.test.ts @@ -25,19 +25,39 @@ describe('listWebhooks', () => { const result = await listWebhooks(mockClient, { items: 10, page: 1 }); expect(mockClient.listWebhooks).toHaveBeenCalledWith({ items: 10, page: 1 }); - expect(result).toEqual({ data: hooks }); + expect(result.data).toEqual(hooks); + }); + + it('should include summarized rows', async () => { + const hooks = [{ id: 1, name: 'H1', url: 'https://a.com', enabled: true, archived: false }]; + mockClient.listWebhooks.mockResolvedValue(hooks); + + const result = await listWebhooks(mockClient, { items: 10, page: 1 }); + + expect(result.rows).toBeDefined(); + expect(result.rows![0]).toMatchObject({ + id: 1, + name: 'H1', + url: 'https://a.com', + enabled: true, + }); }); }); describe('getWebhook', () => { - it('should get webhook by id', async () => { - const hook = { id: 5, name: 'hook' }; - mockClient.getWebhook.mockResolvedValue(hook); + const hook = { id: 5, name: 'hook', url: 'https://x.com', enabled: true, secret: 'shh' }; + it('should return summarized data by default', async () => { + mockClient.getWebhook.mockResolvedValue(hook); const result = await getWebhook(mockClient, { id: 5 as any }); + expect(result.data).not.toHaveProperty('secret'); + expect(result.data).toMatchObject({ id: 5, name: 'hook', url: 'https://x.com' }); + }); - expect(mockClient.getWebhook).toHaveBeenCalledWith(5); - expect(result).toEqual({ data: hook }); + it('should return raw data when raw=true', async () => { + mockClient.getWebhook.mockResolvedValue(hook); + const result = await getWebhook(mockClient, { id: 5 as any, raw: true }); + expect(result.data).toEqual(hook); }); }); From 9182c997358bb2420b0205b4e5dbfe095b6e2d05 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 13:46:15 +0100 Subject: [PATCH 06/35] fix(cli): add bounded pagination to permissions list commands --- src/api-client/api-client.ts | 25 +++++++--- src/commands/permissions/index.ts | 62 +++++++++++++++++------- src/core/permissions/list.ts | 38 +++++++++++---- src/core/permissions/permissions.test.ts | 30 ++++++++++++ 4 files changed, 123 insertions(+), 32 deletions(-) diff --git a/src/api-client/api-client.ts b/src/api-client/api-client.ts index 625bada..5a39605 100644 --- a/src/api-client/api-client.ts +++ b/src/api-client/api-client.ts @@ -1402,13 +1402,21 @@ export class APIClient { await this.request('DELETE', `/roles/${id}`); } - async listPermissions(): Promise { - const response = await this.request('GET', '/permissions'); + async listPermissions(options: { items?: number; page?: number } = {}): Promise { + const params: Record = {}; + if (options.items !== undefined) params.items = String(options.items); + if (options.page !== undefined) params.page = String(options.page); + const response = await this.request('GET', '/permissions', { params }); return this.validateListResponse(response, 'permissions', 'listPermissions'); } - async listPermissionCategories(): Promise { - const response = await this.request('GET', '/permission_categories'); + async listPermissionCategories( + options: { items?: number; page?: number } = {} + ): Promise { + const params: Record = {}; + if (options.items !== undefined) params.items = String(options.items); + if (options.page !== undefined) params.page = String(options.page); + const response = await this.request('GET', '/permission_categories', { params }); return this.validateListResponse( response, 'permission_categories', @@ -1959,8 +1967,13 @@ export class APIClient { return this.validateListResponse(response, 'webhook_events', 'listWebhookEvents'); } - async listAccessControlPolicies(): Promise { - const response = await this.request('GET', '/access_control_policies'); + async listAccessControlPolicies( + options: { items?: number; page?: number } = {} + ): Promise { + const params: Record = {}; + if (options.items !== undefined) params.items = String(options.items); + if (options.page !== undefined) params.page = String(options.page); + const response = await this.request('GET', '/access_control_policies', { params }); return this.validateListResponse( response, 'access_control_policies', diff --git a/src/commands/permissions/index.ts b/src/commands/permissions/index.ts index f6b3ae4..6f49f47 100644 --- a/src/commands/permissions/index.ts +++ b/src/commands/permissions/index.ts @@ -5,6 +5,7 @@ import { printFormatted, withErrorHandling, } from '../../lib/utils/api-helper.js'; +import { addPaginationOptions, printPaginationFooter } from '../../lib/utils/pagination.js'; import { listPermissions, listPermissionCategories, @@ -15,33 +16,60 @@ export const permissionsCommand = new Command('permissions') .aliases(['permission', 'perms', 'perm']) .description('Permission commands'); -const listCommand = new Command('list').description('List all permissions').action( - withErrorHandling(async () => { +const listCommand = addPaginationOptions( + new Command('list').description('List all permissions') +).action( + withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await listPermissions(client); + const result = await listPermissions(client, { items: options.items, page: options.page }); printFormatted(result.data, globalOptions); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); }) ); -const categoriesCommand = new Command('categories') - .aliases(['cats', 'cat']) - .description('List permission categories') - .action( - withErrorHandling(async () => { - const globalOptions = getGlobalOptions(categoriesCommand); - const client = await getAPIClientFromOptions(globalOptions); - const result = await listPermissionCategories(client); - printFormatted(result.data, globalOptions); - }) - ); +const categoriesCommand = addPaginationOptions( + new Command('categories').aliases(['cats', 'cat']).description('List permission categories') +).action( + withErrorHandling(async (options) => { + const globalOptions = getGlobalOptions(categoriesCommand); + const client = await getAPIClientFromOptions(globalOptions); + const result = await listPermissionCategories(client, { + items: options.items, + page: options.page, + }); + printFormatted(result.data, globalOptions); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); + }) +); -const policiesCommand = new Command('policies').description('List access control policies').action( - withErrorHandling(async () => { +const policiesCommand = addPaginationOptions( + new Command('policies').description('List access control policies') +).action( + withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(policiesCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await listAccessControlPolicies(client); + const result = await listAccessControlPolicies(client, { + items: options.items, + page: options.page, + }); printFormatted(result.data, globalOptions); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); }) ); diff --git a/src/core/permissions/list.ts b/src/core/permissions/list.ts index 8cab042..f57851d 100644 --- a/src/core/permissions/list.ts +++ b/src/core/permissions/list.ts @@ -1,19 +1,39 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CommandResult } from '../types.js'; -export async function listPermissions(client: APIClient): Promise> { - const data = await client.listPermissions(); - return { data }; +const DEFAULT_PERMISSIONS_PAGE_SIZE = 20; + +export interface ListPermissionsParams { + items?: number | undefined; + page?: number | undefined; } -export async function listPermissionCategories(client: APIClient): Promise> { - const data = await client.listPermissionCategories(); - return { data }; +export async function listPermissions( + client: APIClient, + params: ListPermissionsParams = {} +): Promise> { + const items = params.items ?? DEFAULT_PERMISSIONS_PAGE_SIZE; + const page = params.page ?? 1; + const data = await client.listPermissions({ items, page }); + return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; +} + +export async function listPermissionCategories( + client: APIClient, + params: ListPermissionsParams = {} +): Promise> { + const items = params.items ?? DEFAULT_PERMISSIONS_PAGE_SIZE; + const page = params.page ?? 1; + const data = await client.listPermissionCategories({ items, page }); + return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; } export async function listAccessControlPolicies( - client: APIClient + client: APIClient, + params: ListPermissionsParams = {} ): Promise> { - const data = await client.listAccessControlPolicies(); - return { data }; + const items = params.items ?? DEFAULT_PERMISSIONS_PAGE_SIZE; + const page = params.page ?? 1; + const data = await client.listAccessControlPolicies({ items, page }); + return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; } diff --git a/src/core/permissions/permissions.test.ts b/src/core/permissions/permissions.test.ts index a5f20fc..aea7a39 100644 --- a/src/core/permissions/permissions.test.ts +++ b/src/core/permissions/permissions.test.ts @@ -29,3 +29,33 @@ describe('permissions', () => { expect(result.data).toEqual([{ id: 1, name: 'default' }]); }); }); + +describe('listPermissions pagination', () => { + const mockClient = { listPermissions: vi.fn() }; + + it('should pass items/page to the client', async () => { + mockClient.listPermissions.mockResolvedValue([]); + await listPermissions(mockClient as any, { items: 25, page: 2 }); + expect(mockClient.listPermissions).toHaveBeenCalledWith({ items: 25, page: 2 }); + }); +}); + +describe('listPermissionCategories pagination', () => { + const mockClient = { listPermissionCategories: vi.fn() }; + + it('should pass items/page to the client', async () => { + mockClient.listPermissionCategories.mockResolvedValue([]); + await listPermissionCategories(mockClient as any, { items: 25, page: 2 }); + expect(mockClient.listPermissionCategories).toHaveBeenCalledWith({ items: 25, page: 2 }); + }); +}); + +describe('listAccessControlPolicies pagination', () => { + const mockClient = { listAccessControlPolicies: vi.fn() }; + + it('should pass items/page to the client', async () => { + mockClient.listAccessControlPolicies.mockResolvedValue([]); + await listAccessControlPolicies(mockClient as any, { items: 25, page: 2 }); + expect(mockClient.listAccessControlPolicies).toHaveBeenCalledWith({ items: 25, page: 2 }); + }); +}); From 979632186f1252867d518fe421bfd0b24733e87d Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 13:53:37 +0100 Subject: [PATCH 07/35] test(cli): cover permissions CLI pagination flags Adds command-layer coverage for --items/--page on permissions list/categories/policies, matching the precedent in users/api-keys.test.ts. Review flagged this as missing. --- src/commands/permissions/permissions.test.ts | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/commands/permissions/permissions.test.ts b/src/commands/permissions/permissions.test.ts index d42a5eb..8148ab6 100644 --- a/src/commands/permissions/permissions.test.ts +++ b/src/commands/permissions/permissions.test.ts @@ -70,4 +70,38 @@ describe('permissions command', () => { expect(mockClient.listAccessControlPolicies).toHaveBeenCalled(); expect(printFormatted).toHaveBeenCalled(); }); + + it('should list permissions with pagination', async () => { + await permissionsCommand.parseAsync(['node', 'test', 'list', '--items', '10', '--page', '2']); + + expect(mockClient.listPermissions).toHaveBeenCalledWith({ items: 10, page: 2 }); + }); + + it('should list permission categories with pagination', async () => { + await permissionsCommand.parseAsync([ + 'node', + 'test', + 'categories', + '--items', + '10', + '--page', + '2', + ]); + + expect(mockClient.listPermissionCategories).toHaveBeenCalledWith({ items: 10, page: 2 }); + }); + + it('should list access control policies with pagination', async () => { + await permissionsCommand.parseAsync([ + 'node', + 'test', + 'policies', + '--items', + '10', + '--page', + '2', + ]); + + expect(mockClient.listAccessControlPolicies).toHaveBeenCalledWith({ items: 10, page: 2 }); + }); }); From c8610b6707e070167e514043e9c4e36986123f9c Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 13:59:33 +0100 Subject: [PATCH 08/35] fix(cli): add bounded pagination to listAssetRoles --- src/api-client/api-client.ts | 9 +++++++-- src/core/assetroles/assetroles.test.ts | 16 ++++++++++++---- src/core/assetroles/assetroles.ts | 18 +++++++++++++++--- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/api-client/api-client.ts b/src/api-client/api-client.ts index 5a39605..ce1a4a4 100644 --- a/src/api-client/api-client.ts +++ b/src/api-client/api-client.ts @@ -1764,8 +1764,13 @@ export class APIClient { await this.request('DELETE', `/goals/${id}/asset_role_teams/${teamId}/${assetRoleId}`); } - async listAssetRoles(): Promise { - const response = await this.request('GET', '/asset_roles'); + async listAssetRoles( + options: { items?: number; page?: number } = {} + ): Promise { + const params: Record = {}; + if (options.items !== undefined) params.items = String(options.items); + if (options.page !== undefined) params.page = String(options.page); + const response = await this.request('GET', '/asset_roles', { params }); return this.validateListResponse(response, 'asset_roles', 'listAssetRoles'); } diff --git a/src/core/assetroles/assetroles.test.ts b/src/core/assetroles/assetroles.test.ts index 86566bf..0b2b882 100644 --- a/src/core/assetroles/assetroles.test.ts +++ b/src/core/assetroles/assetroles.test.ts @@ -18,14 +18,22 @@ const mockClient = { beforeEach(() => vi.clearAllMocks()); describe('listAssetRoles', () => { - it('should call client.listAssetRoles and return data', async () => { + it('should default items/page and forward to client', async () => { const mockData = [{ id: 1 }, { id: 2 }]; mockClient.listAssetRoles.mockResolvedValue(mockData); - const result = await listAssetRoles(mockClient); + const result = await listAssetRoles(mockClient, {}); - expect(mockClient.listAssetRoles).toHaveBeenCalledOnce(); - expect(result).toEqual({ data: mockData }); + expect(mockClient.listAssetRoles).toHaveBeenCalledWith({ items: 20, page: 1 }); + expect(result.data).toEqual(mockData); + }); + + it('should forward explicit items/page', async () => { + mockClient.listAssetRoles.mockResolvedValue([]); + + await listAssetRoles(mockClient, { items: 5, page: 3 }); + + expect(mockClient.listAssetRoles).toHaveBeenCalledWith({ items: 5, page: 3 }); }); }); diff --git a/src/core/assetroles/assetroles.ts b/src/core/assetroles/assetroles.ts index e875dbf..e5b4d97 100644 --- a/src/core/assetroles/assetroles.ts +++ b/src/core/assetroles/assetroles.ts @@ -3,9 +3,21 @@ import type { AssetRoleId } from '../../lib/api/branded-types.js'; import type { CommandResult } from '../types.js'; import { requireAtLeastOneField } from '../../lib/utils/validators.js'; -export async function listAssetRoles(client: APIClient): Promise> { - const data = await client.listAssetRoles(); - return { data }; +const DEFAULT_LIST_PAGE_SIZE = 20; + +export interface ListAssetRolesParams { + items?: number | undefined; + page?: number | undefined; +} + +export async function listAssetRoles( + client: APIClient, + params: ListAssetRolesParams = {} +): Promise> { + const items = params.items ?? DEFAULT_LIST_PAGE_SIZE; + const page = params.page ?? 1; + const data = await client.listAssetRoles({ items, page }); + return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; } export interface GetAssetRoleParams { From 433634276285ae8d351be9371949d30ff8d22e4a Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 13:59:53 +0100 Subject: [PATCH 09/35] fix(cli): add bounded pagination to listCorsOrigins --- src/api-client/api-client.ts | 7 +++++-- src/core/cors/cors.test.ts | 12 +++++++++--- src/core/cors/cors.ts | 18 +++++++++++++++--- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/api-client/api-client.ts b/src/api-client/api-client.ts index ce1a4a4..4b375ca 100644 --- a/src/api-client/api-client.ts +++ b/src/api-client/api-client.ts @@ -2001,8 +2001,11 @@ export class APIClient { return this.validateEntityResponse(response, 'config', 'updatePlatformConfig'); } - async listCorsOrigins(): Promise { - const response = await this.request('GET', '/cors'); + async listCorsOrigins(options: { items?: number; page?: number } = {}): Promise { + const params: Record = {}; + if (options.items !== undefined) params.items = String(options.items); + if (options.page !== undefined) params.page = String(options.page); + const response = await this.request('GET', '/cors', { params }); return this.validateListResponse(response, 'cors_allowed_origins', 'listCorsOrigins'); } diff --git a/src/core/cors/cors.test.ts b/src/core/cors/cors.test.ts index 11a992b..ec2738b 100644 --- a/src/core/cors/cors.test.ts +++ b/src/core/cors/cors.test.ts @@ -16,13 +16,19 @@ describe('cors', () => { deleteCorsOrigin: vi.fn(), }; - it('should list cors origins', async () => { + it('should default items/page and forward to client', async () => { mockClient.listCorsOrigins.mockResolvedValue([{ id: 1 }]); - const result = await listCorsOrigins(mockClient as any); - expect(mockClient.listCorsOrigins).toHaveBeenCalled(); + const result = await listCorsOrigins(mockClient as any, {}); + expect(mockClient.listCorsOrigins).toHaveBeenCalledWith({ items: 20, page: 1 }); expect(result.data).toEqual([{ id: 1 }]); }); + it('should forward explicit items/page', async () => { + mockClient.listCorsOrigins.mockResolvedValue([]); + await listCorsOrigins(mockClient as any, { items: 5, page: 3 }); + expect(mockClient.listCorsOrigins).toHaveBeenCalledWith({ items: 5, page: 3 }); + }); + it('should get cors origin by id', async () => { mockClient.getCorsOrigin.mockResolvedValue({ id: 1, origin: 'http://example.com' }); const result = await getCorsOrigin(mockClient as any, { id: 1 as any }); diff --git a/src/core/cors/cors.ts b/src/core/cors/cors.ts index 9506666..5e482cd 100644 --- a/src/core/cors/cors.ts +++ b/src/core/cors/cors.ts @@ -2,9 +2,21 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CorsOriginId } from '../../lib/api/branded-types.js'; import type { CommandResult } from '../types.js'; -export async function listCorsOrigins(client: APIClient): Promise> { - const data = await client.listCorsOrigins(); - return { data }; +const DEFAULT_LIST_PAGE_SIZE = 20; + +export interface ListCorsOriginsParams { + items?: number | undefined; + page?: number | undefined; +} + +export async function listCorsOrigins( + client: APIClient, + params: ListCorsOriginsParams = {} +): Promise> { + const items = params.items ?? DEFAULT_LIST_PAGE_SIZE; + const page = params.page ?? 1; + const data = await client.listCorsOrigins({ items, page }); + return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; } export interface GetCorsOriginParams { From 5bbb56abccf51250537e6416731c887d437e06ca Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 14:00:20 +0100 Subject: [PATCH 10/35] fix(cli): add bounded pagination to listDatasources --- src/api-client/api-client.ts | 7 +++++-- src/core/datasources/datasources.test.ts | 12 +++++++++--- src/core/datasources/datasources.ts | 18 +++++++++++++++--- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/api-client/api-client.ts b/src/api-client/api-client.ts index 4b375ca..6b03663 100644 --- a/src/api-client/api-client.ts +++ b/src/api-client/api-client.ts @@ -2037,8 +2037,11 @@ export class APIClient { this.validateOkResponse(response, 'deleteCorsOrigin'); } - async listDatasources(): Promise { - const response = await this.request('GET', '/datasources'); + async listDatasources(options: { items?: number; page?: number } = {}): Promise { + const params: Record = {}; + if (options.items !== undefined) params.items = String(options.items); + if (options.page !== undefined) params.page = String(options.page); + const response = await this.request('GET', '/datasources', { params }); return this.validateListResponse( response, 'event_datasource_configs', diff --git a/src/core/datasources/datasources.test.ts b/src/core/datasources/datasources.test.ts index ca79853..4269adb 100644 --- a/src/core/datasources/datasources.test.ts +++ b/src/core/datasources/datasources.test.ts @@ -36,13 +36,19 @@ describe('datasources', () => { previewDatasourceJsonLayouts: vi.fn(), }; - it('should list datasources', async () => { + it('should default items/page and forward to client', async () => { mockClient.listDatasources.mockResolvedValue([{ id: 1 }]); - const result = await listDatasources(mockClient as any); - expect(mockClient.listDatasources).toHaveBeenCalled(); + const result = await listDatasources(mockClient as any, {}); + expect(mockClient.listDatasources).toHaveBeenCalledWith({ items: 20, page: 1 }); expect(result.data).toEqual([{ id: 1 }]); }); + it('should forward explicit items/page', async () => { + mockClient.listDatasources.mockResolvedValue([]); + await listDatasources(mockClient as any, { items: 5, page: 3 }); + expect(mockClient.listDatasources).toHaveBeenCalledWith({ items: 5, page: 3 }); + }); + it('should get datasource by id', async () => { mockClient.getDatasource.mockResolvedValue({ id: 1, name: 'test' }); const result = await getDatasource(mockClient as any, { id: 1 as any }); diff --git a/src/core/datasources/datasources.ts b/src/core/datasources/datasources.ts index 4829231..156a9b3 100644 --- a/src/core/datasources/datasources.ts +++ b/src/core/datasources/datasources.ts @@ -2,9 +2,21 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { DatasourceId } from '../../lib/api/branded-types.js'; import type { CommandResult } from '../types.js'; -export async function listDatasources(client: APIClient): Promise> { - const data = await client.listDatasources(); - return { data }; +const DEFAULT_LIST_PAGE_SIZE = 20; + +export interface ListDatasourcesParams { + items?: number | undefined; + page?: number | undefined; +} + +export async function listDatasources( + client: APIClient, + params: ListDatasourcesParams = {} +): Promise> { + const items = params.items ?? DEFAULT_LIST_PAGE_SIZE; + const page = params.page ?? 1; + const data = await client.listDatasources({ items, page }); + return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; } export interface GetDatasourceParams { From c585234572c2dbab2eb36d4e01fcdca0d3a25c5d Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 14:01:09 +0100 Subject: [PATCH 11/35] fix(cli): add bounded pagination to listExportConfigs --- src/api-client/api-client.ts | 12 ++++++++++-- src/core/exportconfigs/exportconfigs.test.ts | 20 +++++++++++++++++--- src/core/exportconfigs/exportconfigs.ts | 19 ++++++++++++++++--- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/api-client/api-client.ts b/src/api-client/api-client.ts index 6b03663..62d4dcf 100644 --- a/src/api-client/api-client.ts +++ b/src/api-client/api-client.ts @@ -2095,9 +2095,17 @@ export class APIClient { this.validateOkResponse(response, 'validateDatasourceQuery'); } - async listExportConfigs(params?: { statuses?: string }): Promise { + async listExportConfigs(params?: { + statuses?: string | undefined; + items?: number | undefined; + page?: number | undefined; + }): Promise { + const queryParams: Record = {}; + if (params?.statuses !== undefined) queryParams.statuses = params.statuses; + if (params?.items !== undefined) queryParams.items = String(params.items); + if (params?.page !== undefined) queryParams.page = String(params.page); const response = await this.request('GET', '/export_configs', { - params: params as Record, + params: queryParams, }); return this.validateListResponse( response, diff --git a/src/core/exportconfigs/exportconfigs.test.ts b/src/core/exportconfigs/exportconfigs.test.ts index a8d997d..62fbd83 100644 --- a/src/core/exportconfigs/exportconfigs.test.ts +++ b/src/core/exportconfigs/exportconfigs.test.ts @@ -22,13 +22,27 @@ describe('exportconfigs', () => { cancelExportHistory: vi.fn(), }; - it('should list export configs', async () => { + it('should default items/page and forward to client', async () => { mockClient.listExportConfigs.mockResolvedValue([{ id: 1 }]); - const result = await listExportConfigs(mockClient as any); - expect(mockClient.listExportConfigs).toHaveBeenCalled(); + const result = await listExportConfigs(mockClient as any, {}); + expect(mockClient.listExportConfigs).toHaveBeenCalledWith({ + statuses: undefined, + items: 20, + page: 1, + }); expect(result.data).toEqual([{ id: 1 }]); }); + it('should forward explicit statuses/items/page', async () => { + mockClient.listExportConfigs.mockResolvedValue([]); + await listExportConfigs(mockClient as any, { statuses: 'COMPLETED', items: 5, page: 3 }); + expect(mockClient.listExportConfigs).toHaveBeenCalledWith({ + statuses: 'COMPLETED', + items: 5, + page: 3, + }); + }); + it('should get export config by id', async () => { mockClient.getExportConfig.mockResolvedValue({ id: 1, name: 'test' }); const result = await getExportConfig(mockClient as any, { id: 1 as any }); diff --git a/src/core/exportconfigs/exportconfigs.ts b/src/core/exportconfigs/exportconfigs.ts index 4a9ac17..8ed827e 100644 --- a/src/core/exportconfigs/exportconfigs.ts +++ b/src/core/exportconfigs/exportconfigs.ts @@ -2,9 +2,22 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { ExportConfigId } from '../../lib/api/branded-types.js'; import type { CommandResult } from '../types.js'; -export async function listExportConfigs(client: APIClient): Promise> { - const data = await client.listExportConfigs(); - return { data }; +const DEFAULT_LIST_PAGE_SIZE = 20; + +export interface ListExportConfigsParams { + statuses?: string | undefined; + items?: number | undefined; + page?: number | undefined; +} + +export async function listExportConfigs( + client: APIClient, + params: ListExportConfigsParams = {} +): Promise> { + const items = params.items ?? DEFAULT_LIST_PAGE_SIZE; + const page = params.page ?? 1; + const data = await client.listExportConfigs({ statuses: params.statuses, items, page }); + return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; } export interface GetExportConfigParams { From 6be8dc1c4e54a48a399e2719f353fae9cca3e359 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 14:01:32 +0100 Subject: [PATCH 12/35] fix(cli): add bounded pagination to listUpdateSchedules --- src/api-client/api-client.ts | 7 +++++-- .../updateschedules/updateschedules.test.ts | 12 +++++++++--- src/core/updateschedules/updateschedules.ts | 18 +++++++++++++++--- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/api-client/api-client.ts b/src/api-client/api-client.ts index 62d4dcf..d4d4cd7 100644 --- a/src/api-client/api-client.ts +++ b/src/api-client/api-client.ts @@ -2151,8 +2151,11 @@ export class APIClient { ); } - async listUpdateSchedules(): Promise { - const response = await this.request('GET', '/experiment_update_schedules'); + async listUpdateSchedules(options: { items?: number; page?: number } = {}): Promise { + const params: Record = {}; + if (options.items !== undefined) params.items = String(options.items); + if (options.page !== undefined) params.page = String(options.page); + const response = await this.request('GET', '/experiment_update_schedules', { params }); return this.validateListResponse( response, 'experiment_update_schedules', diff --git a/src/core/updateschedules/updateschedules.test.ts b/src/core/updateschedules/updateschedules.test.ts index 7fba169..c0321a9 100644 --- a/src/core/updateschedules/updateschedules.test.ts +++ b/src/core/updateschedules/updateschedules.test.ts @@ -16,13 +16,19 @@ describe('updateschedules', () => { deleteUpdateSchedule: vi.fn(), }; - it('should list update schedules', async () => { + it('should default items/page and forward to client', async () => { mockClient.listUpdateSchedules.mockResolvedValue([{ id: 1 }]); - const result = await listUpdateSchedules(mockClient as any); - expect(mockClient.listUpdateSchedules).toHaveBeenCalled(); + const result = await listUpdateSchedules(mockClient as any, {}); + expect(mockClient.listUpdateSchedules).toHaveBeenCalledWith({ items: 20, page: 1 }); expect(result.data).toEqual([{ id: 1 }]); }); + it('should forward explicit items/page', async () => { + mockClient.listUpdateSchedules.mockResolvedValue([]); + await listUpdateSchedules(mockClient as any, { items: 5, page: 3 }); + expect(mockClient.listUpdateSchedules).toHaveBeenCalledWith({ items: 5, page: 3 }); + }); + it('should get update schedule by id', async () => { mockClient.getUpdateSchedule.mockResolvedValue({ id: 1, cron: '* * * * *' }); const result = await getUpdateSchedule(mockClient as any, { id: 1 as any }); diff --git a/src/core/updateschedules/updateschedules.ts b/src/core/updateschedules/updateschedules.ts index f30582f..dea2d8e 100644 --- a/src/core/updateschedules/updateschedules.ts +++ b/src/core/updateschedules/updateschedules.ts @@ -2,9 +2,21 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { UpdateScheduleId } from '../../lib/api/branded-types.js'; import type { CommandResult } from '../types.js'; -export async function listUpdateSchedules(client: APIClient): Promise> { - const data = await client.listUpdateSchedules(); - return { data }; +const DEFAULT_LIST_PAGE_SIZE = 20; + +export interface ListUpdateSchedulesParams { + items?: number | undefined; + page?: number | undefined; +} + +export async function listUpdateSchedules( + client: APIClient, + params: ListUpdateSchedulesParams = {} +): Promise> { + const items = params.items ?? DEFAULT_LIST_PAGE_SIZE; + const page = params.page ?? 1; + const data = await client.listUpdateSchedules({ items, page }); + return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; } export interface GetUpdateScheduleParams { From 93d907a7e0bfe897bae0127c5e92b52266f22eb2 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 14:02:04 +0100 Subject: [PATCH 13/35] fix(cli): add bounded pagination to listCustomSections --- src/api-client/api-client.ts | 18 +++++++++++++---- .../customsections/customsections.test.ts | 20 ++++++++++++++++--- src/core/customsections/customsections.ts | 19 +++++++++++++++--- 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/api-client/api-client.ts b/src/api-client/api-client.ts index d4d4cd7..7e8bf0f 100644 --- a/src/api-client/api-client.ts +++ b/src/api-client/api-client.ts @@ -1025,10 +1025,20 @@ export class APIClient { }); } - async listCustomSections(type?: string): Promise { - const params: Record = {}; - if (type) params.type = type; - const response = await this.request('GET', '/experiment_custom_sections', { params }); + async listCustomSections( + params: { + type?: string | undefined; + items?: number | undefined; + page?: number | undefined; + } = {} + ): Promise { + const queryParams: Record = {}; + if (params.type) queryParams.type = params.type; + if (params.items !== undefined) queryParams.items = String(params.items); + if (params.page !== undefined) queryParams.page = String(params.page); + const response = await this.request('GET', '/experiment_custom_sections', { + params: queryParams, + }); return this.validateListResponse( response, 'experiment_custom_sections', diff --git a/src/core/customsections/customsections.test.ts b/src/core/customsections/customsections.test.ts index f7d6af7..46fde7a 100644 --- a/src/core/customsections/customsections.test.ts +++ b/src/core/customsections/customsections.test.ts @@ -16,13 +16,27 @@ describe('customsections', () => { reorderCustomSections: vi.fn(), }; - it('should list custom sections', async () => { + it('should default items/page and forward to client', async () => { mockClient.listCustomSections.mockResolvedValue([{ id: 1 }]); - const result = await listCustomSections(mockClient as any); - expect(mockClient.listCustomSections).toHaveBeenCalled(); + const result = await listCustomSections(mockClient as any, {}); + expect(mockClient.listCustomSections).toHaveBeenCalledWith({ + type: undefined, + items: 20, + page: 1, + }); expect(result.data).toEqual([{ id: 1 }]); }); + it('should forward explicit type/items/page', async () => { + mockClient.listCustomSections.mockResolvedValue([]); + await listCustomSections(mockClient as any, { type: 'text', items: 5, page: 3 }); + expect(mockClient.listCustomSections).toHaveBeenCalledWith({ + type: 'text', + items: 5, + page: 3, + }); + }); + it('should create custom section', async () => { mockClient.createCustomSection.mockResolvedValue({ id: 2, name: 'new', type: 'text' }); const result = await createCustomSection(mockClient as any, { name: 'new', type: 'text' }); diff --git a/src/core/customsections/customsections.ts b/src/core/customsections/customsections.ts index 6c69e58..5444ed0 100644 --- a/src/core/customsections/customsections.ts +++ b/src/core/customsections/customsections.ts @@ -3,9 +3,22 @@ import type { CustomSectionId } from '../../lib/api/branded-types.js'; import type { CommandResult } from '../types.js'; import { requireAtLeastOneField } from '../../lib/utils/validators.js'; -export async function listCustomSections(client: APIClient): Promise> { - const data = await client.listCustomSections(); - return { data }; +const DEFAULT_LIST_PAGE_SIZE = 20; + +export interface ListCustomSectionsParams { + type?: string | undefined; + items?: number | undefined; + page?: number | undefined; +} + +export async function listCustomSections( + client: APIClient, + params: ListCustomSectionsParams = {} +): Promise> { + const items = params.items ?? DEFAULT_LIST_PAGE_SIZE; + const page = params.page ?? 1; + const data = await client.listCustomSections({ type: params.type, items, page }); + return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; } export interface CreateCustomSectionParams { From 3009bf2d76e6f596ebc08d57631c4e8590b432af Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 14:02:25 +0100 Subject: [PATCH 14/35] fix(cli): add bounded pagination to listStorageConfigs --- src/api-client/api-client.ts | 7 +++++-- src/core/storageconfigs/storageconfigs.test.ts | 12 +++++++++--- src/core/storageconfigs/storageconfigs.ts | 18 +++++++++++++++--- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/api-client/api-client.ts b/src/api-client/api-client.ts index 7e8bf0f..218c569 100644 --- a/src/api-client/api-client.ts +++ b/src/api-client/api-client.ts @@ -2463,8 +2463,11 @@ export class APIClient { return response.data as { matrix: number[][] }; } - async listStorageConfigs(): Promise { - const response = await this.request('GET', '/storage_configs'); + async listStorageConfigs(options: { items?: number; page?: number } = {}): Promise { + const params: Record = {}; + if (options.items !== undefined) params.items = String(options.items); + if (options.page !== undefined) params.page = String(options.page); + const response = await this.request('GET', '/storage_configs', { params }); return this.validateListResponse(response, 'storage_configs', 'listStorageConfigs'); } diff --git a/src/core/storageconfigs/storageconfigs.test.ts b/src/core/storageconfigs/storageconfigs.test.ts index 61527be..7951a4f 100644 --- a/src/core/storageconfigs/storageconfigs.test.ts +++ b/src/core/storageconfigs/storageconfigs.test.ts @@ -16,13 +16,19 @@ describe('storageconfigs', () => { testStorageConfig: vi.fn(), }; - it('should list storage configs', async () => { + it('should default items/page and forward to client', async () => { mockClient.listStorageConfigs.mockResolvedValue([{ id: 1 }]); - const result = await listStorageConfigs(mockClient as any); - expect(mockClient.listStorageConfigs).toHaveBeenCalled(); + const result = await listStorageConfigs(mockClient as any, {}); + expect(mockClient.listStorageConfigs).toHaveBeenCalledWith({ items: 20, page: 1 }); expect(result.data).toEqual([{ id: 1 }]); }); + it('should forward explicit items/page', async () => { + mockClient.listStorageConfigs.mockResolvedValue([]); + await listStorageConfigs(mockClient as any, { items: 5, page: 3 }); + expect(mockClient.listStorageConfigs).toHaveBeenCalledWith({ items: 5, page: 3 }); + }); + it('should get storage config by id', async () => { mockClient.getStorageConfig.mockResolvedValue({ id: 1, type: 's3' }); const result = await getStorageConfig(mockClient as any, { id: 1 }); diff --git a/src/core/storageconfigs/storageconfigs.ts b/src/core/storageconfigs/storageconfigs.ts index c507c45..9c6fc2a 100644 --- a/src/core/storageconfigs/storageconfigs.ts +++ b/src/core/storageconfigs/storageconfigs.ts @@ -1,9 +1,21 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CommandResult } from '../types.js'; -export async function listStorageConfigs(client: APIClient): Promise> { - const data = await client.listStorageConfigs(); - return { data }; +const DEFAULT_LIST_PAGE_SIZE = 20; + +export interface ListStorageConfigsParams { + items?: number | undefined; + page?: number | undefined; +} + +export async function listStorageConfigs( + client: APIClient, + params: ListStorageConfigsParams = {} +): Promise> { + const items = params.items ?? DEFAULT_LIST_PAGE_SIZE; + const page = params.page ?? 1; + const data = await client.listStorageConfigs({ items, page }); + return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; } export interface GetStorageConfigParams { From 424dfe783575d328213683f50616e69b772b7f0f Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 14:02:50 +0100 Subject: [PATCH 15/35] fix(cli): add bounded pagination to listActionDialogFields --- src/api-client/api-client.ts | 9 ++++++-- .../actiondialogfields.test.ts | 22 +++++++++++++++---- .../actiondialogfields/actiondialogfields.ts | 18 ++++++++++++--- 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/api-client/api-client.ts b/src/api-client/api-client.ts index 218c569..8d77422 100644 --- a/src/api-client/api-client.ts +++ b/src/api-client/api-client.ts @@ -2579,8 +2579,13 @@ export class APIClient { return this.validateEntityResponse(response, 'user', 'updateCurrentUser'); } - async listExperimentActionDialogFields(): Promise { - const response = await this.request('GET', '/experiment_action_dialog_fields'); + async listExperimentActionDialogFields( + options: { items?: number; page?: number } = {} + ): Promise { + const params: Record = {}; + if (options.items !== undefined) params.items = String(options.items); + if (options.page !== undefined) params.page = String(options.page); + const response = await this.request('GET', '/experiment_action_dialog_fields', { params }); return this.validateListResponse( response, 'experiment_action_dialog_fields', diff --git a/src/core/actiondialogfields/actiondialogfields.test.ts b/src/core/actiondialogfields/actiondialogfields.test.ts index f66c7b1..6bcec24 100644 --- a/src/core/actiondialogfields/actiondialogfields.test.ts +++ b/src/core/actiondialogfields/actiondialogfields.test.ts @@ -16,14 +16,28 @@ const mockClient = { beforeEach(() => vi.clearAllMocks()); describe('listActionDialogFields', () => { - it('should call client.listExperimentActionDialogFields and return data', async () => { + it('should default items/page and forward to client', async () => { const mockData = [{ id: 1 }, { id: 2 }]; mockClient.listExperimentActionDialogFields.mockResolvedValue(mockData); - const result = await listActionDialogFields(mockClient); + const result = await listActionDialogFields(mockClient, {}); - expect(mockClient.listExperimentActionDialogFields).toHaveBeenCalledOnce(); - expect(result).toEqual({ data: mockData }); + expect(mockClient.listExperimentActionDialogFields).toHaveBeenCalledWith({ + items: 20, + page: 1, + }); + expect(result.data).toEqual(mockData); + }); + + it('should forward explicit items/page', async () => { + mockClient.listExperimentActionDialogFields.mockResolvedValue([]); + + await listActionDialogFields(mockClient, { items: 5, page: 3 }); + + expect(mockClient.listExperimentActionDialogFields).toHaveBeenCalledWith({ + items: 5, + page: 3, + }); }); }); diff --git a/src/core/actiondialogfields/actiondialogfields.ts b/src/core/actiondialogfields/actiondialogfields.ts index 0e95137..5088e8a 100644 --- a/src/core/actiondialogfields/actiondialogfields.ts +++ b/src/core/actiondialogfields/actiondialogfields.ts @@ -1,9 +1,21 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CommandResult } from '../types.js'; -export async function listActionDialogFields(client: APIClient): Promise> { - const data = await client.listExperimentActionDialogFields(); - return { data }; +const DEFAULT_LIST_PAGE_SIZE = 20; + +export interface ListActionDialogFieldsParams { + items?: number | undefined; + page?: number | undefined; +} + +export async function listActionDialogFields( + client: APIClient, + params: ListActionDialogFieldsParams = {} +): Promise> { + const items = params.items ?? DEFAULT_LIST_PAGE_SIZE; + const page = params.page ?? 1; + const data = await client.listExperimentActionDialogFields({ items, page }); + return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; } export interface GetActionDialogFieldParams { From e8c5ad5a4ff1680d3433c3db92247aa7f8d06eb2 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 14:07:34 +0100 Subject: [PATCH 16/35] fix(cli): wire pagination flags for asset-roles list command --- src/api-client/api-client.ts | 4 +--- src/commands/assetroles/assetroles.test.ts | 8 +++++++- src/commands/assetroles/index.ts | 15 ++++++++++++--- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/api-client/api-client.ts b/src/api-client/api-client.ts index 8d77422..7d3671b 100644 --- a/src/api-client/api-client.ts +++ b/src/api-client/api-client.ts @@ -1774,9 +1774,7 @@ export class APIClient { await this.request('DELETE', `/goals/${id}/asset_role_teams/${teamId}/${assetRoleId}`); } - async listAssetRoles( - options: { items?: number; page?: number } = {} - ): Promise { + async listAssetRoles(options: { items?: number; page?: number } = {}): Promise { const params: Record = {}; if (options.items !== undefined) params.items = String(options.items); if (options.page !== undefined) params.page = String(options.page); diff --git a/src/commands/assetroles/assetroles.test.ts b/src/commands/assetroles/assetroles.test.ts index 5543d7e..4a29ea0 100644 --- a/src/commands/assetroles/assetroles.test.ts +++ b/src/commands/assetroles/assetroles.test.ts @@ -52,10 +52,16 @@ describe('asset-roles command', () => { it('should list asset roles', async () => { mockClient.listAssetRoles.mockResolvedValue([{ id: 1, name: 'editor' }]); await assetRolesCommand.parseAsync(['node', 'test', 'list']); - expect(mockClient.listAssetRoles).toHaveBeenCalled(); + expect(mockClient.listAssetRoles).toHaveBeenCalledWith({ items: 20, page: 1 }); expect(printFormatted).toHaveBeenCalledWith([{ id: 1, name: 'editor' }], expect.anything()); }); + it('should list asset roles with pagination', async () => { + mockClient.listAssetRoles.mockResolvedValue([]); + await assetRolesCommand.parseAsync(['node', 'test', 'list', '--items', '10', '--page', '2']); + expect(mockClient.listAssetRoles).toHaveBeenCalledWith({ items: 10, page: 2 }); + }); + it('should get asset role by id', async () => { mockClient.getAssetRole.mockResolvedValue({ id: 1, name: 'editor' }); await assetRolesCommand.parseAsync(['node', 'test', 'get', '1']); diff --git a/src/commands/assetroles/index.ts b/src/commands/assetroles/index.ts index d19948d..150ba55 100644 --- a/src/commands/assetroles/index.ts +++ b/src/commands/assetroles/index.ts @@ -6,6 +6,7 @@ import { printResult, withErrorHandling, } from '../../lib/utils/api-helper.js'; +import { addPaginationOptions, printPaginationFooter } from '../../lib/utils/pagination.js'; import { parseAssetRoleId } from '../../lib/utils/validators.js'; import type { AssetRoleId } from '../../lib/api/branded-types.js'; import { @@ -20,12 +21,20 @@ export const assetRolesCommand = new Command('asset-roles') .alias('assetroles') .description('Asset role commands'); -const listCommand = new Command('list').description('List all asset roles').action( - withErrorHandling(async () => { +const listCommand = addPaginationOptions( + new Command('list').description('List all asset roles') +).action( + withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await listAssetRoles(client); + const result = await listAssetRoles(client, { items: options.items, page: options.page }); printFormatted(result.data, globalOptions); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); }) ); From 216f2a608e8e2e80c0189e28c97659a736eda438 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 14:07:39 +0100 Subject: [PATCH 17/35] fix(cli): wire pagination flags for cors list command --- src/commands/cors/cors.test.ts | 8 +++++++- src/commands/cors/index.ts | 15 ++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/commands/cors/cors.test.ts b/src/commands/cors/cors.test.ts index b1a59ce..e71f7a0 100644 --- a/src/commands/cors/cors.test.ts +++ b/src/commands/cors/cors.test.ts @@ -52,10 +52,16 @@ describe('cors command', () => { it('should list CORS origins', async () => { await corsCommand.parseAsync(['node', 'test', 'list']); - expect(mockClient.listCorsOrigins).toHaveBeenCalled(); + expect(mockClient.listCorsOrigins).toHaveBeenCalledWith({ items: 20, page: 1 }); expect(printFormatted).toHaveBeenCalled(); }); + it('should list CORS origins with pagination', async () => { + await corsCommand.parseAsync(['node', 'test', 'list', '--items', '10', '--page', '2']); + + expect(mockClient.listCorsOrigins).toHaveBeenCalledWith({ items: 10, page: 2 }); + }); + it('should get CORS origin by id', async () => { await corsCommand.parseAsync(['node', 'test', 'get', '1']); diff --git a/src/commands/cors/index.ts b/src/commands/cors/index.ts index 9cfb07a..69c66f4 100644 --- a/src/commands/cors/index.ts +++ b/src/commands/cors/index.ts @@ -6,6 +6,7 @@ import { printResult, withErrorHandling, } from '../../lib/utils/api-helper.js'; +import { addPaginationOptions, printPaginationFooter } from '../../lib/utils/pagination.js'; import { parseCorsOriginId } from '../../lib/utils/validators.js'; import type { CorsOriginId } from '../../lib/api/branded-types.js'; import { @@ -18,12 +19,20 @@ import { export const corsCommand = new Command('cors').description('CORS origins management'); -const listCommand = new Command('list').description('List CORS allowed origins').action( - withErrorHandling(async () => { +const listCommand = addPaginationOptions( + new Command('list').description('List CORS allowed origins') +).action( + withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await listCorsOrigins(client); + const result = await listCorsOrigins(client, { items: options.items, page: options.page }); printFormatted(result.data, globalOptions); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); }) ); From cf3912c4545604f16a305c3a8649c2068285fc92 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 14:07:39 +0100 Subject: [PATCH 18/35] fix(cli): wire pagination flags for datasources list command --- src/commands/datasources/datasources.test.ts | 8 +++++++- src/commands/datasources/index.ts | 15 ++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/commands/datasources/datasources.test.ts b/src/commands/datasources/datasources.test.ts index 0466e3a..d1ab9a4 100644 --- a/src/commands/datasources/datasources.test.ts +++ b/src/commands/datasources/datasources.test.ts @@ -77,10 +77,16 @@ describe('datasources command', () => { it('should list datasources', async () => { await datasourcesCommand.parseAsync(['node', 'test', 'list']); - expect(mockClient.listDatasources).toHaveBeenCalled(); + expect(mockClient.listDatasources).toHaveBeenCalledWith({ items: 20, page: 1 }); expect(printFormatted).toHaveBeenCalled(); }); + it('should list datasources with pagination', async () => { + await datasourcesCommand.parseAsync(['node', 'test', 'list', '--items', '10', '--page', '2']); + + expect(mockClient.listDatasources).toHaveBeenCalledWith({ items: 10, page: 2 }); + }); + it('should get datasource by id', async () => { await datasourcesCommand.parseAsync(['node', 'test', 'get', '1']); diff --git a/src/commands/datasources/index.ts b/src/commands/datasources/index.ts index 14fdbfe..dd2f4d6 100644 --- a/src/commands/datasources/index.ts +++ b/src/commands/datasources/index.ts @@ -9,6 +9,7 @@ import { } from '../../lib/utils/api-helper.js'; import { parseDatasourceId, validateJSON } from '../../lib/utils/validators.js'; import { readStdinText } from '../../lib/utils/stdin.js'; +import { addPaginationOptions, printPaginationFooter } from '../../lib/utils/pagination.js'; import type { DatasourceId } from '../../lib/api/branded-types.js'; import { listDatasources as coreListDatasources, @@ -33,12 +34,20 @@ export const datasourcesCommand = new Command('datasources') .aliases(['datasource', 'ds']) .description('Datasource management'); -const listCommand = new Command('list').description('List datasources').action( - withErrorHandling(async () => { +const listCommand = addPaginationOptions( + new Command('list').description('List datasources') +).action( + withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await coreListDatasources(client); + const result = await coreListDatasources(client, { items: options.items, page: options.page }); printFormatted(result.data, globalOptions); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); }) ); From 2085dcf615f6538b1c6c2fcc5344e0e0d7b2256f Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 14:07:39 +0100 Subject: [PATCH 19/35] fix(cli): wire pagination flags for export-configs list command --- .../exportconfigs/exportconfigs.test.ts | 16 +++++++++++++++- src/commands/exportconfigs/index.ts | 18 +++++++++++++++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/commands/exportconfigs/exportconfigs.test.ts b/src/commands/exportconfigs/exportconfigs.test.ts index ddcd1f3..c90d769 100644 --- a/src/commands/exportconfigs/exportconfigs.test.ts +++ b/src/commands/exportconfigs/exportconfigs.test.ts @@ -55,10 +55,24 @@ describe('export-configs command', () => { it('should list export configs', async () => { await exportConfigsCommand.parseAsync(['node', 'test', 'list']); - expect(mockClient.listExportConfigs).toHaveBeenCalled(); + expect(mockClient.listExportConfigs).toHaveBeenCalledWith({ + statuses: undefined, + items: 20, + page: 1, + }); expect(printFormatted).toHaveBeenCalled(); }); + it('should list export configs with pagination', async () => { + await exportConfigsCommand.parseAsync(['node', 'test', 'list', '--items', '10', '--page', '2']); + + expect(mockClient.listExportConfigs).toHaveBeenCalledWith({ + statuses: undefined, + items: 10, + page: 2, + }); + }); + it('should get export config by id', async () => { await exportConfigsCommand.parseAsync(['node', 'test', 'get', '1']); diff --git a/src/commands/exportconfigs/index.ts b/src/commands/exportconfigs/index.ts index 0e0683d..4ddb876 100644 --- a/src/commands/exportconfigs/index.ts +++ b/src/commands/exportconfigs/index.ts @@ -6,6 +6,7 @@ import { printResult, withErrorHandling, } from '../../lib/utils/api-helper.js'; +import { addPaginationOptions, printPaginationFooter } from '../../lib/utils/pagination.js'; import { parseExportConfigId, validateJSON } from '../../lib/utils/validators.js'; import type { ExportConfigId } from '../../lib/api/branded-types.js'; import { @@ -23,12 +24,23 @@ export const exportConfigsCommand = new Command('export-configs') .aliases(['exportconfigs', 'export-config']) .description('Export configuration management'); -const listCommand = new Command('list').description('List export configurations').action( - withErrorHandling(async () => { +const listCommand = addPaginationOptions( + new Command('list').description('List export configurations') +).action( + withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await coreListExportConfigs(client); + const result = await coreListExportConfigs(client, { + items: options.items, + page: options.page, + }); printFormatted(result.data, globalOptions); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); }) ); From 08e8b05280d48c90801f0e9a80a94746d82bf67a Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 14:07:39 +0100 Subject: [PATCH 20/35] fix(cli): wire pagination flags for update-schedules list command --- src/commands/updateschedules/index.ts | 18 +++++++++++++++--- .../updateschedules/updateschedules.test.ts | 16 +++++++++++++++- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/commands/updateschedules/index.ts b/src/commands/updateschedules/index.ts index 753ae6e..09e714a 100644 --- a/src/commands/updateschedules/index.ts +++ b/src/commands/updateschedules/index.ts @@ -6,6 +6,7 @@ import { printResult, withErrorHandling, } from '../../lib/utils/api-helper.js'; +import { addPaginationOptions, printPaginationFooter } from '../../lib/utils/pagination.js'; import { parseUpdateScheduleId, validateJSON } from '../../lib/utils/validators.js'; import type { UpdateScheduleId } from '../../lib/api/branded-types.js'; import { @@ -20,12 +21,23 @@ export const updateSchedulesCommand = new Command('update-schedules') .aliases(['updateschedules']) .description('Experiment update schedule management'); -const listCommand = new Command('list').description('List experiment update schedules').action( - withErrorHandling(async () => { +const listCommand = addPaginationOptions( + new Command('list').description('List experiment update schedules') +).action( + withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await listUpdateSchedules(client); + const result = await listUpdateSchedules(client, { + items: options.items, + page: options.page, + }); printFormatted(result.data, globalOptions); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); }) ); diff --git a/src/commands/updateschedules/updateschedules.test.ts b/src/commands/updateschedules/updateschedules.test.ts index 262e76a..43095eb 100644 --- a/src/commands/updateschedules/updateschedules.test.ts +++ b/src/commands/updateschedules/updateschedules.test.ts @@ -52,10 +52,24 @@ describe('update-schedules command', () => { it('should list update schedules', async () => { await updateSchedulesCommand.parseAsync(['node', 'test', 'list']); - expect(mockClient.listUpdateSchedules).toHaveBeenCalled(); + expect(mockClient.listUpdateSchedules).toHaveBeenCalledWith({ items: 20, page: 1 }); expect(printFormatted).toHaveBeenCalled(); }); + it('should list update schedules with pagination', async () => { + await updateSchedulesCommand.parseAsync([ + 'node', + 'test', + 'list', + '--items', + '10', + '--page', + '2', + ]); + + expect(mockClient.listUpdateSchedules).toHaveBeenCalledWith({ items: 10, page: 2 }); + }); + it('should get update schedule by id', async () => { await updateSchedulesCommand.parseAsync(['node', 'test', 'get', '1']); From 7458e7ddfef51616f1c29780d249ac4c0b25c36d Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 14:07:39 +0100 Subject: [PATCH 21/35] fix(cli): wire pagination flags for custom-sections list command --- src/commands/customsections/index.test.ts | 26 ++++++++++++++++++++++- src/commands/customsections/index.ts | 18 +++++++++++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/commands/customsections/index.test.ts b/src/commands/customsections/index.test.ts index 06aa258..218f21c 100644 --- a/src/commands/customsections/index.test.ts +++ b/src/commands/customsections/index.test.ts @@ -55,9 +55,33 @@ describe('custom-sections command', () => { await customSectionsCommand.parseAsync(['node', 'test', 'list']); - expect(mockClient.listCustomSections).toHaveBeenCalled(); + expect(mockClient.listCustomSections).toHaveBeenCalledWith({ + type: undefined, + items: 20, + page: 1, + }); expect(printFormatted).toHaveBeenCalledWith([{ id: 1 }, { id: 2 }], expect.any(Object)); }); + + it('should list custom sections with pagination', async () => { + mockClient.listCustomSections.mockResolvedValue([]); + + await customSectionsCommand.parseAsync([ + 'node', + 'test', + 'list', + '--items', + '10', + '--page', + '2', + ]); + + expect(mockClient.listCustomSections).toHaveBeenCalledWith({ + type: undefined, + items: 10, + page: 2, + }); + }); }); describe('create', () => { diff --git a/src/commands/customsections/index.ts b/src/commands/customsections/index.ts index 2055db4..0c0e3b0 100644 --- a/src/commands/customsections/index.ts +++ b/src/commands/customsections/index.ts @@ -6,6 +6,7 @@ import { printResult, withErrorHandling, } from '../../lib/utils/api-helper.js'; +import { addPaginationOptions, printPaginationFooter } from '../../lib/utils/pagination.js'; import { parseCustomSectionId } from '../../lib/utils/validators.js'; import type { CustomSectionId } from '../../lib/api/branded-types.js'; import { @@ -20,12 +21,23 @@ export const customSectionsCommand = new Command('custom-sections') .alias('customsections') .description('Experiment custom section commands'); -const listCommand = new Command('list').description('List all experiment custom sections').action( - withErrorHandling(async () => { +const listCommand = addPaginationOptions( + new Command('list').description('List all experiment custom sections') +).action( + withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await listCustomSections(client); + const result = await listCustomSections(client, { + items: options.items, + page: options.page, + }); printFormatted(result.data, globalOptions); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); }) ); From 88e476e68c51bc0076d6f3d78fead8d754bf007e Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 14:07:39 +0100 Subject: [PATCH 22/35] fix(cli): wire pagination flags for storage-configs list command --- src/commands/storageconfigs/index.ts | 18 +++++++++++++++--- .../storageconfigs/storageconfigs.test.ts | 16 +++++++++++++++- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/commands/storageconfigs/index.ts b/src/commands/storageconfigs/index.ts index 1f2c309..bfb50a8 100644 --- a/src/commands/storageconfigs/index.ts +++ b/src/commands/storageconfigs/index.ts @@ -7,6 +7,7 @@ import { withErrorHandling, } from '../../lib/utils/api-helper.js'; import { validateJSON } from '../../lib/utils/validators.js'; +import { addPaginationOptions, printPaginationFooter } from '../../lib/utils/pagination.js'; import { listStorageConfigs, getStorageConfig, @@ -19,12 +20,23 @@ export const storageConfigsCommand = new Command('storage-configs') .aliases(['storageconfigs', 'storage-config']) .description('Storage config management'); -const listCommand = new Command('list').description('List storage configs').action( - withErrorHandling(async () => { +const listCommand = addPaginationOptions( + new Command('list').description('List storage configs') +).action( + withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await listStorageConfigs(client); + const result = await listStorageConfigs(client, { + items: options.items, + page: options.page, + }); printFormatted(result.data, globalOptions); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); }) ); diff --git a/src/commands/storageconfigs/storageconfigs.test.ts b/src/commands/storageconfigs/storageconfigs.test.ts index ab0af90..eee9ee8 100644 --- a/src/commands/storageconfigs/storageconfigs.test.ts +++ b/src/commands/storageconfigs/storageconfigs.test.ts @@ -52,10 +52,24 @@ describe('storage-configs command', () => { it('should list storage configs', async () => { await storageConfigsCommand.parseAsync(['node', 'test', 'list']); - expect(mockClient.listStorageConfigs).toHaveBeenCalled(); + expect(mockClient.listStorageConfigs).toHaveBeenCalledWith({ items: 20, page: 1 }); expect(printFormatted).toHaveBeenCalled(); }); + it('should list storage configs with pagination', async () => { + await storageConfigsCommand.parseAsync([ + 'node', + 'test', + 'list', + '--items', + '10', + '--page', + '2', + ]); + + expect(mockClient.listStorageConfigs).toHaveBeenCalledWith({ items: 10, page: 2 }); + }); + it('should get storage config by id', async () => { await storageConfigsCommand.parseAsync(['node', 'test', 'get', '1']); From da6271006c30592443890715ead9d9cf0df8a788 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 14:07:39 +0100 Subject: [PATCH 23/35] fix(cli): wire pagination flags for action-dialog-fields list command --- .../actiondialogfields.test.ts | 22 ++++++++++++++++++- src/commands/actiondialogfields/index.ts | 18 ++++++++++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/commands/actiondialogfields/actiondialogfields.test.ts b/src/commands/actiondialogfields/actiondialogfields.test.ts index ee0ac28..547a85d 100644 --- a/src/commands/actiondialogfields/actiondialogfields.test.ts +++ b/src/commands/actiondialogfields/actiondialogfields.test.ts @@ -51,10 +51,30 @@ describe('actiondialogfields command', () => { it('should list action dialog fields', async () => { await actionDialogFieldsCommand.parseAsync(['node', 'test', 'list']); - expect(mockClient.listExperimentActionDialogFields).toHaveBeenCalled(); + expect(mockClient.listExperimentActionDialogFields).toHaveBeenCalledWith({ + items: 20, + page: 1, + }); expect(printFormatted).toHaveBeenCalled(); }); + it('should list action dialog fields with pagination', async () => { + await actionDialogFieldsCommand.parseAsync([ + 'node', + 'test', + 'list', + '--items', + '10', + '--page', + '2', + ]); + + expect(mockClient.listExperimentActionDialogFields).toHaveBeenCalledWith({ + items: 10, + page: 2, + }); + }); + it('should get action dialog field by id', async () => { await actionDialogFieldsCommand.parseAsync(['node', 'test', 'get', '1']); diff --git a/src/commands/actiondialogfields/index.ts b/src/commands/actiondialogfields/index.ts index a4dafc9..0417860 100644 --- a/src/commands/actiondialogfields/index.ts +++ b/src/commands/actiondialogfields/index.ts @@ -7,6 +7,7 @@ import { withErrorHandling, } from '../../lib/utils/api-helper.js'; import { validateJSON } from '../../lib/utils/validators.js'; +import { addPaginationOptions, printPaginationFooter } from '../../lib/utils/pagination.js'; function parsePositiveInt(value: string): number { const parsed = parseInt(value, 10); @@ -26,12 +27,23 @@ export const actionDialogFieldsCommand = new Command('action-dialog-fields') .aliases(['actiondialogfields']) .description('Action dialog field management'); -const listCommand = new Command('list').description('List action dialog fields').action( - withErrorHandling(async () => { +const listCommand = addPaginationOptions( + new Command('list').description('List action dialog fields') +).action( + withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await coreListActionDialogFields(client); + const result = await coreListActionDialogFields(client, { + items: options.items, + page: options.page, + }); printFormatted(result.data, globalOptions); + printPaginationFooter( + (result.data as unknown[]).length, + options.items, + options.page, + globalOptions.output as string + ); }) ); From c084a788c61f58b1959b1c02c8962d281101b13c Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 14:16:57 +0100 Subject: [PATCH 24/35] fix(cli): cap notifications list results with a default limit --- src/commands/notifications/index.ts | 11 +++++++- src/core/notifications/notifications.test.ts | 27 ++++++++++++++++++++ src/core/notifications/notifications.ts | 15 +++++++++-- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/commands/notifications/index.ts b/src/commands/notifications/index.ts index d0af5cc..2e2d761 100644 --- a/src/commands/notifications/index.ts +++ b/src/commands/notifications/index.ts @@ -22,12 +22,21 @@ export const notificationsCommand = new Command('notifications') const listCommand = new Command('list') .description('List notifications') .option('--cursor ', 'pagination cursor', parseInt) + .option('--limit ', 'max number of notifications to show', parseInt) .action( withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); - const result = await coreListNotifications(client, { cursor: options.cursor }); + const result = await coreListNotifications(client, { + cursor: options.cursor, + limit: options.limit, + }); printFormatted(result.data, globalOptions); + if (result.warnings) { + for (const w of result.warnings) { + console.log(chalk.gray(w)); + } + } }) ); diff --git a/src/core/notifications/notifications.test.ts b/src/core/notifications/notifications.test.ts index f46c147..e80f4c5 100644 --- a/src/core/notifications/notifications.test.ts +++ b/src/core/notifications/notifications.test.ts @@ -62,4 +62,31 @@ describe('notifications', () => { expect(mockClient.hasNewNotifications).toHaveBeenCalledWith(undefined); expect(result.data).toEqual({ hasNew: false }); }); + + describe('listNotifications limit', () => { + it('should cap results to the default limit and warn when truncated', async () => { + const all = Array.from({ length: 50 }, (_, i) => ({ id: i })); + mockClient.getNotifications.mockResolvedValue(all); + + const result = await listNotifications(mockClient as any, {}); + + expect((result.data as unknown[]).length).toBe(20); + expect(result.warnings).toEqual([ + 'Showing 20 of 50 notifications. Use --limit to show more.', + ]); + }); + + it('should not warn when under the limit', async () => { + mockClient.getNotifications.mockResolvedValue([{ id: 1 }]); + const result = await listNotifications(mockClient as any, {}); + expect(result.warnings).toBeUndefined(); + }); + + it('should respect an explicit limit', async () => { + const all = Array.from({ length: 50 }, (_, i) => ({ id: i })); + mockClient.getNotifications.mockResolvedValue(all); + const result = await listNotifications(mockClient as any, { limit: 5 }); + expect((result.data as unknown[]).length).toBe(5); + }); + }); }); diff --git a/src/core/notifications/notifications.ts b/src/core/notifications/notifications.ts index f648026..64a32a1 100644 --- a/src/core/notifications/notifications.ts +++ b/src/core/notifications/notifications.ts @@ -1,16 +1,27 @@ import type { APIClient } from '../../api-client/api-client.js'; import type { CommandResult } from '../types.js'; +const DEFAULT_NOTIFICATIONS_LIMIT = 20; + export interface ListNotificationsParams { cursor?: number | undefined; + limit?: number | undefined; } export async function listNotifications( client: APIClient, params: ListNotificationsParams ): Promise> { - const data = await client.getNotifications(params.cursor); - return { data }; + const all = await client.getNotifications(params.cursor); + const limit = params.limit ?? DEFAULT_NOTIFICATIONS_LIMIT; + const data = all.slice(0, limit); + return { + data, + warnings: + all.length > limit + ? [`Showing ${limit} of ${all.length} notifications. Use --limit to show more.`] + : undefined, + }; } export async function markNotificationsSeen(client: APIClient): Promise> { From c42e9943c2ac2c1816c14338fdbed5ac559b5761 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 14:17:00 +0100 Subject: [PATCH 25/35] fix(cli): default a bounded take for events list --- src/commands/events/events.test.ts | 4 +++- src/core/events/events.test.ts | 20 ++++++++++++++++++-- src/core/events/events.ts | 4 +++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/commands/events/events.test.ts b/src/commands/events/events.test.ts index 839e5e2..9d1fe8c 100644 --- a/src/commands/events/events.test.ts +++ b/src/commands/events/events.test.ts @@ -67,7 +67,7 @@ describe('events command', () => { it('should list events', async () => { await eventsCommand.parseAsync(['node', 'test', 'list']); - expect(mockClient.listEvents).toHaveBeenCalledWith({}); + expect(mockClient.listEvents).toHaveBeenCalledWith({ take: 100 }); expect(printFormatted).toHaveBeenCalled(); }); @@ -101,6 +101,7 @@ describe('events command', () => { await eventsCommand.parseAsync(['node', 'test', 'list', '--valid-exposures']); expect(mockClient.listEvents).toHaveBeenCalledWith({ filters: { effective_exposures: true }, + take: 100, }); }); @@ -108,6 +109,7 @@ describe('events command', () => { await eventsCommand.parseAsync(['node', 'test', 'list', '--invalid-exposures']); expect(mockClient.listEvents).toHaveBeenCalledWith({ filters: { effective_exposures: false }, + take: 100, }); }); diff --git a/src/core/events/events.test.ts b/src/core/events/events.test.ts index d6499f9..d531c48 100644 --- a/src/core/events/events.test.ts +++ b/src/core/events/events.test.ts @@ -107,7 +107,7 @@ describe('events', () => { it('should omit empty filters', async () => { mockClient.listEvents.mockResolvedValue([]); await listEvents(mockClient as any, {}); - expect(mockClient.listEvents).toHaveBeenCalledWith({}); + expect(mockClient.listEvents).toHaveBeenCalledWith({ take: 100 }); }); it('should send effective_exposures=true when validExposures is true', async () => { @@ -115,6 +115,7 @@ describe('events', () => { await listEvents(mockClient as any, { validExposures: true }); expect(mockClient.listEvents).toHaveBeenCalledWith({ filters: { effective_exposures: true }, + take: 100, }); }); @@ -123,13 +124,28 @@ describe('events', () => { await listEvents(mockClient as any, { validExposures: false }); expect(mockClient.listEvents).toHaveBeenCalledWith({ filters: { effective_exposures: false }, + take: 100, }); }); it('should omit effective_exposures when validExposures is undefined', async () => { mockClient.listEvents.mockResolvedValue([]); await listEvents(mockClient as any, { from: 1 }); - expect(mockClient.listEvents).toHaveBeenCalledWith({ filters: { from: 1 } }); + expect(mockClient.listEvents).toHaveBeenCalledWith({ filters: { from: 1 }, take: 100 }); + }); + }); + + describe('listEvents default take', () => { + it('should apply a default take when not specified', async () => { + mockClient.listEvents.mockResolvedValue({ columnNames: [], rows: [] }); + await listEvents(mockClient as any, {}); + expect(mockClient.listEvents).toHaveBeenCalledWith(expect.objectContaining({ take: 100 })); + }); + + it('should respect an explicit take', async () => { + mockClient.listEvents.mockResolvedValue({ columnNames: [], rows: [] }); + await listEvents(mockClient as any, { take: 500 }); + expect(mockClient.listEvents).toHaveBeenCalledWith(expect.objectContaining({ take: 500 })); }); }); diff --git a/src/core/events/events.ts b/src/core/events/events.ts index 322a43c..45b0a4a 100644 --- a/src/core/events/events.ts +++ b/src/core/events/events.ts @@ -55,6 +55,8 @@ export function formatEventRowTimestamps( }); } +const DEFAULT_EVENTS_TAKE = 100; + export interface ListEventsParams { from?: number | undefined; to?: number | undefined; @@ -88,7 +90,7 @@ export async function listEvents( const body: Record = {}; if (Object.keys(filters).length > 0) body.filters = filters; - if (params.take !== undefined) body.take = params.take; + body.take = params.take ?? DEFAULT_EVENTS_TAKE; if (params.skip !== undefined) body.skip = params.skip; const data = await client.listEvents(body as Parameters[0]); From d97de6a3ad704551b8a87fbed2bf529de374ae3c Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 14:28:51 +0100 Subject: [PATCH 26/35] fix(cli): skip notifications truncation warning for json/yaml output Warnings were printed via console.log unconditionally, corrupting piped JSON/YAML output once the new default truncation kicked in. --- src/commands/notifications/index.ts | 6 +++++- .../notifications/notifications.test.ts | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/commands/notifications/index.ts b/src/commands/notifications/index.ts index 2e2d761..702e3a9 100644 --- a/src/commands/notifications/index.ts +++ b/src/commands/notifications/index.ts @@ -32,7 +32,11 @@ const listCommand = new Command('list') limit: options.limit, }); printFormatted(result.data, globalOptions); - if (result.warnings) { + if ( + result.warnings && + globalOptions.output !== 'json' && + globalOptions.output !== 'yaml' + ) { for (const w of result.warnings) { console.log(chalk.gray(w)); } diff --git a/src/commands/notifications/notifications.test.ts b/src/commands/notifications/notifications.test.ts index a16b107..5c5f133 100644 --- a/src/commands/notifications/notifications.test.ts +++ b/src/commands/notifications/notifications.test.ts @@ -61,6 +61,23 @@ describe('notifications command', () => { expect(mockClient.getNotifications).toHaveBeenCalledWith(100); }); + it('should not print truncation warnings when output is json', async () => { + vi.mocked(getGlobalOptions).mockReturnValue({ output: 'json' } as any); + const all = Array.from({ length: 50 }, (_, i) => ({ id: i })); + mockClient.getNotifications.mockResolvedValue(all); + await notificationsCommand.parseAsync(['node', 'test', 'list', '--limit', '20']); + const output = consoleSpy.mock.calls.flat().join(' '); + expect(output).not.toContain('Showing 20 of 50 notifications'); + }); + + it('should print truncation warnings for table output', async () => { + const all = Array.from({ length: 50 }, (_, i) => ({ id: i })); + mockClient.getNotifications.mockResolvedValue(all); + await notificationsCommand.parseAsync(['node', 'test', 'list', '--limit', '20']); + const output = consoleSpy.mock.calls.flat().join(' '); + expect(output).toContain('Showing 20 of 50 notifications'); + }); + it('should mark notifications as seen', async () => { mockClient.markNotificationsSeen.mockResolvedValue(undefined); await notificationsCommand.parseAsync(['node', 'test', 'mark-seen']); From 4aa95fdf291b20adfc86f445fe0e1d56a0339a30 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 14:29:14 +0100 Subject: [PATCH 27/35] fix(cli): restore backward compatibility for listCustomSections public API listCustomSections is exported via the public ./api-client subpath. Task 7 changed it from a positional (type?: string) signature to an options-object signature, which would silently drop the type filter for JS/loosely-typed external consumers still calling it positionally. Accept both forms. --- src/api-client/api-client.test.ts | 24 ++++++++++++++++++++++++ src/api-client/api-client.ts | 14 +++++++++----- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/api-client/api-client.test.ts b/src/api-client/api-client.test.ts index 739ae38..dbdc00d 100644 --- a/src/api-client/api-client.test.ts +++ b/src/api-client/api-client.test.ts @@ -1193,6 +1193,30 @@ describe.skipIf(isLiveMode)('APIClient core', () => { expect(await client.listCustomSections()).toEqual([]); }); + it('should list custom sections with the legacy positional type string', async () => { + server.use( + http.get(`${BASE_URL}/experiment_custom_sections`, ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('type')).toBe('active'); + return HttpResponse.json({ experiment_custom_sections: [{ id: 1, type: 'active' }] }); + }) + ); + expect(await client.listCustomSections('active')).toEqual([{ id: 1, type: 'active' }]); + }); + + it('should list custom sections with the options-object form', async () => { + server.use( + http.get(`${BASE_URL}/experiment_custom_sections`, ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('type')).toBe('active'); + expect(url.searchParams.get('items')).toBe('5'); + expect(url.searchParams.get('page')).toBe('2'); + return HttpResponse.json({ experiment_custom_sections: [] }); + }) + ); + await client.listCustomSections({ type: 'active', items: 5, page: 2 }); + }); + it('should reorder custom sections', async () => { server.use( http.put(`${BASE_URL}/experiment_custom_sections/order`, () => diff --git a/src/api-client/api-client.ts b/src/api-client/api-client.ts index 7d3671b..ed87c6c 100644 --- a/src/api-client/api-client.ts +++ b/src/api-client/api-client.ts @@ -1026,12 +1026,16 @@ export class APIClient { } async listCustomSections( - params: { - type?: string | undefined; - items?: number | undefined; - page?: number | undefined; - } = {} + typeOrParams?: + | string + | { + type?: string | undefined; + items?: number | undefined; + page?: number | undefined; + } ): Promise { + const params = + typeof typeOrParams === 'string' ? { type: typeOrParams } : (typeOrParams ?? {}); const queryParams: Record = {}; if (params.type) queryParams.type = params.type; if (params.items !== undefined) queryParams.items = String(params.items); From afa17a7f319616dac394e55c3a2b6fa7f7ff98f6 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 14:29:21 +0100 Subject: [PATCH 28/35] chore(release): bump cli to 1.15.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 565ce4e..8be69ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@absmartly/cli", - "version": "1.14.0", + "version": "1.15.0", "description": "ABSmartly CLI - A/B Testing and Feature Flags command-line tool for AI agents and humans", "type": "module", "main": "./dist/index.js", From e005ad981bacdbc166912f006b2d4e5bb3719c8b Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 24 Sep 2026 14:31:22 +0100 Subject: [PATCH 29/35] style: run prettier on notifications/api-client fixes --- src/api-client/api-client.ts | 3 +-- src/commands/notifications/index.ts | 6 +----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/api-client/api-client.ts b/src/api-client/api-client.ts index ed87c6c..fc9328e 100644 --- a/src/api-client/api-client.ts +++ b/src/api-client/api-client.ts @@ -1034,8 +1034,7 @@ export class APIClient { page?: number | undefined; } ): Promise { - const params = - typeof typeOrParams === 'string' ? { type: typeOrParams } : (typeOrParams ?? {}); + const params = typeof typeOrParams === 'string' ? { type: typeOrParams } : (typeOrParams ?? {}); const queryParams: Record = {}; if (params.type) queryParams.type = params.type; if (params.items !== undefined) queryParams.items = String(params.items); diff --git a/src/commands/notifications/index.ts b/src/commands/notifications/index.ts index 702e3a9..88f2d19 100644 --- a/src/commands/notifications/index.ts +++ b/src/commands/notifications/index.ts @@ -32,11 +32,7 @@ const listCommand = new Command('list') limit: options.limit, }); printFormatted(result.data, globalOptions); - if ( - result.warnings && - globalOptions.output !== 'json' && - globalOptions.output !== 'yaml' - ) { + if (result.warnings && globalOptions.output !== 'json' && globalOptions.output !== 'yaml') { for (const w of result.warnings) { console.log(chalk.gray(w)); } From 181b5066770d3a9504ac14e0a0fb778e60658324 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Fri, 25 Sep 2026 22:20:02 +0100 Subject: [PATCH 30/35] fix(cli): render webhook event names instead of [object Object] summarizeWebhook() assumed a webhook's `events` field was a plain string array and joined it directly. The real API (and the OpenAPI schema backing absmartly-api-mocks) returns each entry as a subscription object `{ webhook_id, webhook_event_id, enabled, event: { id, name, description } }`, so `abs webhooks get ` rendered the events column as a literal "[object Object]" in both table and plain output. Extract each entry's `event.name`, falling back to the raw value when it's already a plain string for backward compatibility. Found by running every read-only command against the repo's own absmartly-api-mocks server with realistic payloads. --- src/api-client/entity-summary.test.ts | 35 +++++++++++++++++++++++++-- src/api-client/entity-summary.ts | 20 +++++++++++++-- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/api-client/entity-summary.test.ts b/src/api-client/entity-summary.test.ts index dbc2d35..c73087a 100644 --- a/src/api-client/entity-summary.test.ts +++ b/src/api-client/entity-summary.test.ts @@ -372,7 +372,7 @@ describe('summarizeNamedEntityRow', () => { }); describe('summarizeWebhook', () => { - it('should include detail fields', () => { + it('should include detail fields and join event subscription names', () => { const result = summarizeWebhook({ id: 3, name: 'Slack notify', @@ -382,7 +382,20 @@ describe('summarizeWebhook', () => { max_retries: 2, archived: false, description: 'notifies slack', - events: ['experiment.created'], + events: [ + { + webhook_id: 3, + webhook_event_id: 1, + enabled: true, + event: { id: 1, name: 'ExperimentCreated', description: 'Experiment created' }, + }, + { + webhook_id: 3, + webhook_event_id: 2, + enabled: true, + event: { id: 2, name: 'ExperimentDevelopment', description: 'Experiment started' }, + }, + ], created_at: '2024-01-01T00:00:00Z', updated_at: '2024-06-01T00:00:00Z', secret: 'super-secret-should-be-dropped', @@ -393,9 +406,27 @@ describe('summarizeWebhook', () => { url: 'https://hooks.example.com/abc', enabled: true, description: 'notifies slack', + events: 'ExperimentCreated, ExperimentDevelopment', }); expect(result).not.toHaveProperty('secret'); }); + + it('should not throw on missing/empty events and should fall back to empty string', () => { + const result = summarizeWebhook({ id: 4, name: 'No events' }); + expect(result.events).toBe(''); + + const emptyResult = summarizeWebhook({ id: 5, name: 'Empty events', events: [] }); + expect(emptyResult.events).toBe(''); + }); + + it('should still join plain string events for backward compatibility', () => { + const result = summarizeWebhook({ + id: 6, + name: 'Legacy shape', + events: ['experiment.created'], + }); + expect(result.events).toBe('experiment.created'); + }); }); describe('summarizeWebhookRow', () => { diff --git a/src/api-client/entity-summary.ts b/src/api-client/entity-summary.ts index dca1fb3..56c6599 100644 --- a/src/api-client/entity-summary.ts +++ b/src/api-client/entity-summary.ts @@ -268,8 +268,24 @@ export function summarizeNamedEntityRow(e: Record): Record { + if (typeof e === 'string') return e; + const name = (e as Record | null)?.event as + | Record + | undefined; + return typeof name?.name === 'string' ? name.name : undefined; + }) + .filter((name): name is string => typeof name === 'string') + .join(', '); +} + export function summarizeWebhook(w: Record): Record { - const events = w.events as Array | undefined; return { id: w.id, name: w.name ?? '', @@ -278,7 +294,7 @@ export function summarizeWebhook(w: Record): Record | undefined), From 18bd85af0553a852058dbfd036b91e32bac4d70a Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Fri, 25 Sep 2026 22:35:25 +0100 Subject: [PATCH 31/35] fix(cli): correct 3 stale endpoint paths/response shapes broken against the real API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by running every read-only command against a mock server with realistic payloads (matching the actual OpenAPI schema) — these three commands crashed or hung with a 30s timeout because the CLI's request no longer matched the real API: - listWebhookEvents: expected a `webhook_events` response key; the endpoint wraps its array under `items` (with a `metadata` envelope). - hasNewNotifications: called the nonexistent `/notifications/has-new` path (real path is `/notifications/check_for_new`) and expected an `{ has_new: bool }` object; the endpoint returns a bare boolean. - getVelocityInsights / getVelocityInsightsDetail: called `/insights/velocity/summary` and `/insights/velocity/summary/detail`, neither of which exist; the real paths are `/insights/velocity/widgets` and `/insights/velocity/history`. None of these are touched by FT-2245's summarizer/pagination work — they're pre-existing bugs in untested code paths, caught only by exercising the CLI end-to-end rather than via fully-mocked unit tests. The `access_control_policies` vs `access_control_policy` response-key mismatch found by the same run is left as-is per the PR description; unlike these three, resolving it would require confirming which side (client or a stale spec) is actually wrong against a live backend. --- src/api-client/api-client.test.ts | 6 +++--- src/api-client/api-client.ts | 17 +++++++++-------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/api-client/api-client.test.ts b/src/api-client/api-client.test.ts index dbdc00d..815cc93 100644 --- a/src/api-client/api-client.test.ts +++ b/src/api-client/api-client.test.ts @@ -921,7 +921,7 @@ describe.skipIf(isLiveMode)('APIClient core', () => { it('should list webhook events', async () => { server.use( http.get(`${BASE_URL}/webhook_events`, () => - HttpResponse.json({ webhook_events: ['experiment.started'] }) + HttpResponse.json({ items: ['experiment.started'], metadata: { count: 1 } }) ) ); expect(await client.listWebhookEvents()).toContain('experiment.started'); @@ -1017,7 +1017,7 @@ describe.skipIf(isLiveMode)('APIClient core', () => { it('should check for new notifications', async () => { server.use( - http.get(`${BASE_URL}/notifications/has-new`, () => HttpResponse.json({ has_new: true })) + http.get(`${BASE_URL}/notifications/check_for_new`, () => HttpResponse.json(true)) ); expect(await client.hasNewNotifications()).toBe(true); }); @@ -1230,7 +1230,7 @@ describe.skipIf(isLiveMode)('APIClient core', () => { describe('insights', () => { it('should get velocity insights', async () => { server.use( - http.get(`${BASE_URL}/insights/velocity/summary`, () => HttpResponse.json({ data: [] })) + http.get(`${BASE_URL}/insights/velocity/widgets`, () => HttpResponse.json({ data: [] })) ); const result = await client.getVelocityInsights({ from: 1000, diff --git a/src/api-client/api-client.ts b/src/api-client/api-client.ts index fc9328e..2819ec4 100644 --- a/src/api-client/api-client.ts +++ b/src/api-client/api-client.ts @@ -1621,14 +1621,15 @@ export class APIClient { async hasNewNotifications(lastNotificationId?: number): Promise { const params: Record = {}; if (lastNotificationId !== undefined) params.last_notification_id = lastNotificationId; - const response = await this.request>('GET', '/notifications/has-new', { + const response = await this.request('GET', '/notifications/check_for_new', { params, }); - const data = response.data; - if (!data || typeof data !== 'object') { - throw new Error('Invalid API response for hasNewNotifications: Expected object'); + if (typeof response.data !== 'boolean') { + throw new Error( + `Invalid API response for hasNewNotifications: Expected boolean, got ${typeof response.data}` + ); } - return Boolean((data as Record).has_new); + return response.data; } async listExperimentAccessUsers(id: ExperimentId): Promise { @@ -1958,7 +1959,7 @@ export class APIClient { team_ids?: number[]; owner_ids?: number[]; }): Promise { - const response = await this.request('GET', '/insights/velocity/summary', { + const response = await this.request('GET', '/insights/velocity/widgets', { params: this.buildInsightParams(params), }); return response.data; @@ -1980,7 +1981,7 @@ export class APIClient { async listWebhookEvents(): Promise { const response = await this.request('GET', '/webhook_events'); - return this.validateListResponse(response, 'webhook_events', 'listWebhookEvents'); + return this.validateListResponse(response, 'items', 'listWebhookEvents'); } async listAccessControlPolicies( @@ -2418,7 +2419,7 @@ export class APIClient { teams?: string; applications?: string; }): Promise { - const response = await this.request('GET', '/insights/velocity/summary/detail', { + const response = await this.request('GET', '/insights/velocity/history', { params: this.buildInsightParams(params), }); return response.data; From 9cca960630e791a874b2f2408ffe99b6d3b8bd16 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Fri, 25 Sep 2026 22:48:48 +0100 Subject: [PATCH 32/35] fix(cli): revert 3 wrong endpoint fixes, fix access_control_policies for real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checked the previous commit's 3 "fixes" (webhook_events response key, notifications check path, insights velocity paths) against the real backend source (~/git_tree/abs/office/backend and shared/lib) instead of the vendored absmartly-api-mocks OpenAPI schema, which turned out to be stale on all three: - listWebhookEvents: real route builds its response key by pluralizing the CRUD resource's field name ("webhook_event" -> "webhook_events"), confirmed in src/lib/crud/router.ts + pluralize.js. The original `webhook_events` key was already correct; reverted. - hasNewNotifications: real route is `@Get("has-new")` on NotificationController, returning a bare boolean. The original path was already correct; reverted (kept the boolean-response-shape fix, which was independently right). - getVelocityInsights / getVelocityInsightsDetail: real router registers `GET /insights/velocity/summary` and `.../summary/detail` (routes/insights/velocity.js) — not `/widgets` or `/history`, which don't exist on this router at all. Reverted both paths. Fixed access_control_policies for real this time: the real route (routes/access_control_policies/index.js) does `res.json({ access_control_policy })` — singular, confirmed directly in source, not inferred from a spec. Also found and fixed a second, more consequential bug the key mismatch was masking: `permissions`, `permission_categories`, and `access_control_policies` are all hand-written Express routes that call their model's `getAll()` with no arguments — none of them apply server-side `items`/`page` pagination despite Task 6 wiring those params through. Left as shipped, the CLI's `hasMore: data.length >= items` would misreport `hasMore: true` indefinitely, since the server always returns the full unfiltered set regardless of page. Now paginates client-side and warns on truncation, mirroring this branch's own `notifications` precedent for exactly this situation (server has no page-size support). action-dialog-fields' pagination, and the /events endpoint family, were also checked against the real backend and found to be correctly implemented already (the vendored mock's `query?: never` annotation for action-dialog-fields was itself wrong — the generic CRUD list builder applies items/page generically to every resource; /events genuinely exists on the real router, just missing from the mocks package's own OpenAPI registration) — no code changes needed for either. --- src/api-client/api-client.test.ts | 10 ++-- src/api-client/api-client.ts | 10 ++-- src/commands/permissions/index.ts | 12 +++++ src/commands/permissions/permissions.test.ts | 17 +++++++ src/core/permissions/list.ts | 49 +++++++++++++++++--- src/core/permissions/permissions.test.ts | 39 ++++++++++++++++ 6 files changed, 120 insertions(+), 17 deletions(-) diff --git a/src/api-client/api-client.test.ts b/src/api-client/api-client.test.ts index 815cc93..b9c50b0 100644 --- a/src/api-client/api-client.test.ts +++ b/src/api-client/api-client.test.ts @@ -921,7 +921,7 @@ describe.skipIf(isLiveMode)('APIClient core', () => { it('should list webhook events', async () => { server.use( http.get(`${BASE_URL}/webhook_events`, () => - HttpResponse.json({ items: ['experiment.started'], metadata: { count: 1 } }) + HttpResponse.json({ webhook_events: ['experiment.started'] }) ) ); expect(await client.listWebhookEvents()).toContain('experiment.started'); @@ -1016,9 +1016,7 @@ describe.skipIf(isLiveMode)('APIClient core', () => { }); it('should check for new notifications', async () => { - server.use( - http.get(`${BASE_URL}/notifications/check_for_new`, () => HttpResponse.json(true)) - ); + server.use(http.get(`${BASE_URL}/notifications/has-new`, () => HttpResponse.json(true))); expect(await client.hasNewNotifications()).toBe(true); }); }); @@ -1230,7 +1228,7 @@ describe.skipIf(isLiveMode)('APIClient core', () => { describe('insights', () => { it('should get velocity insights', async () => { server.use( - http.get(`${BASE_URL}/insights/velocity/widgets`, () => HttpResponse.json({ data: [] })) + http.get(`${BASE_URL}/insights/velocity/summary`, () => HttpResponse.json({ data: [] })) ); const result = await client.getVelocityInsights({ from: 1000, @@ -1423,7 +1421,7 @@ describe.skipIf(isLiveMode)('APIClient core', () => { it('should list access control policies', async () => { server.use( http.get(`${BASE_URL}/access_control_policies`, () => - HttpResponse.json({ access_control_policies: [] }) + HttpResponse.json({ access_control_policy: [] }) ) ); expect(await client.listAccessControlPolicies()).toEqual([]); diff --git a/src/api-client/api-client.ts b/src/api-client/api-client.ts index 2819ec4..6b3654e 100644 --- a/src/api-client/api-client.ts +++ b/src/api-client/api-client.ts @@ -1621,7 +1621,7 @@ export class APIClient { async hasNewNotifications(lastNotificationId?: number): Promise { const params: Record = {}; if (lastNotificationId !== undefined) params.last_notification_id = lastNotificationId; - const response = await this.request('GET', '/notifications/check_for_new', { + const response = await this.request('GET', '/notifications/has-new', { params, }); if (typeof response.data !== 'boolean') { @@ -1959,7 +1959,7 @@ export class APIClient { team_ids?: number[]; owner_ids?: number[]; }): Promise { - const response = await this.request('GET', '/insights/velocity/widgets', { + const response = await this.request('GET', '/insights/velocity/summary', { params: this.buildInsightParams(params), }); return response.data; @@ -1981,7 +1981,7 @@ export class APIClient { async listWebhookEvents(): Promise { const response = await this.request('GET', '/webhook_events'); - return this.validateListResponse(response, 'items', 'listWebhookEvents'); + return this.validateListResponse(response, 'webhook_events', 'listWebhookEvents'); } async listAccessControlPolicies( @@ -1993,7 +1993,7 @@ export class APIClient { const response = await this.request('GET', '/access_control_policies', { params }); return this.validateListResponse( response, - 'access_control_policies', + 'access_control_policy', 'listAccessControlPolicies' ); } @@ -2419,7 +2419,7 @@ export class APIClient { teams?: string; applications?: string; }): Promise { - const response = await this.request('GET', '/insights/velocity/history', { + const response = await this.request('GET', '/insights/velocity/summary/detail', { params: this.buildInsightParams(params), }); return response.data; diff --git a/src/commands/permissions/index.ts b/src/commands/permissions/index.ts index 6f49f47..5196452 100644 --- a/src/commands/permissions/index.ts +++ b/src/commands/permissions/index.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; +import chalk from 'chalk'; import { getAPIClientFromOptions, getGlobalOptions, @@ -12,6 +13,14 @@ import { listAccessControlPolicies, } from '../../core/permissions/list.js'; +function printWarnings(warnings: string[] | undefined, output: string): void { + if (warnings && output !== 'json' && output !== 'yaml') { + for (const w of warnings) { + console.log(chalk.gray(w)); + } + } +} + export const permissionsCommand = new Command('permissions') .aliases(['permission', 'perms', 'perm']) .description('Permission commands'); @@ -24,6 +33,7 @@ const listCommand = addPaginationOptions( const client = await getAPIClientFromOptions(globalOptions); const result = await listPermissions(client, { items: options.items, page: options.page }); printFormatted(result.data, globalOptions); + printWarnings(result.warnings, globalOptions.output as string); printPaginationFooter( (result.data as unknown[]).length, options.items, @@ -44,6 +54,7 @@ const categoriesCommand = addPaginationOptions( page: options.page, }); printFormatted(result.data, globalOptions); + printWarnings(result.warnings, globalOptions.output as string); printPaginationFooter( (result.data as unknown[]).length, options.items, @@ -64,6 +75,7 @@ const policiesCommand = addPaginationOptions( page: options.page, }); printFormatted(result.data, globalOptions); + printWarnings(result.warnings, globalOptions.output as string); printPaginationFooter( (result.data as unknown[]).length, options.items, diff --git a/src/commands/permissions/permissions.test.ts b/src/commands/permissions/permissions.test.ts index 8148ab6..5661d0a 100644 --- a/src/commands/permissions/permissions.test.ts +++ b/src/commands/permissions/permissions.test.ts @@ -104,4 +104,21 @@ describe('permissions command', () => { expect(mockClient.listAccessControlPolicies).toHaveBeenCalledWith({ items: 10, page: 2 }); }); + + it('should warn when the server returns more results than the page size', async () => { + const all = Array.from({ length: 30 }, (_, i) => ({ id: i })); + mockClient.listPermissions.mockResolvedValue(all); + await permissionsCommand.parseAsync(['node', 'test', 'list', '--items', '20', '--page', '1']); + const output = consoleSpy.mock.calls.flat().join(' '); + expect(output).toContain('Showing 20 of 30 results'); + }); + + it('should not print the results warning when output is json', async () => { + vi.mocked(getGlobalOptions).mockReturnValue({ output: 'json' } as any); + const all = Array.from({ length: 30 }, (_, i) => ({ id: i })); + mockClient.listPermissions.mockResolvedValue(all); + await permissionsCommand.parseAsync(['node', 'test', 'list', '--items', '20', '--page', '1']); + const output = consoleSpy.mock.calls.flat().join(' '); + expect(output).not.toContain('Showing 20 of 30 results'); + }); }); diff --git a/src/core/permissions/list.ts b/src/core/permissions/list.ts index f57851d..fe8ded8 100644 --- a/src/core/permissions/list.ts +++ b/src/core/permissions/list.ts @@ -8,14 +8,41 @@ export interface ListPermissionsParams { page?: number | undefined; } +// permissions/permission_categories/access_control_policies have no +// server-side items/page support (confirmed against the real backend: +// each route calls its model's getAll() with no query args), unlike the +// other pagination groups this ticket touched. Slicing client-side and +// warning on truncation mirrors this codebase's own notifications +// precedent — without it, `hasMore` would always be true past the first +// page and every subsequent --page would silently re-return the same +// full list. +function paginateClientSide( + data: unknown[], + page: number, + items: number +): { pageData: unknown[]; warnings: string[] | undefined } { + const start = (page - 1) * items; + const pageData = data.slice(start, start + items); + const warnings = + data.length > items + ? [`Showing ${pageData.length} of ${data.length} results. Use --page to see more.`] + : undefined; + return { pageData, warnings }; +} + export async function listPermissions( client: APIClient, params: ListPermissionsParams = {} ): Promise> { const items = params.items ?? DEFAULT_PERMISSIONS_PAGE_SIZE; const page = params.page ?? 1; - const data = await client.listPermissions({ items, page }); - return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; + const all = await client.listPermissions({ items, page }); + const { pageData, warnings } = paginateClientSide(all as unknown[], page, items); + return { + data: pageData, + warnings, + pagination: { page, items, hasMore: page * items < (all as unknown[]).length }, + }; } export async function listPermissionCategories( @@ -24,8 +51,13 @@ export async function listPermissionCategories( ): Promise> { const items = params.items ?? DEFAULT_PERMISSIONS_PAGE_SIZE; const page = params.page ?? 1; - const data = await client.listPermissionCategories({ items, page }); - return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; + const all = await client.listPermissionCategories({ items, page }); + const { pageData, warnings } = paginateClientSide(all as unknown[], page, items); + return { + data: pageData, + warnings, + pagination: { page, items, hasMore: page * items < (all as unknown[]).length }, + }; } export async function listAccessControlPolicies( @@ -34,6 +66,11 @@ export async function listAccessControlPolicies( ): Promise> { const items = params.items ?? DEFAULT_PERMISSIONS_PAGE_SIZE; const page = params.page ?? 1; - const data = await client.listAccessControlPolicies({ items, page }); - return { data, pagination: { page, items, hasMore: (data as unknown[]).length >= items } }; + const all = await client.listAccessControlPolicies({ items, page }); + const { pageData, warnings } = paginateClientSide(all as unknown[], page, items); + return { + data: pageData, + warnings, + pagination: { page, items, hasMore: page * items < (all as unknown[]).length }, + }; } diff --git a/src/core/permissions/permissions.test.ts b/src/core/permissions/permissions.test.ts index aea7a39..7caab7b 100644 --- a/src/core/permissions/permissions.test.ts +++ b/src/core/permissions/permissions.test.ts @@ -38,6 +38,29 @@ describe('listPermissions pagination', () => { await listPermissions(mockClient as any, { items: 25, page: 2 }); expect(mockClient.listPermissions).toHaveBeenCalledWith({ items: 25, page: 2 }); }); + + it('should slice client-side and warn when the server ignores items/page', async () => { + const all = Array.from({ length: 30 }, (_, i) => ({ id: i })); + mockClient.listPermissions.mockResolvedValue(all); + const result = await listPermissions(mockClient as any, { items: 20, page: 1 }); + expect((result.data as unknown[]).length).toBe(20); + expect(result.warnings).toEqual(['Showing 20 of 30 results. Use --page to see more.']); + expect(result.pagination?.hasMore).toBe(true); + }); + + it('should return the correct slice for page 2 and report hasMore false at the end', async () => { + const all = Array.from({ length: 30 }, (_, i) => ({ id: i })); + mockClient.listPermissions.mockResolvedValue(all); + const result = await listPermissions(mockClient as any, { items: 20, page: 2 }); + expect((result.data as unknown[]).length).toBe(10); + expect(result.pagination?.hasMore).toBe(false); + }); + + it('should not warn when everything fits on one page', async () => { + mockClient.listPermissions.mockResolvedValue([{ id: 1 }]); + const result = await listPermissions(mockClient as any, { items: 20, page: 1 }); + expect(result.warnings).toBeUndefined(); + }); }); describe('listPermissionCategories pagination', () => { @@ -48,6 +71,14 @@ describe('listPermissionCategories pagination', () => { await listPermissionCategories(mockClient as any, { items: 25, page: 2 }); expect(mockClient.listPermissionCategories).toHaveBeenCalledWith({ items: 25, page: 2 }); }); + + it('should slice client-side and warn when the server ignores items/page', async () => { + const all = Array.from({ length: 30 }, (_, i) => ({ id: i })); + mockClient.listPermissionCategories.mockResolvedValue(all); + const result = await listPermissionCategories(mockClient as any, { items: 20, page: 1 }); + expect((result.data as unknown[]).length).toBe(20); + expect(result.warnings).toEqual(['Showing 20 of 30 results. Use --page to see more.']); + }); }); describe('listAccessControlPolicies pagination', () => { @@ -58,4 +89,12 @@ describe('listAccessControlPolicies pagination', () => { await listAccessControlPolicies(mockClient as any, { items: 25, page: 2 }); expect(mockClient.listAccessControlPolicies).toHaveBeenCalledWith({ items: 25, page: 2 }); }); + + it('should slice client-side and warn when the server ignores items/page', async () => { + const all = Array.from({ length: 30 }, (_, i) => ({ id: i })); + mockClient.listAccessControlPolicies.mockResolvedValue(all); + const result = await listAccessControlPolicies(mockClient as any, { items: 20, page: 1 }); + expect((result.data as unknown[]).length).toBe(20); + expect(result.warnings).toEqual(['Showing 20 of 30 results. Use --page to see more.']); + }); }); From aed859069cbe89995729fde392082a090d371855 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Sat, 26 Sep 2026 09:37:47 +0100 Subject: [PATCH 33/35] =?UTF-8?q?fix(cli):=20correct=20velocity=20insights?= =?UTF-8?q?=20paths=20=E2=80=94=20no=20/velocity/=20segment=20on=20the=20r?= =?UTF-8?q?eal=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-verified against the real backend's insights router (src/routes/insights/velocity.js, src/app.ts) after updating the local absmartly-api-mocks checkout to its just-merged spec-sync fix (FT-2223) and finding it disagreed with my prior commit's paths. The router mounts at /v1/insights and registers "/summary" and "/summary/detail" directly — there is no "velocity" path segment anywhere in this router, unlike my previous commit's claim. My earlier read of this file was simply wrong. Confirmed the correct paths two ways: (1) the exact route strings in velocity.js, and (2) the mocks package's own updated insights_summary/insights_summary_detail operations, whose params, permissions (ViewVelocity), and response shapes match getVelocityInsights/getVelocityInsightsDetail exactly, registered at /insights/summary and /insights/summary/detail in openapi.yaml. --- src/api-client/api-client.test.ts | 4 +--- src/api-client/api-client.ts | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/api-client/api-client.test.ts b/src/api-client/api-client.test.ts index b9c50b0..6ba982c 100644 --- a/src/api-client/api-client.test.ts +++ b/src/api-client/api-client.test.ts @@ -1227,9 +1227,7 @@ describe.skipIf(isLiveMode)('APIClient core', () => { describe('insights', () => { it('should get velocity insights', async () => { - server.use( - http.get(`${BASE_URL}/insights/velocity/summary`, () => HttpResponse.json({ data: [] })) - ); + server.use(http.get(`${BASE_URL}/insights/summary`, () => HttpResponse.json({ data: [] }))); const result = await client.getVelocityInsights({ from: 1000, to: 2000, diff --git a/src/api-client/api-client.ts b/src/api-client/api-client.ts index 6b3654e..c7466dc 100644 --- a/src/api-client/api-client.ts +++ b/src/api-client/api-client.ts @@ -1959,7 +1959,7 @@ export class APIClient { team_ids?: number[]; owner_ids?: number[]; }): Promise { - const response = await this.request('GET', '/insights/velocity/summary', { + const response = await this.request('GET', '/insights/summary', { params: this.buildInsightParams(params), }); return response.data; @@ -2419,7 +2419,7 @@ export class APIClient { teams?: string; applications?: string; }): Promise { - const response = await this.request('GET', '/insights/velocity/summary/detail', { + const response = await this.request('GET', '/insights/summary/detail', { params: this.buildInsightParams(params), }); return response.data; From bd63c1e96d805547531fd17eb200a0e82d5d394a Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Sat, 26 Sep 2026 10:42:37 +0100 Subject: [PATCH 34/35] fix(cli): validate notifications --limit, stop suggesting a page past the last one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from CodeRabbit's review of this PR: - notifications list: --limit was passed straight to Array.slice with no validation. --limit -1 reached slice(0, -1), silently dropping the last notification and printing a nonsensical "Showing -1 of N" warning; a non-numeric value became NaN, making slice(0, NaN) return an empty array with no explanation. Now rejected at the CLI boundary (parsePositiveIntFlag, matching the existing precedent in commands/activity/index.ts) before it reaches the core function, with a defense-in-depth fallback to the default limit in listNotifications itself for any other caller that passes a bad value directly. - permissions/permission_categories/policies pagination: the client-side truncation warning used `data.length > items` to decide whether to print "Use --page to see more", which stays true forever once the full list is longer than one page — so the last page (e.g. 30 results, --items 20 --page 2, showing the final 10) still suggested a nonexistent next page. Uses the same `page * items < data.length` condition `pagination.hasMore` already computed correctly two lines below, so the warning and the hasMore field can no longer disagree. --- src/commands/notifications/index.ts | 14 +++++++++-- .../notifications/notifications.test.ts | 19 +++++++++++++++ src/core/notifications/notifications.test.ts | 24 +++++++++++++++++++ src/core/notifications/notifications.ts | 5 +++- src/core/permissions/list.ts | 2 +- src/core/permissions/permissions.test.ts | 7 ++++++ 6 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/commands/notifications/index.ts b/src/commands/notifications/index.ts index 88f2d19..4b382e3 100644 --- a/src/commands/notifications/index.ts +++ b/src/commands/notifications/index.ts @@ -19,17 +19,27 @@ export const notificationsCommand = new Command('notifications') .alias('notif') .description('Notifications commands'); +function parsePositiveIntFlag(value: string, flag: string): number { + const parsed = parseInt(value, 10); + if (Number.isNaN(parsed) || parsed <= 0) { + throw new Error(`Invalid value for ${flag}: "${value}". Expected a positive integer.`); + } + return parsed; +} + const listCommand = new Command('list') .description('List notifications') .option('--cursor ', 'pagination cursor', parseInt) - .option('--limit ', 'max number of notifications to show', parseInt) + .option('--limit ', 'max number of notifications to show') .action( withErrorHandling(async (options) => { const globalOptions = getGlobalOptions(listCommand); const client = await getAPIClientFromOptions(globalOptions); + const limit = + options.limit !== undefined ? parsePositiveIntFlag(options.limit, '--limit') : undefined; const result = await coreListNotifications(client, { cursor: options.cursor, - limit: options.limit, + limit, }); printFormatted(result.data, globalOptions); if (result.warnings && globalOptions.output !== 'json' && globalOptions.output !== 'yaml') { diff --git a/src/commands/notifications/notifications.test.ts b/src/commands/notifications/notifications.test.ts index 5c5f133..9e40e28 100644 --- a/src/commands/notifications/notifications.test.ts +++ b/src/commands/notifications/notifications.test.ts @@ -78,6 +78,25 @@ describe('notifications command', () => { expect(output).toContain('Showing 20 of 50 notifications'); }); + it('should reject a negative --limit instead of returning an inverted slice', async () => { + mockClient.getNotifications.mockResolvedValue([{ id: 1 }]); + await expect( + notificationsCommand.parseAsync(['node', 'test', 'list', '--limit', '-1']) + ).rejects.toThrow('process.exit: 1'); + const errorOutput = consoleErrorSpy.mock.calls.flat().join(' '); + expect(errorOutput).toContain('Invalid value for --limit'); + expect(mockClient.getNotifications).not.toHaveBeenCalled(); + }); + + it('should reject a non-numeric --limit instead of silently returning nothing', async () => { + mockClient.getNotifications.mockResolvedValue([{ id: 1 }]); + await expect( + notificationsCommand.parseAsync(['node', 'test', 'list', '--limit', 'abc']) + ).rejects.toThrow('process.exit: 1'); + const errorOutput = consoleErrorSpy.mock.calls.flat().join(' '); + expect(errorOutput).toContain('Invalid value for --limit'); + }); + it('should mark notifications as seen', async () => { mockClient.markNotificationsSeen.mockResolvedValue(undefined); await notificationsCommand.parseAsync(['node', 'test', 'mark-seen']); diff --git a/src/core/notifications/notifications.test.ts b/src/core/notifications/notifications.test.ts index e80f4c5..eeecb7e 100644 --- a/src/core/notifications/notifications.test.ts +++ b/src/core/notifications/notifications.test.ts @@ -88,5 +88,29 @@ describe('notifications', () => { const result = await listNotifications(mockClient as any, { limit: 5 }); expect((result.data as unknown[]).length).toBe(5); }); + + it('should fall back to the default limit for a negative value instead of an inverted slice', async () => { + const all = Array.from({ length: 50 }, (_, i) => ({ id: i })); + mockClient.getNotifications.mockResolvedValue(all); + const result = await listNotifications(mockClient as any, { limit: -1 }); + expect((result.data as unknown[]).length).toBe(20); + expect(result.warnings).toEqual([ + 'Showing 20 of 50 notifications. Use --limit to show more.', + ]); + }); + + it('should fall back to the default limit for a non-integer value instead of silently returning nothing', async () => { + const all = Array.from({ length: 50 }, (_, i) => ({ id: i })); + mockClient.getNotifications.mockResolvedValue(all); + const result = await listNotifications(mockClient as any, { limit: NaN }); + expect((result.data as unknown[]).length).toBe(20); + }); + + it('should fall back to the default limit for zero', async () => { + const all = Array.from({ length: 50 }, (_, i) => ({ id: i })); + mockClient.getNotifications.mockResolvedValue(all); + const result = await listNotifications(mockClient as any, { limit: 0 }); + expect((result.data as unknown[]).length).toBe(20); + }); }); }); diff --git a/src/core/notifications/notifications.ts b/src/core/notifications/notifications.ts index 64a32a1..f1c1562 100644 --- a/src/core/notifications/notifications.ts +++ b/src/core/notifications/notifications.ts @@ -13,7 +13,10 @@ export async function listNotifications( params: ListNotificationsParams ): Promise> { const all = await client.getNotifications(params.cursor); - const limit = params.limit ?? DEFAULT_NOTIFICATIONS_LIMIT; + const limit = + params.limit !== undefined && Number.isInteger(params.limit) && params.limit > 0 + ? params.limit + : DEFAULT_NOTIFICATIONS_LIMIT; const data = all.slice(0, limit); return { data, diff --git a/src/core/permissions/list.ts b/src/core/permissions/list.ts index fe8ded8..556e701 100644 --- a/src/core/permissions/list.ts +++ b/src/core/permissions/list.ts @@ -24,7 +24,7 @@ function paginateClientSide( const start = (page - 1) * items; const pageData = data.slice(start, start + items); const warnings = - data.length > items + page * items < data.length ? [`Showing ${pageData.length} of ${data.length} results. Use --page to see more.`] : undefined; return { pageData, warnings }; diff --git a/src/core/permissions/permissions.test.ts b/src/core/permissions/permissions.test.ts index 7caab7b..b3d7639 100644 --- a/src/core/permissions/permissions.test.ts +++ b/src/core/permissions/permissions.test.ts @@ -56,6 +56,13 @@ describe('listPermissions pagination', () => { expect(result.pagination?.hasMore).toBe(false); }); + it('should not warn to see more on the final page even though the full list exceeds items', async () => { + const all = Array.from({ length: 30 }, (_, i) => ({ id: i })); + mockClient.listPermissions.mockResolvedValue(all); + const result = await listPermissions(mockClient as any, { items: 20, page: 2 }); + expect(result.warnings).toBeUndefined(); + }); + it('should not warn when everything fits on one page', async () => { mockClient.listPermissions.mockResolvedValue([{ id: 1 }]); const result = await listPermissions(mockClient as any, { items: 20, page: 1 }); From cd617e600f76a26936d7761d5d18670e7cb080a3 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Sat, 26 Sep 2026 20:01:10 +0100 Subject: [PATCH 35/35] chore(deps): bump absmartly-api-mocks to ^1.0.9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the just-published FT-2268 mocks fix (corrected notifications/has-new path, /events handlers, and schema-compatible Goal/Metric factories) that this branch's own investigation reported upstream. Full suite (2734 tests), typecheck, and lint all pass against the new version with no CLI code changes required — the mismatches were entirely on the mocks side. --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 2a2799c..14a4a9e 100644 --- a/bun.lock +++ b/bun.lock @@ -28,7 +28,7 @@ "@typescript-eslint/parser": "^8.21.0", "@vitest/coverage-v8": "^4.1.4", "@vitest/ui": "^4.0.18", - "absmartly-api-mocks": "npm:@absmartly/api-mocks@^1.0.6", + "absmartly-api-mocks": "^1.0.9", "dotenv": "^16.4.7", "eslint": "^9.19.0", "jsdom": "^28.0.0", @@ -307,7 +307,7 @@ "@vitest/utils": ["@vitest/utils@4.1.4", "", { "dependencies": { "@vitest/pretty-format": "4.1.4", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw=="], - "absmartly-api-mocks": ["@absmartly/api-mocks@1.0.6", "", { "dependencies": { "@apidevtools/json-schema-ref-parser": "^15.2.2", "@faker-js/faker": "^10.2.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "js-yaml": "^4.1.1" }, "peerDependencies": { "msw": "^2.0.0" } }, "sha512-dhLFeIjrZ2D+JGIo9JPKTnKzLLwIkNze1s24ZPs/8H3KqvEC52TuDuWC04PY15q9kjE5G9P3GeyWF/XeqFF9ug=="], + "absmartly-api-mocks": ["@absmartly/api-mocks@1.0.9", "", { "dependencies": { "@apidevtools/json-schema-ref-parser": "^15.2.2", "@faker-js/faker": "^10.2.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "js-yaml": "^4.1.1" }, "peerDependencies": { "msw": "^2.0.0" } }, "sha512-y9U9eqDuQinssHLPGliw5WhhPeQ2OShr0Geh0eFGVUof8+D0kz6ibpXANkCaCEEogOIkQvXivRofFhdpioHziQ=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], diff --git a/package.json b/package.json index 8be69ef..d54e546 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,7 @@ "@typescript-eslint/parser": "^8.21.0", "@vitest/coverage-v8": "^4.1.4", "@vitest/ui": "^4.0.18", - "absmartly-api-mocks": "npm:@absmartly/api-mocks@^1.0.6", + "absmartly-api-mocks": "npm:@absmartly/api-mocks@^1.0.9", "dotenv": "^16.4.7", "eslint": "^9.19.0", "jsdom": "^28.0.0",