diff --git a/src/commands/init.ts b/src/commands/init.ts index fa41011..2636c31 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -913,10 +913,25 @@ export async function init(options: InitOptions = {}): Promise { // Show errors if any if (result.errors.length > 0) { console.log() - console.log(ansis.red(` ⚠ ${i18n.t('init:installationErrors')}`)) + if (!result.success) { + // Critical failure — prominent red box + console.log(ansis.red.bold(` ╔════════════════════════════════════════════════════════════╗`)) + console.log(ansis.red.bold(` ║ ⚠ 安装出现错误 / Installation errors detected ║`)) + console.log(ansis.red.bold(` ╚════════════════════════════════════════════════════════════╝`)) + } + else { + console.log(ansis.yellow(` ⚠ ${i18n.t('init:installationErrors')}`)) + } result.errors.forEach((error) => { console.log(` ${ansis.red('✗')} ${error}`) }) + if (!result.success) { + console.log() + console.log(ansis.yellow(` 尝试修复 / Try to fix:`)) + console.log(ansis.cyan(` npx ccg-workflow@latest init --force`)) + console.log(ansis.gray(` 如仍失败,请提交 issue 并附上以上错误信息`)) + console.log(ansis.gray(` If still failing, report an issue with the errors above`)) + } } // Show binary installation result diff --git a/src/commands/update.ts b/src/commands/update.ts index a7bf281..51c7f05 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -2,12 +2,13 @@ import type { ModelRouting, ModelType } from '../types' import ansis from 'ansis' import { exec } from 'node:child_process' import { promisify } from 'node:util' +import fs from 'fs-extra' import inquirer from 'inquirer' import ora from 'ora' import { homedir } from 'node:os' import { join } from 'pathe' import { checkForUpdates, compareVersions } from '../utils/version' -import { showBinaryDownloadWarning, uninstallWorkflows, verifyBinary } from '../utils/installer' +import { showBinaryDownloadWarning, verifyBinary } from '../utils/installer' import { readCcgConfig, writeCcgConfig } from '../utils/config' import { migrateToV1_4_0, needsMigration } from '../utils/migration' import { i18n } from '../i18n' @@ -249,7 +250,6 @@ async function performUpdate(fromVersion: string, toVersion: string, isNewVersio catch { const npxCachePath = join(homedir(), '.npm', '_npx') try { - const fs = await import('fs-extra') await fs.remove(npxCachePath) } catch { @@ -298,31 +298,59 @@ async function performUpdate(fromVersion: string, toVersion: string, isNewVersio } } - // Step 3: Delete old workflows (preserve binary to avoid unnecessary re-download) - spinner = ora(i18n.t('update:removingOld')).start() + // ── Atomic update: backup → install → verify → cleanup / rollback ── + // Old approach deleted everything BEFORE installing, so if install failed + // the user was left with nothing. New approach backs up first, installs new, + // verifies, then cleans up backups. On failure, restores from backup. const installDir = join(homedir(), '.claude') + const BACKUP_SUFFIX = '.ccg-update-bak' + // Directories to back up before installing new version + const backupTargets = [ + join(installDir, 'commands', 'ccg'), + join(installDir, 'agents', 'ccg'), + join(installDir, 'skills', 'ccg'), + ] + + // Step 3: Back up existing files (move to *.ccg-update-bak) + spinner = ora(i18n.t('update:removingOld')).start() + + const backedUp: string[] = [] try { - const uninstallResult = await uninstallWorkflows(installDir, { preserveBinary: true }) - - if (uninstallResult.success) { - spinner.succeed(i18n.t('update:oldRemoved')) - } - else { - spinner.warn(i18n.t('update:partialRemoveFailed')) - for (const error of uninstallResult.errors) { - console.log(ansis.yellow(` • ${error}`)) + for (const dir of backupTargets) { + if (await fs.pathExists(dir)) { + const backupPath = dir + BACKUP_SUFFIX + // Clean up leftover backups from previous failed update + if (await fs.pathExists(backupPath)) { + await fs.remove(backupPath) + } + await fs.move(dir, backupPath) + backedUp.push(dir) } } + spinner.succeed(i18n.t('update:oldRemoved')) } catch (error) { - spinner.warn(i18n.t('update:removeFailed', { error: String(error) })) + // Backup failed — restore what we moved and abort + spinner.warn(`Backup failed: ${error}`) + for (const dir of backedUp) { + const backupPath = dir + BACKUP_SUFFIX + try { + if (await fs.pathExists(backupPath)) { + await fs.move(backupPath, dir) + } + } + catch { /* best-effort restore */ } + } + console.log(ansis.yellow(' 旧版本文件已保留 / Old files preserved')) + return } // Step 4: Install new workflows using the latest version via npx spinner = ora(i18n.t('update:installingNew')).start() + let installSuccess = false try { await execAsync(`npx --yes ccg-workflow@latest init --force --skip-mcp --skip-prompt`, { timeout: 300000, // 5min — binary download from GitHub Release may be slow (especially in China) @@ -331,26 +359,78 @@ async function performUpdate(fromVersion: string, toVersion: string, isNewVersio CCG_UPDATE_MODE: 'true', }, }) - spinner.succeed(i18n.t('update:installDone')) - // Read updated config to display installed commands - const config = await readCcgConfig() - if (config?.workflows?.installed) { - console.log() - console.log(ansis.cyan(i18n.t('update:installed', { count: config.workflows.installed.length }))) - for (const cmd of config.workflows.installed) { - console.log(` ${ansis.gray('•')} /ccg:${cmd}`) + // Step 5: Verify new installation actually produced files + const commandsDir = join(installDir, 'commands', 'ccg') + const hasCommands = await fs.pathExists(commandsDir) + && (await fs.readdir(commandsDir)).some(f => f.endsWith('.md')) + + if (hasCommands) { + installSuccess = true + spinner.succeed(i18n.t('update:installDone')) + + // Read updated config to display installed commands + const config = await readCcgConfig() + if (config?.workflows?.installed) { + console.log() + console.log(ansis.cyan(i18n.t('update:installed', { count: config.workflows.installed.length }))) + for (const cmd of config.workflows.installed) { + console.log(` ${ansis.gray('•')} /ccg:${cmd}`) + } } } - - // Step 5: Verify binary exists and is functional after update - if (!(await verifyBinary(installDir))) { - showBinaryDownloadWarning(join(installDir, 'bin')) + else { + // Subprocess reported success but no files were created + spinner.fail(i18n.t('update:installFailed')) + console.log(ansis.red(' Install subprocess completed but no command files were created')) } } catch (error) { spinner.fail(i18n.t('update:installFailed')) console.log(ansis.red(`${i18n.t('common:error')}: ${error}`)) + } + + // Step 6: Cleanup or rollback + if (installSuccess) { + // Success: remove backups + for (const dir of backedUp) { + try { + await fs.remove(dir + BACKUP_SUFFIX) + } + catch { /* non-critical: stale backup files */ } + } + + // Verify binary exists and is functional + if (!(await verifyBinary(installDir))) { + showBinaryDownloadWarning(join(installDir, 'bin')) + } + } + else { + // Failure: restore from backups so user still has a working installation + console.log() + console.log(ansis.yellow.bold(' ⚠ 正在恢复旧版本文件 / Restoring old version files...')) + let restored = 0 + for (const dir of backedUp) { + const backupPath = dir + BACKUP_SUFFIX + try { + // Remove any partial install artifacts + if (await fs.pathExists(dir)) { + await fs.remove(dir) + } + if (await fs.pathExists(backupPath)) { + await fs.move(backupPath, dir) + restored++ + } + } + catch (restoreErr) { + console.log(ansis.red(` Failed to restore ${dir}: ${restoreErr}`)) + } + } + + if (restored > 0) { + console.log(ansis.green(` ✓ 已恢复 ${restored} 个目录 / Restored ${restored} directories`)) + console.log(ansis.gray(' 旧版命令仍可正常使用 / Old commands still work')) + } console.log() console.log(ansis.yellow(i18n.t('update:manualRetry'))) console.log(ansis.cyan(' npx ccg-workflow@latest')) diff --git a/src/utils/installer-template.ts b/src/utils/installer-template.ts index 1808781..2c7dd9f 100644 --- a/src/utils/installer-template.ts +++ b/src/utils/installer-template.ts @@ -7,15 +7,37 @@ import { isWindows } from './platform' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) -// Find package root by looking for package.json +/** + * Find package root by looking for package.json up the directory tree. + * Validates that the found root contains a templates/ directory. + * + * Increased depth from 5 → 10 to handle deeply nested npm cache paths + * on Windows (e.g., AppData\Local\npm-cache\_npx\\node_modules\...). + */ function findPackageRoot(startDir: string): string { let dir = startDir - for (let i = 0; i < 5; i++) { + for (let i = 0; i < 10; i++) { if (fs.existsSync(join(dir, 'package.json'))) { - return dir + // Validate: package root must contain templates/ directory + if (fs.existsSync(join(dir, 'templates'))) { + return dir + } + // Found package.json but no templates/ — might be a parent workspace + // Continue searching upward } - dir = dirname(dir) + const parent = dirname(dir) + if (parent === dir) break // Reached filesystem root + dir = parent } + + // Fallback: warn loudly — this is the root cause of "silent install failure" + console.error( + `[CCG] ⚠ PACKAGE_ROOT resolution failed: could not find package.json with templates/ directory.\n` + + ` Start dir: ${startDir}\n` + + ` Last checked: ${dir}\n` + + ` This will cause commands/skills/prompts to not be installed.\n` + + ` Please report this issue at: https://github.com/fengshao1227/ccg-workflow/issues`, + ) return startDir } diff --git a/src/utils/installer.ts b/src/utils/installer.ts index 3dadc9f..15c8652 100644 --- a/src/utils/installer.ts +++ b/src/utils/installer.ts @@ -145,7 +145,11 @@ async function copyMdTemplates( options: { inject?: boolean } = {}, ): Promise { const installed: string[] = [] - if (!(await fs.pathExists(srcDir))) return installed + if (!(await fs.pathExists(srcDir))) { + // Log warning — helps diagnose "0 commands installed" issues + console.error(`[CCG] Template source directory not found: ${srcDir}`) + return installed + } await fs.ensureDir(destDir) const files = await fs.readdir(srcDir) @@ -241,7 +245,10 @@ async function installAgentFiles(ctx: InstallContext): Promise { async function installPromptFiles(ctx: InstallContext): Promise { const promptsTemplateDir = join(ctx.templateDir, 'prompts') const promptsDir = join(ctx.installDir, '.ccg', 'prompts') - if (!(await fs.pathExists(promptsTemplateDir))) return + if (!(await fs.pathExists(promptsTemplateDir))) { + ctx.result.errors.push(`Prompts template directory not found: ${promptsTemplateDir}`) + return + } for (const model of ['codex', 'gemini', 'claude']) { try { @@ -278,7 +285,13 @@ async function collectSkillNames(dir: string, depth = 0): Promise { } } } - catch { /* Directory doesn't exist or can't be read */ } + catch (error) { + // Only suppress ENOENT (dir not found); log other errors that indicate real problems + const code = (error as NodeJS.ErrnoException).code + if (code !== 'ENOENT') { + console.error(`[CCG] Failed to read skills directory ${dir}: ${code || error}`) + } + } return names } @@ -300,7 +313,12 @@ async function removeDirCollectMdNames(dir: string): Promise { async function installSkillFiles(ctx: InstallContext): Promise { const skillsTemplateDir = join(ctx.templateDir, 'skills') const skillsDestDir = join(ctx.installDir, 'skills', 'ccg') - if (!(await fs.pathExists(skillsTemplateDir))) return + + // Report error instead of silently returning when template dir is missing + if (!(await fs.pathExists(skillsTemplateDir))) { + ctx.result.errors.push(`Skills template directory not found: ${skillsTemplateDir}`) + return + } try { // Migration: move old v1.7.73 layout into skills/ccg/ namespace @@ -314,14 +332,21 @@ async function installSkillFiles(ctx: InstallContext): Promise { const oldPath = join(oldSkillsRoot, item) const newPath = join(skillsDestDir, item) if (await fs.pathExists(oldPath)) { - await fs.move(oldPath, newPath, { overwrite: true }) + try { + await fs.move(oldPath, newPath, { overwrite: true }) + } + catch (moveErr) { + // Windows: file locking can cause move to fail — log but continue + ctx.result.errors.push(`Skills migration: failed to move ${item}: ${moveErr}`) + } } } } // Recursive copy: preserves full directory tree + // Always overwrite to ensure fresh install gets all files await fs.copy(skillsTemplateDir, skillsDestDir, { - overwrite: ctx.force, + overwrite: true, errorOnExist: false, }) @@ -344,7 +369,17 @@ async function installSkillFiles(ctx: InstallContext): Promise { } await replacePathsInDir(skillsDestDir) - ctx.result.installedSkills = (await collectSkillNames(skillsDestDir)).length + // Post-copy validation: verify at least one SKILL.md was actually copied + const installedSkills = await collectSkillNames(skillsDestDir) + ctx.result.installedSkills = installedSkills.length + + if (installedSkills.length === 0) { + ctx.result.errors.push( + `Skills copy completed but no SKILL.md found in ${skillsDestDir}. ` + + `Possible cause: file locking (antivirus), permission denied, or path too long. ` + + `Try running as administrator or disabling antivirus real-time scanning temporarily.`, + ) + } } catch (error) { ctx.result.errors.push(`Failed to install skills: ${error}`) @@ -431,7 +466,10 @@ export function showBinaryDownloadWarning(binDir: string): void { console.log(ansis.gray(` → 找到 ${ansis.white(binaryFileName)} 并下载`)) console.log() console.log(ansis.white(` 2. 放到 / Place at:`)) - console.log(ansis.cyan(` ${binDir}/${destFileName}`)) + const displayPath = process.platform === 'win32' + ? `${binDir.replace(/\//g, '\\')}\\${destFileName}` + : `${binDir}/${destFileName}` + console.log(ansis.cyan(` ${displayPath}`)) console.log() if (process.platform !== 'win32') { console.log(ansis.white(` 3. 加权限 / Make executable:`)) @@ -540,6 +578,19 @@ export async function installWorkflows( }, } + // ── Pre-flight: validate template directory exists ── + // This is the #1 root cause of "silent install failure" on Windows: + // if PACKAGE_ROOT resolved wrong, templateDir doesn't exist and every + // sub-step silently returns empty results while reporting success. + if (!(await fs.pathExists(ctx.templateDir))) { + const errorMsg = `Template directory not found: ${ctx.templateDir} (PACKAGE_ROOT=${PACKAGE_ROOT}). ` + + `This usually means the npm package is incomplete or the cache is corrupted. ` + + `Try: npm cache clean --force && npx ccg-workflow@latest` + ctx.result.errors.push(errorMsg) + ctx.result.success = false + return ctx.result + } + // Ensure base directories await fs.ensureDir(join(installDir, 'commands', 'ccg')) await fs.ensureDir(join(installDir, '.ccg')) @@ -553,6 +604,17 @@ export async function installWorkflows( await installRuleFiles(ctx) await installBinaryFile(ctx) + // ── Post-flight: validate installation produced results ── + // Catch the case where all sub-steps silently returned empty + if (ctx.result.installedCommands.length === 0 && ctx.result.errors.length === 0) { + ctx.result.errors.push( + `No commands were installed (expected ${workflowIds.length}). ` + + `Template dir: ${ctx.templateDir}. ` + + `This may indicate a corrupted package or file permission issue.`, + ) + ctx.result.success = false + } + ctx.result.configPath = join(installDir, 'commands', 'ccg') return ctx.result }