From 70471af1f82b95e0b7e1cf4e1eb43be1ac3a8f4e Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 10 Nov 2025 21:05:55 -0500 Subject: [PATCH] fix(migration): run migration during install, not on first execution (v3.1.1) Fixed: - Migration now runs during installation across all methods (npm, bash, PowerShell) - Guarantees ~/.ccs/shared/ populated immediately with ~/.claude/ content - Users no longer need to run ccs command to trigger migration Changed: - Refactored SharedManager with _needsMigration() and _performMigration() methods - _copyDirectory() returns {copied, skipped} stats and preserves existing files - Shows detailed migration output: '[OK] Migrated 5 commands, 19 skills' - Removed lazy migration from bin/ccs.js, lib/ccs, lib/ccs.ps1 Implementation: - npm: Migration in scripts/postinstall.js - bash: Migration in installers/install.sh (migrate_shared_data function) - PowerShell: Migration in installers/install.ps1 (Invoke-SharedDataMigration) - Fixed arithmetic expansion with set -e (changed ((var++)) to var=$((var + 1))) Cross-platform parity maintained across all installation methods. --- CHANGELOG.md | 31 +++++++++ VERSION | 2 +- bin/ccs.js | 5 -- bin/shared-manager.js | 138 +++++++++++++++++++++++++++++------------ installers/install.ps1 | 89 +++++++++++++++++++++++++- installers/install.sh | 77 ++++++++++++++++++++++- lib/ccs | 5 +- lib/ccs.ps1 | 5 +- package.json | 2 +- scripts/postinstall.js | 12 ++++ 10 files changed, 310 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42253282..b486b74d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ All notable changes to CCS will be documented here. Format based on [Keep a Changelog](https://keepachangelog.com/). +## [3.1.1] - 2025-11-10 + +### Fixed +- **Migration Timing**: Migration now runs during installation, not on first `ccs` execution + - npm: Migration runs in `scripts/postinstall.js` during `npm install` + - bash: Migration runs in `installers/install.sh` during installation + - PowerShell: Migration runs in `installers/install.ps1` during installation + - Guarantees `~/.ccs/shared/` populated with `~/.claude/` content immediately + - Users no longer need to run `ccs` command to trigger migration + +### Changed +- **SharedManager Refactoring**: Improved migration logic and file preservation + - Extracted `_needsMigration()` method for clearer logic + - Extracted `_performMigration()` method with file counting stats + - `_copyDirectory()` now returns `{copied, skipped}` stats + - Preserves existing files in `~/.ccs/shared/` (never overwrites user modifications) + - Shows detailed migration output: `[OK] Migrated 5 commands, 19 skills` +- **Removed Lazy Migration**: No longer runs migration on first `ccs` execution + - Removed from `bin/ccs.js` (Node.js wrapper) + - Removed from `lib/ccs` (bash executable) + - Removed from `lib/ccs.ps1` (PowerShell executable) + +### Technical Details +- **Modified Files**: All implementations updated for consistency + - `bin/shared-manager.js`: Refactored with `_needsMigration()`, `_performMigration()`, improved `_copyDirectory()` + - `scripts/postinstall.js`: Calls migration after creating shared directories + - `installers/install.sh`: Added `migrate_shared_data()` function + - `installers/install.ps1`: Added `Invoke-SharedDataMigration` function + - `bin/ccs.js`, `lib/ccs`, `lib/ccs.ps1`: Removed lazy migration calls +- **Cross-Platform Parity**: All installation methods (npm, bash, PowerShell) behave identically + ## [3.1.0] - 2025-11-10 ### Added diff --git a/VERSION b/VERSION index fd2a0186..94ff29cc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.0 +3.1.1 diff --git a/bin/ccs.js b/bin/ccs.js index ac99ec11..25d00db5 100755 --- a/bin/ccs.js +++ b/bin/ccs.js @@ -262,11 +262,6 @@ async function main() { recovery.showRecoveryHints(); } - // Run migration to shared structure (Phase 1: idempotent) - const SharedManager = require('./shared-manager'); - const sharedManager = new SharedManager(); - sharedManager.migrateToSharedStructure(); - // Detect profile const { profile, remainingArgs } = detectProfile(args); diff --git a/bin/shared-manager.js b/bin/shared-manager.js index caa2ff5c..89537db2 100644 --- a/bin/shared-manager.js +++ b/bin/shared-manager.js @@ -68,52 +68,100 @@ class SharedManager { } } + /** + * Check if migration is needed + * @returns {boolean} + * @private + */ + _needsMigration() { + // If shared dir doesn't exist, migration needed + if (!fs.existsSync(this.sharedDir)) { + return true; + } + + // Check if ALL shared directories are empty + const allEmpty = this.sharedDirs.every(dir => { + const dirPath = path.join(this.sharedDir, dir); + if (!fs.existsSync(dirPath)) return true; + try { + const files = fs.readdirSync(dirPath); + return files.length === 0; + } catch (err) { + return true; // If can't read, assume empty + } + }); + + return allEmpty; + } + + /** + * Perform migration from ~/.claude/ to ~/.ccs/shared/ + * @returns {object} { commands: N, skills: N, agents: N } + * @private + */ + _performMigration() { + const stats = { commands: 0, skills: 0, agents: 0 }; + const claudeDir = path.join(this.homeDir, '.claude'); + + if (!fs.existsSync(claudeDir)) { + return stats; // No content to migrate + } + + // Migrate commands + const commandsPath = path.join(claudeDir, 'commands'); + if (fs.existsSync(commandsPath)) { + const result = this._copyDirectory(commandsPath, path.join(this.sharedDir, 'commands')); + stats.commands = result.copied; + } + + // Migrate skills + const skillsPath = path.join(claudeDir, 'skills'); + if (fs.existsSync(skillsPath)) { + const result = this._copyDirectory(skillsPath, path.join(this.sharedDir, 'skills')); + stats.skills = result.copied; + } + + // Migrate agents + const agentsPath = path.join(claudeDir, 'agents'); + if (fs.existsSync(agentsPath)) { + const result = this._copyDirectory(agentsPath, path.join(this.sharedDir, 'agents')); + stats.agents = result.copied; + } + + return stats; + } + /** * Migrate existing instances to shared structure * Idempotent: Safe to run multiple times */ migrateToSharedStructure() { - // Check if migration is needed (shared dirs exist but are empty) - const needsMigration = !fs.existsSync(this.sharedDir) || - this.sharedDirs.every(dir => { - const dirPath = path.join(this.sharedDir, dir); - if (!fs.existsSync(dirPath)) return true; - try { - const files = fs.readdirSync(dirPath); - return files.length === 0; // Empty directory needs migration - } catch (err) { - return true; // If we can't read it, assume it needs migration - } - }); + console.log('[i] Checking for content migration...'); - if (!needsMigration) { - return; // Already migrated with content + // Check if migration is needed + if (!this._needsMigration()) { + console.log('[OK] Migration not needed (shared dirs have content)'); + return; } + console.log('[i] Migrating ~/.claude/ content to ~/.ccs/shared/...'); + // Create shared directories this.ensureSharedDirectories(); - // Copy from ~/.claude/ (actual Claude CLI directory) - const claudeDir = path.join(this.homeDir, '.claude'); + // Perform migration + const stats = this._performMigration(); - if (fs.existsSync(claudeDir)) { - // Copy commands to shared (if exists) - const commandsPath = path.join(claudeDir, 'commands'); - if (fs.existsSync(commandsPath)) { - this._copyDirectory(commandsPath, path.join(this.sharedDir, 'commands')); - } - - // Copy skills to shared (if exists) - const skillsPath = path.join(claudeDir, 'skills'); - if (fs.existsSync(skillsPath)) { - this._copyDirectory(skillsPath, path.join(this.sharedDir, 'skills')); - } - - // Copy agents to shared (if exists) - const agentsPath = path.join(claudeDir, 'agents'); - if (fs.existsSync(agentsPath)) { - this._copyDirectory(agentsPath, path.join(this.sharedDir, 'agents')); - } + // Show results + const total = stats.commands + stats.skills + stats.agents; + if (total === 0) { + console.log('[OK] No content to migrate (empty ~/.claude/)'); + } else { + const parts = []; + if (stats.commands > 0) parts.push(`${stats.commands} commands`); + if (stats.skills > 0) parts.push(`${stats.skills} skills`); + if (stats.agents > 0) parts.push(`${stats.agents} agents`); + console.log(`[OK] Migrated ${parts.join(', ')}`); } // Update all instances to use symlinks @@ -127,19 +175,18 @@ class SharedManager { } } } - - console.log('[OK] Migrated to shared structure'); } /** - * Copy directory recursively (fallback for Windows) + * Copy directory recursively (SAFE: preserves existing files) * @param {string} src - Source directory * @param {string} dest - Destination directory + * @returns {object} { copied: N, skipped: N } * @private */ _copyDirectory(src, dest) { if (!fs.existsSync(src)) { - return; + return { copied: 0, skipped: 0 }; } if (!fs.existsSync(dest)) { @@ -147,17 +194,30 @@ class SharedManager { } const entries = fs.readdirSync(src, { withFileTypes: true }); + let copied = 0; + let skipped = 0; for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); + // SAFETY: Skip if destination exists (preserve user modifications) + if (fs.existsSync(destPath)) { + skipped++; + continue; + } + if (entry.isDirectory()) { - this._copyDirectory(srcPath, destPath); + const stats = this._copyDirectory(srcPath, destPath); + copied += stats.copied; + skipped += stats.skipped; } else { fs.copyFileSync(srcPath, destPath); + copied++; } } + + return { copied, skipped }; } } diff --git a/installers/install.ps1 b/installers/install.ps1 index 19d8f5c1..bbad1363 100644 --- a/installers/install.ps1 +++ b/installers/install.ps1 @@ -31,7 +31,7 @@ $InstallMethod = if ($ScriptDir -and ((Test-Path "$ScriptDir\lib\ccs.ps1") -or ( # IMPORTANT: Update this version when releasing new versions! # This hardcoded version is used for standalone installations (irm | iex) # For git installations, VERSION file is read if available -$CcsVersion = "3.1.0" +$CcsVersion = "3.1.1" # Try to read VERSION file for git installations if ($ScriptDir) { @@ -417,6 +417,93 @@ if (Test-Path $GlmSettings) { Write-Host "=========================================" Write-Host "" +# Migrate from ~/.claude/ to ~/.ccs/shared/ (v3.1.1) +function Invoke-SharedDataMigration { + $SharedDir = "$CcsDir\shared" + $ClaudeDir = "$env:USERPROFILE\.claude" + + # Create shared directories + @('commands', 'skills', 'agents') | ForEach-Object { + $Dir = Join-Path $SharedDir $_ + if (-not (Test-Path $Dir)) { + New-Item -ItemType Directory -Path $Dir -Force | Out-Null + } + } + + # Check if migration is needed (shared dirs are empty) + $NeedsMigration = $false + foreach ($Dir in @('commands', 'skills', 'agents')) { + $DirPath = Join-Path $SharedDir $Dir + if (-not (Test-Path $DirPath) -or (Get-ChildItem $DirPath -ErrorAction SilentlyContinue).Count -eq 0) { + $NeedsMigration = $true + break + } + } + + if (-not $NeedsMigration) { + return + } + + # Copy from ~/.claude/ if exists + if (-not (Test-Path $ClaudeDir)) { + return + } + + $MigratedCommands = 0 + $MigratedSkills = 0 + $MigratedAgents = 0 + + # Copy commands + $CommandsPath = Join-Path $ClaudeDir "commands" + if (Test-Path $CommandsPath) { + Get-ChildItem $CommandsPath -File -ErrorAction SilentlyContinue | ForEach-Object { + $DestPath = Join-Path "$SharedDir\commands" $_.Name + if (-not (Test-Path $DestPath)) { + Copy-Item $_.FullName $DestPath -ErrorAction SilentlyContinue + $MigratedCommands++ + } + } + } + + # Copy skills (directories) + $SkillsPath = Join-Path $ClaudeDir "skills" + if (Test-Path $SkillsPath) { + Get-ChildItem $SkillsPath -Directory -ErrorAction SilentlyContinue | ForEach-Object { + $DestPath = Join-Path "$SharedDir\skills" $_.Name + if (-not (Test-Path $DestPath)) { + Copy-Item $_.FullName $DestPath -Recurse -ErrorAction SilentlyContinue + $MigratedSkills++ + } + } + } + + # Copy agents + $AgentsPath = Join-Path $ClaudeDir "agents" + if (Test-Path $AgentsPath) { + Get-ChildItem $AgentsPath -File -ErrorAction SilentlyContinue | ForEach-Object { + $DestPath = Join-Path "$SharedDir\agents" $_.Name + if (-not (Test-Path $DestPath)) { + Copy-Item $_.FullName $DestPath -ErrorAction SilentlyContinue + $MigratedAgents++ + } + } + } + + # Show results + $Total = $MigratedCommands + $MigratedSkills + $MigratedAgents + if ($Total -gt 0) { + $Parts = @() + if ($MigratedCommands -gt 0) { $Parts += "$MigratedCommands commands" } + if ($MigratedSkills -gt 0) { $Parts += "$MigratedSkills skills" } + if ($MigratedAgents -gt 0) { $Parts += "$MigratedAgents agents" } + Write-Host "[i] Migrated $($Parts -join ', ')" + } +} + +Write-Host "[i] Checking for content migration..." +Invoke-SharedDataMigration +Write-Host "" + # Check and update PATH $UserPath = [Environment]::GetEnvironmentVariable("Path", [System.EnvironmentVariableTarget]::User) if ($UserPath -notlike "*$CcsDir*") { diff --git a/installers/install.sh b/installers/install.sh index 19f83e3a..ddcc87c5 100755 --- a/installers/install.sh +++ b/installers/install.sh @@ -32,7 +32,7 @@ fi # IMPORTANT: Update this version when releasing new versions! # This hardcoded version is used for standalone installations (curl | bash) # For git installations, VERSION file is read if available -CCS_VERSION="3.1.0" +CCS_VERSION="3.1.1" # Try to read VERSION file for git installations if [[ -f "$SCRIPT_DIR/VERSION" ]]; then @@ -611,6 +611,81 @@ fi echo "└─" echo "" +# Migrate from ~/.claude/ to ~/.ccs/shared/ (v3.1.1) +migrate_shared_data() { + local shared_dir="$CCS_DIR/shared" + local claude_dir="$HOME/.claude" + + # Create shared directories + mkdir -p "$shared_dir"/{commands,skills,agents} + + # Check if migration is needed (shared dirs are empty) + local needs_migration=false + for dir in commands skills agents; do + if [[ -z "$(ls -A "$shared_dir/$dir" 2>/dev/null)" ]]; then + needs_migration=true + break + fi + done + + if [[ "$needs_migration" == "false" ]]; then + return 0 + fi + + # Copy from ~/.claude/ if exists + if [[ ! -d "$claude_dir" ]]; then + return 0 + fi + + local migrated_commands=0 + local migrated_skills=0 + local migrated_agents=0 + + # Copy commands + if [[ -d "$claude_dir/commands" ]]; then + for file in "$claude_dir/commands"/*; do + [[ -f "$file" ]] || continue + local basename=$(basename "$file") + [[ -f "$shared_dir/commands/$basename" ]] && continue # Skip if exists + cp "$file" "$shared_dir/commands/" 2>/dev/null && migrated_commands=$((migrated_commands + 1)) + done + fi + + # Copy skills (directories) + if [[ -d "$claude_dir/skills" ]]; then + for dir in "$claude_dir/skills"/*; do + [[ -d "$dir" ]] || continue + local basename=$(basename "$dir") + [[ -d "$shared_dir/skills/$basename" ]] && continue # Skip if exists + cp -r "$dir" "$shared_dir/skills/" 2>/dev/null && migrated_skills=$((migrated_skills + 1)) + done + fi + + # Copy agents + if [[ -d "$claude_dir/agents" ]]; then + for file in "$claude_dir/agents"/*; do + [[ -f "$file" ]] || continue + local basename=$(basename "$file") + [[ -f "$shared_dir/agents/$basename" ]] && continue # Skip if exists + cp "$file" "$shared_dir/agents/" 2>/dev/null && migrated_agents=$((migrated_agents + 1)) + done + fi + + # Show results + local total=$((migrated_commands + migrated_skills + migrated_agents)) + if [[ $total -gt 0 ]]; then + local parts=() + [[ $migrated_commands -gt 0 ]] && parts+=("$migrated_commands commands") + [[ $migrated_skills -gt 0 ]] && parts+=("$migrated_skills skills") + [[ $migrated_agents -gt 0 ]] && parts+=("$migrated_agents agents") + echo "[i] Migrated $(IFS=", "; echo "${parts[*]}")" + fi +} + +echo "[i] Checking for content migration..." +migrate_shared_data +echo "" + # Auto-configure PATH if needed (all Unix platforms) configure_shell_path diff --git a/lib/ccs b/lib/ccs index aa375aba..420acf5d 100755 --- a/lib/ccs +++ b/lib/ccs @@ -2,7 +2,7 @@ set -euo pipefail # Version (updated by scripts/bump-version.sh) -CCS_VERSION="3.1.0" +CCS_VERSION="3.1.1" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly CONFIG_FILE="${CCS_CONFIG:-$HOME/.ccs/config.json}" readonly PROFILES_JSON="$HOME/.ccs/profiles.json" @@ -1010,9 +1010,6 @@ auto_recover || { exit 1 } -# Run migration to shared structure (Phase 1: idempotent) -migrate_to_shared_structure - # Smart profile detection: if first arg starts with '-', it's a flag not a profile if [[ $# -eq 0 ]] || [[ "${1}" =~ ^- ]]; then # No args or first arg is a flag → use default profile diff --git a/lib/ccs.ps1 b/lib/ccs.ps1 index 5b5694f1..849a1fe1 100644 --- a/lib/ccs.ps1 +++ b/lib/ccs.ps1 @@ -12,7 +12,7 @@ param( $ErrorActionPreference = "Stop" # Version (updated by scripts/bump-version.sh) -$CcsVersion = "3.1.0" +$CcsVersion = "3.1.1" $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $ConfigFile = if ($env:CCS_CONFIG) { $env:CCS_CONFIG } else { "$env:USERPROFILE\.ccs\config.json" } $ProfilesJson = "$env:USERPROFILE\.ccs\profiles.json" @@ -1000,9 +1000,6 @@ if (-not (Invoke-AutoRecovery)) { exit 1 } -# Run migration to shared structure (Phase 1: idempotent) -Migrate-SharedStructure - # Smart profile detection: if first arg starts with '-', it's a flag not a profile if ($RemainingArgs.Count -eq 0 -or $RemainingArgs[0] -match '^-') { # No args or first arg is a flag → use default profile diff --git a/package.json b/package.json index fcff3c24..004892d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "3.1.0", + "version": "3.1.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 1a91a39e..7826eb9d 100755 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -89,6 +89,18 @@ function createConfigFiles() { } } + // Migrate from ~/.claude/ to ~/.ccs/shared/ (v3.1.1) + console.log(''); + try { + const SharedManager = require('../bin/shared-manager'); + const sharedManager = new SharedManager(); + sharedManager.migrateToSharedStructure(); + } catch (err) { + console.warn('[!] Migration warning:', err.message); + console.warn(' You can manually copy files from ~/.claude/ to ~/.ccs/shared/'); + } + console.log(''); + // Create config.json if missing const configPath = path.join(ccsDir, 'config.json'); if (!fs.existsSync(configPath)) {