From 1d19267321c1dc39ecc171f17e7c19b645ffb9c2 Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 17 Apr 2026 21:14:59 -0400 Subject: [PATCH 1/5] feat(organization): surface Socket API quota usage, max, and refresh time `socket organization quota` was hidden and only showed remaining units, giving users no way to see how close they were to their cap. The SDK already returns `maxQuota` and `nextWindowRefresh` on the same call, so we now render all three. - Unhide the `quota` subcommand so it shows up in help - Show `remaining / max (N% used)` plus a human-readable refresh window in text and markdown output - Fall back to remaining-only when `maxQuota` is missing - Update the description to match the actual behavior --- CHANGELOG.md | 4 + .../organization/cmd-organization-quota.mts | 5 +- .../commands/organization/output-quota.mts | 50 ++++++++++- .../cmd-organization-quota.test.mts | 6 +- .../organization/output-quota.test.mts | 84 +++++++++++++++++-- 5 files changed, 136 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 187edda2a..2573fe7e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Changed + +- `socket organization quota` is no longer hidden and now shows remaining quota, total quota, usage percentage, and the next refresh time in text and markdown output. + ### Added - Advanced TUI components and styling for rich terminal interfaces: diff --git a/packages/cli/src/commands/organization/cmd-organization-quota.mts b/packages/cli/src/commands/organization/cmd-organization-quota.mts index 3c53f7811..f77ef5b35 100644 --- a/packages/cli/src/commands/organization/cmd-organization-quota.mts +++ b/packages/cli/src/commands/organization/cmd-organization-quota.mts @@ -14,8 +14,9 @@ import type { const config: CliCommandConfig = { commandName: 'quota', - description: 'List organizations associated with the Socket API token', - hidden: true, + description: + 'Show remaining Socket API quota for the current token, plus refresh window', + hidden: false, flags: { ...commonFlags, ...outputFlags, diff --git a/packages/cli/src/commands/organization/output-quota.mts b/packages/cli/src/commands/organization/output-quota.mts index e1ff9200f..e9ba9007f 100644 --- a/packages/cli/src/commands/organization/output-quota.mts +++ b/packages/cli/src/commands/organization/output-quota.mts @@ -8,8 +8,47 @@ import type { CResult, OutputKind } from '../../types.mts' import type { SocketSdkSuccessResult } from '@socketsecurity/sdk' const logger = getDefaultLogger() +type QuotaData = SocketSdkSuccessResult<'getQuota'>['data'] + +function formatRefresh(nextWindowRefresh: string | null | undefined): string { + if (!nextWindowRefresh) { + return 'unknown' + } + const ts = Date.parse(nextWindowRefresh) + if (Number.isNaN(ts)) { + return nextWindowRefresh + } + const now = Date.now() + const diffMs = ts - now + const date = new Date(ts).toISOString() + if (diffMs <= 0) { + return `${date} (due now)` + } + const mins = Math.round(diffMs / 60_000) + if (mins < 60) { + return `${date} (in ${mins} min)` + } + const hours = Math.round(mins / 60) + if (hours < 48) { + return `${date} (in ${hours} h)` + } + const days = Math.round(hours / 24) + return `${date} (in ${days} d)` +} + +function formatUsageLine(data: QuotaData): string { + const remaining = data.quota + const max = data.maxQuota + if (!max) { + return `Quota remaining: ${remaining}` + } + const used = Math.max(0, max - remaining) + const pct = Math.round((used / max) * 100) + return `Quota remaining: ${remaining} / ${max} (${pct}% used)` +} + export async function outputQuota( - result: CResult['data']>, + result: CResult, outputKind: OutputKind = 'text', ): Promise { if (!result.ok) { @@ -25,14 +64,19 @@ export async function outputQuota( return } + const usageLine = formatUsageLine(result.data) + const refreshLine = `Next refresh: ${formatRefresh(result.data.nextWindowRefresh)}` + if (outputKind === 'markdown') { logger.log(mdHeader('Quota')) logger.log('') - logger.log(`Quota left on the current API token: ${result.data.quota}`) + logger.log(`- ${usageLine}`) + logger.log(`- ${refreshLine}`) logger.log('') return } - logger.log(`Quota left on the current API token: ${result.data.quota}`) + logger.log(usageLine) + logger.log(refreshLine) logger.log('') } diff --git a/packages/cli/test/unit/commands/organization/cmd-organization-quota.test.mts b/packages/cli/test/unit/commands/organization/cmd-organization-quota.test.mts index 352b48363..b526c296c 100644 --- a/packages/cli/test/unit/commands/organization/cmd-organization-quota.test.mts +++ b/packages/cli/test/unit/commands/organization/cmd-organization-quota.test.mts @@ -58,12 +58,12 @@ describe('cmd-organization-quota', () => { describe('command metadata', () => { it('should have correct description', () => { expect(cmdOrganizationQuota.description).toBe( - 'List organizations associated with the Socket API token', + 'Show remaining Socket API quota for the current token, plus refresh window', ) }) - it('should be hidden', () => { - expect(cmdOrganizationQuota.hidden).toBe(true) + it('should not be hidden', () => { + expect(cmdOrganizationQuota.hidden).toBe(false) }) }) diff --git a/packages/cli/test/unit/commands/organization/output-quota.test.mts b/packages/cli/test/unit/commands/organization/output-quota.test.mts index 8c14a775f..01399aae3 100644 --- a/packages/cli/test/unit/commands/organization/output-quota.test.mts +++ b/packages/cli/test/unit/commands/organization/output-quota.test.mts @@ -9,7 +9,9 @@ * Test Coverage: * - JSON format output for successful results * - JSON format error output with exit codes - * - Text format with quota information display + * - Text format with remaining/max/refresh display + * - Fallback when maxQuota is missing + * - Refresh time rendering when nextWindowRefresh is set * - Text format error output with badges * - Markdown format output * - Zero quota handling @@ -135,18 +137,83 @@ describe('outputQuota', () => { const result = createSuccessResult({ quota: 500, + maxQuota: 1000, + nextWindowRefresh: null, }) process.exitCode = undefined await outputQuota(result as any, 'text') expect(mockLogger.log).toHaveBeenCalledWith( - 'Quota left on the current API token: 500', + 'Quota remaining: 500 / 1000 (50% used)', ) + expect(mockLogger.log).toHaveBeenCalledWith('Next refresh: unknown') expect(mockLogger.log).toHaveBeenCalledWith('') expect(process.exitCode).toBeUndefined() }) + it('falls back to remaining-only when maxQuota is missing', async () => { + const mockLogger = { + fail: vi.fn(), + info: vi.fn(), + log: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } + + vi.doMock('@socketsecurity/lib/logger', () => ({ + getDefaultLogger: () => mockLogger, + logger: mockLogger, + })) + + const { outputQuota } = + await import('../../../../src/commands/organization/output-quota.mts') + + const result = createSuccessResult({ + quota: 250, + maxQuota: 0, + nextWindowRefresh: null, + }) + + process.exitCode = undefined + await outputQuota(result as any, 'text') + + expect(mockLogger.log).toHaveBeenCalledWith('Quota remaining: 250') + }) + + it('formats nextWindowRefresh when provided', async () => { + const mockLogger = { + fail: vi.fn(), + info: vi.fn(), + log: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } + + vi.doMock('@socketsecurity/lib/logger', () => ({ + getDefaultLogger: () => mockLogger, + logger: mockLogger, + })) + + const { outputQuota } = + await import('../../../../src/commands/organization/output-quota.mts') + + const result = createSuccessResult({ + quota: 100, + maxQuota: 1000, + nextWindowRefresh: '2099-01-01T00:00:00.000Z', + }) + + process.exitCode = undefined + await outputQuota(result as any, 'text') + + // Exact "in X d" count is time-sensitive; just confirm it rendered the ISO date. + const calls = mockLogger.log.mock.calls.map((c: any[]) => c[0]) + expect(calls.some((c: unknown) => typeof c === 'string' && c.includes('2099-01-01T00:00:00.000Z'))).toBe(true) + }) + it('outputs error in text format', async () => { // Create mocks INSIDE each test. const mockLogger = { @@ -214,6 +281,8 @@ describe('outputQuota', () => { const result = createSuccessResult({ quota: 750, + maxQuota: 1000, + nextWindowRefresh: null, }) process.exitCode = undefined @@ -222,8 +291,9 @@ describe('outputQuota', () => { expect(mockLogger.log).toHaveBeenCalledWith('# Quota') expect(mockLogger.log).toHaveBeenCalledWith('') expect(mockLogger.log).toHaveBeenCalledWith( - 'Quota left on the current API token: 750', + '- Quota remaining: 750 / 1000 (25% used)', ) + expect(mockLogger.log).toHaveBeenCalledWith('- Next refresh: unknown') }) it('handles zero quota correctly', async () => { @@ -249,13 +319,15 @@ describe('outputQuota', () => { const result = createSuccessResult({ quota: 0, + maxQuota: 1000, + nextWindowRefresh: null, }) process.exitCode = undefined await outputQuota(result as any, 'text') expect(mockLogger.log).toHaveBeenCalledWith( - 'Quota left on the current API token: 0', + 'Quota remaining: 0 / 1000 (100% used)', ) }) @@ -282,13 +354,15 @@ describe('outputQuota', () => { const result = createSuccessResult({ quota: 100, + maxQuota: 1000, + nextWindowRefresh: null, }) process.exitCode = undefined await outputQuota(result as any) expect(mockLogger.log).toHaveBeenCalledWith( - 'Quota left on the current API token: 100', + 'Quota remaining: 100 / 1000 (90% used)', ) expect(mockLogger.log).toHaveBeenCalledWith('') }) From 3fb354587acc14d5056fe1f5fb69ed2e4bdf7c15 Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 17 Apr 2026 22:00:37 -0400 Subject: [PATCH 2/5] fix(organization): compute quota refresh units directly from diffMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Cursor bugbot feedback on PR #1236. Cascaded rounding (hours derived from rounded minutes, days derived from rounded hours) produced incorrect output at boundaries — e.g. 89.5 min rounded to 90 min, then to 2 h, when 89.5 min is closer to 1 h. Each unit is now computed directly from the raw `diffMs` delta. --- .../src/commands/organization/output-quota.mts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/organization/output-quota.mts b/packages/cli/src/commands/organization/output-quota.mts index e9ba9007f..457adec53 100644 --- a/packages/cli/src/commands/organization/output-quota.mts +++ b/packages/cli/src/commands/organization/output-quota.mts @@ -24,16 +24,16 @@ function formatRefresh(nextWindowRefresh: string | null | undefined): string { if (diffMs <= 0) { return `${date} (due now)` } - const mins = Math.round(diffMs / 60_000) - if (mins < 60) { - return `${date} (in ${mins} min)` + // Compute each unit directly from diffMs to avoid cascaded rounding + // errors — e.g. 89.5 min would round to 90 min, then to 2 h via the + // chain, even though 89.5 min is closer to 1 h. + if (diffMs < 3_600_000) { + return `${date} (in ${Math.round(diffMs / 60_000)} min)` } - const hours = Math.round(mins / 60) - if (hours < 48) { - return `${date} (in ${hours} h)` + if (diffMs < 172_800_000) { + return `${date} (in ${Math.round(diffMs / 3_600_000)} h)` } - const days = Math.round(hours / 24) - return `${date} (in ${days} d)` + return `${date} (in ${Math.round(diffMs / 86_400_000)} d)` } function formatUsageLine(data: QuotaData): string { From b5cc26b80e715c49c752efce21af720669e22e81 Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 17 Apr 2026 22:27:32 -0400 Subject: [PATCH 3/5] fix(organization): avoid 'in 0 min' and 'in 60 min' refresh edge cases --- .../commands/organization/output-quota.mts | 14 +++- .../organization/output-quota.test.mts | 71 +++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/organization/output-quota.mts b/packages/cli/src/commands/organization/output-quota.mts index 457adec53..052ea8a98 100644 --- a/packages/cli/src/commands/organization/output-quota.mts +++ b/packages/cli/src/commands/organization/output-quota.mts @@ -24,13 +24,21 @@ function formatRefresh(nextWindowRefresh: string | null | undefined): string { if (diffMs <= 0) { return `${date} (due now)` } + // Under 60 seconds, say "<1 min" so we never show the misleading + // "in 0 min". + if (diffMs < 60_000) { + return `${date} (in <1 min)` + } // Compute each unit directly from diffMs to avoid cascaded rounding // errors — e.g. 89.5 min would round to 90 min, then to 2 h via the - // chain, even though 89.5 min is closer to 1 h. - if (diffMs < 3_600_000) { + // chain, even though 89.5 min is closer to 1 h. The thresholds below + // promote to the next unit before rounding would produce a degenerate + // display (e.g. 59.5 min → "in 60 min"), so the midpoint value there + // is shown as "in 1 h" instead. + if (diffMs < 3_570_000) { return `${date} (in ${Math.round(diffMs / 60_000)} min)` } - if (diffMs < 172_800_000) { + if (diffMs < 171_000_000) { return `${date} (in ${Math.round(diffMs / 3_600_000)} h)` } return `${date} (in ${Math.round(diffMs / 86_400_000)} d)` diff --git a/packages/cli/test/unit/commands/organization/output-quota.test.mts b/packages/cli/test/unit/commands/organization/output-quota.test.mts index 01399aae3..1db5be9e3 100644 --- a/packages/cli/test/unit/commands/organization/output-quota.test.mts +++ b/packages/cli/test/unit/commands/organization/output-quota.test.mts @@ -214,6 +214,77 @@ describe('outputQuota', () => { expect(calls.some((c: unknown) => typeof c === 'string' && c.includes('2099-01-01T00:00:00.000Z'))).toBe(true) }) + it('shows <1 min when refresh is within 60 seconds', async () => { + // Regression: Math.round(diffMs / 60_000) used to produce "in 0 min" + // for 1–29,999 ms. See Cursor bugbot feedback on PR #1236. + const mockLogger = { + fail: vi.fn(), + info: vi.fn(), + log: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } + + vi.doMock('@socketsecurity/lib/logger', () => ({ + getDefaultLogger: () => mockLogger, + logger: mockLogger, + })) + + const { outputQuota } = + await import('../../../../src/commands/organization/output-quota.mts') + + const soon = new Date(Date.now() + 5_000).toISOString() + const result = createSuccessResult({ + quota: 10, + maxQuota: 1000, + nextWindowRefresh: soon, + }) + + process.exitCode = undefined + await outputQuota(result as any, 'text') + + const calls = mockLogger.log.mock.calls.map((c: any[]) => c[0]) + expect(calls.some((c: unknown) => typeof c === 'string' && c.includes('<1 min'))).toBe(true) + expect(calls.some((c: unknown) => typeof c === 'string' && c.includes('0 min'))).toBe(false) + }) + + it('promotes to hours before producing "in 60 min" at the boundary', async () => { + // Regression: at diffMs ~= 59.5 min, Math.round rounded up to 60, + // giving "in 60 min" instead of "in 1 h". See Cursor bugbot feedback + // on PR #1236. + const mockLogger = { + fail: vi.fn(), + info: vi.fn(), + log: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } + + vi.doMock('@socketsecurity/lib/logger', () => ({ + getDefaultLogger: () => mockLogger, + logger: mockLogger, + })) + + const { outputQuota } = + await import('../../../../src/commands/organization/output-quota.mts') + + const near = new Date(Date.now() + 59.8 * 60_000).toISOString() + const result = createSuccessResult({ + quota: 10, + maxQuota: 1000, + nextWindowRefresh: near, + }) + + process.exitCode = undefined + await outputQuota(result as any, 'text') + + const calls = mockLogger.log.mock.calls.map((c: any[]) => c[0]) + expect(calls.some((c: unknown) => typeof c === 'string' && c.includes('60 min'))).toBe(false) + expect(calls.some((c: unknown) => typeof c === 'string' && c.includes('1 h'))).toBe(true) + }) + it('outputs error in text format', async () => { // Create mocks INSIDE each test. const mockLogger = { From 0efefe52483cf0a38ed5ded82ac1baf5a63334c7 Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 20 Apr 2026 14:31:40 -0400 Subject: [PATCH 4/5] chore(quota): condense verbose rounding comment in formatRefresh --- .../cli/src/commands/organization/output-quota.mts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/organization/output-quota.mts b/packages/cli/src/commands/organization/output-quota.mts index 052ea8a98..5c953e9ff 100644 --- a/packages/cli/src/commands/organization/output-quota.mts +++ b/packages/cli/src/commands/organization/output-quota.mts @@ -24,17 +24,12 @@ function formatRefresh(nextWindowRefresh: string | null | undefined): string { if (diffMs <= 0) { return `${date} (due now)` } - // Under 60 seconds, say "<1 min" so we never show the misleading - // "in 0 min". + // Under a minute, say "<1 min" rather than the misleading "in 0 min". if (diffMs < 60_000) { return `${date} (in <1 min)` } - // Compute each unit directly from diffMs to avoid cascaded rounding - // errors — e.g. 89.5 min would round to 90 min, then to 2 h via the - // chain, even though 89.5 min is closer to 1 h. The thresholds below - // promote to the next unit before rounding would produce a degenerate - // display (e.g. 59.5 min → "in 60 min"), so the midpoint value there - // is shown as "in 1 h" instead. + // Thresholds promote one unit early (59.5 min → "in 1 h") to avoid + // degenerate displays like "in 60 min" from naive rounding. if (diffMs < 3_570_000) { return `${date} (in ${Math.round(diffMs / 60_000)} min)` } From d43acff0c0e3eeb6b26b85bf2e245f7307aca18d Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 20 Apr 2026 14:50:54 -0400 Subject: [PATCH 5/5] chore(quota tests): drop self-referential PR number from regression comments --- .../test/unit/commands/organization/output-quota.test.mts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/cli/test/unit/commands/organization/output-quota.test.mts b/packages/cli/test/unit/commands/organization/output-quota.test.mts index 1db5be9e3..7691a1935 100644 --- a/packages/cli/test/unit/commands/organization/output-quota.test.mts +++ b/packages/cli/test/unit/commands/organization/output-quota.test.mts @@ -216,7 +216,7 @@ describe('outputQuota', () => { it('shows <1 min when refresh is within 60 seconds', async () => { // Regression: Math.round(diffMs / 60_000) used to produce "in 0 min" - // for 1–29,999 ms. See Cursor bugbot feedback on PR #1236. + // for 1–29,999 ms. const mockLogger = { fail: vi.fn(), info: vi.fn(), @@ -251,8 +251,7 @@ describe('outputQuota', () => { it('promotes to hours before producing "in 60 min" at the boundary', async () => { // Regression: at diffMs ~= 59.5 min, Math.round rounded up to 60, - // giving "in 60 min" instead of "in 1 h". See Cursor bugbot feedback - // on PR #1236. + // giving "in 60 min" instead of "in 1 h". const mockLogger = { fail: vi.fn(), info: vi.fn(),