| 1 | import * as Cause from "effect/Cause"; |
| 2 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Command from "effect/cli/Command"; |
| 6 | import * as CliConfig from "effect/cli/CliConfig"; |
| 7 | import * as CliError from "effect/cli/CliError"; |
| 8 | import * as Flag from "effect/cli/Flag"; |
| 9 | import * as GlobalFlag from "effect/cli/GlobalFlag"; |
| 10 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 11 | |
| 12 | import { AlchemyContextLive } from "alchemy/AlchemyContext"; |
| 13 | import { ArtifactStore, createArtifactStore } from "alchemy/Artifacts"; |
| 14 | import { CredentialsStoreLive } from "alchemy/Auth/Credentials"; |
| 15 | import { ProfileStoreLive } from "alchemy/Auth/Profile"; |
| 16 | import { TelemetryLive } from "alchemy/Telemetry/Layer"; |
| 17 | import { PlatformServices } from "alchemy/Util/PlatformServices"; |
| 18 | import { moduleExtension } from "alchemy/Util/Node"; |
| 19 | import packageJson from "../../package.json" with { type: "json" }; |
| 20 | |
| 21 | import * as CliKit from "./CliKit/index.ts"; |
| 22 | import { checkLatestVersion } from "./checkVersion.ts"; |
| 23 | import { GlobalLogLive, logRunHeader } from "./GlobalLog.ts"; |
| 24 | import { handleCliErrors } from "./commands/errors.ts"; |
| 25 | import { |
| 26 | compatibilityCommand, |
| 27 | compatibilityCommands, |
| 28 | type CompatibilityCommand, |
| 29 | } from "./commands/compat.ts"; |
| 30 | import { selectCliServices } from "./selectCli.ts"; |
| 31 | |
| 32 | const commandMetadata = [ |
| 33 | ["provider", "Manage cloud provider prerequisites and utilities"], |
| 34 | ["prisma", "Generate Prisma Effect bindings and schemas"], |
| 35 | ["deploy", "Deploy a stack"], |
| 36 | ["dev", "Develop a stack with live reload"], |
| 37 | ["destroy", "Destroy a deployed stack"], |
| 38 | ["plan", "Preview changes to a stack"], |
| 39 | ["logs", "Fetch or tail logs from stack resources"], |
| 40 | ["profile", "Manage authentication profiles and accounts"], |
| 41 | ["state", "Inspect and manage deployment state"], |
| 42 | ["drift", "Detect infrastructure drift"], |
| 43 | ["unsafe", "Unsafe maintenance commands"], |
| 44 | ] as const; |
| 45 | |
| 46 | type CommandName = (typeof commandMetadata)[number][0]; |
| 47 | |
| 48 | const placeholderCommand = (name: CommandName, description: string) => |
| 49 | Command.make(name, {}, () => Effect.void).pipe( |
| 50 | Command.withDescription(description), |
| 51 | name === "unsafe" ? Command.unlisted : (command) => command, |
| 52 | ); |
| 53 | |
| 54 | const loadCommand = async (name: CommandName) => { |
| 55 | switch (name) { |
| 56 | case "prisma": |
| 57 | return (await import("./commands/prisma.ts")).prismaCommand; |
| 58 | case "provider": |
| 59 | return (await import("./commands/provider.ts")).providerCommand; |
| 60 | case "deploy": |
| 61 | return (await import("./commands/deploy.ts")).deployCommand; |
| 62 | case "dev": |
| 63 | return (await import("./commands/dev.ts")).devCommand; |
| 64 | case "destroy": |
| 65 | return (await import("./commands/deploy.ts")).destroyCommand; |
| 66 | case "plan": |
| 67 | return (await import("./commands/deploy.ts")).planCommand; |
| 68 | case "logs": |
| 69 | return (await import("./commands/logs.ts")).logsCommand; |
| 70 | case "profile": |
| 71 | return (await import("./commands/profile/index.ts")).profileCommand; |
| 72 | case "state": |
| 73 | return (await import("./commands/state.ts")).stateCommand; |
| 74 | case "drift": |
| 75 | return (await import("./commands/drift.ts")).driftCommand; |
| 76 | case "unsafe": |
| 77 | return (await import("./commands/nuke.ts")).unsafeCommand; |
| 78 | } |
| 79 | }; |
| 80 | |
| 81 | const argv = process.argv.slice(2); |
| 82 | const compatibilityName = Object.hasOwn(compatibilityCommands, argv[0] ?? "") |
| 83 | ? (argv[0] as CompatibilityCommand) |
| 84 | : undefined; |
| 85 | |
| 86 | // Compatibility commands only print their replacement. Discard everything |
| 87 | // after the old command name so historical positional arguments and flags are |
| 88 | // accepted without teaching the retired parser surface about every old form. |
| 89 | if (compatibilityName !== undefined) { |
| 90 | const index = process.argv.indexOf(compatibilityName, 2); |
| 91 | process.argv.splice(index + 1); |
| 92 | } |
| 93 | |
| 94 | const commandNames = new Set<CommandName>( |
| 95 | commandMetadata.map(([name]) => name), |
| 96 | ); |
| 97 | const requestedCommand = argv.find((value): value is CommandName => |
| 98 | commandNames.has(value as CommandName), |
| 99 | ); |
| 100 | const loadEveryCommand = argv.includes("--completions"); |
| 101 | const commands = await Promise.all( |
| 102 | commandMetadata.map(([name, description]) => |
| 103 | loadEveryCommand || name === requestedCommand |
| 104 | ? loadCommand(name) |
| 105 | : placeholderCommand(name, description), |
| 106 | ), |
| 107 | ); |
| 108 | |
| 109 | /** |
| 110 | * `--no-input` forces plain, prompt-free output regardless of TTY or env |
| 111 | * detection. The value is read via an argv scan in `Util/interactive.ts` |
| 112 | * (capability detection runs while the service layers are built, before flag |
| 113 | * parsing); this registration exists so the parser accepts the flag. |
| 114 | */ |
| 115 | const NoInput = GlobalFlag.Setting("no-input")({ |
| 116 | flag: Flag.Boolean("no-input").pipe( |
| 117 | Flag.withDescription( |
| 118 | "Disable prompts and the interactive TUI (plain output; commands needing input fail)", |
| 119 | ), |
| 120 | Flag.withDefault(false), |
| 121 | ), |
| 122 | }); |
| 123 | |
| 124 | const root = Command.make("alchemy", {}, () => |
| 125 | Effect.fail(new CliError.ShowHelp({ commandPath: ["alchemy"], errors: [] })), |
| 126 | ).pipe( |
| 127 | Command.withDescription( |
| 128 | "Define, deploy, and operate cloud infrastructure with type-safe Effect programs.", |
| 129 | ), |
| 130 | Command.withExamples([ |
| 131 | { command: "alchemy deploy" }, |
| 132 | { command: "alchemy plan --stage prod" }, |
| 133 | { command: "alchemy dev" }, |
| 134 | { command: "alchemy logs --tail" }, |
| 135 | ]), |
| 136 | Command.withSubcommands([ |
| 137 | ...commands, |
| 138 | ...Object.keys(compatibilityCommands).map((name) => |
| 139 | compatibilityCommand(name as CompatibilityCommand), |
| 140 | ), |
| 141 | ]), |
| 142 | Command.withGlobalFlags([NoInput]), |
| 143 | ); |
| 144 | |
| 145 | /** |
| 146 | * How this CLI process is running, appended to `--version` in a checkout |
| 147 | * for debugging the launcher's implicit mode selection (`bin/cli.js`): the |
| 148 | * runtime, and whether we're executing `.ts` source (bun, or node with type |
| 149 | * stripping + the Oxc register hook) or built output — `node <x>, lib` is |
| 150 | * the tell for the old-node fallback that still needs a build. Published |
| 151 | * installs print the plain version string. |
| 152 | */ |
| 153 | const devRunMode = import.meta.url.includes("/node_modules/") |
| 154 | ? undefined |
| 155 | : `${ |
| 156 | typeof globalThis.Bun !== "undefined" |
| 157 | ? `bun ${globalThis.Bun.version}` |
| 158 | : `node ${process.versions.node}` |
| 159 | }, ${moduleExtension(import.meta.url) === ".ts" ? "src" : "lib"}`; |
| 160 | |
| 161 | const cli = Command.run(root, { |
| 162 | version: |
| 163 | devRunMode === undefined |
| 164 | ? packageJson.version |
| 165 | : `${packageJson.version} (${devRunMode})`, |
| 166 | }); |
| 167 | |
| 168 | const services = Layer.mergeAll( |
| 169 | CliConfig.layer({ |
| 170 | builtIns: [ |
| 171 | GlobalFlag.Help, |
| 172 | GlobalFlag.Version, |
| 173 | GlobalFlag.Completions, |
| 174 | GlobalFlag.LogLevel, |
| 175 | ], |
| 176 | }), |
| 177 | Layer.provideMerge(AlchemyContextLive, PlatformServices), |
| 178 | Layer.provide(ProfileStoreLive, PlatformServices), |
| 179 | Layer.provide(CredentialsStoreLive, PlatformServices), |
| 180 | // Ambient per-CLI-run artifact root. Commands that define their own run |
| 181 | // boundary (deploy, drift) provide a fresh store closer to the work, which |
| 182 | // wins over this one. |
| 183 | Layer.succeed(ArtifactStore, createArtifactStore()), |
| 184 | FetchHttpClient.layer, |
| 185 | ConfigProvider.layer(ConfigProvider.fromEnv()), |
| 186 | Layer.provide( |
| 187 | Layer.provideMerge( |
| 188 | Layer.mergeAll(selectCliServices(), CliKit.CliKitInteraction), |
| 189 | CliKit.layer(), |
| 190 | ), |
| 191 | PlatformServices, |
| 192 | ), |
| 193 | // Debug run log under ~/.alchemy/logs — the console noise floor stays at |
| 194 | // Info, but full causes and auth-flow breadcrumbs land in the file so |
| 195 | // support can ask users for it. |
| 196 | // |
| 197 | // Telemetry sits on top of the console/file loggers so its OTLP logger |
| 198 | // merges with them. Listed as `mergeAll` siblings, whichever came last |
| 199 | // would win the `CurrentLoggers` slot and silently drop the other. |
| 200 | Layer.provideMerge( |
| 201 | TelemetryLive, |
| 202 | Layer.provide(GlobalLogLive, PlatformServices), |
| 203 | ), |
| 204 | ); |
| 205 | |
| 206 | const program = Effect.gen(function* () { |
| 207 | yield* logRunHeader; |
| 208 | // Best-effort check for a newer published version. Runs to completion |
| 209 | // before the command so the warning prints before any interactive |
| 210 | // prompts; the registry response is cached for a day and the fetch is |
| 211 | // bounded by a short timeout, so this stays fast. |
| 212 | yield* checkLatestVersion; |
| 213 | return yield* cli; |
| 214 | }).pipe( |
| 215 | // The terminal shows the friendly message; the run log keeps the full |
| 216 | // cause chain. Must sit inside the service provision so the file logger |
| 217 | // is still installed. |
| 218 | Effect.tapCause((cause) => |
| 219 | Effect.logDebug(`command failed:\n${Cause.pretty(cause)}`), |
| 220 | ), |
| 221 | ); |
| 222 | |
| 223 | const mainEffect = program.pipe( |
| 224 | // $USER and $ALCHEMY_STAGE are set by the environment |
| 225 | Effect.provide(services), |
| 226 | Effect.scoped, |
| 227 | handleCliErrors, |
| 228 | ); |
| 229 | |
| 230 | /** Fully wired CLI program. */ |
| 231 | export const main = mainEffect as Effect.Effect< |
| 232 | void, |
| 233 | Effect.Error<typeof mainEffect> |
| 234 | >; |