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.
This commit is contained in:
kaitranntt
2025-11-10 21:05:55 -05:00
parent d925dfc169
commit 70471af1f8
10 changed files with 310 additions and 56 deletions
+31
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
3.1.0
3.1.1
-5
View File
@@ -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);
+99 -39
View File
@@ -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 };
}
}
+88 -1
View File
@@ -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*") {
+76 -1
View File
@@ -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
+1 -4
View File
@@ -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
+1 -4
View File
@@ -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
+1 -1
View File
@@ -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",
+12
View File
@@ -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)) {