spense-core 0.1.1

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,339 @@
1
+ import inquirer from 'inquirer';
2
+ import chalk from 'chalk';
3
+ import { AwsCredentials, DeployConfig, Ec2Choice, PRICING, ProjectInfo, DatabaseType } from './types';
4
+ import { validateCredentials, checkPermissions } from './aws-validator';
5
+ import { listEc2Instances, listRdsInstances } from './aws-resources';
6
+
7
+ export async function runConversation(project: ProjectInfo): Promise<DeployConfig> {
8
+ console.log(chalk.blue('\nšŸš€ Spense - Let\'s deploy your app!\n'));
9
+ console.log(chalk.gray(`Detected: ${project.type} project "${project.name}"\n`));
10
+
11
+ // Step 1: AWS Credentials
12
+ const credentials = await askCredentials();
13
+
14
+ // Step 2: Validate & check permissions
15
+ await validateAndCheckPermissions(credentials);
16
+
17
+ // Step 3: EC2 configuration
18
+ const ec2 = await askEc2Config(credentials);
19
+
20
+ // Step 4: Elastic IP
21
+ const useElasticIp = await askElasticIp(ec2);
22
+
23
+ // Step 5: Database
24
+ const { database, dbInstanceClass } = await askDatabase(credentials);
25
+
26
+ // Step 6: Domain & HTTPS
27
+ const { domain, useHttps } = await askDomain();
28
+
29
+ // Step 7: Summary & confirm
30
+ const config: DeployConfig = {
31
+ credentials,
32
+ ec2,
33
+ useElasticIp,
34
+ database,
35
+ dbInstanceClass,
36
+ domain,
37
+ useHttps,
38
+ };
39
+
40
+ await showSummary(config);
41
+
42
+ return config;
43
+ }
44
+
45
+ async function askCredentials(): Promise<AwsCredentials> {
46
+ console.log(chalk.yellow('Step 1: AWS Credentials\n'));
47
+ console.log(chalk.gray('We need AWS credentials to deploy your app. These are stored locally and never sent anywhere.\n'));
48
+
49
+ const answers = await inquirer.prompt([
50
+ {
51
+ type: 'password',
52
+ name: 'accessKeyId',
53
+ message: 'AWS Access Key ID:',
54
+ mask: '*',
55
+ validate: (v: string) => v.length === 20 || 'Access Key ID should be 20 characters'
56
+ },
57
+ {
58
+ type: 'password',
59
+ name: 'secretAccessKey',
60
+ message: 'AWS Secret Access Key:',
61
+ mask: '*',
62
+ validate: (v: string) => v.length === 40 || 'Secret Access Key should be 40 characters'
63
+ },
64
+ {
65
+ type: 'list',
66
+ name: 'region',
67
+ message: 'AWS Region:',
68
+ choices: [
69
+ { name: 'US East (N. Virginia) - us-east-1', value: 'us-east-1' },
70
+ { name: 'US West (Oregon) - us-west-2', value: 'us-west-2' },
71
+ { name: 'EU (Ireland) - eu-west-1', value: 'eu-west-1' },
72
+ { name: 'Asia Pacific (Singapore) - ap-southeast-1', value: 'ap-southeast-1' },
73
+ { name: 'Asia Pacific (Sydney) - ap-southeast-2', value: 'ap-southeast-2' },
74
+ ]
75
+ }
76
+ ]);
77
+
78
+ return answers;
79
+ }
80
+
81
+ async function validateAndCheckPermissions(creds: AwsCredentials): Promise<void> {
82
+ console.log(chalk.yellow('\nStep 2: Validating credentials...\n'));
83
+
84
+ const validation = await validateCredentials(creds);
85
+ if (!validation.valid) {
86
+ console.log(chalk.red(`āœ— Invalid credentials: ${validation.error}`));
87
+ process.exit(1);
88
+ }
89
+ console.log(chalk.green(`āœ“ Authenticated as: ${validation.username}\n`));
90
+
91
+ console.log(chalk.gray('Checking permissions for required services...\n'));
92
+ const permissions = await checkPermissions(creds);
93
+
94
+ let allGood = true;
95
+ for (const p of permissions) {
96
+ if (p.hasAccess) {
97
+ console.log(chalk.green(` āœ“ ${p.service}`));
98
+ } else {
99
+ console.log(chalk.red(` āœ— ${p.service}: ${p.error}`));
100
+ allGood = false;
101
+ }
102
+ }
103
+
104
+ if (!allGood) {
105
+ console.log(chalk.red('\n⚠ Some permissions are missing. Deployment may fail.'));
106
+ const { proceed } = await inquirer.prompt([{
107
+ type: 'confirm',
108
+ name: 'proceed',
109
+ message: 'Continue anyway?',
110
+ default: false
111
+ }]);
112
+ if (!proceed) process.exit(1);
113
+ }
114
+ console.log('');
115
+ }
116
+
117
+ async function askEc2Config(creds: AwsCredentials): Promise<Ec2Choice> {
118
+ console.log(chalk.yellow('Step 3: EC2 Configuration\n'));
119
+
120
+ const existing = await listEc2Instances(creds);
121
+
122
+ if (existing.length > 0) {
123
+ console.log(chalk.gray('Found existing EC2 instances:\n'));
124
+ existing.forEach((inst, i) => {
125
+ console.log(chalk.gray(` ${i + 1}. ${inst.name} (${inst.id}) - ${inst.type} - ${inst.state}`));
126
+ });
127
+ console.log('');
128
+ }
129
+
130
+ const { useExisting } = await inquirer.prompt([{
131
+ type: 'list',
132
+ name: 'useExisting',
133
+ message: 'How would you like to deploy?',
134
+ choices: [
135
+ { name: 'šŸ†• Create new EC2 instances (recommended for production)', value: false },
136
+ ...(existing.length > 0 ? [{ name: 'šŸ“¦ Use existing EC2 instance(s)', value: true }] : [])
137
+ ]
138
+ }]);
139
+
140
+ if (useExisting) {
141
+ const { selectedInstances } = await inquirer.prompt([{
142
+ type: 'checkbox',
143
+ name: 'selectedInstances',
144
+ message: 'Select instance(s) to deploy to:',
145
+ choices: existing.map(inst => ({
146
+ name: `${inst.name} (${inst.id}) - ${inst.type}`,
147
+ value: inst.id
148
+ })),
149
+ validate: (v: string[]) => v.length > 0 || 'Select at least one instance'
150
+ }]);
151
+ return { useExisting: true, existingInstanceIds: selectedInstances };
152
+ }
153
+
154
+ // New instances
155
+ console.log(chalk.gray('\nLet\'s configure your new instances:\n'));
156
+
157
+ const instanceTypes = Object.entries(PRICING.ec2).map(([type, price]) => ({
158
+ name: `${type} - $${(price * 730).toFixed(2)}/month (~$${price}/hr)`,
159
+ value: type
160
+ }));
161
+
162
+ const answers = await inquirer.prompt([
163
+ {
164
+ type: 'list',
165
+ name: 'instanceType',
166
+ message: 'Instance type:',
167
+ choices: instanceTypes,
168
+ default: 't3.small'
169
+ },
170
+ {
171
+ type: 'number',
172
+ name: 'minInstances',
173
+ message: 'Minimum instances (for auto-scaling):',
174
+ default: 1,
175
+ validate: (v: number) => v >= 1 || 'At least 1'
176
+ },
177
+ {
178
+ type: 'number',
179
+ name: 'maxInstances',
180
+ message: 'Maximum instances (for auto-scaling):',
181
+ default: 3,
182
+ validate: (v: number, answers: any) => v >= answers.minInstances || 'Must be >= minimum'
183
+ }
184
+ ]);
185
+
186
+ return { useExisting: false, ...answers };
187
+ }
188
+
189
+ async function askElasticIp(ec2: Ec2Choice): Promise<boolean> {
190
+ if (ec2.useExisting) return false;
191
+
192
+ console.log(chalk.yellow('\nStep 4: Elastic IP\n'));
193
+ console.log(chalk.gray('An Elastic IP gives your instances a static public IP address.\n'));
194
+ console.log(chalk.cyan('Benefits:'));
195
+ console.log(chalk.gray(' • IP stays the same even if instance restarts'));
196
+ console.log(chalk.gray(' • Required if you want to point a domain directly to an instance'));
197
+ console.log(chalk.gray(' • Useful for whitelisting in firewalls\n'));
198
+ console.log(chalk.cyan('Cost:'));
199
+ console.log(chalk.gray(` • ${PRICING.elasticIp.note}`));
200
+ console.log(chalk.gray(' • With ALB (which we create), you typically don\'t need Elastic IPs\n'));
201
+
202
+ const { useElasticIp } = await inquirer.prompt([{
203
+ type: 'confirm',
204
+ name: 'useElasticIp',
205
+ message: 'Assign Elastic IP to each instance?',
206
+ default: false
207
+ }]);
208
+
209
+ return useElasticIp;
210
+ }
211
+
212
+ async function askDatabase(creds: AwsCredentials): Promise<{ database: DatabaseType; dbInstanceClass?: string }> {
213
+ console.log(chalk.yellow('\nStep 5: Database\n'));
214
+
215
+ const existing = await listRdsInstances(creds);
216
+ if (existing.length > 0) {
217
+ console.log(chalk.gray('Found existing RDS databases:\n'));
218
+ existing.forEach(db => {
219
+ console.log(chalk.gray(` • ${db.id} (${db.engine}) - ${db.instanceClass}`));
220
+ });
221
+ console.log('');
222
+ }
223
+
224
+ const { database } = await inquirer.prompt([{
225
+ type: 'list',
226
+ name: 'database',
227
+ message: 'Database:',
228
+ choices: [
229
+ { name: '🐘 PostgreSQL (recommended)', value: 'postgresql' },
230
+ { name: '🐬 MySQL', value: 'mysql' },
231
+ { name: 'āŒ No database', value: 'none' }
232
+ ]
233
+ }]);
234
+
235
+ if (database === 'none') {
236
+ return { database };
237
+ }
238
+
239
+ const dbTypes = Object.entries(PRICING.rds).map(([type, price]) => ({
240
+ name: `${type} - $${(price * 730).toFixed(2)}/month`,
241
+ value: type
242
+ }));
243
+
244
+ const { dbInstanceClass } = await inquirer.prompt([{
245
+ type: 'list',
246
+ name: 'dbInstanceClass',
247
+ message: 'Database instance size:',
248
+ choices: dbTypes,
249
+ default: 'db.t3.micro'
250
+ }]);
251
+
252
+ return { database, dbInstanceClass };
253
+ }
254
+
255
+ async function askDomain(): Promise<{ domain?: string; useHttps: boolean }> {
256
+ console.log(chalk.yellow('\nStep 6: Domain & HTTPS\n'));
257
+
258
+ const { hasDomain } = await inquirer.prompt([{
259
+ type: 'confirm',
260
+ name: 'hasDomain',
261
+ message: 'Do you have a domain to use?',
262
+ default: false
263
+ }]);
264
+
265
+ if (!hasDomain) {
266
+ console.log(chalk.gray('\nNo problem! You\'ll get an ALB URL like: my-app-123456.us-east-1.elb.amazonaws.com\n'));
267
+ return { useHttps: false };
268
+ }
269
+
270
+ const { domain } = await inquirer.prompt([{
271
+ type: 'input',
272
+ name: 'domain',
273
+ message: 'Domain (e.g., api.myapp.com):',
274
+ validate: (v: string) => /^[a-z0-9.-]+\.[a-z]{2,}$/.test(v) || 'Enter a valid domain'
275
+ }]);
276
+
277
+ console.log(chalk.gray('\nHTTPS encrypts traffic between users and your app.'));
278
+ console.log(chalk.gray('We\'ll use AWS Certificate Manager (free) to provision an SSL certificate.\n'));
279
+
280
+ const { useHttps } = await inquirer.prompt([{
281
+ type: 'confirm',
282
+ name: 'useHttps',
283
+ message: 'Enable HTTPS?',
284
+ default: true
285
+ }]);
286
+
287
+ return { domain, useHttps };
288
+ }
289
+
290
+ async function showSummary(config: DeployConfig): Promise<void> {
291
+ console.log(chalk.yellow('\nšŸ“‹ Deployment Summary\n'));
292
+
293
+ // Calculate costs
294
+ let monthlyCost = 0;
295
+
296
+ console.log(chalk.cyan('Infrastructure:'));
297
+
298
+ if (config.ec2.useExisting) {
299
+ console.log(chalk.gray(` • EC2: Using existing (${config.ec2.existingInstanceIds?.join(', ')})`));
300
+ } else {
301
+ const ec2Cost = PRICING.ec2[config.ec2.instanceType!] * 730 * config.ec2.minInstances!;
302
+ monthlyCost += ec2Cost;
303
+ console.log(chalk.gray(` • EC2: ${config.ec2.minInstances}-${config.ec2.maxInstances} x ${config.ec2.instanceType} (~$${ec2Cost.toFixed(2)}/mo min)`));
304
+ }
305
+
306
+ if (config.useElasticIp) {
307
+ console.log(chalk.gray(` • Elastic IP: Yes (free when attached)`));
308
+ }
309
+
310
+ const albCost = PRICING.alb.perHour * 730;
311
+ monthlyCost += albCost;
312
+ console.log(chalk.gray(` • ALB: Yes (~$${albCost.toFixed(2)}/mo + LCU charges)`));
313
+
314
+ if (config.database !== 'none') {
315
+ const dbCost = PRICING.rds[config.dbInstanceClass!] * 730;
316
+ monthlyCost += dbCost;
317
+ console.log(chalk.gray(` • RDS: ${config.database} ${config.dbInstanceClass} (~$${dbCost.toFixed(2)}/mo)`));
318
+ }
319
+
320
+ if (config.domain) {
321
+ console.log(chalk.gray(` • Domain: ${config.domain}`));
322
+ console.log(chalk.gray(` • HTTPS: ${config.useHttps ? 'Yes' : 'No'}`));
323
+ }
324
+
325
+ console.log(chalk.cyan(`\nEstimated minimum cost: ~$${monthlyCost.toFixed(2)}/month`));
326
+ console.log(chalk.gray('(Actual cost depends on usage, data transfer, etc.)\n'));
327
+
328
+ const { confirm } = await inquirer.prompt([{
329
+ type: 'confirm',
330
+ name: 'confirm',
331
+ message: 'Proceed with deployment?',
332
+ default: true
333
+ }]);
334
+
335
+ if (!confirm) {
336
+ console.log(chalk.yellow('Deployment cancelled.'));
337
+ process.exit(0);
338
+ }
339
+ }
package/src/deploy.ts ADDED
@@ -0,0 +1,29 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { ProjectInfo, DeployConfig } from './types';
4
+ import { runConversation } from './conversation';
5
+ import { deployInfra } from './infra';
6
+
7
+ export async function deploy(project: ProjectInfo): Promise<void> {
8
+ const config = await runConversation(project);
9
+
10
+ console.log(chalk.blue('\nšŸš€ Starting deployment...\n'));
11
+
12
+ const spinner = ora('Creating infrastructure...').start();
13
+
14
+ try {
15
+ const outputs = await deployInfra(project, config);
16
+ spinner.succeed('Deployment complete!\n');
17
+
18
+ console.log(chalk.green('āœ“ Your app is live!\n'));
19
+ console.log(chalk.cyan('Endpoints:'));
20
+ console.log(chalk.gray(` • URL: ${outputs.url}`));
21
+ if (outputs.dbEndpoint) {
22
+ console.log(chalk.gray(` • Database: ${outputs.dbEndpoint}`));
23
+ }
24
+ console.log('');
25
+ } catch (err) {
26
+ spinner.fail('Deployment failed');
27
+ throw err;
28
+ }
29
+ }
@@ -0,0 +1,43 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { ProjectInfo } from './types';
4
+
5
+ export function detectNodeProject(dir: string): ProjectInfo {
6
+ const pkgPath = path.join(dir, 'package.json');
7
+ if (!fs.existsSync(pkgPath)) {
8
+ throw new Error('No package.json found');
9
+ }
10
+
11
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
12
+
13
+ return {
14
+ type: 'nodejs',
15
+ name: pkg.name || path.basename(dir),
16
+ port: parseInt(process.env.PORT || '3000'),
17
+ buildCommand: pkg.scripts?.build ? 'npm run build' : '',
18
+ startCommand: pkg.scripts?.start || 'node index.js',
19
+ artifactPath: dir,
20
+ };
21
+ }
22
+
23
+ export function detectSpringBootProject(dir: string): ProjectInfo {
24
+ const pomPath = path.join(dir, 'pom.xml');
25
+ const gradlePath = path.join(dir, 'build.gradle');
26
+ const isGradle = fs.existsSync(gradlePath);
27
+ const isMaven = fs.existsSync(pomPath);
28
+
29
+ if (!isMaven && !isGradle) {
30
+ throw new Error('No pom.xml or build.gradle found');
31
+ }
32
+
33
+ const name = path.basename(dir);
34
+
35
+ return {
36
+ type: 'springboot',
37
+ name,
38
+ port: 8080,
39
+ buildCommand: isGradle ? './gradlew build' : 'mvn package',
40
+ startCommand: `java -jar target/${name}.jar`,
41
+ artifactPath: isGradle ? 'build/libs/*.jar' : 'target/*.jar',
42
+ };
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './types';
2
+ export { detectNodeProject, detectSpringBootProject } from './detector';
3
+ export { deploy } from './deploy';
4
+ export { runConversation } from './conversation';
5
+ export { validateCredentials, checkPermissions } from './aws-validator';