From 2ada39409869a23f23b5e1eed1015f575cb54a06 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Sun, 26 Apr 2026 21:59:20 -0400 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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'} + ) } From fe48cb149b7be0eea1939dac5afbf980a95d78ef Mon Sep 17 00:00:00 2001 From: kevinwang Date: Mon, 27 Apr 2026 09:37:28 -0400 Subject: [PATCH 06/10] feat: Added partial applys. If a resource fails to apply, skip it and all transitive dependent resources. Apply un-related resources. --- src/common/base-command.ts | 2 +- src/common/errors.ts | 14 ++++++++++++ src/entities/plan.ts | 24 ++++++++++++++++++++ src/orchestrators/apply.ts | 15 ++++++++++++- src/plugins/plugin-manager.ts | 27 +++++++++++++++++++++-- src/ui/components/default-component.tsx | 29 +++++++++++++++---------- src/ui/reporters/default-reporter.tsx | 16 +++++++++----- src/ui/reporters/json-reporter.ts | 6 ++--- src/ui/reporters/plain-reporter.ts | 12 +++++----- src/ui/reporters/reporter.ts | 2 +- src/ui/reporters/stub-reporter.ts | 3 ++- 11 files changed, 118 insertions(+), 32 deletions(-) diff --git a/src/common/base-command.ts b/src/common/base-command.ts index e4267f02..a99eb37d 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(); - await this.reporter.displayPluginError(err); + await this.reporter.displayPluginError([err]); process.exit(1); } diff --git a/src/common/errors.ts b/src/common/errors.ts index c3acaca3..5fa689f0 100644 --- a/src/common/errors.ts +++ b/src/common/errors.ts @@ -250,6 +250,20 @@ export class PluginError extends CodifyError { } } +export class ApplyPartialFailureError extends CodifyError { + name = 'ApplyPartialFailureError'; + errors: PluginError[]; + + constructor(errors: PluginError[]) { + super(`Apply failed with ${errors.length} error(s)`); + this.errors = errors; + } + + 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/entities/plan.ts b/src/entities/plan.ts index 704a342d..58ceb136 100644 --- a/src/entities/plan.ts +++ b/src/entities/plan.ts @@ -54,6 +54,30 @@ export class Plan { return this.raw.every((r) => r.operation === ResourceOperation.NOOP); } + computeTransitiveDependents(failedId: string): Set { + const reverseDeps = new Map>(); + for (const r of this.project.resourceConfigs) { + for (const depId of r.dependencyIds) { + if (!reverseDeps.has(depId)) reverseDeps.set(depId, new Set()); + reverseDeps.get(depId)!.add(r.id); + } + } + + const toSkip = new Set(); + const queue = [failedId]; + while (queue.length > 0) { + const current = queue.shift()!; + const dependents = reverseDeps.get(current) ?? new Set(); + for (const dep of dependents) { + if (!toSkip.has(dep)) { + toSkip.add(dep); + queue.push(dep); + } + } + } + return toSkip; + } + *[Symbol.iterator](): Iterator { for (const resource of this.resources) { yield resource; diff --git a/src/orchestrators/apply.ts b/src/orchestrators/apply.ts index 69318562..c323f8a9 100644 --- a/src/orchestrators/apply.ts +++ b/src/orchestrators/apply.ts @@ -1,3 +1,4 @@ +import { ApplyPartialFailureError } from '../common/errors.js'; import { ProcessName, ctx } from '../events/context.js'; import { DefaultReporter } from '../ui/reporters/default-reporter.js'; import { Reporter } from '../ui/reporters/reporter.js'; @@ -44,7 +45,19 @@ export const ApplyOrchestrator = { if (!args.noProgress) ctx.processStarted(ProcessName.APPLY); if (!args.noProgress) await reporter.displayProgress(); - await pluginManager.apply(project, filteredPlan); + try { + await pluginManager.apply(project, filteredPlan); + } catch (err) { + if (err instanceof ApplyPartialFailureError) { + if (!args.noProgress) ctx.processFinished(ProcessName.APPLY); + await sleep(100); + await reporter.hide(); + await reporter.displayPluginError(err.errors); + return process.exit(1); + } + throw err; + } + if (!args.noProgress) ctx.processFinished(ProcessName.APPLY); // Need to sleep to wait for the message to display before we exit diff --git a/src/plugins/plugin-manager.ts b/src/plugins/plugin-manager.ts index 39879c4f..fd1c6d30 100644 --- a/src/plugins/plugin-manager.ts +++ b/src/plugins/plugin-manager.ts @@ -4,7 +4,7 @@ import { ValidateResponseData, } from '@codifycli/schemas'; -import { InternalError } from '../common/errors.js'; +import { ApplyPartialFailureError, InternalError, PluginError } from '../common/errors.js'; import { config } from '../config.js'; import { Plan, ResourcePlan } from '../entities/plan.js'; import { Project } from '../entities/project.js'; @@ -138,7 +138,16 @@ export class PluginManager { } async apply(project: Project, plan: Plan): Promise { + const collectedErrors: PluginError[] = []; + const skippedIds = new Set(); + for (const id of project.evaluationOrder ?? []) { + if (skippedIds.has(id)) { + ctx.subprocessStarted(SubProcessName.APPLYING_RESOURCE, id); + ctx.subprocessFinished(SubProcessName.APPLYING_RESOURCE, id); + continue; + } + ctx.subprocessStarted(SubProcessName.APPLYING_RESOURCE, id); const resourcePlan = plan.getResourcePlan(id); @@ -152,10 +161,24 @@ export class PluginManager { throw new InternalError(`Unable to determine plugin for apply: ${resourceType}`); } - await this.plugins.get(pluginName)!.apply(resourcePlan); + try { + await this.plugins.get(pluginName)!.apply(resourcePlan); + } catch (err) { + if (err instanceof PluginError) { + collectedErrors.push(err); + const dependents = plan.computeTransitiveDependents(id); + for (const depId of dependents) skippedIds.add(depId); + } else { + throw err; + } + } ctx.subprocessFinished(SubProcessName.APPLYING_RESOURCE, resourcePlan.id); } + + if (collectedErrors.length > 0) { + throw new ApplyPartialFailureError(collectedErrors); + } } async setVerbosityLevel(verbosityLevel: number): Promise { diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index eb248302..d666a2a4 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -52,21 +52,26 @@ export function DefaultComponent(props: { } { renderStatus === RenderStatus.APPLY_VALIDATION_ERROR && ( - { - (resourcePlan, idx) => ( + { + (resourcePlans, idx) => ( - - {`Apply failed: resource "${resourcePlan.id}" did not reach its desired state. \nExiting...`} - - - Changes still needed: - {prettyFormatResourcePlan(resourcePlan)} + {resourcePlans.map((resourcePlan) => ( + + + {`Apply failed: resource "${resourcePlan.id}" did not reach its desired state.`} + + + Changes still needed: + {prettyFormatResourcePlan(resourcePlan)} + + + ))} + Exiting... 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'} - ) } @@ -74,10 +79,10 @@ export function DefaultComponent(props: { } { renderStatus === RenderStatus.PLUGIN_ERROR && ( - { - (message, idx) => ( + { + (messages, idx) => ( - {message} + {messages.map((msg, i) => {msg})} ) } diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index 438eed7b..c8cf767c 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -268,13 +268,17 @@ export class DefaultReporter implements Reporter { await this.updateRenderState(RenderStatus.DISPLAY_FILE_MODIFICATION, diff); } - async displayPluginError(error: PluginError): Promise { - if (error.errorData.errorType === 'apply_validation') { - const resourcePlan = new ResourcePlan((error.errorData.data as any).plan); - await this.updateRenderState(RenderStatus.APPLY_VALIDATION_ERROR, resourcePlan); - return; + async displayPluginError(errors: PluginError[]): Promise { + const validationErrors = errors.filter((e) => e.errorData.errorType === 'apply_validation'); + const genericErrors = errors.filter((e) => e.errorData.errorType !== 'apply_validation'); + + if (validationErrors.length > 0) { + const resourcePlans = validationErrors.map((e) => new ResourcePlan((e.errorData.data as any).plan)); + await this.updateRenderState(RenderStatus.APPLY_VALIDATION_ERROR, resourcePlans); + } + if (genericErrors.length > 0) { + await this.updateRenderState(RenderStatus.PLUGIN_ERROR, genericErrors.map((e) => e.message)); } - await 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 2fe748ab..e2ae5240 100644 --- a/src/ui/reporters/json-reporter.ts +++ b/src/ui/reporters/json-reporter.ts @@ -73,13 +73,13 @@ export class JsonReporter implements Reporter { throw new Error('Json reporter error: disableRawMode is not supported. Raw stdin mode requires interactive terminal access.'); } - async displayPluginError(error: PluginError): Promise { - console.log(JSON.stringify({ + async displayPluginError(errors: PluginError[]): Promise { + console.log(JSON.stringify(errors.map((error) => ({ errorType: error.errorData.errorType, message: error.message, pluginName: error.pluginName, resourceType: error.resourceType, data: error.errorData.data, - }, null, 2)); + })), null, 2)); } } diff --git a/src/ui/reporters/plain-reporter.ts b/src/ui/reporters/plain-reporter.ts index e6ff264e..31d2435d 100644 --- a/src/ui/reporters/plain-reporter.ts +++ b/src/ui/reporters/plain-reporter.ts @@ -165,12 +165,14 @@ Use this init flow to get started quickly with Codify. ); } - async displayPluginError(error: PluginError): Promise { - if (error.errorData.errorType === 'apply_validation') { - ctx.log(chalk.red(formatApplyValidationError(error))); - return; + async displayPluginError(errors: PluginError[]): Promise { + for (const error of errors) { + if (error.errorData.errorType === 'apply_validation') { + ctx.log(chalk.red(formatApplyValidationError(error))); + } else { + ctx.log(chalk.red(error.message)); + } } - 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 b08e27d3..b6f0dba1 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -85,7 +85,7 @@ export interface Reporter { disableRawMode(): Promise - displayPluginError(error: PluginError): Promise; + displayPluginError(errors: PluginError[]): Promise; } export enum ReporterType { diff --git a/src/ui/reporters/stub-reporter.ts b/src/ui/reporters/stub-reporter.ts index 5811ce03..d8fdf0c6 100644 --- a/src/ui/reporters/stub-reporter.ts +++ b/src/ui/reporters/stub-reporter.ts @@ -1,5 +1,6 @@ import { CommandRequestData } from '@codifycli/schemas'; +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'; @@ -25,5 +26,5 @@ export class StubReporter implements Reporter { async displayImportWarning(requiresParameters: string[], noParametersRequired: string[]): Promise {} async setRawMode(): Promise {} async disableRawMode(): Promise {} - async displayPluginError(): Promise {} + async displayPluginError(_errors: PluginError[]): Promise {} } From 9156743f31376aa4dd9c6e8273604dff5f8b0e07 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Mon, 27 Apr 2026 10:21:27 -0400 Subject: [PATCH 07/10] feat: Further refactored the error messages. Combined it with a final display message change. The final display message will now tell the user a list of all changes that were made. --- src/common/errors.ts | 14 ------ src/entities/apply-result.ts | 53 ++++++++++++++++++++++ src/orchestrators/apply.ts | 26 +++-------- src/orchestrators/destroy.ts | 11 +++-- src/plugins/plugin-manager.ts | 11 ++--- src/ui/components/default-component.tsx | 60 +++++++++++++++++++++++++ src/ui/reporters/default-reporter.tsx | 7 ++- src/ui/reporters/json-reporter.ts | 14 +++++- src/ui/reporters/plain-reporter.ts | 52 ++++++++++++++++++--- src/ui/reporters/reporter.ts | 5 ++- src/ui/reporters/stub-reporter.ts | 2 + src/ui/store/index.ts | 1 + test/orchestrator/apply/apply.test.ts | 4 +- test/orchestrator/mocks/reporter.ts | 13 ++++++ 14 files changed, 221 insertions(+), 52 deletions(-) create mode 100644 src/entities/apply-result.ts diff --git a/src/common/errors.ts b/src/common/errors.ts index 5fa689f0..c3acaca3 100644 --- a/src/common/errors.ts +++ b/src/common/errors.ts @@ -250,20 +250,6 @@ export class PluginError extends CodifyError { } } -export class ApplyPartialFailureError extends CodifyError { - name = 'ApplyPartialFailureError'; - errors: PluginError[]; - - constructor(errors: PluginError[]) { - super(`Apply failed with ${errors.length} error(s)`); - this.errors = errors; - } - - 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/entities/apply-result.ts b/src/entities/apply-result.ts new file mode 100644 index 00000000..656424de --- /dev/null +++ b/src/entities/apply-result.ts @@ -0,0 +1,53 @@ +import { ResourceOperation } from '@codifycli/schemas'; + +import { PluginError } from '../common/errors.js'; +import { ResourcePlan } from './plan.js'; + +export interface ApplyResultEntry { + id: string; + operation: ResourceOperation; + status: 'success' | 'failed' | 'skipped'; + error?: PluginError; +} + +export interface ApplyResult { + entries: ApplyResultEntry[]; + errors: PluginError[]; + + isPartialFailure(): boolean; +} + +export function createApplyResult( + succeededPlans: ResourcePlan[], + failedErrors: PluginError[], + skippedIds: Set, +): ApplyResult { + const failedByType = new Map(failedErrors.map((e) => [e.resourceType, e])); + + const entries: ApplyResultEntry[] = [ + ...succeededPlans.map((p) => ({ + id: p.id, + operation: p.operation, + status: 'success' as const, + })), + ...failedErrors.map((e) => ({ + id: e.resourceType, + operation: ResourceOperation.NOOP, + status: 'failed' as const, + error: e, + })), + ...[...skippedIds].map((id) => ({ + id, + operation: ResourceOperation.NOOP, + status: 'skipped' as const, + })), + ]; + + return { + entries, + errors: failedErrors, + isPartialFailure() { + return failedErrors.length > 0; + }, + }; +} diff --git a/src/orchestrators/apply.ts b/src/orchestrators/apply.ts index c323f8a9..02d79b2a 100644 --- a/src/orchestrators/apply.ts +++ b/src/orchestrators/apply.ts @@ -1,4 +1,3 @@ -import { ApplyPartialFailureError } from '../common/errors.js'; import { ProcessName, ctx } from '../events/context.js'; import { DefaultReporter } from '../ui/reporters/default-reporter.js'; import { Reporter } from '../ui/reporters/reporter.js'; @@ -30,7 +29,7 @@ export const ApplyOrchestrator = { return process.exit(0); } } - + const { plan, pluginManager, project } = planResult; const filteredPlan = plan.filterNoopResources() @@ -45,26 +44,15 @@ export const ApplyOrchestrator = { if (!args.noProgress) ctx.processStarted(ProcessName.APPLY); if (!args.noProgress) await reporter.displayProgress(); - try { - await pluginManager.apply(project, filteredPlan); - } catch (err) { - if (err instanceof ApplyPartialFailureError) { - if (!args.noProgress) ctx.processFinished(ProcessName.APPLY); - await sleep(100); - await reporter.hide(); - await reporter.displayPluginError(err.errors); - return process.exit(1); - } - throw err; - } + const applyResult = await pluginManager.apply(project, filteredPlan); if (!args.noProgress) ctx.processFinished(ProcessName.APPLY); - // Need to sleep to wait for the message to display before we exit - await sleep(100); + await reporter.displayApplyComplete(applyResult); - await reporter.displayMessage(` -šŸŽ‰ Finished applying šŸŽ‰ -Open a new terminal or source '.zshrc' for the new changes to be reflected`); + if (applyResult.isPartialFailure()) { + await reporter.displayPluginError(applyResult.errors); + process.exit(1); + } }, }; diff --git a/src/orchestrators/destroy.ts b/src/orchestrators/destroy.ts index e0dc707b..d5684395 100644 --- a/src/orchestrators/destroy.ts +++ b/src/orchestrators/destroy.ts @@ -70,13 +70,16 @@ export class DestroyOrchestrator { } await reporter.displayProgress(); - await ctx.process(ProcessName.DESTROY, () => + const applyResult = await ctx.process(ProcessName.DESTROY, () => pluginManager.apply(destroyProject, filteredPlan) ) - await reporter.displayMessage(` -šŸŽ‰ Finished applying šŸŽ‰ -Open a new terminal or source '.zshrc' for the new changes to be reflected`); + await reporter.displayApplyComplete(applyResult); + + if (applyResult.isPartialFailure()) { + await reporter.displayPluginError(applyResult.errors); + process.exit(1); + } } /** This method is responsible for generating a plan for specific resources specified by the user */ diff --git a/src/plugins/plugin-manager.ts b/src/plugins/plugin-manager.ts index fd1c6d30..6dfedf20 100644 --- a/src/plugins/plugin-manager.ts +++ b/src/plugins/plugin-manager.ts @@ -4,8 +4,9 @@ import { ValidateResponseData, } from '@codifycli/schemas'; -import { ApplyPartialFailureError, InternalError, PluginError } from '../common/errors.js'; +import { InternalError, PluginError } from '../common/errors.js'; import { config } from '../config.js'; +import { ApplyResult, createApplyResult } from '../entities/apply-result.js'; import { Plan, ResourcePlan } from '../entities/plan.js'; import { Project } from '../entities/project.js'; import { ResourceConfig } from '../entities/resource-config.js'; @@ -137,9 +138,10 @@ export class PluginManager { return new Plan(result, project); } - async apply(project: Project, plan: Plan): Promise { + async apply(project: Project, plan: Plan): Promise { const collectedErrors: PluginError[] = []; const skippedIds = new Set(); + const succeededPlans: ResourcePlan[] = []; for (const id of project.evaluationOrder ?? []) { if (skippedIds.has(id)) { @@ -163,6 +165,7 @@ export class PluginManager { try { await this.plugins.get(pluginName)!.apply(resourcePlan); + succeededPlans.push(resourcePlan); } catch (err) { if (err instanceof PluginError) { collectedErrors.push(err); @@ -176,9 +179,7 @@ export class PluginManager { ctx.subprocessFinished(SubProcessName.APPLYING_RESOURCE, resourcePlan.id); } - if (collectedErrors.length > 0) { - throw new ApplyPartialFailureError(collectedErrors); - } + return createApplyResult(succeededPlans, collectedErrors, skippedIds); } async setVerbosityLevel(verbosityLevel: number): Promise { diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index d666a2a4..2bd4a093 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -5,6 +5,9 @@ import { useAtom } from 'jotai'; import { EventEmitter } from 'node:events'; import React, { useLayoutEffect } from 'react'; +import { ResourceOperation } from '@codifycli/schemas'; + +import { ApplyResult, ApplyResultEntry } from '../../entities/apply-result.js'; import { Plan, ResourcePlan } from '../../entities/plan.js'; import { prettyFormatResourcePlan } from '../plan-pretty-printer.js'; import { FileModificationResult } from '../../generators/index.js'; @@ -22,6 +25,56 @@ import { PromptPressKeyToContinue } from './widgets/PromptPressKeyToContinue.js' import { SudoPasswordInput } from './widgets/SudoPasswordInput.js'; import { TextInput } from './widgets/TextInput.js'; +function entryLabel(entry: ApplyResultEntry): string { + if (entry.status === 'failed') return 'failed'; + if (entry.status === 'skipped') return 'skipped'; + switch (entry.operation) { + case ResourceOperation.CREATE: return 'installed'; + case ResourceOperation.DESTROY: return 'destroyed'; + case ResourceOperation.MODIFY: + case ResourceOperation.RECREATE: return 'modified'; + default: return 'applied'; + } +} + +function entryColor(entry: ApplyResultEntry): string { + if (entry.status === 'failed') return 'red'; + if (entry.status === 'skipped') return 'gray'; + switch (entry.operation) { + case ResourceOperation.CREATE: return 'green'; + case ResourceOperation.DESTROY: return 'red'; + case ResourceOperation.MODIFY: + case ResourceOperation.RECREATE: return '#d4a017'; + default: return 'white'; + } +} + +function ApplyCompleteComponent({ result }: { result: ApplyResult }) { + const isPartial = result.isPartialFailure(); + return ( + + + {isPartial ? '⚠ Apply completed with errors' : 'šŸŽ‰ Finished applying šŸŽ‰'} + + {result.entries.length > 0 && ( + + {result.entries.map((entry) => ( + + {entry.id.padEnd(30)} + {entryLabel(entry)} + + ))} + + )} + {!isPartial && ( + + Open a new terminal or source '.zshrc' for the new changes to be reflected + + )} + + ); +} + export function DefaultComponent(props: { emitter: EventEmitter onWriteReady?: (write: (data: string) => void) => void @@ -40,6 +93,13 @@ export function DefaultComponent(props: { {renderData as string} ) } + { + renderStatus === RenderStatus.APPLY_COMPLETE && ( + { + (result, idx) => + } + ) + } { renderStatus === RenderStatus.PROGRESS && ( diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index c8cf767c..c752f8e8 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -5,8 +5,9 @@ import { EventEmitter } from 'node:events'; import React from 'react'; import stripAnsi from 'strip-ansi' -import { Plan, ResourcePlan } from '../../entities/plan.js'; import { PluginError } from '../../common/errors.js'; +import { ApplyResult } from '../../entities/apply-result.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'; @@ -268,6 +269,10 @@ export class DefaultReporter implements Reporter { await this.updateRenderState(RenderStatus.DISPLAY_FILE_MODIFICATION, diff); } + async displayApplyComplete(result: ApplyResult): Promise { + await this.updateRenderState(RenderStatus.APPLY_COMPLETE, result); + } + async displayPluginError(errors: PluginError[]): Promise { const validationErrors = errors.filter((e) => e.errorData.errorType === 'apply_validation'); const genericErrors = errors.filter((e) => e.errorData.errorType !== 'apply_validation'); diff --git a/src/ui/reporters/json-reporter.ts b/src/ui/reporters/json-reporter.ts index e2ae5240..8f593bfd 100644 --- a/src/ui/reporters/json-reporter.ts +++ b/src/ui/reporters/json-reporter.ts @@ -1,7 +1,8 @@ import { CommandRequestData } from '@codifycli/schemas'; -import { Plan } from '../../entities/plan.js'; import { PluginError } from '../../common/errors.js'; +import { ApplyResult } from '../../entities/apply-result.js'; +import { Plan } from '../../entities/plan.js'; import { ResourceConfig } from '../../entities/resource-config.js'; import { ImportResult } from '../../orchestrators/import.js'; import { Reporter } from './reporter.js'; @@ -82,4 +83,15 @@ export class JsonReporter implements Reporter { data: error.errorData.data, })), null, 2)); } + + async displayApplyComplete(result: ApplyResult): Promise { + console.log(JSON.stringify({ + success: !result.isPartialFailure(), + entries: result.entries.map((e) => ({ + id: e.id, + operation: e.operation, + status: e.status, + })), + }, null, 2)); + } } diff --git a/src/ui/reporters/plain-reporter.ts b/src/ui/reporters/plain-reporter.ts index 31d2435d..f950a6de 100644 --- a/src/ui/reporters/plain-reporter.ts +++ b/src/ui/reporters/plain-reporter.ts @@ -1,9 +1,10 @@ import chalk from 'chalk'; -import { CommandRequestData } from '@codifycli/schemas'; +import { CommandRequestData, ResourceOperation } from '@codifycli/schemas'; import readline from 'node:readline'; -import { Plan } from '../../entities/plan.js'; import { PluginError } from '../../common/errors.js'; +import { ApplyResult, ApplyResultEntry } from '../../entities/apply-result.js'; +import { Plan } from '../../entities/plan.js'; import { formatApplyValidationError } from '../plugin-error-formatter.js'; import { ResourceConfig } from '../../entities/resource-config.js'; import { ResourceInfo } from '../../entities/resource-info.js'; @@ -13,6 +14,30 @@ import { ImportResult } from '../../orchestrators/import.js'; import { prettyFormatPlan } from '../plan-pretty-printer.js'; import { PromptType, Reporter } from './reporter.js'; +function plainEntryLabel(entry: ApplyResultEntry): string { + if (entry.status === 'failed') return 'failed'; + if (entry.status === 'skipped') return 'skipped'; + switch (entry.operation) { + case ResourceOperation.CREATE: return 'installed'; + case ResourceOperation.DESTROY: return 'destroyed'; + case ResourceOperation.MODIFY: + case ResourceOperation.RECREATE: return 'modified'; + default: return 'applied'; + } +} + +function plainEntryColor(entry: ApplyResultEntry): (s: string) => string { + if (entry.status === 'failed') return chalk.red; + if (entry.status === 'skipped') return chalk.gray; + switch (entry.operation) { + case ResourceOperation.CREATE: return chalk.green; + case ResourceOperation.DESTROY: return chalk.red; + case ResourceOperation.MODIFY: + case ResourceOperation.RECREATE: return chalk.yellow; + default: return (s) => s; + } +} + export class PlainReporter implements Reporter { private readonly rl = readline.createInterface(process.stdin, process.stdout); silent = false; @@ -175,8 +200,25 @@ Use this init flow to get started quickly with Codify. } } - displayApplyComplete(message: string[]): void { - ctx.log('šŸŽ‰ Finished applying šŸŽ‰'); - ctx.log('Open a new terminal or source \'.zshrc\' for the new changes to be reflected') + async displayApplyComplete(result: ApplyResult): Promise { + if (result.isPartialFailure()) { + ctx.log(chalk.red('⚠ Apply completed with errors')); + } else { + ctx.log('šŸŽ‰ Finished applying šŸŽ‰'); + } + + if (result.entries.length > 0) { + ctx.log(''); + for (const entry of result.entries) { + const label = plainEntryLabel(entry); + const colorFn = plainEntryColor(entry); + ctx.log(` ${entry.id.padEnd(30)}${colorFn(label)}`); + } + } + + if (!result.isPartialFailure()) { + ctx.log(''); + 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 b6f0dba1..3c32b541 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -1,7 +1,8 @@ import { CommandRequestData } from '@codifycli/schemas'; -import { Plan } from '../../entities/plan.js'; import { PluginError } from '../../common/errors.js'; +import { ApplyResult } from '../../entities/apply-result.js'; +import { Plan } from '../../entities/plan.js'; import { ResourceConfig } from '../../entities/resource-config.js'; import { ResourceInfo } from '../../entities/resource-info.js'; import { FileModificationResult } from '../../generators/index.js'; @@ -86,6 +87,8 @@ export interface Reporter { disableRawMode(): Promise displayPluginError(errors: PluginError[]): Promise; + + displayApplyComplete(result: ApplyResult): Promise; } export enum ReporterType { diff --git a/src/ui/reporters/stub-reporter.ts b/src/ui/reporters/stub-reporter.ts index d8fdf0c6..192fe4e2 100644 --- a/src/ui/reporters/stub-reporter.ts +++ b/src/ui/reporters/stub-reporter.ts @@ -1,6 +1,7 @@ import { CommandRequestData } from '@codifycli/schemas'; import { PluginError } from '../../common/errors.js'; +import { ApplyResult } from '../../entities/apply-result.js'; import { ResourceConfig } from '../../entities/resource-config.js'; import { ResourceInfo } from '../../entities/resource-info.js'; import { FileModificationResult } from '../../generators/index.js'; @@ -27,4 +28,5 @@ export class StubReporter implements Reporter { async setRawMode(): Promise {} async disableRawMode(): Promise {} async displayPluginError(_errors: PluginError[]): Promise {} + async displayApplyComplete(_result: ApplyResult): Promise {} } diff --git a/src/ui/store/index.ts b/src/ui/store/index.ts index 0de069ec..2833e5e8 100644 --- a/src/ui/store/index.ts +++ b/src/ui/store/index.ts @@ -25,6 +25,7 @@ export enum RenderStatus { DISPLAY_MESSAGE, APPLY_VALIDATION_ERROR, PLUGIN_ERROR, + APPLY_COMPLETE, } export const store = new class { diff --git a/test/orchestrator/apply/apply.test.ts b/test/orchestrator/apply/apply.test.ts index 9d06c256..cbe54d89 100644 --- a/test/orchestrator/apply/apply.test.ts +++ b/test/orchestrator/apply/apply.test.ts @@ -37,7 +37,7 @@ describe('Apply orchestrator tests', () => { }); const applyConfirmationSpy = vi.spyOn(reporter, 'promptConfirmation'); - const applyCompleteSpy = vi.spyOn(reporter, 'displayMessage'); + const applyCompleteSpy = vi.spyOn(reporter, 'displayApplyComplete'); console.log(MockOs.get('xcode-tools')) expect(MockOs.get('mock')).to.be.undefined; @@ -74,7 +74,7 @@ describe('Apply orchestrator tests', () => { }); const applyConfirmationSpy = vi.spyOn(reporter, 'promptConfirmation'); - const applyCompleteSpy = vi.spyOn(reporter, 'displayMessage'); + const applyCompleteSpy = vi.spyOn(reporter, 'displayApplyComplete'); MockOs.destroy('xcode-tools'); expect(MockOs.get('xcode-tools')).to.be.undefined; diff --git a/test/orchestrator/mocks/reporter.ts b/test/orchestrator/mocks/reporter.ts index bc9ce05f..fe1b8510 100644 --- a/test/orchestrator/mocks/reporter.ts +++ b/test/orchestrator/mocks/reporter.ts @@ -1,5 +1,7 @@ import { SudoRequestData } from '@codifycli/schemas'; +import { PluginError } from '../../../src/common/errors.js'; +import { ApplyResult } from '../../../src/entities/apply-result.js'; import { Plan } from '../../../src/entities/plan.js'; import { ResourceConfig } from '../../../src/entities/resource-config.js'; import { ResourceInfo } from '../../../src/entities/resource-info.js'; @@ -12,6 +14,7 @@ export interface MockReporterConfig { validatePlan?: (plan: Plan) => Promise | void; validateMessage?: (message: string) => Promise | void; validateImport?: (result: ImportResult) => Promise | void; + validateApplyComplete?: (result: ApplyResult) => Promise | void; promptConfirmation?: () => boolean; promptOptions?: (message: string, options: string[]) => number; promptUserForValues?: (resourceInfo: ResourceInfo[]) => Promise | ResourceConfig[]; @@ -100,4 +103,14 @@ export class MockReporter implements Reporter { displayImportResult(importResult: ImportResult, showConfigs: boolean): void { this.config?.displayImportResult?.(importResult, showConfigs); } + + async displayPluginError(_errors: PluginError[]): Promise {} + + async displayApplyComplete(result: ApplyResult): Promise { + await this.config?.validateApplyComplete?.(result); + } + + async setRawMode(): Promise {} + + async disableRawMode(): Promise {} } From ed0d323edea431eeff0f37475a8f34d84a4f4d22 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Mon, 27 Apr 2026 10:34:06 -0400 Subject: [PATCH 08/10] feat: refactored the code so that the final apply message is handled entirely inside the reporters. --- src/common/base-command.ts | 3 +- src/orchestrators/apply.ts | 2 - src/orchestrators/destroy.ts | 1 - src/ui/apply-result-formatter.ts | 40 +++++++++++++++ src/ui/components/default-component.tsx | 57 ++------------------- src/ui/components/widgets/ApplyComplete.tsx | 31 +++++++++++ src/ui/reporters/default-reporter.tsx | 27 ++++++---- src/ui/reporters/json-reporter.ts | 13 +++-- src/ui/reporters/plain-reporter.ts | 52 ++++++------------- src/ui/reporters/reporter.ts | 2 +- src/ui/reporters/stub-reporter.ts | 2 +- test/orchestrator/mocks/reporter.ts | 2 +- 12 files changed, 121 insertions(+), 111 deletions(-) create mode 100644 src/ui/apply-result-formatter.ts create mode 100644 src/ui/components/widgets/ApplyComplete.tsx diff --git a/src/common/base-command.ts b/src/common/base-command.ts index a99eb37d..a0a4a597 100644 --- a/src/common/base-command.ts +++ b/src/common/base-command.ts @@ -146,8 +146,7 @@ export abstract class BaseCommand extends Command { protected async catch(err: Error): Promise { if (err instanceof PluginError && this.reporter) { - await this.reporter.hide(); - await this.reporter.displayPluginError([err]); + await this.reporter.displayPluginError(err); process.exit(1); } diff --git a/src/orchestrators/apply.ts b/src/orchestrators/apply.ts index 02d79b2a..f2e8b556 100644 --- a/src/orchestrators/apply.ts +++ b/src/orchestrators/apply.ts @@ -1,7 +1,6 @@ import { ProcessName, ctx } from '../events/context.js'; import { DefaultReporter } from '../ui/reporters/default-reporter.js'; import { Reporter } from '../ui/reporters/reporter.js'; -import { sleep } from '../utils/index.js'; import { VerbosityLevel } from '../utils/verbosity-level.js'; import { PlanOrchestrator } from './plan.js'; @@ -51,7 +50,6 @@ export const ApplyOrchestrator = { await reporter.displayApplyComplete(applyResult); if (applyResult.isPartialFailure()) { - await reporter.displayPluginError(applyResult.errors); process.exit(1); } }, diff --git a/src/orchestrators/destroy.ts b/src/orchestrators/destroy.ts index d5684395..7d35bf83 100644 --- a/src/orchestrators/destroy.ts +++ b/src/orchestrators/destroy.ts @@ -77,7 +77,6 @@ export class DestroyOrchestrator { await reporter.displayApplyComplete(applyResult); if (applyResult.isPartialFailure()) { - await reporter.displayPluginError(applyResult.errors); process.exit(1); } } diff --git a/src/ui/apply-result-formatter.ts b/src/ui/apply-result-formatter.ts new file mode 100644 index 00000000..af7ea4b5 --- /dev/null +++ b/src/ui/apply-result-formatter.ts @@ -0,0 +1,40 @@ +import chalk from 'chalk'; +import { ResourceOperation } from '@codifycli/schemas'; + +import { ApplyResultEntry } from '../entities/apply-result.js'; + +export function applyEntryLabel(entry: ApplyResultEntry): string { + if (entry.status === 'failed') return 'failed'; + if (entry.status === 'skipped') return 'skipped'; + switch (entry.operation) { + case ResourceOperation.CREATE: return 'installed'; + case ResourceOperation.DESTROY: return 'destroyed'; + case ResourceOperation.MODIFY: + case ResourceOperation.RECREATE: return 'modified'; + default: return 'applied'; + } +} + +export function applyEntryInkColor(entry: ApplyResultEntry): string { + if (entry.status === 'failed') return 'red'; + if (entry.status === 'skipped') return 'gray'; + switch (entry.operation) { + case ResourceOperation.CREATE: return 'green'; + case ResourceOperation.DESTROY: return 'red'; + case ResourceOperation.MODIFY: + case ResourceOperation.RECREATE: return '#d4a017'; + default: return 'white'; + } +} + +export function applyEntryChalkColor(entry: ApplyResultEntry): (s: string) => string { + if (entry.status === 'failed') return chalk.red; + if (entry.status === 'skipped') return chalk.gray; + switch (entry.operation) { + case ResourceOperation.CREATE: return chalk.green; + case ResourceOperation.DESTROY: return chalk.red; + case ResourceOperation.MODIFY: + case ResourceOperation.RECREATE: return chalk.yellow; + default: return (s) => s; + } +} diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index 2bd4a093..98d0d427 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -5,9 +5,7 @@ import { useAtom } from 'jotai'; import { EventEmitter } from 'node:events'; import React, { useLayoutEffect } from 'react'; -import { ResourceOperation } from '@codifycli/schemas'; - -import { ApplyResult, ApplyResultEntry } from '../../entities/apply-result.js'; +import { ApplyResult } from '../../entities/apply-result.js'; import { Plan, ResourcePlan } from '../../entities/plan.js'; import { prettyFormatResourcePlan } from '../plan-pretty-printer.js'; import { FileModificationResult } from '../../generators/index.js'; @@ -21,60 +19,11 @@ import { InitBanner } from './init/InitBanner.js'; import { MultiSelect } from './multi-select/MultiSelect.js'; import { PlanComponent } from './plan/plan.js'; import { ProgressDisplay } from './progress/progress-display.js'; +import { ApplyComplete } from './widgets/ApplyComplete.js'; import { PromptPressKeyToContinue } from './widgets/PromptPressKeyToContinue.js'; import { SudoPasswordInput } from './widgets/SudoPasswordInput.js'; import { TextInput } from './widgets/TextInput.js'; -function entryLabel(entry: ApplyResultEntry): string { - if (entry.status === 'failed') return 'failed'; - if (entry.status === 'skipped') return 'skipped'; - switch (entry.operation) { - case ResourceOperation.CREATE: return 'installed'; - case ResourceOperation.DESTROY: return 'destroyed'; - case ResourceOperation.MODIFY: - case ResourceOperation.RECREATE: return 'modified'; - default: return 'applied'; - } -} - -function entryColor(entry: ApplyResultEntry): string { - if (entry.status === 'failed') return 'red'; - if (entry.status === 'skipped') return 'gray'; - switch (entry.operation) { - case ResourceOperation.CREATE: return 'green'; - case ResourceOperation.DESTROY: return 'red'; - case ResourceOperation.MODIFY: - case ResourceOperation.RECREATE: return '#d4a017'; - default: return 'white'; - } -} - -function ApplyCompleteComponent({ result }: { result: ApplyResult }) { - const isPartial = result.isPartialFailure(); - return ( - - - {isPartial ? '⚠ Apply completed with errors' : 'šŸŽ‰ Finished applying šŸŽ‰'} - - {result.entries.length > 0 && ( - - {result.entries.map((entry) => ( - - {entry.id.padEnd(30)} - {entryLabel(entry)} - - ))} - - )} - {!isPartial && ( - - Open a new terminal or source '.zshrc' for the new changes to be reflected - - )} - - ); -} - export function DefaultComponent(props: { emitter: EventEmitter onWriteReady?: (write: (data: string) => void) => void @@ -96,7 +45,7 @@ export function DefaultComponent(props: { { renderStatus === RenderStatus.APPLY_COMPLETE && ( { - (result, idx) => + (result, idx) => } ) } diff --git a/src/ui/components/widgets/ApplyComplete.tsx b/src/ui/components/widgets/ApplyComplete.tsx new file mode 100644 index 00000000..5bcc8f71 --- /dev/null +++ b/src/ui/components/widgets/ApplyComplete.tsx @@ -0,0 +1,31 @@ +import { Box, Text } from 'ink'; +import React from 'react'; + +import { ApplyResult } from '../../../entities/apply-result.js'; +import { applyEntryInkColor, applyEntryLabel } from '../../apply-result-formatter.js'; + +export function ApplyComplete({ result }: { result: ApplyResult }) { + const isPartial = result.isPartialFailure(); + return ( + + + {isPartial ? '⚠ Apply completed with errors' : 'šŸŽ‰ Finished applying šŸŽ‰'} + + {result.entries.length > 0 && ( + + {result.entries.map((entry) => ( + + {entry.id.padEnd(30)} + {applyEntryLabel(entry)} + + ))} + + )} + {!isPartial && ( + + Open a new terminal or source '.zshrc' for the new changes to be reflected + + )} + + ); +} diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index c752f8e8..8dac0957 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -271,19 +271,28 @@ export class DefaultReporter implements Reporter { async displayApplyComplete(result: ApplyResult): Promise { await this.updateRenderState(RenderStatus.APPLY_COMPLETE, result); - } - async displayPluginError(errors: PluginError[]): Promise { - const validationErrors = errors.filter((e) => e.errorData.errorType === 'apply_validation'); - const genericErrors = errors.filter((e) => e.errorData.errorType !== 'apply_validation'); + if (result.isPartialFailure()) { + const validationErrors = result.errors.filter((e) => e.errorData.errorType === 'apply_validation'); + const genericErrors = result.errors.filter((e) => e.errorData.errorType !== 'apply_validation'); - if (validationErrors.length > 0) { - const resourcePlans = validationErrors.map((e) => new ResourcePlan((e.errorData.data as any).plan)); - await this.updateRenderState(RenderStatus.APPLY_VALIDATION_ERROR, resourcePlans); + if (validationErrors.length > 0) { + const resourcePlans = validationErrors.map((e) => new ResourcePlan((e.errorData.data as any).plan)); + await this.updateRenderState(RenderStatus.APPLY_VALIDATION_ERROR, resourcePlans); + } + if (genericErrors.length > 0) { + await this.updateRenderState(RenderStatus.PLUGIN_ERROR, genericErrors.map((e) => e.message)); + } } - if (genericErrors.length > 0) { - await this.updateRenderState(RenderStatus.PLUGIN_ERROR, genericErrors.map((e) => e.message)); + } + + async displayPluginError(error: PluginError): Promise { + if (error.errorData.errorType === 'apply_validation') { + const resourcePlan = new ResourcePlan((error.errorData.data as any).plan); + await this.updateRenderState(RenderStatus.APPLY_VALIDATION_ERROR, [resourcePlan]); + return; } + await 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 8f593bfd..b1b42de8 100644 --- a/src/ui/reporters/json-reporter.ts +++ b/src/ui/reporters/json-reporter.ts @@ -74,14 +74,14 @@ export class JsonReporter implements Reporter { throw new Error('Json reporter error: disableRawMode is not supported. Raw stdin mode requires interactive terminal access.'); } - async displayPluginError(errors: PluginError[]): Promise { - console.log(JSON.stringify(errors.map((error) => ({ + async displayPluginError(error: PluginError): Promise { + console.log(JSON.stringify({ errorType: error.errorData.errorType, message: error.message, pluginName: error.pluginName, resourceType: error.resourceType, data: error.errorData.data, - })), null, 2)); + }, null, 2)); } async displayApplyComplete(result: ApplyResult): Promise { @@ -92,6 +92,13 @@ export class JsonReporter implements Reporter { operation: e.operation, status: e.status, })), + errors: result.errors.map((error) => ({ + 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 f950a6de..ed903961 100644 --- a/src/ui/reporters/plain-reporter.ts +++ b/src/ui/reporters/plain-reporter.ts @@ -1,10 +1,11 @@ import chalk from 'chalk'; -import { CommandRequestData, ResourceOperation } from '@codifycli/schemas'; +import { CommandRequestData } from '@codifycli/schemas'; import readline from 'node:readline'; import { PluginError } from '../../common/errors.js'; -import { ApplyResult, ApplyResultEntry } from '../../entities/apply-result.js'; +import { ApplyResult } from '../../entities/apply-result.js'; import { Plan } from '../../entities/plan.js'; +import { applyEntryChalkColor, applyEntryLabel } from '../apply-result-formatter.js'; import { formatApplyValidationError } from '../plugin-error-formatter.js'; import { ResourceConfig } from '../../entities/resource-config.js'; import { ResourceInfo } from '../../entities/resource-info.js'; @@ -14,30 +15,6 @@ import { ImportResult } from '../../orchestrators/import.js'; import { prettyFormatPlan } from '../plan-pretty-printer.js'; import { PromptType, Reporter } from './reporter.js'; -function plainEntryLabel(entry: ApplyResultEntry): string { - if (entry.status === 'failed') return 'failed'; - if (entry.status === 'skipped') return 'skipped'; - switch (entry.operation) { - case ResourceOperation.CREATE: return 'installed'; - case ResourceOperation.DESTROY: return 'destroyed'; - case ResourceOperation.MODIFY: - case ResourceOperation.RECREATE: return 'modified'; - default: return 'applied'; - } -} - -function plainEntryColor(entry: ApplyResultEntry): (s: string) => string { - if (entry.status === 'failed') return chalk.red; - if (entry.status === 'skipped') return chalk.gray; - switch (entry.operation) { - case ResourceOperation.CREATE: return chalk.green; - case ResourceOperation.DESTROY: return chalk.red; - case ResourceOperation.MODIFY: - case ResourceOperation.RECREATE: return chalk.yellow; - default: return (s) => s; - } -} - export class PlainReporter implements Reporter { private readonly rl = readline.createInterface(process.stdin, process.stdout); silent = false; @@ -190,13 +167,11 @@ Use this init flow to get started quickly with Codify. ); } - async displayPluginError(errors: PluginError[]): Promise { - for (const error of errors) { - if (error.errorData.errorType === 'apply_validation') { - ctx.log(chalk.red(formatApplyValidationError(error))); - } else { - ctx.log(chalk.red(error.message)); - } + async displayPluginError(error: PluginError): Promise { + if (error.errorData.errorType === 'apply_validation') { + ctx.log(chalk.red(formatApplyValidationError(error))); + } else { + ctx.log(chalk.red(error.message)); } } @@ -210,13 +185,16 @@ Use this init flow to get started quickly with Codify. if (result.entries.length > 0) { ctx.log(''); for (const entry of result.entries) { - const label = plainEntryLabel(entry); - const colorFn = plainEntryColor(entry); - ctx.log(` ${entry.id.padEnd(30)}${colorFn(label)}`); + ctx.log(` ${entry.id.padEnd(30)}${applyEntryChalkColor(entry)(applyEntryLabel(entry))}`); } } - if (!result.isPartialFailure()) { + if (result.isPartialFailure()) { + ctx.log(''); + for (const error of result.errors) { + await this.displayPluginError(error); + } + } else { ctx.log(''); 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 3c32b541..f69a66a9 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -86,7 +86,7 @@ export interface Reporter { disableRawMode(): Promise - displayPluginError(errors: PluginError[]): Promise; + displayPluginError(error: PluginError): Promise; displayApplyComplete(result: ApplyResult): Promise; } diff --git a/src/ui/reporters/stub-reporter.ts b/src/ui/reporters/stub-reporter.ts index 192fe4e2..3c04cf77 100644 --- a/src/ui/reporters/stub-reporter.ts +++ b/src/ui/reporters/stub-reporter.ts @@ -27,6 +27,6 @@ export class StubReporter implements Reporter { async displayImportWarning(requiresParameters: string[], noParametersRequired: string[]): Promise {} async setRawMode(): Promise {} async disableRawMode(): Promise {} - async displayPluginError(_errors: PluginError[]): Promise {} + async displayPluginError(_error: PluginError): Promise {} async displayApplyComplete(_result: ApplyResult): Promise {} } diff --git a/test/orchestrator/mocks/reporter.ts b/test/orchestrator/mocks/reporter.ts index fe1b8510..0adb0cc1 100644 --- a/test/orchestrator/mocks/reporter.ts +++ b/test/orchestrator/mocks/reporter.ts @@ -104,7 +104,7 @@ export class MockReporter implements Reporter { this.config?.displayImportResult?.(importResult, showConfigs); } - async displayPluginError(_errors: PluginError[]): Promise {} + async displayPluginError(_error: PluginError): Promise {} async displayApplyComplete(result: ApplyResult): Promise { await this.config?.validateApplyComplete?.(result); From 7b00ac23b0f16dcf06f615b79a069dd3f508382b Mon Sep 17 00:00:00 2001 From: kevinwang Date: Mon, 27 Apr 2026 10:50:54 -0400 Subject: [PATCH 09/10] feat: Additional refactoring. Cleaned up dead APPLY_VALIDATION_ERROR dead code. Moved rendering logic to the ApplyComplete component instead. --- src/ui/components/default-component.tsx | 27 ------------- src/ui/components/widgets/ApplyComplete.tsx | 45 ++++++++++++++++++++- src/ui/reporters/default-reporter.tsx | 18 --------- src/ui/store/index.ts | 1 - 4 files changed, 44 insertions(+), 47 deletions(-) diff --git a/src/ui/components/default-component.tsx b/src/ui/components/default-component.tsx index 98d0d427..9186714e 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -59,33 +59,6 @@ export function DefaultComponent(props: { (plan, idx) => } } - { - renderStatus === RenderStatus.APPLY_VALIDATION_ERROR && ( - { - (resourcePlans, idx) => ( - - {resourcePlans.map((resourcePlan) => ( - - - {`Apply failed: resource "${resourcePlan.id}" did not reach its desired state.`} - - - Changes still needed: - {prettyFormatResourcePlan(resourcePlan)} - - - ))} - Exiting... - - 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'} - - ) - } - ) - } { renderStatus === RenderStatus.PLUGIN_ERROR && ( { diff --git a/src/ui/components/widgets/ApplyComplete.tsx b/src/ui/components/widgets/ApplyComplete.tsx index 5bcc8f71..132858ed 100644 --- a/src/ui/components/widgets/ApplyComplete.tsx +++ b/src/ui/components/widgets/ApplyComplete.tsx @@ -1,16 +1,32 @@ -import { Box, Text } from 'ink'; +import { Box, Static, Text } from 'ink'; import React from 'react'; import { ApplyResult } from '../../../entities/apply-result.js'; +import { ResourcePlan } from '../../../entities/plan.js'; import { applyEntryInkColor, applyEntryLabel } from '../../apply-result-formatter.js'; +import { prettyFormatResourcePlan } from '../../plan-pretty-printer.js'; export function ApplyComplete({ result }: { result: ApplyResult }) { const isPartial = result.isPartialFailure(); + + const validationErrors = isPartial + ? result.errors + .filter((e) => e.errorData.errorType === 'apply_validation') + .map((e) => new ResourcePlan((e.errorData.data as any).plan)) + : []; + + const genericErrors = isPartial + ? result.errors + .filter((e) => e.errorData.errorType !== 'apply_validation') + .map((e) => e.message) + : []; + return ( {isPartial ? '⚠ Apply completed with errors' : 'šŸŽ‰ Finished applying šŸŽ‰'} + {result.entries.length > 0 && ( {result.entries.map((entry) => ( @@ -21,6 +37,33 @@ export function ApplyComplete({ result }: { result: ApplyResult }) { ))} )} + + {validationErrors.length > 0 && ( + + {validationErrors.map((resourcePlan) => ( + + + {`Apply failed: resource "${resourcePlan.id}" did not reach its desired state.`} + + + Changes still needed: + {prettyFormatResourcePlan(resourcePlan)} + + + ))} + 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'} + + )} + + {genericErrors.length > 0 && ( + + {genericErrors.map((msg, i) => {msg})} + + )} + {!isPartial && ( Open a new terminal or source '.zshrc' for the new changes to be reflected diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index 8dac0957..a0f9074c 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -271,27 +271,9 @@ export class DefaultReporter implements Reporter { async displayApplyComplete(result: ApplyResult): Promise { await this.updateRenderState(RenderStatus.APPLY_COMPLETE, result); - - if (result.isPartialFailure()) { - const validationErrors = result.errors.filter((e) => e.errorData.errorType === 'apply_validation'); - const genericErrors = result.errors.filter((e) => e.errorData.errorType !== 'apply_validation'); - - if (validationErrors.length > 0) { - const resourcePlans = validationErrors.map((e) => new ResourcePlan((e.errorData.data as any).plan)); - await this.updateRenderState(RenderStatus.APPLY_VALIDATION_ERROR, resourcePlans); - } - if (genericErrors.length > 0) { - await this.updateRenderState(RenderStatus.PLUGIN_ERROR, genericErrors.map((e) => e.message)); - } - } } async displayPluginError(error: PluginError): Promise { - if (error.errorData.errorType === 'apply_validation') { - const resourcePlan = new ResourcePlan((error.errorData.data as any).plan); - await this.updateRenderState(RenderStatus.APPLY_VALIDATION_ERROR, [resourcePlan]); - return; - } await this.updateRenderState(RenderStatus.PLUGIN_ERROR, [error.message]); } diff --git a/src/ui/store/index.ts b/src/ui/store/index.ts index 2833e5e8..8d99204f 100644 --- a/src/ui/store/index.ts +++ b/src/ui/store/index.ts @@ -23,7 +23,6 @@ export enum RenderStatus { PROMPT_PRESS_KEY_TO_CONTINUE, SUDO_PROMPT, DISPLAY_MESSAGE, - APPLY_VALIDATION_ERROR, PLUGIN_ERROR, APPLY_COMPLETE, } From a528b2a6ff13a78bcb8129aa4e7129246ce0cafe Mon Sep 17 00:00:00 2001 From: kevinwang Date: Mon, 27 Apr 2026 13:56:39 -0400 Subject: [PATCH 10/10] fix: Fix /etc/os-release not present --- src/utils/os-utils.ts | 18 ++++++++++++------ src/utils/shell.ts | 18 ++++++++++++------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/utils/os-utils.ts b/src/utils/os-utils.ts index e79408e1..110a7794 100644 --- a/src/utils/os-utils.ts +++ b/src/utils/os-utils.ts @@ -170,12 +170,18 @@ export const OsUtils = { }, async getLinuxDistro(): Promise { - const osRelease = await fs.readFile('/etc/os-release', 'utf8'); - const lines = osRelease.split('\n'); - for (const line of lines) { - if (line.startsWith('ID=')) { - const distroId = line.slice(3).trim().replaceAll('"', ''); - return Object.values(LinuxDistro).includes(distroId as LinuxDistro) ? distroId as LinuxDistro : undefined; + for (const candidate of ['/etc/os-release', '/usr/lib/os-release']) { + let osRelease: string; + try { + osRelease = await fs.readFile(candidate, 'utf8'); + } catch { + continue; + } + for (const line of osRelease.split('\n')) { + if (line.startsWith('ID=')) { + const distroId = line.slice(3).trim().replaceAll('"', ''); + return Object.values(LinuxDistro).includes(distroId as LinuxDistro) ? distroId as LinuxDistro : undefined; + } } } diff --git a/src/utils/shell.ts b/src/utils/shell.ts index 5c4c45f8..29709d39 100644 --- a/src/utils/shell.ts +++ b/src/utils/shell.ts @@ -51,12 +51,18 @@ export const ShellUtils = { }, async getLinuxDistro(): Promise { - const osRelease = await fs.readFile('/etc/os-release', 'utf8'); - const lines = osRelease.split('\n'); - for (const line of lines) { - if (line.startsWith('ID=')) { - const distroId = line.slice(3).trim().replaceAll('"', ''); - return Object.values(LinuxDistro).includes(distroId as LinuxDistro) ? distroId as LinuxDistro : undefined; + for (const candidate of ['/etc/os-release', '/usr/lib/os-release']) { + let osRelease: string; + try { + osRelease = await fs.readFile(candidate, 'utf8'); + } catch { + continue; + } + for (const line of osRelease.split('\n')) { + if (line.startsWith('ID=')) { + const distroId = line.slice(3).trim().replaceAll('"', ''); + return Object.values(LinuxDistro).includes(distroId as LinuxDistro) ? distroId as LinuxDistro : undefined; + } } }