1import * as Cause from "effect/Cause";
2import * as ConfigProvider from "effect/ConfigProvider";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Command from "effect/cli/Command";
6import * as CliConfig from "effect/cli/CliConfig";
7import * as CliError from "effect/cli/CliError";
8import * as Flag from "effect/cli/Flag";
9import * as GlobalFlag from "effect/cli/GlobalFlag";
10import * as FetchHttpClient from "effect/http/FetchHttpClient";
11
12import { AlchemyContextLive } from "alchemy/AlchemyContext";
13import { ArtifactStore, createArtifactStore } from "alchemy/Artifacts";
14import { CredentialsStoreLive } from "alchemy/Auth/Credentials";
15import { ProfileStoreLive } from "alchemy/Auth/Profile";
16import { TelemetryLive } from "alchemy/Telemetry/Layer";
17import { PlatformServices } from "alchemy/Util/PlatformServices";
18import { moduleExtension } from "alchemy/Util/Node";
19import packageJson from "../../package.json" with { type: "json" };
20
21import * as CliKit from "./CliKit/index.ts";
22import { checkLatestVersion } from "./checkVersion.ts";
23import { GlobalLogLive, logRunHeader } from "./GlobalLog.ts";
24import { handleCliErrors } from "./commands/errors.ts";
25import {
26 compatibilityCommand,
27 compatibilityCommands,
28 type CompatibilityCommand,
29} from "./commands/compat.ts";
30import { selectCliServices } from "./selectCli.ts";
31
32const 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
46type CommandName = (typeof commandMetadata)[number][0];
47
48const 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
54const 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
81const argv = process.argv.slice(2);
82const 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.
89if (compatibilityName !== undefined) {
90 const index = process.argv.indexOf(compatibilityName, 2);
91 process.argv.splice(index + 1);
92}
93
94const commandNames = new Set<CommandName>(
95 commandMetadata.map(([name]) => name),
96);
97const requestedCommand = argv.find((value): value is CommandName =>
98 commandNames.has(value as CommandName),
99);
100const loadEveryCommand = argv.includes("--completions");
101const 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 */
115const 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
124const 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 */
153const 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
161const cli = Command.run(root, {
162 version:
163 devRunMode === undefined
164 ? packageJson.version
165 : `${packageJson.version} (${devRunMode})`,
166});
167
168const 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
206const 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
223const 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. */
231export const main = mainEffect as Effect.Effect<
232 void,
233 Effect.Error<typeof mainEffect>
234>;