@actuallyjamez/elysian 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Smart Terraform file handling - append missing blocks
3
+ */
4
+ /**
5
+ * Check if content has AWS provider configured
6
+ */
7
+ export function hasAwsProvider(content) {
8
+ return /source\s*=\s*["']hashicorp\/aws["']/.test(content);
9
+ }
10
+ /**
11
+ * Check if a variable exists in the content
12
+ */
13
+ export function hasVariable(content, name) {
14
+ const regex = new RegExp(`variable\\s+["']${name}["']`, "i");
15
+ return regex.test(content);
16
+ }
17
+ /**
18
+ * Check if a resource of a given type exists
19
+ */
20
+ export function hasResource(content, type) {
21
+ const regex = new RegExp(`resource\\s+["']${type}["']`, "i");
22
+ return regex.test(content);
23
+ }
24
+ /**
25
+ * Check if an output exists in the content
26
+ */
27
+ export function hasOutput(content, name) {
28
+ const regex = new RegExp(`output\\s+["']${name}["']`, "i");
29
+ return regex.test(content);
30
+ }
31
+ /**
32
+ * Providers template
33
+ */
34
+ const PROVIDERS_BLOCK = `
35
+ # Elysian: AWS Provider Configuration
36
+ terraform {
37
+ required_providers {
38
+ aws = {
39
+ source = "hashicorp/aws"
40
+ version = "~> 6.0"
41
+ }
42
+ }
43
+ }
44
+
45
+ provider "aws" {
46
+ region = var.region
47
+ }
48
+ `;
49
+ /**
50
+ * Smart append to providers.tf - only add if AWS provider is missing
51
+ */
52
+ export function appendProviders(existing) {
53
+ if (hasAwsProvider(existing)) {
54
+ return existing; // Already has AWS provider
55
+ }
56
+ return existing + "\n" + PROVIDERS_BLOCK;
57
+ }
58
+ /**
59
+ * Get missing variables and return the block to append
60
+ */
61
+ export function getMissingVariables(existing, apiName) {
62
+ const variables = {
63
+ region: `
64
+ variable "region" {
65
+ type = string
66
+ default = "eu-west-2"
67
+ }`,
68
+ lambda_names: `
69
+ variable "lambda_names" {
70
+ type = list(string)
71
+ default = []
72
+ }`,
73
+ api_routes: `
74
+ variable "api_routes" {
75
+ type = map(object({
76
+ lambda_key = string
77
+ route_key = string
78
+ path_parameters = list(string)
79
+ }))
80
+ default = {}
81
+ }`,
82
+ lambda_runtime: `
83
+ variable "lambda_runtime" {
84
+ type = string
85
+ default = "nodejs22.x"
86
+ }`,
87
+ lambda_memory_size: `
88
+ variable "lambda_memory_size" {
89
+ type = number
90
+ default = 256
91
+ }`,
92
+ lambda_timeout: `
93
+ variable "lambda_timeout" {
94
+ type = number
95
+ default = 30
96
+ }`,
97
+ api_name: `
98
+ variable "api_name" {
99
+ type = string
100
+ default = "${apiName}"
101
+ }`,
102
+ tags: `
103
+ variable "tags" {
104
+ type = map(string)
105
+ default = {}
106
+ }`,
107
+ };
108
+ const missing = [];
109
+ for (const [name, block] of Object.entries(variables)) {
110
+ if (!hasVariable(existing, name)) {
111
+ missing.push(block);
112
+ }
113
+ }
114
+ if (missing.length === 0) {
115
+ return existing;
116
+ }
117
+ return existing + "\n# Elysian: Required Variables" + missing.join("");
118
+ }
119
+ /**
120
+ * Main resources template
121
+ */
122
+ const MAIN_RESOURCES = `
123
+ # Elysian: Lambda and API Gateway Resources
124
+
125
+ locals {
126
+ lambda_functions = {
127
+ for name in var.lambda_names : name => {
128
+ filename = "\${path.module}/../dist/\${name}.zip"
129
+ handler = "index.handler"
130
+ source_code_hash = filebase64sha256("\${path.module}/../dist/\${name}.zip")
131
+ }
132
+ }
133
+ }
134
+
135
+ # API Gateway
136
+ resource "aws_apigatewayv2_api" "elysian" {
137
+ name = var.api_name
138
+ protocol_type = "HTTP"
139
+ tags = var.tags
140
+ }
141
+
142
+ # IAM Role for Lambda
143
+ resource "aws_iam_role" "elysian_lambda" {
144
+ name = "\${var.api_name}-lambda-role"
145
+
146
+ assume_role_policy = jsonencode({
147
+ Version = "2012-10-17"
148
+ Statement = [{
149
+ Action = "sts:AssumeRole"
150
+ Effect = "Allow"
151
+ Principal = {
152
+ Service = "lambda.amazonaws.com"
153
+ }
154
+ }]
155
+ })
156
+
157
+ tags = var.tags
158
+ }
159
+
160
+ resource "aws_iam_role_policy_attachment" "elysian_lambda_basic" {
161
+ role = aws_iam_role.elysian_lambda.name
162
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
163
+ }
164
+
165
+ # Lambda Functions
166
+ resource "aws_lambda_function" "elysian" {
167
+ for_each = local.lambda_functions
168
+
169
+ filename = each.value.filename
170
+ function_name = each.key
171
+ role = aws_iam_role.elysian_lambda.arn
172
+ handler = each.value.handler
173
+ source_code_hash = each.value.source_code_hash
174
+ runtime = var.lambda_runtime
175
+ memory_size = var.lambda_memory_size
176
+ timeout = var.lambda_timeout
177
+
178
+ tags = var.tags
179
+ }
180
+
181
+ # API Gateway Integrations
182
+ resource "aws_apigatewayv2_integration" "elysian" {
183
+ for_each = local.lambda_functions
184
+
185
+ api_id = aws_apigatewayv2_api.elysian.id
186
+ integration_type = "AWS_PROXY"
187
+ integration_uri = aws_lambda_function.elysian[each.key].invoke_arn
188
+ integration_method = "POST"
189
+ payload_format_version = "2.0"
190
+ }
191
+
192
+ # Lambda Permissions
193
+ resource "aws_lambda_permission" "elysian_apigateway" {
194
+ for_each = local.lambda_functions
195
+
196
+ statement_id = "AllowAPIGateway"
197
+ action = "lambda:InvokeFunction"
198
+ function_name = aws_lambda_function.elysian[each.key].function_name
199
+ principal = "apigateway.amazonaws.com"
200
+ source_arn = "\${aws_apigatewayv2_api.elysian.execution_arn}/*/*"
201
+ }
202
+
203
+ # API Gateway Routes
204
+ resource "aws_apigatewayv2_route" "elysian" {
205
+ for_each = var.api_routes
206
+
207
+ api_id = aws_apigatewayv2_api.elysian.id
208
+ route_key = each.value.route_key
209
+ target = "integrations/\${aws_apigatewayv2_integration.elysian[each.value.lambda_key].id}"
210
+ }
211
+
212
+ # API Gateway Stage
213
+ resource "aws_apigatewayv2_stage" "elysian" {
214
+ api_id = aws_apigatewayv2_api.elysian.id
215
+ name = "$default"
216
+ auto_deploy = true
217
+ tags = var.tags
218
+ }
219
+ `;
220
+ /**
221
+ * Smart append to main.tf - only add if API Gateway resource is missing
222
+ */
223
+ export function appendMain(existing) {
224
+ // Check for our specific resource or any API Gateway
225
+ if (hasResource(existing, "aws_apigatewayv2_api") ||
226
+ hasResource(existing, "aws_lambda_function")) {
227
+ return existing; // Already has Lambda/API Gateway resources
228
+ }
229
+ return existing + MAIN_RESOURCES;
230
+ }
231
+ /**
232
+ * Outputs template
233
+ */
234
+ const OUTPUTS_BLOCK = `
235
+ # Elysian: API Endpoint Output
236
+ output "api_endpoint" {
237
+ description = "API Gateway endpoint URL"
238
+ value = aws_apigatewayv2_stage.elysian.invoke_url
239
+ }
240
+ `;
241
+ /**
242
+ * Smart append to outputs.tf - only add if api_endpoint output is missing
243
+ */
244
+ export function appendOutputs(existing) {
245
+ if (hasOutput(existing, "api_endpoint")) {
246
+ return existing;
247
+ }
248
+ return existing + OUTPUTS_BLOCK;
249
+ }
250
+ /**
251
+ * Full templates for new files
252
+ */
253
+ export const templates = {
254
+ providers: PROVIDERS_BLOCK.trim() + "\n",
255
+ variables: (apiName) => `# Elysian: Terraform Variables
256
+
257
+ variable "region" {
258
+ type = string
259
+ default = "eu-west-2"
260
+ }
261
+
262
+ variable "lambda_names" {
263
+ type = list(string)
264
+ default = []
265
+ }
266
+
267
+ variable "api_routes" {
268
+ type = map(object({
269
+ lambda_key = string
270
+ route_key = string
271
+ path_parameters = list(string)
272
+ }))
273
+ default = {}
274
+ }
275
+
276
+ variable "lambda_runtime" {
277
+ type = string
278
+ default = "nodejs22.x"
279
+ }
280
+
281
+ variable "lambda_memory_size" {
282
+ type = number
283
+ default = 256
284
+ }
285
+
286
+ variable "lambda_timeout" {
287
+ type = number
288
+ default = 30
289
+ }
290
+
291
+ variable "api_name" {
292
+ type = string
293
+ default = "${apiName}"
294
+ }
295
+
296
+ variable "tags" {
297
+ type = map(string)
298
+ default = {}
299
+ }
300
+ `,
301
+ main: MAIN_RESOURCES.trim() + "\n",
302
+ outputs: `# Elysian: Outputs
303
+
304
+ output "api_endpoint" {
305
+ description = "API Gateway endpoint URL"
306
+ value = aws_apigatewayv2_stage.elysian.invoke_url
307
+ }
308
+ `,
309
+ };
@@ -1,12 +1,7 @@
1
1
  /**
2
- * Init command - Initialize a new elysian project
2
+ * Init command - Interactive wizard to initialize elysian projects
3
3
  */
4
4
  export declare const initCommand: import("citty").CommandDef<{
5
- name: {
6
- type: "string";
7
- description: string;
8
- default: string;
9
- };
10
5
  force: {
11
6
  type: "boolean";
12
7
  description: string;
@@ -1,21 +1,19 @@
1
1
  /**
2
- * Init command - Initialize a new elysian project
2
+ * Init command - Interactive wizard to initialize elysian projects
3
3
  */
4
4
  import { defineCommand } from "citty";
5
5
  import consola from "consola";
6
6
  import { existsSync, mkdirSync } from "fs";
7
- import { join } from "path";
7
+ import { resolve, basename } from "path";
8
+ import { detectProject, } from "./init/detect";
9
+ import { promptTargetDirectory, runFreshProjectWizard, runExistingProjectWizard, } from "./init/prompts";
10
+ import { scaffoldProject, installDependencies, printResults, } from "./init/scaffold";
8
11
  export const initCommand = defineCommand({
9
12
  meta: {
10
13
  name: "init",
11
14
  description: "Initialize a new elysian project",
12
15
  },
13
16
  args: {
14
- name: {
15
- type: "string",
16
- description: "API name",
17
- default: "my-api",
18
- },
19
17
  force: {
20
18
  type: "boolean",
21
19
  description: "Overwrite existing files",
@@ -23,273 +21,79 @@ export const initCommand = defineCommand({
23
21
  },
24
22
  },
25
23
  async run({ args }) {
26
- const cwd = process.cwd();
27
- const apiName = args.name;
28
- consola.start(`Initializing elysian project: ${apiName}`);
29
- // Check for existing config
30
- const configPath = join(cwd, "elysian.config.ts");
31
- if (existsSync(configPath) && !args.force) {
24
+ const originalCwd = process.cwd();
25
+ // Step 1: Get target directory
26
+ const targetDir = await promptTargetDirectory(basename(originalCwd));
27
+ if (!targetDir) {
28
+ consola.info("Cancelled");
29
+ process.exit(0);
30
+ }
31
+ // Resolve the target directory
32
+ const cwd = resolve(originalCwd, targetDir);
33
+ const apiName = basename(cwd);
34
+ // Create directory if it doesn't exist
35
+ if (!existsSync(cwd)) {
36
+ mkdirSync(cwd, { recursive: true });
37
+ consola.success(`Created directory: ${targetDir}`);
38
+ }
39
+ // Detect project state in target directory
40
+ const info = await detectProject(cwd);
41
+ // Check for existing config (unless force)
42
+ if (info.hasElysianConfig && !args.force) {
32
43
  consola.error("elysian.config.ts already exists. Use --force to overwrite.");
33
44
  process.exit(1);
34
45
  }
35
- // Create directories
36
- const lambdasDir = join(cwd, "src/lambdas");
37
- const terraformDir = join(cwd, "terraform");
38
- mkdirSync(lambdasDir, { recursive: true });
39
- mkdirSync(terraformDir, { recursive: true });
40
- consola.success("Created src/lambdas/");
41
- consola.success("Created terraform/");
42
- // Write config file
43
- const configContent = `import { defineConfig } from "@actuallyjamez/elysian";
44
-
45
- export default defineConfig({
46
- apiName: "${apiName}",
47
-
48
- // Lambda source directory
49
- lambdasDir: "src/lambdas",
50
-
51
- // Build output directory
52
- outputDir: "dist",
53
-
54
- // OpenAPI configuration
55
- openapi: {
56
- enabled: true,
57
- title: "${apiName}",
58
- version: "1.0.0",
59
- description: "API powered by Elysia and AWS Lambda",
60
- },
61
-
62
- // Terraform configuration
63
- terraform: {
64
- outputDir: "terraform",
65
- tfvarsFilename: "api-routes.auto.tfvars",
66
- },
67
-
68
- // Lambda defaults
69
- lambda: {
70
- runtime: "nodejs20.x",
71
- memorySize: 256,
72
- timeout: 30,
73
- },
74
- });
75
- `;
76
- await Bun.write(configPath, configContent);
77
- consola.success("Created elysian.config.ts");
78
- // Write example lambda
79
- const exampleLambdaPath = join(lambdasDir, "hello.ts");
80
- if (!existsSync(exampleLambdaPath) || args.force) {
81
- const exampleLambdaContent = `import { createLambda, t } from "@actuallyjamez/elysian";
82
-
83
- /**
84
- * Example Lambda - Hello World
85
- *
86
- * Routes defined here will be automatically:
87
- * - Bundled into a Lambda function
88
- * - Mapped to API Gateway routes
89
- * - Included in OpenAPI documentation
90
- */
91
- export default createLambda()
92
- .get("/hello", ({ query }) => {
93
- return \`Hello, \${query.name ?? "World"}!\`;
94
- }, {
95
- response: t.String(),
96
- query: t.Object({
97
- name: t.Optional(t.String()),
98
- }),
99
- detail: {
100
- summary: "Say hello",
101
- description: "Returns a greeting message",
102
- tags: ["Greeting"],
103
- },
104
- });
105
- `;
106
- await Bun.write(exampleLambdaPath, exampleLambdaContent);
107
- consola.success("Created src/lambdas/hello.ts");
46
+ // Run appropriate wizard based on whether directory is empty
47
+ let answers;
48
+ if (info.isEmpty) {
49
+ const result = await runFreshProjectWizard(apiName);
50
+ if (!result) {
51
+ consola.info("Cancelled");
52
+ process.exit(0);
53
+ }
54
+ answers = {
55
+ targetDir,
56
+ apiName,
57
+ ...result,
58
+ };
59
+ }
60
+ else {
61
+ const result = await runExistingProjectWizard(apiName, info.packageManager);
62
+ if (!result) {
63
+ consola.info("Cancelled");
64
+ process.exit(0);
65
+ }
66
+ answers = {
67
+ targetDir,
68
+ apiName,
69
+ ...result,
70
+ };
108
71
  }
109
- // Write Terraform main.tf template
110
- const terraformMainPath = join(terraformDir, "main.tf");
111
- if (!existsSync(terraformMainPath) || args.force) {
112
- const terraformContent = `terraform {
113
- required_providers {
114
- aws = {
115
- source = "hashicorp/aws"
116
- version = "~> 5.0"
117
- }
118
- }
119
- }
120
-
121
- provider "aws" {
122
- region = var.region
123
- }
124
-
125
- variable "region" {
126
- type = string
127
- default = "eu-west-2"
128
- }
129
-
130
- variable "lambda_names" {
131
- type = list(string)
132
- default = []
133
- }
134
-
135
- variable "api_routes" {
136
- type = map(object({
137
- lambda_key = string
138
- route_key = string
139
- path_parameters = list(string)
140
- }))
141
- default = {}
142
- }
143
-
144
- variable "lambda_runtime" {
145
- type = string
146
- default = "nodejs20.x"
147
- }
148
-
149
- variable "lambda_memory_size" {
150
- type = number
151
- default = 256
152
- }
153
-
154
- variable "lambda_timeout" {
155
- type = number
156
- default = 30
157
- }
158
-
159
- variable "api_name" {
160
- type = string
161
- default = "${apiName}"
162
- }
163
-
164
- variable "tags" {
165
- type = map(string)
166
- default = {}
167
- }
168
-
169
- locals {
170
- lambda_functions = {
171
- for name in var.lambda_names : name => {
172
- filename = "\${path.module}/../dist/\${name}.zip"
173
- handler = "index.handler"
174
- source_code_hash = filebase64sha256("\${path.module}/../dist/\${name}.zip")
175
- }
176
- }
177
- }
178
-
179
- # API Gateway
180
- resource "aws_apigatewayv2_api" "this" {
181
- name = var.api_name
182
- protocol_type = "HTTP"
183
- description = "API Gateway for \${var.api_name}"
184
- tags = var.tags
185
- }
186
-
187
- # IAM Role for Lambda
188
- resource "aws_iam_role" "lambda" {
189
- name = "\${var.api_name}-lambda-role"
190
-
191
- assume_role_policy = jsonencode({
192
- Version = "2012-10-17"
193
- Statement = [{
194
- Action = "sts:AssumeRole"
195
- Effect = "Allow"
196
- Principal = {
197
- Service = "lambda.amazonaws.com"
198
- }
199
- }]
200
- })
201
-
202
- tags = var.tags
203
- }
204
-
205
- resource "aws_iam_role_policy_attachment" "lambda_basic" {
206
- role = aws_iam_role.lambda.name
207
- policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
208
- }
209
-
210
- # Lambda Functions
211
- resource "aws_lambda_function" "this" {
212
- for_each = local.lambda_functions
213
-
214
- filename = each.value.filename
215
- function_name = "\${var.api_name}-\${each.key}"
216
- role = aws_iam_role.lambda.arn
217
- handler = each.value.handler
218
- source_code_hash = each.value.source_code_hash
219
- runtime = var.lambda_runtime
220
- memory_size = var.lambda_memory_size
221
- timeout = var.lambda_timeout
222
-
223
- tags = var.tags
224
- }
225
-
226
- # API Gateway Integrations
227
- resource "aws_apigatewayv2_integration" "this" {
228
- for_each = local.lambda_functions
229
-
230
- api_id = aws_apigatewayv2_api.this.id
231
- integration_type = "AWS_PROXY"
232
- integration_uri = aws_lambda_function.this[each.key].invoke_arn
233
- integration_method = "POST"
234
- payload_format_version = "2.0"
235
- }
236
-
237
- # Lambda Permissions
238
- resource "aws_lambda_permission" "apigateway" {
239
- for_each = local.lambda_functions
240
-
241
- statement_id = "AllowExecutionFromAPIGateway-\${each.key}"
242
- action = "lambda:InvokeFunction"
243
- function_name = aws_lambda_function.this[each.key].function_name
244
- principal = "apigateway.amazonaws.com"
245
- source_arn = "\${aws_apigatewayv2_api.this.execution_arn}/*/*"
246
- }
247
-
248
- # API Gateway Routes
249
- resource "aws_apigatewayv2_route" "this" {
250
- for_each = var.api_routes
251
-
252
- api_id = aws_apigatewayv2_api.this.id
253
- route_key = each.value.route_key
254
- target = "integrations/\${aws_apigatewayv2_integration.this[each.value.lambda_key].id}"
255
- }
256
-
257
- # API Gateway Stage
258
- resource "aws_apigatewayv2_stage" "this" {
259
- api_id = aws_apigatewayv2_api.this.id
260
- name = "$default"
261
- auto_deploy = true
262
-
263
- tags = var.tags
264
-
265
- depends_on = [
266
- aws_apigatewayv2_route.this,
267
- aws_lambda_permission.apigateway
268
- ]
269
- }
270
-
271
- # Outputs
272
- output "api_endpoint" {
273
- value = aws_apigatewayv2_stage.this.invoke_url
274
- }
275
-
276
- output "lambda_functions" {
277
- value = { for k, v in aws_lambda_function.this : k => v.arn }
278
- }
279
- `;
280
- await Bun.write(terraformMainPath, terraformContent);
281
- consola.success("Created terraform/main.tf");
72
+ // Scaffold files
73
+ const scaffoldResult = await scaffoldProject(cwd, info, answers, args.force);
74
+ // Print results
75
+ printResults(cwd, scaffoldResult);
76
+ // Install dependencies if requested
77
+ if (answers.installDeps) {
78
+ try {
79
+ await installDependencies(cwd, answers.packageManager);
80
+ }
81
+ catch (error) {
82
+ consola.error(error instanceof Error ? error.message : "Failed to install dependencies");
83
+ consola.info(`You can manually install with: ${answers.packageManager} add elysia @actuallyjamez/elysian`);
84
+ }
282
85
  }
283
86
  // Print next steps
284
87
  console.log("");
88
+ const pm = answers.packageManager;
89
+ const runCmd = pm === "npm" ? "npm run" : pm;
90
+ // If we created in a subdirectory, tell user to cd into it
91
+ const cdStep = targetDir !== "." ? `cd ${targetDir}\n\n` : "";
285
92
  consola.box("Project initialized!\n\n" +
286
93
  "Next steps:\n\n" +
287
- "1. Install dependencies:\n" +
288
- " bun add elysia @actuallyjamez/elysian\n\n" +
289
- "2. Add more lambdas in src/lambdas/\n\n" +
290
- "3. Build your lambdas:\n" +
291
- " bunx elysian build\n\n" +
292
- "4. Deploy with Terraform:\n" +
293
- " cd terraform && terraform init && terraform apply");
94
+ cdStep +
95
+ (answers.installDeps ? "" : `${pm} add elysia @actuallyjamez/elysian\n\n`) +
96
+ `${runCmd} elysian build\n\n` +
97
+ "cd terraform && terraform init && terraform apply");
294
98
  },
295
99
  });
@@ -20,7 +20,7 @@ export interface BuildConfig {
20
20
  external?: string[];
21
21
  }
22
22
  export interface LambdaConfig {
23
- /** Lambda runtime (default: "nodejs20.x") */
23
+ /** Lambda runtime (default: "nodejs22.x") */
24
24
  runtime?: string;
25
25
  /** Lambda memory size in MB (default: 256) */
26
26
  memorySize?: number;