cloudmason2 1.0.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,279 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const AdmZip = require("adm-zip");
4
+ const { S3Client, PutObjectCommand, GetObjectCommand } = require("@aws-sdk/client-s3");
5
+ const { EC2Client, DescribeRegionsCommand, DescribeImagesCommand, DeregisterImageCommand, DeleteSnapshotCommand } = require("@aws-sdk/client-ec2");
6
+ const OrgConfig = require('./helpers/org_config');
7
+ const Apps = require('./helpers/apps');
8
+ const { buildAMI } = require('./ssh_build');
9
+
10
+ exports.main = async function(args){
11
+ console.log(`Updating ${args.app} v${args.v}`);
12
+
13
+ // Check Version Format
14
+ if (!args.v.match(/^[1-9][0-9]{0,4}\.[0-9]{0,4}$/)){
15
+ console.log('Invalid Version Format. Use format [major].[minor] without leading 0s');
16
+ throw new Error('Invalid Version Format');
17
+ }
18
+
19
+ const cfg = OrgConfig.read();
20
+ if (!cfg){ throw new Error('No org config found. Run set-org') }
21
+ const { bucket, prefix } = parseCdn(cfg.cdn);
22
+ const s3 = new S3Client({ region: cfg.region });
23
+ process.env.orgRegion = cfg.region; // used by ssh_build
24
+
25
+ const appName = args.app.toLowerCase();
26
+
27
+ // Get App
28
+ const app = await Apps.resolveApp(s3, bucket, prefix, appName);
29
+ if (!app){ throw new Error('No app named ' + args.app + '. Run new-app') }
30
+ const { appPrefix, manifest } = app;
31
+
32
+ // Published versions are frozen: no further updates to the same major/minor
33
+ const existing = manifest.versions.find(v=>{ return v.version === args.v || v.version.startsWith(args.v + '.') });
34
+ if (existing && existing.published){
35
+ throw new Error(`v${existing.version} is published. Use a new major/minor version`);
36
+ }
37
+
38
+ // --- I PREP ZIP ---
39
+ const zipPath = path.resolve(args.path);
40
+ if (!fs.existsSync(zipPath)){ throw new Error("Path not found:" + zipPath) }
41
+ const zipFilePath = await prepZip(zipPath);
42
+
43
+ // Stored as _app.zip so the CDN public-access policy keeps it private
44
+ const versionPrefix = `${appPrefix}/versions/${args.v}`;
45
+ await uploadFile(s3, bucket, `${versionPrefix}/_app.zip`, zipFilePath);
46
+ console.log(`Uploaded s3://${bucket}/${versionPrefix}/_app.zip`);
47
+
48
+ // --- II GET STACK ---
49
+ const stackKey = `${versionPrefix}/stack.yaml`;
50
+ let stackText;
51
+ if (args.stack){
52
+ const stackPath = path.resolve(args.stack);
53
+ if (!fs.existsSync(stackPath)){ throw new Error("Stack not found:" + stackPath) }
54
+ stackText = fs.readFileSync(stackPath, 'utf-8');
55
+ } else {
56
+ stackText = await getFileText(s3, bucket, stackKey);
57
+ if (stackText === null){ throw new Error('No stack found for v' + args.v + '. Pass -stack') }
58
+ }
59
+
60
+ // --- III BUILD IMAGE ---
61
+ const buildTime = new Date();
62
+ const ts = buildTime.toISOString().slice(0, 16).replace(/[-:]/g, ''); // e.g. 20260826T2130
63
+ const fullVersion = `${args.v}.${ts}`;
64
+
65
+ let ami_id = null;
66
+ if (manifest.noami){
67
+ console.log('noami app: skipping AMI build');
68
+ } else {
69
+ const amiName = `${appName}-v${fullVersion}`;
70
+ const arch = archFromInstanceType(manifest.RecommendedInstanceType || 'r8g.medium');
71
+ const buildInstanceType = arch === 'arm' ? 'r8g.medium' : 'm6a.large';
72
+
73
+ console.log(`Building AMI: ${amiName} (${arch}, ${buildInstanceType})`);
74
+ console.log(`Using local zip: ${zipFilePath}`);
75
+
76
+ try {
77
+ ami_id = await buildAMI(amiName, zipFilePath, buildInstanceType, arch);
78
+ } catch(e) {
79
+ console.log("Error Creating AMI:" + e);
80
+ throw new Error("Error - Build Not Complete");
81
+ }
82
+ }
83
+
84
+ // --- IV UPLOAD STACK ---
85
+ // Only alteration to the template: set the AmiId parameter's Default to the new AMI
86
+ let finalStack = stackText;
87
+ if (ami_id){
88
+ const amiDefault = setAmiDefault(stackText, ami_id);
89
+ finalStack = amiDefault.text;
90
+ if (amiDefault.updated){
91
+ console.log(`Set AmiId parameter Default to ${ami_id}`);
92
+ } else {
93
+ console.log('No AmiId parameter found in stack. Template unchanged');
94
+ }
95
+ }
96
+ await s3.send(new PutObjectCommand({ Bucket: bucket, Key: stackKey, Body: finalStack }));
97
+ console.log(`Uploaded s3://${bucket}/${stackKey}`);
98
+
99
+ // --- V UPDATE MANIFEST ---
100
+ // One entry per major.minor: drop any prior patch versions of this version
101
+ manifest.versions = manifest.versions.filter(v=>{ return !v.version.startsWith(args.v + '.') && v.version !== args.v });
102
+ manifest.versions.push({
103
+ version: fullVersion,
104
+ updated_on: buildTime.toISOString(),
105
+ ami_id: ami_id,
106
+ stack_url: `https://${bucket}.s3.${cfg.region}.amazonaws.com/${stackKey}`
107
+ });
108
+ await putManifest(s3, bucket, appPrefix, manifest);
109
+ console.log(`Recorded v${fullVersion} (${ami_id}) in manifest`);
110
+
111
+ // --- VI PRUNE AMIS ---
112
+ // Fire and forget: keep only the newest build per major.minor across all regions
113
+ if (!manifest.noami){
114
+ prune_amis(appName, cfg.region).catch(e=>{ console.log('Prune failed: ' + e.message) });
115
+ }
116
+
117
+ return true;
118
+ }
119
+
120
+
121
+ ///////////////////////////////////////////////
122
+ ///////////////////////////////////////////////
123
+ ///////////////////////////////////////////////
124
+
125
+ function parseCdn(cdn){
126
+ const p = cdn.trim().replace(/^s3:\/\//i, '').replace(/\/+$/, '');
127
+ const parts = p.split('/');
128
+ return { bucket: parts[0], prefix: parts.slice(1).join('/') };
129
+ }
130
+
131
+ // Graviton instance families end in g (r8g, m7g, t4g) and need arm64 AMIs
132
+ function archFromInstanceType(instanceType){
133
+ const family = instanceType.split('.')[0];
134
+ return /[0-9]g/.test(family) ? 'arm' : 'x86_64';
135
+ }
136
+
137
+ async function prepZip(appPath){
138
+ console.log('Zipping ' + appPath);
139
+ const inPath = path.resolve(appPath);
140
+ let zipPath = path.resolve(`./app.zip`);
141
+
142
+ const pathStat = fs.statSync(inPath);
143
+ // If dir, zip
144
+ if (!pathStat.isFile()){
145
+ const zip = new AdmZip();
146
+ zip.addLocalFolder(inPath);
147
+ zip.writeZip(zipPath);
148
+ } else {
149
+ // If not zip, throw error
150
+ if (path.extname(inPath) !== '.zip'){
151
+ console.log('ERROR:Not a .zip file >>' + inPath)
152
+ throw 'ERROR:Not a .zip file >>' + inPath;
153
+ }
154
+ // Copy .zip file
155
+ fs.copyFileSync(inPath,zipPath);
156
+ }
157
+ process.on('exit', function(){ fs.unlinkSync(zipPath) });
158
+ return zipPath;
159
+ }
160
+
161
+ async function uploadFile(s3, bucket, key, localPath){
162
+ await s3.send(new PutObjectCommand({
163
+ Bucket: bucket,
164
+ Key: key,
165
+ Body: fs.createReadStream(localPath)
166
+ }));
167
+ }
168
+
169
+ // Remove earlier builds of this app in every region, keeping only the newest AMI per major.minor
170
+ async function prune_amis(appName, orgRegion){
171
+ const ec2 = new EC2Client({ region: orgRegion });
172
+ const r = await ec2.send(new DescribeRegionsCommand({}));
173
+ const regions = r.Regions.map(rg=>{ return rg.RegionName });
174
+
175
+ for (const region of regions){
176
+ try {
177
+ await pruneRegion(appName, region);
178
+ } catch (e){
179
+ console.log(`Prune failed in ${region}: ${e.message}`);
180
+ }
181
+ }
182
+ }
183
+
184
+ async function pruneRegion(appName, region){
185
+ const client = new EC2Client({ region });
186
+ const r = await client.send(new DescribeImagesCommand({
187
+ Owners: ['self'],
188
+ Filters: [{ Name: 'name', Values: [`${appName}-v*`] }]
189
+ }));
190
+ if (!r.Images || r.Images.length === 0){ return }
191
+
192
+ // Group builds by major.minor. Name format: app-v[major.minor].[timestamp]
193
+ const groups = {};
194
+ r.Images.forEach(img=>{
195
+ const m = img.Name.match(/-v([0-9]+\.[0-9]+)\.(.+)$/);
196
+ if (!m){ return }
197
+ (groups[m[1]] = groups[m[1]] || []).push(img);
198
+ });
199
+
200
+ for (const v of Object.keys(groups)){
201
+ // Timestamps are fixed-width (YYYYMMDDTHHMM) so names sort chronologically
202
+ const builds = groups[v].sort((a,b)=>{ return a.Name < b.Name ? 1 : -1 });
203
+ for (const img of builds.slice(1)){
204
+ console.log(`Pruning ${img.Name} (${img.ImageId}) in ${region}`);
205
+ await client.send(new DeregisterImageCommand({ ImageId: img.ImageId }));
206
+ for (const bdm of (img.BlockDeviceMappings || [])){
207
+ if (bdm.Ebs && bdm.Ebs.SnapshotId){
208
+ await client.send(new DeleteSnapshotCommand({ SnapshotId: bdm.Ebs.SnapshotId }));
209
+ }
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ async function getFileText(s3, bucket, key){
216
+ try {
217
+ const r = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
218
+ return await r.Body.transformToString();
219
+ } catch (e){
220
+ if (/NoSuchKey|NotFound/.test(e)){ return null }
221
+ throw e;
222
+ }
223
+ }
224
+
225
+ // Set Default on the AmiId parameter, touching nothing else in the template.
226
+ // JSON templates are parsed; YAML templates get a single-line text edit.
227
+ function setAmiDefault(stackText, amiId){
228
+ if (stackText.trim().startsWith('{')){
229
+ const tpl = JSON.parse(stackText);
230
+ if (!tpl.Parameters || !tpl.Parameters.AmiId){ return { text: stackText, updated: false } }
231
+ tpl.Parameters.AmiId.Default = amiId;
232
+ return { text: JSON.stringify(tpl, null, 4), updated: true };
233
+ }
234
+
235
+ const lines = stackText.split(/\r?\n/);
236
+ // Find top-level Parameters section
237
+ const pStart = lines.findIndex(l=>{ return /^Parameters:\s*(#.*)?$/.test(l) });
238
+ if (pStart === -1){ return { text: stackText, updated: false } }
239
+
240
+ // Find the AmiId key inside Parameters (stop at the next top-level section)
241
+ let amiLine = -1;
242
+ let amiIndent = 0;
243
+ for (let i=pStart+1; i<lines.length; i++){
244
+ if (/^\S/.test(lines[i])){ break }
245
+ const m = lines[i].match(/^(\s+)AmiId:\s*(#.*)?$/);
246
+ if (m){ amiLine = i; amiIndent = m[1].length; break }
247
+ }
248
+ if (amiLine === -1){ return { text: stackText, updated: false } }
249
+
250
+ // Scan the AmiId block for an existing Default line
251
+ let blockEnd = lines.length;
252
+ for (let i=amiLine+1; i<lines.length; i++){
253
+ if (lines[i].trim() === ''){ continue }
254
+ const indent = lines[i].match(/^\s*/)[0].length;
255
+ if (indent <= amiIndent){ blockEnd = i; break }
256
+ const dm = lines[i].match(/^(\s+)Default:\s*/);
257
+ if (dm){
258
+ lines[i] = `${dm[1]}Default: ${amiId}`;
259
+ return { text: lines.join('\n'), updated: true };
260
+ }
261
+ }
262
+
263
+ // No Default line - insert one right after AmiId:, matching the block's indentation
264
+ let childIndent = ' '.repeat(amiIndent + 2);
265
+ for (let i=amiLine+1; i<blockEnd; i++){
266
+ if (lines[i].trim() !== ''){ childIndent = lines[i].match(/^\s*/)[0]; break }
267
+ }
268
+ lines.splice(amiLine+1, 0, `${childIndent}Default: ${amiId}`);
269
+ return { text: lines.join('\n'), updated: true };
270
+ }
271
+
272
+ async function putManifest(s3, bucket, appPrefix, manifest){
273
+ await s3.send(new PutObjectCommand({
274
+ Bucket: bucket,
275
+ Key: `${appPrefix}/manifest.json`,
276
+ Body: JSON.stringify(manifest, null, 4),
277
+ ContentType: 'application/json'
278
+ }));
279
+ }
@@ -0,0 +1,124 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
4
+ const OrgConfig = require('./helpers/org_config');
5
+ const Apps = require('./helpers/apps');
6
+
7
+ // Marketplace listing text files stored as top-level manifest attributes.
8
+ // Names match the keys the AWS Marketplace Catalog API expects
9
+ const TEXT_ASSETS = ['ShortDescription', 'LongDescription', 'UsageInstructions', 'SupportDescription'];
10
+ // Images uploaded to s3; their public URLs stored under the AWS attribute name
11
+ const IMAGE_ASSETS = [
12
+ { file: 'ArchitectureDiagram.png', attr: 'ArchitectureDiagram' },
13
+ { file: 'Logo.png', attr: 'LogoUrl' }
14
+ ];
15
+
16
+ // update-listing: update the manifest from the assets folder and/or set the product id
17
+ exports.main = async function(args){
18
+ const cfg = OrgConfig.read();
19
+ if (!cfg){ throw new Error('No org config found. Run set-org') }
20
+ const { bucket, prefix } = parseCdn(cfg.cdn);
21
+ const s3 = new S3Client({ region: cfg.region });
22
+
23
+ const appName = args.app.toLowerCase();
24
+
25
+ const app = await Apps.resolveApp(s3, bucket, prefix, appName);
26
+ if (!app){ throw new Error('No app named ' + args.app + '. Run new-app') }
27
+ const { appPrefix, manifest } = app;
28
+
29
+ if (!args.assets && !args.pid && !args.ec2){
30
+ console.log('Nothing to update. Pass -assets, -pid, or -ec2');
31
+ return true;
32
+ }
33
+
34
+ // Apply all manifest updates in one download/update/upload pass
35
+ if (args.assets){
36
+ await applyAssets(s3, bucket, appPrefix, cfg.region, args.assets, manifest);
37
+ }
38
+ if (args.pid){
39
+ manifest.productId = args.pid;
40
+ console.log('Set productId');
41
+ }
42
+ if (args.ec2){
43
+ manifest.RecommendedInstanceType = args.ec2;
44
+ console.log('Set RecommendedInstanceType');
45
+ }
46
+ await putManifest(s3, bucket, appPrefix, manifest);
47
+ console.log('Updated manifest');
48
+ return true;
49
+ }
50
+
51
+
52
+ /////////////////////////////////////////
53
+ ////////////// FUNCS ////////////////////
54
+ ////////////////////////////////////////
55
+
56
+ function parseCdn(cdn){
57
+ const p = cdn.trim().replace(/^s3:\/\//i, '').replace(/\/+$/, '');
58
+ const parts = p.split('/');
59
+ return { bucket: parts[0], prefix: parts.slice(1).join('/') };
60
+ }
61
+
62
+ // Listing text files go into the manifest; only images are uploaded as files.
63
+ // Empty placeholder files are skipped so they never blank out existing values
64
+ async function applyAssets(s3, bucket, appPrefix, region, assetsPath, manifest){
65
+ if (!fs.existsSync(assetsPath)){ throw new Error('Assets path does not exist: ' + assetsPath) }
66
+
67
+ TEXT_ASSETS.forEach(name=>{
68
+ const filePath = path.join(assetsPath, `${name}.txt`);
69
+ if (!fs.existsSync(filePath) || fs.readFileSync(filePath, 'utf-8').trim() === ''){
70
+ console.log(`Skipping ${name}: no content in ${name}.txt`);
71
+ return;
72
+ }
73
+ manifest[name] = fs.readFileSync(filePath, 'utf-8');
74
+ console.log(`Set ${name}`);
75
+ });
76
+
77
+ // VideoUrls.txt: one URL per line (AWS currently supports a single URL)
78
+ const videoPath = path.join(assetsPath, 'VideoUrls.txt');
79
+ if (fs.existsSync(videoPath)){
80
+ const urls = fs.readFileSync(videoPath, 'utf-8').split('\n').map(l=>{ return l.trim() }).filter(l=>{ return l });
81
+ if (urls.length){
82
+ manifest.VideoUrls = urls;
83
+ console.log('Set VideoUrls');
84
+ }
85
+ }
86
+
87
+ // AdditionalResources.json: array of { Text, Url }
88
+ const resPath = path.join(assetsPath, 'AdditionalResources.json');
89
+ if (fs.existsSync(resPath)){
90
+ const raw = fs.readFileSync(resPath, 'utf-8').trim();
91
+ if (raw && raw !== '[]'){
92
+ let resources;
93
+ try { resources = JSON.parse(raw) } catch (e){ throw new Error('AdditionalResources.json is not valid JSON') }
94
+ manifest.AdditionalResources = resources;
95
+ console.log('Set AdditionalResources');
96
+ }
97
+ }
98
+
99
+ for (const img of IMAGE_ASSETS){
100
+ const imgPath = path.join(assetsPath, img.file);
101
+ if (!fs.existsSync(imgPath) || fs.statSync(imgPath).size === 0){
102
+ console.log(`Skipping ${img.attr}: no content in ${img.file}`);
103
+ continue;
104
+ }
105
+ const key = `${appPrefix}/marketplace/${img.file}`;
106
+ await s3.send(new PutObjectCommand({
107
+ Bucket: bucket,
108
+ Key: key,
109
+ Body: fs.createReadStream(imgPath),
110
+ ContentType: 'image/png'
111
+ }));
112
+ manifest[img.attr] = `https://${bucket}.s3.${region}.amazonaws.com/${key}`;
113
+ console.log(`Uploaded ${img.file}: ${manifest[img.attr]}`);
114
+ }
115
+ }
116
+
117
+ async function putManifest(s3, bucket, appPrefix, manifest){
118
+ await s3.send(new PutObjectCommand({
119
+ Bucket: bucket,
120
+ Key: `${appPrefix}/manifest.json`,
121
+ Body: JSON.stringify(manifest, null, 4),
122
+ ContentType: 'application/json'
123
+ }));
124
+ }
package/main.js ADDED
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+
3
+ const Commands = {
4
+ 'create-cdn': {
5
+ desc: "Create a CDN bucket with public access policy and marketplace role",
6
+ exec: require('./commands/create_cdn').main,
7
+ args: [
8
+ {n: 'name', desc: 'CDN bucket name (lowercase letters, numbers, hyphens)', pattern: `^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$`, r: true}
9
+ ]
10
+ },
11
+ 'set-org': {
12
+ desc: "Set the organization CDN",
13
+ exec: require('./commands/init_org').main,
14
+ args: [
15
+ {n: 'cdn', desc: 'CDN bucket name from create-cdn (e.g. cdn-theorim-io)', pattern: `^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$`, r: true}
16
+ ]
17
+ },
18
+ 'new-app': {
19
+ desc: 'Create a new application',
20
+ exec: require('./commands/new_app').main,
21
+ args: [
22
+ {n: 'name', desc: 'Application name (letters only)', pattern: `^[A-Za-z]{2,20}$`, r: true},
23
+ {n: 'repo', desc: 'GitHub repo (org/repo). Creates a CI role assumable by GitHub Actions from that repo', r: false},
24
+ {n: 'noami', desc: 'Serverless app: update-app skips the AMI build and launch passes no AmiId', r: false},
25
+ {n: 'private', desc: 'Store the app under a private _ folder (not publicly accessible)', r: false}
26
+ ]
27
+ },
28
+ 'update-listing': {
29
+ desc: 'Update marketplace listing details',
30
+ exec: require('./commands/update_listing').main,
31
+ args: [
32
+ {n: 'app', desc: 'Name of existing app', pattern: `^[A-Za-z]{2,20}$`, r: true},
33
+ {n: 'assets', desc: 'Path to folder with application assets', r: false},
34
+ {n: 'pid', desc: 'AWS Product ID', r: false},
35
+ {n: 'ec2', desc: 'Instance type (default r8g.medium)', r: false}
36
+ ]
37
+ },
38
+ 'update-app': {
39
+ desc: 'Update application',
40
+ exec: require('./commands/update_app').main,
41
+ args: [
42
+ {n: 'app', desc: 'Name of existing app', pattern: `^[A-Za-z]{2,20}$`, r: true},
43
+ {n: 'v', desc: 'Version to update', pattern: `[0-9]{1,20}`, r: true},
44
+ {n: 'path', desc: 'Path to app zip file or folder', r: true},
45
+ {n: 'stack', desc: 'Path to updated JSON or YML stack', r: false},
46
+ ]
47
+ },
48
+ 'launch': {
49
+ desc: 'Launch application version to an instance',
50
+ exec: require('./commands/launch_app').main,
51
+ args: [
52
+ {n: 'app', desc: 'Name of existing app', pattern: `^[A-Za-z]{2,20}$`, r: true},
53
+ {n: 'v', desc: 'Version to launch', pattern: `[0-9]{1,20}`, r: true},
54
+ {n: 'title', desc: 'Title of the version', pattern: `^[A-Za-z][A-Za-z0-9-]{1,30}$`, r: true},
55
+ {n: 'region', desc: 'AWS Region to launch the instance', pattern: `^[a-z]{2}-[a-z]+-[0-9]$`, r: true},
56
+ ]
57
+ },
58
+ 'publish': {
59
+ desc: 'Publish app to marketplace',
60
+ exec: require('./commands/publish').main,
61
+ args: [
62
+ {n: 'app', desc: 'Name of existing app', pattern: `^[A-Za-z]{2,20}$`, r: true},
63
+ {n: 'desc', desc: 'Description of Changes', r: true},
64
+ {n: 'v', desc: 'Version to launch', pattern: `[0-9]{1,20}`, r: true},
65
+ {n: 'await', desc: 'Wait for marketplace processing to complete (up to 90m)', r: false}
66
+ ]
67
+ },
68
+ /////
69
+ 'delete-app': {
70
+ desc: 'Delete app',
71
+ exec: require('./commands/delete').delete_app,
72
+ args: [
73
+ {n: 'app', desc: 'Name of existing app', pattern: `^[A-Za-z]{2,20}$`, r: true}
74
+ ]
75
+ },
76
+ 'delete-instance': {
77
+ desc: 'Delete instance (rolls back its stack)',
78
+ exec: require('./commands/delete').delete_instance,
79
+ args: [
80
+ {n: 'app', desc: 'Name of existing app', pattern: `^[A-Za-z]{2,20}$`, r: true},
81
+ {n: 'title', desc: 'Instance title used on launch', pattern: `^[A-Za-z][A-Za-z0-9-]{1,30}$`, r: true},
82
+ {n: 'region', desc: 'Region of the stack (searched across all regions if omitted)', pattern: `^[a-z]{2}-[a-z]+-[0-9]$`, r: false},
83
+ ]
84
+ },
85
+ 'list-apps': {
86
+ desc: 'List all apps',
87
+ exec: require('./commands/list_apps').main
88
+ },
89
+ 'app-details': {
90
+ desc: 'Get details of an existing app',
91
+ exec: require('./commands/app_details').main,
92
+ args: [
93
+ {n: 'app', desc: 'Name of existing app', pattern: `^[A-Za-z]{2,20}$`, r: true}
94
+ ]
95
+ }
96
+ }
97
+
98
+ async function main(){
99
+ const args = parseArgs();
100
+ // Print info if no command given
101
+ if (!args.cmd){
102
+ printAllInfo();
103
+ return;
104
+ }
105
+
106
+ // Check for valid command
107
+ if (!Commands[args.cmd]){
108
+ console.log('Invalid command. Run without args to list commands');
109
+ return;
110
+ }
111
+ // If Command has args, but none are give, print info
112
+ if (Commands[args.cmd].args && Object.keys(args.args).length == 0){
113
+ printCmdInfo(args.cmd);
114
+ return
115
+ }
116
+
117
+ // Validate args
118
+ const valid = validateArgs(args);
119
+ if (!valid){
120
+ console.log('FAILED:Invalid Arguments')
121
+ process.exit(1);
122
+ }
123
+ // Exec Command
124
+ try{
125
+ await Commands[args.cmd].exec(args.args);
126
+ } catch (e){
127
+ const errLocation = e.stack ? e.stack.split('\n')[1].replace(/.*\\/,'') : e.at;
128
+ console.log('FAILED>>' + e.message + ' @ ' + errLocation);
129
+ process.exit(1)
130
+ }
131
+ console.log('SUCCESS')
132
+ }
133
+
134
+
135
+ /////////////////////////////////
136
+ ////////////////////////////////
137
+
138
+
139
+
140
+ function parseArgs(){
141
+ var args = {
142
+ cmd: process.argv[2],
143
+ args: {}
144
+ };
145
+ for (let i=0;i<process.argv.length; i++){
146
+ if (process.argv[i][0] === '-'){
147
+ args.args[process.argv[i].replace('-','')] = null;
148
+ if (process.argv[i+1] && process.argv[i+1][0] !== '-'){
149
+ args.args[process.argv[i].replace('-','')] = process.argv[i+1];
150
+ i += 1
151
+ }
152
+ }
153
+ }
154
+ return args;
155
+ }
156
+
157
+ function printAllInfo(){
158
+ Object.entries(Commands).forEach((c)=>{
159
+ console.log(`| ${c[0]} ${c[1].desc}`)
160
+ if (c[1].args){
161
+ c[1].args.forEach(a=>{
162
+ const argName = a.r ? `${a.n}*` : a.n;
163
+ console.log(`\t-${argName}: ${a.desc}`)
164
+ })
165
+ }
166
+ console.log('-------\n');
167
+ })
168
+ console.log('\n*required')
169
+ }
170
+
171
+ function printCmdInfo(cmd){
172
+ const comm = Commands[cmd].args;
173
+ console.log('\n' + cmd)
174
+ comm.forEach(a=>{
175
+ const argName = a.r ? `${a.n}*` : a.n;
176
+ console.log(`\t-${argName}: ${a.desc}`)
177
+ })
178
+ console.log('-------\n');
179
+ }
180
+
181
+ function validateArgs(args){
182
+ const command = Commands[args.cmd];
183
+ if (!command.args){ return true }
184
+ for (let i=0; i<command.args.length;i++){
185
+ let carg = command.args[i];
186
+ const userArg = args.args[carg.n]
187
+ if (carg.pattern && userArg){
188
+ const rgx = new RegExp(carg.pattern);
189
+ if (!rgx.test(userArg)){
190
+ console.log(`Arg ${carg.n} does not match pattern ${carg.pattern}`);
191
+ return false;
192
+ }
193
+ }
194
+ if (carg.r && !userArg){
195
+ console.log('Missing required arg ' + carg.n);
196
+ return false;
197
+ }
198
+ }
199
+ return true;
200
+ }
201
+
202
+ main();
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "cloudmason2",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "main": "main.js",
6
+ "files": [
7
+ "main.js",
8
+ "commands",
9
+ ".marketplace"
10
+ ],
11
+ "scripts": {
12
+ "build": "node build.js"
13
+ },
14
+ "bin": {
15
+ "mason2": "./main.js"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/kai-harvey/cloudmason.git"
20
+ },
21
+ "author": "Kai Harvey",
22
+ "license": "ISC",
23
+ "dependencies": {
24
+ "@aws-sdk/client-acm": "^3.418.0",
25
+ "@aws-sdk/client-auto-scaling": "^3.470.0",
26
+ "@aws-sdk/client-cloudformation": "^3.418.0",
27
+ "@aws-sdk/client-ec2": "^3.864.0",
28
+ "@aws-sdk/client-iam": "^3.864.0",
29
+ "@aws-sdk/client-marketplace-catalog": "^3.716.0",
30
+ "@aws-sdk/client-route-53": "^3.425.0",
31
+ "@aws-sdk/client-s3": "^3.418.0",
32
+ "@aws-sdk/client-ssm": "^3.421.0",
33
+ "@aws-sdk/client-sts": "^3.421.0",
34
+ "adm-zip": "^0.5.10",
35
+ "ssh2": "^1.16.0",
36
+ "yaml": "^2.6.1"
37
+ }
38
+ }