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/package-lock.json b/package-lock.json index 0d7ea324..ab8a6b82 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-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-beta5", - "resolved": "https://registry.npmjs.org/@codifycli/schemas/-/schemas-1.1.0-beta5.tgz", - "integrity": "sha512-xxfh6b48KW7Kav2uyrJb5E3dtuBDg4EUIaVKPJvJnocaaYFKGPwEUWvYwiNxqWDNBhrsbsGmNRatHGwXfhTT2A==", + "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 40e1eb20..81bd00f3 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-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 903c1e53..e4267f02 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 { PluginError, 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 PluginError && this.reporter) { + await this.reporter.hide(); + await this.reporter.displayPluginError(err); + process.exit(1); + } + prettyPrintError(err); process.exit(1); } diff --git a/src/common/errors.ts b/src/common/errors.ts index 14c9a3f4..c3acaca3 100644 --- a/src/common/errors.ts +++ b/src/common/errors.ts @@ -1,5 +1,6 @@ import { ErrorObject } from 'ajv'; import chalk from 'chalk'; +import { PluginErrorData } from '@codifycli/schemas'; import { ResourceConfig } from '../entities/resource-config.js'; import { SourceMapCache } from '../parser/source-maps.js'; @@ -231,6 +232,24 @@ export class SpawnError extends CodifyError { } } +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 { + 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/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 a1e6c15d..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,8 +194,8 @@ export class ImportOrchestrator { } // No writes - reporter.displayImportResult(importResult, true); - reporter.displayMessage('\nšŸŽ‰ Imported completed šŸŽ‰') + 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/plugins/plugin.ts b/src/plugins/plugin.ts index f6dfd568..7792be4c 100644 --- a/src/plugins/plugin.ts +++ b/src/plugins/plugin.ts @@ -1,4 +1,5 @@ import { + ErrorResponseDataSchema, GetResourceInfoResponseData, GetResourceInfoResponseDataSchema, ImportRequestData, @@ -11,6 +12,7 @@ import { PlanRequestData, PlanResponseData, PlanResponseDataSchema, + PluginErrorData, ResourceJson, ValidateResponseData, ValidateResponseDataSchema, @@ -18,9 +20,11 @@ import { import { ResourcePlan } from '../entities/plan.js'; import { ResourceConfig } from '../entities/resource-config.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); @@ -67,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)) { @@ -83,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)) { @@ -100,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)) { @@ -110,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)) { @@ -126,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)) { @@ -146,7 +146,7 @@ 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); + throw new PluginError(this.name, plan.resourceType, this.toErrorData(result.data)); } } @@ -154,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 1f0e9d02..eb248302 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,39 @@ export function DefaultComponent(props: { (plan, idx) => } } + { + renderStatus === RenderStatus.APPLY_VALIDATION_ERROR && ( + { + (resourcePlan, idx) => ( + + + {`Apply failed: resource "${resourcePlan.id}" did not reach its desired state. \nExiting...`} + + + 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'} + + + ) + } + ) + } + { + renderStatus === RenderStatus.PLUGIN_ERROR && ( + { + (message, idx) => ( + + {message} + + ) + } + ) + } { renderStatus === RenderStatus.PROMPT_CONFIRMATION && ( 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') + ',' } 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 4c417928..438eed7b 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -5,7 +5,8 @@ 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 { 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'; @@ -206,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 { @@ -222,12 +223,12 @@ 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); } - 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 { @@ -263,8 +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); + } + + 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 { @@ -378,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 f66518f8..2fe748ab 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 } 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'; @@ -8,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)); } @@ -58,7 +59,7 @@ export class JsonReporter implements Reporter { async displayFileModifications(): Promise { } - displayMessage(): void { + async displayMessage(): Promise { } async displayImportWarning(): Promise { @@ -71,4 +72,14 @@ 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.'); } + + 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)); + } } diff --git a/src/ui/reporters/plain-reporter.ts b/src/ui/reporters/plain-reporter.ts index 4169918c..e6ff264e 100644 --- a/src/ui/reporters/plain-reporter.ts +++ b/src/ui/reporters/plain-reporter.ts @@ -3,6 +3,8 @@ import { CommandRequestData } from '@codifycli/schemas'; import readline from 'node:readline'; 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'; @@ -68,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) { @@ -79,7 +81,7 @@ export class PlainReporter implements Reporter { } } - displayMessage(message: string): void { + async displayMessage(message: string): Promise { ctx.log(message); } @@ -126,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)); @@ -157,12 +159,20 @@ 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()) ); } + async displayPluginError(error: PluginError): Promise { + if (error.errorData.errorType === 'apply_validation') { + ctx.log(chalk.red(formatApplyValidationError(error))); + return; + } + ctx.log(chalk.red(error.message)); + } + 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..b08e27d3 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -1,6 +1,7 @@ import { CommandRequestData } from '@codifycli/schemas'; 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'; @@ -38,6 +39,7 @@ export enum RenderState { // TODO: instead of having GENERATE_PLAN and APPLYING APPLYING, APPLY_COMPLETE, DISPLAY_IMPORT_RESULT, + APPLY_VALIDATION_ERROR, } export enum PromptType { @@ -49,7 +51,7 @@ export enum PromptType { export interface Reporter { silent: boolean; - displayPlan(plan: Plan): void + displayPlan(plan: Plan): Promise displayInitBanner(): Promise @@ -71,17 +73,19 @@ 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 setRawMode(): Promise disableRawMode(): Promise + + displayPluginError(error: PluginError): Promise; } export enum ReporterType { diff --git a/src/ui/reporters/stub-reporter.ts b/src/ui/reporters/stub-reporter.ts index 982dd1d2..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,4 +25,5 @@ export class StubReporter implements Reporter { async displayImportWarning(requiresParameters: string[], noParametersRequired: string[]): Promise {} async setRawMode(): Promise {} async disableRawMode(): Promise {} + async displayPluginError(): Promise {} } diff --git a/src/ui/store/index.ts b/src/ui/store/index.ts index 669640b0..0de069ec 100644 --- a/src/ui/store/index.ts +++ b/src/ui/store/index.ts @@ -23,6 +23,8 @@ export enum RenderStatus { PROMPT_PRESS_KEY_TO_CONTINUE, SUDO_PROMPT, DISPLAY_MESSAGE, + APPLY_VALIDATION_ERROR, + PLUGIN_ERROR, } export const store = new class { 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); }, });