fix: eliminate silent install failures and atomic update with rollback

Root cause: Windows/Linux users reported missing commands and skills after
install/update. Three critical issues found:

1. Update flow deleted all files BEFORE installing new ones. If the
   subprocess failed (network timeout, npm cache, permissions), the user
   was left with nothing — no commands, no skills, no rollback.

2. Multiple install functions silently returned empty results when template
   directories were missing (wrong PACKAGE_ROOT resolution), while still
   reporting success. Users saw "install complete" but had 0 files.

3. findPackageRoot only searched 5 levels up, insufficient for deeply
   nested npm cache paths on Windows.

Changes:
- Rewrite update flow: backup → install → verify → cleanup/rollback
  If install fails, old version is automatically restored from backup
- findPackageRoot: depth 5→10, validate templates/ dir exists, detect
  filesystem root, log actionable error on failure
- installWorkflows: pre-flight templateDir validation, post-flight
  0-commands check → force success=false
- installSkillFiles: report errors instead of silent return, post-copy
  SKILL.md validation, individual try-catch for migration moves,
  always overwrite (not conditional on --force)
- installPromptFiles: report errors instead of silent return
- copyMdTemplates: log warning when source dir missing
- collectSkillNames: distinguish ENOENT from permission/path errors
- init.ts: prominent red-box error display with fix instructions
- showBinaryDownloadWarning: correct Windows path separators
This commit is contained in:
li committed 2026-03-17 19:23:03 +08:00
1 parent 8ea7e59685
commit e4eace2e42
4 files changed
+218 -39

No files matched your search

+16 -1
View File
@@ -913,10 +913,25 @@ export async function init(options: InitOptions = {}): Promise<void> {
// 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
+106 -26
View File
@@ -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'))
+26 -4
View File
@@ -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\<hash>\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
}
+70 -8
View File
@@ -145,7 +145,11 @@ async function copyMdTemplates(
options: { inject?: boolean } = {},
): Promise<string[]> {
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<void> {
async function installPromptFiles(ctx: InstallContext): Promise<void> {
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<string[]> {
}
}
}
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<string[]> {
async function installSkillFiles(ctx: InstallContext): Promise<void> {
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<void> {
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<void> {
}
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
}