@fishawack/lab-env 2.0.1 → 2.2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  ## Changelog
2
2
 
3
+ ### 2.2.0 (2022-08-10)
4
+ * [Feature] Can now skip diagnose in `fw diagnose`
5
+ * [Feature] Can now provision AWS environments using `fw provision`
6
+ * [Change] Now requires node > 16
7
+ * [Change] Auditted npm dependencies
8
+ * [Misc] Added test coverage for new AWS service
9
+
10
+ ### 2.1.0 (2022-05-27)
11
+ * [Change] Bumped core `0.0.21` to `0.1.0`
12
+
13
+ ### 2.0.2 (2022-04-26)
14
+ * [Change] Bumped core `0.0.20` to `0.0.21`
15
+ * [Bug] Lock eb-cli due to breaking change
16
+ * [Bug] Removed imagemagick from php containers as it's now included in base image
17
+ * [Misc] Updated core publish postversion command
18
+
3
19
  ### 2.0.1 (2022-04-13)
4
20
  * [Bug] Explicitly set platform to fix M1 chip issues
5
21
 
File without changes
@@ -0,0 +1,10 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const host = "ftp-fishawack.egnyte.com";
4
+ const creds = JSON.parse(fs.readFileSync(`${os.homedir()}/targets/.ftppass`))[host];
5
+
6
+ module.exports = {
7
+ opts: {encoding: 'utf8', stdio: process.argv.includes('--publish') ? 'pipe' : 'inherit'},
8
+ host,
9
+ creds
10
+ };
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ const expect = require('chai').expect;
4
+ const aws = require("../commands/create/services/aws/index.js");
5
+ var fetch;
6
+
7
+ describe('provision', async () => {
8
+ let config;
9
+ let repo = 'lab-env-test-suite';
10
+ let account = 'fishawack';
11
+
12
+ before(async () => {
13
+ fetch = (await import('node-fetch')).default;
14
+
15
+ config = await aws.static(repo, account);
16
+
17
+ await aws.s3.addFileToS3Bucket(repo, account, 'index.html', new TextEncoder().encode("test"));
18
+ });
19
+
20
+ it('Should provision s3 bucket', async () => {
21
+ expect((await fetch(config.url)).status).to.be.equal(200);
22
+ });
23
+
24
+ after(async () => {
25
+ await aws.s3.removeFileToS3Bucket(repo, account, 'index.html');
26
+
27
+ await aws.s3.removeS3Bucket(repo, account);
28
+
29
+ await aws.cloudfront.removeCloudFrontDistribution(config.cloudfront, account);
30
+
31
+ await aws.cloudfront.removeCloudFrontFunction(repo, account);
32
+ });
33
+ });
package/cli.js CHANGED
@@ -4,10 +4,6 @@ process.env.CWD = process.cwd();
4
4
 
5
5
  const _ = require('./globals.js');
6
6
 
7
- const updateNotifier = require('update-notifier');
8
- const pkg = require('./package.json');
9
- updateNotifier({pkg, updateCheckInterval: 0}).notify();
10
-
11
7
  const execSync = require('child_process').execSync;
12
8
 
13
9
  const yargs = require('yargs/yargs');
@@ -16,9 +12,13 @@ const { hideBin } = require('yargs/helpers');
16
12
  const args = hideBin(process.argv);
17
13
 
18
14
  // Stop here if docker process not running and command isn't version or origin which don't require docker
19
- if(!_.services && !(args[0] === 'origin' || args[0] === '--version')) return;
15
+ if(!_.services && !(args[0] === 'origin' || args[0] === '--version')) process.exit();
20
16
 
21
17
  (async () => {
18
+ const updateNotifier = (await import('update-notifier')).default;
19
+ const pkg = require('./package.json');
20
+ updateNotifier({pkg, updateCheckInterval: 0}).notify();
21
+
22
22
  process.env.REPO = _.repo;
23
23
 
24
24
  await _.ports.set();
@@ -59,7 +59,7 @@ if(!_.services && !(args[0] === 'origin' || args[0] === '--version')) return;
59
59
  ['build', 'config', 'down', 'mocha', 'rebuild', 'up', 'volumes', 'compose'].forEach(d => cli.command(...require(`./commands/docker/${d}.js`)));
60
60
 
61
61
  // Create commands
62
- ['new', 'diagnose', 'delete'].forEach(d => cli.command(...require(`./commands/create/cmds/${d}.js`)));
62
+ ['new', 'provision', 'deprovision', 'diagnose', 'delete'].forEach(d => cli.command(...require(`./commands/create/cmds/${d}.js`)));
63
63
 
64
64
  cli.demandCommand(1, '')
65
65
  .wrap(null)
@@ -0,0 +1,53 @@
1
+ const _ = require('../../../globals.js');
2
+ const inquirer = require('inquirer');
3
+ const aws = require('../services/aws/index.js');
4
+
5
+ module.exports = [
6
+ ['deprovision', 'deprov'],
7
+ false,
8
+ yargs => {
9
+ yargs.option('branch', {
10
+ alias: 'b',
11
+ describe: 'Branch to configure',
12
+ type: 'string'
13
+ });
14
+ },
15
+ async argv => {
16
+ let branch = argv.branch || _.branch;
17
+
18
+ let answer = await inquirer.prompt([
19
+ {
20
+ type: 'confirm',
21
+ name: 'check',
22
+ message: `Deprovisioning fw-auto-${_.repo}-${branch}, are you sure you want to continue?`,
23
+ default: 'Y'
24
+ }
25
+ ]);
26
+
27
+ if(!answer.check){
28
+ process.exit(1);
29
+ }
30
+
31
+ const answers = await inquirer.prompt([
32
+ {
33
+ type: 'input',
34
+ name: 'id',
35
+ message: 'What is the Id of the CloudFront distribution?',
36
+ validate: (input) => !!input.length
37
+ },
38
+ {
39
+ type: 'list',
40
+ name: 'client',
41
+ message: 'Which AWS account is this deployed too?',
42
+ choices: ['fishawack', 'abbvie', 'sanofigenzyme', 'gsk', 'janssen', 'astrazeneca', 'training'],
43
+ default: 'fishawack'
44
+ }
45
+ ]);
46
+
47
+ try { await aws.s3.removeS3Bucket(`fw-auto-${_.repo}-${branch}`, answers.client); } catch(e) {}
48
+
49
+ try { await aws.cloudfront.removeCloudFrontDistribution(answers.id, answers.client); } catch(e) {}
50
+
51
+ try { await aws.cloudfront.removeCloudFrontFunction(`fw-auto-${_.repo}-${branch}`, answers.client); } catch(e) {}
52
+ }
53
+ ];
@@ -57,9 +57,11 @@ module.exports = [
57
57
  while(!await test.bitbucket() || !await bitbucket.check()){
58
58
  await guide.bitbucket();
59
59
  }
60
-
61
- while(!await test.gitlab() || !await gitlab.check()){
62
- await guide.gitlab();
60
+
61
+ if(!await guide.gitlabSkip()) {
62
+ while(!await test.gitlab() || !await gitlab.check()){
63
+ await guide.gitlab();
64
+ }
63
65
  }
64
66
 
65
67
  const userRepoName = vars.misc.bitbucket.username.split('@')[0].replace(/\./, '-');
@@ -0,0 +1,120 @@
1
+ const _ = require('../../../globals.js');
2
+ const utilities = require('../libs/utilities');
3
+ const execSync = require('child_process').execSync;
4
+ const inquirer = require('inquirer');
5
+ const aws = require('../services/aws/index.js');
6
+ const generator = require('generate-password');
7
+
8
+ module.exports = [
9
+ ['provision', 'prov'],
10
+ 'Provisions the deployment target',
11
+ yargs => {
12
+ yargs.option('branch', {
13
+ alias: 'b',
14
+ describe: 'Branch to configure',
15
+ type: 'string'
16
+ });
17
+ },
18
+ async argv => {
19
+ let branch = argv.branch || _.branch;
20
+
21
+ let answer = await inquirer.prompt([
22
+ {
23
+ type: 'confirm',
24
+ name: 'check',
25
+ message: `Provisioning the ${utilities.colorize(branch, 'success')} branch, is this correct?`,
26
+ default: 'Y'
27
+ }
28
+ ]);
29
+
30
+ if(!answer.check){
31
+ process.exit(1);
32
+ }
33
+
34
+ const answers = await inquirer.prompt([
35
+ {
36
+ type: 'list',
37
+ name: 'stack',
38
+ message: 'What type of project are you deploying?',
39
+ choices: ['static'],
40
+ default: 'static'
41
+ },
42
+ {
43
+ type: 'list',
44
+ name: 'client',
45
+ message: 'Which AWS account should this be deployed too?',
46
+ choices: ['fishawack', 'abbvie', 'sanofigenzyme', 'gsk', 'janssen', 'astrazeneca', 'training'],
47
+ default: 'fishawack'
48
+ },
49
+ {
50
+ type: 'confirm',
51
+ name: 'protected',
52
+ message: 'Should the site be password protected?',
53
+ default: true
54
+ }
55
+ ]);
56
+
57
+ let credentials = [];
58
+
59
+ if(answers.protected){
60
+ let user = {
61
+ another: true
62
+ };
63
+
64
+ while(user.another){
65
+ user = await inquirer.prompt([
66
+ {
67
+ type: 'input',
68
+ name: 'username',
69
+ message: 'Username',
70
+ default: `${_.repo_safe}User${credentials.length ? `-${credentials.length}` : ''}`,
71
+ validate: (input) => !!input.length
72
+ },
73
+ {
74
+ type: 'input',
75
+ name: 'password',
76
+ message: 'Password (leave empty to generate)',
77
+ default: generator.generate({ length: 10, numbers: true }),
78
+ validate: (input) => !!input.length
79
+ },
80
+ {
81
+ type: 'confirm',
82
+ name: 'another',
83
+ message: 'Add another user?',
84
+ default: false
85
+ }
86
+ ]);
87
+
88
+ credentials.push({username: user.username, password: user.password});
89
+ }
90
+ }
91
+
92
+ let config = {};
93
+ let infastructure;
94
+
95
+ try{
96
+ infastructure = await aws.static(`fw-auto-${_.repo}-${branch}`, answers.client, [{Key: 'repository', Value: _.repo}, {Key: 'environment', Value: branch}], credentials);
97
+ } catch(e){
98
+ console.log(e.message);
99
+ process.exit(1);
100
+ }
101
+
102
+ config[branch] = {
103
+ deploy: {
104
+ "url": infastructure.url,
105
+ "location": infastructure.bucket.slice(1), // Remove / from start
106
+ "aws-s3": answers.client,
107
+ "aws-cloudfront": infastructure.cloudfront
108
+ }
109
+ };
110
+
111
+ if(credentials.length){
112
+ config[branch].deploy.users = credentials;
113
+ }
114
+
115
+ let stringify = JSON.stringify(config, null, 4);
116
+ let output = stringify.substring(1, stringify.length-1).trim();
117
+ execSync(`printf '${output}' | pbcopy`);
118
+ console.log(utilities.colorize(`\n${output}\n\n(copied to clipboard)`, 'title'));
119
+ }
120
+ ];
@@ -0,0 +1,77 @@
1
+ function handler(event) {
2
+ // Redirect if www to non-www
3
+ if (event.request.headers.host.value.includes('www.')) {
4
+ var query = '';
5
+ var index = 0;
6
+
7
+ for(var key in event.request.querystring){
8
+ query += `${index ? '&' : '?'}${key}=${event.request.querystring[key].value}`;
9
+ index++;
10
+ }
11
+
12
+ var response = {
13
+ statusCode: 301,
14
+ statusDescription: 'Moved Permanently',
15
+ headers: { "location": { "value": `${event.request.headers.host.value.split('www.')[1]}${event.request.uri}${query}` } }
16
+ };
17
+
18
+ return response;
19
+ }
20
+
21
+ // Redirect if no trailing slash
22
+ if(!event.request.uri.includes('.') && !event.request.uri.endsWith('/')){
23
+ var query = '';
24
+ var index = 0;
25
+
26
+ for(var key in event.request.querystring){
27
+ query += `${index ? '&' : '?'}${key}=${event.request.querystring[key].value}`;
28
+ index++;
29
+ }
30
+
31
+ return {
32
+ statusCode: 301,
33
+ statusDescription: 'Moved Permanently',
34
+ headers: { "location": { "value": `${event.request.uri}/${query}` } }
35
+ };
36
+ }
37
+
38
+ var authHeaders = event.request.headers.authorization;
39
+
40
+ // The Base64-encoded Auth string that should be present.
41
+ // It is an encoding of `Basic base64([username]:[password])`
42
+ var expected = <%= credentials %>;
43
+
44
+ // If an Authorization header is supplied and it's an exact match, pass the
45
+ // request on through to CF/the origin without any modification.
46
+ if(authHeaders && expected.find(d => d === authHeaders.value)) {
47
+ // Rewrite url to append index.html if not present
48
+ var request = event.request;
49
+ var uri = request.uri;
50
+
51
+ // Check whether the URI is missing a file name.
52
+ if(uri.endsWith('/')) {
53
+ request.uri += 'index.html';
54
+ }
55
+ // Check whether the URI is missing a file extension.
56
+ else if(!uri.includes('.')) {
57
+ request.uri += '/index.html';
58
+ }
59
+
60
+ return request;
61
+ }
62
+
63
+ // But if we get here, we must either be missing the auth header or the
64
+ // credentials failed to match what we expected.
65
+ // Request the browser present the Basic Auth dialog.
66
+ var response = {
67
+ statusCode: 401,
68
+ statusDescription: 'Unauthorized',
69
+ headers: {
70
+ "www-authenticate": {
71
+ value: 'Basic'
72
+ }
73
+ },
74
+ };
75
+
76
+ return response;
77
+ }
@@ -0,0 +1,51 @@
1
+ function handler(event) {
2
+ // Redirect if www to non-www
3
+ if (event.request.headers.host.value.includes('www.')) {
4
+ var query = '';
5
+ var index = 0;
6
+
7
+ for(var key in event.request.querystring){
8
+ query += `${index ? '&' : '?'}${key}=${event.request.querystring[key].value}`;
9
+ index++;
10
+ }
11
+
12
+ var response = {
13
+ statusCode: 301,
14
+ statusDescription: 'Moved Permanently',
15
+ headers: { "location": { "value": `${event.request.headers.host.value.split('www.')[1]}${event.request.uri}${query}` } }
16
+ };
17
+
18
+ return response;
19
+ }
20
+
21
+ // Redirect if no trailing slash
22
+ if(!event.request.uri.includes('.') && !event.request.uri.endsWith('/')){
23
+ var query = '';
24
+ var index = 0;
25
+
26
+ for(var key in event.request.querystring){
27
+ query += `${index ? '&' : '?'}${key}=${event.request.querystring[key].value}`;
28
+ index++;
29
+ }
30
+
31
+ return {
32
+ statusCode: 301,
33
+ statusDescription: 'Moved Permanently',
34
+ headers: { "location": { "value": `${event.request.uri}/${query}` } }
35
+ };
36
+ }
37
+
38
+ var request = event.request;
39
+ var uri = request.uri;
40
+
41
+ // Check whether the URI is missing a file name.
42
+ if(uri.endsWith('/')) {
43
+ request.uri += 'index.html';
44
+ }
45
+ // Check whether the URI is missing a file extension.
46
+ else if(!uri.includes('.')) {
47
+ request.uri += '/index.html';
48
+ }
49
+
50
+ return request;
51
+ }
@@ -23,9 +23,12 @@ const colorize = module.exports.colorize = (str, type) => {
23
23
  }
24
24
  };
25
25
 
26
+ let latestSpinner = null;
27
+
26
28
  module.exports.Spinner = class Spinner {
27
- constructor(startMessage) {
28
- this.ora = ora(colorize(startMessage, 'info')).start();
29
+ constructor(startMessage, simple = false) {
30
+ this.ora = ora(simple ? startMessage : colorize(startMessage, 'info')).start();
31
+ latestSpinner = this;
29
32
  }
30
33
 
31
34
  update(message, status) {
@@ -37,6 +40,50 @@ module.exports.Spinner = class Spinner {
37
40
  this.ora.succeed(colorize(message, status || 'success'));
38
41
  }
39
42
  }
43
+
44
+ action(message, action, success, failure) {
45
+ return new Promise(async (resolve, reject) => {
46
+ let instance = new this.constructor(message);
47
+ let res;
48
+
49
+ try {
50
+ res = await action();
51
+
52
+ instance.update(success);
53
+ } catch (e){
54
+ instance.update(failure, 'fail');
55
+ reject(e);
56
+ }
57
+
58
+ resolve(res);
59
+ });
60
+ }
61
+
62
+ simple(message, action) {
63
+ return new Promise(async (resolve, reject) => {
64
+ let instance = new this.constructor(message, true);
65
+ let res;
66
+
67
+ try {
68
+ res = await action();
69
+
70
+ instance.ora.succeed();
71
+ } catch (e){
72
+ instance.ora.fail();
73
+ reject(e);
74
+ }
75
+
76
+ resolve(res);
77
+ });
78
+ }
79
+
80
+ ping(){
81
+ latestSpinner.ora.color = latestSpinner.ora.color === 'cyan' ? 'magenta' : 'cyan';
82
+ let message = ' - check: ';
83
+ let split = latestSpinner.ora.text.split(message);
84
+ let iteration = split[1] && +split[1].split(':')[0] || 0;
85
+ latestSpinner.ora.text = `${split[0]}${message}${iteration+1}`;
86
+ }
40
87
  };
41
88
 
42
89
  module.exports.encode = (username, password) => {
@@ -0,0 +1,289 @@
1
+ const { CloudFrontClient, CreateDistributionWithTagsCommand, CreateCloudFrontOriginAccessIdentityCommand, DeleteDistributionCommand , DeleteCloudFrontOriginAccessIdentityCommand, GetDistributionCommand, UpdateDistributionCommand, GetCloudFrontOriginAccessIdentityCommand, CreateFunctionCommand, GetFunctionCommand, UpdateFunctionCommand, PublishFunctionCommand, DeleteFunctionCommand, DescribeFunctionCommand } = require("@aws-sdk/client-cloudfront");
2
+ const fs = require('fs');
3
+ const { Spinner } = require('../../libs/utilities');
4
+
5
+ let region = `us-east-1`;
6
+
7
+ const client = new CloudFrontClient({ region });
8
+
9
+ module.exports.createCloudFrontDistribution = async (name, account, tags = []) => {
10
+ process.env.AWS_PROFILE = account;
11
+
12
+ let OAI = await Spinner.prototype.simple(`Creating CloudFront OAI`, () => {
13
+ return client.send(
14
+ new CreateCloudFrontOriginAccessIdentityCommand({
15
+ CloudFrontOriginAccessIdentityConfig: {
16
+ CallerReference: name,
17
+ Comment: `lab-env provisioned CloudFront OAI for s3 bucket ${name}`,
18
+ }
19
+ })
20
+ )
21
+ });
22
+
23
+ let res;
24
+
25
+ try {
26
+ res = await Spinner.prototype.simple(`Creating CloudFront distribution with CloudFront OAI and tags`, () => {
27
+ return client.send(
28
+ new CreateDistributionWithTagsCommand({
29
+ DistributionConfigWithTags: {
30
+ DistributionConfig: {
31
+ Enabled: true,
32
+ CallerReference: name,
33
+ Comment: `lab-env provisioned CloudFront distribution for project ${name}`,
34
+ CustomErrorResponses: {
35
+ Items: [
36
+ {
37
+ ErrorCachingMinTTL: 0,
38
+ ErrorCode: 403,
39
+ ResponseCode: 200,
40
+ ResponsePagePath: '/index.html'
41
+ }
42
+ ],
43
+ Quantity: 1
44
+ },
45
+ DefaultCacheBehavior: {
46
+ Compress: true,
47
+ TargetOriginId: `${name}.s3.${region}.amazonaws.com`,
48
+ ViewerProtocolPolicy: 'redirect-to-https',
49
+ CachePolicyId: '658327ea-f89d-4fab-a63d-7e88639e58f6' // Built in, Managed AWS Policy - Cache Optimized
50
+ },
51
+ Origins: {
52
+ Items: [
53
+ {
54
+ DomainName: `${name}.s3.${region}.amazonaws.com`,
55
+ Id: `${name}.s3.${region}.amazonaws.com`,
56
+ S3OriginConfig: {
57
+ OriginAccessIdentity: `origin-access-identity/cloudfront/${OAI.CloudFrontOriginAccessIdentity.Id}`
58
+ }
59
+ }
60
+ ],
61
+ Quantity: 1
62
+ }
63
+ },
64
+ Tags: {
65
+ Items: [{Key: 'client', Value: account}].concat(tags)
66
+ }
67
+ }
68
+ })
69
+ )
70
+ });
71
+
72
+ await Spinner.prototype.simple(`Waiting for CloudFront distribution to deploy`, () => {
73
+ return module.exports.waitForCloudFrontDistribution(res.Distribution.Id, account);
74
+ });
75
+ } catch(e){
76
+ let Id = e.message.split(' ')[e.message.split(' ').length - 1];
77
+
78
+ res = await Spinner.prototype.simple(`Retrieving the already existing CloudFront distribution`, () => {
79
+ return client.send(
80
+ new GetDistributionCommand({ Id })
81
+ );
82
+ });
83
+ }
84
+
85
+ return res;
86
+ }
87
+
88
+ module.exports.removeCloudFrontDistribution = async (Id, account) => {
89
+ process.env.AWS_PROFILE = account;
90
+
91
+ let res = await Spinner.prototype.simple(`Retrieving the CloudFront distribution ${Id}`, () => {
92
+ return client.send(
93
+ new GetDistributionCommand({ Id })
94
+ );
95
+ });
96
+
97
+ let OAI = res.Distribution.DistributionConfig.Origins.Items[0].S3OriginConfig.OriginAccessIdentity.split('origin-access-identity/cloudfront/')[1];
98
+
99
+ res.Distribution.DistributionConfig.Enabled = false;
100
+
101
+ res = await Spinner.prototype.simple(`Disabling the CloudFront distribution`, () => {
102
+ return client.send(
103
+ new UpdateDistributionCommand({ DistributionConfig: res.Distribution.DistributionConfig, Id, IfMatch: res.ETag })
104
+ );
105
+ });
106
+
107
+ await Spinner.prototype.simple(`Waiting for CloudFront distribution to deploy`, () => {
108
+ return module.exports.waitForCloudFrontDistribution(res.Distribution.Id, account);
109
+ });
110
+
111
+ res = await Spinner.prototype.simple(`Deleting the CloudFront distribution`, () => {
112
+ return client.send(
113
+ new DeleteDistributionCommand({ Id, IfMatch: res.ETag })
114
+ );
115
+ });
116
+
117
+ res = await Spinner.prototype.simple(`Retrieving the CloudFront OAI`, () => {
118
+ return client.send(
119
+ new GetCloudFrontOriginAccessIdentityCommand({ Id: OAI })
120
+ );
121
+ });
122
+
123
+ res = await Spinner.prototype.simple(`Deleting the CloudFront OAI`, () => {
124
+ return client.send(
125
+ new DeleteCloudFrontOriginAccessIdentityCommand({ Id: OAI, IfMatch: res.ETag })
126
+ );
127
+ });
128
+ }
129
+
130
+ module.exports.waitForCloudFrontDistribution = async (Id, account) => {
131
+ process.env.AWS_PROFILE = account;
132
+
133
+ let status;
134
+
135
+ do{
136
+ await new Promise((resolve) => setTimeout(() => resolve(), 5000));
137
+
138
+ await Spinner.prototype.ping();
139
+
140
+ let check = await client.send(
141
+ new GetDistributionCommand({ Id })
142
+ );
143
+
144
+ status = check.Distribution.Status;
145
+ } while(status !== 'Deployed')
146
+ }
147
+
148
+ module.exports.createCloudFrontFunction = async (name, account, fn, config) => {
149
+ process.env.AWS_PROFILE = account;
150
+
151
+ let FunctionConfig = {
152
+ Comment: `lab-env provisioned cloudfront function for project ${name} using code snippet ${fn}.js`,
153
+ Runtime: `cloudfront-js-1.0`
154
+ };
155
+
156
+ let res;
157
+
158
+ try{
159
+ res = await Spinner.prototype.simple(`Creating CloudFront function`, () => {
160
+ return client.send(
161
+ new CreateFunctionCommand({
162
+ Name: name,
163
+ FunctionCode: new TextEncoder().encode(" "),
164
+ FunctionConfig
165
+ })
166
+ );
167
+ });
168
+ } catch (e){
169
+ res = await Spinner.prototype.simple(`Retrieving the already existing CloudFront function`, () => {
170
+ return client.send(
171
+ new GetFunctionCommand({
172
+ Name: name
173
+ })
174
+ );
175
+ });
176
+ }
177
+
178
+ let processedFn = fs.readFileSync(`${__dirname}/../../libs/${fn}.js`).toString();
179
+ processedFn = processedFn.replace(/<%=.*%>/g, (el) => JSON.stringify(config[el.slice(3, el.length - 2).trim()], null, 4));
180
+
181
+ res = await Spinner.prototype.simple(`Updating CloudFront function with ${fn}.js code`, () => {
182
+ return client.send(
183
+ new UpdateFunctionCommand({
184
+ Name: name,
185
+ FunctionCode: new TextEncoder().encode(processedFn),
186
+ FunctionConfig,
187
+ IfMatch: res.ETag
188
+ })
189
+ );
190
+ });
191
+
192
+ res = await Spinner.prototype.simple(`Publishing CloudFront function`, () => {
193
+ return client.send(
194
+ new PublishFunctionCommand({
195
+ Name: name,
196
+ IfMatch: res.ETag
197
+ })
198
+ );
199
+ });
200
+
201
+ return res;
202
+ }
203
+
204
+ module.exports.removeCloudFrontFunction = async (name, account) => {
205
+ process.env.AWS_PROFILE = account;
206
+
207
+ let res = await Spinner.prototype.simple(`Retrieving CloudFront function`, () => {
208
+ return client.send(
209
+ new GetFunctionCommand({
210
+ Name: name
211
+ })
212
+ );
213
+ });
214
+
215
+ res = await Spinner.prototype.simple(`Deleting CloudFront function`, () => {
216
+ return client.send(
217
+ new DeleteFunctionCommand({
218
+ Name: name,
219
+ IfMatch: res.ETag
220
+ })
221
+ );
222
+ });
223
+
224
+ return res;
225
+ }
226
+
227
+ module.exports.setCloudFrontFunctionAssociation = async (Id, account) => {
228
+ process.env.AWS_PROFILE = account;
229
+
230
+ let res = await Spinner.prototype.simple(`Retrieving CloudFront distribution`, () => {
231
+ return client.send(
232
+ new GetDistributionCommand({ Id })
233
+ );
234
+ });
235
+
236
+ let { FunctionSummary: { FunctionMetadata: { FunctionARN } } } = await Spinner.prototype.simple(`Retrieving CloudFront function`, () => {
237
+ return client.send(
238
+ new DescribeFunctionCommand({
239
+ Name: res.Distribution.DistributionConfig.CallerReference,
240
+ Stage: 'LIVE'
241
+ })
242
+ );
243
+ });
244
+
245
+ res.Distribution.DistributionConfig.DefaultCacheBehavior.FunctionAssociations = {
246
+ Items: [
247
+ {
248
+ EventType: 'viewer-request',
249
+ FunctionARN: FunctionARN
250
+ }
251
+ ],
252
+ Quantity: 1
253
+ };
254
+
255
+ res = await Spinner.prototype.simple(`Adding CloudFront function to CloudFront distribution`, () => {
256
+ return client.send(
257
+ new UpdateDistributionCommand({ DistributionConfig: res.Distribution.DistributionConfig, Id, IfMatch: res.ETag })
258
+ );
259
+ });
260
+
261
+ await Spinner.prototype.simple(`Waiting for CloudFront distribution to deploy`, () => {
262
+ return module.exports.waitForCloudFrontDistribution(res.Distribution.Id, account);
263
+ });
264
+ };
265
+
266
+ module.exports.removeCloudFrontFunctionAssociation = async (Id, account) => {
267
+ process.env.AWS_PROFILE = account;
268
+
269
+ let res = await Spinner.prototype.simple(`Retrieving CloudFront distribution`, () => {
270
+ return client.send(
271
+ new GetDistributionCommand({ Id })
272
+ );
273
+ });
274
+
275
+ res.Distribution.DistributionConfig.DefaultCacheBehavior.FunctionAssociations = {
276
+ Items: [],
277
+ Quantity: 0
278
+ };
279
+
280
+ res = await Spinner.prototype.simple(`Removing CloudFront function from CloudFront distribution`, () => {
281
+ return client.send(
282
+ new UpdateDistributionCommand({ DistributionConfig: res.Distribution.DistributionConfig, Id, IfMatch: res.ETag })
283
+ );
284
+ });
285
+
286
+ await Spinner.prototype.simple(`Waiting for CloudFront distribution to deploy`, () => {
287
+ return module.exports.waitForCloudFrontDistribution(res.Distribution.Id, account);
288
+ });
289
+ };
@@ -0,0 +1,22 @@
1
+ module.exports.s3 = require("./s3.js");
2
+ module.exports.cloudfront = require("./cloudfront.js");
3
+
4
+ module.exports.static = async (name, account, tags = [], credentials = []) => {
5
+ let s3 = await module.exports.s3.createS3Bucket(name, account, tags);
6
+
7
+ let cloudfront = await module.exports.cloudfront.createCloudFrontDistribution(name, account, tags);
8
+
9
+ await module.exports.s3.setS3BucketPolicy(name, account, cloudfront.Distribution.DistributionConfig.Origins.Items[0].S3OriginConfig.OriginAccessIdentity.split('origin-access-identity/cloudfront/')[1]);
10
+
11
+ await module.exports.cloudfront.createCloudFrontFunction(name, account, credentials.length ? 'aws-cloudfront-auth' : 'aws-cloudfront-simple', {credentials: credentials.map(d => `Basic ${Buffer.from(`${d.username}:${d.password}`).toString('base64')}`)});
12
+
13
+ await module.exports.cloudfront.setCloudFrontFunctionAssociation(cloudfront.Distribution.Id, account);
14
+
15
+ let config = {
16
+ "bucket": s3.Location,
17
+ "url": `https://${cloudfront.Distribution.DomainName}`,
18
+ "cloudfront": cloudfront.Distribution.Id,
19
+ };
20
+
21
+ return config;
22
+ }
@@ -0,0 +1,100 @@
1
+ const { S3Client, CreateBucketCommand, DeleteBucketCommand, PutPublicAccessBlockCommand, PutBucketTaggingCommand, PutBucketPolicyCommand, PutObjectCommand, DeleteObjectCommand } = require("@aws-sdk/client-s3");
2
+ const { Spinner } = require('../../libs/utilities');
3
+
4
+ let region = `us-east-1`;
5
+
6
+ const client = new S3Client({ region });
7
+
8
+ module.exports.createS3Bucket = async (bucket, account, tags = []) => {
9
+ process.env.AWS_PROFILE = account;
10
+
11
+ let res = await Spinner.prototype.simple(`Creating s3 bucket ${bucket}`, () => {
12
+ return client.send(
13
+ new CreateBucketCommand({ Bucket: bucket })
14
+ );
15
+ });
16
+
17
+ await Spinner.prototype.simple(`Blocking all public access to s3 bucket`, () => {
18
+ return client.send(
19
+ new PutPublicAccessBlockCommand({ Bucket: bucket, PublicAccessBlockConfiguration: { BlockPublicAcls: true, BlockPublicPolicy: true, IgnorePublicAcls: true, RestrictPublicBuckets: true } })
20
+ );
21
+ });
22
+
23
+ await Spinner.prototype.simple(`Adding tags to s3 bucket`, () => {
24
+ return client.send(
25
+ new PutBucketTaggingCommand({ Bucket: bucket, Tagging: {TagSet: [{Key: 'client', Value: account}].concat(tags)} })
26
+ );
27
+ });
28
+
29
+ return res;
30
+ }
31
+
32
+ module.exports.removeS3Bucket = async (bucket, account) => {
33
+ process.env.AWS_PROFILE = account;
34
+
35
+ await Spinner.prototype.simple(`Removing s3 bucket ${bucket}`, () => {
36
+ return client.send(
37
+ new DeleteBucketCommand({ Bucket: bucket })
38
+ );
39
+ });
40
+ }
41
+
42
+ module.exports.setS3BucketPolicy = async (bucket, account, OAI) => {
43
+ process.env.AWS_PROFILE = account;
44
+
45
+ let res = await Spinner.prototype.simple(`Updating s3 bucket policy`, () => {
46
+ return client.send(
47
+ new PutBucketPolicyCommand({
48
+ Bucket: bucket,
49
+ Policy: JSON.stringify({
50
+ "Version": "2008-10-17",
51
+ "Id": "PolicyForCloudFrontPrivateContent",
52
+ "Statement": [
53
+ {
54
+ "Sid": "1",
55
+ "Effect": "Allow",
56
+ "Principal": {
57
+ "AWS": `arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${OAI}`
58
+ },
59
+ "Action": "s3:GetObject",
60
+ "Resource": `arn:aws:s3:::${bucket}/*`
61
+ }
62
+ ]
63
+ })
64
+ })
65
+ );
66
+ });
67
+
68
+ return res;
69
+ }
70
+
71
+ module.exports.addFileToS3Bucket = async (bucket, account, filepath, file) => {
72
+ process.env.AWS_PROFILE = account;
73
+
74
+ let res = await Spinner.prototype.simple(`Adding file to s3 bucket`, () => {
75
+ return client.send(
76
+ new PutObjectCommand({
77
+ Bucket: bucket,
78
+ Body: file,
79
+ Key: filepath
80
+ })
81
+ );
82
+ });
83
+
84
+ return res;
85
+ };
86
+
87
+ module.exports.removeFileToS3Bucket = async (bucket, account, filepath) => {
88
+ process.env.AWS_PROFILE = account;
89
+
90
+ let res = await Spinner.prototype.simple(`Removing file from s3 bucket`, () => {
91
+ return client.send(
92
+ new DeleteObjectCommand({
93
+ Bucket: bucket,
94
+ Key: filepath
95
+ })
96
+ );
97
+ });
98
+
99
+ return res;
100
+ }
@@ -194,6 +194,19 @@ module.exports.preset = async () => {
194
194
  return inputs.preset;
195
195
  }
196
196
 
197
+ module.exports.gitlabSkip = async () => {
198
+ let inputs = await inquirer.prompt([
199
+ {
200
+ type: 'confirm',
201
+ name: 'confirm',
202
+ message: 'Do you want to test gitlab setup? (VPN required)',
203
+ default: true
204
+ }
205
+ ]);
206
+
207
+ return !inputs.confirm;
208
+ }
209
+
197
210
  module.exports.config = async () => {
198
211
  let inputs = await inquirer.prompt([
199
212
  {
@@ -132,13 +132,19 @@ RUN apt-get install -y locales
132
132
  # Install AWS Elastic Beanstalk cli
133
133
  RUN apt-get install -y zlib1g-dev libssl-dev libncurses-dev libffi-dev libsqlite3-dev libreadline-dev libbz2-dev
134
134
 
135
- RUN git clone https://github.com/aws/aws-elastic-beanstalk-cli-setup.git
135
+ RUN git clone https://github.com/aws/aws-elastic-beanstalk-cli-setup.git --depth 1 --branch v0.1.2
136
136
  RUN ./aws-elastic-beanstalk-cli-setup/scripts/bundled_installer
137
137
  RUN rm -rf ./aws-elastic-beanstalk-cli-setup
138
138
 
139
139
  RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && \
140
140
  locale-gen
141
141
 
142
+ # Install AWS-CLI@2
143
+ RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
144
+ RUN unzip awscliv2.zip
145
+ RUN ./aws/install
146
+ RUN rm -rf ./aws && rm -rf awscliv2.zip
147
+
142
148
  # Cleanup apt-get install folders
143
149
  RUN apt-get clean && \
144
150
  rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
File without changes
@@ -0,0 +1,4 @@
1
+ ## Changelog
2
+
3
+ ### 0.1.0 (2022-05-27)
4
+ * [Feature] Added aws-cli@2 to core container
package/core/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "core",
3
- "version": "0.0.20",
3
+ "version": "0.1.0",
4
4
  "description": "lab-env docker config for the @fishawack/core npm module",
5
5
  "scripts": {
6
- "postversion": "docker tag fishawack/core:$npm_package_version fishawack/core:latest && docker push fishawack/core:$npm_package_version fishawack/core:latest"
6
+ "postversion": "mv ./*/ ./$npm_package_version && docker build ./*/ -t fishawack/core:$npm_package_version -t fishawack/core:latest && docker push fishawack/core:$npm_package_version && docker push fishawack/core:latest && git add . && git commit -m 'Bumped core to $npm_package_version'"
7
7
  },
8
8
  "author": "Mike Mellor",
9
9
  "license": "ISC"
@@ -28,7 +28,7 @@ services:
28
28
  build:
29
29
  context: ./php/
30
30
  dockerfile: Dockerfile
31
- image: lab-env/craftcms/3/php:0.0.1
31
+ image: lab-env/craftcms/3/php:0.0.2
32
32
  init: true
33
33
  working_dir: /app
34
34
  networks:
@@ -36,6 +36,7 @@ services:
36
36
  volumes:
37
37
  - $CWD/:/app
38
38
  - ./php/custom.conf:/opt/bitnami/php/etc/custom.conf
39
+ - ./php/policy.xml:/etc/ImageMagick-6/policy.xml
39
40
  - vendor:/app/vendor
40
41
  networks:
41
42
  default:
@@ -6,11 +6,8 @@ MAINTAINER Mike Mellor
6
6
  RUN apt-get update && \
7
7
  apt-get install -y mariadb-client
8
8
 
9
- # Install Imagemagick
10
- RUN apt-get install -y libmagickwand-dev --no-install-recommends
11
-
12
- # PHP Imagick ext
13
- RUN pecl install imagick && docker-php-ext-enable imagick
9
+ # Install ghostscript
10
+ RUN apt-get install -y ghostscript
14
11
 
15
12
  # Cleanup apt-get install folders
16
13
  RUN apt-get clean && \
@@ -0,0 +1,99 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE policymap [
3
+ <!ELEMENT policymap (policy)*>
4
+ <!ATTLIST policymap xmlns CDATA #FIXED ''>
5
+ <!ELEMENT policy EMPTY>
6
+ <!ATTLIST policy xmlns CDATA #FIXED '' domain NMTOKEN #REQUIRED
7
+ name NMTOKEN #IMPLIED pattern CDATA #IMPLIED rights NMTOKEN #IMPLIED
8
+ stealth NMTOKEN #IMPLIED value CDATA #IMPLIED>
9
+ ]>
10
+ <!--
11
+ Configure ImageMagick policies.
12
+
13
+ Domains include system, delegate, coder, filter, path, or resource.
14
+
15
+ Rights include none, read, write, execute and all. Use | to combine them,
16
+ for example: "read | write" to permit read from, or write to, a path.
17
+
18
+ Use a glob expression as a pattern.
19
+
20
+ Suppose we do not want users to process MPEG video images:
21
+
22
+ <policy domain="delegate" rights="none" pattern="mpeg:decode" />
23
+
24
+ Here we do not want users reading images from HTTP:
25
+
26
+ <policy domain="coder" rights="none" pattern="HTTP" />
27
+
28
+ The /repository file system is restricted to read only. We use a glob
29
+ expression to match all paths that start with /repository:
30
+
31
+ <policy domain="path" rights="read" pattern="/repository/*" />
32
+
33
+ Lets prevent users from executing any image filters:
34
+
35
+ <policy domain="filter" rights="none" pattern="*" />
36
+
37
+ Any large image is cached to disk rather than memory:
38
+
39
+ <policy domain="resource" name="area" value="1GP"/>
40
+
41
+ Use the default system font unless overwridden by the application:
42
+
43
+ <policy domain="system" name="font" value="/usr/share/fonts/favorite.ttf"/>
44
+
45
+ Define arguments for the memory, map, area, width, height and disk resources
46
+ with SI prefixes (.e.g 100MB). In addition, resource policies are maximums
47
+ for each instance of ImageMagick (e.g. policy memory limit 1GB, -limit 2GB
48
+ exceeds policy maximum so memory limit is 1GB).
49
+
50
+ Rules are processed in order. Here we want to restrict ImageMagick to only
51
+ read or write a small subset of proven web-safe image types:
52
+
53
+ <policy domain="delegate" rights="none" pattern="*" />
54
+ <policy domain="filter" rights="none" pattern="*" />
55
+ <policy domain="coder" rights="none" pattern="*" />
56
+ <policy domain="coder" rights="read|write" pattern="{GIF,JPEG,PNG,WEBP}" />
57
+ -->
58
+ <policymap>
59
+ <!-- <policy domain="resource" name="temporary-path" value="/tmp"/> -->
60
+ <policy domain="resource" name="memory" value="256MiB"/>
61
+ <policy domain="resource" name="map" value="512MiB"/>
62
+ <policy domain="resource" name="width" value="16KP"/>
63
+ <policy domain="resource" name="height" value="16KP"/>
64
+ <!-- <policy domain="resource" name="list-length" value="128"/> -->
65
+ <policy domain="resource" name="area" value="128MP"/>
66
+ <policy domain="resource" name="disk" value="1GiB"/>
67
+ <!-- <policy domain="resource" name="file" value="768"/> -->
68
+ <!-- <policy domain="resource" name="thread" value="4"/> -->
69
+ <!-- <policy domain="resource" name="throttle" value="0"/> -->
70
+ <!-- <policy domain="resource" name="time" value="3600"/> -->
71
+ <!-- <policy domain="coder" rights="none" pattern="MVG" /> -->
72
+ <!-- <policy domain="module" rights="none" pattern="{PS,PDF,XPS}" /> -->
73
+ <!-- <policy domain="path" rights="none" pattern="@*" /> -->
74
+ <!-- <policy domain="cache" name="memory-map" value="anonymous"/> -->
75
+ <!-- <policy domain="cache" name="synchronize" value="True"/> -->
76
+ <!-- <policy domain="cache" name="shared-secret" value="passphrase" stealth="true"/> -->
77
+ <!-- <policy domain="system" name="max-memory-request" value="256MiB"/> -->
78
+ <!-- <policy domain="system" name="shred" value="2"/> -->
79
+ <!-- <policy domain="system" name="precision" value="6"/> -->
80
+ <!-- <policy domain="system" name="font" value="/path/to/font.ttf"/> -->
81
+ <!-- <policy domain="system" name="pixel-cache-memory" value="anonymous"/> -->
82
+ <!-- <policy domain="system" name="shred" value="2"/> -->
83
+ <!-- <policy domain="system" name="precision" value="6"/> -->
84
+ <!-- not needed due to the need to use explicitly by mvg: -->
85
+ <!-- <policy domain="delegate" rights="none" pattern="MVG" /> -->
86
+ <!-- use curl -->
87
+ <policy domain="delegate" rights="none" pattern="URL" />
88
+ <policy domain="delegate" rights="none" pattern="HTTPS" />
89
+ <policy domain="delegate" rights="none" pattern="HTTP" />
90
+ <!-- in order to avoid to get image with password text -->
91
+ <policy domain="path" rights="none" pattern="@*"/>
92
+ <!-- disable ghostscript format types -->
93
+ <!-- <policy domain="coder" rights="none" pattern="PS" />
94
+ <policy domain="coder" rights="none" pattern="PS2" />
95
+ <policy domain="coder" rights="none" pattern="PS3" />
96
+ <policy domain="coder" rights="none" pattern="EPS" />
97
+ <policy domain="coder" rights="none" pattern="PDF" />
98
+ <policy domain="coder" rights="none" pattern="XPS" /> -->
99
+ </policymap>
@@ -36,7 +36,7 @@ services:
36
36
  build:
37
37
  context: ./php/
38
38
  dockerfile: Dockerfile
39
- image: lab-env/drupal/9/php:0.0.2
39
+ image: lab-env/drupal/9/php:0.0.3
40
40
  init: true
41
41
  working_dir: /app
42
42
  networks:
@@ -6,12 +6,6 @@ MAINTAINER Mike Mellor
6
6
  RUN apt-get update && \
7
7
  apt-get install -y mariadb-client
8
8
 
9
- # Install Imagemagick
10
- RUN apt-get install -y libmagickwand-dev --no-install-recommends
11
-
12
- # PHP Imagick ext
13
- RUN pecl install imagick && docker-php-ext-enable imagick
14
-
15
9
  # Install ghostscript
16
10
  RUN apt-get install -y ghostscript
17
11
 
package/globals.js CHANGED
@@ -20,8 +20,15 @@ var run;
20
20
  var exec;
21
21
  var running;
22
22
  var services;
23
+ var branch;
23
24
  var opts = {encoding: 'utf8', stdio: 'inherit', shell: '/bin/bash'};
24
25
 
26
+ try{
27
+ branch = process.env.BRANCH || process.env.CI_COMMIT_REF_NAME || require('git-branch').sync();
28
+ } catch(e){
29
+ branch = 'unknown';
30
+ }
31
+
25
32
  try{
26
33
  repo = execSync('basename "$(git rev-parse --show-toplevel)"', {encoding: 'utf8', stdio: 'pipe'}).trim() || path.basename(process.cwd());
27
34
  } catch(e){
@@ -113,6 +120,7 @@ module.exports = {
113
120
  services,
114
121
  platform,
115
122
  repo,
123
+ branch,
116
124
  repo_safe: repo.replace(/\./g, ''),
117
125
  pkg,
118
126
  docker,
@@ -28,7 +28,7 @@ services:
28
28
  build:
29
29
  context: ./php/
30
30
  dockerfile: Dockerfile
31
- image: lab-env/laravel/8/php:0.0.1
31
+ image: lab-env/laravel/8/php:0.0.2
32
32
  init: true
33
33
  working_dir: /app
34
34
  networks:
@@ -2,15 +2,9 @@ FROM chialab/php:7.4-fpm
2
2
 
3
3
  MAINTAINER Mike Mellor
4
4
 
5
- # Install Imagemagick
6
- RUN apt-get update && \
7
- apt-get install -y libmagickwand-dev --no-install-recommends
8
-
9
- # PHP Imagick ext
10
- RUN pecl install imagick && docker-php-ext-enable imagick
11
-
12
5
  # Install ghostscript
13
- RUN apt-get install -y ghostscript
6
+ RUN apt-get update && \
7
+ apt-get install -y ghostscript
14
8
 
15
9
  # Cleanup apt-get install folders
16
10
  RUN apt-get clean && \
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@fishawack/lab-env",
3
- "version": "2.0.1",
3
+ "version": "2.2.0",
4
4
  "description": "Docker manager for FW",
5
5
  "main": "cli.js",
6
6
  "scripts": {
7
- "test": "rm -rf _Test/_fixtures && mkdir _Test/_fixtures && mocha _Test/*.js --timeout 120s --bail",
7
+ "test": "rm -rf _Test/_fixtures/boilerplate*; mocha _Test/*.js --timeout 1200s --bail",
8
8
  "preversion": "npm test",
9
9
  "postversion": "git push && git push --tags && npm publish",
10
10
  "postpublish": "git checkout development && git merge master && git push"
@@ -16,23 +16,33 @@
16
16
  "author": "",
17
17
  "license": "ISC",
18
18
  "homepage": "https://bitbucket.org/fishawackdigital/lab-env#readme",
19
+ "type": "commonjs",
19
20
  "bin": {
20
21
  "lab-env": "./cli.js",
21
22
  "fw": "./cli.js"
22
23
  },
23
24
  "dependencies": {
24
- "axios": "0.21.1",
25
+ "@aws-sdk/client-cloudfront": "^3.141.0",
26
+ "@aws-sdk/client-s3": "^3.141.0",
27
+ "axios": "^0.21.4",
25
28
  "chalk": "4.1.0",
29
+ "generate-password": "^1.7.0",
26
30
  "get-port": "5.1.1",
31
+ "git-branch": "^2.0.1",
27
32
  "glob": "7.1.7",
28
33
  "inquirer": "8.1.2",
29
34
  "ora": "5.4.1",
30
35
  "semver": "7.3.4",
31
- "update-notifier": "5.1.0",
36
+ "update-notifier": "^6.0.2",
32
37
  "yargs": "16.2.0"
33
38
  },
34
39
  "devDependencies": {
35
40
  "chai": "4.3.4",
36
- "mocha": "9.1.2"
41
+ "mocha": "^9.2.2",
42
+ "node-fetch": "^3.2.10"
43
+ },
44
+ "engines": {
45
+ "npm": ">=8",
46
+ "node": ">=18"
37
47
  }
38
48
  }