import * as Cause from "effect/Cause";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Console from "effect/Console";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import * as Command from "effect/unstable/cli/Command";
import * as Flag from "effect/unstable/cli/Flag";
import { AdoptPolicy } from "../../AdoptPolicy.ts";
import { AlchemyContext } from "../../AlchemyContext.ts";
import { apply } from "../../Apply.ts";
import { ArtifactStore, createArtifactStore } from "../../Artifacts.ts";
import { AuthProviders } from "../../Auth/AuthProvider.ts";
import { withProfileOverride } from "../../Auth/Profile.ts";
import * as CLI from "../../Cli/Cli.ts";
import * as Plan from "../../Plan.ts";
import { Stage } from "../../Stage.ts";
import { loadConfigProvider } from "../../Util/ConfigProvider.ts";
import { fileLogger } from "../../Util/FileLogger.ts";
import {
dryRun as dryRunFlag,
envFile,
force,
importStack,
instrumentCommand,
profile,
script,
stage,
yes,
} from "./_shared.ts";
export const ExecStackOptions = Schema.Struct({
main: Schema.String,
stage: Schema.String,
envFile: Schema.OptionFromOptional(Schema.String),
profile: Schema.optional(Schema.String),
dryRun: Schema.optional(Schema.Boolean),
force: Schema.optional(Schema.Boolean),
yes: Schema.optional(Schema.Boolean),
destroy: Schema.optional(Schema.Boolean),
dev: Schema.optional(Schema.Boolean),
adopt: Schema.optional(Schema.Boolean),
});
export type ExecStackOptions = typeof ExecStackOptions.Type;
export type ExecStackOptionsEncoded = typeof ExecStackOptions.Encoded;
const stackSpanAttrs = (args: ExecStackOptions) => ({
"alchemy.stage": args.stage,
"alchemy.profile": args.profile,
"alchemy.main": args.main,
"alchemy.dry_run": !!args.dryRun,
"alchemy.force": !!args.force,
"alchemy.destroy": !!args.destroy,
"alchemy.dev": !!args.dev,
"alchemy.adopt": !!args.adopt,
});
const adopt = Flag.boolean("adopt").pipe(
Flag.withDescription(
"Adopt pre-existing cloud resources that conflict with this stack instead of failing. " +
"Useful for re-importing infrastructure into a fresh state store.",
),
Flag.withDefault(false),
);
const runStack = Effect.fn(function* ({
main,
stage,
envFile,
profile,
dryRun = false,
force = false,
yes = false,
destroy = false,
dev = false,
adopt = false,
}: ExecStackOptions) {
const stackEffect = yield* importStack(main);
const services = Layer.mergeAll(
Layer.effect(
AlchemyContext,
AlchemyContext.pipe(
Effect.map((ctx) => ({
...ctx,
dev,
adopt,
// `--yes` also auto-accepts (and performs) an out-of-date state
// store upgrade, instead of prompting.
updateStateStore: yes,
})),
),
),
// `--adopt` opts the entire deploy in to adoption-on-conflict.
// Resource providers that wire `AdoptPolicy` (Worker domains,
// Cloudflare.SecretsStore, etc.) will reconcile against
// pre-existing cloud resources instead of failing on duplicates.
// Default is `false` so an unintentional collision still surfaces
// loudly.
Layer.succeed(AdoptPolicy, adopt),
Layer.succeed(ArtifactStore, createArtifactStore()),
Layer.succeed(
AuthProviders,
yield* Effect.serviceOption(AuthProviders).pipe(
Effect.map(Option.getOrElse(() => ({}))),
),
),
ConfigProvider.layer(
withProfileOverride(yield* loadConfigProvider(envFile), profile),
),
Logger.layer([fileLogger("out")], { mergeWithExisting: true }),
Layer.succeed(Stage, stage),
);
yield* Effect.gen(function* () {
const cli = yield* CLI.Cli;
const stack = yield* stackEffect;
yield* Effect.gen(function* () {
const updatePlan = destroy
? yield* Plan.destroy(stack)
: yield* Plan.make(stack, { force });
if (dryRun) {
yield* cli.displayPlan(updatePlan);
} else {
const hasChanges =
Object.keys(updatePlan.deletions).length > 0 ||
Object.values(updatePlan.resources).some(
(node) =>
node.action !== "noop" ||
node.bindings.some((b) => b.action !== "noop"),
);
if (!yes && hasChanges) {
const approved = yield* cli.approvePlan(updatePlan);
if (!approved) {
return;
}
}
// Smoke-test kill switch: `ALCHEMY_DEV_ONCE=1 alchemy dev` exits after
// the first apply instead of keeping the dev session alive — apply
// failures propagate (non-zero exit) instead of being swallowed, so
// scripts and CI can assert "dev deploys cleanly" without a timeout.
const devOnce =
dev &&
(process.env.ALCHEMY_DEV_ONCE === "1" ||
process.env.ALCHEMY_DEV_ONCE === "true");
// In dev, a failed apply must not drain the keep-alive below:
// `alchemy dev` runs under `bun --watch`, which cancels watch mode
// entirely on a clean exit (oven-sh/bun#10983), so completing here
// would tear down every healthy local resource along with the failed
// one. Swallow apply failures (logging the full cause, since the TUI
// renderer only shows the failure status) so the keep-alive engages
// and the rest of the stack keeps serving, but re-propagate a pure
// interruption (Ctrl-C / fiber kill) so dev still shuts down cleanly.
const applyPlan =
dev && !devOnce
? apply(updatePlan).pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.failCause(cause)
: Console.error(
`alchemy dev: apply failed; keeping dev alive so healthy resources keep serving.\n${Cause.pretty(cause)}`,
).pipe(Effect.as(undefined)),
),
)
: apply(updatePlan);
const outputs = yield* applyPlan;
if (outputs !== undefined) {
yield* Console.log(outputs);
}
if (dev) {
if (devOnce) {
return;
}
return yield* Effect.never;
}
}
}).pipe(Effect.provide(stack.services));
}).pipe(Effect.provide(services));
});
// In dev, failures OUTSIDE the apply guard above must not exit the process
// either: the user saves mid-edit states where importing the stack module
// itself throws (missing export, module-evaluation crash), or planning fails
// against the half-edited program. Those failures escape `runStack` before
// the apply-level guard exists, and exiting here kills the `--watch` session
// (oven-sh/bun#10983), so dev would stop reloading on subsequent saves. Log
// the cause and park forever; the watcher restarts the run on the next file
// change. Pure interruption (Ctrl-C / fiber kill) still propagates so dev
// shuts down cleanly.
export const devKeepAlive = (
effect: Effect.Effect,
): Effect.Effect =>
effect.pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.failCause(cause)
: Console.error(
`alchemy dev: run failed; waiting for the next file change to retry.\n${Cause.pretty(cause)}`,
).pipe(Effect.andThen(Effect.never)),
),
);
export const execStack = (options: ExecStackOptions) =>
options.dev ? devKeepAlive(runStack(options)) : runStack(options);
export const deployCommand = Command.make(
"deploy",
{
dryRun: dryRunFlag,
force,
main: script,
envFile,
stage,
yes,
profile,
adopt,
},
instrumentCommand("deploy", stackSpanAttrs)(execStack),
);
export const destroyCommand = Command.make(
"destroy",
{
dryRun: dryRunFlag,
main: script,
envFile,
stage,
yes,
profile,
},
instrumentCommand(
"destroy",
stackSpanAttrs,
)((args) =>
execStack({
...args,
destroy: true,
}),
),
);
export const planCommand = Command.make(
"plan",
{
main: script,
envFile,
stage,
profile,
},
instrumentCommand(
"plan",
stackSpanAttrs,
)((args) =>
execStack({
...args,
// plan is the same as deploy with dryRun always set to true
dryRun: true,
}),
),
);