1import * as Console from "effect/Console";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Argument from "effect/cli/Argument";
5import { Command, Flag } from "effect/cli";
6import * as AlchemistState from "../../Alchemist/routes/state.ts";
7import type { StateSource } from "../../Alchemist/routes/state.ts";
8import * as State from "../../State/index.ts";
9import * as CliKit from "../../Cli/CliKit/index.ts";
10import {
11 stateExplorerScreen,
12 type StateExplorerSource,
13} from "../components/view/StateExplorer.tsx";
14import { failWithHelp, UserInputError } from "./errors.ts";
15import { envFile, profile, yes } from "./flags.ts";
16import { confirmOrDecline } from "./confirm.ts";
17import { instrumentCommand } from "./instrument.ts";
18
19const backend = Flag.Literals("backend", [
20 "configured",
21 "local",
22 "cloudflare",
23 "aws",
24] as const).pipe(
25 Flag.withDescription("State backend (default: configured)"),
26 Flag.withDefault("configured" as const),
27);
28const config = Flag.File("config").pipe(
29 Flag.withDescription("Alchemy entrypoint file (default: alchemy.run.ts)"),
30 Flag.withAlias("c"),
31 Flag.withDefault("alchemy.run.ts"),
32);
33const pathArgument = Argument.String("path").pipe(
34 Argument.withDescription("State path (stack/stage/namespace/resource)"),
35 Argument.optional,
36);
37const requiredPathArgument = Argument.String("path").pipe(
38 Argument.withDescription("State path (stack/stage/namespace/resource)"),
39);
40const recursive = Flag.Boolean("recursive").pipe(
41 Flag.withAlias("r"),
42 Flag.withDescription("Operate recursively on directories"),
43 Flag.withDefault(false),
44);
45
46type StateArgs = {
47 readonly main: string;
48 readonly envFile: Option.Option<string>;
49 readonly profile: string | undefined;
50 readonly backend: "configured" | "local" | "cloudflare" | "aws";
51};
52
53const source = (args: StateArgs): StateSource =>
54 args.backend === "local"
55 ? { backend: "local" }
56 : args.backend === "cloudflare" || args.backend === "aws"
57 ? {
58 backend: args.backend,
59 profile: args.profile,
60 envFile: Option.getOrUndefined(args.envFile),
61 }
62 : {
63 backend: "configured",
64 entrypoint: args.main,
65 profile: args.profile,
66 envFile: Option.getOrUndefined(args.envFile),
67 };
68
69const normalizedPath = (path: string | undefined) => {
70 const parts = (path ?? "")
71 .split("/")
72 .filter((part) => part !== "" && part !== ".");
73 return parts.includes("..")
74 ? Effect.fail(
75 new UserInputError({ message: `invalid state path: ${path ?? "/"}` }),
76 )
77 : Effect.succeed(parts.join("/"));
78};
79
80const provideStore =
81 (store: State.StateService) =>
82 <A, E>(operation: Effect.Effect<A, E, State.State>) =>
83 operation.pipe(Effect.provideService(State.State, Effect.succeed(store)));
84
85/** Run a state tree operation against a resolved store, mapping a bad path
86 * to a single-line user error instead of a cause dump. */
87const usingStore =
88 (store: State.StateService) =>
89 <A>(
90 operation: Effect.Effect<
91 A,
92 State.InvalidStatePath | State.StateStoreError,
93 State.State
94 >,
95 ) =>
96 provideStore(store)(operation).pipe(
97 Effect.catchTag("InvalidStatePath", (error) =>
98 Effect.fail(
99 new UserInputError({ message: `${error.path}: ${error.reason}` }),
100 ),
101 ),
102 );
103
104const listCommand = Command.make(
105 "list",
106 { path: pathArgument, recursive, main: config, envFile, profile, backend },
107 instrumentCommand("state.list")(
108 Effect.fn(function* ({ path, recursive, ...args }) {
109 const store = yield* AlchemistState.store(source(args));
110 const items = yield* usingStore(store)(
111 State.listState({
112 path: yield* normalizedPath(Option.getOrUndefined(path)),
113 recursive,
114 }),
115 );
116 yield* Console.log([...items].sort().join("\n"));
117 }),
118 ),
119).pipe(
120 Command.withAlias("ls"),
121 Command.withDescription("List a state-store directory"),
122);
123
124const readCommand = Command.make(
125 "read",
126 { path: pathArgument, recursive, main: config, envFile, profile, backend },
127 instrumentCommand("state.read")(
128 Effect.fn(function* ({ path, recursive, ...args }) {
129 const store = yield* AlchemistState.store(source(args));
130 const requested = yield* normalizedPath(Option.getOrUndefined(path));
131 const entries = yield* usingStore(store)(
132 State.readState({ path: requested, recursive }),
133 );
134 yield* Console.log(
135 JSON.stringify(
136 entries.length === 1
137 ? entries[0]!.value
138 : Object.fromEntries(
139 entries.map((entry) => [entry.path, entry.value]),
140 ),
141 null,
142 2,
143 ),
144 );
145 }),
146 ),
147).pipe(
148 Command.withAlias("cat"),
149 Command.withDescription("Read a state-store file or directory"),
150);
151
152const deleteCommand = Command.make(
153 "delete",
154 {
155 path: requiredPathArgument,
156 recursive,
157 main: config,
158 envFile,
159 profile,
160 backend,
161 },
162 instrumentCommand("state.delete")(
163 Effect.fn(function* ({ path, recursive, ...args }) {
164 const requested = yield* normalizedPath(path);
165 if (requested === "") {
166 return yield* Effect.fail(
167 new UserInputError({ message: "cannot delete the state root" }),
168 );
169 }
170 const store = yield* AlchemistState.store(source(args));
171 yield* usingStore(store)(
172 State.deleteState({ path: requested, recursive }),
173 );
174 yield* CliKit.accessors.output.success(`Deleted state at ${requested}`);
175 }),
176 ),
177).pipe(
178 Command.withAlias("rm"),
179 Command.withDescription(
180 "Delete state records without deleting cloud resources",
181 ),
182);
183
184const stateExplorer = (args: StateArgs) =>
185 Effect.gen(function* () {
186 const cli = yield* CliKit.CliKit;
187 const store = yield* AlchemistState.store(source(args));
188 const on = provideStore(store);
189 const explorer: StateExplorerSource = {
190 backend: store.id,
191 listStacks: on(State.listState({})).pipe(
192 Effect.map((paths) => paths.map((path) => path.slice(0, -1))),
193 ),
194 listStages: (stack) =>
195 on(State.listState({ path: stack })).pipe(
196 Effect.map((paths) =>
197 paths.map((path) => path.slice(stack.length + 1, -1)),
198 ),
199 ),
200 listResources: (stack, stage) =>
201 on(
202 State.listState({ path: `${stack}/${stage}`, recursive: true }),
203 ).pipe(
204 Effect.map((paths) =>
205 paths
206 .filter((path) => path !== `${stack}/${stage}/output`)
207 .map((path) => path.slice(`${stack}/${stage}/`.length)),
208 ),
209 ),
210 readFile: (file) =>
211 on(
212 State.readState({
213 path: `${file.stack}/${file.stage}/${file.kind === "output" ? "output" : file.fqn}`,
214 }),
215 ).pipe(Effect.map((entries) => entries[0]?.value)),
216 deleteNodes: (nodes) =>
217 Effect.forEach(
218 nodes.filter(
219 (node, index) =>
220 !nodes.some(
221 (parent, parentIndex) =>
222 parentIndex !== index &&
223 ["stack", "stage", "namespace"].includes(parent.kind) &&
224 node.path.startsWith(`${parent.path}/`),
225 ),
226 ),
227 (node) =>
228 node.kind === "output"
229 ? Effect.void
230 : on(
231 State.deleteState({ path: node.path, recursive: true }),
232 ).pipe(Effect.asVoid),
233 { concurrency: 32, discard: true },
234 ),
235 };
236 // Captured here so the explorer's forked fibers run with the command's
237 // ambient services (tracing, terminal) rather than the default runtime.
238 const services = yield* Effect.context<never>();
239 yield* cli
240 .application(cli.prompt.custom(stateExplorerScreen(explorer, services)))
241 .pipe(CliKit.Application.alternate)
242 .pipe(Effect.catchTag("TerminalCancelled", () => Effect.void));
243 });
244
245const nukeCommand = Command.make(
246 "nuke",
247 { main: config, envFile, profile, backend, yes },
248 instrumentCommand("state.unsafe.nuke")(
249 Effect.fn(function* ({ yes, ...args }) {
250 const store = yield* AlchemistState.store(source(args));
251 const stacks = [...(yield* store.listStacks())].sort();
252 if (stacks.length === 0) {
253 yield* CliKit.accessors.output.info("Nothing to clear.");
254 return;
255 }
256 yield* Console.log(stacks.map((stack) => `• ${stack}`).join("\n"));
257 yield* confirmOrDecline({
258 yes,
259 message: `Permanently delete ALL ${stacks.length} stacks from the state store? This cannot be undone. Cloud resources will remain.`,
260 });
261 yield* Effect.forEach(
262 stacks,
263 (stack) =>
264 store
265 .deleteStack({ stack })
266 .pipe(
267 Effect.andThen(
268 CliKit.accessors.output.success(`Cleared ${stack}`),
269 ),
270 ),
271 { concurrency: 32, discard: true },
272 );
273 }),
274 ),
275).pipe(
276 Command.withDescription(
277 "Delete all stacks, stages, and outputs from the state store",
278 ),
279 Command.unlisted,
280);
281
282const unsafeCommand = Command.make("unsafe", {}).pipe(
283 Command.withDescription("Destructive state operations"),
284 Command.withSubcommands([nukeCommand]),
285 Command.unlisted,
286);
287
288export const stateCommand = Command.make(
289 "state",
290 { main: config, envFile, profile, backend },
291 instrumentCommand("state")(
292 Effect.fn(function* (args) {
293 if (!(yield* CliKit.CliKit).terminal.input) {
294 return yield* failWithHelp(["alchemy", "state"]);
295 }
296 yield* stateExplorer(args);
297 }),
298 ),
299).pipe(
300 Command.withDescription("Inspect and manage deployment state"),
301 Command.withSubcommands([
302 listCommand,
303 readCommand,
304 deleteCommand,
305 unsafeCommand,
306 ]),
307);