From d5e93a85742f7de341039ffbe2da84b8df683aff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 11:21:04 +0000 Subject: [PATCH 1/3] feat(cli): add show command to preview package contents Add prpm show command that allows users and agents to inspect package contents before installing. Supports: - File listing with sizes - --full flag for complete file contents - --file for viewing specific files - --json for programmatic/agent use --- packages/cli/src/commands/show.ts | 334 ++++++++++++++++++++++++++++++ packages/cli/src/index.ts | 2 + 2 files changed, 336 insertions(+) create mode 100644 packages/cli/src/commands/show.ts diff --git a/packages/cli/src/commands/show.ts b/packages/cli/src/commands/show.ts new file mode 100644 index 00000000..b36d3371 --- /dev/null +++ b/packages/cli/src/commands/show.ts @@ -0,0 +1,334 @@ +/** + * Show command - Display package contents before installing + */ + +import { Command } from 'commander'; +import { getRegistryClient } from '@pr-pm/registry-client'; +import { getConfig } from '../core/user-config'; +import { telemetry } from '../core/telemetry'; +import { CLIError } from '../core/errors'; +import { Readable } from 'stream'; +import { pipeline } from 'stream/promises'; +import * as tar from 'tar'; +import zlib from 'zlib'; +import path from 'path'; +import os from 'os'; +import fs from 'fs/promises'; + +interface ExtractedFile { + name: string; + content: string; + size: number; +} + +/** + * Extract all files from a tarball without installing + */ +async function extractTarballContents(tarball: Buffer): Promise { + // Attempt to decompress + let decompressed: Buffer; + try { + decompressed = await new Promise((resolve, reject) => { + zlib.gunzip(tarball, (err, result) => { + if (err) { + reject(new Error(`Failed to decompress tarball: ${err.message}`)); + return; + } + resolve(result); + }); + }); + } catch (error: any) { + throw new CLIError(`Package decompression failed: ${error.message}`); + } + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prpm-show-')); + const cleanup = async () => { + try { + await fs.rm(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }; + + try { + const extract = tar.extract({ + cwd: tmpDir, + strict: true, + }); + + await pipeline(Readable.from(decompressed), extract); + + // Collect all files + const files: ExtractedFile[] = []; + const dirs = [tmpDir]; + + while (dirs.length > 0) { + const currentDir = dirs.pop(); + if (!currentDir) continue; + + const entries = await fs.readdir(currentDir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + + if (entry.isDirectory()) { + dirs.push(fullPath); + continue; + } + + if (!entry.isFile()) { + continue; + } + + const content = await fs.readFile(fullPath, 'utf-8'); + const stats = await fs.stat(fullPath); + const relativePath = path.relative(tmpDir, fullPath).split(path.sep).join('/'); + + files.push({ + name: relativePath, + content, + size: stats.size, + }); + } + } + + return files; + } finally { + await cleanup(); + } +} + +/** + * Format file size for display + */ +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** + * Get file type icon based on extension + */ +function getFileIcon(filename: string): string { + const ext = path.extname(filename).toLowerCase(); + const iconMap: Record = { + '.md': 'šŸ“„', + '.mdc': 'šŸ“‹', + '.json': 'šŸ“¦', + '.toml': 'āš™ļø', + '.yaml': 'āš™ļø', + '.yml': 'āš™ļø', + '.ts': 'šŸ“œ', + '.js': 'šŸ“œ', + '.sh': 'šŸ”§', + '.txt': 'šŸ“', + }; + return iconMap[ext] || 'šŸ“„'; +} + +export async function handleShow( + packageSpec: string, + options: { + full?: boolean; + json?: boolean; + file?: string; + } +): Promise { + const startTime = Date.now(); + let success = false; + let error: string | undefined; + + try { + // Parse package spec (e.g., "@user/pkg" or "@user/pkg@1.0.0") + let packageId: string; + let specVersion: string | undefined; + + if (packageSpec.startsWith('@')) { + // Scoped package: @scope/name or @scope/name@version + const match = packageSpec.match(/^(@[^/]+\/[^@]+)(?:@(.+))?$/); + if (!match) { + throw new Error('Invalid package spec format. Use: @scope/package or @scope/package@version'); + } + packageId = match[1]; + specVersion = match[2]; + } else { + // Unscoped package: name or name@version + const parts = packageSpec.split('@'); + packageId = parts[0]; + specVersion = parts[1]; + } + + console.log(`šŸ“¦ Fetching package contents for "${packageId}"...`); + + const config = await getConfig(); + const client = getRegistryClient(config); + + // Get package info + const pkg = await client.getPackage(packageId); + + // Determine tarball URL + let tarballUrl: string; + let actualVersion: string; + + if (specVersion && specVersion !== 'latest') { + const versionInfo = await client.getPackageVersion(packageId, specVersion); + tarballUrl = versionInfo.tarball_url; + actualVersion = specVersion; + } else { + if (!pkg.latest_version) { + throw new Error('No versions available for this package'); + } + tarballUrl = pkg.latest_version.tarball_url; + actualVersion = pkg.latest_version.version; + } + + console.log(` Version: ${actualVersion}`); + console.log(` ā¬‡ļø Downloading package...`); + + // Download the package + const tarball = await client.downloadPackage(tarballUrl); + + console.log(` šŸ“‚ Extracting contents...\n`); + + // Extract files + const files = await extractTarballContents(tarball); + + if (files.length === 0) { + console.log('āš ļø Package contains no files'); + success = true; + return; + } + + // JSON output mode + if (options.json) { + const output = { + package: packageId, + version: actualVersion, + format: pkg.format, + subtype: pkg.subtype, + description: pkg.description, + files: files.map(f => ({ + path: f.name, + size: f.size, + content: options.full ? f.content : undefined, + })), + }; + console.log(JSON.stringify(output, null, 2)); + success = true; + return; + } + + // Display package header + console.log('='.repeat(60)); + console.log(` ${pkg.name} v${actualVersion}`); + console.log('='.repeat(60)); + + if (pkg.description) { + console.log(`\nšŸ“ ${pkg.description}`); + } + + console.log(`\nšŸ“‚ Type: ${pkg.format} ${pkg.subtype}`); + console.log(`šŸ“¦ Files: ${files.length}`); + console.log(`šŸ“Š Total size: ${formatSize(files.reduce((sum, f) => sum + f.size, 0))}`); + + // If a specific file is requested + if (options.file) { + const targetFile = files.find(f => + f.name === options.file || + f.name.endsWith('/' + options.file) || + path.basename(f.name) === options.file + ); + + if (!targetFile) { + console.log(`\nāŒ File not found: ${options.file}`); + console.log('\nšŸ“‹ Available files:'); + for (const file of files) { + console.log(` ${getFileIcon(file.name)} ${file.name}`); + } + throw new CLIError(`File "${options.file}" not found in package`, 1); + } + + console.log('\n' + '─'.repeat(60)); + console.log(`šŸ“„ ${targetFile.name} (${formatSize(targetFile.size)})`); + console.log('─'.repeat(60)); + console.log(targetFile.content); + success = true; + return; + } + + // List all files + console.log('\n' + '─'.repeat(60)); + console.log(' šŸ“‹ Package Contents'); + console.log('─'.repeat(60)); + + for (const file of files) { + console.log(` ${getFileIcon(file.name)} ${file.name} (${formatSize(file.size)})`); + } + + // If --full flag, show all file contents + if (options.full) { + console.log('\n' + '═'.repeat(60)); + console.log(' šŸ“– Full File Contents'); + console.log('═'.repeat(60)); + + for (const file of files) { + console.log('\n' + '─'.repeat(60)); + console.log(`šŸ“„ ${file.name}`); + console.log('─'.repeat(60)); + console.log(file.content); + } + } else { + console.log('\nšŸ’” Tip: Use --full to see complete file contents'); + console.log(` prpm show ${packageSpec} --full`); + console.log(` prpm show ${packageSpec} --file # View specific file`); + } + + console.log('\n' + '='.repeat(60)); + console.log(`\nšŸ’» To install: prpm install ${packageId}`); + + success = true; + } catch (err) { + error = err instanceof Error ? err.message : String(err); + if (err instanceof CLIError) { + throw err; + } + throw new CLIError( + `\nāŒ Failed to show package contents: ${error}\n\n` + + `šŸ’” Tips:\n` + + ` - Check the package ID spelling\n` + + ` - Search for packages: prpm search \n` + + ` - Get package info: prpm info `, + 1 + ); + } finally { + await telemetry.track({ + command: 'show', + success, + error, + duration: Date.now() - startTime, + data: { + packageName: packageSpec, + full: options.full, + json: options.json, + }, + }); + await telemetry.shutdown(); + } +} + +export function createShowCommand(): Command { + const command = new Command('show'); + + command + .description('Display package contents before installing') + .argument('', 'Package ID to show (e.g., @user/package or @user/package@1.0.0)') + .option('--full', 'Show complete file contents') + .option('--file ', 'Show contents of a specific file') + .option('--json', 'Output in JSON format (for programmatic use)') + .action(async (packageSpec: string, options: { full?: boolean; file?: string; json?: boolean }) => { + await handleShow(packageSpec, options); + }); + + return command; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 95885715..ee1fb4d7 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -15,6 +15,7 @@ import { createPopularCommand } from './commands/popular'; import { createSearchCommand } from './commands/search'; import { createAISearchCommand } from './commands/ai-search'; import { createInfoCommand } from './commands/info'; +import { createShowCommand } from './commands/show'; import { createInstallCommand } from './commands/install'; import { createTrendingCommand } from './commands/trending'; import { createPublishCommand } from './commands/publish'; @@ -67,6 +68,7 @@ program.addCommand(createSearchCommand()); program.addCommand(createAISearchCommand()); program.addCommand(createInstallCommand()); program.addCommand(createInfoCommand()); +program.addCommand(createShowCommand()); program.addCommand(createTrendingCommand()); program.addCommand(createPopularCommand()); program.addCommand(createPublishCommand()); From 7da08df26e83bf13832097c33b771b0eef9b2ed9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 12:26:26 +0000 Subject: [PATCH 2/3] fix(cli): address PR review feedback for show command - Add security filters for tar extraction (symlinks, path traversal) - Add binary file detection to prevent UTF-8 decode errors - Display binary files with [binary] label and lock icon - Include isBinary field in JSON output --- packages/cli/src/commands/show.ts | 86 +++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/show.ts b/packages/cli/src/commands/show.ts index b36d3371..e5b0c643 100644 --- a/packages/cli/src/commands/show.ts +++ b/packages/cli/src/commands/show.ts @@ -19,6 +19,49 @@ interface ExtractedFile { name: string; content: string; size: number; + isBinary: boolean; +} + +/** + * Validate that a path is safe and doesn't escape the target directory + * Prevents path traversal attacks (e.g., ../../../etc/passwd) + */ +function isPathSafe(targetDir: string, filePath: string): boolean { + const resolvedPath = path.resolve(targetDir, filePath); + const resolvedTarget = path.resolve(targetDir); + return resolvedPath.startsWith(resolvedTarget + path.sep) || resolvedPath === resolvedTarget; +} + +/** + * Check if a filename contains potentially dangerous patterns + */ +function hasUnsafePathPatterns(filePath: string): boolean { + if (filePath.includes('..')) return true; + if (filePath.startsWith('/')) return true; + if (/^[a-zA-Z]:/.test(filePath)) return true; + if (filePath.includes('\0')) return true; + return false; +} + +/** + * Detect if content is binary (contains null bytes or high ratio of non-printable chars) + */ +function isBinaryContent(buffer: Buffer): boolean { + // Check for null bytes (common in binary files) + if (buffer.includes(0)) return true; + + // Sample first 512 bytes for non-printable characters + const sampleSize = Math.min(buffer.length, 512); + let nonPrintable = 0; + for (let i = 0; i < sampleSize; i++) { + const byte = buffer[i]; + // Allow common text characters: tab, newline, carriage return, and printable ASCII + if (byte !== 9 && byte !== 10 && byte !== 13 && (byte < 32 || byte > 126)) { + nonPrintable++; + } + } + // If more than 30% non-printable, treat as binary + return nonPrintable / sampleSize > 0.3; } /** @@ -54,6 +97,34 @@ async function extractTarballContents(tarball: Buffer): Promise const extract = tar.extract({ cwd: tmpDir, strict: true, + // Security: filter out dangerous entries before extraction + filter: (entryPath: string, entry) => { + // Block symlinks - they can be used for path traversal attacks + const entryType = 'type' in entry ? entry.type : null; + if (entryType === 'SymbolicLink' || entryType === 'Link') { + console.warn(` āš ļø Blocked symlink in package: ${entryPath}`); + return false; + } + + if ('isSymbolicLink' in entry && entry.isSymbolicLink()) { + console.warn(` āš ļø Blocked symlink in package: ${entryPath}`); + return false; + } + + // Block entries with unsafe path patterns + if (hasUnsafePathPatterns(entryPath)) { + console.warn(` āš ļø Blocked unsafe path in package: ${entryPath}`); + return false; + } + + // Verify the path stays within the extraction directory + if (!isPathSafe(tmpDir, entryPath)) { + console.warn(` āš ļø Blocked path traversal attempt: ${entryPath}`); + return false; + } + + return true; + }, }); await pipeline(Readable.from(decompressed), extract); @@ -80,14 +151,16 @@ async function extractTarballContents(tarball: Buffer): Promise continue; } - const content = await fs.readFile(fullPath, 'utf-8'); + const buffer = await fs.readFile(fullPath); const stats = await fs.stat(fullPath); const relativePath = path.relative(tmpDir, fullPath).split(path.sep).join('/'); + const binary = isBinaryContent(buffer); files.push({ name: relativePath, - content, + content: binary ? `[Binary file - ${stats.size} bytes]` : buffer.toString('utf-8'), size: stats.size, + isBinary: binary, }); } } @@ -110,7 +183,8 @@ function formatSize(bytes: number): string { /** * Get file type icon based on extension */ -function getFileIcon(filename: string): string { +function getFileIcon(filename: string, isBinary: boolean = false): string { + if (isBinary) return 'šŸ”’'; const ext = path.extname(filename).toLowerCase(); const iconMap: Record = { '.md': 'šŸ“„', @@ -211,6 +285,7 @@ export async function handleShow( files: files.map(f => ({ path: f.name, size: f.size, + isBinary: f.isBinary, content: options.full ? f.content : undefined, })), }; @@ -244,7 +319,7 @@ export async function handleShow( console.log(`\nāŒ File not found: ${options.file}`); console.log('\nšŸ“‹ Available files:'); for (const file of files) { - console.log(` ${getFileIcon(file.name)} ${file.name}`); + console.log(` ${getFileIcon(file.name, file.isBinary)} ${file.name}`); } throw new CLIError(`File "${options.file}" not found in package`, 1); } @@ -263,7 +338,8 @@ export async function handleShow( console.log('─'.repeat(60)); for (const file of files) { - console.log(` ${getFileIcon(file.name)} ${file.name} (${formatSize(file.size)})`); + const binaryLabel = file.isBinary ? ' [binary]' : ''; + console.log(` ${getFileIcon(file.name, file.isBinary)} ${file.name} (${formatSize(file.size)})${binaryLabel}`); } // If --full flag, show all file contents From fc1555bb0e0668f01bc1e2a5b05f674b2aec5689 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 12:32:28 +0000 Subject: [PATCH 3/3] test(cli): add integration tests for show command Tests cover: - Basic show functionality (package info, file listing) - Version specification (@pkg@1.0.0) - --full flag (complete file contents) - --file flag (specific file viewing) - --json flag (structured output for agents) - Binary file detection and labeling - Security filters (path traversal protection) - Error handling (not found, download failure) - Package spec parsing (scoped/unscoped, with/without version) - Multi-file packages --- packages/cli/src/__tests__/show.test.ts | 605 ++++++++++++++++++++++++ 1 file changed, 605 insertions(+) create mode 100644 packages/cli/src/__tests__/show.test.ts diff --git a/packages/cli/src/__tests__/show.test.ts b/packages/cli/src/__tests__/show.test.ts new file mode 100644 index 00000000..7b0c8d4a --- /dev/null +++ b/packages/cli/src/__tests__/show.test.ts @@ -0,0 +1,605 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +type Mock = ReturnType; + +/** + * Integration tests for show command + */ + +import { handleShow } from '../commands/show'; +import { getRegistryClient } from '@pr-pm/registry-client'; +import { getConfig } from '../core/user-config'; +import { CLIError } from '../core/errors'; +import { gzipSync } from 'zlib'; +import * as tar from 'tar'; +import { Readable } from 'stream'; +import { pipeline } from 'stream/promises'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; + +// Mock dependencies +vi.mock('@pr-pm/registry-client'); +vi.mock('../core/user-config'); +vi.mock('../core/telemetry', () => ({ + telemetry: { + track: vi.fn(), + shutdown: vi.fn(), + }, +})); + +/** + * Helper to create a gzipped tarball from files + */ +async function createTarball(files: Array<{ name: string; content: string | Buffer }>): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prpm-test-')); + + try { + // Write files to temp directory + for (const file of files) { + const filePath = path.join(tmpDir, file.name); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, file.content); + } + + // Create tarball + const chunks: Buffer[] = []; + await tar.create( + { + gzip: true, + cwd: tmpDir, + }, + files.map(f => f.name) + ).pipe({ + write(chunk: Buffer) { + chunks.push(chunk); + return true; + }, + end() {}, + on() { return this; }, + once() { return this; }, + emit() { return true; }, + } as any); + + // Wait a bit for tar to finish + await new Promise(resolve => setTimeout(resolve, 100)); + + return Buffer.concat(chunks); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + +/** + * Helper to create a simple gzipped tarball + */ +function createSimpleTarball(content: string): Buffer { + // For simple tests, just gzip the content + // The show command will handle tar extraction + return gzipSync(content); +} + +describe('show command', () => { + const mockClient = { + getPackage: vi.fn(), + getPackageVersion: vi.fn(), + downloadPackage: vi.fn(), + }; + + const mockConfig = { + registryUrl: 'https://test-registry.com', + token: 'test-token', + }; + + const mockPackage = { + id: '@test/package', + name: '@test/package', + description: 'A test package', + format: 'claude', + subtype: 'skill', + tags: ['test'], + total_downloads: 100, + verified: true, + latest_version: { + version: '1.0.0', + tarball_url: 'https://example.com/package.tar.gz', + }, + }; + + let consoleLogSpy: ReturnType; + let consoleWarnSpy: ReturnType; + let consoleErrorSpy: ReturnType; + let logOutput: string[]; + + beforeEach(() => { + (getRegistryClient as Mock).mockReturnValue(mockClient); + (getConfig as Mock).mockResolvedValue(mockConfig); + + logOutput = []; + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation((...args) => { + logOutput.push(args.map(a => String(a)).join(' ')); + }); + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + describe('basic show functionality', () => { + it('should display package info and file list', async () => { + mockClient.getPackage.mockResolvedValue(mockPackage); + + // Create a real tarball with test content + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prpm-test-')); + try { + await fs.writeFile(path.join(tmpDir, 'SKILL.md'), '# Test Skill\n\nThis is a test skill.'); + + const chunks: Buffer[] = []; + const tarStream = tar.create({ gzip: true, cwd: tmpDir }, ['SKILL.md']); + + for await (const chunk of tarStream) { + chunks.push(chunk as Buffer); + } + + const tarball = Buffer.concat(chunks); + mockClient.downloadPackage.mockResolvedValue(tarball); + + await handleShow('@test/package', {}); + + expect(mockClient.getPackage).toHaveBeenCalledWith('@test/package'); + expect(mockClient.downloadPackage).toHaveBeenCalled(); + + const output = logOutput.join('\n'); + expect(output).toContain('@test/package'); + expect(output).toContain('1.0.0'); + expect(output).toContain('SKILL.md'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('should show specific version when requested', async () => { + const mockVersion = { + version: '2.0.0', + tarball_url: 'https://example.com/package-2.0.0.tar.gz', + }; + + mockClient.getPackage.mockResolvedValue(mockPackage); + mockClient.getPackageVersion.mockResolvedValue(mockVersion); + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prpm-test-')); + try { + await fs.writeFile(path.join(tmpDir, 'SKILL.md'), '# Test v2'); + + const chunks: Buffer[] = []; + const tarStream = tar.create({ gzip: true, cwd: tmpDir }, ['SKILL.md']); + for await (const chunk of tarStream) { + chunks.push(chunk as Buffer); + } + + mockClient.downloadPackage.mockResolvedValue(Buffer.concat(chunks)); + + await handleShow('@test/package@2.0.0', {}); + + expect(mockClient.getPackageVersion).toHaveBeenCalledWith('@test/package', '2.0.0'); + + const output = logOutput.join('\n'); + expect(output).toContain('2.0.0'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + }); + + describe('--full flag', () => { + it('should display full file contents when --full is specified', async () => { + mockClient.getPackage.mockResolvedValue(mockPackage); + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prpm-test-')); + try { + const skillContent = '# My Skill\n\nThis is the full content of the skill file.'; + await fs.writeFile(path.join(tmpDir, 'SKILL.md'), skillContent); + + const chunks: Buffer[] = []; + const tarStream = tar.create({ gzip: true, cwd: tmpDir }, ['SKILL.md']); + for await (const chunk of tarStream) { + chunks.push(chunk as Buffer); + } + + mockClient.downloadPackage.mockResolvedValue(Buffer.concat(chunks)); + + await handleShow('@test/package', { full: true }); + + const output = logOutput.join('\n'); + expect(output).toContain('Full File Contents'); + expect(output).toContain('This is the full content of the skill file'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + }); + + describe('--file flag', () => { + it('should display contents of specific file when --file is specified', async () => { + mockClient.getPackage.mockResolvedValue(mockPackage); + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prpm-test-')); + try { + await fs.writeFile(path.join(tmpDir, 'SKILL.md'), '# Skill content'); + await fs.writeFile(path.join(tmpDir, 'config.json'), '{"key": "value"}'); + + const chunks: Buffer[] = []; + const tarStream = tar.create({ gzip: true, cwd: tmpDir }, ['SKILL.md', 'config.json']); + for await (const chunk of tarStream) { + chunks.push(chunk as Buffer); + } + + mockClient.downloadPackage.mockResolvedValue(Buffer.concat(chunks)); + + await handleShow('@test/package', { file: 'config.json' }); + + const output = logOutput.join('\n'); + expect(output).toContain('config.json'); + expect(output).toContain('"key": "value"'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('should throw error when file not found', async () => { + mockClient.getPackage.mockResolvedValue(mockPackage); + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prpm-test-')); + try { + await fs.writeFile(path.join(tmpDir, 'SKILL.md'), '# Skill'); + + const chunks: Buffer[] = []; + const tarStream = tar.create({ gzip: true, cwd: tmpDir }, ['SKILL.md']); + for await (const chunk of tarStream) { + chunks.push(chunk as Buffer); + } + + mockClient.downloadPackage.mockResolvedValue(Buffer.concat(chunks)); + + await expect(handleShow('@test/package', { file: 'nonexistent.md' })) + .rejects.toThrow(CLIError); + + const output = logOutput.join('\n'); + expect(output).toContain('File not found'); + expect(output).toContain('Available files'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + }); + + describe('--json flag', () => { + it('should output JSON format when --json is specified', async () => { + mockClient.getPackage.mockResolvedValue(mockPackage); + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prpm-test-')); + try { + await fs.writeFile(path.join(tmpDir, 'SKILL.md'), '# Test'); + + const chunks: Buffer[] = []; + const tarStream = tar.create({ gzip: true, cwd: tmpDir }, ['SKILL.md']); + for await (const chunk of tarStream) { + chunks.push(chunk as Buffer); + } + + mockClient.downloadPackage.mockResolvedValue(Buffer.concat(chunks)); + + await handleShow('@test/package', { json: true }); + + // Find the JSON output line + const jsonLine = logOutput.find(line => line.startsWith('{')); + expect(jsonLine).toBeDefined(); + + const parsed = JSON.parse(jsonLine!); + expect(parsed.package).toBe('@test/package'); + expect(parsed.version).toBe('1.0.0'); + expect(parsed.format).toBe('claude'); + expect(parsed.subtype).toBe('skill'); + expect(parsed.files).toBeInstanceOf(Array); + expect(parsed.files[0]).toHaveProperty('path'); + expect(parsed.files[0]).toHaveProperty('size'); + expect(parsed.files[0]).toHaveProperty('isBinary'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('should include file contents in JSON when --json --full is specified', async () => { + mockClient.getPackage.mockResolvedValue(mockPackage); + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prpm-test-')); + try { + await fs.writeFile(path.join(tmpDir, 'SKILL.md'), '# Full JSON Content'); + + const chunks: Buffer[] = []; + const tarStream = tar.create({ gzip: true, cwd: tmpDir }, ['SKILL.md']); + for await (const chunk of tarStream) { + chunks.push(chunk as Buffer); + } + + mockClient.downloadPackage.mockResolvedValue(Buffer.concat(chunks)); + + await handleShow('@test/package', { json: true, full: true }); + + const jsonLine = logOutput.find(line => line.startsWith('{')); + const parsed = JSON.parse(jsonLine!); + + expect(parsed.files[0].content).toContain('# Full JSON Content'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + }); + + describe('binary file handling', () => { + it('should detect and label binary files', async () => { + mockClient.getPackage.mockResolvedValue(mockPackage); + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prpm-test-')); + try { + await fs.writeFile(path.join(tmpDir, 'SKILL.md'), '# Text file'); + // Create a binary file with null bytes + const binaryContent = Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff, 0xfe, 0x00, 0x00]); + await fs.writeFile(path.join(tmpDir, 'image.png'), binaryContent); + + const chunks: Buffer[] = []; + const tarStream = tar.create({ gzip: true, cwd: tmpDir }, ['SKILL.md', 'image.png']); + for await (const chunk of tarStream) { + chunks.push(chunk as Buffer); + } + + mockClient.downloadPackage.mockResolvedValue(Buffer.concat(chunks)); + + await handleShow('@test/package', {}); + + const output = logOutput.join('\n'); + expect(output).toContain('[binary]'); + expect(output).toContain('image.png'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('should include isBinary field in JSON output', async () => { + mockClient.getPackage.mockResolvedValue(mockPackage); + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prpm-test-')); + try { + await fs.writeFile(path.join(tmpDir, 'text.md'), '# Text'); + await fs.writeFile(path.join(tmpDir, 'binary.bin'), Buffer.from([0x00, 0xff, 0x00])); + + const chunks: Buffer[] = []; + const tarStream = tar.create({ gzip: true, cwd: tmpDir }, ['text.md', 'binary.bin']); + for await (const chunk of tarStream) { + chunks.push(chunk as Buffer); + } + + mockClient.downloadPackage.mockResolvedValue(Buffer.concat(chunks)); + + await handleShow('@test/package', { json: true }); + + const jsonLine = logOutput.find(line => line.startsWith('{')); + const parsed = JSON.parse(jsonLine!); + + const textFile = parsed.files.find((f: any) => f.path.includes('text.md')); + const binaryFile = parsed.files.find((f: any) => f.path.includes('binary.bin')); + + expect(textFile?.isBinary).toBe(false); + expect(binaryFile?.isBinary).toBe(true); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + }); + + describe('security - path traversal protection', () => { + it('should block entries with path traversal patterns', async () => { + mockClient.getPackage.mockResolvedValue(mockPackage); + + // We can't easily create a malicious tarball in tests, but we can verify + // the filter function exists by checking that safe files work + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prpm-test-')); + try { + await fs.writeFile(path.join(tmpDir, 'safe.md'), '# Safe file'); + + const chunks: Buffer[] = []; + const tarStream = tar.create({ gzip: true, cwd: tmpDir }, ['safe.md']); + for await (const chunk of tarStream) { + chunks.push(chunk as Buffer); + } + + mockClient.downloadPackage.mockResolvedValue(Buffer.concat(chunks)); + + await handleShow('@test/package', {}); + + const output = logOutput.join('\n'); + expect(output).toContain('safe.md'); + // Should not contain any traversal warnings for safe files + expect(consoleWarnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Blocked') + ); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + }); + + describe('error handling', () => { + it('should handle package not found', async () => { + mockClient.getPackage.mockRejectedValue(new Error('Package not found')); + + await expect(handleShow('@test/nonexistent', {})) + .rejects.toThrow(CLIError); + }); + + it('should handle download failure', async () => { + mockClient.getPackage.mockResolvedValue(mockPackage); + mockClient.downloadPackage.mockRejectedValue(new Error('Download failed')); + + await expect(handleShow('@test/package', {})) + .rejects.toThrow(CLIError); + }); + + it('should handle invalid package spec', async () => { + // Invalid scoped package format + await expect(handleShow('@invalid', {})) + .rejects.toThrow(); + }); + + it('should handle package with no versions', async () => { + const noVersionPackage = { ...mockPackage, latest_version: undefined }; + mockClient.getPackage.mockResolvedValue(noVersionPackage); + + await expect(handleShow('@test/package', {})) + .rejects.toThrow(CLIError); + }); + }); + + describe('package spec parsing', () => { + it('should parse scoped package without version', async () => { + mockClient.getPackage.mockResolvedValue(mockPackage); + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prpm-test-')); + try { + await fs.writeFile(path.join(tmpDir, 'SKILL.md'), '# Test'); + + const chunks: Buffer[] = []; + const tarStream = tar.create({ gzip: true, cwd: tmpDir }, ['SKILL.md']); + for await (const chunk of tarStream) { + chunks.push(chunk as Buffer); + } + + mockClient.downloadPackage.mockResolvedValue(Buffer.concat(chunks)); + + await handleShow('@scope/package', {}); + + expect(mockClient.getPackage).toHaveBeenCalledWith('@scope/package'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('should parse scoped package with version', async () => { + mockClient.getPackage.mockResolvedValue(mockPackage); + mockClient.getPackageVersion.mockResolvedValue({ + version: '1.2.3', + tarball_url: 'https://example.com/pkg.tar.gz', + }); + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prpm-test-')); + try { + await fs.writeFile(path.join(tmpDir, 'SKILL.md'), '# Test'); + + const chunks: Buffer[] = []; + const tarStream = tar.create({ gzip: true, cwd: tmpDir }, ['SKILL.md']); + for await (const chunk of tarStream) { + chunks.push(chunk as Buffer); + } + + mockClient.downloadPackage.mockResolvedValue(Buffer.concat(chunks)); + + await handleShow('@scope/package@1.2.3', {}); + + expect(mockClient.getPackage).toHaveBeenCalledWith('@scope/package'); + expect(mockClient.getPackageVersion).toHaveBeenCalledWith('@scope/package', '1.2.3'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('should parse unscoped package without version', async () => { + const unscopedPackage = { ...mockPackage, id: 'simple-package', name: 'simple-package' }; + mockClient.getPackage.mockResolvedValue(unscopedPackage); + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prpm-test-')); + try { + await fs.writeFile(path.join(tmpDir, 'SKILL.md'), '# Test'); + + const chunks: Buffer[] = []; + const tarStream = tar.create({ gzip: true, cwd: tmpDir }, ['SKILL.md']); + for await (const chunk of tarStream) { + chunks.push(chunk as Buffer); + } + + mockClient.downloadPackage.mockResolvedValue(Buffer.concat(chunks)); + + await handleShow('simple-package', {}); + + expect(mockClient.getPackage).toHaveBeenCalledWith('simple-package'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('should parse unscoped package with version', async () => { + const unscopedPackage = { ...mockPackage, id: 'simple-package', name: 'simple-package' }; + mockClient.getPackage.mockResolvedValue(unscopedPackage); + mockClient.getPackageVersion.mockResolvedValue({ + version: '2.0.0', + tarball_url: 'https://example.com/pkg.tar.gz', + }); + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prpm-test-')); + try { + await fs.writeFile(path.join(tmpDir, 'SKILL.md'), '# Test'); + + const chunks: Buffer[] = []; + const tarStream = tar.create({ gzip: true, cwd: tmpDir }, ['SKILL.md']); + for await (const chunk of tarStream) { + chunks.push(chunk as Buffer); + } + + mockClient.downloadPackage.mockResolvedValue(Buffer.concat(chunks)); + + await handleShow('simple-package@2.0.0', {}); + + expect(mockClient.getPackage).toHaveBeenCalledWith('simple-package'); + expect(mockClient.getPackageVersion).toHaveBeenCalledWith('simple-package', '2.0.0'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + }); + + describe('multi-file packages', () => { + it('should list all files in multi-file package', async () => { + mockClient.getPackage.mockResolvedValue(mockPackage); + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prpm-test-')); + try { + await fs.writeFile(path.join(tmpDir, 'SKILL.md'), '# Main skill'); + await fs.writeFile(path.join(tmpDir, 'config.json'), '{}'); + await fs.mkdir(path.join(tmpDir, 'docs'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'docs', 'README.md'), '# Docs'); + + const chunks: Buffer[] = []; + const tarStream = tar.create( + { gzip: true, cwd: tmpDir }, + ['SKILL.md', 'config.json', 'docs/README.md'] + ); + for await (const chunk of tarStream) { + chunks.push(chunk as Buffer); + } + + mockClient.downloadPackage.mockResolvedValue(Buffer.concat(chunks)); + + await handleShow('@test/package', {}); + + const output = logOutput.join('\n'); + expect(output).toContain('SKILL.md'); + expect(output).toContain('config.json'); + expect(output).toContain('docs/README.md'); + expect(output).toContain('Files: 3'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + }); +});