clever-tools 3.12.0 → 3.13.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.
Files changed (42) hide show
  1. package/bin/clever.js +280 -6
  2. package/package.json +1 -1
  3. package/src/clever-client/auth-bridge.js +1 -1
  4. package/src/clever-client/operators.js +121 -0
  5. package/src/commands/addon.js +12 -7
  6. package/src/commands/cancel-deploy.js +3 -4
  7. package/src/commands/config.js +16 -20
  8. package/src/commands/console.js +4 -10
  9. package/src/commands/create.js +76 -9
  10. package/src/commands/delete.js +11 -10
  11. package/src/commands/deploy.js +35 -21
  12. package/src/commands/emails.js +174 -0
  13. package/src/commands/keycloak.js +138 -0
  14. package/src/commands/link.js +3 -1
  15. package/src/commands/makeDefault.js +2 -1
  16. package/src/commands/matomo.js +85 -0
  17. package/src/commands/metabase.js +112 -0
  18. package/src/commands/open.js +2 -5
  19. package/src/commands/otoroshi.js +138 -0
  20. package/src/commands/profile.js +2 -4
  21. package/src/commands/restart.js +1 -1
  22. package/src/commands/ssh-keys.js +159 -0
  23. package/src/commands/stop.js +1 -1
  24. package/src/commands/tcp-redirs.js +3 -3
  25. package/src/commands/tokens.js +16 -7
  26. package/src/commands/unlink.js +2 -1
  27. package/src/experimental-features.js +16 -15
  28. package/src/lib/operator-commands.js +281 -0
  29. package/src/lib/prompts.js +30 -0
  30. package/src/lib/slugify.js +10 -0
  31. package/src/models/addon.js +5 -2
  32. package/src/models/app_configuration.js +12 -9
  33. package/src/models/application.js +24 -12
  34. package/src/models/application_configuration.js +72 -72
  35. package/src/models/git.js +29 -1
  36. package/src/models/ids-resolver.js +37 -1
  37. package/src/models/interact.js +0 -24
  38. package/src/models/log-v4.js +13 -4
  39. package/src/models/operator.js +48 -0
  40. package/src/models/utils.js +21 -0
  41. package/src/parsers.js +14 -0
  42. package/src/prompt-password.js +0 -10
@@ -1,7 +1,6 @@
1
1
  import cliparse from 'cliparse';
2
- import colors from 'colors/safe.js';
3
-
4
2
  import { Logger } from '../logger.js';
3
+ import dedent from 'dedent';
5
4
 
6
5
  const CONFIG_KEYS = [
7
6
  { id: 'name', name: 'name', displayName: 'Name', kind: 'string' },
@@ -10,31 +9,43 @@ const CONFIG_KEYS = [
10
9
  { id: 'sticky-sessions', name: 'stickySessions', displayName: 'Sticky sessions', kind: 'bool' },
11
10
  { id: 'cancel-on-push', name: 'cancelOnPush', displayName: 'Cancel current deployment on push', kind: 'bool' },
12
11
  { id: 'force-https', name: 'forceHttps', displayName: 'Force redirection of HTTP to HTTPS', kind: 'force-https' },
12
+ { id: 'task', name: 'instanceLifetime', displayName: 'Deploy an application as a Clever Task', kind: 'task' },
13
13
  ];
14
14
 
15
- export function listAvailableIds () {
16
- return CONFIG_KEYS.map((config) => config.id);
15
+ export function listAvailableIds (asText = false) {
16
+ const ids = CONFIG_KEYS.map((config) => config.id);
17
+ if (asText) {
18
+ return new Intl
19
+ .ListFormat('en', { style: 'short', type: 'disjunction' })
20
+ .format(ids);
21
+ }
22
+ return ids;
17
23
  }
18
24
 
19
25
  export function getById (id) {
20
26
  const config = CONFIG_KEYS.find((config) => config.id === id);
21
- if (config == null) {
22
- Logger.error(`Invalid configuration name: ${id}.`);
23
- Logger.error(`Available configuration names are: ${listAvailableIds().join(', ')}.`);
27
+ if (config != null) {
28
+ return config;
24
29
  }
25
- return config;
30
+ throw new Error(dedent`
31
+ Invalid configuration name: ${id}.
32
+ Available configuration names: ${listAvailableIds(true)}.
33
+ `);
26
34
  }
27
35
 
28
- function display (config, value) {
36
+ export function formatValue (config, value) {
29
37
  switch (config.kind) {
30
38
  case 'bool': {
31
- return (value) ? 'enabled' : 'disabled';
39
+ return value;
32
40
  }
33
41
  case 'inverted-bool': {
34
- return (value) ? 'disabled' : 'enabled';
42
+ return !value;
35
43
  }
36
44
  case 'force-https': {
37
- return value.toLowerCase();
45
+ return value === 'ENABLED';
46
+ }
47
+ case 'task': {
48
+ return value === 'TASK';
38
49
  }
39
50
  default: {
40
51
  return String(value);
@@ -44,14 +55,26 @@ function display (config, value) {
44
55
 
45
56
  export function parse (config, value) {
46
57
  switch (config.kind) {
47
- case 'bool': {
48
- return (value !== 'false');
49
- }
50
- case 'inverted-bool': {
51
- return (value === 'false');
52
- }
53
- case 'force-https': {
54
- return (value === 'false') ? 'DISABLED' : 'ENABLED';
58
+ case 'bool':
59
+ case 'inverted-bool':
60
+ case 'force-https':
61
+ case 'task': {
62
+ if (value !== 'true' && value !== 'false') {
63
+ throw new Error('Invalid configuration value, it must be a boolean (true or false)');
64
+ }
65
+ if (config.kind === 'bool') {
66
+ return (value === 'true');
67
+ }
68
+ if (config.kind === 'inverted-bool') {
69
+ return (value === 'false');
70
+ }
71
+ if (config.kind === 'force-https') {
72
+ return (value === 'true') ? 'ENABLED' : 'DISABLED';
73
+ }
74
+ if (config.kind === 'task') {
75
+ return (value === 'false') ? 'REGULAR' : 'TASK';
76
+ }
77
+ return;
55
78
  }
56
79
  default: {
57
80
  return value;
@@ -69,7 +92,8 @@ function getConfigOptions (config) {
69
92
  switch (config.kind) {
70
93
  case 'bool':
71
94
  case 'inverted-bool':
72
- case 'force-https': {
95
+ case 'force-https':
96
+ case 'task': {
73
97
  return [
74
98
  cliparse.flag(`enable-${config.id}`, { description: `Enable ${config.id}` }),
75
99
  cliparse.flag(`disable-${config.id}`, { description: `Disable ${config.id}` }),
@@ -92,69 +116,45 @@ export function parseOptions (options) {
92
116
 
93
117
  function parseConfigOption (config, options) {
94
118
  switch (config.kind) {
95
- case 'bool': {
96
- const enable = options[`enable-${config.id}`];
97
- const disable = options[`disable-${config.id}`];
98
- if (enable && disable) {
99
- Logger.warn(`${config.id} is both enabled and disabled, ignoring`);
100
- }
101
- else if (enable || disable) {
102
- return [config.name, enable];
103
- }
104
- return null;
105
- }
106
- case 'inverted-bool': {
107
- const disable = options[`enable-${config.id}`];
108
- const enable = options[`disable-${config.id}`];
109
- if (enable && disable) {
110
- Logger.warn(`${config.id} is both enabled and disabled, ignoring`);
111
- }
112
- else if (enable || disable) {
113
- return [config.name, enable];
114
- }
115
- return null;
116
- }
117
- case 'force-https': {
119
+ case 'bool':
120
+ case 'inverted-bool':
121
+ case 'force-https':
122
+ case 'task': {
118
123
  const enable = options[`enable-${config.id}`];
119
124
  const disable = options[`disable-${config.id}`];
120
125
  if (enable && disable) {
121
- Logger.warn(`${config.id} is both enabled and disabled, ignoring`);
126
+ throw new Error(`You cannot use both --enable-${config.id} and --disable-${config.id} at the same time`);
122
127
  }
123
- else if (enable || disable) {
124
- const value = (enable) ? 'ENABLED' : 'DISABLED';
125
- return [config.name, value];
128
+ if (enable || disable) {
129
+ if (config.kind === 'bool') {
130
+ return [config.name, enable];
131
+ }
132
+ if (config.kind === 'inverted-bool') {
133
+ return [config.name, disable];
134
+ }
135
+ if (config.kind === 'force-https' || config.kind === 'task') {
136
+ return [config.name, parse(config, String(enable))];
137
+ }
126
138
  }
127
- return null;
139
+ return;
128
140
  }
129
141
  default: {
130
- if (options[config.id] !== null) {
131
- return [config.name, options[config.id]];
132
- }
133
- return null;
142
+ return [config.name, options[config.id]];
134
143
  }
135
144
  }
136
145
  }
137
146
 
138
- function printConfig (app, config) {
139
- if (app[config.name] != null) {
140
- Logger.println(`${config.displayName}: ${colors.bold(display(config, app[config.name]))}`);
141
- }
142
- }
143
-
144
- export function printById (app, id) {
147
+ export function printValue (app, id) {
145
148
  const config = getById(id);
146
- if (config != null) {
147
- printConfig(app, config);
148
- }
149
+ Logger.println(formatValue(config, app[config.name]));
149
150
  }
150
151
 
151
- export function printByName (app, name) {
152
- const config = CONFIG_KEYS.find((config) => config.name === name);
153
- printConfig(app, config);
154
- }
155
-
156
- export function print (app) {
157
- for (const config of CONFIG_KEYS) {
158
- printConfig(app, config);
159
- }
152
+ export function printAllValues (app) {
153
+ console.table(
154
+ Object.fromEntries(
155
+ CONFIG_KEYS.map((config) => {
156
+ return [config.id, formatValue(config, app[config.name])];
157
+ }),
158
+ ),
159
+ );
160
160
  }
package/src/models/git.js CHANGED
@@ -5,7 +5,7 @@ import _ from 'lodash';
5
5
  import git from 'isomorphic-git';
6
6
  import * as http from './isomorphic-http-with-agent.js';
7
7
  import cliparse from 'cliparse';
8
- import slugify from 'slugify';
8
+ import { slugify } from '../lib/slugify.js';
9
9
  import { findPath } from './fs-utils.js';
10
10
  import { loadOAuthConf } from './configuration.js';
11
11
 
@@ -130,3 +130,31 @@ export async function isShallow () {
130
130
  return false;
131
131
  }
132
132
  }
133
+
134
+ /**
135
+ * Check if the current directory is a git repository
136
+ * @returns {Promise<boolean>}
137
+ */
138
+ export async function isInsideGitRepo () {
139
+ return getRepo()
140
+ .then(() => true)
141
+ .catch(() => false);
142
+ }
143
+
144
+ /**
145
+ * Check if the current git working directory is clean
146
+ * @returns {Promise<boolean>}
147
+ */
148
+ export async function isGitWorkingDirectoryClean () {
149
+ const repo = await getRepo();
150
+ const status = await git.statusMatrix({ ...repo });
151
+ const isStatusEmpty = status
152
+ .filter(([filepath, head, workdir]) => {
153
+ // WARNING: isomorphic-git does not support global gitignore so we filter hidden files and dirs to reduce the amount of false positives
154
+ const isHidden = filepath.startsWith('.');
155
+ const isCleverJson = filepath === '.clever.json';
156
+ return (!isHidden || isCleverJson) && head !== workdir;
157
+ })
158
+ .length === 0;
159
+ return isStatusEmpty;
160
+ }
@@ -110,7 +110,7 @@ async function getIdsFromSummary () {
110
110
  * @param {{ orga_name?: string, orga_id?: string }} ownerNameOrId
111
111
  * @throws {Error} if no add-on is found
112
112
  * @throws {Error} if several add-ons are found
113
- * @returns {Object} The ID and owner ID of the add-on { addonId, ownerId }
113
+ * @returns {Object} The name, IDs and owner ID of the add-on { name, addonId, realId, ownerId }
114
114
  */
115
115
  export async function findAddonsByNameOrId (addonIdOrRealIdOrName, ownerNameOrId) {
116
116
  const summary = await getSummary().then(sendToApi);
@@ -128,7 +128,9 @@ export async function findAddonsByNameOrId (addonIdOrRealIdOrName, ownerNameOrId
128
128
  return matchOwner && matchAddon;
129
129
  })
130
130
  .map(({ addon, owner }) => ({
131
+ name: addon.name,
131
132
  addonId: addon.id,
133
+ realId: addon.realId,
132
134
  ownerId: owner.id,
133
135
  }));
134
136
 
@@ -139,3 +141,37 @@ export async function findAddonsByNameOrId (addonIdOrRealIdOrName, ownerNameOrId
139
141
 
140
142
  return candidates;
141
143
  }
144
+
145
+ /**
146
+ * Get the IDs and owners of found add-ons from a name, ID or real ID
147
+ * @param {string} addonIdOrRealIdOrName
148
+ * @throws {Error} if no add-on is found
149
+ * @throws {Error} if several add-ons are found
150
+ * @returns {Object} The name, IDs and owner ID of the add-on { name, addonId, realId, ownerId }
151
+ */
152
+ export async function findAddonsByAddonProvider (provider) {
153
+ const summary = await getSummary().then(sendToApi);
154
+
155
+ Logger.debug(`Searching for ${provider} add-ons in ${summary.user.id} and ${summary.organisations.map((org) => org.id).join(', ')}`);
156
+ const candidates = [summary.user, ...summary.organisations]
157
+ .flatMap((owner) => {
158
+ return owner.addons
159
+ .filter((addon) => addon.providerId === provider)
160
+ .map((addon) => {
161
+ return {
162
+ name: addon.name,
163
+ addonId: addon.id,
164
+ realId: addon.realId,
165
+ ownerId: owner.id,
166
+ ownerName: owner.name,
167
+ };
168
+ });
169
+ });
170
+
171
+ Logger.debug(`Found ${candidates.length} candidate(s) for provider ${provider}:`);
172
+ for (const candidate of candidates) {
173
+ Logger.debug(` - ${candidate.addonId} (${candidate.ownerId})`);
174
+ }
175
+
176
+ return candidates;
177
+ }
@@ -1,24 +0,0 @@
1
- import readline from 'node:readline';
2
-
3
- function ask (question) {
4
-
5
- const rl = readline.createInterface({
6
- input: process.stdin,
7
- output: process.stdout,
8
- });
9
-
10
- return new Promise((resolve) => {
11
- rl.question(question, (answer) => {
12
- rl.close();
13
- resolve(answer);
14
- });
15
- });
16
- }
17
-
18
- export async function confirm (question, rejectionMessage, expectedAnswers = ['yes', 'y']) {
19
- const answer = await ask(question);
20
- if (!expectedAnswers.includes(answer)) {
21
- throw new Error(rejectionMessage);
22
- }
23
- return true;
24
- }
@@ -6,6 +6,8 @@ import { waitForDeploymentEnd, waitForDeploymentStart } from './deployments.js';
6
6
  import { ApplicationLogStream } from '@clevercloud/client/esm/streams/application-logs.js';
7
7
  import { JsonArray } from './json-array.js';
8
8
  import * as ExitStrategy from '../models/exit-strategy-option.js';
9
+ import { getBest } from './domain.js';
10
+ import { conf } from './configuration.js';
9
11
 
10
12
  // 2000 logs per 100ms maximum
11
13
  const THROTTLE_ELEMENTS = 2000;
@@ -98,9 +100,11 @@ export async function watchDeploymentAndDisplayLogs (options) {
98
100
 
99
101
  ExitStrategy.plotQuietWarning(exitStrategy, quiet);
100
102
  // If in quiet mode, we only log start/finished deployment messages
101
- !quiet && Logger.println('Waiting for deployment to start…');
103
+ if (!quiet) {
104
+ Logger.println(` ${colors.blue('→ Waiting for deployment to start…')}`);
105
+ }
102
106
  const deployment = await waitForDeploymentStart({ ownerId, appId, deploymentId, commitId, knownDeployments });
103
- Logger.println(colors.bold.blue(`Deployment started (${deployment.uuid})`));
107
+ Logger.println(` ${colors.green(`✓ Deployment started ${colors.grey(`(${deployment.uuid})`)}`)}`);
104
108
 
105
109
  if (exitStrategy === 'deploy-start') {
106
110
  return;
@@ -119,7 +123,9 @@ export async function watchDeploymentAndDisplayLogs (options) {
119
123
  logsStream = await displayLogs({ ownerId, appId, deploymentId: deployment.uuid, since: redeployDate, deferred });
120
124
  }
121
125
 
122
- !quiet && Logger.println('Waiting for application logs…');
126
+ if (!quiet) {
127
+ Logger.println(` ${colors.blue('→ Waiting for application logs…')}`);
128
+ }
123
129
 
124
130
  // Wait for deployment end (or an error thrown by logs with the deferred)
125
131
  const deploymentEnded = await Promise.race([
@@ -132,7 +138,10 @@ export async function watchDeploymentAndDisplayLogs (options) {
132
138
  }
133
139
 
134
140
  if (deploymentEnded.state === 'OK') {
135
- Logger.println(colors.bold.green('Deployment successful'));
141
+ const favouriteDomain = await getBest(appId, ownerId);
142
+ Logger.println('');
143
+ Logger.println(`${colors.bold.green('✓ Access your application:')} ${colors.underline.bold(`https://${favouriteDomain.fqdn}`)}`);
144
+ Logger.println(`${colors.bold.blue('→ Manage your application:')} ${colors.underline.bold(`${conf.GOTO_URL}/${appId}`)}`);
136
145
  }
137
146
  else if (deploymentEnded.state === 'CANCELLED') {
138
147
  throw new Error('Deployment was cancelled. Please check the activity');
@@ -0,0 +1,48 @@
1
+ import colors from 'colors/safe.js';
2
+ import dedent from 'dedent';
3
+
4
+ import { sendToApi } from './send-to-api.js';
5
+ import { findAddonsByNameOrId } from './ids-resolver.js';
6
+ import { getOperator } from '../clever-client/operators.js';
7
+
8
+ /**
9
+ * Get the details of an operator from its name or ID
10
+ * @param {string} provider The operator's provider
11
+ * @param {object|string} operatorIdOrName The operator's ID or name
12
+ * @returns {Promise<object>} The operator's details
13
+ * @throws {Error} If the operator provider is unknown
14
+ */
15
+ export async function getDetails (provider, operatorIdOrName) {
16
+ const realId = await getSingleRealId(operatorIdOrName);
17
+ return getOperator({ provider, realId }).then(sendToApi);
18
+ }
19
+
20
+ /**
21
+ * Get the real ID of an operator from its name or ID
22
+ * @param {object|string} operatorIdOrName The operator's ID or name
23
+ * @returns {Promise<string>} The operator's real ID
24
+ * @throws {Error} If the operator is not found
25
+ * @throws {Error} If the operator name is ambiguous
26
+ */
27
+ export async function getSingleRealId (operatorIdOrName) {
28
+
29
+ if (operatorIdOrName.operator_id != null) {
30
+ return operatorIdOrName.operator_id;
31
+ }
32
+
33
+ const name = operatorIdOrName.addon_name ?? operatorIdOrName.addon_id;
34
+ const operators = await findAddonsByNameOrId(name);
35
+
36
+ if (operators.length === 0) {
37
+ throw new Error(`Could not find ${colors.red(name)}`);
38
+ }
39
+
40
+ if (operators.length > 1) {
41
+ throw new Error(dedent`
42
+ Ambiguous name ${colors.red(name)}, use the real ID instead:
43
+ ${colors.grey(operators.map((otoroshi) => `- ${otoroshi.name} (${otoroshi.realId})`).join('\n'))}
44
+ `);
45
+ }
46
+
47
+ return operators[0].realId;
48
+ }
@@ -1,3 +1,7 @@
1
+ import { Logger } from '../logger.js';
2
+ import { conf } from './configuration.js';
3
+ import openPage from 'open';
4
+
1
5
  // Inspirations:
2
6
  // https://github.com/sindresorhus/p-defer/blob/master/index.js
3
7
  // https://github.com/ljharb/promise-deferred/blob/master/index.js
@@ -13,6 +17,23 @@ export class Deferred {
13
17
  }
14
18
  }
15
19
 
20
+ /**
21
+ * Open an absolute URL or a console path in the default browser
22
+ * @param {string} urlOrPath The URL to open
23
+ * @param {string} message The message to display before opening the URL
24
+ * @returns {Promise<void>} A promise that resolves when the URL is opened
25
+ */
26
+ export function openBrowser (urlOrPath, message) {
27
+ const url = urlOrPath.startsWith('/')
28
+ ? `${conf.CONSOLE_URL}${urlOrPath}`
29
+ : urlOrPath;
30
+
31
+ Logger.debug(`Opening URL "${url}" in browser`);
32
+ Logger.println(message);
33
+
34
+ return openPage(url, { wait: false });
35
+ }
36
+
16
37
  export function truncateWithEllipsis (length, string) {
17
38
  if (string.length > length - 1) {
18
39
  return string.substring(0, length - 1) + '…';
package/src/parsers.js CHANGED
@@ -69,6 +69,16 @@ export function futureDateOrDuration (dateString) {
69
69
  return duration;
70
70
  }
71
71
 
72
+ // This simple regex is enough for our use cases
73
+ const emailRegex = /^\S+@\S+\.\S+$/g;
74
+
75
+ export function email (string) {
76
+ if (string.match(emailRegex)) {
77
+ return cliparse.parsers.success(string);
78
+ }
79
+ return cliparse.parsers.error('Invalid email');
80
+ }
81
+
72
82
  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;
73
83
 
74
84
  export function appIdOrName (string) {
@@ -88,11 +98,15 @@ export function orgaIdOrName (string) {
88
98
  }
89
99
 
90
100
  const addonIdRegex = /^addon_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
101
+ const operatorIdRegex = /^(keycloak|otoroshi|matomo|metabase)_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
91
102
 
92
103
  export function addonIdOrName (string) {
93
104
  if (string.match(addonIdRegex)) {
94
105
  return cliparse.parsers.success({ addon_id: string });
95
106
  }
107
+ if (string.match(operatorIdRegex)) {
108
+ return cliparse.parsers.success({ operator_id: string });
109
+ }
96
110
  return cliparse.parsers.success({ addon_name: string });
97
111
  }
98
112
 
@@ -1,10 +0,0 @@
1
- import { password } from '@inquirer/prompts';
2
-
3
- export function promptPassword (message) {
4
- return password({ message, mask: true }).catch((error) => {
5
- if (error instanceof Error && error.name === 'ExitPromptError') {
6
- process.exit(1);
7
- }
8
- throw error;
9
- });
10
- }