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:
1 parent
8ea7e59685
commit
e4eace2e42
4 files changed
+218
-39
No files matched your search
+16
-1
@@ -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
@@ -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'))
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user