Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/handbuch/dynamic-groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ sources:
- src/config/query-refs.ts
- src/engine/dynamic.ts
- src/engine/synthetic.ts
- src/commands/adopt-group.ts
sources_hash: 437e71c4f4f8bf36
- src/application/operations/adopt-group.ts
sources_hash: c49d089ed6662ea7
reviewed: 2026-08-17
---

Expand Down
6 changes: 3 additions & 3 deletions docs/handbuch/group-member-fields.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
---
sources_hash: a5335e766218b514
sources_hash: 984c67a4f952f9fc
title: Group member fields
sources:
- src/engine/member-fields.ts
- src/engine/synthetic.ts
- src/config/context.ts
- src/commands/adopt-group.ts
- src/commands/destroy.ts
- src/application/operations/adopt-group.ts
- src/application/operations/destroy.ts
reviewed: 2026-08-26
---

Expand Down
434 changes: 434 additions & 0 deletions docs/superpowers/plans/2026-08-23-ui-core-projection.md

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions src/api/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Error rendering shared by every adapter and by the application layer.
*
* This lives outside `src/ui.ts` on purpose: the application layer must not import the terminal
* presenter, but it still has to produce the SAME error text — an HTTP status plus a truncated
* response body (#50, #71) — in the messages it returns to whichever adapter is listening.
*/
import { CtApiError } from "./ctClient.js";

/** Response bodies beyond this are truncated so a huge HTML/JSON dump doesn't flood the terminal. */
const MAX_BODY_CHARS = 2000;

function formatBody(body: unknown): string {
if (body === null || body === undefined) {
return "";
}
const text = typeof body === "string" ? body : JSON.stringify(body, null, 2);
if (text.length > MAX_BODY_CHARS) {
return `${text.slice(0, MAX_BODY_CHARS)}\n… (truncated, ${text.length} chars total)`;
}
return text;
}

/**
* Render a caught error. For {@link CtApiError} this surfaces the HTTP status + response body —
* without it, a failing `ct get raw` (or any API call) prints only "✗ GET ... failed" with no way
* to see what ChurchTools actually said (#50).
*/
export function formatError(err: unknown): string {
if (err instanceof CtApiError) {
const body = formatBody(err.body);
return `${err.message} (HTTP ${err.status})${body ? `\n${body}` : ""}`;
}
return err instanceof Error ? err.message : String(err);
}
65 changes: 65 additions & 0 deletions src/application/contracts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/** JSON-compatible values used at the application/adapter boundary. */
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };

export type OperationName =
"plan" | "apply" | "coverage" | "adopt" | "state" | "refresh" | "destroy" | "auth";

/** Common project selection accepted by CLI and, later, HTTP adapters. */
export interface ProjectRequest {
cwd?: string;
configPath?: string;
statePath?: string;
environment?: string;
}

/** Public, non-secret project context resolved before an operation starts. */
export interface ResolvedProjectInfo {
cwd: string;
/** Absolute paths used by operations. */
configPath: string;
statePath: string;
environmentsPath: string;
/** Effective flag/env/default spelling retained for byte-compatible CLI messages. */
configDisplayPath: string;
stateDisplayPath: string;
environment: string | null;
protected: boolean;
host: string;
}

export interface CtWarning {
code: string;
message: string;
details?: Record<string, JsonValue>;
}

export interface OperationResult<T> {
operation: OperationName;
project: ResolvedProjectInfo;
value: T;
warnings: CtWarning[];
}

/**
* One completed unit of work, reported the moment it happens.
*
* Every long-running operation emits these as it goes instead of only returning them at the end:
* a `ct destroy` that dies halfway through must still have said which resources it already
* deleted, and a fan-out caution has to reach the operator BEFORE the fan-out runs (#156 review).
*/
export interface OperationOutcomeEvent {
/** `ok` reads as a success line, `note` as neutral information, `failed` as an error. */
status: "ok" | "note" | "failed";
message: string;
}

export type OperationEvent =
| { type: "phase-started"; phase: string }
| { type: "resource-reading"; resourceType: string; key: string }
| { type: "resource-created"; resourceType: string; key: string; id: number }
| { type: "resource-updated"; resourceType: string; key: string; id: number }
| { type: "resource-destroyed"; resourceType: string; key: string; id: number }
| { type: "backup-written"; path: string }
| { type: "warning"; warning: CtWarning }
| { type: "outcome"; outcome: OperationOutcomeEvent };
41 changes: 41 additions & 0 deletions src/application/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { JsonValue } from "./contracts.js";

export const APPLICATION_ERROR_CODES = [
"PLAN_INCOMPLETE",
"AUTH_REQUIRED",
"HOST_MISMATCH",
"PROTECTED_ENV_CONFIRMATION_REQUIRED",
"PLAN_CONFIRMATION_MISMATCH",
"PREVENT_DESTROY",
"DESTROY_BACKUP_FAILED",
"OPERATION_EXPIRED",
"OPERATION_ALREADY_USED",
"MUTATION_BUSY",
] as const;

export type ApplicationErrorCode = (typeof APPLICATION_ERROR_CODES)[number];

/** Stable application error translated independently by CLI, HTTP and UI adapters. */
export class CtApplicationError extends Error {
readonly code: ApplicationErrorCode;
readonly details?: Record<string, JsonValue>;

constructor(
code: ApplicationErrorCode,
message: string,
options: { details?: Record<string, JsonValue>; cause?: unknown } = {},
) {
super(message, options.cause === undefined ? undefined : { cause: options.cause });
this.name = "CtApplicationError";
this.code = code;
this.details = options.details;
}

toJSON(): { code: ApplicationErrorCode; message: string; details?: Record<string, JsonValue> } {
return {
code: this.code,
message: this.message,
...(this.details ? { details: this.details } : {}),
};
}
}
Loading
Loading