import * as Cause from "effect/Cause";
import * as Config from "effect/Config";
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 Path from "effect/Path";
import * as S from "effect/Schema";
import * as Argument from "effect/unstable/cli/Argument";
import * as CliError from "effect/unstable/cli/CliError";
import * as Flag from "effect/unstable/cli/Flag";
import { pathToFileURL } from "node:url";
import {
type AuthProvider,
AuthError,
AuthProviders,
} from "../../Auth/AuthProvider.ts";
import {
type AlchemyProfileProviders,
ALCHEMY_PROFILE,
withProfileOverride,
} from "../../Auth/Profile.ts";
import { AwsAuth } from "../../AWS/AuthProvider.ts";
import { AxiomAuth } from "../../Axiom/AuthProvider.ts";
import { CloudflareAuth } from "../../Cloudflare/Auth/AuthProvider.ts";
import { GitHubAuth } from "../../GitHub/AuthProvider.ts";
import { HetznerAuth } from "../../Hetzner/AuthProvider.ts";
import { NeonAuth } from "../../Neon/AuthProvider.ts";
import { PlanetscaleAuth } from "../../Planetscale/AuthProvider.ts";
import { PrismaAuth } from "../../Prisma/AuthProvider.ts";
import * as Stack from "../../Stack.ts";
import { Stage } from "../../Stage.ts";
import { recordCli } from "../../Telemetry/Metrics.ts";
import { PromptCancelled } from "../../Util/Clank.ts";
import { loadConfigProvider } from "../../Util/ConfigProvider.ts";
import { fileLogger } from "../../Util/FileLogger.ts";
export const USER = Config.string("USER").pipe(
Config.orElse(() => Config.string("USERNAME")),
Config.withDefault("unknown"),
);
export const STAGE = Config.string("STAGE").pipe(
Config.option,
(a) => a,
Effect.map(Option.getOrUndefined),
);
/**
* `true` if `e` is a {@link PromptCancelled}, or an {@link AuthError} whose
* `cause` chain bottoms out in one. Schema-tagged errors don't always
* survive `instanceof` across module boundaries, so we also accept any
* object whose `_tag` matches.
*/
const isPromptCancellation = (e: unknown): boolean => {
for (let cur: unknown = e, i = 0; cur != null && i < 16; i++) {
if (cur instanceof PromptCancelled) return true;
if (
typeof cur === "object" &&
(cur as { _tag?: unknown })._tag === "PromptCancelled"
) {
return true;
}
if (
cur instanceof AuthError ||
(typeof cur === "object" &&
(cur as { _tag?: unknown })._tag === "AuthError")
) {
cur = (cur as { cause?: unknown }).cause;
continue;
}
return false;
}
return false;
};
/**
* Catches user cancellations (Ctrl+C inside a prompt, surfaced as
* {@link PromptCancelled} or wrapped in an {@link AuthError}) and exits
* the CLI cleanly with a friendly message instead of dumping a stack
* trace.
*/
export const handleCancellation = (self: Effect.Effect) =>
self.pipe(
Effect.catchCause((cause) => {
const cancelled = cause.reasons.some((r) => {
if (Cause.isFailReason(r)) return isPromptCancellation(r.error);
if (Cause.isDieReason(r)) return isPromptCancellation(r.defect);
return false;
});
return cancelled
? Console.log("\nCancelled.")
: (Effect.failCause(cause) as Effect.Effect);
}),
// A bare fiber interrupt (Ctrl+C while not inside a prompt) shouldn't
// dump a stack trace either.
Effect.onInterrupt(() => Console.log("\nInterrupted.")),
);
export const stage = Flag.string("stage").pipe(
Flag.withSchema(S.String.check(S.isPattern(/^[a-z0-9]+([-_a-z0-9]+)*$/gi))),
Flag.withDescription("Stage to deploy to, defaults to dev_${USER}"),
Flag.optional,
Flag.map(Option.getOrUndefined),
Flag.mapEffect(
Effect.fn(function* (stage) {
if (stage) {
return stage;
}
return yield* STAGE.pipe(
Effect.catch(() =>
Effect.fail(
new CliError.MissingOption({
option: "stage",
}),
),
),
Effect.flatMap((s) =>
s === undefined
? USER.pipe(
Effect.map((user) => `dev_${user}`),
Effect.catch(() => Effect.succeed("unknown")),
)
: Effect.succeed(s),
),
);
}),
),
);
export const envFile = Flag.file("env-file").pipe(
Flag.optional,
Flag.withDescription(
"File to load environment variables from, defaults to .env",
),
);
export const dryRun = Flag.boolean("dry-run").pipe(
Flag.withDescription("Dry run the deployment, do not actually deploy"),
Flag.withDefault(false),
);
export const yes = Flag.boolean("yes").pipe(
Flag.withDescription("Yes to all prompts"),
Flag.withDefault(false),
);
export const force = Flag.boolean("force").pipe(
Flag.withDescription(
"Force updates for resources that would otherwise no-op",
),
Flag.withDefault(false),
);
export const script = Argument.file("main", {
mustExist: true,
}).pipe(
Argument.withDescription("Main file to deploy, defaults to alchemy.run.ts"),
Argument.withDefault("alchemy.run.ts"),
);
export const profile = Flag.string("profile").pipe(
Flag.withDescription(
"Auth profile to use (~/.alchemy/profiles.json). Defaults to $ALCHEMY_PROFILE or 'default'.",
),
Flag.optional,
Flag.mapEffect(
Effect.fn(function* (profile) {
// --profile wins; otherwise fall back to $ALCHEMY_PROFILE (which
// itself defaults to "default"). Without this, the flag's default
// would shadow the env var via withProfileOverride.
if (Option.isSome(profile)) {
return profile.value;
}
return yield* ALCHEMY_PROFILE.pipe(
Effect.catch(() => Effect.succeed("default")),
);
}),
),
);
export const resourceFilter = Flag.string("filter").pipe(
Flag.withDescription(
"Comma-separated logical resource IDs (e.g. Api,Sandbox). Only those resources are included.",
),
Flag.optional,
Flag.map(Option.getOrUndefined),
);
export const TAIL_COLORS = [
"\x1b[36m", // cyan
"\x1b[35m", // magenta
"\x1b[33m", // yellow
"\x1b[32m", // green
"\x1b[34m", // blue
"\x1b[91m", // bright red
"\x1b[96m", // bright cyan
"\x1b[95m", // bright magenta
"\x1b[93m", // bright yellow
"\x1b[92m", // bright green
];
export const TAIL_RESET = "\x1b[0m";
export const formatLocalTimestamp = (date: Date): string => {
const y = date.getFullYear();
const mo = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
const h = String(date.getHours()).padStart(2, "0");
const mi = String(date.getMinutes()).padStart(2, "0");
const s = String(date.getSeconds()).padStart(2, "0");
const ms = String(date.getMilliseconds()).padStart(3, "0");
const tz =
new Intl.DateTimeFormat("en-US", { timeZoneName: "short" })
.formatToParts(date)
.find((p) => p.type === "timeZoneName")?.value ?? "";
return `${y}-${mo}-${d} ${h}:${mi}:${s}.${ms} ${tz}`;
};
export const parseResourceFilter = (
filter: string | undefined,
): ReadonlySet | undefined => {
if (filter === undefined) return undefined;
const ids = filter
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (ids.length === 0) return undefined;
return new Set(ids);
};
export const parseSince = (value: string): Date => {
const match = value.match(/^(\d+)([smhd])$/);
if (match) {
const num = parseInt(match[1]!, 10);
const unit = match[2]!;
const ms =
unit === "s"
? num * 1000
: unit === "m"
? num * 60_000
: unit === "h"
? num * 3_600_000
: num * 86_400_000;
return new Date(Date.now() - ms);
}
const parsed = new Date(value);
if (isNaN(parsed.getTime())) {
throw new Error(
`Invalid --since value: '${value}'. Use a duration (e.g. '1h', '30m') or ISO date.`,
);
}
return parsed;
};
/**
* Wraps a CLI command handler with a top-level OpenTelemetry span
* (`cli.`) and bumps the `alchemy.cli.invocations` counter.
*
* `attrs` runs against the parsed command args and contributes
* additional attributes to the span (e.g. stage, profile, dry-run flag).
*
* Usage:
* ```ts
* Command.make(
* "deploy",
* { ...flags },
* instrumentCommand("deploy", (a) => ({
* "alchemy.stage": a.stage,
* "alchemy.profile": a.profile,
* }))(execStack),
* );
* ```
*/
export const instrumentCommand =
(
command: string,
attrs?: (args: AttrsArgs) => Record,
) =>
(
handler: (args: Args) => Effect.Effect,
): ((args: Args) => Effect.Effect) =>
(args) =>
handler(args).pipe(
Effect.withSpan(`cli.${command}`, {
attributes: attrs ? attrs(args) : {},
}),
recordCli(command),
);
/**
* Render a profile's stored credential entries the same way across
* `alchemy login` and `alchemy profile show`: one `── Provider ──`
* header per entry, then either the provider's own `prettyPrint` block
* (preferred) or a JSON-style fallback when the provider isn't
* registered in the supplied {@link AuthProviders} registry.
*/
export const printProfile = Effect.fn(function* (
profile: string,
stored: AlchemyProfileProviders,
registry: AuthProviders["Service"],
) {
yield* Console.log(`Profile: ${profile}`);
const names = Object.keys(stored).sort();
if (names.length === 0) {
yield* Console.log("(no providers configured)");
return;
}
for (const name of names) {
const cfg = stored[name]!;
yield* Console.log("");
yield* Console.log(`── ${name} ──`);
const provider: AuthProvider | undefined = registry[name];
if (provider == null) {
yield* Console.log(` method: ${cfg.method}`);
const { method: _method, ...rest } = cfg as Record & {
method: string;
};
for (const [k, v] of Object.entries(rest)) {
yield* Console.log(
` ${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`,
);
}
continue;
}
// A provider's `prettyPrint` catches its own credential-resolution
// failures, but some resolve paths `Effect.orDie` (e.g. AWS SSO with an
// expired token), which escapes as a defect. Contain it here — via
// `Console.log` so the message stays on stdout under this provider's
// header instead of interleaving on stderr — so one broken provider
// can't abort rendering the rest of the profile.
yield* provider.prettyPrint(profile, cfg).pipe(
Effect.catchCause((cause) => {
const error = Cause.squash(cause);
const message = error instanceof Error ? error.message : String(error);
return Console.log(` Failed to retrieve credentials: ${message}`);
}),
);
}
});
export const importStack = Effect.fn(function* (main: string) {
const path = yield* Path.Path;
// Build a `file://` URL from the absolute path. `import.meta.resolve` expects a
// module specifier / URL, not a raw filesystem path: on Windows an absolute
// path like `D:\stack.ts` is not a valid specifier and fails to resolve, so the
// CLI cannot load the user's stack. `pathToFileURL` produces a valid URL on
// every platform.
const url = pathToFileURL(path.resolve(main)).href;
const module = yield* Effect.promise(() => import(url));
const stackEffect = module.default as ReturnType<
ReturnType
>;
if (!Effect.isEffect(stackEffect)) {
return yield* Effect.die(
new Error(
`Main file '${main}' must export a default stack definition (export default Alchemy.Stack({...}))`,
),
);
}
return stackEffect as typeof stackEffect & {
stackName: string;
stage: string;
providers: Layer.Layer;
state: Layer.Layer;
};
});
/**
* Placeholder {@link Stack.Stack} value used while building a stack's
* `providers()` layer out of band. No real resources exist yet — we only
* want the layer's provider/auth registrations and cloud-environment
* services, so `resources`/`bindings`/`actions` are empty and the stage is a
* sentinel.
*/
const placeholderStack = (name: string) => ({
actions: {},
bindings: {},
name,
resources: {},
stage: "placeholder",
});
export interface BuildStackProvidersOptions {
/** Stack entrypoint to import (e.g. `"alchemy.run.ts"`). */
main: string;
envFile: Option.Option;
profile: string;
/**
* Registry to populate. Pass a pre-seeded registry (e.g. one that already
* has built-in providers) to layer the stack's providers on top of it,
* overriding by name. Defaults to a fresh empty registry.
*/
registry?: AuthProviders["Service"];
/**
* Logger layer used during the build. Defaults to the file logger
* (`out`). `alchemy unsafe nuke` overrides this to log to the console in
* debug mode.
*/
logger?: Layer.Layer;
/**
* Extra layer merged into the placeholder scaffold — e.g.
* `Layer.succeed(MinimumLogLevel, ...)`, which sets a fiber-ref default
* and so contributes no context service (`Layer`).
*/
extra?: Layer.Layer;
}
/**
* Import a stack entrypoint and build its `providers()` (+ `state()`) layer
* out of band against placeholder {@link Stack.Stack}/{@link Stage} services,
* so its `AuthProviderLayer` registrations land in an {@link AuthProviders}
* registry and the built context holds every resource provider plus the
* cloud-environment services their operations need.
*
* Shared by `alchemy login`, `alchemy profile show`, and `alchemy unsafe
* nuke`. The caller decides what to do with the result — use `authProviders`
* (login / profile show) or `context` (nuke) — and whether a missing/invalid
* entrypoint is fatal (login / nuke let it propagate) or best-effort
* (profile show wraps the call in `Effect.catchCause`).
*/
export const buildStackProviders = Effect.fn("buildStackProviders")(function* (
options: BuildStackProvidersOptions,
) {
const authProviders = options.registry ?? {};
const stackEffect = yield* importStack(options.main);
const configProvider = withProfileOverride(
yield* loadConfigProvider(options.envFile),
options.profile,
);
const context = yield* Layer.build(
(stackEffect.providers ?? Layer.empty).pipe(
Layer.provideMerge(stackEffect.state ?? Layer.empty),
Layer.provideMerge(
Layer.mergeAll(
Layer.succeed(AuthProviders, authProviders),
ConfigProvider.layer(configProvider),
options.logger ??
Logger.layer([fileLogger("out")], { mergeWithExisting: true }),
Layer.succeed(Stage, "placeholder"),
Layer.succeed(Stack.Stack, placeholderStack(stackEffect.stackName)),
options.extra ?? Layer.empty,
),
),
),
);
return { authProviders, context, stackEffect };
});
/**
* The auth providers Alchemy ships with. Used as the baseline registry so
* `alchemy login` works from any folder (no `alchemy.run.ts` required) and
* `alchemy profile show` can pretty-print any provider a profile mentions,
* even one the current stack doesn't wire up.
*/
export const builtinAuth = Layer.mergeAll(
AwsAuth,
AxiomAuth,
CloudflareAuth,
GitHubAuth,
HetznerAuth,
NeonAuth,
PlanetscaleAuth,
PrismaAuth,
);
/**
* Build {@link builtinAuth} against `registry` so every built-in auth
* provider registers itself, without importing any stack entrypoint.
*/
export const buildBuiltinAuthProviders = Effect.fn("buildBuiltinAuthProviders")(
function* (options: {
envFile: Option.Option;
profile: string;
/** Registry to populate. Defaults to a fresh empty registry. */
registry?: AuthProviders["Service"];
}) {
const authProviders = options.registry ?? {};
yield* Layer.build(
Layer.provide(
builtinAuth,
Layer.mergeAll(
Layer.succeed(AuthProviders, authProviders),
ConfigProvider.layer(
withProfileOverride(
yield* loadConfigProvider(options.envFile),
options.profile,
),
),
Logger.layer([fileLogger("out")], { mergeWithExisting: true }),
),
),
);
return authProviders;
},
);