contensis-cli 1.0.0-beta.94 → 1.0.0-beta.96

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.
@@ -1,8 +1,14 @@
1
+ import inquirer from 'inquirer';
1
2
  import { JSONPath, JSONPathOptions } from 'jsonpath-plus';
3
+ import { Document } from 'yaml';
4
+ import {
5
+ GitHubActionPushBlockJob,
6
+ GitHubActionPushBlockJobStep,
7
+ GitLabPushBlockJobStage,
8
+ } from '~/models/DevService';
2
9
  import { readFile } from '~/providers/file-provider';
3
10
  import ContensisDev from '~/services/ContensisDevService';
4
11
  import { diffFileContent } from '~/util/diff';
5
- import { GitHelper } from '~/util/git';
6
12
  import { logError } from '~/util/logger';
7
13
  import { normaliseLineEndings } from '~/util/os';
8
14
  import { parseYamlDocument, validateWorkflowYaml } from '~/util/yaml';
@@ -13,141 +19,482 @@ type MappedWorkflowOutput = {
13
19
  diff: string;
14
20
  };
15
21
 
16
- export const mapCIWorkflowContent = (
17
- cli: ContensisDev,
18
- git: GitHelper
19
- ): MappedWorkflowOutput | undefined => {
22
+ export const mapCIWorkflowContent = async (
23
+ cli: ContensisDev
24
+ ): Promise<MappedWorkflowOutput | undefined> => {
20
25
  // get existing workflow file
21
- const workflowFile = readFile(git.ciFilePath);
26
+ const workflowFile = readFile(cli.git.ciFilePath);
22
27
  if (!workflowFile) return undefined;
23
28
 
24
- const blockId = git.name;
25
- if (git.type === 'github') {
26
- const addGitHubActionJobStep = {
27
- name: 'Push block to Contensis',
28
- id: 'push-block',
29
- uses: 'contensis/block-push@v1',
30
- with: {
31
- 'block-id': blockId,
32
- // 'image-uri': '${{ steps.build.outputs.image-uri }}',
33
- alias: cli.currentEnv,
34
- 'project-id': cli.currentProject,
35
- 'client-id': '${{ secrets.CONTENSIS_CLIENT_ID }}',
36
- 'shared-secret': '${{ secrets.CONTENSIS_SHARED_SECRET }}',
37
- },
38
- };
29
+ const blockId = cli.git.name;
30
+
31
+ // parse yaml to js
32
+ const workflowDoc = parseYamlDocument(workflowFile);
33
+ const workflowJS = workflowDoc.toJS();
39
34
 
40
- // parse yaml to js
41
- const workflowDoc = parseYamlDocument(workflowFile);
42
- const workflow = workflowDoc.toJS();
43
- const setWorkflowElement = (path: string | any[], value: any) => {
44
- const findPath =
45
- typeof path === 'string' && path.includes('.')
46
- ? path
47
- .split('.')
48
- .map(p => (Number(p) || Number(p) !== 0 ? p : Number(p)))
49
- : path;
50
-
51
- if (workflowDoc.hasIn(findPath)) {
52
- workflowDoc.setIn(findPath, value);
53
- } else {
54
- workflowDoc.addIn(findPath, value);
55
- // }
56
- }
35
+ if (cli.git.type === 'github') {
36
+ const newWorkflow = await mapGitHubActionCIWorkflowContent(
37
+ cli,
38
+ blockId,
39
+ workflowDoc,
40
+ workflowJS
41
+ );
42
+ return {
43
+ existingWorkflow: workflowFile,
44
+ newWorkflow,
45
+ diff: diffFileContent(workflowFile, newWorkflow),
57
46
  };
58
- const findExistingJobSteps = (
59
- resultType: JSONPathOptions['resultType']
60
- ) => {
61
- // look for line in job
62
- // jobs.x.steps[uses: contensis/block-push]
63
- const path =
64
- git.type === 'github'
65
- ? '$.jobs..steps.*[?(@property === "uses" && @.match(/^contensis\\/block-push/i))]^'
66
- : // TODO: add jsonpath for gitlab file
67
- '';
68
-
69
- const existingJobStep = JSONPath({
70
- path,
71
- json: workflow,
72
- resultType: resultType,
73
- });
74
-
75
- return existingJobStep;
47
+ } else if (cli.git.type === 'gitlab') {
48
+ const newWorkflow = await mapGitLabCIWorkflowContent(
49
+ cli,
50
+ blockId,
51
+ workflowDoc,
52
+ workflowJS
53
+ );
54
+ return {
55
+ existingWorkflow: workflowFile,
56
+ newWorkflow,
57
+ diff: diffFileContent(workflowFile, newWorkflow),
76
58
  };
59
+ }
60
+ };
77
61
 
78
- const existingJobStep = findExistingJobSteps('all');
62
+ const findExistingJobSteps = (
63
+ path: string,
64
+ json: any,
65
+ resultType: JSONPathOptions['resultType']
66
+ ) => {
67
+ const existingJobStep = JSONPath({
68
+ path,
69
+ json,
70
+ resultType: resultType,
71
+ });
79
72
 
80
- // update job step
81
- if (existingJobStep.length) {
82
- //cli.log.json(existingJobStep);
73
+ return existingJobStep;
74
+ };
83
75
 
84
- // The [0] index means we're only looking at updating the first instance in the file
85
- const step = existingJobStep[0];
76
+ const setWorkflowElement = (
77
+ workflowDoc: Document,
78
+ path: string | any[],
79
+ value: any
80
+ ) => {
81
+ const findPath =
82
+ typeof path === 'string' && path.includes('.')
83
+ ? path.split('.').map(p => (Number(p) || Number(p) !== 0 ? p : Number(p)))
84
+ : path;
86
85
 
87
- // Path looks like this "$['jobs']['build']['steps'][3]"
88
- // We want it to look like this "jobs.build.steps.3"
89
- const stepPath = step.path
90
- .replace('$[', '')
91
- .replaceAll('][', '.')
92
- .replace(']', '')
93
- .replaceAll("'", '');
86
+ if (workflowDoc.hasIn(findPath)) {
87
+ workflowDoc.setIn(findPath, value);
88
+ } else {
89
+ workflowDoc.addIn(findPath, value);
90
+ }
91
+ };
94
92
 
95
- cli.log.info(
96
- `Found existing Job step: ${stepPath}
97
- - name: ${step.value.name}
98
- id: ${step.value.id}\n`
99
- );
93
+ const mapGitLabCIWorkflowContent = async (
94
+ cli: ContensisDev,
95
+ blockId: string,
96
+ workflowDoc: Document,
97
+ workflowJS: any
98
+ ) => {
99
+ const addGitLabJobStage: GitLabPushBlockJobStage = {
100
+ stage: 'push-block',
101
+ variables: {
102
+ block_id: blockId,
103
+ alias: cli.currentEnv,
104
+ project_id: cli.currentProject,
105
+ client_id: '$CONTENSIS_CLIENT_ID',
106
+ shared_secret: '$CONTENSIS_SHARED_SECRET',
107
+ },
108
+ };
109
+
110
+ const addAppImageUri = async () => {
111
+ // Look in document level "variables"
112
+ const appImageUri = await determineAppImageUri(
113
+ cli,
114
+ Object.entries(workflowJS.variables || {}),
115
+ '$CI_REGISTRY_IMAGE/$CI_COMMIT_REF_NAME/app:build-$CI_PIPELINE_IID'
116
+ );
100
117
 
101
- setWorkflowElement(`${stepPath}.with.alias`, cli.currentEnv);
102
- setWorkflowElement(`${stepPath}.with.project-id`, cli.currentProject);
103
- setWorkflowElement(`${stepPath}.with.block-id`, blockId);
118
+ if (appImageUri.addVar)
104
119
  setWorkflowElement(
105
- `${stepPath}.with.client-id`,
106
- '${{ secrets.CONTENSIS_CLIENT_ID }}'
120
+ workflowDoc,
121
+ `variables.${appImageUri.var}`,
122
+ appImageUri.uri
107
123
  );
124
+
125
+ if (appImageUri.var)
126
+ addGitLabJobStage.variables['image_uri'] = `\$${appImageUri.var}`;
127
+ };
128
+
129
+ // look for line in job
130
+ // jobname[stage: push-block]
131
+ const existingJobStep = findExistingJobSteps(
132
+ '$..[?(@property === "stage" && @.match(/^push-block/i))]^',
133
+ workflowJS,
134
+ 'all'
135
+ );
136
+
137
+ // update job step
138
+ if (existingJobStep.length) {
139
+ // cli.log.json(existingJobStep);
140
+
141
+ // The [0] index means we're only looking at updating the first instance in the file
142
+ const step = existingJobStep[0];
143
+
144
+ // Path looks like this "$['job_name']"
145
+ // We want it to look like this "job_name"
146
+ const stepPath = step.path
147
+ .replace('$[', '')
148
+ .replaceAll('][', '.')
149
+ .replace(']', '')
150
+ .replaceAll("'", '');
151
+
152
+ cli.log.info(
153
+ `Found existing Job step:
154
+ ${stepPath}
155
+ stage: ${step.value.stage}\n`
156
+ );
157
+
158
+ setWorkflowElement(
159
+ workflowDoc,
160
+ `${stepPath}.variables.alias`,
161
+ cli.currentEnv
162
+ );
163
+ setWorkflowElement(
164
+ workflowDoc,
165
+ `${stepPath}.variables.project_id`,
166
+ cli.currentProject
167
+ );
168
+ setWorkflowElement(workflowDoc, `${stepPath}.variables.block_id`, blockId);
169
+
170
+ // This is likely not needed when updating an existing push-block job step
171
+ // we are assuming this is a forked/copied workflow with an already working image-uri reference
172
+ // await addAppImageUri();
173
+
174
+ setWorkflowElement(
175
+ workflowDoc,
176
+ `${stepPath}.variables.client_id`,
177
+ '$CONTENSIS_CLIENT_ID'
178
+ );
179
+ setWorkflowElement(
180
+ workflowDoc,
181
+ `${stepPath}.variables.shared_secret`,
182
+ '$CONTENSIS_SHARED_SECRET'
183
+ );
184
+ } else {
185
+ // create job with push step
186
+
187
+ // Does a series of checks and prompts to determine the correct image-uri
188
+ // for the app container build
189
+ await addAppImageUri();
190
+
191
+ // Add the new "job" to the Yaml Document
192
+ workflowDoc.addIn(['stages'], 'push-block');
193
+ workflowDoc.addIn([], {
194
+ key: 'push-to-contensis',
195
+ value: addGitLabJobStage,
196
+ });
197
+ setWorkflowElement(
198
+ workflowDoc,
199
+ `include`,
200
+ 'https://gitlab.zengenti.com/ops/contensis-ci/-/raw/main/push-block.yml'
201
+ );
202
+ }
203
+
204
+ cli.log.debug(`New file content to write\n\n${workflowDoc}`);
205
+
206
+ const newWorkflow = normaliseLineEndings(
207
+ workflowDoc.toString({ lineWidth: 0 })
208
+ );
209
+
210
+ return newWorkflow;
211
+ };
212
+
213
+ const mapGitHubActionCIWorkflowContent = async (
214
+ cli: ContensisDev,
215
+ blockId: string,
216
+ workflowDoc: Document,
217
+ workflowJS: any
218
+ ) => {
219
+ const addGitHubActionJobStep: GitHubActionPushBlockJobStep = {
220
+ name: 'Push block to Contensis',
221
+ id: 'push-block',
222
+ uses: 'contensis/block-push@v1',
223
+ with: {
224
+ 'block-id': blockId,
225
+ // 'image-uri': '${{ steps.build.outputs.image-uri }}',
226
+ alias: cli.currentEnv,
227
+ 'project-id': cli.currentProject,
228
+ 'client-id': '${{ secrets.CONTENSIS_CLIENT_ID }}',
229
+ 'shared-secret': '${{ secrets.CONTENSIS_SHARED_SECRET }}',
230
+ },
231
+ };
232
+
233
+ const addAppImageUri = async () => {
234
+ // Look in document level "env" vars
235
+ const appImageUri = await determineAppImageUri(
236
+ cli,
237
+ Object.entries(workflowJS.env || {}),
238
+ 'ghcr.io/${{ github.repository }}/${{ github.ref_name }}/app:build-${{ github.run_number }}'
239
+ );
240
+
241
+ if (appImageUri.addVar)
108
242
  setWorkflowElement(
109
- `${stepPath}.with.shared-secret`,
110
- '${{ secrets.CONTENSIS_SHARED_SECRET }}'
243
+ workflowDoc,
244
+ `env.${appImageUri.var}`,
245
+ appImageUri.uri
111
246
  );
112
- } else {
113
- // create job with push step
114
- workflowDoc.addIn(['jobs'], {
115
- key: 'deploy',
116
- value: {
117
- name: 'Push image to Contensis',
118
- 'runs-on': 'ubuntu-latest',
119
- steps: [addGitHubActionJobStep],
247
+ // workflowDoc.addIn(['env'], { [appImageUri.var]: appImageUri.uri });
248
+
249
+ if (appImageUri.var)
250
+ addGitHubActionJobStep.with[
251
+ 'image-uri'
252
+ ] = `\${{ env.${appImageUri.var} }}`;
253
+ };
254
+
255
+ // look for line in job
256
+ // jobs.x.steps[uses: contensis/block-push]
257
+ const existingJobStep = findExistingJobSteps(
258
+ '$.jobs..steps.*[?(@property === "uses" && @.match(/^contensis\\/block-push/i))]^',
259
+ workflowJS,
260
+ 'all'
261
+ );
262
+
263
+ // update job step
264
+ if (existingJobStep.length) {
265
+ //cli.log.json(existingJobStep);
266
+
267
+ // The [0] index means we're only looking at updating the first instance in the file
268
+ const step = existingJobStep[0];
269
+
270
+ // Path looks like this "$['jobs']['build']['steps'][3]"
271
+ // We want it to look like this "jobs.build.steps.3"
272
+ const stepPath = step.path
273
+ .replace('$[', '')
274
+ .replaceAll('][', '.')
275
+ .replace(']', '')
276
+ .replaceAll("'", '');
277
+
278
+ cli.log.info(
279
+ `Found existing Job step: ${stepPath}
280
+ - name: ${step.value.name}
281
+ id: ${step.value.id}\n`
282
+ );
283
+
284
+ setWorkflowElement(workflowDoc, `${stepPath}.with.alias`, cli.currentEnv);
285
+ setWorkflowElement(
286
+ workflowDoc,
287
+ `${stepPath}.with.project-id`,
288
+ cli.currentProject
289
+ );
290
+ setWorkflowElement(workflowDoc, `${stepPath}.with.block-id`, blockId);
291
+
292
+ // This is likely not needed when updating an existing push-block job step
293
+ // we are assuming this is a forked/copied workflow with an already working image-uri reference
294
+ // await addAppImageUri();
295
+
296
+ setWorkflowElement(
297
+ workflowDoc,
298
+ `${stepPath}.with.client-id`,
299
+ '${{ secrets.CONTENSIS_CLIENT_ID }}'
300
+ );
301
+ setWorkflowElement(
302
+ workflowDoc,
303
+ `${stepPath}.with.shared-secret`,
304
+ '${{ secrets.CONTENSIS_SHARED_SECRET }}'
305
+ );
306
+ } else {
307
+ // create job with push step
308
+
309
+ // is there already a job with a property name containing "build"?
310
+ const existingBuildJobStep = findExistingJobSteps(
311
+ '$.jobs[?(@property.match(/build/i))]',
312
+ workflowJS,
313
+ 'all'
314
+ ) as JSONPathOptions[]; // This isn't the correct type for this object
315
+
316
+ let needs: string | undefined;
317
+ // There are multiple jobs called *build*
318
+ if (existingBuildJobStep.length > 1) {
319
+ // prompt which build job we should depend on before pushing the block
320
+ const choices = existingBuildJobStep.map(s => s.parentProperty);
321
+ choices.push(new inquirer.Separator() as any);
322
+ choices.push('none');
323
+
324
+ ({ needs } = await inquirer.prompt([
325
+ {
326
+ type: 'list',
327
+ prefix: 'āŒ›',
328
+ message: cli.messages.devinit.ciMultipleBuildJobChoices(),
329
+ name: 'needs',
330
+ choices,
331
+ default: choices.find(
332
+ s => typeof s === 'string' && s.includes('docker')
333
+ ),
120
334
  },
121
- });
122
- }
335
+ ]));
336
+ cli.log.raw('');
337
+ } else if (existingBuildJobStep.length === 1)
338
+ // Exactly one job step found containing a property name of *build*
339
+ // we'll assume that is the one the push-block job depends on
340
+ needs = existingBuildJobStep[0].parentProperty;
123
341
 
124
- // Workflow validation provided by @action-validator/core package
125
- const workflowIsValid = validateWorkflowYaml(workflowDoc.toString());
342
+ if (existingBuildJobStep.length === 0 || needs === 'none') {
343
+ // No existing build step found or chosen, offer all job steps in prompt
344
+ const choices = Object.keys(workflowJS.jobs);
345
+ choices.push(new inquirer.Separator() as any);
346
+ choices.push('none');
126
347
 
127
- if (workflowIsValid === true) {
128
- cli.log.debug(
129
- `New file content to write to ${git.ciFilePath}\n\n${workflowDoc}`
130
- );
131
- } else if (Array.isArray(workflowIsValid)) {
132
- // Errors
133
- logError(
134
- [
135
- ...workflowIsValid.map(
136
- res => new Error(`${res.code}: ${res.detail || res.title}`)
348
+ ({ needs } = await inquirer.prompt([
349
+ {
350
+ type: 'list',
351
+ prefix: 'āŒ›',
352
+ message: cli.messages.devinit.ciMultipleJobChoices(),
353
+ name: 'needs',
354
+ choices,
355
+ default: choices.find(
356
+ j => typeof j === 'string' && j.includes('docker')
137
357
  ),
138
- workflowDoc.toString(),
139
- ],
140
- `GitHub workflow YAML did not pass validation check`
141
- );
358
+ },
359
+ ]));
360
+ if (needs === 'none') needs = undefined;
361
+ cli.log.raw('');
142
362
  }
143
- const newWorkflow = normaliseLineEndings(
144
- workflowDoc.toString({ lineWidth: 0 })
145
- );
146
363
 
147
- return {
148
- existingWorkflow: workflowFile,
149
- newWorkflow,
150
- diff: diffFileContent(workflowFile, newWorkflow),
364
+ // Does a series of checks and prompts to determine the correct image-uri
365
+ // for the app container build
366
+ await addAppImageUri();
367
+
368
+ const newJob: GitHubActionPushBlockJob = {
369
+ name: 'Deploy container image to Contensis',
370
+ 'runs-on': 'ubuntu-latest',
371
+ needs,
372
+ steps: [addGitHubActionJobStep],
151
373
  };
374
+
375
+ // Add the new "job" to the Yaml Document
376
+ workflowDoc.addIn(['jobs'], {
377
+ key: 'deploy',
378
+ value: newJob,
379
+ });
380
+ }
381
+
382
+ // Workflow validation provided by @action-validator/core package
383
+ const workflowIsValid = validateWorkflowYaml(workflowDoc.toString());
384
+
385
+ // We could expand validation to check for having a build step to wait for
386
+ // or if a valid image-uri attribute is set
387
+ if (workflowIsValid === true) {
388
+ cli.log.success(`GitHub workflow YAML is valid`);
389
+ cli.log.debug(`New file content to write\n\n${workflowDoc}`);
390
+ } else if (Array.isArray(workflowIsValid)) {
391
+ // Errors
392
+ logError(
393
+ [
394
+ ...workflowIsValid.map(
395
+ res => new Error(`${res.code}: ${res.detail || res.title}`)
396
+ ),
397
+ workflowDoc.toString(),
398
+ ],
399
+ `GitHub workflow YAML did not pass validation check`
400
+ );
401
+ }
402
+ const newWorkflow = normaliseLineEndings(
403
+ workflowDoc.toString({ lineWidth: 0 })
404
+ );
405
+
406
+ return newWorkflow;
407
+ };
408
+
409
+ const determineAppImageUri = async (
410
+ cli: ContensisDev,
411
+ vars: [string, string][],
412
+ defaultUri: string
413
+ ) => {
414
+ // Determine container image-uri via variables and/or prompts
415
+
416
+ // Find vars including the word "image"
417
+ const imageVars = vars.filter(([varname]) =>
418
+ varname.toLowerCase().includes('image')
419
+ );
420
+ // Find vars named "image" that include the word "app"
421
+ const appImageVars = imageVars.filter(
422
+ ([varname, value]) =>
423
+ !varname.toLowerCase().includes('builder_') &&
424
+ (varname.toLowerCase().includes('app_') ||
425
+ varname.toLowerCase().includes('build_') ||
426
+ value?.toLowerCase().includes('/app'))
427
+ );
428
+
429
+ const appImageUriGuess = appImageVars?.[0];
430
+
431
+ let appImageUri: string | undefined;
432
+ let appImageVar: string | undefined;
433
+
434
+ if (appImageUriGuess) {
435
+ cli.log.success(
436
+ `Found variable ${cli.log.standardText(
437
+ appImageUriGuess[0]
438
+ )} we'll use for pulling the block image from: ${cli.log.infoText(
439
+ appImageUriGuess[1]
440
+ )}`
441
+ );
442
+ appImageVar = appImageUriGuess[0];
443
+ } else {
444
+ // Could not find a suitable var to use for block image-uri
445
+ // prompt for an app image uri
446
+ const choices = vars.map(v => v[0]);
447
+ const enterOwnMsg = 'enter my own / use default';
448
+
449
+ choices.push(new inquirer.Separator() as any);
450
+ choices.push(enterOwnMsg);
451
+ choices.push(defaultUri);
452
+
453
+ ({ appImageVar, appImageUri } = await inquirer.prompt([
454
+ // First question determines if an existing env variable
455
+ // already containes the tagged app image uri
456
+ {
457
+ type: 'list',
458
+ prefix: '🐳',
459
+ message: cli.messages.devinit.ciMultipleAppImageVarChoices(),
460
+ name: 'appImageVar',
461
+ choices,
462
+ default: choices.find(
463
+ v => typeof v === 'string' && v.toLowerCase().includes('image')
464
+ ),
465
+ },
466
+ // Subsequent prompt allows input of an image-uri if needed
467
+ {
468
+ type: 'input',
469
+ when(answers) {
470
+ return [enterOwnMsg, defaultUri].includes(answers.appImageVar);
471
+ },
472
+ prefix: `\n \nšŸ”—`,
473
+ message: cli.messages.devinit.ciEnterOwnAppImagePrompt(cli.git),
474
+ name: 'appImageUri',
475
+ default: defaultUri,
476
+ },
477
+ ]));
478
+ cli.log.raw('');
479
+
480
+ // this indicates a uri has been added and we will add a new var
481
+ // to the workflow to encourage users to update the docker tag part
482
+ // of their build workflow to use the same var as the push-block job/step
483
+ if ([enterOwnMsg, defaultUri].includes(appImageVar || ''))
484
+ appImageVar = undefined;
152
485
  }
486
+
487
+ if (appImageVar)
488
+ appImageUri =
489
+ cli.git.type === 'github'
490
+ ? `\${{ env.${appImageVar} }}`
491
+ : `\$${appImageVar}`;
492
+
493
+ // TODO: prompt further for image tag
494
+
495
+ return {
496
+ addVar: !appImageVar,
497
+ uri: appImageUri,
498
+ var: appImageVar || 'BUILD_IMAGE',
499
+ };
153
500
  };
@@ -2,4 +2,37 @@ export type EnvContentsToAdd = {
2
2
  ALIAS: string;
3
3
  PROJECT: string;
4
4
  ACCESS_TOKEN?: string;
5
- }
5
+ };
6
+
7
+ export type GitHubActionPushBlockJobStep = {
8
+ name: string;
9
+ id: 'push-block';
10
+ uses: string;
11
+ with: {
12
+ 'block-id': string;
13
+ alias: string;
14
+ 'project-id': string;
15
+ 'client-id': string;
16
+ 'shared-secret': string;
17
+ 'image-uri'?: string;
18
+ };
19
+ };
20
+
21
+ export type GitHubActionPushBlockJob = {
22
+ name: string;
23
+ 'runs-on': string;
24
+ needs?: string;
25
+ steps: GitHubActionPushBlockJobStep[];
26
+ };
27
+
28
+ export type GitLabPushBlockJobStage = {
29
+ stage: string;
30
+ variables: {
31
+ alias: string;
32
+ project_id: string;
33
+ block_id: string;
34
+ image_uri?: string;
35
+ client_id: string;
36
+ shared_secret: string;
37
+ };
38
+ };
@@ -1,2 +1,3 @@
1
1
  declare module 'giturl';
2
2
  declare module 'inquirer-command-prompt';
3
+ declare module 'printable-characters';