Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
58 changes: 55 additions & 3 deletions packages/cli/src/commands/organization/output-quota.mts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,55 @@ 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 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. 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)`
Comment thread
jdalton marked this conversation as resolved.
}
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<SocketSdkSuccessResult<'getQuota'>['data']>,
result: CResult<QuotaData>,
outputKind: OutputKind = 'text',
): Promise<void> {
if (!result.ok) {
Expand All @@ -25,14 +72,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('')
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})

Expand Down
155 changes: 150 additions & 5 deletions packages/cli/test/unit/commands/organization/output-quota.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -135,18 +137,154 @@ 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. 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 = {
Expand Down Expand Up @@ -214,6 +352,8 @@ describe('outputQuota', () => {

const result = createSuccessResult({
quota: 750,
maxQuota: 1000,
nextWindowRefresh: null,
})

process.exitCode = undefined
Expand All @@ -222,8 +362,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 () => {
Expand All @@ -249,13 +390,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)',
)
})

Expand All @@ -282,13 +425,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('')
})
Expand Down