Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
50 changes: 47 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,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)`
}
// 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)`
Comment thread
jdalton marked this conversation as resolved.
}
if (diffMs < 172_800_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 +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('')
}
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
84 changes: 79 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,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 = {
Expand Down Expand Up @@ -214,6 +281,8 @@ describe('outputQuota', () => {

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

process.exitCode = undefined
Expand All @@ -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 () => {
Expand All @@ -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)',
)
})

Expand All @@ -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('')
})
Expand Down