clever-tools 3.11.0 → 3.12.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,202 @@
1
+ import colors from 'colors/safe.js';
2
+ import * as networkGroup from '../models/ng.js';
3
+ import * as networkGroupResources from '../models/ng-resources.js';
4
+
5
+ import { Logger } from '../logger.js';
6
+ import { printResults } from '../lib/ng-print.js';
7
+
8
+ /** Create a Network Group
9
+ * @param {Object} params
10
+ * @param {Array<Object>} params.args
11
+ * @param {Object} params.args[0] Network Group label
12
+ * @param {string} params.options.description Network Group description
13
+ * @param {Array<string>} params.options.link Array of member IDs or labels to link to the Network Group
14
+ * @param {Object} params.options.org Organisation ID or name
15
+ * @param {string} params.options.tags Comma-separated list of tags
16
+ */
17
+ export async function createNg (params) {
18
+ const [ngLabel] = params.args;
19
+ const label = ngLabel.ngResourceLabel;
20
+ const { description, link: membersIds, org, tags } = params.options;
21
+
22
+ await networkGroup.create(label, description, tags, membersIds, org);
23
+
24
+ const successMessage = `Network Group ${colors.green(label)} successfully created`;
25
+ if (membersIds == null) {
26
+ Logger.printSuccess(`${successMessage}!`);
27
+ }
28
+ else {
29
+ Logger.printSuccess(`${successMessage} with member(s):`);
30
+ Logger.println(membersIds.map((id) => colors.grey(` - ${id}`)).join('\n'));
31
+ }
32
+ }
33
+
34
+ /** Delete a Network Group
35
+ * @param {Object} params
36
+ * @param {Object} params.args[0] Network Group ID or label
37
+ * @param {Object} params.options.org Organisation ID or name
38
+ */
39
+ export async function deleteNg (params) {
40
+ const [ngIdOrLabel] = params.args;
41
+ const { org } = params.options;
42
+
43
+ await networkGroup.destroy(ngIdOrLabel, org);
44
+ const ngText = ngIdOrLabel.ngResourceLabel ?? ngIdOrLabel.ngId;
45
+ Logger.printSuccess(`Network Group ${colors.green(ngText)} successfully deleted!`);
46
+ }
47
+
48
+ /** Create an external peer in a Network Group
49
+ * @param {Object} params
50
+ * @param {Object} params.args[0] External peer ID or label
51
+ * @param {Object} params.args[1] Network Group ID or label
52
+ * @param {string} params.args[2] Wireguard public key
53
+ * @param {Object} params.options.org Organisation ID or name
54
+ */
55
+ export async function createExternalPeer (params) {
56
+ const [peerIdOrLabel, ngIdOrLabel, publicKey] = params.args;
57
+ const { org } = params.options;
58
+
59
+ await networkGroupResources.createExternalPeerWithParent(ngIdOrLabel, peerIdOrLabel.ngResourceLabel, publicKey, org);
60
+ const ngText = ngIdOrLabel.ngResourceLabel ?? ngIdOrLabel.ngId;
61
+ Logger.printSuccess(`External peer ${colors.green(peerIdOrLabel.ngResourceLabel)} successfully created in Network Group ${colors.green(ngText)}`);
62
+ }
63
+
64
+ /** Delete an external peer from a Network Group
65
+ * @param {Object} params
66
+ * @param {Object} params.args[0] External peer ID or label
67
+ * @param {Object} params.args[1] Network Group ID or label
68
+ * @param {Object} params.options.org Organisation ID or name
69
+ */
70
+ export async function deleteExternalPeer (params) {
71
+ const [peerIdOrLabel, ngIdOrLabel] = params.args;
72
+ const { org } = params.options;
73
+
74
+ const peerText = peerIdOrLabel.ngResourceLabel ?? peerIdOrLabel.memberId;
75
+ const ngText = ngIdOrLabel.ngResourceLabel ?? ngIdOrLabel.ngId;
76
+ await networkGroupResources.deleteExternalPeerWithParent(ngIdOrLabel, peerText, org);
77
+ Logger.printSuccess(`External peer ${colors.green(peerText)} successfully deleted from Network Group ${colors.green(ngText)}`);
78
+ }
79
+
80
+ /** Link a member to a Network Group
81
+ * @param {Object} params
82
+ * @param {string} params.args[0] Member ID
83
+ * @param {Object} params.args[1] Network Group ID or label
84
+ * @param {Object} params.options.org Organisation ID or name
85
+ */
86
+ export async function linkToNg (params) {
87
+ const [resourceId, ngIdOrLabel] = params.args;
88
+ const { org } = params.options;
89
+
90
+ await networkGroupResources.linkMember(ngIdOrLabel, resourceId.memberId, org);
91
+ const ngText = ngIdOrLabel.ngResourceLabel ?? ngIdOrLabel.ngId;
92
+ Logger.printSuccess(`Member ${colors.green(resourceId.memberId)} successfully linked to Network Group ${colors.green(ngText)}`);
93
+ }
94
+
95
+ /** Unlink a member from a Network Group
96
+ * @param {Object} params
97
+ * @param {string} params.args[0] Member ID
98
+ * @param {Object} params.args[1] Network Group ID or label
99
+ * @param {Object} params.options.org Organisation ID or name
100
+ */
101
+ export async function unlinkFromNg (params) {
102
+ const [resourceId, ngIdOrLabel] = params.args;
103
+ const { org } = params.options;
104
+
105
+ await networkGroupResources.unlinkMember(ngIdOrLabel, resourceId.memberId, org);
106
+ const ngText = ngIdOrLabel.ngResourceLabel ?? ngIdOrLabel.ngId;
107
+ Logger.printSuccess(`Member ${colors.green(resourceId.memberId)} successfully unlinked from Network Group ${colors.green(ngText)}`);
108
+ }
109
+
110
+ /** Print the configuration of a Network Group's peer
111
+ * @param {Object} params
112
+ * @param {Object} params.args[0] Peer ID or label
113
+ * @param {Object} params.args[1] Network Group ID or label
114
+ * @param {Object} params.options.org Organisation ID or name
115
+ * @param {string} params.options.format Output format
116
+ */
117
+ export async function getPeerConfig (params) {
118
+ const [peerIdOrLabel, ngIdOrLabel] = params.args;
119
+ const { org, format } = params.options;
120
+
121
+ const config = await networkGroup.getPeerConfig(peerIdOrLabel, ngIdOrLabel, org);
122
+
123
+ switch (format) {
124
+ case 'json': {
125
+ Logger.printJson(config);
126
+ break;
127
+ }
128
+ case 'human':
129
+ default: {
130
+ const decodedConfiguration = Buffer.from(config.configuration, 'base64').toString('utf8');
131
+ Logger.println(decodedConfiguration);
132
+ }
133
+ }
134
+ }
135
+
136
+ /** List Network Groups, their members and peers
137
+ * @param {Object} params
138
+ * @param {Object} params.options.orgaIdOrName Organisation ID or name
139
+ * @param {string} params.options.format Output format
140
+ */
141
+ export async function listNg (params) {
142
+ const { org, format } = params.options;
143
+
144
+ const ngs = await networkGroup.getAllNGs(org);
145
+
146
+ switch (format) {
147
+ case 'json': {
148
+ Logger.printJson(ngs);
149
+ break;
150
+ }
151
+ case 'human':
152
+ default: {
153
+ if (!ngs.length) {
154
+ Logger.println(`ℹ️ No Network Group found, create one with ${colors.blue('clever ng create')} command`);
155
+ return;
156
+ }
157
+ const ngList = ngs.map(({
158
+ id,
159
+ label,
160
+ networkIp,
161
+ members,
162
+ peers,
163
+ }) => ({
164
+ ID: id,
165
+ Label: label,
166
+ 'Network CIDR': networkIp,
167
+ Members: Object.keys(members).length,
168
+ Peers: Object.keys(peers).length,
169
+ }));
170
+
171
+ console.table(ngList);
172
+ }
173
+ }
174
+ }
175
+
176
+ /** Show information about a Network Group, a member or a peer
177
+ * @param {Object} params
178
+ * @param {Object} params.args[0] ID or label of the Network Group, a member or a peer
179
+ * @param {Object} params.options.org Organisation ID or name
180
+ * @param {string} params.options.format Output format
181
+ */
182
+ export async function get (params) {
183
+ const [idOrLabel] = params.args;
184
+ const { org, format } = params.options;
185
+ const type = params.options.type ?? 'single';
186
+
187
+ await printResults(idOrLabel, org, format, 'get', type);
188
+ }
189
+
190
+ /** Show information about a Network Group, a member or a peer
191
+ * @param {Object} params
192
+ * @param {Object} params.args[0] ID or label of the Network Group, a member or a peer
193
+ * @param {Object} params.options.org Organisation ID or name
194
+ * @param {string} params.options.format Output format
195
+ */
196
+ export async function search (params) {
197
+ const [idOrLabel] = params.args;
198
+ const { org, format } = params.options;
199
+ const type = params.options.type;
200
+
201
+ await printResults(idOrLabel, org, format, 'search', type);
202
+ }
@@ -2,6 +2,7 @@ import colors from 'colors/safe.js';
2
2
  import openPage from 'open';
3
3
  import { Logger } from '../logger.js';
4
4
  import * as User from '../models/user.js';
5
+ import dedent from 'dedent';
5
6
 
6
7
  export async function profile (params) {
7
8
  const { format } = params.options;
@@ -37,12 +38,14 @@ export async function profile (params) {
37
38
  }
38
39
  case 'human':
39
40
  default: {
40
- Logger.println('You\'re currently logged in as:');
41
- Logger.println(`User id ${formattedUser.id}`);
42
- Logger.println(`Name ${formattedUser.name ?? colors.red.bold('[not specified]')}`);
43
- Logger.println(`Email ${formattedUser.email}`);
44
- Logger.println(`Token expiration ${tokenExpiration}`);
45
- Logger.println(`Two factor auth ${formattedUser.has2FA ? 'yes' : 'no'}`);
41
+ Logger.println(dedent`
42
+ You're currently logged in as:
43
+ User id ${formattedUser.id}
44
+ Name ${formattedUser.name ?? colors.red.bold('[not specified]')}
45
+ Email ${formattedUser.email}
46
+ Token expiration ${tokenExpiration}
47
+ Two factor auth ${formattedUser.has2FA ? 'yes' : 'no'}
48
+ `);
46
49
  }
47
50
  }
48
51
  };
@@ -3,13 +3,13 @@ import { Logger } from '../logger.js';
3
3
  import * as variables from '../models/variables.js';
4
4
  import { sendToApi } from '../models/send-to-api.js';
5
5
  import { toNameEqualsValueString, validateName } from '@clevercloud/client/esm/utils/env-vars.js';
6
- import * as application from '@clevercloud/client/esm/api/v2/application.js';
6
+ import { getAllExposedEnvVars, updateAllExposedEnvVars } from '@clevercloud/client/esm/api/v2/application.js';
7
7
 
8
8
  export async function list (params) {
9
9
  const { alias, app: appIdOrName, format } = params.options;
10
10
  const { ownerId, appId } = await Application.resolveId(appIdOrName, alias);
11
11
 
12
- const publishedConfigs = await application.getAllExposedEnvVars({ id: ownerId, appId }).then(sendToApi);
12
+ const publishedConfigs = await getAllExposedEnvVars({ id: ownerId, appId }).then(sendToApi);
13
13
  const pairs = Object.entries(publishedConfigs).map(([name, value]) => ({ name, value }));
14
14
 
15
15
  switch (format) {
@@ -39,9 +39,9 @@ export async function set (params) {
39
39
 
40
40
  const { ownerId, appId } = await Application.resolveId(appIdOrName, alias);
41
41
 
42
- const publishedConfigs = await application.getAllExposedEnvVars({ id: ownerId, appId }).then(sendToApi);
42
+ const publishedConfigs = await getAllExposedEnvVars({ id: ownerId, appId }).then(sendToApi);
43
43
  publishedConfigs[varName] = varValue;
44
- await application.updateAllExposedEnvVars({ id: ownerId, appId }, publishedConfigs).then(sendToApi);
44
+ await updateAllExposedEnvVars({ id: ownerId, appId }, publishedConfigs).then(sendToApi);
45
45
 
46
46
  Logger.println('Your published config item has been successfully saved');
47
47
  };
@@ -51,9 +51,9 @@ export async function rm (params) {
51
51
  const { alias, app: appIdOrName } = params.options;
52
52
  const { ownerId, appId } = await Application.resolveId(appIdOrName, alias);
53
53
 
54
- const publishedConfigs = await application.getAllExposedEnvVars({ id: ownerId, appId }).then(sendToApi);
54
+ const publishedConfigs = await getAllExposedEnvVars({ id: ownerId, appId }).then(sendToApi);
55
55
  delete publishedConfigs[varName];
56
- await application.updateAllExposedEnvVars({ id: ownerId, appId }, publishedConfigs).then(sendToApi);
56
+ await updateAllExposedEnvVars({ id: ownerId, appId }, publishedConfigs).then(sendToApi);
57
57
 
58
58
  Logger.println('Your published config item has been successfully removed');
59
59
  };
@@ -64,7 +64,7 @@ export async function importEnv (params) {
64
64
  const { ownerId, appId } = await Application.resolveId(appIdOrName, alias);
65
65
 
66
66
  const publishedConfigs = await variables.readVariablesFromStdin(format);
67
- await application.updateAllExposedEnvVars({ id: ownerId, appId }, publishedConfigs).then(sendToApi);
67
+ await updateAllExposedEnvVars({ id: ownerId, appId }, publishedConfigs).then(sendToApi);
68
68
 
69
69
  Logger.println('Your published configs have been set');
70
70
  };
@@ -1,12 +1,12 @@
1
1
  import * as Application from '../models/application.js';
2
- import * as application from '@clevercloud/client/esm/api/v2/application.js';
3
2
  import { Logger } from '../logger.js';
4
3
  import { sendToApi } from '../models/send-to-api.js';
4
+ import { undeploy as stopApplication } from '@clevercloud/client/esm/api/v2/application.js';
5
5
 
6
6
  export async function stop (params) {
7
7
  const { alias, app: appIdOrName } = params.options;
8
8
  const { ownerId, appId } = await Application.resolveId(appIdOrName, alias);
9
9
 
10
- await application.undeploy({ id: ownerId, appId }).then(sendToApi);
10
+ await stopApplication({ id: ownerId, appId }).then(sendToApi);
11
11
  Logger.println('App successfully stopped!');
12
12
  }
@@ -4,8 +4,8 @@ import * as Namespaces from '../models/namespaces.js';
4
4
  import { sendToApi } from '../models/send-to-api.js';
5
5
  import * as Interact from '../models/interact.js';
6
6
  import { Logger } from '../logger.js';
7
- import * as application from '@clevercloud/client/esm/api/v2/application.js';
8
7
  import * as Application from '../models/application.js';
8
+ import { addTcpRedir, getTcpRedirs, removeTcpRedir } from '@clevercloud/client/esm/api/v2/application.js';
9
9
 
10
10
  export async function listNamespaces (params) {
11
11
  const { alias, app: appIdOrName, format } = params.options;
@@ -44,7 +44,7 @@ export async function list (params) {
44
44
  const { alias, app: appIdOrName, format } = params.options;
45
45
  const { ownerId, appId } = await Application.resolveId(appIdOrName, alias);
46
46
 
47
- const redirs = await application.getTcpRedirs({ id: ownerId, appId }).then(sendToApi);
47
+ const redirs = await getTcpRedirs({ id: ownerId, appId }).then(sendToApi);
48
48
 
49
49
  switch (format) {
50
50
  case 'json': {
@@ -83,10 +83,10 @@ export async function add (params) {
83
83
  const { alias, app: appIdOrName, namespace, yes: skipConfirmation } = params.options;
84
84
  const { ownerId, appId } = await Application.resolveId(appIdOrName, alias);
85
85
 
86
- const { port } = await application.addTcpRedir({ id: ownerId, appId }, { namespace }).then(sendToApi).catch((error) => {
86
+ const { port } = await addTcpRedir({ id: ownerId, appId }, { namespace }).then(sendToApi).catch((error) => {
87
87
  if (error.status === 402) {
88
88
  return acceptPayment(error.response.body, skipConfirmation).then(() => {
89
- return application.addTcpRedir({ id: ownerId, appId, payment: 'accepted' }, { namespace }).then(sendToApi);
89
+ return addTcpRedir({ id: ownerId, appId, payment: 'accepted' }, { namespace }).then(sendToApi);
90
90
  });
91
91
  }
92
92
  else {
@@ -102,7 +102,7 @@ export async function remove (params) {
102
102
  const { alias, app: appIdOrName, namespace } = params.options;
103
103
  const { ownerId, appId } = await Application.resolveId(appIdOrName, alias);
104
104
 
105
- await application.removeTcpRedir({ id: ownerId, appId, sourcePort: port, namespace }).then(sendToApi);
105
+ await removeTcpRedir({ id: ownerId, appId, sourcePort: port, namespace }).then(sendToApi);
106
106
 
107
107
  Logger.println('Successfully removed tcp redirection.');
108
108
  };
@@ -0,0 +1,137 @@
1
+ import colors from 'colors/safe.js';
2
+ import { Logger } from '../logger.js';
3
+ import { createApiToken, deleteApiToken, listApiTokens } from '../clever-client/auth-bridge.js';
4
+ import { sendToAuthBridge } from '../models/send-to-api.js';
5
+ import { getCurrent as getCurrentUser } from '../models/user.js';
6
+ import { conf } from '../models/configuration.js';
7
+ import dedent from 'dedent';
8
+ import { promptPassword } from '../prompt-password.js';
9
+
10
+ /**
11
+ * Create a new API token
12
+ * @param {Object} params - Function parameters
13
+ * @param {[string]} params.args - Command line args
14
+ * @param {Object} params.options - Command line options
15
+ * @param {'json'|'human'} params.options.format - Output format
16
+ */
17
+ export async function create (params) {
18
+ const [apiTokenName] = params.args;
19
+ const { expiration, format } = params.options;
20
+
21
+ // Expire in 1 year
22
+ const dateObject = new Date();
23
+ dateObject.setFullYear(dateObject.getFullYear() + 1);
24
+ const maxExpirationDate = dateObject;
25
+
26
+ let expirationDate;
27
+ if (expiration != null) {
28
+ if (expiration > maxExpirationDate) {
29
+ throw new Error('You cannot set an expiration date greater than 1 year');
30
+ }
31
+ expirationDate = expiration;
32
+ }
33
+ else {
34
+ expirationDate = maxExpirationDate;
35
+ }
36
+
37
+ const user = await getCurrentUser();
38
+
39
+ const password = await promptPassword('Enter your password:');
40
+
41
+ let mfaCode;
42
+ if (user.preferredMFA === 'TOTP') {
43
+ mfaCode = await promptPassword('Enter your 2FA code:');
44
+ }
45
+
46
+ const tokenData = {
47
+ email: user.email,
48
+ password,
49
+ mfaCode,
50
+ name: apiTokenName,
51
+ expirationDate: expirationDate.toISOString(),
52
+ };
53
+ const createdToken = await createApiToken(tokenData).then(sendToAuthBridge).catch((error) => {
54
+ const errorCode = error?.cause?.responseBody?.code;
55
+ if (errorCode === 'invalid-credential') {
56
+ throw new Error('Invalid credentials, check your password');
57
+ }
58
+ if (errorCode === 'invalid-mfa-code') {
59
+ throw new Error('Invalid credentials, check your 2FA code');
60
+ }
61
+ throw error;
62
+ });
63
+
64
+ switch (format) {
65
+ case 'json':
66
+ Logger.printJson(createdToken);
67
+ break;
68
+ case 'human':
69
+ default:
70
+ Logger.println(dedent`
71
+ ${colors.green('✔')} API token successfully created! Store it securely, you won't able to print it again.
72
+
73
+ - API token ID : ${colors.grey(createdToken.apiTokenId)}
74
+ - API token : ${colors.grey(createdToken.apiToken)}
75
+ - Expiration : ${colors.grey(formatDate(createdToken.expirationDate))}
76
+
77
+ Export this token and use it to make authenticated requests to the Clever Cloud API through the Auth Bridge:
78
+
79
+ export CC_API_TOKEN=${createdToken.apiToken}
80
+ curl -H "Authorization: Bearer $CC_API_TOKEN" ${conf.AUTH_BRIDGE_HOST}/v2/self
81
+
82
+ Then, to revoke this token, run:
83
+ clever tokens revoke ${createdToken.apiTokenId}
84
+ `);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Get information about an API token
90
+ * @param {Object} params - Function parameters
91
+ * @param {Object} params.options - Command line options
92
+ * @param {Object} params.options.format - Output format
93
+ * @returns {Promise<void>}
94
+ */
95
+ export async function list (params) {
96
+ const { format } = params.options;
97
+
98
+ const tokens = await listApiTokens().then(sendToAuthBridge);
99
+
100
+ if (format === 'json') {
101
+ Logger.printJson(tokens);
102
+ }
103
+ else {
104
+ if (tokens.length === 0) {
105
+ Logger.println(`ℹ️ No API token found, create one with ${colors.blue('clever tokens create')} command`);
106
+ }
107
+ else {
108
+ console.table(tokens.map((token) => {
109
+ return {
110
+ 'API token ID': token.apiTokenId,
111
+ Name: token.name,
112
+ 'Creation IP address': token.ip,
113
+ Creation: formatDate(token.creationDate),
114
+ Expiration: formatDate(token.expirationDate),
115
+ };
116
+ }));
117
+ }
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Revoke an API token
123
+ * @param {Object} params - Function parameters
124
+ * @param {string[]} params.args - Command line arguments, token ID to revoke is expected as first argument
125
+ * @returns {Promise<void>}
126
+ */
127
+ export async function revoke (params) {
128
+ const [apiTokenId] = params.args;
129
+
130
+ await deleteApiToken(apiTokenId).then(sendToAuthBridge);
131
+
132
+ Logger.println(colors.green('✔'), 'API token successfully revoked!');
133
+ }
134
+
135
+ function formatDate (dateInput) {
136
+ return new Date(dateInput).toISOString().substring(0, 16).replace('T', ' ');
137
+ }
@@ -1,8 +1,11 @@
1
+ import dedent from 'dedent';
2
+ import { conf } from './models/configuration.js';
3
+
1
4
  export const EXPERIMENTAL_FEATURES = {
2
5
  kv: {
3
6
  status: 'alpha',
4
7
  description: 'Send commands to databases such as Materia KV or Redis® directly from Clever Tools, without other dependencies',
5
- instructions: `
8
+ instructions: dedent`
6
9
  Target any compatible add-on by its name or ID (with an org ID if needed) and send commands to it:
7
10
 
8
11
  clever kv myMateriaKV SET myKey myValue
@@ -12,6 +15,47 @@ export const EXPERIMENTAL_FEATURES = {
12
15
  clever kv redis_xxxxx --org org_xxxxx PING
13
16
 
14
17
  Learn more about Materia KV: https://www.clever-cloud.com/developers/doc/addons/materia-kv/
15
- `,
18
+ `,
19
+ },
20
+ tokens: {
21
+ status: 'beta',
22
+ description: `Manage API tokens to query Clever Cloud API from ${conf.AUTH_BRIDGE_HOST}`,
23
+ instructions: dedent`
24
+ Create, list or revoke API tokens from a single command:
25
+
26
+ clever tokens create myTokenName
27
+ clever tokens --format json
28
+ clever tokens revoke myTokenId
29
+
30
+ Learn more about Clever Cloud API: https://www.clever-cloud.com/developers/api
31
+ `,
32
+ },
33
+ ng: {
34
+ status: 'beta',
35
+ description: 'Manage Network Groups to manage applications, add-ons, external peers through a Wireguard network',
36
+ instructions: dedent`
37
+ - Create a Network Group:
38
+ clever ng create myNG
39
+ - Create a Network Group with members (application, database add-on):
40
+ clever ng create myNG --link app_xxx,addon_xxx
41
+ - List Network Groups:
42
+ clever ng
43
+ - Delete a Network Group:
44
+ clever ng delete myNG
45
+ - (Un)Link an application or a database add-on to an existing Network Group:
46
+ clever ng link app_xxx myNG
47
+ clever ng unlink addon_xxx myNG
48
+ - Get the Wireguard configuration of a peer:
49
+ clever ng get-config peerIdOrLabel myNG
50
+ - Get details about a Network Group, a member or a peer:
51
+ clever ng get myNg
52
+ clever ng get app_xxx
53
+ clever ng get peerId
54
+ clever ng get memberLabel
55
+ - Search Network Groups, members or peers:
56
+ clever ng search myQuery
57
+
58
+ Learn more about Network Groups: https://github.com/CleverCloud/clever-tools/blob/master/docs/ng.md
59
+ `,
16
60
  },
17
61
  };