@actuallyjamez/elysian 0.3.0 → 0.5.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.
@@ -1,21 +1,20 @@
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
+ import pc from "picocolors";
6
7
  import { existsSync, mkdirSync } from "fs";
7
- import { join } from "path";
8
+ import { resolve, basename } from "path";
9
+ import { detectProject, } from "./init/detect";
10
+ import { promptTargetDirectory, runFreshProjectWizard, runExistingProjectWizard, } from "./init/prompts";
11
+ import { scaffoldProject, installDependencies, printResults, } from "./init/scaffold";
8
12
  export const initCommand = defineCommand({
9
13
  meta: {
10
14
  name: "init",
11
15
  description: "Initialize a new elysian project",
12
16
  },
13
17
  args: {
14
- name: {
15
- type: "string",
16
- description: "API name",
17
- default: "my-api",
18
- },
19
18
  force: {
20
19
  type: "boolean",
21
20
  description: "Overwrite existing files",
@@ -23,273 +22,85 @@ export const initCommand = defineCommand({
23
22
  },
24
23
  },
25
24
  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) {
25
+ const originalCwd = process.cwd();
26
+ // Step 1: Get target directory
27
+ const targetDir = await promptTargetDirectory(basename(originalCwd));
28
+ if (!targetDir) {
29
+ consola.info("Cancelled");
30
+ process.exit(0);
31
+ }
32
+ // Resolve the target directory
33
+ const cwd = resolve(originalCwd, targetDir);
34
+ const name = basename(cwd);
35
+ // Create directory if it doesn't exist
36
+ if (!existsSync(cwd)) {
37
+ mkdirSync(cwd, { recursive: true });
38
+ consola.success(`Created directory: ${targetDir}`);
39
+ }
40
+ // Detect project state in target directory
41
+ const info = await detectProject(cwd);
42
+ // Check for existing config (unless force)
43
+ if (info.hasElysianConfig && !args.force) {
32
44
  consola.error("elysian.config.ts already exists. Use --force to overwrite.");
33
45
  process.exit(1);
34
46
  }
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");
47
+ // Run appropriate wizard based on whether directory is empty
48
+ let answers;
49
+ if (info.isEmpty) {
50
+ const result = await runFreshProjectWizard(name);
51
+ if (!result) {
52
+ consola.info("Cancelled");
53
+ process.exit(0);
54
+ }
55
+ answers = {
56
+ targetDir,
57
+ ...result,
58
+ };
59
+ }
60
+ else {
61
+ const result = await runExistingProjectWizard(name, info.packageManager);
62
+ if (!result) {
63
+ consola.info("Cancelled");
64
+ process.exit(0);
65
+ }
66
+ answers = {
67
+ targetDir,
68
+ ...result,
69
+ };
108
70
  }
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");
71
+ // Scaffold files
72
+ const scaffoldResult = await scaffoldProject(cwd, info, answers, args.force);
73
+ // Print results
74
+ printResults(cwd, scaffoldResult);
75
+ // Install dependencies if requested
76
+ if (answers.installDeps) {
77
+ try {
78
+ await installDependencies(cwd, answers.packageManager);
79
+ }
80
+ catch (error) {
81
+ consola.error(error instanceof Error ? error.message : "Failed to install dependencies");
82
+ consola.info(`You can manually install with: ${answers.packageManager} add elysia @actuallyjamez/elysian`);
83
+ }
282
84
  }
283
85
  // Print next steps
284
86
  console.log("");
285
- consola.box("Project initialized!\n\n" +
286
- "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");
87
+ const pm = answers.packageManager;
88
+ const runCmd = pm === "npm" ? "npm run" : pm;
89
+ // If we created in a subdirectory, tell user to cd into it
90
+ const cdStep = targetDir !== "." ? `cd ${targetDir}\n\n` : "";
91
+ console.log(` ${pc.green("✓")} Project initialized!`);
92
+ console.log();
93
+ console.log(` ${pc.bold("Next steps")}:`);
94
+ console.log();
95
+ if (cdStep) {
96
+ console.log(` ${cdStep}`);
97
+ }
98
+ if (!answers.installDeps) {
99
+ console.log(` ${pm} add elysia @actuallyjamez/elysian`);
100
+ console.log();
101
+ }
102
+ console.log(` ${runCmd} elysian build`);
103
+ console.log();
104
+ console.log(` cd terraform && terraform init && terraform apply`);
294
105
  },
295
106
  });
@@ -4,9 +4,9 @@
4
4
  export interface OpenAPIConfig {
5
5
  /** Enable OpenAPI auto-aggregation (default: true) */
6
6
  enabled?: boolean;
7
- /** API title for OpenAPI spec */
7
+ /** API title for OpenAPI spec (defaults to name if not provided) */
8
8
  title?: string;
9
- /** API version for OpenAPI spec */
9
+ /** API version for OpenAPI spec (defaults to package.json version if not provided) */
10
10
  version?: string;
11
11
  /** API description for OpenAPI spec */
12
12
  description?: string;
@@ -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;
@@ -35,7 +35,7 @@ export interface TerraformConfig {
35
35
  }
36
36
  export interface ElysianConfig {
37
37
  /** Name of the API (used for resource naming) */
38
- apiName: string;
38
+ name: string;
39
39
  /** Directory containing lambda files (default: "src/lambdas") */
40
40
  lambdasDir?: string;
41
41
  /** Output directory for built lambdas (default: "dist") */
@@ -50,7 +50,7 @@ export interface ElysianConfig {
50
50
  terraform?: TerraformConfig;
51
51
  }
52
52
  export interface ResolvedConfig {
53
- apiName: string;
53
+ name: string;
54
54
  lambdasDir: string;
55
55
  outputDir: string;
56
56
  openapi: Required<OpenAPIConfig>;
@@ -65,7 +65,7 @@ export declare function defineConfig(config: ElysianConfig): ElysianConfig;
65
65
  /**
66
66
  * Resolve configuration with defaults applied
67
67
  */
68
- export declare function resolveConfig(config: ElysianConfig): ResolvedConfig;
68
+ export declare function resolveConfig(config: ElysianConfig, cwd: string): Promise<ResolvedConfig>;
69
69
  /**
70
70
  * Load configuration from elysian.config.ts
71
71
  */
@@ -16,7 +16,7 @@ const DEFAULT_CONFIG = {
16
16
  external: ["@aws-sdk/*"],
17
17
  },
18
18
  lambda: {
19
- runtime: "nodejs20.x",
19
+ runtime: "nodejs22.x",
20
20
  memorySize: 256,
21
21
  timeout: 30,
22
22
  },
@@ -25,6 +25,19 @@ const DEFAULT_CONFIG = {
25
25
  tfvarsFilename: "api-routes.auto.tfvars",
26
26
  },
27
27
  };
28
+ /**
29
+ * Read version from package.json
30
+ */
31
+ async function readPackageVersion(cwd) {
32
+ const packagePath = `${cwd}/package.json`;
33
+ try {
34
+ const content = await Bun.file(packagePath).json();
35
+ return content.version || null;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
28
41
  /**
29
42
  * Define configuration with type safety and defaults
30
43
  */
@@ -34,15 +47,16 @@ export function defineConfig(config) {
34
47
  /**
35
48
  * Resolve configuration with defaults applied
36
49
  */
37
- export function resolveConfig(config) {
50
+ export async function resolveConfig(config, cwd) {
51
+ const pkgVersion = await readPackageVersion(cwd);
38
52
  return {
39
- apiName: config.apiName,
53
+ name: config.name,
40
54
  lambdasDir: config.lambdasDir ?? DEFAULT_CONFIG.lambdasDir,
41
55
  outputDir: config.outputDir ?? DEFAULT_CONFIG.outputDir,
42
56
  openapi: {
43
57
  enabled: config.openapi?.enabled ?? DEFAULT_CONFIG.openapi.enabled,
44
- title: config.openapi?.title ?? DEFAULT_CONFIG.openapi.title,
45
- version: config.openapi?.version ?? DEFAULT_CONFIG.openapi.version,
58
+ title: config.openapi?.title ?? config.name,
59
+ version: config.openapi?.version ?? pkgVersion ?? DEFAULT_CONFIG.openapi.version,
46
60
  description: config.openapi?.description ?? DEFAULT_CONFIG.openapi.description,
47
61
  },
48
62
  build: {
@@ -70,10 +84,10 @@ export async function loadConfig(cwd = process.cwd()) {
70
84
  try {
71
85
  const configModule = await import(configPath);
72
86
  const config = configModule.default;
73
- if (!config.apiName) {
74
- throw new Error("apiName is required in elysian.config.ts");
87
+ if (!config.name) {
88
+ throw new Error("name is required in elysian.config.ts");
75
89
  }
76
- return resolveConfig(config);
90
+ return resolveConfig(config, cwd);
77
91
  }
78
92
  catch (error) {
79
93
  if (error.code === "ERR_MODULE_NOT_FOUND") {
@@ -36,7 +36,7 @@ export declare function generateRouteName(lambda: string, method: string, path:
36
36
  /**
37
37
  * Generate manifest by introspecting built lambda modules
38
38
  */
39
- export declare function generateManifest(lambdaFiles: string[], outputDir: string, openapiEnabled?: boolean, apiName?: string): Promise<ApiManifest>;
39
+ export declare function generateManifest(lambdaFiles: string[], outputDir: string, openapiEnabled?: boolean, name?: string): Promise<ApiManifest>;
40
40
  /**
41
41
  * Write manifest to JSON file
42
42
  */
@@ -31,7 +31,7 @@ export function generateRouteName(lambda, method, path) {
31
31
  /**
32
32
  * Generate manifest by introspecting built lambda modules
33
33
  */
34
- export async function generateManifest(lambdaFiles, outputDir, openapiEnabled = true, apiName = "") {
34
+ export async function generateManifest(lambdaFiles, outputDir, openapiEnabled = true, name = "") {
35
35
  const manifest = {
36
36
  lambdas: [],
37
37
  routes: [],
@@ -43,7 +43,7 @@ export async function generateManifest(lambdaFiles, outputDir, openapiEnabled =
43
43
  for (const file of sortedFiles) {
44
44
  const originalName = file.replace(/\.ts$/, "");
45
45
  // Use prefixed bundle name for file lookup
46
- const bundleName = apiName ? getLambdaBundleName(apiName, originalName) : originalName;
46
+ const bundleName = name ? getLambdaBundleName(name, originalName) : originalName;
47
47
  const modulePath = isAbsolute(outputDir)
48
48
  ? join(outputDir, `${bundleName}.js`)
49
49
  : join(process.cwd(), outputDir, `${bundleName}.js`);
@@ -71,12 +71,12 @@ export async function generateManifest(lambdaFiles, outputDir, openapiEnabled =
71
71
  });
72
72
  // Determine which lambda should handle this route
73
73
  let targetLambda = bundleName;
74
- // OpenAPI routes always go to the __openapi__ lambda if enabled
74
+ // OpenAPI routes always go to __openapi__ lambda if enabled
75
75
  if (openapiEnabled && path.startsWith("/openapi")) {
76
- targetLambda = apiName ? getLambdaBundleName(apiName, "__openapi__") : "__openapi__";
76
+ targetLambda = name ? getLambdaBundleName(name, "__openapi__") : "__openapi__";
77
77
  }
78
78
  else if (originalName === "__openapi__") {
79
- // Skip non-openapi routes from the openapi aggregator lambda
79
+ // Skip non-openapi routes from openapi aggregator lambda
80
80
  continue;
81
81
  }
82
82
  // Check for route conflicts
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Naming utilities for lambda bundles
2
+ * Lambda naming utilities
3
3
  */
4
4
  /**
5
- * Generate the prefixed lambda name for bundle files
6
- * Format: {apiName}-{lambdaName}
5
+ * Generate Lambda bundle name with API name prefix
6
+ * Format: {name}-{lambdaName}
7
7
  */
8
- export declare function getLambdaBundleName(apiName: string, lambdaName: string): string;
8
+ export declare function getLambdaBundleName(name: string, lambdaName: string): string;
9
9
  /**
10
- * Extract the original lambda name from a prefixed bundle name
10
+ * Extract original lambda name from bundle name
11
+ * Reverses getLambdaBundleName()
11
12
  */
12
- export declare function getOriginalLambdaName(apiName: string, bundleName: string): string;
13
+ export declare function getOriginalLambdaName(name: string, bundleName: string): string;
@@ -1,18 +1,19 @@
1
1
  /**
2
- * Naming utilities for lambda bundles
2
+ * Lambda naming utilities
3
3
  */
4
4
  /**
5
- * Generate the prefixed lambda name for bundle files
6
- * Format: {apiName}-{lambdaName}
5
+ * Generate Lambda bundle name with API name prefix
6
+ * Format: {name}-{lambdaName}
7
7
  */
8
- export function getLambdaBundleName(apiName, lambdaName) {
9
- return `${apiName}-${lambdaName}`;
8
+ export function getLambdaBundleName(name, lambdaName) {
9
+ return `${name}-${lambdaName}`;
10
10
  }
11
11
  /**
12
- * Extract the original lambda name from a prefixed bundle name
12
+ * Extract original lambda name from bundle name
13
+ * Reverses getLambdaBundleName()
13
14
  */
14
- export function getOriginalLambdaName(apiName, bundleName) {
15
- const prefix = `${apiName}-`;
15
+ export function getOriginalLambdaName(name, bundleName) {
16
+ const prefix = `${name}-`;
16
17
  if (bundleName.startsWith(prefix)) {
17
18
  return bundleName.slice(prefix.length);
18
19
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuallyjamez/elysian",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Automatic Lambda bundler for Elysia with API Gateway and Terraform integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",