Skip to content

Commit baf0b0f

Browse files
Kevin/improved errors (#61)
* feat: Improved apply validation errors * feat: Refactored to share the same plugin error type. Use the reporter to determine how to present it. * fix: Fixed display functions to be async * feat: Improve the copy and formatting for apply validation error. Fix pretty print error for NOOP. Added to CLAUDE.md * feat: Improved copy again * feat: Added partial applys. If a resource fails to apply, skip it and all transitive dependent resources. Apply un-related resources. * 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. * feat: refactored the code so that the final apply message is handled entirely inside the reporters. * feat: Additional refactoring. Cleaned up dead APPLY_VALIDATION_ERROR dead code. Moved rendering logic to the ApplyComplete component instead. * fix: Fix /etc/os-release not present
1 parent 9eab689 commit baf0b0f

31 files changed

Lines changed: 535 additions & 109 deletions

CLAUDE.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,4 +184,27 @@ Parent Process Plugin Process
184184
3. **Plugin IPC**: Plugins cannot directly read stdin (security isolation)
185185
4. **Sudo Caching**: Password cached in memory during session unless `--secure` flag used
186186
5. **File Watcher**: Use `persistent: false` option to prevent hanging processes
187-
6. **Linting**: ESLint enforces single quotes, specific import ordering, and strict type safety
187+
6. **Linting**: ESLint enforces single quotes, specific import ordering, and strict type safety
188+
7. **Reporter display methods are async**: All `Reporter` interface display methods (`displayPlan`, `displayImportResult`, `displayFileModifications`, `displayMessage`, `displayPluginError`) return `Promise<void>`. 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.
189+
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.
190+
191+
## Plugin Error Handling Architecture
192+
193+
Plugin errors flow as structured `PluginErrorData` over IPC and are caught as `PluginError` instances on the CLI side:
194+
195+
**IPC envelope** (`@codifycli/schemas`):
196+
```typescript
197+
interface PluginErrorData {
198+
errorType: string; // 'apply_validation' | 'sudo_error' | 'unknown'
199+
message: string;
200+
data?: unknown;
201+
}
202+
```
203+
204+
**CLI carrier** (`src/common/errors.ts`): `PluginError extends CodifyError` holds `pluginName`, `resourceType`, and `errorData: PluginErrorData`.
205+
206+
**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.
207+
208+
**Shared formatter**: `src/ui/plugin-error-formatter.ts` exports `formatApplyValidationError(error: PluginError): string` used by both `PlainReporter` and `DefaultComponent`.
209+
210+
**Backward compat**: `plugin.ts#toErrorData()` validates IPC data against `ErrorResponseDataSchema` (AJV); falls back to `{ errorType: 'unknown', message: data }` for old plugins sending bare strings.

package-lock.json

Lines changed: 26 additions & 22 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
},
66
"dependencies": {
77
"@codifycli/ink-form": "0.0.12",
8-
"@codifycli/schemas": "1.1.0-beta5",
8+
"@codifycli/schemas": "1.1.0-beta8",
99
"@homebridge/node-pty-prebuilt-multiarch": "^0.12.0-beta.5",
1010
"@mischnic/json-sourcemap": "^0.1.1",
1111
"@oclif/core": "^4.0.8",
@@ -43,7 +43,7 @@
4343
},
4444
"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.",
4545
"devDependencies": {
46-
"@codifycli/plugin-core": "^1.1.0-beta13",
46+
"@codifycli/plugin-core": "^1.1.0-beta19",
4747
"@oclif/prettier-config": "^0.2.1",
4848
"@types/chalk": "^2.2.0",
4949
"@types/cors": "^2.8.19",

src/common/base-command.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { DefaultReporter } from '../ui/reporters/default-reporter.js';
1111
import { Reporter, ReporterFactory, ReporterType } from '../ui/reporters/reporter.js';
1212
import { spawnSafe } from '../utils/spawn.js';
1313
import { SudoUtils } from '../utils/sudo.js';
14-
import { prettyPrintError } from './errors.js';
14+
import { PluginError, prettyPrintError } from './errors.js';
1515

1616
export abstract class BaseCommand extends Command {
1717
static baseFlags = {
@@ -145,6 +145,11 @@ export abstract class BaseCommand extends Command {
145145
}
146146

147147
protected async catch(err: Error): Promise<void> {
148+
if (err instanceof PluginError && this.reporter) {
149+
await this.reporter.displayPluginError(err);
150+
process.exit(1);
151+
}
152+
148153
prettyPrintError(err);
149154
process.exit(1);
150155
}

src/common/errors.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ErrorObject } from 'ajv';
22
import chalk from 'chalk';
3+
import { PluginErrorData } from '@codifycli/schemas';
34

45
import { ResourceConfig } from '../entities/resource-config.js';
56
import { SourceMapCache } from '../parser/source-maps.js';
@@ -231,6 +232,24 @@ export class SpawnError extends CodifyError {
231232
}
232233
}
233234

235+
export class PluginError extends CodifyError {
236+
name = 'PluginError';
237+
pluginName: string;
238+
resourceType: string;
239+
errorData: PluginErrorData;
240+
241+
constructor(pluginName: string, resourceType: string, errorData: PluginErrorData) {
242+
super(errorData.message);
243+
this.pluginName = pluginName;
244+
this.resourceType = resourceType;
245+
this.errorData = errorData;
246+
}
247+
248+
formattedMessage(): string {
249+
return this.message;
250+
}
251+
}
252+
234253
export function prettyPrintError(error: unknown): void {
235254
if (error instanceof CodifyError) {
236255
return console.error(chalk.red(error.formattedMessage()));

src/entities/apply-result.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { ResourceOperation } from '@codifycli/schemas';
2+
3+
import { PluginError } from '../common/errors.js';
4+
import { ResourcePlan } from './plan.js';
5+
6+
export interface ApplyResultEntry {
7+
id: string;
8+
operation: ResourceOperation;
9+
status: 'success' | 'failed' | 'skipped';
10+
error?: PluginError;
11+
}
12+
13+
export interface ApplyResult {
14+
entries: ApplyResultEntry[];
15+
errors: PluginError[];
16+
17+
isPartialFailure(): boolean;
18+
}
19+
20+
export function createApplyResult(
21+
succeededPlans: ResourcePlan[],
22+
failedErrors: PluginError[],
23+
skippedIds: Set<string>,
24+
): ApplyResult {
25+
const failedByType = new Map(failedErrors.map((e) => [e.resourceType, e]));
26+
27+
const entries: ApplyResultEntry[] = [
28+
...succeededPlans.map((p) => ({
29+
id: p.id,
30+
operation: p.operation,
31+
status: 'success' as const,
32+
})),
33+
...failedErrors.map((e) => ({
34+
id: e.resourceType,
35+
operation: ResourceOperation.NOOP,
36+
status: 'failed' as const,
37+
error: e,
38+
})),
39+
...[...skippedIds].map((id) => ({
40+
id,
41+
operation: ResourceOperation.NOOP,
42+
status: 'skipped' as const,
43+
})),
44+
];
45+
46+
return {
47+
entries,
48+
errors: failedErrors,
49+
isPartialFailure() {
50+
return failedErrors.length > 0;
51+
},
52+
};
53+
}

src/entities/plan.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,30 @@ export class Plan {
5454
return this.raw.every((r) => r.operation === ResourceOperation.NOOP);
5555
}
5656

57+
computeTransitiveDependents(failedId: string): Set<string> {
58+
const reverseDeps = new Map<string, Set<string>>();
59+
for (const r of this.project.resourceConfigs) {
60+
for (const depId of r.dependencyIds) {
61+
if (!reverseDeps.has(depId)) reverseDeps.set(depId, new Set());
62+
reverseDeps.get(depId)!.add(r.id);
63+
}
64+
}
65+
66+
const toSkip = new Set<string>();
67+
const queue = [failedId];
68+
while (queue.length > 0) {
69+
const current = queue.shift()!;
70+
const dependents = reverseDeps.get(current) ?? new Set();
71+
for (const dep of dependents) {
72+
if (!toSkip.has(dep)) {
73+
toSkip.add(dep);
74+
queue.push(dep);
75+
}
76+
}
77+
}
78+
return toSkip;
79+
}
80+
5781
*[Symbol.iterator](): Iterator<ResourcePlan> {
5882
for (const resource of this.resources) {
5983
yield resource;

src/orchestrators/apply.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { ProcessName, ctx } from '../events/context.js';
22
import { DefaultReporter } from '../ui/reporters/default-reporter.js';
33
import { Reporter } from '../ui/reporters/reporter.js';
4-
import { sleep } from '../utils/index.js';
54
import { VerbosityLevel } from '../utils/verbosity-level.js';
65
import { PlanOrchestrator } from './plan.js';
76

@@ -29,7 +28,7 @@ export const ApplyOrchestrator = {
2928
return process.exit(0);
3029
}
3130
}
32-
31+
3332
const { plan, pluginManager, project } = planResult;
3433
const filteredPlan = plan.filterNoopResources()
3534

@@ -44,14 +43,14 @@ export const ApplyOrchestrator = {
4443
if (!args.noProgress) ctx.processStarted(ProcessName.APPLY);
4544
if (!args.noProgress) await reporter.displayProgress();
4645

47-
await pluginManager.apply(project, filteredPlan);
46+
const applyResult = await pluginManager.apply(project, filteredPlan);
47+
4848
if (!args.noProgress) ctx.processFinished(ProcessName.APPLY);
4949

50-
// Need to sleep to wait for the message to display before we exit
51-
await sleep(100);
50+
await reporter.displayApplyComplete(applyResult);
5251

53-
reporter.displayMessage(`
54-
🎉 Finished applying 🎉
55-
Open a new terminal or source '.zshrc' for the new changes to be reflected`);
52+
if (applyResult.isPartialFailure()) {
53+
process.exit(1);
54+
}
5655
},
5756
};

src/orchestrators/destroy.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export class DestroyOrchestrator {
4242
plan.sortByEvalOrder(project.evaluationOrder);
4343
destroyProject.removeNoopFromEvaluationOrder(plan);
4444

45-
reporter.displayPlan(plan);
45+
await reporter.displayPlan(plan);
4646

4747
// Short circuit and exit if every change is NOOP
4848
if (plan.isEmpty()) {
@@ -70,13 +70,15 @@ export class DestroyOrchestrator {
7070
}
7171

7272
await reporter.displayProgress();
73-
await ctx.process(ProcessName.DESTROY, () =>
73+
const applyResult = await ctx.process(ProcessName.DESTROY, () =>
7474
pluginManager.apply(destroyProject, filteredPlan)
7575
)
7676

77-
await reporter.displayMessage(`
78-
🎉 Finished applying 🎉
79-
Open a new terminal or source '.zshrc' for the new changes to be reflected`);
77+
await reporter.displayApplyComplete(applyResult);
78+
79+
if (applyResult.isPartialFailure()) {
80+
process.exit(1);
81+
}
8082
}
8183

8284
/** This method is responsible for generating a plan for specific resources specified by the user */

0 commit comments

Comments
 (0)