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..a0a4a597 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,11 @@ export abstract class BaseCommand extends Command { } protected async catch(err: Error): Promise { + if (err instanceof PluginError && this.reporter) { + 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/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/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 d79dc22f..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'; @@ -29,7 +28,7 @@ export const ApplyOrchestrator = { return process.exit(0); } } - + const { plan, pluginManager, project } = planResult; const filteredPlan = plan.filterNoopResources() @@ -44,14 +43,14 @@ export const ApplyOrchestrator = { if (!args.noProgress) ctx.processStarted(ProcessName.APPLY); if (!args.noProgress) await reporter.displayProgress(); - await pluginManager.apply(project, filteredPlan); + 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); - reporter.displayMessage(` -šŸŽ‰ Finished applying šŸŽ‰ -Open a new terminal or source '.zshrc' for the new changes to be reflected`); + if (applyResult.isPartialFailure()) { + process.exit(1); + } }, }; diff --git a/src/orchestrators/destroy.ts b/src/orchestrators/destroy.ts index e40ca8b5..7d35bf83 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()) { @@ -70,13 +70,15 @@ 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()) { + process.exit(1); + } } /** This method is responsible for generating a plan for specific resources specified by the user */ 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-manager.ts b/src/plugins/plugin-manager.ts index 39879c4f..6dfedf20 100644 --- a/src/plugins/plugin-manager.ts +++ b/src/plugins/plugin-manager.ts @@ -4,8 +4,9 @@ import { ValidateResponseData, } from '@codifycli/schemas'; -import { InternalError } 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,8 +138,18 @@ 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)) { + 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 +163,23 @@ 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); + succeededPlans.push(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); } + + return createApplyResult(succeededPlans, collectedErrors, skippedIds); } async setVerbosityLevel(verbosityLevel: number): Promise { 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/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 1f0e9d02..9186714e 100644 --- a/src/ui/components/default-component.tsx +++ b/src/ui/components/default-component.tsx @@ -5,7 +5,9 @@ import { useAtom } from 'jotai'; import { EventEmitter } from 'node:events'; import React, { useLayoutEffect } from 'react'; -import { Plan } from '../../entities/plan.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'; import { ImportResult } from '../../orchestrators/import.js'; import { RenderEvent } from '../reporters/reporter.js'; @@ -17,6 +19,7 @@ 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'; @@ -39,6 +42,13 @@ export function DefaultComponent(props: { {renderData as string} ) } + { + renderStatus === RenderStatus.APPLY_COMPLETE && ( + { + (result, idx) => + } + ) + } { renderStatus === RenderStatus.PROGRESS && ( @@ -49,6 +59,17 @@ export function DefaultComponent(props: { (plan, idx) => } } + { + renderStatus === RenderStatus.PLUGIN_ERROR && ( + { + (messages, idx) => ( + + {messages.map((msg, i) => {msg})} + + ) + } + ) + } { renderStatus === RenderStatus.PROMPT_CONFIRMATION && ( diff --git a/src/ui/components/widgets/ApplyComplete.tsx b/src/ui/components/widgets/ApplyComplete.tsx new file mode 100644 index 00000000..132858ed --- /dev/null +++ b/src/ui/components/widgets/ApplyComplete.tsx @@ -0,0 +1,74 @@ +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) => ( + + {entry.id.padEnd(30)} + {applyEntryLabel(entry)} + + ))} + + )} + + {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/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..a0f9074c 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -5,7 +5,9 @@ import { EventEmitter } from 'node:events'; import React from 'react'; import stripAnsi from 'strip-ansi' -import { Plan } 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'; @@ -206,11 +208,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 +224,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 +265,16 @@ 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 displayApplyComplete(result: ApplyResult): Promise { + await this.updateRenderState(RenderStatus.APPLY_COMPLETE, result); + } + + async displayPluginError(error: PluginError): Promise { + 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..b1b42de8 100644 --- a/src/ui/reporters/json-reporter.ts +++ b/src/ui/reporters/json-reporter.ts @@ -1,5 +1,7 @@ import { CommandRequestData } from '@codifycli/schemas'; +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'; @@ -8,7 +10,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 +60,7 @@ export class JsonReporter implements Reporter { async displayFileModifications(): Promise { } - displayMessage(): void { + async displayMessage(): Promise { } async displayImportWarning(): Promise { @@ -71,4 +73,32 @@ 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)); + } + + 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, + })), + 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 4169918c..ed903961 100644 --- a/src/ui/reporters/plain-reporter.ts +++ b/src/ui/reporters/plain-reporter.ts @@ -2,7 +2,11 @@ import chalk from 'chalk'; import { CommandRequestData } from '@codifycli/schemas'; import readline from 'node:readline'; +import { PluginError } from '../../common/errors.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'; import { Event, ctx } from '../../events/context.js'; @@ -68,7 +72,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 +83,7 @@ export class PlainReporter implements Reporter { } } - displayMessage(message: string): void { + async displayMessage(message: string): Promise { ctx.log(message); } @@ -126,7 +130,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,14 +161,42 @@ 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()) ); } - 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 displayPluginError(error: PluginError): Promise { + if (error.errorData.errorType === 'apply_validation') { + ctx.log(chalk.red(formatApplyValidationError(error))); + } else { + ctx.log(chalk.red(error.message)); + } + } + + 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) { + ctx.log(` ${entry.id.padEnd(30)}${applyEntryChalkColor(entry)(applyEntryLabel(entry))}`); + } + } + + 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 91c548db..f69a66a9 100644 --- a/src/ui/reporters/reporter.ts +++ b/src/ui/reporters/reporter.ts @@ -1,5 +1,7 @@ import { CommandRequestData } from '@codifycli/schemas'; +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'; @@ -38,6 +40,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 +52,7 @@ export enum PromptType { export interface Reporter { silent: boolean; - displayPlan(plan: Plan): void + displayPlan(plan: Plan): Promise displayInitBanner(): Promise @@ -71,17 +74,21 @@ 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; + + displayApplyComplete(result: ApplyResult): Promise; } export enum ReporterType { diff --git a/src/ui/reporters/stub-reporter.ts b/src/ui/reporters/stub-reporter.ts index 982dd1d2..3c04cf77 100644 --- a/src/ui/reporters/stub-reporter.ts +++ b/src/ui/reporters/stub-reporter.ts @@ -1,5 +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'; @@ -8,7 +10,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 +27,6 @@ export class StubReporter implements Reporter { async displayImportWarning(requiresParameters: string[], noParametersRequired: string[]): Promise {} async setRawMode(): Promise {} async disableRawMode(): Promise {} + async displayPluginError(_error: PluginError): Promise {} + async displayApplyComplete(_result: ApplyResult): Promise {} } diff --git a/src/ui/store/index.ts b/src/ui/store/index.ts index 669640b0..8d99204f 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, + PLUGIN_ERROR, + APPLY_COMPLETE, } export const store = new class { 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; + } } } 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/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); }, }); diff --git a/test/orchestrator/mocks/reporter.ts b/test/orchestrator/mocks/reporter.ts index bc9ce05f..0adb0cc1 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(_error: PluginError): Promise {} + + async displayApplyComplete(result: ApplyResult): Promise { + await this.config?.validateApplyComplete?.(result); + } + + async setRawMode(): Promise {} + + async disableRawMode(): Promise {} }