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..5c953e9ff 100644 --- a/packages/cli/src/commands/organization/output-quota.mts +++ b/packages/cli/src/commands/organization/output-quota.mts @@ -8,8 +8,50 @@ 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)` + } + // Under a minute, say "<1 min" rather than the misleading "in 0 min". + if (diffMs < 60_000) { + return `${date} (in <1 min)` + } + // 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)` + } + 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)` +} + +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 +67,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..7691a1935 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,153 @@ 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('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. + 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". + 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 = { @@ -214,6 +351,8 @@ describe('outputQuota', () => { const result = createSuccessResult({ quota: 750, + maxQuota: 1000, + nextWindowRefresh: null, }) process.exitCode = undefined @@ -222,8 +361,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 +389,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 +424,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('') })