| 1 | import * as Console from "effect/Console"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Option from "effect/Option"; |
| 4 | import * as Argument from "effect/cli/Argument"; |
| 5 | import { Command, Flag } from "effect/cli"; |
| 6 | import * as AlchemistState from "../../Alchemist/routes/state.ts"; |
| 7 | import type { StateSource } from "../../Alchemist/routes/state.ts"; |
| 8 | import * as State from "../../State/index.ts"; |
| 9 | import * as CliKit from "../../Cli/CliKit/index.ts"; |
| 10 | import { |
| 11 | stateExplorerScreen, |
| 12 | type StateExplorerSource, |
| 13 | } from "../components/view/StateExplorer.tsx"; |
| 14 | import { failWithHelp, UserInputError } from "./errors.ts"; |
| 15 | import { envFile, profile, yes } from "./flags.ts"; |
| 16 | import { confirmOrDecline } from "./confirm.ts"; |
| 17 | import { instrumentCommand } from "./instrument.ts"; |
| 18 | |
| 19 | const 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 | ); |
| 28 | const 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 | ); |
| 33 | const pathArgument = Argument.String("path").pipe( |
| 34 | Argument.withDescription("State path (stack/stage/namespace/resource)"), |
| 35 | Argument.optional, |
| 36 | ); |
| 37 | const requiredPathArgument = Argument.String("path").pipe( |
| 38 | Argument.withDescription("State path (stack/stage/namespace/resource)"), |
| 39 | ); |
| 40 | const recursive = Flag.Boolean("recursive").pipe( |
| 41 | Flag.withAlias("r"), |
| 42 | Flag.withDescription("Operate recursively on directories"), |
| 43 | Flag.withDefault(false), |
| 44 | ); |
| 45 | |
| 46 | type 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 | |
| 53 | const 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 | |
| 69 | const 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 | |
| 80 | const 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. */ |
| 87 | const 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 | |
| 104 | const 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 | |
| 124 | const 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 | |
| 152 | const 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 | |
| 184 | const 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 | |
| 245 | const 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 | |
| 282 | const unsafeCommand = Command.make("unsafe", {}).pipe( |
| 283 | Command.withDescription("Destructive state operations"), |
| 284 | Command.withSubcommands([nukeCommand]), |
| 285 | Command.unlisted, |
| 286 | ); |
| 287 | |
| 288 | export 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 | ); |