From 2ada39409869a23f23b5e1eed1015f575cb54a06 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sun, 26 Apr 2026 21:59:20 -0400 Subject: [PATCH 1/5] feat: Improved apply validation errors --- package-lock.json | 12 ++++++------ package.json | 2 +- src/common/base-command.ts | 8 +++++++- src/common/errors.ts | 15 +++++++++++++++ src/orchestrators/apply.ts | 2 +- src/orchestrators/import.ts | 2 +- src/plugins/plugin.ts | 13 ++++++++++++- src/ui/components/default-component.tsx | 21 ++++++++++++++++++++- src/ui/reporters/default-reporter.tsx | 10 +++++++--- src/ui/reporters/json-reporter.ts | 11 ++++++++++- src/ui/reporters/plain-reporter.ts | 10 ++++++++-- src/ui/reporters/reporter.ts | 7 +++++-- src/ui/reporters/stub-reporter.ts | 1 + src/ui/store/index.ts | 1 + 14 files changed, 95 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0d7ea324..a4a17ef2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "codify", - "version": "1.1.0-beta", + "version": "1.1.0-beta6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codify", - "version": "1.1.0-beta", + "version": "1.1.0-beta6", "license": "Apache-2.0", "dependencies": { "@codifycli/ink-form": "0.0.12", - "@codifycli/schemas": "1.1.0-beta5", + "@codifycli/schemas": "1.1.0-beta7", "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5", "@mischnic/json-sourcemap": "^0.1.1", "@oclif/core": "^4.0.8", @@ -1157,9 +1157,9 @@ } }, "node_modules/@codifycli/schemas": { - "version": "1.1.0-beta5", - "resolved": "https://registry.npmjs.org/@codifycli/schemas/-/schemas-1.1.0-beta5.tgz", - "integrity": "sha512-xxfh6b48KW7Kav2uyrJb5E3dtuBDg4EUIaVKPJvJnocaaYFKGPwEUWvYwiNxqWDNBhrsbsGmNRatHGwXfhTT2A==", + "version": "1.1.0-beta7", + "resolved": "https://registry.npmjs.org/@codifycli/schemas/-/schemas-1.1.0-beta7.tgz", + "integrity": "sha512-l9k0xovt4Xh1lZ6WEupXn5I2PtJHX/5G6C+CBYozTpFxB5A4iGnOkWbiQyxuJHw1cY2kDUzZeve+nGzQj3XiBA==", "license": "ISC", "dependencies": { "ajv": "^8.18.0" diff --git a/package.json b/package.json index 40e1eb20..feec97ab 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ }, "dependencies": { "@codifycli/ink-form": "0.0.12", - "@codifycli/schemas": "1.1.0-beta5", + "@codifycli/schemas": "1.1.0-beta7", "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5", "@mischnic/json-sourcemap": "^0.1.1", "@oclif/core": "^4.0.8", diff --git a/src/common/base-command.ts b/src/common/base-command.ts index 903c1e53..b9d5bd74 100644 --- a/src/common/base-command.ts +++ b/src/common/base-command.ts @@ -11,7 +11,7 @@ import { DefaultReporter } from '../ui/reporters/default-reporter.js'; import { Reporter, ReporterFactory, ReporterType } from '../ui/reporters/reporter.js'; import { spawnSafe } from '../utils/spawn.js'; import { SudoUtils } from '../utils/sudo.js'; -import { prettyPrintError } from './errors.js'; +import { PluginApplyValidationError, prettyPrintError } from './errors.js'; export abstract class BaseCommand extends Command { static baseFlags = { @@ -145,6 +145,12 @@ export abstract class BaseCommand extends Command { } protected async catch(err: Error): Promise { + if (err instanceof PluginApplyValidationError && this.reporter) { + await this.reporter.hide(); + await this.reporter.displayApplyValidationError(err.resourcePlan); + process.exit(1); + } + prettyPrintError(err); process.exit(1); } diff --git a/src/common/errors.ts b/src/common/errors.ts index 14c9a3f4..7a00971a 100644 --- a/src/common/errors.ts +++ b/src/common/errors.ts @@ -1,6 +1,7 @@ import { ErrorObject } from 'ajv'; import chalk from 'chalk'; +import { ResourcePlan } from '../entities/plan.js'; import { ResourceConfig } from '../entities/resource-config.js'; import { SourceMapCache } from '../parser/source-maps.js'; import { formatAjvErrors } from '../utils/ajv.js'; @@ -231,6 +232,20 @@ export class SpawnError extends CodifyError { } } +export class PluginApplyValidationError extends CodifyError { + name = 'PluginApplyValidationError'; + resourcePlan: ResourcePlan; + + constructor(resourcePlan: ResourcePlan) { + super(`Apply validation failed for resource: "${resourcePlan.id}".`); + this.resourcePlan = resourcePlan; + } + + formattedMessage(): string { + return this.message; + } +} + export function prettyPrintError(error: unknown): void { if (error instanceof CodifyError) { return console.error(chalk.red(error.formattedMessage())); diff --git a/src/orchestrators/apply.ts b/src/orchestrators/apply.ts index d79dc22f..69318562 100644 --- a/src/orchestrators/apply.ts +++ b/src/orchestrators/apply.ts @@ -50,7 +50,7 @@ export const ApplyOrchestrator = { // Need to sleep to wait for the message to display before we exit await sleep(100); - reporter.displayMessage(` + await reporter.displayMessage(` šŸŽ‰ Finished applying šŸŽ‰ Open a new terminal or source '.zshrc' for the new changes to be reflected`); }, diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index a1e6c15d..35ed1832 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -195,7 +195,7 @@ export class ImportOrchestrator { // No writes reporter.displayImportResult(importResult, true); - reporter.displayMessage('\nšŸŽ‰ Imported completed šŸŽ‰') + await reporter.displayMessage('\nšŸŽ‰ Imported completed šŸŽ‰') await sleep(100); } diff --git a/src/plugins/plugin.ts b/src/plugins/plugin.ts index f6dfd568..beb048dc 100644 --- a/src/plugins/plugin.ts +++ b/src/plugins/plugin.ts @@ -1,4 +1,5 @@ import { + ErrorCode, GetResourceInfoResponseData, GetResourceInfoResponseDataSchema, ImportRequestData, @@ -18,6 +19,7 @@ import { import { ResourcePlan } from '../entities/plan.js'; import { ResourceConfig } from '../entities/resource-config.js'; +import { PluginApplyValidationError } from '../common/errors.js'; import { ajv } from '../utils/ajv.js'; import { PluginProcess } from './plugin-process.js'; @@ -146,7 +148,16 @@ export class Plugin implements IPlugin { const result = await this.process!.sendMessageForResult('apply', { plan }); if (!result.isSuccessful()) { - throw new Error(`Apply error for plugin: "${this.name}", resource: "${plan.resourceType}" \n\n` + result.data); + const data = result.data as any; + + if (data?.errorCode === ErrorCode.APPLY_VALIDATION && data.plan) { + throw new PluginApplyValidationError(new ResourcePlan(data.plan)); + } + + const message = typeof data === 'string' + ? data + : (data?.message ?? JSON.stringify(data, null, 2)); + throw new Error(`Apply error for plugin: "${this.name}", resource: "${plan.resourceType}" \n\n` + message); } } diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index 1f0e9d02..2169fd03 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -5,7 +5,8 @@ import { useAtom } from 'jotai'; import { EventEmitter } from 'node:events'; import React, { useLayoutEffect } from 'react'; -import { Plan } from '../../entities/plan.js'; +import { Plan, ResourcePlan } from '../../entities/plan.js'; +import { prettyFormatResourcePlan } from '../plan-pretty-printer.js'; import { FileModificationResult } from '../../generators/index.js'; import { ImportResult } from '../../orchestrators/import.js'; import { RenderEvent } from '../reporters/reporter.js'; @@ -49,6 +50,24 @@ export function DefaultComponent(props: { (plan, idx) => } } + { + renderStatus === RenderStatus.APPLY_VALIDATION_ERROR && ( + { + (resourcePlan, idx) => ( + + + {`Apply validation failed: resource "${resourcePlan.id}" did not reach its desired state. \nExiting...`} + + + Changes still needed: + {prettyFormatResourcePlan(resourcePlan)} + + Try re-running to see if the changes have applied. + + ) + } + ) + } { renderStatus === RenderStatus.PROMPT_CONFIRMATION && ( diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index 4c417928..0eee8757 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -5,7 +5,7 @@ import { EventEmitter } from 'node:events'; import React from 'react'; import stripAnsi from 'strip-ansi' -import { Plan } from '../../entities/plan.js'; +import { Plan, ResourcePlan } from '../../entities/plan.js'; import { ResourceConfig } from '../../entities/resource-config.js'; import { ResourceInfo } from '../../entities/resource-info.js'; import { ctx, Event, ProcessName, SubProcessName } from '../../events/context.js'; @@ -226,8 +226,8 @@ export class DefaultReporter implements Reporter { void this.updateRenderState(RenderStatus.DISPLAY_PLAN, plan) } - displayMessage(message: string) { - void this.updateRenderState(RenderStatus.DISPLAY_MESSAGE, message); + async displayMessage(message: string) { + await this.updateRenderState(RenderStatus.DISPLAY_MESSAGE, message); } async promptInitResultSelection(availableTypes: string[]): Promise { @@ -267,6 +267,10 @@ export class DefaultReporter implements Reporter { void this.updateRenderState(RenderStatus.DISPLAY_FILE_MODIFICATION, diff); } + async displayApplyValidationError(resourcePlan: ResourcePlan) { + await this.updateRenderState(RenderStatus.APPLY_VALIDATION_ERROR, resourcePlan); + } + private log(log: string): void { if (this.silent) return; diff --git a/src/ui/reporters/json-reporter.ts b/src/ui/reporters/json-reporter.ts index f66518f8..b930776f 100644 --- a/src/ui/reporters/json-reporter.ts +++ b/src/ui/reporters/json-reporter.ts @@ -1,6 +1,6 @@ import { CommandRequestData } from '@codifycli/schemas'; -import { Plan } from '../../entities/plan.js'; +import { Plan, ResourcePlan } from '../../entities/plan.js'; import { ResourceConfig } from '../../entities/resource-config.js'; import { ImportResult } from '../../orchestrators/import.js'; import { Reporter } from './reporter.js'; @@ -71,4 +71,13 @@ export class JsonReporter implements Reporter { async disableRawMode(): Promise { throw new Error('Json reporter error: disableRawMode is not supported. Raw stdin mode requires interactive terminal access.'); } + + displayApplyValidationError(resourcePlan: ResourcePlan): void { + console.log(JSON.stringify({ + error: 'apply_validation', + resourceId: resourcePlan.id, + operation: resourcePlan.operation, + parameters: resourcePlan.parameters, + }, null, 2)); + } } diff --git a/src/ui/reporters/plain-reporter.ts b/src/ui/reporters/plain-reporter.ts index 4169918c..7857342b 100644 --- a/src/ui/reporters/plain-reporter.ts +++ b/src/ui/reporters/plain-reporter.ts @@ -2,13 +2,13 @@ import chalk from 'chalk'; import { CommandRequestData } from '@codifycli/schemas'; import readline from 'node:readline'; -import { Plan } from '../../entities/plan.js'; +import { Plan, ResourcePlan } from '../../entities/plan.js'; import { ResourceConfig } from '../../entities/resource-config.js'; import { ResourceInfo } from '../../entities/resource-info.js'; import { Event, ctx } from '../../events/context.js'; import { FileModificationResult } from '../../generators/index.js'; import { ImportResult } from '../../orchestrators/import.js'; -import { prettyFormatPlan } from '../plan-pretty-printer.js'; +import { prettyFormatPlan, prettyFormatResourcePlan } from '../plan-pretty-printer.js'; import { PromptType, Reporter } from './reporter.js'; export class PlainReporter implements Reporter { @@ -163,6 +163,12 @@ Use this init flow to get started quickly with Codify. ); } + displayApplyValidationError(resourcePlan: ResourcePlan): void { + ctx.log(chalk.red.bold(`Apply validation failed: resource "${resourcePlan.id}" did not reach its desired state.`)); + ctx.log(chalk.red('Changes still needed:')); + ctx.log(prettyFormatResourcePlan(resourcePlan)); + } + displayApplyComplete(message: string[]): void { ctx.log('šŸŽ‰ Finished applying šŸŽ‰'); ctx.log('Open a new terminal or source \'.zshrc\' for the new changes to be reflected') diff --git a/src/ui/reporters/reporter.ts b/src/ui/reporters/reporter.ts index 91c548db..5f4ce532 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -1,6 +1,6 @@ import { CommandRequestData } from '@codifycli/schemas'; -import { Plan } from '../../entities/plan.js'; +import { Plan, ResourcePlan } from '../../entities/plan.js'; import { ResourceConfig } from '../../entities/resource-config.js'; import { ResourceInfo } from '../../entities/resource-info.js'; import { FileModificationResult } from '../../generators/index.js'; @@ -38,6 +38,7 @@ export enum RenderState { // TODO: instead of having GENERATE_PLAN and APPLYING APPLYING, APPLY_COMPLETE, DISPLAY_IMPORT_RESULT, + APPLY_VALIDATION_ERROR, } export enum PromptType { @@ -75,13 +76,15 @@ export interface Reporter { displayFileModifications(diff: Array<{ file: string, modification: FileModificationResult }>): void - displayMessage(message: string): void + displayMessage(message: string): Promise displayImportWarning(requiresParameters: string[], noParametersRequired: string[]): Promise setRawMode(): Promise disableRawMode(): Promise + + displayApplyValidationError(resourcePlan: ResourcePlan): Promise; } export enum ReporterType { diff --git a/src/ui/reporters/stub-reporter.ts b/src/ui/reporters/stub-reporter.ts index 982dd1d2..0af8daca 100644 --- a/src/ui/reporters/stub-reporter.ts +++ b/src/ui/reporters/stub-reporter.ts @@ -25,4 +25,5 @@ export class StubReporter implements Reporter { async displayImportWarning(requiresParameters: string[], noParametersRequired: string[]): Promise {} async setRawMode(): Promise {} async disableRawMode(): Promise {} + displayApplyValidationError(): void {} } diff --git a/src/ui/store/index.ts b/src/ui/store/index.ts index 669640b0..452676c5 100644 --- a/src/ui/store/index.ts +++ b/src/ui/store/index.ts @@ -23,6 +23,7 @@ export enum RenderStatus { PROMPT_PRESS_KEY_TO_CONTINUE, SUDO_PROMPT, DISPLAY_MESSAGE, + APPLY_VALIDATION_ERROR, } export const store = new class { From d6eb8d44a5c9a3a03973dcc2c5ce47ba15a134cf Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sun, 26 Apr 2026 22:57:24 -0400 Subject: [PATCH 2/5] feat: Refactored to share the same plugin error type. Use the reporter to determine how to present it. --- package-lock.json | 44 ++++++++++++++----------- package.json | 4 +-- src/common/base-command.ts | 6 ++-- src/common/errors.ts | 20 ++++++----- src/plugins/plugin.ts | 44 +++++++++++-------------- src/ui/components/default-component.tsx | 11 +++++++ src/ui/plugin-error-formatter.ts | 12 +++++++ src/ui/reporters/default-reporter.tsx | 10 ++++-- src/ui/reporters/json-reporter.ts | 14 ++++---- src/ui/reporters/plain-reporter.ts | 16 +++++---- src/ui/reporters/reporter.ts | 7 ++-- src/ui/reporters/stub-reporter.ts | 2 +- src/ui/store/index.ts | 1 + 13 files changed, 116 insertions(+), 75 deletions(-) create mode 100644 src/ui/plugin-error-formatter.ts diff --git a/package-lock.json b/package-lock.json index a4a17ef2..ab8a6b82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@codifycli/ink-form": "0.0.12", - "@codifycli/schemas": "1.1.0-beta7", + "@codifycli/schemas": "1.1.0-beta8", "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5", "@mischnic/json-sourcemap": "^0.1.1", "@oclif/core": "^4.0.8", @@ -50,7 +50,7 @@ "codify": "bin/run.js" }, "devDependencies": { - "@codifycli/plugin-core": "^1.1.0-beta13", + "@codifycli/plugin-core": "^1.1.0-beta19", "@oclif/prettier-config": "^0.2.1", "@types/chalk": "^2.2.0", "@types/cors": "^2.8.19", @@ -1089,13 +1089,13 @@ } }, "node_modules/@codifycli/plugin-core": { - "version": "1.1.0-beta13", - "resolved": "https://registry.npmjs.org/@codifycli/plugin-core/-/plugin-core-1.1.0-beta13.tgz", - "integrity": "sha512-K5lW0eH8fCSkpWkZ9jxDMSFp8shHx4a34tsT7T37q1Jkll7h2zvn8g1/DJR96G3ZAvXQmDuwUF8cA/y/v5YsLg==", + "version": "1.1.0-beta19", + "resolved": "https://registry.npmjs.org/@codifycli/plugin-core/-/plugin-core-1.1.0-beta19.tgz", + "integrity": "sha512-ci8QU2xn3Zl50EdCA1ymi2KiwDQO43t27fG7cRqBnbCpQZgVtlSyV18xLd3td6rzigVVDNtCSY3a6ZayM7zhpg==", "dev": true, "license": "ISC", "dependencies": { - "@codifycli/schemas": "1.1.0-beta4", + "@codifycli/schemas": "^1.1.0-beta8", "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", "ajv": "^8.18.0", "ajv-formats": "^2.1.1", @@ -1103,7 +1103,7 @@ "lodash.isequal": "^4.5.0", "nanoid": "^5.0.9", "strip-ansi": "^7.1.0", - "uuid": "^10.0.0", + "uuid": "^14.0.0", "zod": "4.1.13" }, "bin": { @@ -1113,16 +1113,6 @@ "node": ">=22.0.0" } }, - "node_modules/@codifycli/plugin-core/node_modules/@codifycli/schemas": { - "version": "1.1.0-beta4", - "resolved": "https://registry.npmjs.org/@codifycli/schemas/-/schemas-1.1.0-beta4.tgz", - "integrity": "sha512-bBEr9c+MqMcs+Ke5//JfQ3+Vmixh+8TvMqeJKh0OKPEntzwGmLVTqv6g8CDk9/M8H+To2KASLw2pjEHEBiJGSw==", - "dev": true, - "license": "ISC", - "dependencies": { - "ajv": "^8.18.0" - } - }, "node_modules/@codifycli/plugin-core/node_modules/@homebridge/node-pty-prebuilt-multiarch": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/@homebridge/node-pty-prebuilt-multiarch/-/node-pty-prebuilt-multiarch-0.13.1.tgz", @@ -1156,10 +1146,24 @@ } } }, + "node_modules/@codifycli/plugin-core/node_modules/uuid": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/@codifycli/schemas": { - "version": "1.1.0-beta7", - "resolved": "https://registry.npmjs.org/@codifycli/schemas/-/schemas-1.1.0-beta7.tgz", - "integrity": "sha512-l9k0xovt4Xh1lZ6WEupXn5I2PtJHX/5G6C+CBYozTpFxB5A4iGnOkWbiQyxuJHw1cY2kDUzZeve+nGzQj3XiBA==", + "version": "1.1.0-beta8", + "resolved": "https://registry.npmjs.org/@codifycli/schemas/-/schemas-1.1.0-beta8.tgz", + "integrity": "sha512-2PLCPmU2mtDilqx71uQIjpZLnvqSkdSR+BgImN6eRbRWKJcfltBEONPAlRhRU74kAyURpqCfDSLKTYa1MqLxZw==", "license": "ISC", "dependencies": { "ajv": "^8.18.0" diff --git a/package.json b/package.json index feec97ab..81bd00f3 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ }, "dependencies": { "@codifycli/ink-form": "0.0.12", - "@codifycli/schemas": "1.1.0-beta7", + "@codifycli/schemas": "1.1.0-beta8", "@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5", "@mischnic/json-sourcemap": "^0.1.1", "@oclif/core": "^4.0.8", @@ -43,7 +43,7 @@ }, "description": "Codify is a configuration-as-code tool that declaratively installs and manages developer tools and applications. Check out https://dashboard.codifycli.com for an editor.", "devDependencies": { - "@codifycli/plugin-core": "^1.1.0-beta13", + "@codifycli/plugin-core": "^1.1.0-beta19", "@oclif/prettier-config": "^0.2.1", "@types/chalk": "^2.2.0", "@types/cors": "^2.8.19", diff --git a/src/common/base-command.ts b/src/common/base-command.ts index b9d5bd74..653bf4fb 100644 --- a/src/common/base-command.ts +++ b/src/common/base-command.ts @@ -11,7 +11,7 @@ import { DefaultReporter } from '../ui/reporters/default-reporter.js'; import { Reporter, ReporterFactory, ReporterType } from '../ui/reporters/reporter.js'; import { spawnSafe } from '../utils/spawn.js'; import { SudoUtils } from '../utils/sudo.js'; -import { PluginApplyValidationError, prettyPrintError } from './errors.js'; +import { PluginError, prettyPrintError } from './errors.js'; export abstract class BaseCommand extends Command { static baseFlags = { @@ -145,9 +145,9 @@ export abstract class BaseCommand extends Command { } protected async catch(err: Error): Promise { - if (err instanceof PluginApplyValidationError && this.reporter) { + if (err instanceof PluginError && this.reporter) { await this.reporter.hide(); - await this.reporter.displayApplyValidationError(err.resourcePlan); + this.reporter.displayPluginError(err); process.exit(1); } diff --git a/src/common/errors.ts b/src/common/errors.ts index 7a00971a..c3acaca3 100644 --- a/src/common/errors.ts +++ b/src/common/errors.ts @@ -1,7 +1,7 @@ import { ErrorObject } from 'ajv'; import chalk from 'chalk'; +import { PluginErrorData } from '@codifycli/schemas'; -import { ResourcePlan } from '../entities/plan.js'; import { ResourceConfig } from '../entities/resource-config.js'; import { SourceMapCache } from '../parser/source-maps.js'; import { formatAjvErrors } from '../utils/ajv.js'; @@ -232,13 +232,17 @@ export class SpawnError extends CodifyError { } } -export class PluginApplyValidationError extends CodifyError { - name = 'PluginApplyValidationError'; - resourcePlan: ResourcePlan; - - constructor(resourcePlan: ResourcePlan) { - super(`Apply validation failed for resource: "${resourcePlan.id}".`); - this.resourcePlan = resourcePlan; +export class PluginError extends CodifyError { + name = 'PluginError'; + pluginName: string; + resourceType: string; + errorData: PluginErrorData; + + constructor(pluginName: string, resourceType: string, errorData: PluginErrorData) { + super(errorData.message); + this.pluginName = pluginName; + this.resourceType = resourceType; + this.errorData = errorData; } formattedMessage(): string { diff --git a/src/plugins/plugin.ts b/src/plugins/plugin.ts index beb048dc..7792be4c 100644 --- a/src/plugins/plugin.ts +++ b/src/plugins/plugin.ts @@ -1,5 +1,5 @@ import { - ErrorCode, + ErrorResponseDataSchema, GetResourceInfoResponseData, GetResourceInfoResponseDataSchema, ImportRequestData, @@ -12,6 +12,7 @@ import { PlanRequestData, PlanResponseData, PlanResponseDataSchema, + PluginErrorData, ResourceJson, ValidateResponseData, ValidateResponseDataSchema, @@ -19,10 +20,11 @@ import { import { ResourcePlan } from '../entities/plan.js'; import { ResourceConfig } from '../entities/resource-config.js'; -import { PluginApplyValidationError } from '../common/errors.js'; +import { PluginError } from '../common/errors.js'; import { ajv } from '../utils/ajv.js'; import { PluginProcess } from './plugin-process.js'; +const errorResponseValidator = ajv.compile(ErrorResponseDataSchema); const initializeResponseValidator = ajv.compile(InitializeResponseDataSchema); const validateResponseValidator = ajv.compile(ValidateResponseDataSchema); const getResourceInfoResponseValidator = ajv.compile(GetResourceInfoResponseDataSchema); @@ -69,9 +71,9 @@ export class Plugin implements IPlugin { async validate(configs: ResourceConfig[]): Promise { const jsonConfigs = configs.map((c) => c.toJson()); const result = await this.process!.sendMessageForResult('validate', { configs: jsonConfigs }); - + if (!result.isSuccessful()) { - throw new Error(`Validate error for plugin: "${this.name}" \n\n${JSON.stringify(result.data, null, 2)}`); + throw new PluginError(this.name, 'validate', this.toErrorData(result.data)); } if (!this.validateValidateResponse(result.data)) { @@ -85,7 +87,7 @@ export class Plugin implements IPlugin { const result = await this.process!.sendMessageForResult('getResourceInfo', { type }); if (!result.isSuccessful()) { - throw new Error(`Unable to get info for resource: "${type}" from plugin: "${this.name}" \n\n` + result.data); + throw new PluginError(this.name, type, this.toErrorData(result.data)); } if (!this.validateGetResourceInfoResponse(result.data)) { @@ -102,7 +104,7 @@ export class Plugin implements IPlugin { }); if (!result.isSuccessful()) { - throw new Error(`Unable to match resource: "${resource.type}" from plugin: "${this.name}" \n\n` + result.data); + throw new PluginError(this.name, resource.type, this.toErrorData(result.data)); } if (!this.validateMatchResponse(result.data)) { @@ -112,12 +114,11 @@ export class Plugin implements IPlugin { return result.data; } - async import(config: ResourceJson, autoSearchAll = false): Promise { const result = await this.process!.sendMessageForResult('import', { ...config, autoSearchAll }); if (!result.isSuccessful()) { - throw new Error(`Unable import resource ${config.core.type} with plugin: "${this.name}" \n\n` + result.data); + throw new PluginError(this.name, config.core.type, this.toErrorData(result.data)); } if (!this.validateImportResponse(result.data)) { @@ -128,13 +129,10 @@ export class Plugin implements IPlugin { } async plan(request: PlanRequestData): Promise { - const result = await this.process!.sendMessageForResult( - 'plan', - request - ); + const result = await this.process!.sendMessageForResult('plan', request); if (!result.isSuccessful()) { - throw new Error(`Plan error for plugin: "${this.name}", resource: "${request.core.type}" \n\n` + result.data); + throw new PluginError(this.name, request.core.type, this.toErrorData(result.data)); } if (!this.validatePlanResponse(result.data)) { @@ -148,16 +146,7 @@ export class Plugin implements IPlugin { const result = await this.process!.sendMessageForResult('apply', { plan }); if (!result.isSuccessful()) { - const data = result.data as any; - - if (data?.errorCode === ErrorCode.APPLY_VALIDATION && data.plan) { - throw new PluginApplyValidationError(new ResourcePlan(data.plan)); - } - - const message = typeof data === 'string' - ? data - : (data?.message ?? JSON.stringify(data, null, 2)); - throw new Error(`Apply error for plugin: "${this.name}", resource: "${plan.resourceType}" \n\n` + message); + throw new PluginError(this.name, plan.resourceType, this.toErrorData(result.data)); } } @@ -165,8 +154,15 @@ export class Plugin implements IPlugin { const result = await this.process!.sendMessageForResult('setVerbosityLevel', { verbosityLevel }); if (!result.isSuccessful()) { - throw new Error(`Set verbosity error for plugin: "${this.name}" \n\n` + result.data); + throw new PluginError(this.name, 'setVerbosityLevel', this.toErrorData(result.data)); + } + } + + private toErrorData(data: unknown): PluginErrorData { + if (errorResponseValidator(data)) { + return data as unknown as PluginErrorData; } + return { errorType: 'unknown', message: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }; } kill() { diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index 2169fd03..9e700577 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -68,6 +68,17 @@ export function DefaultComponent(props: { } ) } + { + renderStatus === RenderStatus.PLUGIN_ERROR && ( + { + (message, idx) => ( + + {message} + + ) + } + ) + } { renderStatus === RenderStatus.PROMPT_CONFIRMATION && ( diff --git a/src/ui/plugin-error-formatter.ts b/src/ui/plugin-error-formatter.ts new file mode 100644 index 00000000..27de1cd7 --- /dev/null +++ b/src/ui/plugin-error-formatter.ts @@ -0,0 +1,12 @@ +import { PluginError } from '../common/errors.js'; +import { ResourcePlan } from '../entities/plan.js'; +import { prettyFormatResourcePlan } from './plan-pretty-printer.js'; + +export function formatApplyValidationError(error: PluginError): string { + const plan = new ResourcePlan((error.errorData.data as any).plan); + return [ + `Apply validation failed: resource "${plan.id}" did not reach its desired state.`, + 'Changes still needed:', + prettyFormatResourcePlan(plan), + ].join('\n'); +} diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index 0eee8757..a1ff79c0 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -6,6 +6,7 @@ import React from 'react'; import stripAnsi from 'strip-ansi' import { Plan, ResourcePlan } from '../../entities/plan.js'; +import { PluginError } from '../../common/errors.js'; import { ResourceConfig } from '../../entities/resource-config.js'; import { ResourceInfo } from '../../entities/resource-info.js'; import { ctx, Event, ProcessName, SubProcessName } from '../../events/context.js'; @@ -267,8 +268,13 @@ export class DefaultReporter implements Reporter { void this.updateRenderState(RenderStatus.DISPLAY_FILE_MODIFICATION, diff); } - async displayApplyValidationError(resourcePlan: ResourcePlan) { - await this.updateRenderState(RenderStatus.APPLY_VALIDATION_ERROR, resourcePlan); + displayPluginError(error: PluginError): void { + if (error.errorData.errorType === 'apply_validation') { + const resourcePlan = new ResourcePlan((error.errorData.data as any).plan); + void this.updateRenderState(RenderStatus.APPLY_VALIDATION_ERROR, resourcePlan); + return; + } + void this.updateRenderState(RenderStatus.PLUGIN_ERROR, error.message); } private log(log: string): void { diff --git a/src/ui/reporters/json-reporter.ts b/src/ui/reporters/json-reporter.ts index b930776f..8f5ee089 100644 --- a/src/ui/reporters/json-reporter.ts +++ b/src/ui/reporters/json-reporter.ts @@ -1,6 +1,7 @@ import { CommandRequestData } from '@codifycli/schemas'; -import { Plan, ResourcePlan } from '../../entities/plan.js'; +import { Plan } from '../../entities/plan.js'; +import { PluginError } from '../../common/errors.js'; import { ResourceConfig } from '../../entities/resource-config.js'; import { ImportResult } from '../../orchestrators/import.js'; import { Reporter } from './reporter.js'; @@ -72,12 +73,13 @@ export class JsonReporter implements Reporter { throw new Error('Json reporter error: disableRawMode is not supported. Raw stdin mode requires interactive terminal access.'); } - displayApplyValidationError(resourcePlan: ResourcePlan): void { + displayPluginError(error: PluginError): void { console.log(JSON.stringify({ - error: 'apply_validation', - resourceId: resourcePlan.id, - operation: resourcePlan.operation, - parameters: resourcePlan.parameters, + errorType: error.errorData.errorType, + message: error.message, + pluginName: error.pluginName, + resourceType: error.resourceType, + data: error.errorData.data, }, null, 2)); } } diff --git a/src/ui/reporters/plain-reporter.ts b/src/ui/reporters/plain-reporter.ts index 7857342b..224a279d 100644 --- a/src/ui/reporters/plain-reporter.ts +++ b/src/ui/reporters/plain-reporter.ts @@ -2,13 +2,15 @@ import chalk from 'chalk'; import { CommandRequestData } from '@codifycli/schemas'; import readline from 'node:readline'; -import { Plan, ResourcePlan } from '../../entities/plan.js'; +import { Plan } from '../../entities/plan.js'; +import { PluginError } from '../../common/errors.js'; +import { formatApplyValidationError } from '../plugin-error-formatter.js'; import { ResourceConfig } from '../../entities/resource-config.js'; import { ResourceInfo } from '../../entities/resource-info.js'; import { Event, ctx } from '../../events/context.js'; import { FileModificationResult } from '../../generators/index.js'; import { ImportResult } from '../../orchestrators/import.js'; -import { prettyFormatPlan, prettyFormatResourcePlan } from '../plan-pretty-printer.js'; +import { prettyFormatPlan } from '../plan-pretty-printer.js'; import { PromptType, Reporter } from './reporter.js'; export class PlainReporter implements Reporter { @@ -163,10 +165,12 @@ Use this init flow to get started quickly with Codify. ); } - displayApplyValidationError(resourcePlan: ResourcePlan): void { - ctx.log(chalk.red.bold(`Apply validation failed: resource "${resourcePlan.id}" did not reach its desired state.`)); - ctx.log(chalk.red('Changes still needed:')); - ctx.log(prettyFormatResourcePlan(resourcePlan)); + displayPluginError(error: PluginError): void { + if (error.errorData.errorType === 'apply_validation') { + ctx.log(chalk.red(formatApplyValidationError(error))); + return; + } + ctx.log(chalk.red(error.message)); } displayApplyComplete(message: string[]): void { diff --git a/src/ui/reporters/reporter.ts b/src/ui/reporters/reporter.ts index 5f4ce532..0b5236d7 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -1,6 +1,7 @@ import { CommandRequestData } from '@codifycli/schemas'; -import { Plan, ResourcePlan } from '../../entities/plan.js'; +import { Plan } from '../../entities/plan.js'; +import { PluginError } from '../../common/errors.js'; import { ResourceConfig } from '../../entities/resource-config.js'; import { ResourceInfo } from '../../entities/resource-info.js'; import { FileModificationResult } from '../../generators/index.js'; @@ -76,7 +77,7 @@ export interface Reporter { displayFileModifications(diff: Array<{ file: string, modification: FileModificationResult }>): void - displayMessage(message: string): Promise + displayMessage(message: string): void displayImportWarning(requiresParameters: string[], noParametersRequired: string[]): Promise @@ -84,7 +85,7 @@ export interface Reporter { disableRawMode(): Promise - displayApplyValidationError(resourcePlan: ResourcePlan): Promise; + displayPluginError(error: PluginError): void; } export enum ReporterType { diff --git a/src/ui/reporters/stub-reporter.ts b/src/ui/reporters/stub-reporter.ts index 0af8daca..ac42bab7 100644 --- a/src/ui/reporters/stub-reporter.ts +++ b/src/ui/reporters/stub-reporter.ts @@ -25,5 +25,5 @@ export class StubReporter implements Reporter { async displayImportWarning(requiresParameters: string[], noParametersRequired: string[]): Promise {} async setRawMode(): Promise {} async disableRawMode(): Promise {} - displayApplyValidationError(): void {} + displayPluginError(): void {} } diff --git a/src/ui/store/index.ts b/src/ui/store/index.ts index 452676c5..0de069ec 100644 --- a/src/ui/store/index.ts +++ b/src/ui/store/index.ts @@ -24,6 +24,7 @@ export enum RenderStatus { SUDO_PROMPT, DISPLAY_MESSAGE, APPLY_VALIDATION_ERROR, + PLUGIN_ERROR, } export const store = new class { From f6563de9cc48efc3b59b63eb76f948b3fadea580 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sun, 26 Apr 2026 23:26:27 -0400 Subject: [PATCH 3/5] fix: Fixed display functions to be async --- src/common/base-command.ts | 2 +- src/orchestrators/destroy.ts | 2 +- src/orchestrators/import.ts | 18 ++++++++--------- src/orchestrators/plan.ts | 2 +- src/orchestrators/refresh.ts | 2 +- src/orchestrators/test.ts | 2 +- src/ui/reporters/default-reporter.tsx | 26 ++++++++++++++++--------- src/ui/reporters/json-reporter.ts | 6 +++--- src/ui/reporters/plain-reporter.ts | 10 +++++----- src/ui/reporters/reporter.ts | 10 +++++----- src/ui/reporters/stub-reporter.ts | 4 ++-- test/orchestrator/import/import.test.ts | 6 +++--- 12 files changed, 49 insertions(+), 41 deletions(-) diff --git a/src/common/base-command.ts b/src/common/base-command.ts index 653bf4fb..e4267f02 100644 --- a/src/common/base-command.ts +++ b/src/common/base-command.ts @@ -147,7 +147,7 @@ export abstract class BaseCommand extends Command { protected async catch(err: Error): Promise { if (err instanceof PluginError && this.reporter) { await this.reporter.hide(); - this.reporter.displayPluginError(err); + await this.reporter.displayPluginError(err); process.exit(1); } diff --git a/src/orchestrators/destroy.ts b/src/orchestrators/destroy.ts index e40ca8b5..e0dc707b 100644 --- a/src/orchestrators/destroy.ts +++ b/src/orchestrators/destroy.ts @@ -42,7 +42,7 @@ export class DestroyOrchestrator { plan.sortByEvalOrder(project.evaluationOrder); destroyProject.removeNoopFromEvaluationOrder(plan); - reporter.displayPlan(plan); + await reporter.displayPlan(plan); // Short circuit and exit if every change is NOOP if (plan.isEmpty()) { diff --git a/src/orchestrators/import.ts b/src/orchestrators/import.ts index 35ed1832..b7ee7479 100644 --- a/src/orchestrators/import.ts +++ b/src/orchestrators/import.ts @@ -116,7 +116,7 @@ export class ImportOrchestrator { ctx.processFinished(ProcessName.IMPORT) - reporter.displayImportResult(importResult, false); + await reporter.displayImportResult(importResult, false); resourceInfoList.push(...(await pluginManager.getMultipleResourceInfo( project.resourceConfigs.map((r) => r.type) @@ -194,7 +194,7 @@ export class ImportOrchestrator { } // No writes - reporter.displayImportResult(importResult, true); + await reporter.displayImportResult(importResult, true); await reporter.displayMessage('\nšŸŽ‰ Imported completed šŸŽ‰') await sleep(100); @@ -244,17 +244,17 @@ export class ImportOrchestrator { // No changes to be made if (diffs.every((d) => d.modification.diff === '')) { - reporter.displayMessage('\nNo changes are needed! Exiting...') + await reporter.displayMessage('\nNo changes are needed! Exiting...') // Wait for the message to display before we exit await sleep(100); return; } - reporter.displayFileModifications(diffs); + await reporter.displayFileModifications(diffs); const shouldSave = await reporter.promptConfirmation('Save the changes?'); if (!shouldSave) { - reporter.displayMessage('\nSkipping save! Exiting...'); + await reporter.displayMessage('\nSkipping save! Exiting...'); // Wait for the message to display before we exit await sleep(100); @@ -265,7 +265,7 @@ export class ImportOrchestrator { await FileUpdater.write(diff.file, diff.modification.newFile); } - reporter.displayMessage('\nšŸŽ‰ Imported completed and saved to file šŸŽ‰'); + await reporter.displayMessage('\nšŸŽ‰ Imported completed and saved to file šŸŽ‰'); // Wait for the message to display before we exit await sleep(100); @@ -448,11 +448,11 @@ ${JSON.stringify(unsupportedTypeIds)}`); const newFile = JSON.stringify(importResult.result.map((r) => r.raw), null, 2); const diff = prettyFormatFileDiff('', newFile); - reporter.displayFileModifications([{ file: filePath, modification: { newFile, diff } }]); + await reporter.displayFileModifications([{ file: filePath, modification: { newFile, diff } }]); const shouldSave = await reporter.promptConfirmation(`Save the changes? (${filePath})`); if (!shouldSave) { - reporter.displayMessage('\nSkipping save! Exiting...'); + await reporter.displayMessage('\nSkipping save! Exiting...'); // Wait for the message to display before we exit await sleep(100); @@ -461,7 +461,7 @@ ${JSON.stringify(unsupportedTypeIds)}`); await FileUpdater.write(filePath, newFile); - reporter.displayMessage('\nšŸŽ‰ Imported completed and saved to file šŸŽ‰'); + await reporter.displayMessage('\nšŸŽ‰ Imported completed and saved to file šŸŽ‰'); // Wait for the message to display before we exit await sleep(100); diff --git a/src/orchestrators/plan.ts b/src/orchestrators/plan.ts index ee97f966..05cd0617 100644 --- a/src/orchestrators/plan.ts +++ b/src/orchestrators/plan.ts @@ -48,7 +48,7 @@ export class PlanOrchestrator { if (!args.noProgress) ctx.processFinished(ProcessName.PLAN) await reporter.hide(); - reporter.displayPlan(plan); + await reporter.displayPlan(plan); return { plan, diff --git a/src/orchestrators/refresh.ts b/src/orchestrators/refresh.ts index ad1cc9dd..620f49f2 100644 --- a/src/orchestrators/refresh.ts +++ b/src/orchestrators/refresh.ts @@ -35,7 +35,7 @@ export class RefreshOrchestrator { ctx.processFinished(ProcessName.REFRESH); - reporter.displayImportResult(importResult, false); + await reporter.displayImportResult(importResult, false); // Special handling for remote-file resources. Offer to save them remotely if any changes are detected on import. diff --git a/src/orchestrators/test.ts b/src/orchestrators/test.ts index 114dbeb6..7ad745b3 100644 --- a/src/orchestrators/test.ts +++ b/src/orchestrators/test.ts @@ -130,7 +130,7 @@ export const TestOrchestrator = { // Short circuit and exit if every change is NOOP if (!planResult.plan.isEmpty()) { - reporter.displayPlan(planResult.plan); + await reporter.displayPlan(planResult.plan); const confirm = await reporter.promptConfirmation('The following resources will need to be installed (Tart VM - 25gb). Do you want to continue?') if (!confirm) { return process.exit(0); diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index a1ff79c0..438eed7b 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -207,11 +207,11 @@ export class DefaultReporter implements Reporter { } } - displayImportResult(importResult: ImportResult, showConfigs: boolean): void { + async displayImportResult(importResult: ImportResult, showConfigs: boolean): Promise { store.set(store.progressState, null); this.progressState = null; - void this.updateRenderState(RenderStatus.DISPLAY_IMPORT_RESULT, { importResult, showConfigs }); + await this.updateRenderState(RenderStatus.DISPLAY_IMPORT_RESULT, { importResult, showConfigs }); } async promptSudo(pluginName: string, data: CommandRequestData): Promise { @@ -223,8 +223,8 @@ export class DefaultReporter implements Reporter { return password; } - displayPlan(plan: Plan): void { - void this.updateRenderState(RenderStatus.DISPLAY_PLAN, plan) + async displayPlan(plan: Plan): Promise { + await this.updateRenderState(RenderStatus.DISPLAY_PLAN, plan); } async displayMessage(message: string) { @@ -264,17 +264,17 @@ export class DefaultReporter implements Reporter { return options.indexOf(result); } - displayFileModifications(diff: Array<{ file: string; modification: FileModificationResult}>) { - void this.updateRenderState(RenderStatus.DISPLAY_FILE_MODIFICATION, diff); + async displayFileModifications(diff: Array<{ file: string; modification: FileModificationResult}>): Promise { + await this.updateRenderState(RenderStatus.DISPLAY_FILE_MODIFICATION, diff); } - displayPluginError(error: PluginError): void { + async displayPluginError(error: PluginError): Promise { if (error.errorData.errorType === 'apply_validation') { const resourcePlan = new ResourcePlan((error.errorData.data as any).plan); - void this.updateRenderState(RenderStatus.APPLY_VALIDATION_ERROR, resourcePlan); + await this.updateRenderState(RenderStatus.APPLY_VALIDATION_ERROR, resourcePlan); return; } - void this.updateRenderState(RenderStatus.PLUGIN_ERROR, error.message); + await this.updateRenderState(RenderStatus.PLUGIN_ERROR, error.message); } private log(log: string): void { @@ -388,6 +388,14 @@ export class DefaultReporter implements Reporter { return store.get(store.renderState) as { status: RenderStatus, data: any }; } + /** + * Update the render state. We need to make this async because there is currently a weird bug where if we switch the + * layout too quickly then it can potentially crash with a memory error. We first switch to empty, wait 50ms and the + * render the next state + * @param status + * @param data + * @private + */ private async updateRenderState(status: RenderStatus | null, data?: unknown): Promise { const current = this.getRenderState(); if (current?.status !== status) { diff --git a/src/ui/reporters/json-reporter.ts b/src/ui/reporters/json-reporter.ts index 8f5ee089..2fe748ab 100644 --- a/src/ui/reporters/json-reporter.ts +++ b/src/ui/reporters/json-reporter.ts @@ -9,7 +9,7 @@ import { Reporter } from './reporter.js'; export class JsonReporter implements Reporter { silent = false; - displayPlan(plan: Plan): void { + async displayPlan(plan: Plan): Promise { console.log(JSON.stringify(plan.resources.map((r) => r.raw), null, 2)); } @@ -59,7 +59,7 @@ export class JsonReporter implements Reporter { async displayFileModifications(): Promise { } - displayMessage(): void { + async displayMessage(): Promise { } async displayImportWarning(): Promise { @@ -73,7 +73,7 @@ export class JsonReporter implements Reporter { throw new Error('Json reporter error: disableRawMode is not supported. Raw stdin mode requires interactive terminal access.'); } - displayPluginError(error: PluginError): void { + async displayPluginError(error: PluginError): Promise { console.log(JSON.stringify({ errorType: error.errorData.errorType, message: error.message, diff --git a/src/ui/reporters/plain-reporter.ts b/src/ui/reporters/plain-reporter.ts index 224a279d..e6ff264e 100644 --- a/src/ui/reporters/plain-reporter.ts +++ b/src/ui/reporters/plain-reporter.ts @@ -70,7 +70,7 @@ export class PlainReporter implements Reporter { return Number.parseInt(response as string, 10); } - displayFileModifications(diffs: { file: string; modification: FileModificationResult; }[]): void { + async displayFileModifications(diffs: { file: string; modification: FileModificationResult; }[]): Promise { ctx.log(chalk.bold('File modifications\n')) for (const diff of diffs) { @@ -81,7 +81,7 @@ export class PlainReporter implements Reporter { } } - displayMessage(message: string): void { + async displayMessage(message: string): Promise { ctx.log(message); } @@ -128,7 +128,7 @@ export class PlainReporter implements Reporter { return availableTypes; } - displayImportResult(importResult: ImportResult) { + async displayImportResult(importResult: ImportResult): Promise { ctx.log(); ctx.log(JSON.stringify(importResult.result.map((r) => r.raw), null, 2)); @@ -159,13 +159,13 @@ Use this init flow to get started quickly with Codify. return response === 'yes'; } - displayPlan(plan: Plan): void { + async displayPlan(plan: Plan): Promise { ctx.log( prettyFormatPlan(plan.filterNoopResources()) ); } - displayPluginError(error: PluginError): void { + async displayPluginError(error: PluginError): Promise { if (error.errorData.errorType === 'apply_validation') { ctx.log(chalk.red(formatApplyValidationError(error))); return; diff --git a/src/ui/reporters/reporter.ts b/src/ui/reporters/reporter.ts index 0b5236d7..b08e27d3 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -51,7 +51,7 @@ export enum PromptType { export interface Reporter { silent: boolean; - displayPlan(plan: Plan): void + displayPlan(plan: Plan): Promise displayInitBanner(): Promise @@ -73,11 +73,11 @@ export interface Reporter { promptPressKeyToContinue(message?: string): Promise; - displayImportResult(importResult: ImportResult, showConfigs: boolean): void; + displayImportResult(importResult: ImportResult, showConfigs: boolean): Promise; - displayFileModifications(diff: Array<{ file: string, modification: FileModificationResult }>): void + displayFileModifications(diff: Array<{ file: string, modification: FileModificationResult }>): Promise - displayMessage(message: string): void + displayMessage(message: string): Promise displayImportWarning(requiresParameters: string[], noParametersRequired: string[]): Promise @@ -85,7 +85,7 @@ export interface Reporter { disableRawMode(): Promise - displayPluginError(error: PluginError): void; + displayPluginError(error: PluginError): Promise; } export enum ReporterType { diff --git a/src/ui/reporters/stub-reporter.ts b/src/ui/reporters/stub-reporter.ts index ac42bab7..5811ce03 100644 --- a/src/ui/reporters/stub-reporter.ts +++ b/src/ui/reporters/stub-reporter.ts @@ -8,7 +8,7 @@ import { PromptType, Reporter } from './reporter.js'; export class StubReporter implements Reporter { silent: boolean = true; - displayPlan(): void {} + async displayPlan(): Promise {} async displayInitBanner(): Promise {} async displayProgress(): Promise {} async hide(): Promise {} @@ -25,5 +25,5 @@ export class StubReporter implements Reporter { async displayImportWarning(requiresParameters: string[], noParametersRequired: string[]): Promise {} async setRawMode(): Promise {} async disableRawMode(): Promise {} - displayPluginError(): void {} + async displayPluginError(): Promise {} } diff --git a/test/orchestrator/import/import.test.ts b/test/orchestrator/import/import.test.ts index cc07bfcd..e65a610a 100644 --- a/test/orchestrator/import/import.test.ts +++ b/test/orchestrator/import/import.test.ts @@ -167,7 +167,7 @@ describe('Import orchestrator tests', () => { return 0; }, displayFileModifications: (diff: Array<{ file: string, modification: FileModificationResult }>) => { - expect(diff[0].file).to.eq('/codify.json') + expect(diff[0].file).to.eq('/import.codify.jsonc') console.log(diff[0].file); }, }); @@ -731,7 +731,7 @@ describe('Import orchestrator tests', () => { return 0; }, displayFileModifications: (diff: Array<{ file: string, modification: FileModificationResult }>) => { - expect(diff[0].file).to.eq('/codify.json') + expect(diff[0].file).to.eq('/import.codify.jsonc') console.log(diff[0].file); }, }); @@ -810,7 +810,7 @@ describe('Import orchestrator tests', () => { return 0; }, displayFileModifications: (diff: Array<{ file: string, modification: FileModificationResult }>) => { - expect(diff[0].file).to.eq('/codify.json') + expect(diff[0].file).to.eq('/codify.jsonc') console.log(diff[0].file); }, }); From c5f0b3d96f928508163be0fe23cd56bef2f346b3 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sun, 26 Apr 2026 23:46:30 -0400 Subject: [PATCH 4/5] feat: Improve the copy and formatting for apply validation error. Fix pretty print error for NOOP. Added to CLAUDE.md --- CLAUDE.md | 25 ++++++++++++++++++++++++- src/ui/components/default-component.tsx | 4 ++-- src/ui/plan-pretty-printer.ts | 1 + 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 47bb7c3c..a939f614 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -184,4 +184,27 @@ Parent Process Plugin Process 3. **Plugin IPC**: Plugins cannot directly read stdin (security isolation) 4. **Sudo Caching**: Password cached in memory during session unless `--secure` flag used 5. **File Watcher**: Use `persistent: false` option to prevent hanging processes -6. **Linting**: ESLint enforces single quotes, specific import ordering, and strict type safety \ No newline at end of file +6. **Linting**: ESLint enforces single quotes, specific import ordering, and strict type safety +7. **Reporter display methods are async**: All `Reporter` interface display methods (`displayPlan`, `displayImportResult`, `displayFileModifications`, `displayMessage`, `displayPluginError`) return `Promise`. Always `await` them at call sites — `DefaultReporter.updateRenderState()` has a 50ms sleep, so unawaited calls cause `process.exit(1)` to fire before the UI renders. +8. **Mock reporter async assertions**: Assertions inside `MockReporter` config callbacks (e.g. `displayFileModifications`) will silently pass if the call isn't awaited. Making display methods async surfaced latent bugs where expected file paths were wrong. + +## Plugin Error Handling Architecture + +Plugin errors flow as structured `PluginErrorData` over IPC and are caught as `PluginError` instances on the CLI side: + +**IPC envelope** (`@codifycli/schemas`): +```typescript +interface PluginErrorData { + errorType: string; // 'apply_validation' | 'sudo_error' | 'unknown' + message: string; + data?: unknown; +} +``` + +**CLI carrier** (`src/common/errors.ts`): `PluginError extends CodifyError` holds `pluginName`, `resourceType`, and `errorData: PluginErrorData`. + +**Reporter as view model**: Reporters (not components) decide how to render each `errorType`. `DefaultReporter.displayPluginError()` branches on `errorType` to set the appropriate `RenderStatus` (`APPLY_VALIDATION_ERROR` with a `ResourcePlan` for plan diffs, `PLUGIN_ERROR` with a message string for generic errors). The `DefaultComponent` is purely display. + +**Shared formatter**: `src/ui/plugin-error-formatter.ts` exports `formatApplyValidationError(error: PluginError): string` used by both `PlainReporter` and `DefaultComponent`. + +**Backward compat**: `plugin.ts#toErrorData()` validates IPC data against `ErrorResponseDataSchema` (AJV); falls back to `{ errorType: 'unknown', message: data }` for old plugins sending bare strings. \ No newline at end of file diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index 9e700577..64b3feb0 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -56,13 +56,13 @@ export function DefaultComponent(props: { (resourcePlan, idx) => ( - {`Apply validation failed: resource "${resourcePlan.id}" did not reach its desired state. \nExiting...`} + {`Apply failed: resource "${resourcePlan.id}" did not reach its desired state. \nExiting...`} Changes still needed: {prettyFormatResourcePlan(resourcePlan)} - Try re-running to see if the changes have applied. + Try re-running the command to see if the changes have applied. ) } diff --git a/src/ui/plan-pretty-printer.ts b/src/ui/plan-pretty-printer.ts index 176548f6..4d05a931 100644 --- a/src/ui/plan-pretty-printer.ts +++ b/src/ui/plan-pretty-printer.ts @@ -234,6 +234,7 @@ function formatArray(parameter: PlanResponseData['parameters'][0]): string { if (operation === ParameterOperation.NOOP) { return JSON.stringify(mappedB, null, 4) .split(/\n/g) + .map((l, idx) => idx === 0 ? `"${name}": ${l}` : l) .map((l) => ` ${l}`) .join('\n') + ',' } From 6d3421348c9189d30c6f35eb0ce9d8d11c226bd0 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sun, 26 Apr 2026 23:52:44 -0400 Subject: [PATCH 5/5] feat: Improved copy again --- src/ui/components/default-component.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index 64b3feb0..eb248302 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -62,7 +62,11 @@ export function DefaultComponent(props: { Changes still needed: {prettyFormatResourcePlan(resourcePlan)} - Try re-running the command to see if the changes have applied. + Potential fixes: + {' 1. Re-run the command again'} + {' 2. Manually install the resource and retry'} + {' 3. Reach out to support at https://github.com/codifycli/default-plugin/issues'} + ) }