1import * as Clock from "effect/Clock";
2import * as Config from "effect/Config";
3import * as ConfigProvider from "effect/ConfigProvider";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
8import * as Argument from "effect/cli/Argument";
9import * as Flag from "effect/cli/Flag";
10import { loadConfigProvider } from "../../Util/ConfigProvider.ts";
11import { UserInputError } from "./errors.ts";
12
13export const USER = Config.String("USER").pipe(
14 Config.orElse(() => Config.String("USERNAME")),
15 Config.withDefault("unknown"),
16);
17
18/** `live_$USER`, `dev_$USER`, or `test_$USER` (falls back to `*_unknown`). */
19export const userStage = (kind: "live" | "dev" | "test") =>
20 USER.pipe(
21 Effect.map((user) => `${kind}_${user}`),
22 Effect.catch(() => Effect.succeed(`${kind}_unknown`)),
23 );
24
25export const ALCHEMY_STAGE = Config.String("ALCHEMY_STAGE").pipe(
26 Config.option,
27 Effect.map(Option.getOrUndefined),
28);
29
30const STAGE_NAME_PATTERN = /^[a-z0-9]+([-_a-z0-9]+)*$/i;
31
32const makeStageFlag = (kind: "live" | "dev") =>
33 Flag.String("stage").pipe(
34 Flag.withSchema(
35 Schema.String.check(Schema.isPattern(/^[a-z0-9]+([-_a-z0-9]+)*$/gi)),
36 ),
37 Flag.withDescription(
38 kind === "live"
39 ? "Stage to deploy to. Defaults to $ALCHEMY_STAGE or live_${USER}"
40 : "Stage to use for dev. Defaults to $ALCHEMY_STAGE or dev_${USER}",
41 ),
42 Flag.optional,
43 Flag.map(Option.getOrUndefined),
44 );
45
46/** `--stage` for deploy / destroy / plan / logs / drift. Default: `live_$USER`. */
47export const stage = makeStageFlag("live");
48
49/** `--stage` for `alchemy dev`. Default: `dev_$USER`. */
50export const devStage = makeStageFlag("dev");
51
52/**
53 * Resolve the target stage after the command's dotenv provider is known.
54 * `--stage` wins; otherwise `$ALCHEMY_STAGE` from process env / `--env-file`
55 * / `.env`; otherwise `live_$USER` or `dev_$USER`.
56 *
57 * `$STAGE` is not consulted. Use `$ALCHEMY_STAGE` so stage selection
58 * matches `$ALCHEMY_PROFILE`.
59 */
60export const resolveStage = Effect.fn(function* (
61 kind: "live" | "dev",
62 override: string | undefined,
63 envFile: Option.Option<string>,
64) {
65 if (override) return override;
66 const provider = yield* loadConfigProvider(envFile);
67 const configured = yield* ALCHEMY_STAGE.pipe(
68 Effect.provideService(ConfigProvider.ConfigProvider, provider),
69 Effect.catch(() => Effect.succeed(undefined)),
70 );
71 if (configured !== undefined && configured !== "") {
72 if (!STAGE_NAME_PATTERN.test(configured)) {
73 return yield* new UserInputError({
74 message: `Invalid $ALCHEMY_STAGE '${configured}'. Must match [a-z0-9]+([-_a-z0-9]+)*.`,
75 });
76 }
77 return configured;
78 }
79 return yield* userStage(kind);
80});
81
82export const envFile = Flag.File("env-file").pipe(
83 Flag.optional,
84 Flag.withDescription(
85 "File to load environment variables from, defaults to .env",
86 ),
87);
88
89export const dryRun = Flag.Boolean("dry-run").pipe(
90 Flag.withDescription("Dry run the deployment, do not actually deploy"),
91 Flag.withDefault(false),
92);
93
94export const yes = Flag.Boolean("yes").pipe(
95 Flag.withAlias("y"),
96 Flag.withDescription("Yes to all prompts"),
97 Flag.withDefault(false),
98);
99
100export const force = Flag.Boolean("force").pipe(
101 Flag.withDescription(
102 "Force updates for resources that would otherwise no-op",
103 ),
104 Flag.withDefault(false),
105);
106
107/** One pattern per occurrence; commas and whitespace are preserved. */
108const selectionFlag = (name: "include" | "exclude", description: string) =>
109 Flag.String(name).pipe(
110 Flag.withDescription(description),
111 Flag.atLeast(0),
112 Flag.map((values) => (values.length === 0 ? undefined : values)),
113 );
114
115export const include = selectionFlag(
116 "include",
117 "Include exact FQNs, unique logical IDs, or FQN globs and their dependencies (repeatable, one pattern per flag). Quote globs: --include 'App/**'. Other rows and stack outputs are preserved; the whole declaration still runs.",
118);
119
120export const exclude = selectionFlag(
121 "exclude",
122 "Exclude exact FQNs, unique logical IDs, or FQN globs (repeatable, one pattern per flag). Quote globs: --exclude 'App/Legacy/**'. Required excluded dependencies fail planning, even when unchanged.",
123);
124
125export const validateSelectionOptions = (options: {
126 readonly include?: ReadonlyArray<string>;
127 readonly exclude?: ReadonlyArray<string>;
128 readonly destroy?: boolean;
129 readonly detectDrift?: boolean;
130}) =>
131 (options.include !== undefined || options.exclude !== undefined) &&
132 (options.destroy || options.detectDrift)
133 ? Effect.fail(
134 new UserInputError({
135 message:
136 "--include/--exclude cannot be combined with destroy or --detect-drift.",
137 }),
138 )
139 : Effect.void;
140
141export const config = Flag.File("config", { mustExist: true }).pipe(
142 Flag.withDescription("Alchemy entrypoint file (default: alchemy.run.ts)"),
143 Flag.withAlias("c"),
144 Flag.withDefault("alchemy.run.ts"),
145);
146
147export const optionalConfig = Flag.File("config", { mustExist: true }).pipe(
148 Flag.withDescription("Alchemy entrypoint file (default: alchemy.run.ts)"),
149 Flag.withAlias("c"),
150 Flag.optional,
151 Flag.map(Option.getOrUndefined),
152);
153
154export const configPath = Argument.File("config", { mustExist: true }).pipe(
155 Argument.withDescription("Alchemy entrypoint file (default: alchemy.run.ts)"),
156 Argument.optional,
157 Argument.map(Option.getOrUndefined),
158);
159
160export const resolveConfig = <
161 A extends {
162 readonly config: string | undefined;
163 readonly configPath: string | undefined;
164 },
165>(
166 args: A,
167) =>
168 Effect.gen(function* () {
169 if (args.config !== undefined && args.configPath !== undefined) {
170 return yield* new UserInputError({
171 message:
172 "Pass the config path either positionally or with --config, not both.",
173 });
174 }
175 return {
176 ...args,
177 main: args.config ?? args.configPath ?? "alchemy.run.ts",
178 };
179 });
180
181export const resolveStackArgs =
182 (kind: "live" | "dev") =>
183 <
184 A extends {
185 readonly config: string | undefined;
186 readonly configPath: string | undefined;
187 readonly stage: string | undefined;
188 readonly envFile: Option.Option<string>;
189 },
190 >(
191 args: A,
192 ) =>
193 Effect.gen(function* () {
194 const resolved = yield* resolveConfig(args);
195 const stage = yield* resolveStage(kind, args.stage, args.envFile);
196 return { ...resolved, stage };
197 });
198
199export const profile = Flag.String("profile").pipe(
200 Flag.withDescription(
201 "Auth profile to use. Defaults to $ALCHEMY_PROFILE or 'default'.",
202 ),
203 Flag.optional,
204 Flag.map(Option.getOrUndefined),
205);
206
207export const parseSince = (value: string) =>
208 Effect.gen(function* () {
209 const match = value.match(/^(\d+)([smhd])$/);
210 if (match) {
211 const amount = match[1];
212 const unit = match[2];
213 if (amount === undefined || unit === undefined) {
214 return yield* new UserInputError({
215 message: `Invalid --since value: '${value}'.`,
216 });
217 }
218 const num = parseInt(amount, 10);
219 const duration =
220 unit === "s"
221 ? Duration.seconds(num)
222 : unit === "m"
223 ? Duration.minutes(num)
224 : unit === "h"
225 ? Duration.hours(num)
226 : Duration.days(num);
227 const now = yield* Clock.currentTimeMillis;
228 return new Date(now - Duration.toMillis(duration));
229 }
230 const parsed = new Date(value);
231 if (isNaN(parsed.getTime())) {
232 return yield* new UserInputError({
233 message: `Invalid --since value: '${value}'. Use a duration (e.g. '1h', '30m') or ISO date.`,
234 });
235 }
236 return parsed;
237 });