clever-tools 4.10.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/bin/clever.js +5 -10
  2. package/package.json +27 -27
  3. package/src/clever-client/drains.js +17 -0
  4. package/src/commands/accesslogs/accesslogs.command.js +35 -18
  5. package/src/commands/accesslogs/accesslogs.docs.md +1 -1
  6. package/src/commands/addon/addon.docs.md +2 -2
  7. package/src/commands/config-provider/config-provider.docs.md +5 -5
  8. package/src/commands/create/create.command.js +8 -1
  9. package/src/commands/database/database.docs.md +2 -2
  10. package/src/commands/deploy/deploy.docs.md +7 -6
  11. package/src/commands/drain/drain.args.js +6 -0
  12. package/src/commands/drain/drain.check.command.js +43 -0
  13. package/src/commands/drain/drain.create.betterstack.command.js +36 -0
  14. package/src/commands/drain/drain.create.command.js +0 -118
  15. package/src/commands/drain/drain.create.datadog.command.js +27 -0
  16. package/src/commands/drain/drain.create.elasticsearch.command.js +48 -0
  17. package/src/commands/drain/drain.create.newrelic.command.js +36 -0
  18. package/src/commands/drain/drain.create.ovh-tcp.command.js +29 -0
  19. package/src/commands/drain/drain.create.raw-http.command.js +30 -0
  20. package/src/commands/drain/drain.create.splunk.command.js +61 -0
  21. package/src/commands/drain/drain.create.syslog-tcp.command.js +29 -0
  22. package/src/commands/drain/drain.create.syslog-udp.command.js +29 -0
  23. package/src/commands/drain/drain.docs.md +296 -7
  24. package/src/commands/drain/drain.options.js +26 -0
  25. package/src/commands/features/features.list.command.js +6 -13
  26. package/src/commands/global.commands.js +25 -1
  27. package/src/commands/global.options.js +8 -0
  28. package/src/commands/k8s/k8s.docs.md +17 -17
  29. package/src/commands/keycloak/keycloak.docs.md +11 -11
  30. package/src/commands/kv/kv.docs.md +1 -1
  31. package/src/commands/link/link.docs.md +1 -1
  32. package/src/commands/login/login.command.js +11 -1
  33. package/src/commands/matomo/matomo.docs.md +6 -6
  34. package/src/commands/metabase/metabase.docs.md +9 -9
  35. package/src/commands/ng/ng.docs.md +10 -10
  36. package/src/commands/oauth-consumers/oauth-consumers.docs.md +4 -4
  37. package/src/commands/otoroshi/otoroshi.docs.md +13 -13
  38. package/src/commands/profile/profile.switch.command.js +5 -1
  39. package/src/commands/service/service.docs.md +4 -4
  40. package/src/commands/ssh/ssh.command.js +7 -1
  41. package/src/config/features.js +61 -29
  42. package/src/format-table.js +14 -10
  43. package/src/initial-setup.js +13 -0
  44. package/src/initial-update-notifier.js +2 -5
  45. package/src/lib/access-logs-clf.js +52 -0
  46. package/src/lib/access-logs-transport.js +29 -0
  47. package/src/lib/cliparse-patched.js +19 -1
  48. package/src/lib/define-command.types.d.ts +7 -2
  49. package/src/lib/k8s.js +1 -0
  50. package/src/lib/operator-commands.js +1 -0
  51. package/src/lib/profile.js +8 -1
  52. package/src/lib/prompts.js +30 -2
  53. package/src/models/drain.js +39 -12
  54. package/src/models/git-isomorphic.js +16 -1
  55. package/src/models/git-system.js +2 -2
  56. package/src/models/git.js +19 -1
  57. package/src/models/oauth-consumer.js +1 -1
  58. package/src/models/send-to-api.js +15 -0
@@ -31,7 +31,7 @@ clever service link-addon <addon-id|addon-name> [options]
31
31
 
32
32
  |Name|Description|
33
33
  |---|---|
34
- |`addon-id|addon-name`|Add-on ID (or name, if unambiguous)|
34
+ |`addon-id\|addon-name`|Add-on ID (or name, if unambiguous)|
35
35
 
36
36
  ### ⚙️ Options
37
37
 
@@ -52,7 +52,7 @@ clever service link-app <app-id|app-name> [options]
52
52
 
53
53
  |Name|Description|
54
54
  |---|---|
55
- |`app-id|app-name`|Application ID (or name, if unambiguous)|
55
+ |`app-id\|app-name`|Application ID (or name, if unambiguous)|
56
56
 
57
57
  ### ⚙️ Options
58
58
 
@@ -73,7 +73,7 @@ clever service unlink-addon <addon-id|addon-name> [options]
73
73
 
74
74
  |Name|Description|
75
75
  |---|---|
76
- |`addon-id|addon-name`|Add-on ID (or name, if unambiguous)|
76
+ |`addon-id\|addon-name`|Add-on ID (or name, if unambiguous)|
77
77
 
78
78
  ### ⚙️ Options
79
79
 
@@ -94,7 +94,7 @@ clever service unlink-app <app-id|app-name> [options]
94
94
 
95
95
  |Name|Description|
96
96
  |---|---|
97
- |`app-id|app-name`|Application ID (or name, if unambiguous)|
97
+ |`app-id\|app-name`|Application ID (or name, if unambiguous)|
98
98
 
99
99
  ### ⚙️ Options
100
100
 
@@ -45,7 +45,7 @@ export const sshCommand = defineCommand({
45
45
  let sshTarget;
46
46
  if (instances.length === 1) {
47
47
  sshTarget = instances[0].id;
48
- } else if (process.stdout.isTTY) {
48
+ } else if (process.stdin.isTTY) {
49
49
  const choices = instances
50
50
  .sort((a, b) => a.instanceNumber - b.instanceNumber)
51
51
  .map((inst) => ({
@@ -58,12 +58,18 @@ export const sshCommand = defineCommand({
58
58
  }
59
59
 
60
60
  const sshParams = [];
61
+ // Clever Cloud SSH only accepts key auth. Disable password fallback so a missing
62
+ // or unregistered key fails fast instead of prompting for a password that cannot work.
63
+ sshParams.push('-o', 'PreferredAuthentications=publickey', '-o', 'PasswordAuthentication=no');
61
64
  // -t: force PTY allocation (SSH skips it by default because appId is passed as a command for gateway routing)
62
65
  if (command == null) {
63
66
  sshParams.push('-t');
64
67
  }
65
68
  if (identityFile != null) {
69
+ // -i adds the key to the ones ssh may offer, but doesn't guarantee it's the one used
66
70
  sshParams.push('-i', identityFile);
71
+ // IdentitiesOnly forces ssh to only offer this key, ignoring agent/default ones
72
+ sshParams.push('-o', 'IdentitiesOnly=yes');
67
73
  }
68
74
  sshParams.push(config.SSH_GATEWAY, sshTarget);
69
75
 
@@ -1,26 +1,40 @@
1
1
  import dedent from 'dedent';
2
2
  import z from 'zod';
3
- import { readJson, writeJson } from '../lib/fs.js';
3
+ import { readJsonSync, writeJson } from '../lib/fs.js';
4
4
  import { Logger } from '../logger.js';
5
5
  import { config } from './config.js';
6
6
  import { getConfigPath } from './paths.js';
7
7
 
8
8
  const EXPERIMENTAL_FEATURES_FILEPATH = getConfigPath('clever-tools-experimental-features.json');
9
9
 
10
+ /**
11
+ * @typedef {object} ExperimentalFeature
12
+ * @property {'beta'|'stable'} status
13
+ * @property {boolean} defaultValue - Value used when the feature is not explicitly set by the user
14
+ * @property {string} description
15
+ * @property {string} [instructions]
16
+ */
17
+
18
+ /** @type {Record<string, ExperimentalFeature>} */
10
19
  export const EXPERIMENTAL_FEATURES = {
11
20
  'system-git': {
12
- status: 'beta',
13
- description: 'Use system git instead of current JS implementation for git operations',
21
+ status: 'stable',
22
+ defaultValue: true,
23
+ description: 'Use system git instead of the pure JS implementation for git operations',
14
24
  instructions: dedent`
15
- This feature switches from the current JS implementation to using
16
- the git installed on your system.
25
+ This feature switches from the pure JS implementation to using
26
+ the git installed on your system. It is enabled by default since v5.0.0.
17
27
 
18
28
  Requirements:
19
29
  - git must be installed and available in your PATH
30
+
31
+ Disable it to fall back to the previous pure JS implementation:
32
+ clever features disable system-git
20
33
  `,
21
34
  },
22
35
  k8s: {
23
36
  status: 'beta',
37
+ defaultValue: false,
24
38
  description: 'Deploy and manage Kubernetes clusters on Clever Cloud',
25
39
  instructions: dedent`
26
40
  - Create a Kubernetes cluster:
@@ -48,6 +62,7 @@ export const EXPERIMENTAL_FEATURES = {
48
62
  },
49
63
  kv: {
50
64
  status: 'beta',
65
+ defaultValue: false,
51
66
  description:
52
67
  'Send commands to databases such as Materia KV or Redis® directly from Clever Tools, without other dependencies',
53
68
  instructions: dedent`
@@ -64,6 +79,7 @@ export const EXPERIMENTAL_FEATURES = {
64
79
  },
65
80
  ng: {
66
81
  status: 'beta',
82
+ defaultValue: false,
67
83
  description: 'Manage Network Groups to manage applications, add-ons, external peers through a WireGuard network',
68
84
  instructions: dedent`
69
85
  - Create a Network Group:
@@ -92,6 +108,7 @@ export const EXPERIMENTAL_FEATURES = {
92
108
  },
93
109
  operators: {
94
110
  status: 'beta',
111
+ defaultValue: false,
95
112
  description: 'Manage operators and their features such as Keycloak, Matomo, Metabase, Otoroshi',
96
113
  instructions: dedent`
97
114
  clever keycloak
@@ -116,52 +133,67 @@ const FeaturesConfigSchema = z
116
133
  */
117
134
 
118
135
  /**
119
- * Gets the current experimental features configuration.
120
- * Returns an empty object if the features file doesn't exist or is invalid.
121
- * @returns {Promise<FeaturesConfig>} The features configuration object
136
+ * The features explicitly set by the user, loaded synchronously at startup.
137
+ * @type {FeaturesConfig}
122
138
  */
123
- export async function getFeatures() {
124
- Logger.debug(`Get features configuration from ${EXPERIMENTAL_FEATURES_FILEPATH}`);
125
- try {
126
- const rawFeatures = await readJson(EXPERIMENTAL_FEATURES_FILEPATH);
127
- const parsed = FeaturesConfigSchema.safeParse(rawFeatures);
128
- if (!parsed.success) {
129
- Logger.info(`Invalid features format in ${EXPERIMENTAL_FEATURES_FILEPATH}`);
130
- return {};
131
- }
132
- return parsed.data;
133
- } catch (error) {
134
- if (error.code !== 'ENOENT') {
135
- throw new Error(`Cannot get experimental features configuration from ${EXPERIMENTAL_FEATURES_FILEPATH}`);
136
- }
139
+ let userFeatures = loadFeatures();
140
+
141
+ /**
142
+ * Reads and parses the features file.
143
+ * Returns an empty object if the file doesn't exist or is invalid.
144
+ * @returns {FeaturesConfig} The features configuration object
145
+ */
146
+ function loadFeatures() {
147
+ Logger.debug(`Load features configuration from ${EXPERIMENTAL_FEATURES_FILEPATH}`);
148
+ const rawFeatures = readJsonSync(EXPERIMENTAL_FEATURES_FILEPATH);
149
+ if (rawFeatures == null) {
150
+ return {};
151
+ }
152
+ const parsed = FeaturesConfigSchema.safeParse(rawFeatures);
153
+ if (!parsed.success) {
154
+ Logger.info(`Invalid features format in ${EXPERIMENTAL_FEATURES_FILEPATH}`);
137
155
  return {};
138
156
  }
157
+ return parsed.data;
139
158
  }
140
159
 
141
160
  /**
142
161
  * Sets an experimental feature to the specified value.
143
- * Creates the configuration directory and features file if they don't exist.
162
+ * Creates the configuration directory and features file if they don't exist,
163
+ * then reloads the in-memory configuration.
144
164
  * @param {string} feature - The name of the feature to set
145
165
  * @param {boolean} value - The value to set for the feature
146
166
  * @returns {Promise<void>}
147
167
  * @throws {Error} If the features file cannot be written
148
168
  */
149
169
  export async function setFeature(feature, value) {
150
- const currentFeatures = await getFeatures();
151
- const newFeatures = { ...currentFeatures, [feature]: value };
170
+ const newFeatures = { ...userFeatures, [feature]: value };
152
171
  try {
153
172
  await writeJson(EXPERIMENTAL_FEATURES_FILEPATH, newFeatures, { mode: 0o700 });
154
173
  } catch (error) {
155
174
  throw new Error(`Cannot write experimental features configuration to ${EXPERIMENTAL_FEATURES_FILEPATH}`);
156
175
  }
176
+ userFeatures = loadFeatures();
157
177
  }
158
178
 
159
179
  /**
160
180
  * Checks if an experimental feature is enabled.
181
+ * Falls back to the feature's `defaultValue` when the user hasn't set it explicitly.
161
182
  * @param {string} feature - The name of the feature to check
162
- * @returns {Promise<boolean>} True if the feature is explicitly enabled, false otherwise
183
+ * @returns {boolean} True if the feature is enabled
184
+ */
185
+ export function isFeatureEnabled(feature) {
186
+ return userFeatures[feature] ?? EXPERIMENTAL_FEATURES[feature]?.defaultValue ?? false;
187
+ }
188
+
189
+ /**
190
+ * Lists all experimental features with their current status.
191
+ * @returns {Array<{ id: string, status: string, description: string, instructions?: string, enabled: boolean }>}
163
192
  */
164
- export async function isFeatureEnabled(feature) {
165
- const features = await getFeatures();
166
- return features[feature] === true;
193
+ export function getAllFeatures() {
194
+ return Object.entries(EXPERIMENTAL_FEATURES).map(([id, feature]) => ({
195
+ id,
196
+ ...feature,
197
+ enabled: isFeatureEnabled(id),
198
+ }));
167
199
  }
@@ -36,16 +36,20 @@ export const formatTable = (data, columnWidth = []) => {
36
36
  return data
37
37
  .map((row) => row.map((cell) => String(cell)))
38
38
  .map((row) => {
39
- return row
40
- .map((cell, index) => {
41
- const isLastColumn = index === row.length - 1;
42
- if (isLastColumn) {
43
- return cell;
44
- }
45
- const rightPaddingLength = columnSizes[index] - stringLength(cell) ?? 0;
46
- return cell + ' '.repeat(rightPaddingLength);
47
- })
48
- .join(SEPARATOR);
39
+ return (
40
+ row
41
+ .map((cell, index) => {
42
+ const isLastColumn = index === row.length - 1;
43
+ if (isLastColumn) {
44
+ return cell;
45
+ }
46
+ const rightPaddingLength = columnSizes[index] - stringLength(cell) ?? 0;
47
+ return cell + ' '.repeat(rightPaddingLength);
48
+ })
49
+ .join(SEPARATOR)
50
+ // empty trailing cells would otherwise pad the line with meaningless spaces
51
+ .trimEnd()
52
+ );
49
53
  })
50
54
  .join('\n');
51
55
  };
@@ -1,5 +1,18 @@
1
+ import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
1
2
  import { hasParam } from './lib/has-param.js';
2
3
 
4
+ // Node's global fetch() ignores the http_proxy/https_proxy environment variables,
5
+ // unlike most CLI tools (curl, kubectl, s3cmd…). When such a variable is set, install
6
+ // an EnvHttpProxyAgent as the global dispatcher so every fetch() (API calls, update
7
+ // checks…) routes through the proxy and honors no_proxy for exclusions. We only swap
8
+ // the dispatcher when a proxy is actually configured, leaving the default behavior
9
+ // untouched for everyone else.
10
+ const hasProxyConfig =
11
+ process.env.http_proxy || process.env.https_proxy || process.env.HTTP_PROXY || process.env.HTTPS_PROXY;
12
+ if (hasProxyConfig) {
13
+ setGlobalDispatcher(new EnvHttpProxyAgent());
14
+ }
15
+
3
16
  // These need to be set before Logger and other stuffs
4
17
  if (hasParam('-v') || hasParam('--verbose')) {
5
18
  process.env.CLEVER_VERBOSE = '1';
@@ -5,14 +5,11 @@ import { hasParam } from './lib/has-param.js';
5
5
  // These need to be set before Logger and other stuffs
6
6
  const updateNotifierExplicitFalse = hasParam('--no-update-notifier') || hasParam('--update-notifier', 'false');
7
7
  if (!updateNotifierExplicitFalse) {
8
+ const docsUrl = 'https://github.com/CleverCloud/clever-tools/blob/master/docs/update.md';
8
9
  updateNotifierModule({
9
10
  pkg,
10
- tagsUrl: 'https://api.github.com/repos/CleverCloud/clever-tools/tags',
11
11
  }).notify({
12
12
  isGlobal: true,
13
- getDetails() {
14
- const docsUrl = 'https://github.com/CleverCloud/clever-tools/tree/master/docs#how-to-use-clever-tools';
15
- return `\nPlease follow this link to update your clever-tools:\n${docsUrl}`;
16
- },
13
+ message: `Update available {currentVersion} -> {latestVersion}\nPlease follow this link to update your clever-tools:\n${docsUrl}`,
17
14
  });
18
15
  }
@@ -0,0 +1,52 @@
1
+ const CLF_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
2
+
3
+ /**
4
+ * Format a `Date` as a Common Log Format timestamp in UTC, e.g. `10/Oct/2000:13:55:36 +0000`.
5
+ * @param {Date} date
6
+ * @returns {string}
7
+ */
8
+ function formatClfDate(date) {
9
+ const pad = (n) => String(n).padStart(2, '0');
10
+ const day = pad(date.getUTCDate());
11
+ const month = CLF_MONTHS[date.getUTCMonth()];
12
+ const year = date.getUTCFullYear();
13
+ const hours = pad(date.getUTCHours());
14
+ const minutes = pad(date.getUTCMinutes());
15
+ const seconds = pad(date.getUTCSeconds());
16
+ return `${day}/${month}/${year}:${hours}:${minutes}:${seconds} +0000`;
17
+ }
18
+
19
+ /**
20
+ * Escape a value so it can be safely embedded inside a double-quoted CLF field.
21
+ * Backslashes are escaped first, then double-quotes, otherwise a `"` would be escaped
22
+ * twice. Without this, a `"` in the value would close the quoted field early and shift
23
+ * every subsequent column.
24
+ * @param {string} value
25
+ * @returns {string}
26
+ */
27
+ function escapeClfQuoted(value) {
28
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
29
+ }
30
+
31
+ /**
32
+ * Format an HTTP access log as a Common Log Format line.
33
+ *
34
+ * The HTTP protocol version is absent from the v4 access log payload, so the request line is
35
+ * limited to `method path` (no `HTTP/x.y` token). The protocol token is optional in CLF parsers
36
+ * (grok, GoAccess), and emitting a `-` placeholder would actually break their structured parsing.
37
+ *
38
+ * @see https://en.wikipedia.org/wiki/Common_Log_Format
39
+ * @param {object} log an HTTP access log (the `http` section must be present)
40
+ * @returns {string}
41
+ */
42
+ export function formatClf(log) {
43
+ const host = log.source.ip;
44
+ const ident = '-';
45
+ const authuser = '-';
46
+ const date = formatClfDate(log.date);
47
+ const request = `${log.http.request.method} ${escapeClfQuoted(log.http.request.path)}`;
48
+ const status = log.http.response.statusCode;
49
+ const bytes = log.bytesOut;
50
+
51
+ return `${host} ${ident} ${authuser} [${date}] "${request}" ${status} ${bytes}`;
52
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Transport used by the connection an access log describes.
3
+ * @typedef {'HTTP'|'TCP'|'SSH'} AccessLogTransport
4
+ */
5
+
6
+ /**
7
+ * Guess the transport of an access log.
8
+ *
9
+ * The v4 access log payload has no explicit transport field yet, so it is inferred from the
10
+ * sections the API did send:
11
+ *
12
+ * - an `http` section is only emitted for connections that went through the HTTP reverse proxy,
13
+ * - TCP redirections go through the TCP proxy, which assigns a `requestId` but no `http` section,
14
+ * - direct SSH connections to the instance go through neither, so they carry neither field.
15
+ *
16
+ * Drop this function once the API exposes the transport itself.
17
+ *
18
+ * @param {object} log an access log, as emitted by `ApplicationAccessLogStream`
19
+ * @returns {AccessLogTransport}
20
+ */
21
+ export function guessAccessLogTransport(log) {
22
+ if (log.http != null) {
23
+ return 'HTTP';
24
+ }
25
+ if (log.requestId != null) {
26
+ return 'TCP';
27
+ }
28
+ return 'SSH';
29
+ }
@@ -1,4 +1,5 @@
1
1
  import cliparseOriginal from 'cliparse';
2
+ import cliparseArgumentModule from 'cliparse/src/argument.js';
2
3
  import cliparseCommandModule from 'cliparse/src/command.js';
3
4
  import semver from 'semver';
4
5
  import pkg from '../../package.json' with { type: 'json' };
@@ -33,6 +34,23 @@ cliparseOriginal.command = function (name, options, commandFunction) {
33
34
  return command;
34
35
  };
35
36
 
37
+ // Patch cliparse.argument.parseList to drop the parse results that succeeded
38
+ // from its error payload. When several positional args are given and only one
39
+ // fails its parser, parseList returns the *whole* result list (successes
40
+ // included) as the error. Downstream, displayErrors prints the valid siblings
41
+ // as `<arg-name>: undefined` (they carry an `.argument` but no `.error`).
42
+ // We keep only the entries that actually failed, fixing the issue at its root.
43
+ // The success path is untouched (we only rewrite the error array), and
44
+ // missing-value errors carry an `.error` ("missing value") so they are kept.
45
+ const originalParseList = cliparseArgumentModule.parseList;
46
+ cliparseArgumentModule.parseList = function (args, providedArguments) {
47
+ const result = originalParseList(args, providedArguments);
48
+ if (Array.isArray(result.error)) {
49
+ return cliparseOriginal.parsers.error(result.error.filter((entry) => entry.error != null));
50
+ }
51
+ return result;
52
+ };
53
+
36
54
  /**
37
55
  * Map options from cliparse format (using option.name) to the original definition keys.
38
56
  * For example, { theOptionName: defineOption({ name: 'the-option-name', ... }) },
@@ -161,7 +179,7 @@ function wrapParser(parser) {
161
179
  // instead of cliparse.autocomplete.words(array)
162
180
  function wrapComplete(complete) {
163
181
  return (word) => {
164
- // Support both functions and static arrays (e.g. complete: Drain.DRAIN_TYPE_CLI_CODES)
182
+ // Support both functions and static arrays (e.g. complete: ['human', 'json'])
165
183
  const result = typeof complete === 'function' ? complete(word) : complete;
166
184
  return Promise.resolve(result).then(cliparse.autocomplete.words);
167
185
  };
@@ -41,8 +41,13 @@ export interface CommandDefinition<
41
41
  */
42
42
  featureFlag?: string;
43
43
 
44
- /** Version when this command was introduced (semver format, e.g., '2.1.0'). */
45
- since?: `${number}.${number}.${number}`;
44
+ /**
45
+ * Version when this command was introduced (semver format, e.g., '2.1.0').
46
+ * Use `null` for a command not yet released: the release workflow resolves it
47
+ * to the upcoming version (see `scripts/resolve-since.js`). Required on purpose,
48
+ * so a missing value is a type error rather than a silent omission.
49
+ */
50
+ since: `${number}.${number}.${number}` | null;
46
51
 
47
52
  /** Options (named options like --type, --region, --format). */
48
53
  options?: O;
package/src/lib/k8s.js CHANGED
@@ -503,6 +503,7 @@ export async function k8sUpdateVersion(orgIdOrName, clusterIdOrName, askedVersio
503
503
  (await selectAnswer(
504
504
  `Which version do you want to update ${styleText('blue', name)} to, current is ${styleText('blue', versions.installed)}?`,
505
505
  [...versions.available].reverse(),
506
+ 'Use --target <version> to update directly.',
506
507
  ));
507
508
 
508
509
  if (!versions.available.includes(targetVersion)) {
@@ -77,6 +77,7 @@ export async function operatorUpdateVersion(provider, askedVersion, addonIdOrNam
77
77
  (await selectAnswer(
78
78
  `Which version do you want to update ${styleText('blue', name)} to, current is ${styleText('blue', versions.installed)}?`,
79
79
  versions.available.reverse(),
80
+ 'Use --target <version> to update directly.',
80
81
  ));
81
82
 
82
83
  if (!versions.available.includes(targetVersion)) {
@@ -59,7 +59,14 @@ export async function getProfileDetails({ profile, isActive }) {
59
59
  const [user, token] = await Promise.all([
60
60
  getUser({}).then(sendWithCredentials),
61
61
  getCurrentTokenInfo().then(sendWithCredentials),
62
- ]).catch(() => [null, null]);
62
+ ]).catch((error) => {
63
+ // An expired/invalid token surfaces as a 401: degrade gracefully so the command can report it.
64
+ // Any other failure (TLS, network…) must bubble up instead of being masked as "token invalid".
65
+ if (error?.cause?.response?.status === 401) {
66
+ return [null, null];
67
+ }
68
+ throw error;
69
+ });
63
70
 
64
71
  return {
65
72
  id: user?.id ?? profile.userId,
@@ -25,11 +25,23 @@ export async function confirmAnswer(message, rejectionMessage, expectedAnswer) {
25
25
  }
26
26
  }
27
27
 
28
- export function selectAnswer(message, choices) {
28
+ /**
29
+ * @param {string} message
30
+ * @param {Array<unknown>} choices
31
+ * @param {string} [nonInteractiveHint] How to achieve the same result without a prompt (e.g. an option to use)
32
+ */
33
+ export function selectAnswer(message, choices, nonInteractiveHint) {
34
+ assertInteractiveTerminal(nonInteractiveHint);
29
35
  return select({ message, choices }).catch(exitOnPromptError);
30
36
  }
31
37
 
32
- export function promptCheckbox(message, choices) {
38
+ /**
39
+ * @param {string} message
40
+ * @param {Array<unknown>} choices
41
+ * @param {string} [nonInteractiveHint] How to achieve the same result without a prompt (e.g. an option to use)
42
+ */
43
+ export function promptCheckbox(message, choices, nonInteractiveHint) {
44
+ assertInteractiveTerminal(nonInteractiveHint);
33
45
  return checkbox({ message, choices }).catch(exitOnPromptError);
34
46
  }
35
47
 
@@ -50,6 +62,22 @@ export function promptTextOption(option, defaultValue) {
50
62
  return input({ message, default: defaultValue, validate }).catch(exitOnPromptError);
51
63
  }
52
64
 
65
+ /**
66
+ * Throw an explicit error when no interactive terminal is available.
67
+ *
68
+ * Raw-mode prompts (select, checkbox) need a TTY on stdin. Without one (CI, scripts,
69
+ * pipes, `< /dev/null`), Inquirer reads EOF and throws ExitPromptError, which
70
+ * exitOnPromptError turns into a silent `process.exit(1)` — indistinguishable from a
71
+ * user pressing Ctrl+C. Guarding up front lets us surface a clear message instead.
72
+ * @param {string} [hint] How to achieve the same result without a prompt (e.g. an option to use)
73
+ */
74
+ function assertInteractiveTerminal(hint) {
75
+ if (!process.stdin.isTTY) {
76
+ const suffix = hint != null ? ` ${hint}` : '';
77
+ throw new Error(`This command requires an interactive terminal.${suffix}`);
78
+ }
79
+ }
80
+
53
81
  function exitOnPromptError(error) {
54
82
  if (error instanceof Error && error.name === 'ExitPromptError') {
55
83
  process.exit(1);
@@ -1,5 +1,7 @@
1
+ import { createDrain } from '../clever-client/drains.js';
1
2
  import * as Application from './application.js';
2
3
  import { resolveAddon } from './ids-resolver.js';
4
+ import { sendToApi } from './send-to-api.js';
3
5
 
4
6
  export async function resolveDrainResource(alias, appIdOrName, addonIdOrRealId) {
5
7
  if (addonIdOrRealId != null && (appIdOrName != null || alias != null)) {
@@ -15,17 +17,37 @@ export async function resolveDrainResource(alias, appIdOrName, addonIdOrRealId)
15
17
  return { ownerId, resourceId: appId };
16
18
  }
17
19
 
18
- export const DRAIN_TYPES = {
19
- DATADOG: { apiCode: 'DATADOG', cliCode: 'datadog', label: 'Datadog' },
20
- ELASTICSEARCH: { apiCode: 'ELASTICSEARCH', cliCode: 'elasticsearch', label: 'Elasticsearch' },
21
- NEWRELIC: { apiCode: 'NEWRELIC', cliCode: 'newrelic', label: 'New Relic' },
22
- OVH_TCP: { apiCode: 'OVH_TCP', cliCode: 'ovh-tcp', label: 'OVH TCP' },
23
- RAW_HTTP: { apiCode: 'RAW_HTTP', cliCode: 'raw-http', label: 'Raw HTTP' },
24
- SYSLOG_TCP: { apiCode: 'SYSLOG_TCP', cliCode: 'syslog-tcp', label: 'Syslog TCP' },
25
- SYSLOG_UDP: { apiCode: 'SYSLOG_UDP', cliCode: 'syslog-udp', label: 'Syslog UDP' },
26
- };
20
+ /**
21
+ * Creates a log drain, the recipient options that are not set are left out of the payload.
22
+ * @param {string} type - Drain type, as expected by the API
23
+ * @param {string} ownerId
24
+ * @param {string} resourceId
25
+ * @param {string} url - Drain URL
26
+ * @param {Record<string, unknown>} [recipientOptions] - Extra recipient fields, `null` and `undefined` ones are ignored
27
+ */
28
+ export function createLogDrain(type, ownerId, resourceId, url, recipientOptions = {}) {
29
+ const body = { kind: 'LOG', recipient: { type, url } };
30
+
31
+ for (const key in recipientOptions) {
32
+ if (recipientOptions[key] != null) {
33
+ body.recipient[key] = recipientOptions[key];
34
+ }
35
+ }
36
+
37
+ return createDrain({ ownerId, resourceId, body }).then(sendToApi);
38
+ }
27
39
 
28
- export const DRAIN_TYPE_CLI_CODES = Object.values(DRAIN_TYPES).map(({ cliCode }) => cliCode);
40
+ export const DRAIN_TYPE_LABELS = {
41
+ BETTERSTACK: 'Better Stack',
42
+ DATADOG: 'Datadog',
43
+ ELASTICSEARCH: 'Elasticsearch',
44
+ NEWRELIC: 'New Relic',
45
+ OVH_TCP: 'OVH TCP',
46
+ RAW_HTTP: 'Raw HTTP',
47
+ SPLUNK: 'Splunk',
48
+ SYSLOG_TCP: 'Syslog TCP',
49
+ SYSLOG_UDP: 'Syslog UDP',
50
+ };
29
51
 
30
52
  function formatRate(messagesPerSecond) {
31
53
  if (messagesPerSecond < 1) {
@@ -48,14 +70,19 @@ function formatThroughput(bytesPerSecond) {
48
70
  }
49
71
 
50
72
  export function formatDrain(rawDrain) {
51
- const drainType = DRAIN_TYPES[rawDrain.recipient.type];
52
73
  const drainDetails = [
53
74
  ['ID', rawDrain.id],
54
75
  ['Status', rawDrain.status.status],
55
76
  ['Execution status', rawDrain.execution.status],
56
77
  ['URL', rawDrain.recipient.url],
57
- ['Type', drainType.label],
78
+ ['Type', DRAIN_TYPE_LABELS[rawDrain.recipient.type]],
58
79
  ['Custom index', rawDrain.recipient.index],
80
+ ['Sourcetype', rawDrain.recipient.sourcetype],
81
+ // DEFAULT is the implicit norm, only a relaxed verification is worth showing
82
+ [
83
+ 'TLS verification',
84
+ rawDrain.recipient.tlsVerification === 'TRUSTFUL' ? 'Trustful (certificate not verified)' : null,
85
+ ],
59
86
  ['SD parameters', rawDrain.recipient.rfc5424StructuredDataParameters],
60
87
  ['Message output rate', formatRate(rawDrain.backlog.msgRateOut)],
61
88
  ['Message throughput', formatThroughput(rawDrain.backlog.msgThroughputOut)],
@@ -13,6 +13,9 @@ export class GitIsomorphic extends Git {
13
13
 
14
14
  async #getRepo() {
15
15
  const dir = await this._getRepoDir();
16
+ if (await this._isLinkedWorktree(dir)) {
17
+ throw new LinkedWorktreeNotSupportedError();
18
+ }
16
19
  return { fs, dir, http };
17
20
  }
18
21
 
@@ -128,7 +131,8 @@ export class GitIsomorphic extends Git {
128
131
  */
129
132
  async isInsideGitRepo() {
130
133
  this._debug('isInsideGitRepo');
131
- return this.#getRepo()
134
+ // Don't go through #getRepo: it rejects linked worktrees, which are still git repos
135
+ return this._getRepoDir()
132
136
  .then(() => true)
133
137
  .catch(() => false);
134
138
  }
@@ -151,3 +155,14 @@ export class GitIsomorphic extends Git {
151
155
  return isStatusEmpty;
152
156
  }
153
157
  }
158
+
159
+ export class LinkedWorktreeNotSupportedError extends Error {
160
+ constructor() {
161
+ super(
162
+ "Linked git worktrees aren't supported by the default JS git backend.\n" +
163
+ 'Enable the system git backend (it uses your installed git):\n' +
164
+ ' clever features enable system-git',
165
+ );
166
+ this.name = 'LinkedWorktreeNotSupportedError';
167
+ }
168
+ }
@@ -167,8 +167,8 @@ export class GitSystem extends Git {
167
167
  class GitNotFoundError extends Error {
168
168
  constructor() {
169
169
  super(
170
- 'The system git feature requires git to be installed and available in your PATH\n' +
171
- 'Either install git or disable this feature with: clever features disable system-git',
170
+ 'git was not found in your PATH\n' +
171
+ 'Either install git, or fall back to the previous JS implementation with: clever features disable system-git',
172
172
  );
173
173
  this.name = 'GitNotFoundError';
174
174
  }