clever-tools 3.2.0 → 3.5.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/README.md CHANGED
@@ -88,10 +88,10 @@ Where `type` is one of:
88
88
  Where region is one of:
89
89
 
90
90
  - `par` (Paris, [Clever Cloud](https://www.clever-cloud.com/infrastructure/))
91
+ - `grahds` (Gravelines, HDS servers, OVHcloud)
91
92
  - `rbx` (Roubaix, OVHcloud)
92
93
  - `rbxhds` (Roubaix, HDS servers, OVHcloud)
93
94
  - `scw` (Paris, [Scaleway DC5](https://www.clever-cloud.com/blog/press/2023/01/17/clever-cloud-and-scaleway-join-forces-to-unveil-a-sovereign-european-paas-offering/))
94
- - `jed` (Jeddah, Oracle Cloud)
95
95
  - `mtl` (Montreal, OVHcloud)
96
96
  - `sgp` (Singapore, OVHcloud)
97
97
  - `syd` (Sydney, OVHcloud)
package/bin/clever.js CHANGED
@@ -90,7 +90,10 @@ function run () {
90
90
  description: 'Application ID (or name, if unambiguous)',
91
91
  parser: Parsers.appIdOrName,
92
92
  }),
93
- appNameCreation: cliparse.argument('app-name', { description: 'Application name' }),
93
+ appNameCreation: cliparse.argument('app-name', {
94
+ description: 'Application name (optional, current directory name is used if not specified)',
95
+ default: '',
96
+ }),
94
97
  backupId: cliparse.argument('backup-id', { description: 'A Database backup ID (format: UUID)' }),
95
98
  databaseId: cliparse.argument('database-id', { description: 'Any database ID (format: addon_UUID, postgresql_UUID, mysql_UUID, ...)' }),
96
99
  drainId: cliparse.argument('drain-id', { description: 'Drain ID' }),
@@ -132,6 +135,7 @@ function run () {
132
135
  sourceableEnvVarsList: cliparse.flag('add-export', { description: 'Display sourceable env variables setting' }),
133
136
  accesslogsFormat: getOutputFormatOption(['simple', 'extended', 'clf']),
134
137
  addonEnvFormat: getOutputFormatOption(['shell']),
138
+ logsFormat: getOutputFormatOption(['json-stream']),
135
139
  accesslogsFollow: cliparse.flag('follow', {
136
140
  aliases: ['f'],
137
141
  description: 'Display access logs continuously (ignores before/until, after/since)',
@@ -165,7 +169,7 @@ function run () {
165
169
  metavar: 'before',
166
170
  aliases: ['until'],
167
171
  parser: Parsers.date,
168
- description: 'Fetch logs before this date/time (ISO8601)',
172
+ description: 'Fetch logs before this date/time (ISO8601 date or duration, positive number in seconds or duration Ex: 1h)',
169
173
  }),
170
174
  branch: cliparse.option('branch', {
171
175
  aliases: ['b'],
@@ -180,6 +184,12 @@ function run () {
180
184
  metavar: 'commit id',
181
185
  description: 'Restart the application with a specific commit ID',
182
186
  }),
187
+ gitTag: cliparse.option('tag', {
188
+ aliases: ['t'],
189
+ default: '',
190
+ metavar: 'tag',
191
+ description: 'Tag to push (none by default)',
192
+ }),
183
193
  databaseId: cliparse.option('database-id', {
184
194
  metavar: 'database_id',
185
195
  description: 'The Database ID (e.g.: postgresql_xxx)',
@@ -300,7 +310,7 @@ function run () {
300
310
  }),
301
311
  addonPlan: cliparse.option('plan', {
302
312
  aliases: ['p'],
303
- default: 'dev',
313
+ default: '',
304
314
  metavar: 'plan',
305
315
  description: 'Addon plan, depends on the provider',
306
316
  complete: Addon('completePlan'),
@@ -345,11 +355,17 @@ function run () {
345
355
  parser: Parsers.commaSeparated,
346
356
  }),
347
357
  showAllActivity: cliparse.flag('show-all', { description: 'Show all activity' }),
348
- showAll: cliparse.flag('show-all', { description: 'Show all available dependencies' }),
358
+ showAll: cliparse.flag('show-all', { description: 'Show all available add-ons and applications' }),
349
359
  loginToken: cliparse.option('token', {
350
360
  metavar: 'token',
351
361
  description: 'Directly give an existing token',
352
362
  }),
363
+ taskCommand: cliparse.option('task', {
364
+ description: 'The application launch as a task executing the given command, then stopped',
365
+ aliases: ['T'],
366
+ parser: Parsers.nonEmptyString,
367
+ metavar: 'command',
368
+ }),
353
369
  instanceType: cliparse.option('type', {
354
370
  aliases: ['t'],
355
371
  required: true,
@@ -367,6 +383,12 @@ function run () {
367
383
  metavar: 'api_key',
368
384
  description: 'Drain custom key',
369
385
  }),
386
+ drainIndexPrefix: cliparse.option('index-prefix', {
387
+ aliases: ['i'],
388
+ metavar: 'index_prefix',
389
+ description: 'Optional drain index prefix for ElasticSearch: `<indexPrefix>-<YYYY-MM-DD>`',
390
+ default: 'logstash-<YYYY-MM-DD>',
391
+ }),
370
392
  verbose: cliparse.flag('verbose', { aliases: ['v'], description: 'Verbose output' }),
371
393
  withoutCache: cliparse.flag('without-cache', { description: 'Restart the application without using cache' }),
372
394
  confirmAddonCreation: cliparse.flag('yes', {
@@ -559,7 +581,7 @@ function run () {
559
581
  const addonCreateCommand = cliparse.command('create', {
560
582
  description: 'Create an addon',
561
583
  args: [args.addonProvider, args.addonName],
562
- options: [opts.linkAddon, opts.confirmAddonCreation, opts.addonPlan, opts.addonRegion, opts.addonVersion, opts.addonOptions],
584
+ options: [opts.linkAddon, opts.confirmAddonCreation, opts.addonPlan, opts.addonRegion, opts.addonVersion, opts.addonOptions, opts.humanJsonOutputFormat],
563
585
  }, addon('create'));
564
586
  const addonDeleteCommand = cliparse.command('delete', {
565
587
  description: 'Delete an addon',
@@ -586,7 +608,7 @@ function run () {
586
608
 
587
609
  const addonCommands = cliparse.command('addon', {
588
610
  description: 'Manage addons',
589
- options: [opts.orgaIdOrName],
611
+ options: [opts.orgaIdOrName, opts.humanJsonOutputFormat],
590
612
  commands: [addonCreateCommand, addonDeleteCommand, addonRenameCommand, addonProvidersCommand, addonEnvCommand],
591
613
  }, addon('list'));
592
614
 
@@ -629,9 +651,15 @@ function run () {
629
651
  const appCreateCommand = cliparse.command('create', {
630
652
  description: 'Create an application',
631
653
  args: [args.appNameCreation],
632
- options: [opts.instanceType, opts.orgaIdOrName, opts.aliasCreation, opts.region, opts.github, opts.humanJsonOutputFormat],
654
+ options: [opts.instanceType, opts.orgaIdOrName, opts.aliasCreation, opts.region, opts.github, opts.humanJsonOutputFormat, opts.taskCommand],
633
655
  }, create('create'));
634
656
 
657
+ // CURL COMMAND
658
+ // NOTE: it's just here for documentation purposes, look at the bottom of the file for the real "clever curl" command
659
+ const curlCommand = cliparse.command('curl', {
660
+ description: 'Query Clever Cloud\'s API using Clever Tools credentials',
661
+ }, () => null);
662
+
635
663
  // DELETE COMMAND
636
664
  const deleteCommandModule = lazyRequirePromiseModule('../src/commands/delete.js');
637
665
  const deleteCommand = cliparse.command('delete', {
@@ -643,7 +671,7 @@ function run () {
643
671
  const deploy = lazyRequirePromiseModule('../src/commands/deploy.js');
644
672
  const deployCommand = cliparse.command('deploy', {
645
673
  description: 'Deploy an application',
646
- options: [opts.alias, opts.branch, opts.quiet, opts.forceDeploy, opts.followDeployLogs, opts.sameCommitPolicy],
674
+ options: [opts.alias, opts.branch, opts.gitTag, opts.quiet, opts.forceDeploy, opts.followDeployLogs, opts.sameCommitPolicy],
647
675
  }, deploy('deploy'));
648
676
 
649
677
  // DIAG COMMAND
@@ -685,7 +713,7 @@ function run () {
685
713
  const drainCreateCommand = cliparse.command('create', {
686
714
  description: 'Create a drain',
687
715
  args: [args.drainType, args.drainUrl],
688
- options: [opts.addonId, opts.drainUsername, opts.drainPassword, opts.drainAPIKey],
716
+ options: [opts.addonId, opts.drainUsername, opts.drainPassword, opts.drainAPIKey, opts.drainIndexPrefix],
689
717
  }, drain('create'));
690
718
  const drainRemoveCommand = cliparse.command('remove', {
691
719
  description: 'Remove a drain',
@@ -754,7 +782,7 @@ function run () {
754
782
  const logs = lazyRequirePromiseModule('../src/commands/logs.js');
755
783
  const logsCommand = cliparse.command('logs', {
756
784
  description: 'Fetch application logs, continuously',
757
- options: [opts.alias, opts.before, opts.after, opts.search, opts.deploymentId, opts.addonId],
785
+ options: [opts.alias, opts.before, opts.after, opts.search, opts.deploymentId, opts.addonId, opts.logsFormat],
758
786
  }, logs('appLogs'));
759
787
 
760
788
  // MAKE DEFAULT COMMAND
@@ -1021,7 +1049,7 @@ function run () {
1021
1049
  const backupsCommand = cliparse.command('backups', {
1022
1050
  description: 'List available database backups',
1023
1051
  args: [args.databaseId],
1024
- options: [opts.orgaIdOrName],
1052
+ options: [opts.orgaIdOrName, opts.humanJsonOutputFormat],
1025
1053
  commands: [
1026
1054
  downloadBackupCommand,
1027
1055
  ],
@@ -1035,6 +1063,9 @@ function run () {
1035
1063
  console.info('clever database backups download');
1036
1064
  });
1037
1065
 
1066
+ // Patch help command description
1067
+ cliparseCommands.helpCommand.description = 'Display help about the Clever Cloud CLI';
1068
+
1038
1069
  const commands = _sortBy([
1039
1070
  accesslogsCommand,
1040
1071
  activityCommand,
@@ -1045,6 +1076,7 @@ function run () {
1045
1076
  appUnlinkCommand,
1046
1077
  cancelDeployCommand,
1047
1078
  configCommands,
1079
+ curlCommand,
1048
1080
  databaseCommand,
1049
1081
  deleteCommand,
1050
1082
  deployCommand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clever-tools",
3
- "version": "3.2.0",
3
+ "version": "3.5.0",
4
4
  "description": "Command Line Interface for Clever Cloud.",
5
5
  "main": "bin/clever.js",
6
6
  "keywords": [
@@ -25,14 +25,16 @@
25
25
  "scripts/*.sh"
26
26
  ],
27
27
  "dependencies": {
28
- "@clevercloud/client": "^8.1.0",
28
+ "@clevercloud/client": "^8.1.2",
29
29
  "clf-date": "^0.2.0",
30
30
  "cliparse": "^0.3.3",
31
31
  "colors": "1.4.0",
32
32
  "common-env": "^6.4.0",
33
- "curlconverter": "^3.21.0",
33
+ "curlconverter": "^4.9.0",
34
+ "duration-js": "^4.0.0",
34
35
  "eventsource": "^1.1.0",
35
- "isomorphic-git": "^1.25.0",
36
+ "iso8601-duration": "^2.1.2",
37
+ "isomorphic-git": "^1.25.3",
36
38
  "linux-release-info": "^3.0.0",
37
39
  "lodash": "^4.17.21",
38
40
  "mkdirp": "^1.0.4",
@@ -15,63 +15,141 @@ const { sendToApi } = require('../models/send-to-api.js');
15
15
  const { toNameEqualsValueString } = require('@clevercloud/client/cjs/utils/env-vars.js');
16
16
  const { resolveAddonId } = require('../models/ids-resolver.js');
17
17
 
18
+ function getPlan (inputPlan, providerName) {
19
+
20
+ // Return user plan
21
+ if (inputPlan !== '') {
22
+ return inputPlan;
23
+ }
24
+
25
+ // Choose a default plan based on the add-on provider
26
+ switch (providerName) {
27
+ case 'kv':
28
+ return 'alpha';
29
+ default:
30
+ return 'dev';
31
+ }
32
+ }
33
+
18
34
  async function list (params) {
19
- const { org: orgaIdOrName } = params.options;
35
+ const { org: orgaIdOrName, format } = params.options;
20
36
 
21
37
  const ownerId = await Organisation.getId(orgaIdOrName);
22
38
  const addons = await Addon.list(ownerId);
23
39
 
24
- const formattedAddons = addons.map((addon) => {
25
- return [
26
- addon.plan.name + ' ' + addon.provider.name,
27
- addon.region,
28
- colors.bold.green(addon.name),
29
- addon.id,
30
- ];
31
- });
32
- Logger.println(formatTable(formattedAddons));
40
+ switch (format) {
41
+ case 'json': {
42
+ const formattedAddons = addons.map((addon) => {
43
+ return {
44
+ addonId: addon.id,
45
+ creationDate: addon.creationDate,
46
+ name: addon.name,
47
+ planName: addon.plan.name,
48
+ planSlug: addon.plan.slug,
49
+ providerId: addon.provider.id,
50
+ realId: addon.realId,
51
+ region: addon.region,
52
+ type: addon.provider.name,
53
+ };
54
+ });
55
+ Logger.printJson(formattedAddons);
56
+ break;
57
+ }
58
+ case 'human':
59
+ default: {
60
+ const formattedAddons = addons.map((addon) => {
61
+ return [
62
+ addon.plan.name + ' ' + addon.provider.name,
63
+ addon.region,
64
+ colors.bold.green(addon.name),
65
+ addon.id,
66
+ ];
67
+ });
68
+ Logger.println(formatTable(formattedAddons));
69
+ }
70
+ }
33
71
  }
34
72
 
35
73
  async function create (params) {
36
74
  const [providerName, name] = params.args;
37
- const { link: linkedAppAlias, plan: planName, region, yes: skipConfirmation, org: orgaIdOrName } = params.options;
75
+ const {
76
+ link: linkedAppAlias,
77
+ plan: inputPlan,
78
+ region,
79
+ yes: skipConfirmation,
80
+ org: orgaIdOrName,
81
+ format,
82
+ } = params.options;
38
83
  const version = params.options['addon-version'];
39
84
  const addonOptions = parseAddonOptions(params.options.option);
85
+ const planName = getPlan(inputPlan, providerName);
40
86
 
41
87
  const ownerId = (orgaIdOrName != null)
42
88
  ? await Organisation.getId(orgaIdOrName)
43
89
  : await User.getCurrentId();
44
90
 
91
+ const addonToCreate = {
92
+ ownerId,
93
+ name,
94
+ providerName,
95
+ planName,
96
+ region,
97
+ skipConfirmation,
98
+ version,
99
+ addonOptions,
100
+ };
101
+
45
102
  if (linkedAppAlias != null) {
46
103
  const linkedAppData = await AppConfig.getAppDetails({ alias: linkedAppAlias });
47
- if (orgaIdOrName != null && linkedAppData.ownerId !== ownerId) {
104
+ if (orgaIdOrName != null && linkedAppData.ownerId !== ownerId && format === 'human') {
48
105
  Logger.warn('The specified application does not belong to the specified organisation. Ignoring the `--org` option');
49
106
  }
50
107
  const newAddon = await Addon.create({
108
+ ...addonToCreate,
51
109
  ownerId: linkedAppData.ownerId,
52
- name,
53
- providerName,
54
- planName,
55
- region,
56
- skipConfirmation,
57
- version,
58
- addonOptions,
59
110
  });
60
111
  await Addon.link(linkedAppData.ownerId, linkedAppData.appId, { addon_id: newAddon.id });
61
- Logger.println(`Addon ${name} (id: ${newAddon.id}) successfully created and linked to the application`);
112
+ displayAddon(format, newAddon, providerName, `Add-on created and linked to application ${linkedAppAlias} successfully!`);
62
113
  }
63
114
  else {
64
- const newAddon = await Addon.create({
65
- ownerId,
66
- name,
67
- providerName,
68
- planName,
69
- region,
70
- skipConfirmation,
71
- version,
72
- addonOptions,
73
- });
74
- Logger.println(`Addon ${name} (id: ${newAddon.id}) successfully created`);
115
+ const newAddon = await Addon.create(addonToCreate);
116
+ displayAddon(format, newAddon, providerName, 'Add-on created successfully!');
117
+ }
118
+ }
119
+
120
+ function displayAddon (format, addon, providerName, message) {
121
+ switch (format) {
122
+
123
+ case 'json': {
124
+ const jsonAddon = {
125
+ id: addon.id,
126
+ realId: addon.realId,
127
+ name: addon.name,
128
+ };
129
+ Logger.printJson((providerName === 'kv')
130
+ ? { ...jsonAddon, availability: 'alpha', warning: 'Don\'t store sensitive or production grade data' }
131
+ : jsonAddon);
132
+ break;
133
+ }
134
+
135
+ case 'human':
136
+ default:
137
+ Logger.println([
138
+ message,
139
+ `ID: ${addon.id}`,
140
+ `Real ID: ${addon.realId}`,
141
+ `Name: ${addon.name}`,
142
+ ].join('\n'));
143
+ if (providerName === 'kv') {
144
+ const materiaMessage = [
145
+ '',
146
+ colors.yellow(`/!\\ The MateriaDB ${providerName.toUpperCase()} provider is in Alpha testing phase, don't store sensitive or production grade data`),
147
+ 'You can easily use MateriaDB KV with \'redis-cli\', with such commands:',
148
+ colors.blue(`source <(clever addon env ${addon.id} -F shell)`),
149
+ colors.blue('redis-cli -h $KV_HOST -p $KV_PORT'),
150
+ ].join('\n');
151
+ Logger.println(materiaMessage);
152
+ }
75
153
  }
76
154
  }
77
155
 
@@ -1,31 +1,53 @@
1
1
  'use strict';
2
2
 
3
+ const path = require('path');
3
4
  const Application = require('../models/application.js');
4
5
  const AppConfig = require('../models/app_configuration.js');
5
6
  const Logger = require('../logger.js');
6
7
 
7
8
  async function create (params) {
8
9
  const { type: typeName } = params.options;
9
- const [name] = params.args;
10
- const { org: orgaIdOrName, alias, region, github: githubOwnerRepo, format } = params.options;
10
+ const [rawName] = params.args;
11
+ const { org: orgaIdOrName, alias, region, github: githubOwnerRepo, format, task: taskCommand } = params.options;
11
12
  const { apps } = await AppConfig.loadApplicationConf();
12
13
 
14
+ // Application name is optionnal, use current directory name if not specified (empty string)
15
+ const name = (rawName !== '') ? rawName : getCurrentDirectoryName();
16
+
17
+ const isTask = (taskCommand != null);
18
+ const envVars = isTask
19
+ ? { CC_RUN_COMMAND: taskCommand }
20
+ : {};
21
+
13
22
  AppConfig.checkAlreadyLinked(apps, name, alias);
14
23
 
15
24
  const github = getGithubDetails(githubOwnerRepo);
16
- const app = await Application.create(name, typeName, region, orgaIdOrName, github);
25
+ const app = await Application.create(name, typeName, region, orgaIdOrName, github, isTask, envVars);
17
26
  await AppConfig.addLinkedApplication(app, alias);
18
27
 
19
28
  switch (format) {
20
-
21
29
  case 'json': {
22
- console.log(JSON.stringify(app, null, 2));
30
+ Logger.printJson({
31
+ id: app.id,
32
+ name: app.name,
33
+ executedAs: app.instance.lifetime,
34
+ env: app.env,
35
+ deployUrl: app.deployUrl,
36
+ });
23
37
  break;
24
38
  }
25
39
 
26
40
  case 'human':
27
41
  default:
28
- Logger.println('Your application has been successfully created!');
42
+ if (isTask) {
43
+ Logger.println('Your application has been successfully created as a task!');
44
+ Logger.println(`The "CC_RUN_COMMAND" environment variable has been set to "${taskCommand}"`);
45
+ }
46
+ else {
47
+ Logger.println('Your application has been successfully created!');
48
+ }
49
+ Logger.println(`ID: ${app.id}`);
50
+ Logger.println(`Name: ${name}`);
29
51
  }
30
52
  };
31
53
 
@@ -36,4 +58,8 @@ function getGithubDetails (githubOwnerRepo) {
36
58
  }
37
59
  }
38
60
 
61
+ function getCurrentDirectoryName () {
62
+ return path.basename(process.cwd());
63
+ }
64
+
39
65
  module.exports = { create };
@@ -1,9 +1,10 @@
1
1
  'use strict';
2
2
 
3
- const { parseCurlCommand } = require('curlconverter/util.js');
4
3
  const { spawn } = require('child_process');
5
4
  const { loadOAuthConf, conf } = require('../models/configuration.js');
6
5
  const { addOauthHeader } = require('@clevercloud/client/cjs/oauth.js');
6
+ const Logger = require('../logger.js');
7
+ const colors = require('colors/safe');
7
8
 
8
9
  async function loadTokens () {
9
10
  const tokens = await loadOAuthConf();
@@ -15,37 +16,86 @@ async function loadTokens () {
15
16
  };
16
17
  }
17
18
 
19
+ function printCleverCurlHelp () {
20
+ const apiDocUrlv2 = 'https://developers.clever-cloud.com/api/v2/';
21
+ const apiDocUrlv4 = 'https://developers.clever-cloud.com/api/v4/';
22
+
23
+ Logger.println(`Usage: clever curl
24
+ Query Clever Cloud's API using Clever Tools credentials. For example:
25
+
26
+ clever curl ${conf.API_HOST}/v2/self
27
+ clever curl ${conf.API_HOST}/v2/summary
28
+ clever curl ${conf.API_HOST}/v4/products/zones
29
+ clever curl ${conf.API_HOST}/v2/organisations/<ORGANISATION_ID>/applications | jq '.[].id'
30
+ clever curl ${conf.API_HOST}/v4/billing/organisations/<ORGANISATION_ID>/<INVOICE_NUMBER>.pdf > invoice.pdf
31
+
32
+ Our API documentation is available here :
33
+
34
+ ${apiDocUrlv2}
35
+ ${apiDocUrlv4}`);
36
+ }
37
+
18
38
  async function curl () {
19
39
 
20
- // We have to add single quotes on values for the parser
21
- const curlString = process.argv
22
- .slice(2)
23
- .map((str) => !str.startsWith('-') ? `'${str}'` : str)
24
- .join(' ');
40
+ // We remove the first three args: "node", "clever" and "curl"
41
+ const curlArgs = process.argv.slice(3);
42
+ const hasNoArgs = curlArgs.length === 0;
43
+ const startsWithHelpArg = curlArgs[0] === '--help' || curlArgs[0] === '-h';
44
+ const shouldDisplayCleverCurlHelp = hasNoArgs || startsWithHelpArg;
25
45
 
26
- const curlDetails = parseCurlCommand(curlString);
46
+ if (shouldDisplayCleverCurlHelp) {
47
+ printCleverCurlHelp();
48
+ return;
49
+ }
27
50
 
28
- const tokens = await loadTokens();
51
+ const requestParams = await parseCurlCommand(['curl', ...curlArgs]);
29
52
 
30
- const requestParams = {
31
- method: curlDetails.method,
32
- url: curlDetails.urlWithoutQuery,
33
- headers: curlDetails.headers,
34
- queryParams: curlDetails.query,
35
- };
53
+ // We only allow request to the respective API_HOST
54
+ if (!requestParams.url.startsWith(conf.API_HOST)) {
55
+ Logger.error('"clever curl" command must be used with ' + colors.blue(conf.API_HOST));
56
+ process.exit(1);
57
+ }
58
+
59
+ const lastCurlArg = curlArgs.at(-1);
60
+ const lastCurlArgIsHelp = lastCurlArg !== '--help' && lastCurlArg !== '-h';
61
+
62
+ // Add oAuth header, only if last cURL arg is not help
63
+ // We do this because cURL's help arg expect a category
64
+ if (lastCurlArgIsHelp) {
36
65
 
37
- const oauthHeader = await Promise.resolve(requestParams)
38
- .then(addOauthHeader(tokens))
39
- .then((request) => request.headers.Authorization);
66
+ const tokens = await loadTokens();
67
+ const oauthHeader = await Promise.resolve(requestParams)
68
+ .then(addOauthHeader(tokens))
69
+ .then((request) => request.headers.Authorization);
40
70
 
41
- // Reuse raw curl command
42
- const curlParams = process.argv.slice(3);
71
+ curlArgs.push('-H', `Authorization: ${oauthHeader}`);
72
+ }
43
73
 
44
- // Add oauth
45
- curlParams.push('-H', `Authorization: ${oauthHeader}`);
74
+ spawn('curl', curlArgs, { stdio: 'inherit' });
46
75
 
47
- spawn('curl', curlParams, { stdio: 'inherit' });
76
+ }
77
+
78
+ async function parseCurlCommand (curlCommand) {
79
+
80
+ const curlParser = await import('curlconverter/dist/src/parse.js');
81
+
82
+ const [request] = curlParser.parse(curlCommand);
83
+ const url = request.urls[0];
84
+
85
+ return {
86
+ method: url.method.toString(),
87
+ url: url.urlWithoutQueryArray.toString(),
88
+ headers: transformCurlConverterWordsToObject(request.headers.headers),
89
+ queryParams: transformCurlConverterWordsToObject(url.queryDict),
90
+ };
91
+ }
48
92
 
93
+ function transformCurlConverterWordsToObject (words = []) {
94
+ return Object.fromEntries(
95
+ words.map(([key, value]) => {
96
+ return [key.toString(), value.toString()];
97
+ }),
98
+ );
49
99
  }
50
100
 
51
101
  module.exports = { curl };
@@ -7,41 +7,63 @@ const formatTable = require('../format-table')();
7
7
  const superagent = require('superagent');
8
8
  const fs = require('fs');
9
9
  const { findOwnerId } = require('../models/addon.js');
10
- const { resolveRealId } = require('../models/ids-resolver.js');
10
+ const { resolveRealId, resolveAddonId } = require('../models/ids-resolver.js');
11
+ const Logger = require('../logger.js');
11
12
 
12
13
  async function listBackups (params) {
13
14
 
14
- const { org } = params.options;
15
+ const { org, format } = params.options;
15
16
  const [addonIdOrRealId] = params.args;
16
17
 
17
- const addonId = await resolveRealId(addonIdOrRealId);
18
- const ownerId = await findOwnerId(org, addonId);
18
+ const realId = await resolveRealId(addonIdOrRealId);
19
+ const addonId = await resolveAddonId(addonIdOrRealId);
20
+ const ownerId = await findOwnerId(org, realId);
19
21
 
20
- const backups = await getBackups({ ownerId, ref: addonId }).then(sendToApi);
22
+ const backups = await getBackups({ ownerId, ref: realId }).then(sendToApi);
21
23
 
22
- if (backups.length === 0) {
24
+ if (backups.length === 0 && format === 'human') {
23
25
  println('There are no backups yet');
24
26
  return;
25
27
  }
26
28
 
27
- const formattedLines = backups
28
- .sort((a, b) => a.creation_date.localeCompare(b.creation_date))
29
- .map((backup) => [
30
- backup.backup_id,
31
- backup.creation_date,
32
- backup.status,
33
- ]);
34
-
35
- const head = [
36
- 'BACKUP ID',
37
- 'CREATION DATE',
38
- 'STATUS',
39
- ];
40
-
41
- println(formatTable([
42
- head,
43
- ...formattedLines,
44
- ]));
29
+ const sortedBackups = backups.sort((a, b) => a.creation_date.localeCompare(b.creation_date));
30
+
31
+ switch (format) {
32
+ case 'json': {
33
+ const formattedBackups = sortedBackups.map((backup) => {
34
+ return {
35
+ addonId: addonId,
36
+ backupId: backup.backup_id,
37
+ creationDate: backup.creation_date,
38
+ downloadUrl: backup.download_url,
39
+ ownerId: ownerId,
40
+ realId: realId,
41
+ status: backup.status,
42
+ };
43
+ });
44
+ Logger.printJson(formattedBackups);
45
+ break;
46
+ }
47
+ case 'human': {
48
+ const formattedLines = sortedBackups
49
+ .map((backup) => [
50
+ backup.backup_id,
51
+ backup.creation_date,
52
+ backup.status,
53
+ ]);
54
+
55
+ const head = [
56
+ 'BACKUP ID',
57
+ 'CREATION DATE',
58
+ 'STATUS',
59
+ ];
60
+
61
+ println(formatTable([
62
+ head,
63
+ ...formattedLines,
64
+ ]));
65
+ }
66
+ }
45
67
  }
46
68
 
47
69
  async function downloadBackups (params) {
@@ -13,12 +13,12 @@ const { sendToApi } = require('../models/send-to-api.js');
13
13
  // Once the API call to redeploy() has been triggered successfully,
14
14
  // the rest (waiting for deployment state to evolve and displaying logs) is done with auto retry (resilient to network failures)
15
15
  async function deploy (params) {
16
- const { alias, branch: branchName, quiet, force, follow, 'same-commit-policy': sameCommitPolicy } = params.options;
16
+ const { alias, branch: branchName, tag: tagName, quiet, force, follow, 'same-commit-policy': sameCommitPolicy } = params.options;
17
17
 
18
18
  const appData = await AppConfig.getAppDetails({ alias });
19
19
  const { ownerId, appId } = appData;
20
- const branchRefspec = await git.getFullBranch(branchName);
21
20
 
21
+ const branchRefspec = await getBranchToDeploy(branchName, tagName);
22
22
  const commitIdToPush = await git.getBranchCommit(branchRefspec);
23
23
  const remoteHeadCommitId = await git.getRemoteCommit(appData.deployUrl);
24
24
  const deployedCommitId = await Application.get(ownerId, appId)
@@ -66,7 +66,8 @@ async function deploy (params) {
66
66
  const knownDeployments = await getAllDeployments({ id: ownerId, appId, limit: 5 }).then(sendToApi);
67
67
 
68
68
  Logger.println('Pushing source code to Clever Cloud…');
69
- await git.push(appData.deployUrl, branchRefspec, force)
69
+
70
+ await git.push(appData.deployUrl, commitIdToPush, force)
70
71
  .catch(async (e) => {
71
72
  const isShallow = await git.isShallow();
72
73
  if (isShallow) {
@@ -86,4 +87,20 @@ async function restartOnSameCommit (ownerId, appId, commitIdToPush, quiet, follo
86
87
  return Log.watchDeploymentAndDisplayLogs({ ownerId, appId, deploymentId: restart.deploymentId, quiet, follow });
87
88
  }
88
89
 
90
+ async function getBranchToDeploy (branchName, tagName) {
91
+ if (tagName) {
92
+ const useTag = await git.isExistingTag(tagName);
93
+ if (useTag) {
94
+ const tagRefspec = await git.getFullBranch(tagName);
95
+ return tagRefspec;
96
+ }
97
+ else {
98
+ throw new Error(`Tag ${tagName} doesn't exist locally`);
99
+ }
100
+ }
101
+ else {
102
+ return await git.getFullBranch(branchName);
103
+ }
104
+ }
105
+
89
106
  module.exports = { deploy };
@@ -25,16 +25,22 @@ async function list (params) {
25
25
  }
26
26
 
27
27
  drains.forEach((drain) => {
28
- const { id, state, target: { url, drainType } } = drain;
29
- Logger.println(`${id} -> ${state} for ${url} as ${drainType}`);
28
+ const { id, state, target } = drain;
29
+ const { url, drainType, indexPrefix } = target;
30
+
31
+ let drainView = `${id} -> ${state} for ${url} as ${drainType}`;
32
+ if (indexPrefix != null) {
33
+ drainView += `, index: '${indexPrefix}-<YYYY-MM-DD>'`;
34
+ }
35
+ Logger.println(drainView);
30
36
  });
31
37
  }
32
38
 
33
39
  async function create (params) {
34
40
  const [drainTargetType, drainTargetURL] = params.args;
35
- const { alias, addon: addonId, username, password, 'api-key': apiKey } = params.options;
41
+ const { alias, addon: addonId, username, password, 'api-key': apiKey, 'index-prefix': indexPrefix } = params.options;
36
42
  const drainTargetCredentials = { username, password };
37
- const drainTargetConfig = { apiKey };
43
+ const drainTargetConfig = { apiKey, indexPrefix };
38
44
 
39
45
  const appIdOrAddonId = await getAppOrAddonId({ alias, addonId });
40
46
  const body = createDrainBody(appIdOrAddonId, drainTargetURL, drainTargetType, drainTargetCredentials, drainTargetConfig);
@@ -5,26 +5,36 @@ const LogV2 = require('../models/log.js');
5
5
  const Log = require('../models/log-v4.js');
6
6
  const Logger = require('../logger.js');
7
7
  const { Deferred } = require('../models/utils.js');
8
+ const colors = require('colors/safe');
9
+ const { resolveAddonId } = require('../models/ids-resolver.js');
8
10
 
9
11
  async function appLogs (params) {
10
- const { alias, after: since, before: until, search, 'deployment-id': deploymentId } = params.options;
12
+ const { alias, addon: addonIdOrRealId, after: since, before: until, search, 'deployment-id': deploymentId, format } = params.options;
11
13
 
12
14
  // ignore --search ""
13
15
  const filter = (search !== '') ? search : null;
16
+ const isForHuman = (format === 'human');
14
17
 
15
- const { appId, ownerId } = await AppConfig.getAppDetails({ alias });
18
+ // TODO: drop when addons are migrated to the v4 API
19
+ if (addonIdOrRealId != null) {
20
+ const addonId = await resolveAddonId(addonIdOrRealId);
21
+ if (isForHuman) {
22
+ Logger.println(colors.blue('Waiting for addon logs…'));
23
+ }
24
+ else {
25
+ throw new Error(`"${format}" format is not yet available for add-on logs`);
26
+ }
27
+ return LogV2.displayLogs({ appAddonId: addonId, since, until, filter, deploymentId });
28
+ }
16
29
 
17
- Logger.println('Waiting for application logs…');
30
+ const { appId, ownerId } = await AppConfig.getAppDetails({ alias });
18
31
 
19
- // TODO: drop when addons are migrated to the v4 API
20
- if (params.addon) {
21
- const { addon: addonId } = params.options;
22
- const appAddonId = addonId || await AppConfig.getAppDetails({ alias }).then(({ appId }) => appId);
23
- return LogV2.displayLogs({ appAddonId, since, until, filter, deploymentId });
32
+ if (isForHuman) {
33
+ Logger.println(colors.blue('Waiting for application logs…'));
24
34
  }
25
35
 
26
36
  const deferred = new Deferred();
27
- await Log.displayLogs({ ownerId, appId, since, until, filter, deploymentId, deferred });
37
+ await Log.displayLogs({ ownerId, appId, since, until, filter, deploymentId, format, deferred });
28
38
  return deferred.promise;
29
39
  }
30
40
 
@@ -35,11 +35,12 @@ function computeStatus (instances, app) {
35
35
  : colors.bold.red('stopped');
36
36
 
37
37
  const statusLine = `${app.name}: ${statusMessage}`;
38
+ const taskLine = `Executed as: ${colors.bold(app.instance.lifetime)}`;
38
39
  const deploymentLine = isDeploying
39
40
  ? `Deployment in progress ${displayGroupInfo(deployingInstances, deployingCommit)}`
40
41
  : '';
41
42
 
42
- return [statusLine, deploymentLine].join('\n');
43
+ return [statusLine, taskLine, deploymentLine].join('\n');
43
44
  }
44
45
 
45
46
  function displayScalability (app) {
package/src/logger.js CHANGED
@@ -52,6 +52,11 @@ const Logger = _(['debug', 'info', 'warn', 'error'])
52
52
  // No decoration for Logger.println
53
53
  Logger.println = console.log;
54
54
 
55
+ // No decoration for Logger.println
56
+ Logger.printJson = (obj) => {
57
+ console.log(JSON.stringify(obj, null, 2));
58
+ };
59
+
55
60
  // No decoration for Logger.printErrorLine
56
61
  Logger.printErrorLine = console.error;
57
62
 
@@ -17,7 +17,7 @@ function listAvailableTypes () {
17
17
  return autocomplete.words(['docker', 'elixir', 'go', 'gradle', 'haskell', 'jar', 'maven', 'meteor', 'node', 'php', 'play1', 'play2', 'python', 'ruby', 'rust', 'sbt', 'static-apache', 'war']);
18
18
  };
19
19
 
20
- const AVAILABLE_ZONES = ['par', 'rbx', 'rbxhds', 'scw', 'jed', 'mtl', 'sgp', 'syd', 'wsw'];
20
+ const AVAILABLE_ZONES = ['par', 'grahds', 'rbx', 'rbxhds', 'scw', 'mtl', 'sgp', 'syd', 'wsw'];
21
21
 
22
22
  function listAvailableZones () {
23
23
  return autocomplete.words(AVAILABLE_ZONES);
@@ -53,7 +53,7 @@ async function getInstanceType (type) {
53
53
  return instanceVariant;
54
54
  };
55
55
 
56
- async function create (name, typeName, region, orgaIdOrName, github) {
56
+ async function create (name, typeName, region, orgaIdOrName, github, isTask, envVars) {
57
57
  Logger.debug('Create the application…');
58
58
 
59
59
  const ownerId = (orgaIdOrName != null)
@@ -74,6 +74,8 @@ async function create (name, typeName, region, orgaIdOrName, github) {
74
74
  minInstances: 1,
75
75
  name: name,
76
76
  zone: region,
77
+ instanceLifetime: isTask ? 'TASK' : 'REGULAR',
78
+ env: envVars,
77
79
  };
78
80
 
79
81
  if (github != null) {
@@ -6,7 +6,7 @@ const DRAIN_TYPES = [
6
6
  { id: 'TCPSyslog' },
7
7
  { id: 'UDPSyslog' },
8
8
  { id: 'HTTP', credentials: 'OPTIONAL' },
9
- { id: 'ElasticSearch', credentials: 'MANDATORY' },
9
+ { id: 'ElasticSearch', credentials: 'MANDATORY', indexPrefix: 'OPTIONAL' },
10
10
  { id: 'DatadogHTTP' },
11
11
  { id: 'NewRelicHTTP', apiKey: 'MANDATORY' },
12
12
  ];
@@ -30,14 +30,18 @@ function createDrainBody (appId, drainTargetURL, drainTargetType, drainTargetCre
30
30
  if (keyExist(drainTargetConfig)) {
31
31
  body.APIKey = drainTargetConfig.apiKey;
32
32
  }
33
+ if (indexPrefixExist(drainTargetConfig)) {
34
+ body.indexPrefix = drainTargetConfig.indexPrefix;
35
+ }
33
36
  return body;
34
37
  }
35
38
 
36
39
  function authorizeDrainCreation (drainTargetType, drainTargetCredentials, drainTargetConfig) {
37
40
  if (drainTypeExists(drainTargetType)) {
38
- // retrieve creds for drain type ('mandatory', 'optional', undefined)
39
- const credStatus = credentialsStatus(drainTargetType).credentials;
40
- const keyStatus = credentialsStatus(drainTargetType).apiKey;
41
+ // retrieve field for drain type ('mandatory', 'optional', undefined)
42
+ const credStatus = fieldStatus(drainTargetType).credentials;
43
+ const keyStatus = fieldStatus(drainTargetType).apiKey;
44
+ const indexPrefixStatus = fieldStatus(drainTargetType).indexPrefix;
41
45
 
42
46
  if (credStatus === 'MANDATORY') {
43
47
  return credentialsExist(drainTargetCredentials);
@@ -58,10 +62,20 @@ function authorizeDrainCreation (drainTargetType, drainTargetCredentials, drainT
58
62
  if (!keyStatus) {
59
63
  return keyEmpty(drainTargetConfig);
60
64
  }
65
+
66
+ if (indexPrefixStatus === 'MANDATORY') {
67
+ return indexPrefixExist(drainTargetConfig);
68
+ }
69
+ if (indexPrefixStatus === 'OPTIONAL') {
70
+ return true;
71
+ }
72
+ if (!indexPrefixStatus) {
73
+ return indexPrefixEmpty(drainTargetConfig);
74
+ }
61
75
  }
62
76
  }
63
77
 
64
- function credentialsStatus (drainTargetType) {
78
+ function fieldStatus (drainTargetType) {
65
79
  return DRAIN_TYPES.find(({ id }) => id === drainTargetType);
66
80
  }
67
81
 
@@ -77,6 +91,14 @@ function credentialsEmpty ({ username, password }) {
77
91
  return username == null && password == null;
78
92
  }
79
93
 
94
+ function indexPrefixExist ({ indexPrefix }) {
95
+ return indexPrefix != null;
96
+ }
97
+
98
+ function indexPrefixEmpty ({ indexPrefix }) {
99
+ return indexPrefix == null;
100
+ }
101
+
80
102
  function keyExist ({ apiKey }) {
81
103
  return apiKey != null;
82
104
  }
package/src/models/git.js CHANGED
@@ -78,7 +78,19 @@ async function getFullBranch (branchName) {
78
78
 
79
79
  async function getBranchCommit (refspec) {
80
80
  const repo = await getRepo();
81
- return git.resolveRef({ ...repo, ref: refspec });
81
+ const oid = await git.resolveRef({ ...repo, ref: refspec });
82
+ // When a refspec refers to an annotated tag, the OID ref represents the annotation and not the commit directly,
83
+ // that's why we need a call to `readCommit`.
84
+ const res = await git.readCommit({ ...repo, ref: refspec, oid });
85
+ return res.oid;
86
+ }
87
+
88
+ async function isExistingTag (tag) {
89
+ const repo = await getRepo();
90
+ const tags = await git.listTags({
91
+ ...repo,
92
+ });
93
+ return tags.includes(tag);
82
94
  }
83
95
 
84
96
  async function push (remoteUrl, branchRefspec, force) {
@@ -131,4 +143,5 @@ module.exports = {
131
143
  push,
132
144
  completeBranches,
133
145
  isShallow,
146
+ isExistingTag,
134
147
  };
@@ -18,7 +18,11 @@ async function displayLogs (params) {
18
18
 
19
19
  const deferred = params.deferred || new Deferred();
20
20
  const { apiHost, tokens } = await getHostAndTokens();
21
- const { ownerId, appId, filter, since, until, deploymentId } = params;
21
+ const { ownerId, appId, filter, since, until, deploymentId, format } = params;
22
+
23
+ if (format === 'json' && until == null) {
24
+ throw new Error('"json" format is only applicable with a limiting parameter such as `--until`');
25
+ }
22
26
 
23
27
  const logStream = new ApplicationLogStream({
24
28
  apiHost,
@@ -37,21 +41,40 @@ async function displayLogs (params) {
37
41
 
38
42
  // Properly close the stream
39
43
  process.once('SIGINT', (signal) => logStream.close(signal));
44
+ const jsonArray = new JsonArray();
40
45
 
41
46
  logStream
42
47
  .on('open', (event) => {
43
48
  Logger.debug(colors.blue(`Logs stream (open) ${JSON.stringify({ appId, filter, deploymentId })}`));
49
+ if (format === 'json') {
50
+ jsonArray.open();
51
+ }
44
52
  })
45
53
  .on('error', (event) => {
46
54
  Logger.debug(colors.red(`Logs stream (error) ${event.error.message}`));
47
55
  })
48
56
  .onLog((log) => {
49
- Logger.println(formatLogLine(log));
57
+ switch (format) {
58
+ case 'json':
59
+ jsonArray.push(log);
60
+ return;
61
+ case 'json-stream':
62
+ Logger.printJson(log);
63
+ return;
64
+ case 'human':
65
+ default:
66
+ Logger.println(formatLogLine(log));
67
+ }
50
68
  });
51
69
 
52
70
  // start() is blocking until end of stream
53
71
  logStream.start()
54
- .then((reason) => deferred.resolve())
72
+ .then((reason) => {
73
+ if (format === 'json') {
74
+ jsonArray.close();
75
+ }
76
+ return deferred.resolve();
77
+ })
55
78
  .catch(processError)
56
79
  .catch((error) => deferred.reject(error));
57
80
 
@@ -71,7 +94,8 @@ async function watchDeploymentAndDisplayLogs (options) {
71
94
  redeployDate,
72
95
  } = options;
73
96
 
74
- Logger.println('Waiting for deployment to start…');
97
+ // If in quiet mode, we only log start/finished deployment messages
98
+ !quiet && Logger.println('Waiting for deployment to start…');
75
99
  const deployment = await waitForDeploymentStart({ ownerId, appId, deploymentId, commitId, knownDeployments });
76
100
  Logger.println(colors.bold.blue(`Deployment started (${deployment.uuid})`));
77
101
 
@@ -88,7 +112,7 @@ async function watchDeploymentAndDisplayLogs (options) {
88
112
  logsStream = await displayLogs({ ownerId, appId, deploymentId: deployment.uuid, since: redeployDate, deferred });
89
113
  }
90
114
 
91
- Logger.println('Waiting for application logs…');
115
+ !quiet && Logger.println('Waiting for application logs…');
92
116
 
93
117
  // Wait for deployment end (or an error thrown by logs with the deferred)
94
118
  const deploymentEnded = await Promise.race([
@@ -142,3 +166,30 @@ function isBuildSucessMessage (log) {
142
166
  };
143
167
 
144
168
  module.exports = { displayLogs, watchDeploymentAndDisplayLogs };
169
+
170
+ /**
171
+ * Helper to print a real JSON array with starting `[` and ending `]`
172
+ */
173
+ class JsonArray {
174
+ constructor () {
175
+ this._isFirst = true;
176
+ }
177
+
178
+ open () {
179
+ process.stdout.write('[\n');
180
+ }
181
+
182
+ push (log) {
183
+ if (this._isFirst) {
184
+ this._isFirst = false;
185
+ }
186
+ else {
187
+ process.stdout.write(',\n');
188
+ }
189
+ process.stdout.write(` ${JSON.stringify(log)}`);
190
+ }
191
+
192
+ close () {
193
+ process.stdout.write('\n]');
194
+ }
195
+ }
package/src/parsers.js CHANGED
@@ -3,6 +3,8 @@
3
3
  const cliparse = require('cliparse');
4
4
 
5
5
  const Application = require('./models/application.js');
6
+ const ISO8601 = require('iso8601-duration');
7
+ const Duration = require('duration-js');
6
8
 
7
9
  function flavor (flavor) {
8
10
  const flavors = Application.listAvailableFlavors();
@@ -32,10 +34,16 @@ function instances (instances) {
32
34
 
33
35
  function date (dateString) {
34
36
  const date = new Date(dateString);
35
- if (isNaN(date.getTime())) {
36
- return cliparse.parsers.error('Invalid date: ' + dateString + ' (timestamps or IS0 8601 dates are accepted)');
37
+ if (isNaN(dateString) && !isNaN(date.getTime())) {
38
+ return cliparse.parsers.success(date);
37
39
  }
38
- return cliparse.parsers.success(date);
40
+
41
+ const duration = durationInSeconds(dateString);
42
+ if (duration.success) {
43
+ return cliparse.parsers.success(new Date(Date.now() - (duration.success * 1000)));
44
+ }
45
+
46
+ return duration;
39
47
  }
40
48
 
41
49
  const appIdRegex = /^app_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -86,6 +94,13 @@ function integer (string) {
86
94
  return cliparse.parsers.success(integer);
87
95
  }
88
96
 
97
+ function nonEmptyString (string) {
98
+ if (typeof string !== 'string' || string === '') {
99
+ return cliparse.parsers.error('Invalid string, it should not be empty');
100
+ }
101
+ return cliparse.parsers.success(string);
102
+ }
103
+
89
104
  // /^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/i;
90
105
  const tagRegex = /^[^,\s]+$/;
91
106
 
@@ -143,6 +158,39 @@ function portNumber (number) {
143
158
  return cliparse.parsers.error(`Invalid port number '${number}'. Should match ${portNumberRegex}`);
144
159
  }
145
160
 
161
+ /**
162
+ * Parse a duration into seconds
163
+ * A Zero seconds duration is allowed
164
+ * @param {string} durationStr an ISO8601, 1h or a positive number
165
+ * @returns {number} number of seconds
166
+ */
167
+ function durationInSeconds (durationStr = '') {
168
+ const failed = cliparse.parsers.error(`Invalid duration: "${durationStr}", expect (IS0 8601 duration / a "1h, 1m, 30s" like duration / a positive number in seconds)`);
169
+
170
+ if (durationStr.startsWith('P')) {
171
+ try {
172
+ const d = ISO8601.parse(durationStr);
173
+ return cliparse.parsers.success(ISO8601.toSeconds(d));
174
+ }
175
+ catch (err) {
176
+ return failed;
177
+ }
178
+ }
179
+
180
+ try {
181
+ const duration = Duration.parse(durationStr);
182
+ return cliparse.parsers.success(duration.seconds());
183
+ }
184
+ catch (err) {
185
+ const n = Number.parseInt(durationStr);
186
+ if (isNaN(n) || n < 0) {
187
+ return failed;
188
+ }
189
+
190
+ return cliparse.parsers.success(n);
191
+ }
192
+ }
193
+
146
194
  module.exports = {
147
195
  buildFlavor,
148
196
  flavor,
@@ -162,4 +210,6 @@ module.exports = {
162
210
  ipAddress,
163
211
  portNumberRegex,
164
212
  portNumber,
213
+ durationInSeconds,
214
+ nonEmptyString,
165
215
  };