diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 43a99eef..e190c038 100755 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -12,14 +12,24 @@ const os = require('os'); * Runs when: npm install -g @kaitranntt/ccs * Idempotent: Safe to run multiple times (won't overwrite existing configs) * Cross-platform: Works on Unix, macOS, Windows + * + * Test isolation: Set CCS_HOME env var to redirect all operations to a test directory */ +/** + * Get the CCS home directory (respects CCS_HOME env var for test isolation) + * @returns {string} Home directory path + */ +function getCcsHome() { + return process.env.CCS_HOME || os.homedir(); +} + /** * Validate created configuration files * @returns {object} { success: boolean, errors: string[], warnings: string[] } */ function validateConfiguration() { - const homedir = os.homedir(); + const homedir = getCcsHome(); const errors = []; const warnings = []; @@ -63,8 +73,8 @@ function validateConfiguration() { function createConfigFiles() { try { - // Get user home directory (cross-platform) - const homedir = os.homedir(); + // Get user home directory (cross-platform, respects CCS_HOME for test isolation) + const homedir = getCcsHome(); const ccsDir = path.join(homedir, '.ccs'); // Create ~/.ccs/ directory if missing @@ -106,30 +116,9 @@ function createConfigFiles() { } console.log(''); - // Copy .claude/ directory from package to ~/.ccs/.claude/ (v4.1.1) - try { - const ClaudeDirInstaller = require('../dist/utils/claude-dir-installer').default; - const installer = new ClaudeDirInstaller(); - const packageDir = path.join(__dirname, '..'); - installer.install(packageDir); - - // Clean up deprecated files (v4.3.2) - installer.cleanupDeprecated(); - } catch (err) { - console.warn('[!] Failed to install .claude/ directory:', err.message); - console.warn(' CCS items may not be available'); - } - - // Install CCS items to ~/.claude/ (v4.1.0) - try { - const { ClaudeSymlinkManager } = require('../dist/utils/claude-symlink-manager'); - const claudeSymlinkManager = new ClaudeSymlinkManager(); - claudeSymlinkManager.install(); - } catch (err) { - console.warn('[!] CCS item installation warning:', err.message); - console.warn(' Run "ccs sync" to retry'); - } - console.log(''); + // NOTE: .claude/ directory installation moved to "ccs sync" command + // Users can run "ccs sync" to install CCS commands/skills to ~/.claude/ + // This gives users control over when to modify their Claude configuration // Create config.json if missing const configPath = path.join(ccsDir, 'config.json'); diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index ecd25e2a..dff576ac 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -9,11 +9,27 @@ import { error } from './helpers'; // const { ErrorManager } = require('./error-manager'); // const RecoveryManager = require('./recovery-manager'); +/** + * Get the CCS home directory (respects CCS_HOME env var for test isolation) + * @returns Home directory path + */ +export function getCcsHome(): string { + return process.env.CCS_HOME || os.homedir(); +} + +/** + * Get the CCS directory path (~/.ccs) + * @returns Path to .ccs directory + */ +export function getCcsDir(): string { + return path.join(getCcsHome(), '.ccs'); +} + /** * Get config file path */ export function getConfigPath(): string { - return process.env.CCS_CONFIG || path.join(os.homedir(), '.ccs', 'config.json'); + return process.env.CCS_CONFIG || path.join(getCcsHome(), '.ccs', 'config.json'); } /** diff --git a/tests/fixtures/test-environment.js b/tests/fixtures/test-environment.js new file mode 100644 index 00000000..bd6d7554 --- /dev/null +++ b/tests/fixtures/test-environment.js @@ -0,0 +1,160 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Test Environment Isolation + * + * Provides isolated ~/.ccs/ directory for tests to prevent + * interference with the user's actual CCS configuration. + * + * Usage: + * const { createTestEnvironment } = require('../fixtures/test-environment'); + * + * describe('my tests', () => { + * let testEnv; + * + * before(() => { + * testEnv = createTestEnvironment(); + * }); + * + * after(() => { + * testEnv.cleanup(); + * }); + * + * it('test something', () => { + * // Tests run with CCS_HOME pointing to temp directory + * // Your test code here + * }); + * }); + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +/** + * Create an isolated test environment + * Sets CCS_HOME to a temporary directory and provides cleanup + * + * @returns {object} Test environment with paths and cleanup function + */ +function createTestEnvironment() { + // Create unique temp directory for this test run + const tempBase = path.join(os.tmpdir(), 'ccs-test'); + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const testHome = path.join(tempBase, uniqueId); + const testCcsDir = path.join(testHome, '.ccs'); + + // Create directories + fs.mkdirSync(testCcsDir, { recursive: true }); + + // Store original environment + const originalHome = process.env.HOME; + const originalCcsHome = process.env.CCS_HOME; + const originalUserProfile = process.env.USERPROFILE; + + // Set test environment - use CCS_HOME for isolation + process.env.CCS_HOME = testHome; + + // Return environment object + return { + /** Path to the test home directory (like ~) */ + testHome, + + /** Path to the test .ccs directory (like ~/.ccs) */ + ccsDir: testCcsDir, + + /** Original HOME value for reference */ + originalHome, + + /** + * Get a path within the test CCS directory + * @param {...string} parts - Path segments + * @returns {string} Full path + */ + getCcsPath(...parts) { + return path.join(testCcsDir, ...parts); + }, + + /** + * Create a file in the test CCS directory + * @param {string} relativePath - Path relative to .ccs/ + * @param {string|object} content - File content (objects are JSON stringified) + */ + createFile(relativePath, content) { + const fullPath = path.join(testCcsDir, relativePath); + const dir = path.dirname(fullPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const data = typeof content === 'object' ? JSON.stringify(content, null, 2) : content; + fs.writeFileSync(fullPath, data); + }, + + /** + * Read a file from the test CCS directory + * @param {string} relativePath - Path relative to .ccs/ + * @param {boolean} [asJson=false] - Parse as JSON + * @returns {string|object} File content + */ + readFile(relativePath, asJson = false) { + const fullPath = path.join(testCcsDir, relativePath); + const content = fs.readFileSync(fullPath, 'utf8'); + return asJson ? JSON.parse(content) : content; + }, + + /** + * Check if a file exists in the test CCS directory + * @param {string} relativePath - Path relative to .ccs/ + * @returns {boolean} + */ + fileExists(relativePath) { + return fs.existsSync(path.join(testCcsDir, relativePath)); + }, + + /** + * Clean up the test environment and restore original settings + */ + cleanup() { + // Restore original environment + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + // Clean up temp directory + try { + fs.rmSync(testHome, { recursive: true, force: true }); + } catch (err) { + // Ignore cleanup errors + console.warn(`[test-environment] Cleanup warning: ${err.message}`); + } + } + }; +} + +/** + * Get the CCS home directory (respects CCS_HOME env var) + * This should be used instead of os.homedir() for CCS paths + * + * @returns {string} Home directory path + */ +function getCcsHome() { + return process.env.CCS_HOME || os.homedir(); +} + +/** + * Get the CCS directory path + * + * @returns {string} Path to .ccs directory + */ +function getCcsDir() { + return path.join(getCcsHome(), '.ccs'); +} + +module.exports = { + createTestEnvironment, + getCcsHome, + getCcsDir +}; diff --git a/tests/npm/cli.test.js b/tests/npm/cli.test.js index a98083e3..47d6f2f1 100644 --- a/tests/npm/cli.test.js +++ b/tests/npm/cli.test.js @@ -1,28 +1,47 @@ const assert = require('assert'); const { execSync } = require('child_process'); const path = require('path'); -const fs = require('fs'); -const os = require('os'); +const { createTestEnvironment } = require('../fixtures/test-environment'); describe('npm CLI', () => { const ccsPath = path.join(__dirname, '..', '..', 'dist', 'ccs.js'); - const ccsDir = path.join(os.homedir(), '.ccs'); - const configPath = path.join(ccsDir, 'config.json'); + let testEnv; + let testCcsHome; before(() => { - // Ensure CCS is installed and configured - if (!fs.existsSync(configPath)) { - const postinstallScript = path.join(__dirname, '..', '..', 'scripts', 'postinstall.js'); - execSync(`node "${postinstallScript}"`, { stdio: 'ignore' }); + // Create isolated test environment + testEnv = createTestEnvironment(); + testCcsHome = testEnv.testHome; + + // Run postinstall to create config in test environment + const postinstallScript = path.join(__dirname, '..', '..', 'scripts', 'postinstall.js'); + execSync(`node "${postinstallScript}"`, { + stdio: 'ignore', + env: { ...process.env, CCS_HOME: testCcsHome } + }); + }); + + after(() => { + // Clean up test environment + if (testEnv) { + testEnv.cleanup(); } }); + // Helper to run CLI with test environment + function runCli(args, options = {}) { + return execSync(`node "${ccsPath}" ${args}`, { + ...options, + env: { ...process.env, CCS_HOME: testCcsHome } + }); + } + describe('Argument parsing', () => { it('handles flag -c without profile error', function() { this.timeout(5000); try { - execSync(`node "${ccsPath}" -c`, { stdio: 'pipe' }); + runCli('-c', { stdio: 'pipe' }); } catch (e) { const output = e.stderr?.toString() || e.stdout?.toString() || ''; // Should NOT show "Profile '-c' not found" error @@ -34,7 +53,7 @@ describe('npm CLI', () => { this.timeout(5000); try { - execSync(`node "${ccsPath}" --verbose`, { stdio: 'pipe' }); + runCli('--verbose', { stdio: 'pipe' }); } catch (e) { const output = e.stderr?.toString() || e.stdout?.toString() || ''; assert(!output.includes("Profile '--verbose' not found"), 'Should not treat --verbose as profile'); @@ -45,7 +64,7 @@ describe('npm CLI', () => { this.timeout(10000); try { - execSync(`node "${ccsPath}" -p "test prompt"`, { stdio: 'pipe', timeout: 8000 }); + runCli('-p "test prompt"', { stdio: 'pipe', timeout: 8000 }); } catch (e) { const output = e.stderr?.toString() || e.stdout?.toString() || ''; assert(!output.includes("Profile '-p' not found"), 'Should not treat -p as profile'); @@ -56,7 +75,7 @@ describe('npm CLI', () => { this.timeout(5000); try { - execSync(`node "${ccsPath}" -c --verbose`, { stdio: 'pipe' }); + runCli('-c --verbose', { stdio: 'pipe' }); } catch (e) { const output = e.stderr?.toString() || e.stdout?.toString() || ''; assert(!output.includes("Profile '-c' not found"), 'Should not treat flags as profiles'); @@ -70,7 +89,7 @@ describe('npm CLI', () => { this.timeout(5000); try { - execSync(`node "${ccsPath}" glm --help`, { stdio: 'pipe' }); + runCli('glm --help', { stdio: 'pipe' }); } catch (e) { const output = e.stderr?.toString() || ''; assert(!output.includes("Profile 'glm' not found"), 'GLM profile should exist'); @@ -81,7 +100,7 @@ describe('npm CLI', () => { this.timeout(5000); try { - execSync(`node "${ccsPath}" invalid-profile-name`, { stdio: 'pipe' }); + runCli('invalid-profile-name', { stdio: 'pipe' }); assert(false, 'Should have thrown an error for invalid profile'); } catch (e) { const output = e.stderr?.toString() || e.stdout?.toString() || ''; @@ -93,7 +112,7 @@ describe('npm CLI', () => { this.timeout(5000); try { - execSync(`node "${ccsPath}" glm -c`, { stdio: 'pipe' }); + runCli('glm -c', { stdio: 'pipe' }); } catch (e) { const output = e.stderr?.toString() || ''; assert(!output.includes("Profile 'glm' not found"), 'GLM profile should exist'); @@ -106,28 +125,28 @@ describe('npm CLI', () => { it('shows version with --version flag', function() { this.timeout(5000); - const output = execSync(`node "${ccsPath}" --version`, { encoding: 'utf8' }); + const output = runCli('--version', { encoding: 'utf8' }); assert(/\d+\.\d+\.\d+/.test(output), 'Should show version number'); }); it('shows version with -v flag', function() { this.timeout(5000); - const output = execSync(`node "${ccsPath}" -v`, { encoding: 'utf8' }); + const output = runCli('-v', { encoding: 'utf8' }); assert(/\d+\.\d+\.\d+/.test(output), 'Should show version number'); }); it('shows help with --help flag', function() { this.timeout(5000); - const output = execSync(`node "${ccsPath}" --help`, { encoding: 'utf8' }); + const output = runCli('--help', { encoding: 'utf8' }); assert(/usage|help|options/i.test(output), 'Should show help information'); }); it('shows help with -h flag', function() { this.timeout(5000); - const output = execSync(`node "${ccsPath}" -h`, { encoding: 'utf8' }); + const output = runCli('-h', { encoding: 'utf8' }); assert(/usage|help|options/i.test(output), 'Should show help information'); }); }); @@ -137,7 +156,7 @@ describe('npm CLI', () => { this.timeout(5000); try { - execSync(`node "${ccsPath}"`, { stdio: 'pipe' }); + runCli('', { stdio: 'pipe' }); } catch (e) { // Should either succeed or fail gracefully with a helpful error const output = e.stderr?.toString() || e.stdout?.toString() || ''; @@ -150,7 +169,7 @@ describe('npm CLI', () => { const longArg = 'a'.repeat(1000); try { - execSync(`node "${ccsPath}" "${longArg}"`, { stdio: 'pipe' }); + runCli(`"${longArg}"`, { stdio: 'pipe' }); } catch (e) { // Should handle gracefully, not crash const output = e.stderr?.toString() || e.stdout?.toString() || ''; @@ -158,4 +177,4 @@ describe('npm CLI', () => { } }); }); -}); \ No newline at end of file +}); diff --git a/tests/npm/postinstall.test.js b/tests/npm/postinstall.test.js index 874a7074..438a0870 100644 --- a/tests/npm/postinstall.test.js +++ b/tests/npm/postinstall.test.js @@ -1,53 +1,56 @@ const assert = require('assert'); const { execSync } = require('child_process'); -const fs = require('fs'); const path = require('path'); -const os = require('os'); +const { createTestEnvironment } = require('../fixtures/test-environment'); describe('npm postinstall', () => { - const ccsDir = path.join(os.homedir(), '.ccs'); - const configPath = path.join(ccsDir, 'config.json'); - const glmPath = path.join(ccsDir, 'glm.settings.json'); + let testEnv; const postinstallScript = path.join(__dirname, '..', '..', 'scripts', 'postinstall.js'); beforeEach(() => { - // Clean slate before each test - if (fs.existsSync(ccsDir)) { - fs.rmSync(ccsDir, { recursive: true, force: true }); - } + // Create isolated test environment for each test + testEnv = createTestEnvironment(); }); - after(() => { - // Cleanup after all tests - if (fs.existsSync(ccsDir)) { - fs.rmSync(ccsDir, { recursive: true, force: true }); + afterEach(() => { + // Clean up test environment + if (testEnv) { + testEnv.cleanup(); } }); it('creates config.json', () => { - execSync(`node "${postinstallScript}"`, { stdio: 'ignore' }); + execSync(`node "${postinstallScript}"`, { + stdio: 'ignore', + env: { ...process.env, CCS_HOME: testEnv.testHome } + }); - assert(fs.existsSync(configPath), 'config.json should be created'); + assert(testEnv.fileExists('config.json'), 'config.json should be created'); - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + const config = testEnv.readFile('config.json', true); assert(config.profiles, 'config.json should have profiles'); assert(typeof config.profiles === 'object', 'profiles should be an object'); }); it('creates glm.settings.json', () => { - execSync(`node "${postinstallScript}"`, { stdio: 'ignore' }); + execSync(`node "${postinstallScript}"`, { + stdio: 'ignore', + env: { ...process.env, CCS_HOME: testEnv.testHome } + }); - assert(fs.existsSync(glmPath), 'glm.settings.json should be created'); + assert(testEnv.fileExists('glm.settings.json'), 'glm.settings.json should be created'); - const glmSettings = JSON.parse(fs.readFileSync(glmPath, 'utf8')); + const glmSettings = testEnv.readFile('glm.settings.json', true); assert(glmSettings.env, 'glm.settings.json should have env section'); assert(glmSettings.env.ANTHROPIC_MODEL, 'should have ANTHROPIC_MODEL set'); assert.strictEqual(glmSettings.env.ANTHROPIC_MODEL, 'glm-4.6'); }); it('is idempotent', () => { + const env = { ...process.env, CCS_HOME: testEnv.testHome }; + // Run postinstall first time - execSync(`node "${postinstallScript}"`, { stdio: 'ignore' }); + execSync(`node "${postinstallScript}"`, { stdio: 'ignore', env }); // Create custom config const customConfig = { @@ -56,19 +59,22 @@ describe('npm postinstall', () => { glm: '~/.ccs/glm.settings.json' } }; - fs.writeFileSync(configPath, JSON.stringify(customConfig, null, 2)); + testEnv.createFile('config.json', customConfig); // Run postinstall again - execSync(`node "${postinstallScript}"`, { stdio: 'ignore' }); + execSync(`node "${postinstallScript}"`, { stdio: 'ignore', env }); // Verify custom config preserved - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + const config = testEnv.readFile('config.json', true); assert(config.profiles.custom, 'Custom profile should be preserved'); assert.strictEqual(config.profiles.custom, '~/.custom.json'); }); it('uses ASCII symbols', () => { - const output = execSync(`node "${postinstallScript}"`, { encoding: 'utf8' }); + const output = execSync(`node "${postinstallScript}"`, { + encoding: 'utf8', + env: { ...process.env, CCS_HOME: testEnv.testHome } + }); // Check for ASCII symbols [OK], [!], [X], [i] - not emojis assert(/\[(OK|!|X|i)\]/.test(output), 'Should use ASCII symbols, not emojis'); @@ -80,23 +86,27 @@ describe('npm postinstall', () => { it('handles existing directory gracefully', () => { // Create directory manually first - fs.mkdirSync(ccsDir, { recursive: true }); - fs.writeFileSync(path.join(ccsDir, 'existing.txt'), 'exists'); + testEnv.createFile('existing.txt', 'exists'); // Run postinstall - execSync(`node "${postinstallScript}"`, { stdio: 'ignore' }); + execSync(`node "${postinstallScript}"`, { + stdio: 'ignore', + env: { ...process.env, CCS_HOME: testEnv.testHome } + }); // Verify existing file still exists and new files are created - assert(fs.existsSync(path.join(ccsDir, 'existing.txt')), 'Existing files should be preserved'); - assert(fs.existsSync(configPath), 'config.json should be created'); - assert(fs.existsSync(glmPath), 'glm.settings.json should be created'); + assert(testEnv.fileExists('existing.txt'), 'Existing files should be preserved'); + assert(testEnv.fileExists('config.json'), 'config.json should be created'); + assert(testEnv.fileExists('glm.settings.json'), 'glm.settings.json should be created'); }); it('does not create VERSION file', () => { - execSync(`node "${postinstallScript}"`, { stdio: 'ignore' }); + execSync(`node "${postinstallScript}"`, { + stdio: 'ignore', + env: { ...process.env, CCS_HOME: testEnv.testHome } + }); - const versionPath = path.join(ccsDir, 'VERSION'); // The postinstall script doesn't create VERSION file (only native install does) - assert(!fs.existsSync(versionPath), 'VERSION file should NOT be created by npm postinstall'); + assert(!testEnv.fileExists('VERSION'), 'VERSION file should NOT be created by npm postinstall'); }); -}); \ No newline at end of file +});