clever-tools 4.6.0 → 4.7.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/package.json
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
54
|
+
const nodeReadable = Readable.fromWeb(response.body);
|
|
55
|
+
await pipeline(nodeReadable, output ? fs.createWriteStream(output) : process.stdout);
|
|
54
56
|
},
|
|
55
57
|
});
|
|
@@ -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
|
|
28
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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|
|
package/src/config/config.js
CHANGED
|
@@ -212,7 +212,7 @@ export async function removeProfile(alias) {
|
|
|
212
212
|
async function updateConfigFile(newConfig) {
|
|
213
213
|
Logger.debug('Write the new config in the configuration file…');
|
|
214
214
|
try {
|
|
215
|
-
await writeJson(baseConfig.CONFIGURATION_FILE, newConfig, { mode:
|
|
215
|
+
await writeJson(baseConfig.CONFIGURATION_FILE, newConfig, { mode: 0o700 });
|
|
216
216
|
reloadConfig();
|
|
217
217
|
} catch (error) {
|
|
218
218
|
throw new Error(`Cannot write configuration to ${baseConfig.CONFIGURATION_FILE}\n${error.message}`);
|
package/src/models/drain.js
CHANGED
|
@@ -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',
|
|
24
|
-
['Message throughput',
|
|
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));
|
package/src/models/git-system.js
CHANGED
|
@@ -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
|
-
|
|
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',
|
|
91
|
+
const fullRef = await git.revparse(['--symbolic-full-name', ref]);
|
|
99
92
|
return fullRef.trim();
|
|
100
93
|
} catch {
|
|
101
|
-
//
|
|
102
|
-
return
|
|
94
|
+
// Not a symbolic ref (e.g. a commit hash), return as-is
|
|
95
|
+
return ref;
|
|
103
96
|
}
|
|
104
97
|
}
|
|
105
98
|
|