@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.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * File scaffolding logic
3
+ */
4
+ import type { PackageManager, ProjectInfo } from "./detect";
5
+ import type { WizardAnswers } from "./prompts";
6
+ export interface ScaffoldResult {
7
+ created: string[];
8
+ updated: string[];
9
+ skipped: string[];
10
+ }
11
+ /**
12
+ * Scaffold all project files
13
+ */
14
+ export declare function scaffoldProject(cwd: string, info: ProjectInfo, answers: WizardAnswers, force: boolean): Promise<ScaffoldResult>;
15
+ /**
16
+ * Install dependencies using the specified package manager
17
+ */
18
+ export declare function installDependencies(cwd: string, packageManager: PackageManager): Promise<void>;
19
+ /**
20
+ * Get relative path for display
21
+ */
22
+ export declare function getRelativePath(cwd: string, fullPath: string): string;
23
+ /**
24
+ * Print scaffold results
25
+ */
26
+ export declare function printResults(cwd: string, result: ScaffoldResult): void;
@@ -0,0 +1,196 @@
1
+ /**
2
+ * File scaffolding logic
3
+ */
4
+ import { existsSync, mkdirSync } from "fs";
5
+ import { join } from "path";
6
+ import { spawn } from "bun";
7
+ import consola from "consola";
8
+ import { configTemplate, exampleLambdaTemplate, packageJsonTemplate, gitignoreTemplate, tsconfigTemplate, } from "./templates";
9
+ import { templates as tfTemplates, appendProviders, getMissingVariables, appendMain, appendOutputs, } from "./terraform";
10
+ /**
11
+ * Read file content or return empty string if not exists
12
+ */
13
+ async function readFileOrEmpty(path) {
14
+ if (!existsSync(path)) {
15
+ return "";
16
+ }
17
+ return await Bun.file(path).text();
18
+ }
19
+ /**
20
+ * Write file and track in result
21
+ */
22
+ async function writeFile(path, content, result, isUpdate) {
23
+ await Bun.write(path, content);
24
+ if (isUpdate) {
25
+ result.updated.push(path);
26
+ }
27
+ else {
28
+ result.created.push(path);
29
+ }
30
+ }
31
+ /**
32
+ * Scaffold all project files
33
+ */
34
+ export async function scaffoldProject(cwd, info, answers, force) {
35
+ const result = {
36
+ created: [],
37
+ updated: [],
38
+ skipped: [],
39
+ };
40
+ // Create directories
41
+ const lambdasDir = join(cwd, "src/lambdas");
42
+ const terraformDir = join(cwd, "terraform");
43
+ if (!existsSync(lambdasDir)) {
44
+ mkdirSync(lambdasDir, { recursive: true });
45
+ }
46
+ if (!existsSync(terraformDir)) {
47
+ mkdirSync(terraformDir, { recursive: true });
48
+ }
49
+ // For fresh projects, create package.json, .gitignore, tsconfig.json
50
+ if (info.isEmpty) {
51
+ const packageJsonPath = join(cwd, "package.json");
52
+ await writeFile(packageJsonPath, packageJsonTemplate(answers.name), result, false);
53
+ const gitignorePath = join(cwd, ".gitignore");
54
+ if (!existsSync(gitignorePath)) {
55
+ await writeFile(gitignorePath, gitignoreTemplate(), result, false);
56
+ }
57
+ const tsconfigPath = join(cwd, "tsconfig.json");
58
+ if (!existsSync(tsconfigPath)) {
59
+ await writeFile(tsconfigPath, tsconfigTemplate(), result, false);
60
+ }
61
+ }
62
+ // Create elysian.config.ts
63
+ const configPath = join(cwd, "elysian.config.ts");
64
+ if (!existsSync(configPath) || force) {
65
+ await writeFile(configPath, configTemplate(answers.name), result, existsSync(configPath));
66
+ }
67
+ else {
68
+ result.skipped.push(configPath);
69
+ }
70
+ // Create example lambda (only if no lambda files exist)
71
+ const exampleLambdaPath = join(lambdasDir, "hello.ts");
72
+ if (!info.hasLambdaFiles) {
73
+ await writeFile(exampleLambdaPath, exampleLambdaTemplate(), result, false);
74
+ }
75
+ // Handle Terraform files
76
+ await scaffoldTerraform(cwd, info, answers.name, result);
77
+ return result;
78
+ }
79
+ /**
80
+ * Scaffold Terraform files with smart appending
81
+ */
82
+ async function scaffoldTerraform(cwd, info, name, result) {
83
+ const tfDir = join(cwd, "terraform");
84
+ // providers.tf
85
+ const providersPath = join(tfDir, "providers.tf");
86
+ if (info.terraformFiles.providers) {
87
+ const existing = await readFileOrEmpty(providersPath);
88
+ const updated = appendProviders(existing);
89
+ if (updated !== existing) {
90
+ await writeFile(providersPath, updated, result, true);
91
+ }
92
+ else {
93
+ result.skipped.push(providersPath);
94
+ }
95
+ }
96
+ else {
97
+ await writeFile(providersPath, tfTemplates.providers, result, false);
98
+ }
99
+ // variables.tf
100
+ const variablesPath = join(tfDir, "variables.tf");
101
+ if (info.terraformFiles.variables) {
102
+ const existing = await readFileOrEmpty(variablesPath);
103
+ const updated = getMissingVariables(existing, name);
104
+ if (updated !== existing) {
105
+ await writeFile(variablesPath, updated, result, true);
106
+ }
107
+ else {
108
+ result.skipped.push(variablesPath);
109
+ }
110
+ }
111
+ else {
112
+ await writeFile(variablesPath, tfTemplates.variables(name), result, false);
113
+ }
114
+ // main.tf
115
+ const mainPath = join(tfDir, "main.tf");
116
+ if (info.terraformFiles.main) {
117
+ const existing = await readFileOrEmpty(mainPath);
118
+ const updated = appendMain(existing);
119
+ if (updated !== existing) {
120
+ await writeFile(mainPath, updated, result, true);
121
+ }
122
+ else {
123
+ result.skipped.push(mainPath);
124
+ }
125
+ }
126
+ else {
127
+ await writeFile(mainPath, tfTemplates.main, result, false);
128
+ }
129
+ // outputs.tf
130
+ const outputsPath = join(tfDir, "outputs.tf");
131
+ if (info.terraformFiles.outputs) {
132
+ const existing = await readFileOrEmpty(outputsPath);
133
+ const updated = appendOutputs(existing);
134
+ if (updated !== existing) {
135
+ await writeFile(outputsPath, updated, result, true);
136
+ }
137
+ else {
138
+ result.skipped.push(outputsPath);
139
+ }
140
+ }
141
+ else {
142
+ await writeFile(outputsPath, tfTemplates.outputs, result, false);
143
+ }
144
+ }
145
+ /**
146
+ * Install dependencies using the specified package manager
147
+ */
148
+ export async function installDependencies(cwd, packageManager) {
149
+ const deps = ["elysia", "@actuallyjamez/elysian"];
150
+ const devDeps = ["@types/bun", "typescript"];
151
+ const addCmd = packageManager === "npm" ? "install" : "add";
152
+ const devFlag = packageManager === "npm" ? "--save-dev" : "-D";
153
+ consola.start("Installing dependencies...");
154
+ // Install main dependencies
155
+ const addProc = spawn([packageManager, addCmd, ...deps], {
156
+ cwd,
157
+ stdout: "ignore",
158
+ stderr: "pipe",
159
+ });
160
+ await addProc.exited;
161
+ if (addProc.exitCode !== 0) {
162
+ const stderr = await new Response(addProc.stderr).text();
163
+ throw new Error(`Failed to install dependencies: ${stderr}`);
164
+ }
165
+ // Install dev dependencies
166
+ const devProc = spawn([packageManager, addCmd, devFlag, ...devDeps], {
167
+ cwd,
168
+ stdout: "ignore",
169
+ stderr: "pipe",
170
+ });
171
+ await devProc.exited;
172
+ if (devProc.exitCode !== 0) {
173
+ const stderr = await new Response(devProc.stderr).text();
174
+ throw new Error(`Failed to install dev dependencies: ${stderr}`);
175
+ }
176
+ consola.success("Dependencies installed");
177
+ }
178
+ /**
179
+ * Get relative path for display
180
+ */
181
+ export function getRelativePath(cwd, fullPath) {
182
+ return fullPath.replace(cwd + "/", "");
183
+ }
184
+ /**
185
+ * Print scaffold results
186
+ */
187
+ export function printResults(cwd, result) {
188
+ console.log("");
189
+ for (const path of result.created) {
190
+ consola.success(`Created ${getRelativePath(cwd, path)}`);
191
+ }
192
+ for (const path of result.updated) {
193
+ consola.success(`Updated ${getRelativePath(cwd, path)}`);
194
+ }
195
+ // Don't print skipped files - too noisy
196
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Template strings for generated files
3
+ */
4
+ /**
5
+ * elysian.config.ts template
6
+ */
7
+ export declare function configTemplate(name: string): string;
8
+ /**
9
+ * Example lambda template
10
+ */
11
+ export declare function exampleLambdaTemplate(): string;
12
+ /**
13
+ * package.json template for fresh projects
14
+ */
15
+ export declare function packageJsonTemplate(name: string): string;
16
+ /**
17
+ * .gitignore template
18
+ */
19
+ export declare function gitignoreTemplate(): string;
20
+ /**
21
+ * tsconfig.json template
22
+ */
23
+ export declare function tsconfigTemplate(): string;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Template strings for generated files
3
+ */
4
+ /**
5
+ * elysian.config.ts template
6
+ */
7
+ export function configTemplate(name) {
8
+ return `import { defineConfig } from "@actuallyjamez/elysian";
9
+
10
+ export default defineConfig({
11
+ name: "${name}",
12
+ });
13
+ `;
14
+ }
15
+ /**
16
+ * Example lambda template
17
+ */
18
+ export function exampleLambdaTemplate() {
19
+ return `import { createLambda, t } from "@actuallyjamez/elysian";
20
+
21
+ export default createLambda()
22
+ .get("/hello", ({ query }) => {
23
+ return \`Hello, \${query.name ?? "World"}!\`;
24
+ }, {
25
+ response: t.String(),
26
+ query: t.Object({
27
+ name: t.Optional(t.String()),
28
+ }),
29
+ detail: {
30
+ summary: "Say hello",
31
+ tags: ["Greeting"],
32
+ },
33
+ });
34
+ `;
35
+ }
36
+ /**
37
+ * package.json template for fresh projects
38
+ */
39
+ export function packageJsonTemplate(name) {
40
+ return JSON.stringify({
41
+ name: name,
42
+ version: "0.1.0",
43
+ type: "module",
44
+ scripts: {
45
+ build: "elysian build",
46
+ dev: "elysian dev",
47
+ },
48
+ }, null, 2);
49
+ }
50
+ /**
51
+ * .gitignore template
52
+ */
53
+ export function gitignoreTemplate() {
54
+ return `# Dependencies
55
+ node_modules/
56
+
57
+ # Build output
58
+ dist/
59
+
60
+ # Environment
61
+ .env
62
+ .env.local
63
+
64
+ # Terraform
65
+ terraform/.terraform/
66
+ terraform/*.tfstate
67
+ terraform/*.tfstate.backup
68
+ terraform/.terraform.lock.hcl
69
+
70
+ # IDE
71
+ .idea/
72
+ .vscode/
73
+ *.swp
74
+ *.swo
75
+
76
+ # OS
77
+ .DS_Store
78
+ Thumbs.db
79
+ `;
80
+ }
81
+ /**
82
+ * tsconfig.json template
83
+ */
84
+ export function tsconfigTemplate() {
85
+ return JSON.stringify({
86
+ compilerOptions: {
87
+ target: "ESNext",
88
+ module: "ESNext",
89
+ moduleResolution: "bundler",
90
+ strict: true,
91
+ esModuleInterop: true,
92
+ skipLibCheck: true,
93
+ noEmit: true,
94
+ types: ["bun-types"],
95
+ },
96
+ include: ["src/**/*", "elysian.config.ts"],
97
+ exclude: ["node_modules", "dist"],
98
+ }, null, 2);
99
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Smart Terraform file handling - append missing blocks
3
+ */
4
+ /**
5
+ * Check if content has AWS provider configured
6
+ */
7
+ export declare function hasAwsProvider(content: string): boolean;
8
+ /**
9
+ * Check if a variable exists in the content
10
+ */
11
+ export declare function hasVariable(content: string, name: string): boolean;
12
+ /**
13
+ * Check if a resource of a given type exists
14
+ */
15
+ export declare function hasResource(content: string, type: string): boolean;
16
+ /**
17
+ * Check if an output exists in the content
18
+ */
19
+ export declare function hasOutput(content: string, name: string): boolean;
20
+ /**
21
+ * Smart append to providers.tf - only add if AWS provider is missing
22
+ */
23
+ export declare function appendProviders(existing: string): string;
24
+ /**
25
+ * Get missing variables and return the block to append
26
+ */
27
+ export declare function getMissingVariables(existing: string, name: string): string;
28
+ /**
29
+ * Smart append to main.tf - only add if API Gateway resource is missing
30
+ */
31
+ export declare function appendMain(existing: string): string;
32
+ /**
33
+ * Smart append to outputs.tf - only add if api_endpoint output is missing
34
+ */
35
+ export declare function appendOutputs(existing: string): string;
36
+ /**
37
+ * Full templates for new files
38
+ */
39
+ export declare const templates: {
40
+ providers: string;
41
+ variables: (name: string) => string;
42
+ main: string;
43
+ outputs: string;
44
+ };
@@ -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, name) {
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 = "${name}"
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: (name) => `# 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 = "${name}"
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;