1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3import * as Command from "effect/cli/Command";
4import * as Flag from "effect/cli/Flag";
5
6import * as Drift from "../../Alchemist/routes/drift.ts";
7import * as Stacks from "../../Alchemist/routes/stack.ts";
8import { Cli } from "../../Report.ts";
9import * as CliKit from "../CliKit/index.ts";
10import { planDecisionScreen } from "../components/view/PlanDecision.tsx";
11import { stackOutputsView } from "../components/view/StackOutputs.tsx";
12
13import { exitDeclined } from "./errors.ts";
14import {
15 configPath,
16 dryRun as dryRunFlag,
17 envFile,
18 force,
19 include,
20 exclude,
21 validateSelectionOptions,
22 optionalConfig,
23 profile,
24 resolveStackArgs,
25 stage,
26 yes,
27} from "./flags.ts";
28import { instrumentCommand } from "./instrument.ts";
29import { renderApply, renderPlanning } from "./render.ts";
30
31interface StackCommandOptions {
32 readonly main: string;
33 readonly stage: string;
34 readonly envFile: Option.Option<string>;
35 readonly profile?: string;
36 readonly dryRun?: boolean;
37 readonly force?: boolean;
38 readonly include?: ReadonlyArray<string>;
39 readonly exclude?: ReadonlyArray<string>;
40 readonly yes?: boolean;
41 readonly destroy?: boolean;
42 readonly adopt?: boolean;
43 readonly detailed?: boolean;
44 readonly detectDrift?: boolean;
45}
46
47const stackSpanAttrs = (args: StackCommandOptions) => ({
48 "alchemy.stage": args.stage,
49 "alchemy.profile": args.profile,
50 "alchemy.main": args.main,
51 "alchemy.dry_run": !!args.dryRun,
52 "alchemy.force": !!args.force,
53 "alchemy.destroy": !!args.destroy,
54 "alchemy.adopt": !!args.adopt,
55 "alchemy.detailed": !!args.detailed,
56 "alchemy.detect_drift": !!args.detectDrift,
57});
58
59const adopt = Flag.Boolean("adopt").pipe(
60 Flag.withDescription(
61 "Adopt pre-existing cloud resources that conflict with this stack instead of failing. " +
62 "Useful for re-importing infrastructure into a fresh state store.",
63 ),
64 Flag.withDefault(false),
65);
66
67const detailed = Flag.Boolean("detailed").pipe(
68 Flag.withDescription("Show declared resource properties as YAML"),
69 Flag.withDefault(false),
70);
71
72const detectDrift = Flag.Boolean("detect-drift").pipe(
73 Flag.withDescription(
74 "Detect infrastructure drift and offer to repair it before deploying",
75 ),
76 Flag.withDefault(false),
77);
78
79const detectAndMaybeRepairDrift = Effect.fn(function* (
80 target: Stacks.StackTarget,
81 options: {
82 readonly yes?: boolean;
83 readonly detailed?: boolean;
84 readonly dryRun?: boolean;
85 },
86) {
87 const cli = yield* Cli;
88 const snapshot = yield* Drift.inspect(target).pipe(
89 renderPlanning({
90 operation: "Drift",
91 stage: target.stage,
92 computingLabel: "Checking drift",
93 readyLabel: "Drift check complete",
94 }),
95 );
96 if (!Drift.hasDrift(snapshot)) return true;
97
98 if (options.dryRun) {
99 yield* cli.displayPlan(snapshot.repairPlan.native, {
100 detailed: options.detailed,
101 stage: target.stage,
102 });
103 return true;
104 }
105
106 let decision: "repair" | "deploy" | "cancel" = options.yes
107 ? "repair"
108 : "cancel";
109 if (!options.yes) {
110 const terminal = yield* CliKit.CliKit;
111 if (terminal.terminal.input) {
112 decision = yield* terminal.prompt
113 .custom(
114 planDecisionScreen({
115 plan: snapshot.repairPlan.native,
116 message: "Drift detected",
117 choices: [
118 {
119 value: "repair" as const,
120 label: "Repair and Deploy",
121 },
122 {
123 value: "deploy" as const,
124 label: "Deploy without Repair",
125 },
126 {
127 value: "cancel" as const,
128 label: "Cancel",
129 },
130 ],
131 initialValue: "repair" as const,
132 }),
133 )
134 .pipe(
135 Effect.catchTag("TerminalCancelled", () =>
136 Effect.succeed("cancel" as const),
137 ),
138 );
139 } else {
140 yield* cli.displayPlan(snapshot.repairPlan.native, {
141 detailed: options.detailed,
142 stage: target.stage,
143 });
144 yield* CliKit.accessors.output.warning(
145 "Drift detected in a non-interactive terminal. Re-run with --yes to repair and deploy.",
146 );
147 }
148 }
149
150 if (decision === "repair") {
151 yield* Drift.repair(snapshot).pipe(
152 renderApply(snapshot.repairPlan.native, {
153 detailed: options.detailed,
154 stage: target.stage,
155 }),
156 );
157 }
158 return decision !== "cancel";
159});
160
161const runStack = Effect.fn(function* (options: StackCommandOptions) {
162 yield* validateSelectionOptions(options);
163 const cli = yield* Cli;
164 const display = { detailed: options.detailed, stage: options.stage };
165 const target = {
166 entrypoint: options.main,
167 stage: options.stage,
168 profile: options.profile,
169 envFile: Option.getOrUndefined(options.envFile),
170 };
171
172 const operation = options.destroy
173 ? "Destroy"
174 : options.dryRun
175 ? "Plan"
176 : "Deploy";
177 const withPlanningProgress = renderPlanning({
178 operation,
179 stage: options.stage,
180 });
181
182 if (options.detectDrift && !options.destroy) {
183 const proceed = yield* detectAndMaybeRepairDrift(target, options);
184 if (!proceed) {
185 yield* CliKit.accessors.output.info(
186 "Deploy aborted: drift was detected and not approved.",
187 );
188 return yield* exitDeclined;
189 }
190 }
191
192 const snapshot = yield* Stacks.plan({
193 target,
194 operation: options.destroy ? "destroy" : "deploy",
195 force: options.force,
196 include: options.include,
197 exclude: options.exclude,
198 adopt: options.adopt,
199 updateStateStore: options.yes,
200 }).pipe(withPlanningProgress);
201
202 if (options.dryRun) {
203 return yield* cli.displayPlan(snapshot.native, display);
204 }
205
206 if (!options.yes && Stacks.hasChanges(snapshot.summary)) {
207 const approved = yield* cli.approvePlan(snapshot.native, display).pipe(
208 Effect.tap((approved) =>
209 approved
210 ? Effect.void
211 : CliKit.accessors.output.info(
212 `${operation} aborted: plan declined.`,
213 ),
214 ),
215 Effect.catchTag("NonInteractiveTerminal", () =>
216 CliKit.accessors.output
217 .warning(
218 `Cannot prompt for approval in a non-interactive terminal. Nothing was changed — re-run with --yes to ${operation.toLowerCase()}.`,
219 )
220 .pipe(Effect.as(false)),
221 ),
222 );
223 if (!approved) return yield* exitDeclined;
224 }
225
226 const result = yield* Stacks.apply(snapshot).pipe(
227 renderApply(snapshot.native, display),
228 );
229 if (result !== undefined) {
230 const kit = yield* CliKit.CliKit;
231 yield* kit.output.print(stackOutputsView(result));
232 }
233});
234
235export const deployCommand = Command.make(
236 "deploy",
237 {
238 dryRun: dryRunFlag,
239 force,
240 include,
241 exclude,
242 config: optionalConfig,
243 configPath,
244 envFile,
245 stage,
246 yes,
247 profile,
248 adopt,
249 detailed,
250 detectDrift,
251 },
252 (args) =>
253 resolveStackArgs("live")(args).pipe(
254 Effect.flatMap(instrumentCommand("deploy", stackSpanAttrs)(runStack)),
255 ),
256);
257
258export const destroyCommand = Command.make(
259 "destroy",
260 {
261 include,
262 exclude,
263 dryRun: dryRunFlag,
264 config: optionalConfig,
265 configPath,
266 envFile,
267 stage,
268 yes,
269 profile,
270 },
271 (args) =>
272 resolveStackArgs("live")(args).pipe(
273 Effect.flatMap(
274 instrumentCommand(
275 "destroy",
276 stackSpanAttrs,
277 )((options) =>
278 runStack({
279 ...options,
280 destroy: true,
281 }),
282 ),
283 ),
284 ),
285);
286
287export const planCommand = Command.make(
288 "plan",
289 {
290 include,
291 exclude,
292 config: optionalConfig,
293 configPath,
294 envFile,
295 stage,
296 profile,
297 adopt,
298 detailed,
299 },
300 (args) =>
301 resolveStackArgs("live")(args).pipe(
302 Effect.flatMap(
303 instrumentCommand(
304 "plan",
305 stackSpanAttrs,
306 )((options) =>
307 runStack({
308 ...options,
309 // plan is the same as deploy with dryRun always set to true
310 dryRun: true,
311 }),
312 ),
313 ),
314 ),
315);