1import * as Clock from "effect/Clock";
2import * as Console from "effect/Console";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import { Command, Flag } from "effect/cli";
7import * as Nuke from "../../Alchemist/routes/nuke.ts";
8import * as CliKit from "../../Cli/CliKit/index.ts";
9import { formatElapsed } from "../Format.ts";
10import {
11 renderNukeScan,
12 renderNukeDelete,
13 reviewNuke,
14} from "../components/view/Nuke.tsx";
15import { exitDeclined, UserInputError } from "./errors.ts";
16import {
17 configPath,
18 envFile,
19 optionalConfig,
20 profile,
21 resolveConfig,
22 yes,
23} from "./flags.ts";
24import { instrumentCommand } from "./instrument.ts";
25
26const includeFlag = Flag.String("include").pipe(
27 Flag.withDescription("Glob of provider IDs to include (repeatable)"),
28 Flag.atLeast(0),
29);
30const excludeFlag = Flag.String("exclude").pipe(
31 Flag.withDescription("Glob of provider IDs to exclude (repeatable)"),
32 Flag.atLeast(0),
33);
34const filterFlag = Flag.String("filter").pipe(
35 Flag.withDescription(
36 "JavaScript expression evaluated with resource in scope; matching resources are excluded from deletion (repeatable)",
37 ),
38 Flag.atLeast(0),
39);
40const concurrencyFlag = Flag.Int("concurrency").pipe(
41 Flag.withDescription(
42 "Maximum providers processed in parallel; 0 is unbounded",
43 ),
44 Flag.withDefault(16),
45 Flag.map((value): number | "unbounded" => (value <= 0 ? "unbounded" : value)),
46);
47const timeoutFlag = Flag.Int("timeout").pipe(
48 Flag.withDescription("Per-provider timeout in seconds"),
49 Flag.withDefault(120),
50 Flag.map(Duration.seconds),
51);
52const independentFlag = Flag.Boolean("independent").pipe(
53 Flag.withDescription("Retry each resource independently"),
54 Flag.withDefault(false),
55);
56const retriesFlag = Flag.Int("retries").pipe(
57 Flag.withDescription("Independent retries per resource"),
58 Flag.withDefault(10),
59);
60const localFlag = Flag.Boolean("local").pipe(
61 Flag.withDescription("Target only locally emulated providers"),
62 Flag.withDefault(false),
63);
64const dryRunFlag = Flag.Boolean("dry-run").pipe(
65 Flag.withDescription("Scan and show targets without deleting"),
66 Flag.withDefault(false),
67);
68
69// A broken filter must abort the nuke, not silently widen the kill list:
70// compile and evaluation errors both fail the command before any deletion.
71const compileFilter = (expression: string) =>
72 Effect.try({
73 try: () =>
74 new Function(
75 "scope",
76 `with (scope) { return (${expression}); }`,
77 ) as (scope: { resource: Record<string, unknown> }) => unknown,
78 catch: (cause) =>
79 new UserInputError({
80 message: `--filter expression ${JSON.stringify(expression)} failed to compile: ${cause}`,
81 }),
82 }).pipe(
83 Effect.map(
84 (predicate) => (resource: Record<string, unknown>) =>
85 Effect.try({
86 try: () => Boolean(predicate({ resource })),
87 catch: (cause) =>
88 new UserInputError({
89 message: `--filter expression ${JSON.stringify(expression)} threw while evaluating a resource: ${cause}`,
90 }),
91 }),
92 ),
93 );
94
95const nukeCommand = Command.make(
96 "nuke",
97 {
98 config: optionalConfig,
99 configPath,
100 envFile,
101 profile,
102 yes,
103 dryRun: dryRunFlag,
104 concurrency: concurrencyFlag,
105 timeout: timeoutFlag,
106 independent: independentFlag,
107 retries: retriesFlag,
108 include: includeFlag,
109 exclude: excludeFlag,
110 filter: filterFlag,
111 local: localFlag,
112 },
113 (args) =>
114 resolveConfig(args).pipe(
115 Effect.flatMap(
116 instrumentCommand(
117 "unsafe.nuke",
118 (args: { profile: string | undefined; main: string }) => ({
119 "alchemy.profile": args.profile ?? "",
120 "alchemy.main": args.main,
121 }),
122 )(
123 Effect.fn(function* (args) {
124 const scan = yield* Nuke.scan({
125 entrypoint: args.main,
126 profile: args.profile,
127 envFile: Option.getOrUndefined(args.envFile),
128 mode: args.local ? "local" : "live",
129 include: args.include,
130 exclude: args.exclude,
131 concurrency: args.concurrency,
132 providerTimeoutSeconds: Duration.toSeconds(args.timeout),
133 }).pipe(renderNukeScan());
134 const predicates = yield* Effect.forEach(
135 args.filter,
136 compileFilter,
137 );
138 const targets: Array<(typeof scan.resources)[number]> = [];
139 for (const resource of scan.resources) {
140 const matches = yield* Effect.forEach(predicates, (predicate) =>
141 predicate({
142 ...resource.attributes,
143 Type: resource.providerId,
144 LogicalId: resource.displayName,
145 }),
146 );
147 if (!matches.some(Boolean)) targets.push(resource);
148 }
149 if (targets.length === 0) {
150 yield* CliKit.accessors.output.info("Nothing to delete.");
151 return;
152 }
153 const approved = yield* reviewNuke(targets, {
154 mode: scan.mode,
155 yes: args.yes,
156 dryRun: args.dryRun,
157 });
158 if (!approved) {
159 yield* CliKit.accessors.output.info("Aborted.");
160 return yield* exitDeclined;
161 }
162 if (args.dryRun) {
163 yield* Console.log("Dry run complete: nothing was deleted.");
164 return;
165 }
166 const deleteStartedAt = yield* Clock.currentTimeMillis;
167 const result = yield* Nuke.execute({
168 scan,
169 resources: targets,
170 strategy: args.independent
171 ? { _tag: "independent", retries: args.retries }
172 : { _tag: "coordinated" },
173 concurrency: args.concurrency,
174 providerTimeoutSeconds: Duration.toSeconds(args.timeout),
175 }).pipe(renderNukeDelete(targets, scan.mode));
176 const deleteElapsed =
177 (yield* Clock.currentTimeMillis) - deleteStartedAt;
178 yield* CliKit.accessors.output.success(
179 `Deleted ${result.deleted.length} resource(s) over ${result.passes} pass(es) (${formatElapsed(deleteElapsed)}).`,
180 );
181 if (result.held.length > 0) {
182 yield* CliKit.accessors.output.warning(
183 `${result.held.length} resource(s) were held back.`,
184 );
185 }
186 if (result.failed.length > 0) {
187 yield* CliKit.accessors.output.error(
188 `${result.failed.length} resource(s) could not be deleted.`,
189 );
190 }
191 }),
192 ),
193 ),
194 ),
195).pipe(
196 Command.withDescription(
197 "Enumerate resources across the stack providers and delete them",
198 ),
199 Command.unlisted,
200);
201
202export const unsafeCommand = Command.make("unsafe", {}).pipe(
203 Command.withDescription("Dangerous, irreversible operations"),
204 Command.withSubcommands([nukeCommand]),
205 Command.unlisted,
206);