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.
- package/dist/aws-resources.d.ts +16 -0
- package/dist/aws-resources.js +41 -0
- package/dist/aws-validator.d.ts +12 -0
- package/dist/aws-validator.js +76 -0
- package/dist/conversation.d.ts +2 -0
- package/dist/conversation.js +293 -0
- package/dist/deploy.d.ts +2 -0
- package/dist/deploy.js +30 -0
- package/dist/detector.d.ts +3 -0
- package/dist/detector.js +72 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +28 -0
- package/dist/infra.d.ts +6 -0
- package/dist/infra.js +189 -0
- package/dist/types.d.ts +45 -0
- package/dist/types.js +26 -0
- package/package.json +31 -0
- package/src/aws-resources.ts +57 -0
- package/src/aws-validator.ts +83 -0
- package/src/conversation.ts +339 -0
- package/src/deploy.ts +29 -0
- package/src/detector.ts +43 -0
- package/src/index.ts +5 -0
- package/src/infra.ts +315 -0
- package/src/types.ts +59 -0
- package/tsconfig.json +13 -0
|
@@ -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
|
+
}
|
package/src/detector.ts
ADDED
|
@@ -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