Skip to content

Commit d30da72

Browse files
authored
feat(core): defineStack, defineStages and alchemy-effect CLI (#21)
1 parent fa8b893 commit d30da72

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

95 files changed

+2401
-1437
lines changed

.eslintignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
**/*.ts
1+
alchemy-effect/test/**

.oxlintrc.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"$schema": "./node_modules/oxfmt/configuration_schema.json",
3+
"rules": {
4+
"no-misused-new": "off",
5+
"require-yield": "off"
6+
}
7+
}

.vscode/settings.json

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
{
2+
"diffEditor.renderSideBySide": false,
23
"typescript.experimental.useTsgo": true,
34
"typescript.tsdk": "node_modules/typescript/lib",
45
"typescript.enablePromptUseWorkspaceTsdk": true,
56
"typescript.preferences.importModuleSpecifier": "relative",
67
"typescript.preferences.importModuleSpecifierEnding": "js",
7-
"oxc.fmt.experimental": true,
8+
"oxc.fmt.experimental": true,
89
"oxc.enable": true,
10+
"oxc.lint.run": "onSave",
911
"editor.defaultFormatter": "oxc.oxc-vscode",
1012
"editor.codeLens": false,
1113
"editor.formatOnSave": true,
1214
"editor.formatOnSaveMode": "file",
1315
"editor.inlayHints.enabled": "offUnlessPressed",
16+
"editor.indentSize": 2,
17+
"editor.insertSpaces": true,
1418
"editor.codeActionsOnSave": {
1519
"source.fixAll": "explicit",
1620
"source.organizeImports": "explicit"
@@ -19,9 +23,10 @@
1923
"editor.defaultFormatter": "tamasfe.even-better-toml"
2024
},
2125
"[json]": {
22-
"editor.indentSize": 2,
23-
"editor.insertSpaces": true,
24-
"editor.defaultFormatter": "oxc.oxc-vscode"
26+
"editor.defaultFormatter": "vscode.json-language-features"
27+
},
28+
"[jsonc]": {
29+
"editor.defaultFormatter": "vscode.json-language-features"
2530
},
2631
"[markdown]": {
2732
"editor.formatOnSave": false,
@@ -51,4 +56,4 @@
5156
"editor.indentSize": 2,
5257
"editor.defaultFormatter": "oxc.oxc-vscode"
5358
},
54-
}
59+
}
Lines changed: 266 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,266 @@
1-
console.log("CLI main not implemented");
2-
3-
/**
4-
* TODO: implement CLI main
5-
* 1. parse args
6-
* 2. import the user's program
7-
* 3. provide default layers
8-
* 4. implement apply and destroy functions
9-
* 5. support --dry-run etc.
10-
*/
1+
import { Command, Options, Args } from "@effect/cli";
2+
import * as ValidationError from "@effect/cli/ValidationError";
3+
import * as HelpDoc from "@effect/cli/HelpDoc";
4+
import * as ConfigProvider from "effect/ConfigProvider";
5+
import { NodeContext, NodeRuntime } from "@effect/platform-node";
6+
import * as PlatformConfigProvider from "@effect/platform/PlatformConfigProvider";
7+
import * as FetchHttpClient from "@effect/platform/FetchHttpClient";
8+
import { Path } from "@effect/platform/Path";
9+
import * as Logger from "effect/Logger";
10+
import * as Effect from "effect/Effect";
11+
import * as Config from "effect/Config";
12+
import * as Option from "effect/Option";
13+
import * as Layer from "effect/Layer";
14+
import { asEffect } from "../src/util.ts";
15+
import packageJson from "../package.json";
16+
import * as State from "../src/state.ts";
17+
import { applyPlan } from "../src/apply.ts";
18+
import { plan } from "../src/plan.ts";
19+
import { dotAlchemy } from "../src/dot-alchemy.ts";
20+
import * as App from "../src/app.ts";
21+
import type { Stack } from "../src/stack.ts";
22+
import * as CLI from "../src/cli/index.ts";
23+
import { Resource } from "../src/resource.ts";
24+
25+
const USER = Config.string("USER").pipe(
26+
Config.orElse(() => Config.string("USERNAME")),
27+
Config.withDefault("unknown"),
28+
);
29+
30+
const STAGE = Config.string("stage").pipe(
31+
Config.option,
32+
Effect.map(Option.getOrUndefined),
33+
);
34+
35+
const stage = Options.text("stage").pipe(
36+
Options.withDescription("Stage to deploy to, defaults to dev_${USER}"),
37+
Options.optional,
38+
Options.map(Option.getOrUndefined),
39+
Options.mapEffect(
40+
Effect.fn(function* (stage) {
41+
if (stage) {
42+
return stage;
43+
}
44+
return yield* STAGE.pipe(
45+
Effect.catchAll((err) =>
46+
Effect.fail(ValidationError.invalidValue(HelpDoc.p(err.message))),
47+
),
48+
Effect.flatMap((s) =>
49+
s === undefined
50+
? USER.pipe(
51+
Effect.map((user) => `dev_${user}`),
52+
Effect.catchAll(() => Effect.succeed("unknown")),
53+
)
54+
: Effect.succeed(s),
55+
),
56+
);
57+
}),
58+
),
59+
Options.mapEffect((stage) => {
60+
const regex = /^[a-z0-9]+([-_a-z0-9]+)*$/gi;
61+
return regex.test(stage)
62+
? Effect.succeed(stage)
63+
: Effect.fail(
64+
ValidationError.invalidValue(
65+
HelpDoc.p(
66+
`Stage '${stage}' is invalid. Must match the regex ${regex.source} (alphanumeric characters, hyphens and dashes).`,
67+
),
68+
),
69+
);
70+
}),
71+
);
72+
73+
const envFile = Options.file("env-file").pipe(
74+
Options.optional,
75+
Options.withDescription(
76+
"File to load environment variables from, defaults to .env",
77+
),
78+
);
79+
80+
const dryRun = Options.boolean("dry-run").pipe(
81+
Options.withDescription("Dry run the deployment, do not actually deploy"),
82+
Options.withDefault(false),
83+
);
84+
85+
const yes = Options.boolean("yes").pipe(
86+
Options.withDescription("Yes to all prompts"),
87+
Options.withDefault(false),
88+
);
89+
90+
const main = Args.file({
91+
name: "main",
92+
exists: "yes",
93+
}).pipe(
94+
Args.withDescription("Main file to deploy, defaults to alchemy.run.ts"),
95+
Args.withDefault("alchemy.run.ts"),
96+
);
97+
98+
const deployCommand = Command.make(
99+
"deploy",
100+
{
101+
dryRun,
102+
main,
103+
envFile,
104+
stage,
105+
yes,
106+
},
107+
(args) =>
108+
execStack({
109+
...args,
110+
select: (stack) => stack.resources,
111+
}),
112+
);
113+
114+
const destroyCommand = Command.make(
115+
"destroy",
116+
{
117+
dryRun,
118+
main,
119+
envFile,
120+
stage,
121+
},
122+
(args) =>
123+
execStack({
124+
...args,
125+
// destroy is just a plan with 0 resources
126+
select: () => [],
127+
}),
128+
);
129+
130+
const planCommand = Command.make(
131+
"plan",
132+
{
133+
main,
134+
envFile,
135+
stage,
136+
},
137+
(args) =>
138+
execStack({
139+
...args,
140+
// plan is the same as deploy with dryRun always set to true
141+
dryRun: true,
142+
select: (stack) => stack.resources,
143+
}),
144+
);
145+
146+
const execStack = Effect.fn(function* ({
147+
main,
148+
stage,
149+
envFile,
150+
dryRun = false,
151+
yes = false,
152+
select,
153+
}: {
154+
main: string;
155+
stage: string;
156+
envFile: Option.Option<string>;
157+
dryRun?: boolean;
158+
yes?: boolean;
159+
select: (stack: Stack<string, any, never, never, never, never>) => Resource[];
160+
}) {
161+
const path = yield* Path;
162+
const module = yield* Effect.promise(
163+
() => import(path.resolve(process.cwd(), main)),
164+
);
165+
const stack = module.default as Stack<
166+
string,
167+
any,
168+
never,
169+
never,
170+
never,
171+
never
172+
>;
173+
if (!stack) {
174+
return yield* Effect.die(
175+
new Error(
176+
`Main file '${main}' must export a default stack definition (export default defineStack({...}))`,
177+
),
178+
);
179+
}
180+
181+
const stackName = stack.name;
182+
183+
const configProvider = Option.isSome(envFile)
184+
? ConfigProvider.orElse(
185+
yield* PlatformConfigProvider.fromDotEnv(envFile.value),
186+
ConfigProvider.fromEnv,
187+
)
188+
: ConfigProvider.fromEnv();
189+
190+
const stageConfig = yield* asEffect(stack.stages.config(stage)).pipe(
191+
Effect.provide(stack.layers ?? Layer.empty),
192+
Effect.withConfigProvider(configProvider),
193+
);
194+
195+
// TODO(sam): implement local and watch
196+
const platform = Layer.mergeAll(
197+
NodeContext.layer,
198+
FetchHttpClient.layer,
199+
Logger.pretty,
200+
);
201+
202+
// override alchemy state store, CLI/reporting and dotAlchemy
203+
const alchemy = Layer.mergeAll(
204+
stack.state ?? State.localFs,
205+
stack.cli ?? CLI.inkCLI(),
206+
// optional
207+
dotAlchemy,
208+
);
209+
210+
const layers = Layer.provideMerge(
211+
Layer.provideMerge(stack.providers, alchemy),
212+
Layer.mergeAll(
213+
platform,
214+
App.make({
215+
name: stackName,
216+
stage,
217+
config: stageConfig,
218+
}),
219+
),
220+
);
221+
222+
yield* Effect.gen(function* () {
223+
const cli = yield* CLI.CLI;
224+
const resources = select(stack);
225+
const updatePlan = yield* plan(...resources);
226+
if (dryRun) {
227+
yield* cli.displayPlan(updatePlan);
228+
} else {
229+
if (!yes) {
230+
const approved = yield* cli.approvePlan(updatePlan);
231+
if (!approved) {
232+
return;
233+
}
234+
}
235+
const outputs = yield* applyPlan(updatePlan);
236+
if (outputs && stack.tap) {
237+
yield* stack
238+
.tap(outputs)
239+
.pipe(Effect.provide(stack.layers ?? Layer.empty));
240+
}
241+
}
242+
}).pipe(
243+
Effect.provide(layers),
244+
Effect.withConfigProvider(configProvider),
245+
) as Effect.Effect<void, any, never>;
246+
// TODO(sam): figure out why we need to cast to remove the Provider<never> requirement
247+
// Effect.Effect<void, any, Provider<never>>;
248+
});
249+
250+
const root = Command.make("alchemy-effect", {}).pipe(
251+
Command.withSubcommands([deployCommand, destroyCommand, planCommand]),
252+
);
253+
254+
// Set up the CLI application
255+
const cli = Command.run(root, {
256+
name: "Alchemy Effect CLI",
257+
version: packageJson.version,
258+
});
259+
260+
// Prepare and run the CLI application
261+
cli(process.argv).pipe(
262+
// $USER and $STAGE are set by the environment
263+
Effect.withConfigProvider(ConfigProvider.fromEnv()),
264+
Effect.provide(NodeContext.layer),
265+
NodeRuntime.runMain,
266+
);

alchemy-effect/package.json

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@
2020
"alchemy-effect": "bin/alchemy-effect.js"
2121
},
2222
"scripts": {
23+
"dev": "tsdown --watch",
2324
"build": "bun bundle && bun pm pack",
2425
"bundle": "tsdown",
2526
"bundle:watch": "tsdown --watch",
2627
"publish:npm": "cp ../README.md . && bun publish && rm README.md",
27-
"test": "vitest run",
2828
"test:benchmark": "tsx --tsconfig tsconfig.test.json test/types.benchmark.ts"
2929
},
3030
"exports": {
@@ -88,11 +88,6 @@
8888
"import": "./lib/cloudflare/index.js",
8989
"types": "./lib/cloudflare/index.d.ts"
9090
},
91-
"./cloudflare/live": {
92-
"bun": "./src/cloudflare/live.ts",
93-
"import": "./lib/cloudflare/live.js",
94-
"types": "./lib/cloudflare/live.d.ts"
95-
},
9691
"./cloudflare/assets": {
9792
"bun": "./src/cloudflare/worker/assets.fetch.ts",
9893
"import": "./lib/cloudflare/worker/assets.fetch.js",
@@ -133,9 +128,10 @@
133128
"devDependencies": {
134129
"@clack/prompts": "^0.11.0",
135130
"@cloudflare/workers-types": "catalog:",
131+
"@effect/cli": "catalog:",
132+
"@effect/cluster": "catalog:",
136133
"@effect/platform": "catalog:",
137134
"@effect/platform-node": "catalog:",
138-
"@effect/typeclass": "catalog:",
139135
"@types/aws-lambda": "catalog:",
140136
"@types/bun": "catalog:",
141137
"@types/node": "catalog:",
@@ -168,4 +164,4 @@
168164
"publishConfig": {
169165
"access": "public"
170166
}
171-
}
167+
}

0 commit comments

Comments
 (0)