1import * as Clock from "effect/Clock";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Prompt from "effect/cli/Prompt";
5import { inspect } from "node:util";
6import type { ActionApply, ActionDelete, CRUD, Plan } from "../Plan.ts";
7import { Cli, type PlanDisplayOptions } from "../Report.ts";
8import { canPromptOnStdin } from "../Util/interactive.ts";
9import { NonInteractiveTerminal } from "../Interaction.ts";
10import { ansiFg, colorsEnabled, theme } from "./CliKit/index.ts";
11import { formatElapsed } from "./Format.ts";
12import {
13 actionStyle,
14 applyStatusColor,
15 isTerminalStatus as isTerminal,
16} from "./components/view/statusStyle.ts";
17import type { ApplyEvent, ApplyStatus } from "../Report.ts";
18import { formatModeNote } from "./ModeTag.ts";
19import { formatResourceTag } from "../Util/ResourceOutput.ts";
20import {
21 formatDeclaredPropertyYaml,
22 matchYamlChange,
23 matchYamlKey,
24} from "./PropertyDiff.ts";
25import { actionHasPlannedWork, buildPlanSummary } from "./NamespaceTree.ts";
26
27const ESC = "\x1b[";
28const RESET = `${ESC}0m`;
29// Shared with every other non-ink output path — notably it honors
30// FORCE_COLOR, so lines emitted from the piped dev sidecar keep their color.
31const useColor = colorsEnabled();
32const c = (code: string, s: string) =>
33 useColor ? `${ESC}${code}m${s}${RESET}` : s;
34const hex = (color: string) => (s: string) =>
35 useColor ? `${ansiFg(color)}${s}${RESET}` : s;
36const dim = (s: string) => c("2", s);
37const bold = (s: string) => c("1", s);
38const red = hex(theme.color.danger);
39const green = hex(theme.color.success);
40const blue = hex(theme.color.accent);
41const cyan = hex(theme.color.info);
42
43// noop stays terminal-dim rather than brand-muted so it recedes in plain logs
44const actionColor: Record<CRUD["action"], (s: string) => string> = {
45 create: hex(actionStyle.create.color),
46 update: hex(actionStyle.update.color),
47 adopted: hex(actionStyle.adopted.color),
48 replace: hex(actionStyle.replace.color),
49 delete: hex(actionStyle.delete.color),
50 orphaned: hex(actionStyle.orphaned.color),
51 noop: dim,
52};
53
54// muted stays terminal-dim rather than brand-muted, matching `actionColor`.
55const statusColor = (status: ApplyStatus): ((s: string) => string) => {
56 const color = applyStatusColor(status);
57 if (color === undefined || color === theme.color.muted) return dim;
58 return hex(color);
59};
60
61const tag = formatResourceTag;
62
63/**
64 * In-progress statuses that represent real cloud work. Plain output has no
65 * spinner, so these lines are the liveness signal in CI logs
66 * (CloudFormation-style IN_PROGRESS rows). Bookkeeping transitions
67 * (`pending`, `attaching`, `post-attach`) stay silent — they resolve in
68 * milliseconds and would double the log for no information.
69 */
70const isActive = (status: ApplyStatus): boolean =>
71 status === "pre-creating" ||
72 status === "creating" ||
73 status === "creating replacement" ||
74 status === "updating" ||
75 status === "adopting" ||
76 status === "deleting" ||
77 status === "orphaning" ||
78 status === "replacing" ||
79 status === "running";
80
81/**
82 * Dim `(local)` / `(remote)` / `(local → live)` suffix for a resource row,
83 * or `""` when the row's mode matches the run default (the quiet common
84 * case). See {@link formatModeNote} for the rule.
85 */
86const modeSuffix = (options: Parameters<typeof formatModeNote>[0]): string => {
87 const note = formatModeNote(options);
88 return note ? ` ${dim(`(${note})`)}` : "";
89};
90
91/** Exported for unit tests — pure plan-preview rendering. */
92export const formatPlanLines = (
93 plan: Plan,
94 options: PlanDisplayOptions = {},
95): string[] => {
96 const allItems = [
97 ...Object.values(plan.resources),
98 ...Object.values(plan.deletions),
99 ] as CRUD[];
100 const tasks = [
101 ...Object.values(plan.actions ?? {}),
102 ...Object.values(plan.actionDeletions ?? {}),
103 ]
104 .filter((task): task is ActionApply | ActionDelete => task !== undefined)
105 .filter(actionHasPlannedWork);
106 if (allItems.length === 0 && tasks.length === 0) {
107 return [`${bold("Plan:")} ${dim("no resources")}`];
108 }
109
110 const { counts, bindingChanges } = buildPlanSummary(plan);
111 const summaryParts = (
112 ["create", "update", "adopted", "replace", "delete", "orphaned"] as const
113 )
114 .filter((a) => counts[a])
115 .map((a) => actionColor[a](`${counts[a]} to ${a}`));
116 if (bindingChanges > 0) {
117 summaryParts.push(cyan(`${bindingChanges} binding changes`));
118 }
119 if (tasks.length > 0) {
120 summaryParts.push(cyan(`${tasks.length} tasks`));
121 }
122 const summary =
123 summaryParts.length === 0
124 ? dim("no changes")
125 : summaryParts.join(dim(", "));
126
127 const sorted = [...allItems].sort((a, b) =>
128 a.resource.LogicalId.localeCompare(b.resource.LogicalId),
129 );
130 const lines = [`${bold("Plan:")} ${summary}`];
131 if (options.detailed && sorted.length > 0) lines.push("");
132 for (const [index, item] of sorted.entries()) {
133 if (options.detailed && index > 0) lines.push("");
134 const action = actionColor[item.action](item.action);
135 const mode = modeSuffix({
136 mode: item.mode,
137 priorMode:
138 item.action === "replace" ? item.state.providerMode : undefined,
139 defaultMode: plan.defaultMode,
140 });
141 // Surface FQN migrations: `[Assets] update (renamed from Bucket)` —
142 // the update exists to re-brand the moved row's physical resource, and
143 // without the note the plan gives no hint why an untouched resource
144 // reconciles. (Only apply-side nodes can carry a rename — see
145 // `ApplyNodeBase`.)
146 const renamed =
147 "renamedFrom" in item && item.renamedFrom?.length
148 ? ` ${dim(`(renamed from ${item.renamedFrom.join(", ")})`)}`
149 : "";
150 lines.push(`${tag(item.resource.FQN)} ${action}${mode}${renamed}`);
151 for (const binding of [...item.bindings].sort((a, b) =>
152 a.sid.localeCompare(b.sid),
153 )) {
154 const bindingAction =
155 binding.action === "delete"
156 ? dim("unbind")
157 : actionColor[binding.action](binding.action);
158 lines.push(
159 `${tag(`${item.resource.FQN}/${binding.sid}`)} ${bindingAction}`,
160 );
161 }
162 if (
163 options.detailed &&
164 (item.action === "create" ||
165 item.action === "update" ||
166 item.action === "adopted" ||
167 item.action === "replace")
168 ) {
169 const document = formatDeclaredPropertyYaml(
170 item.action === "create" ? {} : item.state.props,
171 item.props,
172 item.action === "adopted" ? "update" : item.action,
173 );
174 if (document === undefined) {
175 lines.push(` ${dim("no declared property changes")}`);
176 } else {
177 lines.push(
178 ...document.lines.map(
179 (line) => ` ${colorYamlLine(line, document.kind)}`,
180 ),
181 );
182 }
183 }
184 }
185 for (const task of tasks.sort((a, b) =>
186 a.def.LogicalId.localeCompare(b.def.LogicalId),
187 )) {
188 lines.push(
189 `${tag(task.def.FQN)} ${cyan(task.action === "delete" ? "drop" : "run")} ${dim("[action]")}`,
190 );
191 }
192 return lines;
193};
194
195const colorYamlLine = (
196 line: string,
197 kind: "create" | "change" | "drift",
198): string => {
199 const change = matchYamlChange(line);
200 if (change !== undefined) {
201 const color = change.marker === "-" ? red : green;
202 return color(`${change.marker} ${change.content}`);
203 }
204 const key = matchYamlKey(line);
205 if (key === undefined) return line;
206 const color = kind === "create" ? green : cyan;
207 return `${key.indent}${color(key.key)}${key.value}`;
208};
209
210export const LoggingCli = Layer.effect(
211 Cli,
212 Effect.gen(function* () {
213 // Captured at layer build so `approvePlan` keeps the CLIService signature
214 // (no requirements on the returned Effects).
215 const promptContext = yield* Effect.context<Prompt.Environment>();
216 const confirm = (options: Prompt.ConfirmOptions) =>
217 Prompt.Confirm(options).pipe(
218 Effect.catchTag("QuitError", () => Effect.succeed(false)),
219 Effect.provideContext(promptContext),
220 );
221 return Cli.of({
222 startPlanningSession: (label, _detail, title) =>
223 Effect.gen(function* () {
224 // Same information as the interactive session, one plain line per
225 // change: the title header carries the operation + stage once, each
226 // phase prints as it starts, and the settle line carries the total
227 // planning time (the spinner's implicit "how long did that take").
228 const startedAt = yield* Clock.currentTimeMillis;
229 if (title !== undefined) yield* Effect.logInfo(bold(title));
230 yield* Effect.logInfo(dim(label));
231 let closed = false;
232 let lastLabel = label;
233 const write = (nextLabel: string, _nextDetail?: string) =>
234 Effect.suspend(() => {
235 // Repeated updates within one phase (spinner detail refreshes)
236 // carry nothing new for a line-per-change log.
237 if (closed || nextLabel === lastLabel) return Effect.void;
238 lastLabel = nextLabel;
239 return Effect.logInfo(dim(nextLabel));
240 });
241 const finish = (message: string, paint: (s: string) => string) =>
242 Effect.suspend(() => {
243 if (closed) return Effect.void;
244 closed = true;
245 return Clock.currentTimeMillis.pipe(
246 Effect.flatMap((now) =>
247 Effect.logInfo(
248 `${paint(message)} ${dim(`(${formatElapsed(now - startedAt)})`)}`,
249 ),
250 ),
251 );
252 });
253 return {
254 update: write,
255 succeed: (message = "Plan ready") => finish(message, green),
256 fail: (message = "Planning failed") => finish(message, red),
257 close: Effect.sync(() => {
258 closed = true;
259 }),
260 };
261 }),
262 approvePlan: (plan, options) =>
263 Effect.gen(function* () {
264 for (const line of formatPlanLines(plan, options))
265 yield* Effect.logInfo(line);
266 if (canPromptOnStdin()) {
267 return yield* confirm(
268 plan.destroy === true
269 ? { message: "Destroy these resources?", initial: false }
270 : { message: "Apply this plan?", initial: true },
271 );
272 }
273 return yield* Effect.fail(
274 new NonInteractiveTerminal({
275 operation: "approve deployment plan",
276 message:
277 "Cannot approve this operation without terminal input. Pass --yes to continue.",
278 }),
279 );
280 }),
281 displayPlan: (plan, options) =>
282 Effect.gen(function* () {
283 for (const line of formatPlanLines(plan, options))
284 yield* Effect.logInfo(line);
285 }),
286 startApplySession: (plan, options) =>
287 Effect.gen(function* () {
288 for (const line of formatPlanLines(plan, options))
289 yield* Effect.logInfo(line);
290 yield* Effect.logInfo("");
291
292 const startedAt = yield* Clock.currentTimeMillis;
293 const started = new Map<string, number>();
294 const terminal = new Map<string, ApplyStatus>();
295 const notes = new Map<string, string>();
296 const statusNotes = new Map<string, string>();
297 return {
298 setOutput: (value: unknown) =>
299 Effect.logInfo(inspect(value, { colors: false })),
300 // Progress is an Effect log record, just like provider and build
301 // diagnostics, so the append-only renderer gives every line the
302 // same timestamp / level / fiber prefix.
303 emit: (event: ApplyEvent) =>
304 Clock.currentTimeMillis.pipe(
305 Effect.flatMap((now) =>
306 Effect.suspend(() => {
307 if (event._tag === "apply.resource.note") {
308 // `status` notes only surface as the settle line's suffix;
309 // everything else streams, deduping consecutive repeats.
310 if (event.kind === "status") {
311 statusNotes.set(event.fqn, event.message);
312 return Effect.void;
313 }
314 if (notes.get(event.fqn) === event.message)
315 return Effect.void;
316 notes.set(event.fqn, event.message);
317 return Effect.logInfo(
318 `${tag(event.fqn)} ${blue(event.message)}`,
319 );
320 }
321 const id = event.fqn;
322 const mode = modeSuffix({
323 mode: event.providerMode,
324 priorMode: event.fromProviderMode,
325 defaultMode: plan.defaultMode,
326 });
327 if (!isTerminal(event.status)) {
328 if (!isActive(event.status)) return Effect.void;
329 if (!started.has(id)) started.set(id, now);
330 return Effect.logInfo(
331 `${tag(id)} ${dim(event.status)}${mode}`,
332 );
333 }
334 terminal.set(id, event.status);
335 const status = statusColor(event.status)(event.status);
336 const from = started.get(id);
337 const took =
338 from === undefined
339 ? ""
340 : ` ${dim(`(${formatElapsed(now - from)})`)}`;
341 const message = event.message ?? statusNotes.get(event.fqn);
342 // Failures carry their error as the message — paint it
343 // red so the line reads as the error line for that
344 // resource, without waiting for the final cause dump.
345 const msg = message
346 ? ` ${dim("—")} ${event.status === "fail" ? red(message) : message}`
347 : "";
348 return Effect.logInfo(
349 `${tag(id)} ${status}${mode}${msg}${took}`,
350 );
351 }),
352 ),
353 ),
354 done: (outcome) =>
355 Clock.currentTimeMillis.pipe(
356 Effect.flatMap((now) => {
357 const statuses = [...terminal.values()];
358 const failed = statuses.filter((s) => s === "fail").length;
359 const skipped = statuses.filter(
360 (s) => s === "skipped",
361 ).length;
362 const succeeded = terminal.size - failed - skipped;
363 const parts = [green(`${succeeded} succeeded`)];
364 if (failed) parts.push(red(`${failed} failed`));
365 if (skipped) parts.push(dim(`${skipped} skipped`));
366 return Effect.logInfo("").pipe(
367 Effect.andThen(
368 Effect.logInfo(
369 `${bold(outcome === "success" ? "Done:" : "Failed:")} ${parts.join(dim(", "))} ${dim(`(${formatElapsed(now - startedAt)})`)}`,
370 ),
371 ),
372 );
373 }),
374 ),
375 };
376 }),
377 });
378 }),
379);