| 1 | import * as Clock from "effect/Clock"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as Prompt from "effect/cli/Prompt"; |
| 5 | import { inspect } from "node:util"; |
| 6 | import type { ActionApply, ActionDelete, CRUD, Plan } from "../Plan.ts"; |
| 7 | import { Cli, type PlanDisplayOptions } from "../Report.ts"; |
| 8 | import { canPromptOnStdin } from "../Util/interactive.ts"; |
| 9 | import { NonInteractiveTerminal } from "../Interaction.ts"; |
| 10 | import { ansiFg, colorsEnabled, theme } from "./CliKit/index.ts"; |
| 11 | import { formatElapsed } from "./Format.ts"; |
| 12 | import { |
| 13 | actionStyle, |
| 14 | applyStatusColor, |
| 15 | isTerminalStatus as isTerminal, |
| 16 | } from "./components/view/statusStyle.ts"; |
| 17 | import type { ApplyEvent, ApplyStatus } from "../Report.ts"; |
| 18 | import { formatModeNote } from "./ModeTag.ts"; |
| 19 | import { formatResourceTag } from "../Util/ResourceOutput.ts"; |
| 20 | import { |
| 21 | formatDeclaredPropertyYaml, |
| 22 | matchYamlChange, |
| 23 | matchYamlKey, |
| 24 | } from "./PropertyDiff.ts"; |
| 25 | import { actionHasPlannedWork, buildPlanSummary } from "./NamespaceTree.ts"; |
| 26 | |
| 27 | const ESC = "\x1b["; |
| 28 | const 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. |
| 31 | const useColor = colorsEnabled(); |
| 32 | const c = (code: string, s: string) => |
| 33 | useColor ? `${ESC}${code}m${s}${RESET}` : s; |
| 34 | const hex = (color: string) => (s: string) => |
| 35 | useColor ? `${ansiFg(color)}${s}${RESET}` : s; |
| 36 | const dim = (s: string) => c("2", s); |
| 37 | const bold = (s: string) => c("1", s); |
| 38 | const red = hex(theme.color.danger); |
| 39 | const green = hex(theme.color.success); |
| 40 | const blue = hex(theme.color.accent); |
| 41 | const cyan = hex(theme.color.info); |
| 42 | |
| 43 | // noop stays terminal-dim rather than brand-muted so it recedes in plain logs |
| 44 | const 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`. |
| 55 | const 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 | |
| 61 | const 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 | */ |
| 70 | const 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 | */ |
| 86 | const 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. */ |
| 92 | export 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 | |
| 195 | const 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 | |
| 210 | export 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 | ); |