From ad2db5992247bb9792b98db567f5f4ea82990f4d Mon Sep 17 00:00:00 2001 From: Jeremy Pollock Date: Mon, 2 Feb 2026 13:47:12 -0800 Subject: [PATCH] feat: add site disk size to sites list command Add optional --size/-s flag to show disk usage for each site. Size includes both site directory and MySQL runtime data. Uses native OS commands for efficient size calculation: - macOS/Linux: du -sk - Windows: PowerShell Get-ChildItem Closes #4 Co-Authored-By: Claude Sonnet 4.5 --- packages/cli/src/formatters/index.ts | 51 +++++++++- packages/cli/src/index.ts | 37 +++++-- packages/cli/src/utils/siteSize.ts | 86 ++++++++++++++++ packages/cli/tests/siteSize.test.ts | 143 +++++++++++++++++++++++++++ 4 files changed, 308 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/utils/siteSize.ts create mode 100644 packages/cli/tests/siteSize.test.ts diff --git a/packages/cli/src/formatters/index.ts b/packages/cli/src/formatters/index.ts index 0012f8b..51069e8 100644 --- a/packages/cli/src/formatters/index.ts +++ b/packages/cli/src/formatters/index.ts @@ -124,15 +124,29 @@ export interface SiteInfo { domain: string; status: string; path?: string; + size?: string; + sizeBytes?: number; } export function formatSiteList( sites: SiteInfo[], format: OutputFormat, - options: { noColor?: boolean } = {} + options: { noColor?: boolean; showSize?: boolean } = {} ): string { + const hasSize = options.showSize && sites.some((s) => s.size); + switch (format) { case 'json': + // Include size fields in JSON when showSize is true + if (hasSize) { + return formatJson( + sites.map((s) => ({ + ...s, + size: s.size, + sizeBytes: s.sizeBytes, + })) + ); + } return formatJson(sites); case 'quiet': @@ -144,6 +158,25 @@ export function formatSiteList( return 'No sites found.'; } + if (hasSize) { + // Calculate total size + const totalBytes = sites.reduce((acc, s) => acc + (s.sizeBytes || 0), 0); + const totalFormatted = formatBytesSimple(totalBytes); + + const table = formatTable( + ['Name', 'Domain', 'Status', 'Size'], + sites.map((s) => [ + s.name, + s.domain, + formatStatus(s.status, options), + s.size || '?', + ]), + options + ); + + return `${table}\n\nTotal: ${sites.length} site(s), ${totalFormatted}`; + } + return formatTable( ['Name', 'Domain', 'Status'], sites.map((s) => [s.name, s.domain, formatStatus(s.status, options)]), @@ -152,6 +185,22 @@ export function formatSiteList( } } +/** + * Simple byte formatter for totals + */ +function formatBytesSimple(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return '?'; + if (bytes === 0) return '0 B'; + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const k = 1024; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + const value = bytes / Math.pow(k, i); + const precision = i >= 3 ? 2 : i >= 2 ? 1 : 0; + + return `${value.toFixed(precision)} ${units[i]}`; +} + /** * Site detail formatter */ diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8caffb0..5f17a6f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -24,6 +24,7 @@ import { FormatterOptions, } from './formatters'; import * as analytics from './analytics'; +import { getSiteSize } from './utils/siteSize'; // Package info const PACKAGE_NAME = '@local-labs-jpollock/local-cli'; @@ -267,6 +268,7 @@ sites .command('list') .description('List all WordPress sites') .option('--status ', 'Filter by status (running|stopped|all)', 'all') + .option('-s, --size', 'Show disk size for each site') .action(async (cmdOptions) => { const globalOpts = program.opts() as FormatterOptions; const format = getOutputFormat(globalOpts); @@ -287,20 +289,39 @@ sites } `); - let sitesToShow = data.sites.map((s) => ({ - id: s.id, - name: s.name, - domain: s.domain, - status: s.status.toLowerCase(), - path: s.path, - })); + let sitesToShow = data.sites.map((s) => { + const base = { + id: s.id, + name: s.name, + domain: s.domain, + status: s.status.toLowerCase(), + path: s.path, + }; + + // Calculate size if requested + if (cmdOptions.size) { + const sizeResult = getSiteSize(s); + return { + ...base, + size: sizeResult.formatted, + sizeBytes: sizeResult.bytes, + }; + } + + return base; + }); // Filter by status if specified if (cmdOptions.status !== 'all') { sitesToShow = sitesToShow.filter((s) => s.status === cmdOptions.status); } - console.log(formatSiteList(sitesToShow, format, { noColor: globalOpts.noColor })); + console.log( + formatSiteList(sitesToShow, format, { + noColor: globalOpts.noColor, + showSize: cmdOptions.size, + }) + ); } catch (error: any) { console.error(formatError(error.message)); process.exit(1); diff --git a/packages/cli/src/utils/siteSize.ts b/packages/cli/src/utils/siteSize.ts new file mode 100644 index 0000000..a26b8b1 --- /dev/null +++ b/packages/cli/src/utils/siteSize.ts @@ -0,0 +1,86 @@ +/** + * Site Size Calculation Utility + * + * Uses native OS commands for efficient disk size calculation: + * - macOS/Linux: du -sk (available since 1971!) + * - Windows: PowerShell Get-ChildItem | Measure-Object + */ + +import { execSync } from 'child_process'; +import { join } from 'path'; +import { homedir } from 'os'; +import { existsSync } from 'fs'; +import { getLocalPaths } from '../bootstrap/paths'; + +/** + * Get directory size using native OS commands. + * Returns size in bytes, or 0 if directory doesn't exist or can't be read. + */ +function getDirectorySize(dirPath: string): number { + if (!existsSync(dirPath)) { + return 0; + } + + try { + if (process.platform === 'win32') { + // Windows: Use PowerShell + const cmd = `powershell -Command "(Get-ChildItem -Recurse -Force '${dirPath}' -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum"`; + const output = execSync(cmd, { encoding: 'utf8', timeout: 60000 }); + return parseInt(output.trim(), 10) || 0; + } else { + // macOS/Linux: Use du (available since 1971!) + const output = execSync(`du -sk "${dirPath}" 2>/dev/null`, { + encoding: 'utf8', + timeout: 60000, + }); + // du -sk returns size in KB + return (parseInt(output.split('\t')[0], 10) || 0) * 1024; + } + } catch { + return 0; // Command failed (permissions, timeout, etc.) + } +} + +/** + * Format bytes to human-readable string (e.g., "1.2 GB") + */ +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return '?'; + if (bytes === 0) return '0 B'; + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const k = 1024; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + const value = bytes / Math.pow(k, i); + + // More precision for larger units + const precision = i >= 3 ? 2 : i >= 2 ? 1 : 0; + return `${value.toFixed(precision)} ${units[i]}`; +} + +export interface SiteSizeResult { + bytes: number; + formatted: string; +} + +/** + * Calculate total disk size for a site (files + runtime data). + * Returns bytes and formatted string. + */ +export function getSiteSize(site: { id: string; path: string }): SiteSizeResult { + const paths = getLocalPaths(); + + // Expand ~ to home directory + const sitePath = site.path.replace(/^~/, homedir()); + const runPath = join(paths.dataDir, 'run', site.id); + + // Calculate both directories + const siteSize = getDirectorySize(sitePath); + const runSize = getDirectorySize(runPath); + const total = siteSize + runSize; + + return { + bytes: total, + formatted: formatBytes(total), + }; +} diff --git a/packages/cli/tests/siteSize.test.ts b/packages/cli/tests/siteSize.test.ts new file mode 100644 index 0000000..54eb6f4 --- /dev/null +++ b/packages/cli/tests/siteSize.test.ts @@ -0,0 +1,143 @@ +/** + * Tests for Site Size Utility + */ + +import { formatBytes, getSiteSize } from '../src/utils/siteSize'; +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; + +jest.mock('child_process'); +jest.mock('fs'); +jest.mock('../src/bootstrap/paths', () => ({ + getLocalPaths: () => ({ + dataDir: '/mock/data/dir', + }), +})); + +const mockExecSync = execSync as jest.MockedFunction; +const mockExistsSync = existsSync as jest.MockedFunction; + +describe('siteSize', () => { + describe('formatBytes', () => { + it('returns "0 B" for zero', () => { + expect(formatBytes(0)).toBe('0 B'); + }); + + it('formats bytes correctly', () => { + expect(formatBytes(500)).toBe('500 B'); + }); + + it('formats KB correctly', () => { + expect(formatBytes(1024)).toBe('1 KB'); + expect(formatBytes(1536)).toBe('2 KB'); + expect(formatBytes(2048)).toBe('2 KB'); + }); + + it('formats MB with 1 decimal', () => { + expect(formatBytes(1024 * 1024)).toBe('1.0 MB'); + expect(formatBytes(1.5 * 1024 * 1024)).toBe('1.5 MB'); + expect(formatBytes(100.3 * 1024 * 1024)).toBe('100.3 MB'); + }); + + it('formats GB with 2 decimals', () => { + expect(formatBytes(1024 * 1024 * 1024)).toBe('1.00 GB'); + expect(formatBytes(1.25 * 1024 * 1024 * 1024)).toBe('1.25 GB'); + expect(formatBytes(2.5 * 1024 * 1024 * 1024)).toBe('2.50 GB'); + }); + + it('formats TB with 2 decimals', () => { + expect(formatBytes(1024 * 1024 * 1024 * 1024)).toBe('1.00 TB'); + }); + + it('returns "?" for negative numbers', () => { + expect(formatBytes(-1)).toBe('?'); + expect(formatBytes(-1000)).toBe('?'); + }); + + it('returns "?" for NaN', () => { + expect(formatBytes(NaN)).toBe('?'); + }); + + it('returns "?" for Infinity', () => { + expect(formatBytes(Infinity)).toBe('?'); + expect(formatBytes(-Infinity)).toBe('?'); + }); + }); + + describe('getSiteSize', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calculates size for both site and run directories', () => { + // Mock both directories exist + mockExistsSync.mockReturnValue(true); + + // Site directory: 100MB, Run directory: 50MB + mockExecSync + .mockReturnValueOnce('102400\t/path/to/site') // 100MB in KB + .mockReturnValueOnce('51200\t/mock/data/dir/run/abc123'); // 50MB in KB + + const result = getSiteSize({ id: 'abc123', path: '~/Local Sites/my-site' }); + + expect(result.bytes).toBe((102400 + 51200) * 1024); + expect(result.formatted).toBe('150.0 MB'); + }); + + it('handles missing site directory', () => { + // Site dir doesn't exist, run dir does + mockExistsSync.mockReturnValueOnce(false).mockReturnValueOnce(true); + + mockExecSync.mockReturnValueOnce('51200\t/mock/data/dir/run/abc123'); + + const result = getSiteSize({ id: 'abc123', path: '~/Local Sites/missing' }); + + expect(result.bytes).toBe(51200 * 1024); + expect(result.formatted).toBe('50.0 MB'); + }); + + it('handles missing run directory', () => { + // Site dir exists, run dir doesn't + mockExistsSync.mockReturnValueOnce(true).mockReturnValueOnce(false); + + mockExecSync.mockReturnValueOnce('102400\t/path/to/site'); + + const result = getSiteSize({ id: 'abc123', path: '~/Local Sites/my-site' }); + + expect(result.bytes).toBe(102400 * 1024); + expect(result.formatted).toBe('100.0 MB'); + }); + + it('handles both directories missing', () => { + mockExistsSync.mockReturnValue(false); + + const result = getSiteSize({ id: 'abc123', path: '~/Local Sites/gone' }); + + expect(result.bytes).toBe(0); + expect(result.formatted).toBe('0 B'); + }); + + it('handles command errors gracefully', () => { + mockExistsSync.mockReturnValue(true); + mockExecSync.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + const result = getSiteSize({ id: 'abc123', path: '~/Local Sites/protected' }); + + expect(result.bytes).toBe(0); + expect(result.formatted).toBe('0 B'); + }); + + it('expands ~ in path', () => { + mockExistsSync.mockReturnValue(true); + mockExecSync.mockReturnValue('1024\t/path'); + + getSiteSize({ id: 'abc123', path: '~/Local Sites/my-site' }); + + // Check that the first call to existsSync uses expanded path + const calls = mockExistsSync.mock.calls; + expect(calls[0][0]).not.toContain('~'); + }); + }); +});