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
49 changes: 49 additions & 0 deletions packages/cli/src/commands/cache/clear.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { createInterface } from 'node:readline';
import { existsSync, rmSync } from 'node:fs';
import { mpak } from '../../utils/config.js';
import { formatSize, logger } from '../../utils/format.js';

export interface CacheClearOptions {
force?: boolean;
}

async function confirmPrompt(question: string): Promise<boolean> {
const rl = createInterface({ input: process.stdin, output: process.stderr });
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
});
});
}

export async function handleCacheClear(
options: CacheClearOptions = {},
_confirm = confirmPrompt,
): Promise<void> {
const info = mpak.bundleCache.getCacheInfo();
const entryCount = info.registryBundles.length + info.localBundles.length;

if (entryCount === 0) {
logger.info('Cache is already empty.');
return;
}

const sizeStr = formatSize(info.totalBytes);
const summary = `${entryCount} bundle(s), ${sizeStr}`;

if (!options.force) {
const ok = await _confirm(`Clear the entire cache (${summary})? [y/N] `);
if (!ok) {
logger.info('Aborted.');
return;
}
}

const cacheHome = mpak.bundleCache.cacheHome;
if (existsSync(cacheHome)) {
rmSync(cacheHome, { recursive: true, force: true });
}

logger.info(`Cleared cache. Freed ${sizeStr}.`);
}
2 changes: 2 additions & 0 deletions packages/cli/src/commands/cache/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { handleCacheInfo } from './info.js';
export { handleCacheClear } from './clear.js';
55 changes: 55 additions & 0 deletions packages/cli/src/commands/cache/info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { mpak } from '../../utils/config.js';
import { formatSize, logger, table } from '../../utils/format.js';

export interface CacheInfoOptions {
json?: boolean;
}

export async function handleCacheInfo(options: CacheInfoOptions = {}): Promise<void> {
const info = mpak.bundleCache.getCacheInfo();

if (options.json) {
console.log(JSON.stringify(info, null, 2));
return;
}

if (info.registryBundles.length === 0 && info.localBundles.length === 0) {
logger.info('Cache is empty.');
return;
}

if (info.registryBundles.length > 0) {
logger.info('Registry bundles:\n');
logger.info(
table(
['Bundle', 'Version', 'Pulled', 'Size'],
info.registryBundles.map((b) => [
b.name,
b.version,
b.pulledAt.slice(0, 10),
formatSize(b.bytes),
]),
{ rightAlign: [3] },
),
);
logger.info('');
}

if (info.localBundles.length > 0) {
logger.info('Local bundles:\n');
logger.info(
table(
['Path', 'Extracted', 'Size'],
info.localBundles.map((b) => [
b.localPath,
b.extractedAt.slice(0, 10),
formatSize(b.bytes),
]),
{ rightAlign: [2] },
),
);
logger.info('');
}

logger.info(`Total: ${formatSize(info.totalBytes)}`);
}
24 changes: 10 additions & 14 deletions packages/cli/src/commands/packages/outdated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,16 @@ export async function getOutdatedBundles(): Promise<OutdatedEntry[]> {

await Promise.all(
cached.map(async (bundle) => {
try {
const latest = await mpak.bundleCache.checkForUpdate(bundle.name, { force: true });
if (latest) {
results.push({
name: bundle.name,
current: bundle.version,
latest,
pulledAt: bundle.pulledAt,
});
}
} catch {
process.stderr.write(
`=> Warning: could not check ${bundle.name} (may have been removed from registry)\n`,
);
const result = await mpak.bundleCache.checkForUpdate(bundle.name, { force: true });
if (result.status === 'update-available') {
results.push({
name: bundle.name,
current: bundle.version,
latest: result.latestVersion,
pulledAt: bundle.pulledAt,
});
} else if (result.status === 'check-failed') {
process.stderr.write(`=> Warning: could not check ${bundle.name}: ${result.reason}\n`);
}
}),
);
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/packages/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export async function handlePull(packageSpec: string, options: PullOptions = {})

logger.info(`\n=> Downloading to ${outputPath}...`);
writeFileSync(outputPath, data);
await mpak.bundleCache.extractBundle(name, data, metadata);

logger.info(`\n=> Bundle downloaded successfully!`);
logger.info(` File: ${outputPath}`);
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
handleSkillInstall,
handleSkillList,
} from "./commands/skills/index.js";
import { handleCacheInfo, handleCacheClear } from "./commands/cache/index.js";

/**
* Creates and configures the CLI program
Expand Down Expand Up @@ -264,6 +265,28 @@ export function createProgram(): Command {
await handleConfigClear(packageName, key);
});

// ==========================================================================
// Cache commands
// ==========================================================================

const cache = program.command("cache").description("Manage the local bundle cache");

cache
.command("info")
.description("Show cache contents and disk usage")
.option("--json", "Output as JSON")
.action(async (options) => {
await handleCacheInfo(options);
});

cache
.command("clear")
.description("Delete all cached bundles and free disk space")
.option("--force", "Skip confirmation prompt")
.action(async (options) => {
await handleCacheClear(options);
});

// ==========================================================================
// Shell completion
// ==========================================================================
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/tests/bundles/outdated.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ describe('getOutdatedBundles', () => {
expect(result[1]!.name).toBe('@scope/zebra');
});

it('skips bundles that fail to resolve from registry', async () => {
it('warns and skips bundles that fail to resolve from registry', async () => {
seedCacheEntry(testDir, 'scope-exists', {
manifest: validManifest('@scope/exists', '1.0.0'),
metadata: validMetadata('1.0.0'),
Expand All @@ -159,9 +159,15 @@ describe('getOutdatedBundles', () => {
{ mpakHome: testDir },
);

const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);

const result = await getOutdatedBundles();

expect(result).toHaveLength(1);
expect(result[0]!.name).toBe('@scope/exists');
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining('could not check @scope/deleted'),
);
});

it('ignores TTL and always checks the registry', async () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/tests/bundles/pull.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import { handlePull } from '../../src/commands/packages/pull.js';
vi.mock('fs', () => ({ writeFileSync: vi.fn() }));

let mockDownloadBundle: ReturnType<typeof vi.fn>;
let mockExtractBundle: ReturnType<typeof vi.fn>;

vi.mock('../../src/utils/config.js', () => ({
get mpak() {
return {
client: { downloadBundle: mockDownloadBundle } as unknown as MpakClient,
bundleCache: { extractBundle: mockExtractBundle },
};
},
}));
Expand Down Expand Up @@ -55,6 +57,7 @@ describe('handlePull', () => {
beforeEach(() => {
vi.mocked(writeFileSync).mockClear();
mockDownloadBundle = vi.fn().mockResolvedValue({ data: bundleData, metadata });
mockExtractBundle = vi.fn().mockResolvedValue(undefined);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mock is set up but no test asserts that extractBundle is actually invoked — the whole point of #60 is unverified at the unit level. The integration test covers it end-to-end, but a unit assertion catches a missed wire-up in a few ms instead of a 30s network test.

Add to the happy-path test:

expect(mockExtractBundle).toHaveBeenCalledWith('@scope/bundle', bundleData, metadata);

While we're here: with --json set, pull.ts returns before calling extractBundle, so mpak pull --json still doesn't fix #60 for scripted callers. Worth either moving the extract above the JSON return, or adding a --json test that asserts the asymmetry so the choice is recorded.

stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
Expand Down Expand Up @@ -140,4 +143,5 @@ describe('handlePull', () => {

expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Bundle not found'));
});

});
30 changes: 26 additions & 4 deletions packages/cli/tests/bundles/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ let stdout: string;
let stderr: string;

beforeEach(() => {
vi.clearAllMocks();
stdout = '';
stderr = '';
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
Expand Down Expand Up @@ -138,7 +139,9 @@ describe('handleUpdate — bulk update', () => {
},
]);
mockCheckForUpdate.mockImplementation(async (name: string) => {
return name === '@scope/a' ? '2.0.0' : '3.0.0';
return name === '@scope/a'
? { status: 'update-available', latestVersion: '2.0.0' }
: { status: 'update-available', latestVersion: '3.0.0' };
});
mockLoadBundle.mockImplementation(async (name: string) => {
const versions: Record<string, string> = { '@scope/a': '2.0.0', '@scope/b': '3.0.0' };
Expand Down Expand Up @@ -168,7 +171,7 @@ describe('handleUpdate — bulk update', () => {
cacheDir: '/cache/bad',
},
]);
mockCheckForUpdate.mockImplementation(async () => '2.0.0');
mockCheckForUpdate.mockResolvedValue({ status: 'update-available', latestVersion: '2.0.0' });
mockLoadBundle.mockImplementation(async (name: string) => {
if (name === '@scope/bad') throw new MpakNotFoundError('@scope/bad@latest');
return { cacheDir: '/cache/good', version: '2.0.0', pulled: true };
Expand All @@ -189,7 +192,7 @@ describe('handleUpdate — bulk update', () => {
cacheDir: '/cache/a',
},
]);
mockCheckForUpdate.mockResolvedValue('2.0.0');
mockCheckForUpdate.mockResolvedValue({ status: 'update-available', latestVersion: '2.0.0' });
mockLoadBundle.mockRejectedValue(new MpakNetworkError('timeout'));

await expect(handleUpdate(undefined)).rejects.toThrow('process.exit called');
Expand All @@ -198,6 +201,25 @@ describe('handleUpdate — bulk update', () => {
expect(stderr).toContain('All updates failed');
});

it('warns and skips bundles whose update check fails', async () => {
mockListCachedBundles.mockReturnValue([
{
name: '@scope/a',
version: '1.0.0',
pulledAt: '2025-01-01T00:00:00.000Z',
cacheDir: '/cache/a',
},
]);
mockCheckForUpdate.mockResolvedValue({ status: 'check-failed', reason: 'timeout' });

const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);

await handleUpdate(undefined);

expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('could not check @scope/a'));
expect(mockLoadBundle).not.toHaveBeenCalled();
});

it('outputs JSON for bulk update with --json', async () => {
mockListCachedBundles.mockReturnValue([
{
Expand All @@ -207,7 +229,7 @@ describe('handleUpdate — bulk update', () => {
cacheDir: '/cache/a',
},
]);
mockCheckForUpdate.mockResolvedValue('2.0.0');
mockCheckForUpdate.mockResolvedValue({ status: 'update-available', latestVersion: '2.0.0' });
mockLoadBundle.mockResolvedValue({ cacheDir: '/cache/a', version: '2.0.0', pulled: true });

await handleUpdate(undefined, { json: true });
Expand Down
Loading
Loading