mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 20:20:09 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user