Skip to content
Merged
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
51 changes: 50 additions & 1 deletion packages/cli/src/formatters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand All @@ -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)]),
Expand All @@ -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
*/
Expand Down
37 changes: 29 additions & 8 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -267,6 +268,7 @@ sites
.command('list')
.description('List all WordPress sites')
.option('--status <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);
Expand All @@ -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);
Expand Down
86 changes: 86 additions & 0 deletions packages/cli/src/utils/siteSize.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}
143 changes: 143 additions & 0 deletions packages/cli/tests/siteSize.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof execSync>;
const mockExistsSync = existsSync as jest.MockedFunction<typeof existsSync>;

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('~');
});
});
});
Loading