clever-tools 4.6.1 → 4.7.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clever-tools",
3
- "version": "4.6.1",
3
+ "version": "4.7.1",
4
4
  "description": "Command Line Interface for Clever Cloud.",
5
5
  "keywords": [
6
6
  "cli",
@@ -1,6 +1,7 @@
1
1
  import { getBackups } from '@clevercloud/client/esm/api/v2/backups.js';
2
2
  import fs from 'node:fs';
3
- import { Writable } from 'node:stream';
3
+ import { Readable } from 'node:stream';
4
+ import { pipeline } from 'node:stream/promises';
4
5
  import { z } from 'zod';
5
6
  import { defineArgument } from '../../lib/define-argument.js';
6
7
  import { defineCommand } from '../../lib/define-command.js';
@@ -50,6 +51,7 @@ export const databaseBackupsDownloadCommand = defineCommand({
50
51
  throw new Error('Failed to download backup');
51
52
  }
52
53
 
53
- await response.body.pipeTo(Writable.toWeb(output ? fs.createWriteStream(output) : process.stdout));
54
+ const nodeReadable = Readable.fromWeb(response.body);
55
+ await pipeline(nodeReadable, output ? fs.createWriteStream(output) : process.stdout);
54
56
  },
55
57
  });
@@ -55,7 +55,7 @@ export const drainCreateCommand = defineCommand({
55
55
  args: [
56
56
  defineArgument({
57
57
  schema: z.string(),
58
- description: 'No description available',
58
+ description: `Drain type (${DRAIN_TYPE_CLI_CODES.join(', ')})`,
59
59
  placeholder: 'drain-type',
60
60
  complete: DRAIN_TYPE_CLI_CODES,
61
61
  }),
@@ -28,7 +28,7 @@ clever drain create <drain-type> <drain-url> [options]
28
28
 
29
29
  |Name|Description|
30
30
  |---|---|
31
- |`drain-type`|No description available|
31
+ |`drain-type`|Drain type (datadog, elasticsearch, newrelic, ovh-tcp, raw-http, syslog-tcp, syslog-udp)|
32
32
  |`drain-url`|Drain URL|
33
33
 
34
34
  ### ⚙️ Options
@@ -1,9 +1,13 @@
1
+ import { getAllInstances } from '@clevercloud/client/esm/api/v2/application.js';
1
2
  import { spawn } from 'node:child_process';
3
+ import { randomUUID } from 'node:crypto';
2
4
  import { z } from 'zod';
3
5
  import { config } from '../../config/config.js';
4
6
  import { defineCommand } from '../../lib/define-command.js';
5
7
  import { defineOption } from '../../lib/define-option.js';
8
+ import { selectAnswer } from '../../lib/prompts.js';
6
9
  import * as Application from '../../models/application.js';
10
+ import { sendToApi } from '../../models/send-to-api.js';
7
11
  import { aliasOption, appIdOrNameOption } from '../global.options.js';
8
12
 
9
13
  export const sshCommand = defineCommand({
@@ -17,24 +21,102 @@ export const sshCommand = defineCommand({
17
21
  aliases: ['i'],
18
22
  placeholder: 'identity-file',
19
23
  }),
24
+ command: defineOption({
25
+ name: 'command',
26
+ schema: z.string().optional(),
27
+ description: 'Execute a command on the remote instance and exit',
28
+ aliases: ['c'],
29
+ placeholder: 'command',
30
+ }),
20
31
  alias: aliasOption,
21
32
  app: appIdOrNameOption,
22
33
  },
23
34
  args: [],
24
35
  async handler(options) {
25
- const { alias, app: appIdOrName, identityFile } = options;
36
+ const { alias, app: appIdOrName, identityFile, command } = options;
37
+ const { appId, ownerId } = await Application.resolveId(appIdOrName, alias);
38
+
39
+ const instances = await getAllInstances({ id: ownerId, appId }).then(sendToApi);
40
+
41
+ if (instances.length === 0) {
42
+ throw new Error('No running instances found for this application');
43
+ }
44
+
45
+ let sshTarget;
46
+ if (instances.length === 1) {
47
+ sshTarget = instances[0].id;
48
+ } else if (process.stdout.isTTY) {
49
+ const choices = instances
50
+ .sort((a, b) => a.instanceNumber - b.instanceNumber)
51
+ .map((inst) => ({
52
+ name: `${inst.displayName} - Instance ${inst.instanceNumber} - ${inst.state} (${inst.id})`,
53
+ value: inst.id,
54
+ }));
55
+ sshTarget = await selectAnswer('Select an instance:', choices);
56
+ } else {
57
+ throw new Error('Multiple instances are running. Cannot select in non-interactive mode.');
58
+ }
26
59
 
27
- const { appId } = await Application.resolveId(appIdOrName, alias);
28
- const sshParams = ['-t', config.SSH_GATEWAY, appId];
60
+ const sshParams = [];
61
+ // -t: force PTY allocation (SSH skips it by default because appId is passed as a command for gateway routing)
62
+ if (command == null) {
63
+ sshParams.push('-t');
64
+ }
29
65
  if (identityFile != null) {
30
66
  sshParams.push('-i', identityFile);
31
67
  }
68
+ sshParams.push(config.SSH_GATEWAY, sshTarget);
32
69
 
33
- await new Promise((resolve, reject) => {
34
- // TODO: we should catch errors
35
- const sshProcess = spawn('ssh', sshParams, { stdio: 'inherit' });
36
- sshProcess.on('exit', resolve);
37
- sshProcess.on('error', reject);
70
+ // Interactive session mode (spawn SSH with inherited stdio)
71
+ if (command == null) {
72
+ return new Promise((resolve, reject) => {
73
+ const sshProcess = spawn('ssh', sshParams, { stdio: 'inherit' });
74
+ sshProcess.on('exit', resolve);
75
+ sshProcess.on('error', reject);
76
+ });
77
+ }
78
+
79
+ // Single command mode (pipe stdio to filter gateway noise via a marker)
80
+ const sshProcess = spawn('ssh', sshParams, { stdio: 'pipe' });
81
+
82
+ // We can't pass the command directly via `ssh gateway 'cmd'` because appId already occupies
83
+ // the remote command slot (used by the gateway for routing). So we write into stdin and use
84
+ // a marker to delimit the start of real output from gateway/login noise.
85
+ const marker = `__CLEVER_${randomUUID()}__`;
86
+ sshProcess.stdin.write(`echo '${marker}'\n`);
87
+
88
+ // `exec $SHELL --login -c` ensures the full login environment is loaded (.bashrc, env vars)
89
+ // while keeping stdout clean (no PTY = no prompt/ANSI noise).
90
+ const escapedCommand = command.replaceAll("'", "'\\''");
91
+ sshProcess.stdin.write(`exec $SHELL --login -c '${escapedCommand}'\n`);
92
+ sshProcess.stdin.end();
93
+
94
+ // Skip gateway/login noise on both stdout and stderr, stream after the marker
95
+ let started = false;
96
+ let buf = '';
97
+ sshProcess.stdout.on('data', (chunk) => {
98
+ if (started) {
99
+ process.stdout.write(chunk);
100
+ return;
101
+ }
102
+ buf += chunk.toString();
103
+ const idx = buf.indexOf(marker + '\n');
104
+ if (idx !== -1) {
105
+ started = true;
106
+ const rest = buf.slice(idx + marker.length + 1);
107
+ if (rest) process.stdout.write(rest);
108
+ buf = '';
109
+ }
110
+ });
111
+
112
+ // Discard stderr noise before the marker, forward after
113
+ sshProcess.stderr.on('data', (chunk) => {
114
+ if (started) {
115
+ process.stderr.write(chunk);
116
+ }
38
117
  });
118
+
119
+ const exitCode = await new Promise((resolve) => sshProcess.on('exit', resolve));
120
+ process.exit(exitCode);
39
121
  },
40
122
  });
@@ -14,4 +14,5 @@ clever ssh [options]
14
14
  |---|---|
15
15
  |`-a`, `--alias` `<alias>`|Short name for the application|
16
16
  |`--app` `<app-id\|app-name>`|Application to manage by its ID (or name, if unambiguous)|
17
+ |`-c`, `--command` `<command>`|Execute a command on the remote instance and exit|
17
18
  |`-i`, `--identity-file` `<identity-file>`|SSH identity file|
@@ -10,6 +10,26 @@ export const DRAIN_TYPES = {
10
10
 
11
11
  export const DRAIN_TYPE_CLI_CODES = Object.values(DRAIN_TYPES).map(({ cliCode }) => cliCode);
12
12
 
13
+ function formatRate(messagesPerSecond) {
14
+ if (messagesPerSecond < 1) {
15
+ return Math.floor(messagesPerSecond * 3600) + ' messages/hour';
16
+ }
17
+ if (messagesPerSecond < 60) {
18
+ return Math.floor(messagesPerSecond * 60) + ' messages/minute';
19
+ }
20
+ return Math.floor(messagesPerSecond) + ' messages/second';
21
+ }
22
+
23
+ function formatThroughput(bytesPerSecond) {
24
+ if (bytesPerSecond < 1024) {
25
+ return Math.floor(bytesPerSecond) + ' bytes/second';
26
+ }
27
+ if (bytesPerSecond < 1024 * 1024) {
28
+ return (bytesPerSecond / 1024).toFixed(2) + ' KiB/second';
29
+ }
30
+ return (bytesPerSecond / (1024 * 1024)).toFixed(2) + ' MiB/second';
31
+ }
32
+
13
33
  export function formatDrain(rawDrain) {
14
34
  const drainType = DRAIN_TYPES[rawDrain.recipient.type];
15
35
  const drainDetails = [
@@ -20,9 +40,18 @@ export function formatDrain(rawDrain) {
20
40
  ['Type', drainType.label],
21
41
  ['Custom index', rawDrain.recipient.index],
22
42
  ['SD parameters', rawDrain.recipient.rfc5424StructuredDataParameters],
23
- ['Message output rate', Math.floor(rawDrain.backlog.msgRateOut * 60) + ' messages/minute'],
24
- ['Message throughput', Math.floor(rawDrain.backlog.msgRateOut) + ' bytes/second'],
43
+ ['Message output rate', formatRate(rawDrain.backlog.msgRateOut)],
44
+ ['Message throughput', formatThroughput(rawDrain.backlog.msgThroughputOut)],
25
45
  ['Backlog', rawDrain.backlog.msgBacklog + ' pending messages'],
46
+ [
47
+ 'Retry attempts',
48
+ rawDrain.execution.attempt != null && rawDrain.execution.maxAttempt != null
49
+ ? `${rawDrain.execution.attempt}/${rawDrain.execution.maxAttempt}`
50
+ : null,
51
+ ],
52
+ ['Last attempt at', rawDrain.execution.lastAttemptAt],
53
+ ['Next attempt at', rawDrain.execution.nextAttemptAt],
54
+ ['Retrying since', rawDrain.execution.retryingSince],
26
55
  ['Last error', rawDrain.execution.lastError],
27
56
  ];
28
57
  return Object.fromEntries(drainDetails.filter(([_name, value]) => value != null));
@@ -86,20 +86,13 @@ export class GitSystem extends Git {
86
86
  async getFullBranch(branchName) {
87
87
  this._debug('getFullBranch', branchName);
88
88
  const git = await this.#getSimpleGit();
89
- if (branchName === '') {
90
- const branch = await git.branch();
91
- if (branch.current) {
92
- return `refs/heads/${branch.current}`;
93
- }
94
- return 'HEAD';
95
- }
96
- // Try to expand the ref
89
+ const ref = branchName === '' ? 'HEAD' : branchName;
97
90
  try {
98
- const fullRef = await git.revparse(['--symbolic-full-name', branchName]);
91
+ const fullRef = await git.revparse(['--symbolic-full-name', ref]);
99
92
  return fullRef.trim();
100
93
  } catch {
101
- // If it fails, it might be a commit hash, return as-is
102
- return branchName;
94
+ // Not a symbolic ref (e.g. a commit hash), return as-is
95
+ return ref;
103
96
  }
104
97
  }
105
98