clever-tools 4.5.2 → 4.6.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/README.md +10 -0
- package/bin/clever.js +25 -22
- package/package.json +2 -2
- package/src/clever-client/auth-bridge.js +0 -25
- package/src/commands/README.md +1 -0
- package/src/commands/accesslogs/accesslogs.command.js +4 -1
- package/src/commands/accesslogs/accesslogs.docs.md +1 -1
- package/src/commands/addon/addon.create.command.js +23 -20
- package/src/commands/config/config.set.command.js +3 -3
- package/src/commands/config-provider/config-provider.args.js +8 -0
- package/src/commands/config-provider/config-provider.command.js +10 -0
- package/src/commands/config-provider/config-provider.docs.md +109 -0
- package/src/commands/config-provider/config-provider.get.command.js +36 -0
- package/src/commands/config-provider/config-provider.import.command.js +40 -0
- package/src/commands/config-provider/config-provider.list.command.js +45 -0
- package/src/commands/config-provider/config-provider.open.command.js +17 -0
- package/src/commands/config-provider/config-provider.rm.command.js +25 -0
- package/src/commands/config-provider/config-provider.set.command.js +37 -0
- package/src/commands/create/create.command.js +7 -6
- package/src/commands/curl/curl.command.js +18 -19
- package/src/commands/deploy/deploy.command.js +14 -8
- package/src/commands/deploy/deploy.docs.md +20 -0
- package/src/commands/diag/diag.command.js +52 -39
- package/src/commands/features/features.disable.command.js +1 -2
- package/src/commands/features/features.enable.command.js +1 -2
- package/src/commands/features/features.info.command.js +1 -1
- package/src/commands/features/features.list.command.js +1 -2
- package/src/commands/global.commands.js +22 -0
- package/src/commands/global.options.js +1 -1
- package/src/commands/login/login.command.js +119 -20
- package/src/commands/login/login.docs.md +9 -2
- package/src/commands/logout/logout.command.js +39 -7
- package/src/commands/logout/logout.docs.md +7 -1
- package/src/commands/logs/logs.command.js +7 -8
- package/src/commands/logs/logs.docs.md +1 -1
- package/src/commands/ng/ng.get-config.command.js +3 -3
- package/src/commands/profile/profile.command.js +13 -37
- package/src/commands/profile/profile.docs.md +28 -0
- package/src/commands/profile/profile.list.command.js +44 -0
- package/src/commands/profile/profile.switch.command.js +80 -0
- package/src/commands/restart/restart.command.js +3 -2
- package/src/commands/ssh/ssh.command.js +2 -2
- package/src/commands/tokens/tokens.command.js +3 -3
- package/src/commands/tokens/tokens.create.command.js +4 -4
- package/src/config/cache.js +60 -0
- package/src/config/config.js +233 -0
- package/src/config/features.js +167 -0
- package/src/config/paths.js +23 -0
- package/src/lib/date-utils.js +48 -0
- package/src/lib/fs.js +49 -0
- package/src/lib/operator-commands.js +4 -4
- package/src/lib/profile.js +116 -0
- package/src/logger.js +123 -69
- package/src/logger.types.d.ts +5 -0
- package/src/models/app_configuration.js +42 -39
- package/src/models/application_configuration.js +30 -30
- package/src/models/config-provider.js +34 -0
- package/src/models/git-isomorphic.js +153 -0
- package/src/models/git-system.js +182 -0
- package/src/models/git.js +150 -128
- package/src/models/ids-resolver.js +1 -1
- package/src/models/log.js +141 -90
- package/src/models/send-to-api.js +58 -33
- package/src/models/user.js +0 -11
- package/src/models/utils.js +2 -2
- package/src/experimental-features.js +0 -91
- package/src/lib/format-date.js +0 -3
- package/src/models/configuration.js +0 -140
- package/src/models/log-v4.js +0 -189
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import dedent from 'dedent';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { createApiToken } from '../../clever-client/auth-bridge.js';
|
|
4
|
+
import { config } from '../../config/config.js';
|
|
5
|
+
import { formatDate } from '../../lib/date-utils.js';
|
|
4
6
|
import { defineArgument } from '../../lib/define-argument.js';
|
|
5
7
|
import { defineCommand } from '../../lib/define-command.js';
|
|
6
8
|
import { defineOption } from '../../lib/define-option.js';
|
|
7
|
-
import { formatDate } from '../../lib/format-date.js';
|
|
8
9
|
import { promptSecret } from '../../lib/prompts.js';
|
|
9
10
|
import { styleText } from '../../lib/style-text.js';
|
|
10
11
|
import { Logger } from '../../logger.js';
|
|
11
|
-
import { conf } from '../../models/configuration.js';
|
|
12
12
|
import { sendToAuthBridge } from '../../models/send-to-api.js';
|
|
13
13
|
import { getCurrent as getCurrentUser } from '../../models/user.js';
|
|
14
14
|
import { futureDateOrDuration } from '../../parsers.js';
|
|
@@ -39,7 +39,7 @@ export const tokensCreateCommand = defineCommand({
|
|
|
39
39
|
const user = await getCurrentUser();
|
|
40
40
|
|
|
41
41
|
if (!user.hasPassword) {
|
|
42
|
-
const apiTokenListHref = new URL('/users/me/api-tokens',
|
|
42
|
+
const apiTokenListHref = new URL('/users/me/api-tokens', config.CONSOLE_URL).href;
|
|
43
43
|
throw new Error(dedent`
|
|
44
44
|
${styleText('yellow', '!')} Your Clever Cloud account is linked via GitHub and has no password. Setting one is required to create API tokens.
|
|
45
45
|
${styleText('blue', '→')} To do so, go to the following URL: ${styleText('blue', apiTokenListHref)}
|
|
@@ -98,7 +98,7 @@ export const tokensCreateCommand = defineCommand({
|
|
|
98
98
|
Export this token and use it to make authenticated requests to the Clever Cloud API through the Auth Bridge:
|
|
99
99
|
|
|
100
100
|
export CC_API_TOKEN=${createdToken.apiToken}
|
|
101
|
-
curl -H "Authorization: Bearer $CC_API_TOKEN" ${
|
|
101
|
+
curl -H "Authorization: Bearer $CC_API_TOKEN" ${config.AUTH_BRIDGE_HOST}/v2/self
|
|
102
102
|
|
|
103
103
|
Then, to revoke this token, run:
|
|
104
104
|
clever tokens revoke ${createdToken.apiTokenId}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { readJson, writeJson } from '../lib/fs.js';
|
|
3
|
+
import { Logger } from '../logger.js';
|
|
4
|
+
import { getConfigPath } from './paths.js';
|
|
5
|
+
|
|
6
|
+
const IDS_CACHE_FILEPATH = getConfigPath('ids-cache.json');
|
|
7
|
+
|
|
8
|
+
const IdsCacheSchema = z.object({
|
|
9
|
+
owners: z.record(z.string(), z.string()),
|
|
10
|
+
addons: z.record(
|
|
11
|
+
z.string(),
|
|
12
|
+
z.object({
|
|
13
|
+
addonId: z.string(),
|
|
14
|
+
realId: z.string(),
|
|
15
|
+
}),
|
|
16
|
+
),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {z.output<typeof IdsCacheSchema>} IdsCache
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** @type {IdsCache} */
|
|
24
|
+
const EMPTY_CACHE = { owners: {}, addons: {} };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Loads the IDs cache from the cache file.
|
|
28
|
+
* Returns an empty cache structure if the file doesn't exist or is invalid.
|
|
29
|
+
* @returns {Promise<IdsCache>} The cached IDs or empty cache structure
|
|
30
|
+
*/
|
|
31
|
+
export async function loadIdsCache() {
|
|
32
|
+
Logger.debug(`Get cache ID from ${IDS_CACHE_FILEPATH}`);
|
|
33
|
+
try {
|
|
34
|
+
const rawIdsCache = await readJson(IDS_CACHE_FILEPATH);
|
|
35
|
+
const parsed = IdsCacheSchema.safeParse(rawIdsCache);
|
|
36
|
+
if (!parsed.success) {
|
|
37
|
+
Logger.info(`Invalid IDs cache format in ${IDS_CACHE_FILEPATH}`);
|
|
38
|
+
return EMPTY_CACHE;
|
|
39
|
+
}
|
|
40
|
+
return parsed.data;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
Logger.info(`Cannot load IDs cache from ${IDS_CACHE_FILEPATH}`);
|
|
43
|
+
return EMPTY_CACHE;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Writes the IDs cache to the cache file.
|
|
49
|
+
* Creates the configuration directory if it doesn't exist.
|
|
50
|
+
* @param {IdsCache} ids - The IDs to cache
|
|
51
|
+
* @returns {Promise<void>}
|
|
52
|
+
* @throws {Error} If the cache file cannot be written
|
|
53
|
+
*/
|
|
54
|
+
export async function writeIdsCache(ids) {
|
|
55
|
+
try {
|
|
56
|
+
await writeJson(IDS_CACHE_FILEPATH, ids, { mode: 0o700 });
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw new Error(`Cannot write IDs cache to ${IDS_CACHE_FILEPATH}\n${error.message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { readJsonSync, writeJson } from '../lib/fs.js';
|
|
4
|
+
import { Logger } from '../logger.js';
|
|
5
|
+
import { getConfigPath } from './paths.js';
|
|
6
|
+
|
|
7
|
+
const OverridesSchema = z.object({
|
|
8
|
+
API_HOST: z.string().url().optional(),
|
|
9
|
+
CONSOLE_URL: z.string().url().optional(),
|
|
10
|
+
AUTH_BRIDGE_HOST: z.string().url().optional(),
|
|
11
|
+
SSH_GATEWAY: z.string().optional(),
|
|
12
|
+
OAUTH_CONSUMER_KEY: z.string().optional(),
|
|
13
|
+
OAUTH_CONSUMER_SECRET: z.string().optional(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const ProfileSchema = z.object({
|
|
17
|
+
alias: z.string(),
|
|
18
|
+
token: z.string(),
|
|
19
|
+
secret: z.string(),
|
|
20
|
+
expirationDate: z.string().optional(),
|
|
21
|
+
userId: z.string().optional(),
|
|
22
|
+
email: z.string().optional(),
|
|
23
|
+
overrides: OverridesSchema.optional(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** @typedef {z.infer<typeof ProfileSchema>} Profile */
|
|
27
|
+
|
|
28
|
+
const LegacyConfigFileSchema = z.object({
|
|
29
|
+
token: z.string(),
|
|
30
|
+
secret: z.string(),
|
|
31
|
+
expirationDate: z.string().optional(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** @typedef {z.infer<typeof LegacyConfigFileSchema>} LegacyConfigFile */
|
|
35
|
+
|
|
36
|
+
const ConfigFileSchema = z.object({
|
|
37
|
+
version: z.literal(1),
|
|
38
|
+
profiles: z.array(ProfileSchema).default([]),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** @typedef {z.infer<typeof ConfigFileSchema>} ConfigFile */
|
|
42
|
+
|
|
43
|
+
const ConfigSchema = z
|
|
44
|
+
.object({
|
|
45
|
+
CONFIGURATION_FILE: z.string().default(getConfigPath('clever-tools.json')),
|
|
46
|
+
EXPERIMENTAL_FEATURES_FILE: z.string().default(getConfigPath('clever-tools-experimental-features.json')),
|
|
47
|
+
APP_CONFIGURATION_FILE: z.string().default(() => path.resolve('.', '.clever.json')),
|
|
48
|
+
|
|
49
|
+
API_HOST: z.url().default('https://api.clever-cloud.com'),
|
|
50
|
+
AUTH_BRIDGE_HOST: z.url().default('https://api-bridge.clever-cloud.com'),
|
|
51
|
+
SSH_GATEWAY: z.string().default('ssh@sshgateway-clevercloud-customers.services.clever-cloud.com'),
|
|
52
|
+
|
|
53
|
+
// The disclosure of these tokens is not considered as a vulnerability.
|
|
54
|
+
// Do not report this to our security service.
|
|
55
|
+
OAUTH_CONSUMER_KEY: z.string().default('T5nFjKeHH4AIlEveuGhB5S3xg8T19e'),
|
|
56
|
+
OAUTH_CONSUMER_SECRET: z.string().default('MgVMqTr6fWlf2M0tkC2MXOnhfqBWDT'),
|
|
57
|
+
|
|
58
|
+
API_DOC_URL: z.url().default('https://www.clever.cloud/developers/api'),
|
|
59
|
+
DOC_URL: z.url().default('https://www.clever.cloud/developers/doc'),
|
|
60
|
+
CONSOLE_URL: z.url().default('https://console.clever-cloud.com'),
|
|
61
|
+
|
|
62
|
+
// Default values are computed from `CONSOLE_URL` below
|
|
63
|
+
CONSOLE_TOKEN_URL: z.url().optional(),
|
|
64
|
+
GOTO_URL: z.url().optional(),
|
|
65
|
+
|
|
66
|
+
token: z.string().optional(),
|
|
67
|
+
secret: z.string().optional(),
|
|
68
|
+
expirationDate: z.string().optional(),
|
|
69
|
+
profiles: z.array(ProfileSchema).default([]),
|
|
70
|
+
})
|
|
71
|
+
.transform((config) => ({
|
|
72
|
+
...config,
|
|
73
|
+
CONSOLE_TOKEN_URL: config.CONSOLE_TOKEN_URL ?? `${config.CONSOLE_URL}/cli-oauth`,
|
|
74
|
+
GOTO_URL: config.GOTO_URL ?? `${config.CONSOLE_URL}/goto`,
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Base configuration: environment variables + Zod schema defaults, without any profile overrides.
|
|
79
|
+
* Use this as fallback when operating on a specific profile (login, profile list)
|
|
80
|
+
* to avoid being affected by the active profile's overrides.
|
|
81
|
+
*/
|
|
82
|
+
export const baseConfig = loadBaseConfig();
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @returns {z.output<typeof ConfigSchema>}
|
|
86
|
+
*/
|
|
87
|
+
function loadBaseConfig() {
|
|
88
|
+
const result = ConfigSchema.safeParse(process.env);
|
|
89
|
+
|
|
90
|
+
if (!result.success) {
|
|
91
|
+
const errors = result.error.issues.map((issue) => `- ${issue.path.join('.')}: ${issue.message}`).join('\n');
|
|
92
|
+
Logger.error(`Invalid configuration:\n${errors}`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return result.data;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The complete configuration object, loaded synchronously at startup.
|
|
101
|
+
* Priority: environment variables > active profile overrides > Zod schema defaults.
|
|
102
|
+
*/
|
|
103
|
+
export const config = loadConfig();
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @returns {z.output<typeof ConfigSchema>}
|
|
107
|
+
*/
|
|
108
|
+
function loadConfig() {
|
|
109
|
+
Logger.debug(`Load configuration from ${baseConfig.CONFIGURATION_FILE}`);
|
|
110
|
+
const configFromFile = loadConfigFile();
|
|
111
|
+
|
|
112
|
+
// If CLEVER_TOKEN and CLEVER_SECRET are set, inject a virtual "$env" profile as the active one
|
|
113
|
+
const profiles =
|
|
114
|
+
process.env.CLEVER_TOKEN != null && process.env.CLEVER_SECRET != null
|
|
115
|
+
? [
|
|
116
|
+
{ alias: '$env', token: process.env.CLEVER_TOKEN, secret: process.env.CLEVER_SECRET },
|
|
117
|
+
...configFromFile.profiles,
|
|
118
|
+
]
|
|
119
|
+
: configFromFile.profiles;
|
|
120
|
+
|
|
121
|
+
const activeProfile = profiles[0];
|
|
122
|
+
|
|
123
|
+
/** @type {z.input<typeof ConfigSchema>} */
|
|
124
|
+
const rawConfig = {
|
|
125
|
+
...activeProfile,
|
|
126
|
+
// Profile overrides (e.g. custom API_HOST) applied after profile auth data, before env vars
|
|
127
|
+
...activeProfile?.overrides,
|
|
128
|
+
profiles,
|
|
129
|
+
...process.env,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const result = ConfigSchema.safeParse(rawConfig);
|
|
133
|
+
|
|
134
|
+
if (!result.success) {
|
|
135
|
+
const errors = result.error.issues.map((issue) => `- ${issue.path.join('.')}: ${issue.message}`).join('\n');
|
|
136
|
+
Logger.error(`Invalid configuration:\n${errors}`);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return result.data;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Reads and parses the config file, handling both current and legacy formats.
|
|
145
|
+
* @returns {ConfigFile}
|
|
146
|
+
*/
|
|
147
|
+
function loadConfigFile() {
|
|
148
|
+
const data = readJsonSync(baseConfig.CONFIGURATION_FILE);
|
|
149
|
+
|
|
150
|
+
// Try parsing as current format
|
|
151
|
+
const result = ConfigFileSchema.safeParse(data);
|
|
152
|
+
if (result.success) {
|
|
153
|
+
Logger.debug('Loaded config file with current format');
|
|
154
|
+
return result.data;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Try parsing as legacy format
|
|
158
|
+
const legacyResult = LegacyConfigFileSchema.safeParse(data);
|
|
159
|
+
if (legacyResult.success) {
|
|
160
|
+
Logger.debug('Loaded config file with legacy format, converting to profile');
|
|
161
|
+
const legacyConfig = legacyResult.data;
|
|
162
|
+
return {
|
|
163
|
+
version: 1,
|
|
164
|
+
profiles: [
|
|
165
|
+
{
|
|
166
|
+
alias: 'default',
|
|
167
|
+
token: legacyConfig.token,
|
|
168
|
+
secret: legacyConfig.secret,
|
|
169
|
+
expirationDate: legacyConfig.expirationDate,
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// No valid config found
|
|
176
|
+
Logger.debug('No valid config file found, using empty config');
|
|
177
|
+
return { version: 1, profiles: [] };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Saves a profile to the configuration.
|
|
182
|
+
* If a profile with the same alias exists, it is replaced.
|
|
183
|
+
* The saved profile becomes the active profile (first in the list).
|
|
184
|
+
* @param {Profile} profile
|
|
185
|
+
* @returns {Promise<void>}
|
|
186
|
+
*/
|
|
187
|
+
export async function saveProfile(profile) {
|
|
188
|
+
const configFile = loadConfigFile();
|
|
189
|
+
const otherProfiles = configFile.profiles.filter((p) => p.alias !== profile.alias);
|
|
190
|
+
// Always put the active profile in the first position
|
|
191
|
+
configFile.profiles = [profile, ...otherProfiles];
|
|
192
|
+
await updateConfigFile(configFile);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Removes a profile by alias and returns the new active profile (if any).
|
|
197
|
+
* @param {string} alias
|
|
198
|
+
* @returns {Promise<Profile | null>}
|
|
199
|
+
*/
|
|
200
|
+
export async function removeProfile(alias) {
|
|
201
|
+
const configFile = loadConfigFile();
|
|
202
|
+
configFile.profiles = configFile.profiles.filter((p) => p.alias !== alias);
|
|
203
|
+
await updateConfigFile(configFile);
|
|
204
|
+
return configFile.profiles[0] ?? null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Writes the configuration to the config file and reloads the in-memory config.
|
|
209
|
+
* @param {ConfigFile} newConfig - The new configuration to persist
|
|
210
|
+
* @returns {Promise<void>}
|
|
211
|
+
*/
|
|
212
|
+
async function updateConfigFile(newConfig) {
|
|
213
|
+
Logger.debug('Write the new config in the configuration file…');
|
|
214
|
+
try {
|
|
215
|
+
await writeJson(baseConfig.CONFIGURATION_FILE, newConfig, { mode: 0o700 });
|
|
216
|
+
reloadConfig();
|
|
217
|
+
} catch (error) {
|
|
218
|
+
throw new Error(`Cannot write configuration to ${baseConfig.CONFIGURATION_FILE}\n${error.message}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Reloads the configuration from file and updates the config object in place.
|
|
224
|
+
* This ensures all modules referencing the config object see the updated values.
|
|
225
|
+
*/
|
|
226
|
+
export function reloadConfig() {
|
|
227
|
+
const mutableConfig = /** @type {Record<string, unknown>} */ (config);
|
|
228
|
+
for (const key of Object.keys(mutableConfig)) {
|
|
229
|
+
delete mutableConfig[key];
|
|
230
|
+
}
|
|
231
|
+
const newConfig = loadConfig();
|
|
232
|
+
Object.assign(config, newConfig);
|
|
233
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import dedent from 'dedent';
|
|
2
|
+
import z from 'zod';
|
|
3
|
+
import { readJson, writeJson } from '../lib/fs.js';
|
|
4
|
+
import { Logger } from '../logger.js';
|
|
5
|
+
import { config } from './config.js';
|
|
6
|
+
import { getConfigPath } from './paths.js';
|
|
7
|
+
|
|
8
|
+
const EXPERIMENTAL_FEATURES_FILEPATH = getConfigPath('clever-tools-experimental-features.json');
|
|
9
|
+
|
|
10
|
+
export const EXPERIMENTAL_FEATURES = {
|
|
11
|
+
'system-git': {
|
|
12
|
+
status: 'beta',
|
|
13
|
+
description: 'Use system git instead of current JS implementation for git operations',
|
|
14
|
+
instructions: dedent`
|
|
15
|
+
This feature switches from the current JS implementation to using
|
|
16
|
+
the git installed on your system.
|
|
17
|
+
|
|
18
|
+
Requirements:
|
|
19
|
+
- git must be installed and available in your PATH
|
|
20
|
+
`,
|
|
21
|
+
},
|
|
22
|
+
k8s: {
|
|
23
|
+
status: 'beta',
|
|
24
|
+
description: 'Deploy and manage Kubernetes clusters on Clever Cloud',
|
|
25
|
+
instructions: dedent`
|
|
26
|
+
- Create a Kubernetes cluster:
|
|
27
|
+
clever k8s create my-cluster
|
|
28
|
+
clever k8s create my-cluster --org myOrg
|
|
29
|
+
|
|
30
|
+
- List Kubernetes clusters:
|
|
31
|
+
clever k8s list
|
|
32
|
+
|
|
33
|
+
- Get details about a Kubernetes cluster:
|
|
34
|
+
clever k8s get my-cluster
|
|
35
|
+
|
|
36
|
+
- Get kubeconfig file for a Kubernetes cluster:
|
|
37
|
+
clever k8s get-kubeconfig my-cluster
|
|
38
|
+
clever k8s get-kubeconfig my-cluster > ~/.kube/config
|
|
39
|
+
|
|
40
|
+
- Activate persistent storage on a Kubernetes cluster:
|
|
41
|
+
clever k8s add-persistent-storage my-cluster
|
|
42
|
+
|
|
43
|
+
- Delete a Kubernetes cluster:
|
|
44
|
+
clever k8s delete my-cluster
|
|
45
|
+
|
|
46
|
+
Learn more about Clever Kubernetes: ${config.DOC_URL}/kubernetes/
|
|
47
|
+
`,
|
|
48
|
+
},
|
|
49
|
+
kv: {
|
|
50
|
+
status: 'beta',
|
|
51
|
+
description:
|
|
52
|
+
'Send commands to databases such as Materia KV or Redis® directly from Clever Tools, without other dependencies',
|
|
53
|
+
instructions: dedent`
|
|
54
|
+
Target any compatible add-on by its name or ID (with an org ID if needed) and send commands to it:
|
|
55
|
+
|
|
56
|
+
clever kv myMateriaKV SET myKey myValue
|
|
57
|
+
clever kv kv_xxxxxxxx GET myKey -F json
|
|
58
|
+
clever kv addon_xxxxx SET myTempKey myTempValue EX 120
|
|
59
|
+
clever kv myMateriaKV -o myOrg TTL myTempKey
|
|
60
|
+
clever kv redis_xxxxx --org org_xxxxx PING
|
|
61
|
+
|
|
62
|
+
Learn more about Materia KV: ${config.DOC_URL}/addons/materia-kv/
|
|
63
|
+
`,
|
|
64
|
+
},
|
|
65
|
+
ng: {
|
|
66
|
+
status: 'beta',
|
|
67
|
+
description: 'Manage Network Groups to manage applications, add-ons, external peers through a WireGuard network',
|
|
68
|
+
instructions: dedent`
|
|
69
|
+
- Create a Network Group:
|
|
70
|
+
clever ng create myNG
|
|
71
|
+
- Create a Network Group with members (application, database add-on):
|
|
72
|
+
clever ng create myNG --link app_xxx,postgresql_xxx
|
|
73
|
+
- List Network Groups:
|
|
74
|
+
clever ng
|
|
75
|
+
- Delete a Network Group:
|
|
76
|
+
clever ng delete myNG
|
|
77
|
+
- (Un)Link an application or a database add-on to an existing Network Group:
|
|
78
|
+
clever ng link app_xxx myNG
|
|
79
|
+
clever ng unlink postgresql_xxx myNG
|
|
80
|
+
- Get the WireGuard configuration of a peer:
|
|
81
|
+
clever ng get-config peerIdOrLabel myNG
|
|
82
|
+
- Get details about a Network Group, a member or a peer:
|
|
83
|
+
clever ng get myNg
|
|
84
|
+
clever ng get app_xxx
|
|
85
|
+
clever ng get peerId
|
|
86
|
+
clever ng get memberLabel
|
|
87
|
+
- Search Network Groups, members or peers:
|
|
88
|
+
clever ng search myQuery
|
|
89
|
+
|
|
90
|
+
Learn more about Network Groups: ${config.DOC_URL}/develop/network-groups/
|
|
91
|
+
`,
|
|
92
|
+
},
|
|
93
|
+
operators: {
|
|
94
|
+
status: 'beta',
|
|
95
|
+
description: 'Manage operators and their features such as Keycloak, Matomo, Metabase, Otoroshi',
|
|
96
|
+
instructions: dedent`
|
|
97
|
+
clever keycloak
|
|
98
|
+
clever keycloak get keycloak_xxx
|
|
99
|
+
clever keycloak ng enable myKeycloak
|
|
100
|
+
|
|
101
|
+
clever metabase version check myMetabase
|
|
102
|
+
clever metabase version update myMetabase 0.53
|
|
103
|
+
|
|
104
|
+
clever matomo open myMatomo
|
|
105
|
+
clever otoroshi open logs myOtoroshi
|
|
106
|
+
`,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const FeaturesConfigSchema = z
|
|
111
|
+
.object(Object.fromEntries(Object.keys(EXPERIMENTAL_FEATURES).map((key) => [key, z.boolean()])))
|
|
112
|
+
.partial();
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @typedef {z.infer<typeof FeaturesConfigSchema>} FeaturesConfig
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
/**
|
|
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
|
|
122
|
+
*/
|
|
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
|
+
}
|
|
137
|
+
return {};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Sets an experimental feature to the specified value.
|
|
143
|
+
* Creates the configuration directory and features file if they don't exist.
|
|
144
|
+
* @param {string} feature - The name of the feature to set
|
|
145
|
+
* @param {boolean} value - The value to set for the feature
|
|
146
|
+
* @returns {Promise<void>}
|
|
147
|
+
* @throws {Error} If the features file cannot be written
|
|
148
|
+
*/
|
|
149
|
+
export async function setFeature(feature, value) {
|
|
150
|
+
const currentFeatures = await getFeatures();
|
|
151
|
+
const newFeatures = { ...currentFeatures, [feature]: value };
|
|
152
|
+
try {
|
|
153
|
+
await writeJson(EXPERIMENTAL_FEATURES_FILEPATH, newFeatures, { mode: 0o700 });
|
|
154
|
+
} catch (error) {
|
|
155
|
+
throw new Error(`Cannot write experimental features configuration to ${EXPERIMENTAL_FEATURES_FILEPATH}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Checks if an experimental feature is enabled.
|
|
161
|
+
* @param {string} feature - The name of the feature to check
|
|
162
|
+
* @returns {Promise<boolean>} True if the feature is explicitly enabled, false otherwise
|
|
163
|
+
*/
|
|
164
|
+
export async function isFeatureEnabled(feature) {
|
|
165
|
+
const features = await getFeatures();
|
|
166
|
+
return features[feature] === true;
|
|
167
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
// @ts-ignore - no types available for this module
|
|
3
|
+
import xdg from 'xdg';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolves the full path for a configuration file.
|
|
7
|
+
* @param {string} configFile - The name of the configuration file
|
|
8
|
+
* @returns {string} The absolute path to the configuration file
|
|
9
|
+
*/
|
|
10
|
+
export function getConfigPath(configFile) {
|
|
11
|
+
return path.resolve(getConfigDir(), configFile);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Gets the configuration directory path based on the operating system.
|
|
16
|
+
* On Windows, uses APPDATA/clever-cloud, on other platforms uses XDG config path.
|
|
17
|
+
* @returns {string} The absolute path to the configuration directory
|
|
18
|
+
*/
|
|
19
|
+
function getConfigDir() {
|
|
20
|
+
return process.platform === 'win32' && process.env.APPDATA != null
|
|
21
|
+
? path.resolve(process.env.APPDATA, 'clever-cloud')
|
|
22
|
+
: xdg.basedir.configPath('clever-cloud');
|
|
23
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formats a date to ISO-like format "YYYY-MM-DD HH:mm".
|
|
3
|
+
* @param {string | number | Date} dateInput
|
|
4
|
+
* @returns {string}
|
|
5
|
+
*/
|
|
6
|
+
export function formatDate(dateInput) {
|
|
7
|
+
return new Date(dateInput).toISOString().substring(0, 16).replace('T', ' ');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Formats a date to localized display format (e.g. "Feb 5, 2027, 14:16 UTC").
|
|
12
|
+
* Returns undefined when the input is nullish.
|
|
13
|
+
* @param {Date | undefined | null} date
|
|
14
|
+
* @returns {string | undefined}
|
|
15
|
+
*/
|
|
16
|
+
export function formatDateLocalized(date) {
|
|
17
|
+
if (date == null) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
return date.toLocaleString('en-US', {
|
|
21
|
+
year: 'numeric',
|
|
22
|
+
month: 'short',
|
|
23
|
+
day: 'numeric',
|
|
24
|
+
hour: '2-digit',
|
|
25
|
+
minute: '2-digit',
|
|
26
|
+
hour12: false,
|
|
27
|
+
timeZone: 'UTC',
|
|
28
|
+
timeZoneName: 'short',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Safely converts various date inputs (ISO string, timestamp, Date) to a Date instance.
|
|
34
|
+
* Returns undefined for null/undefined or invalid values.
|
|
35
|
+
* @param {string | number | Date | undefined | null} dateInput
|
|
36
|
+
* @returns {Date | undefined}
|
|
37
|
+
*/
|
|
38
|
+
export function toDate(dateInput) {
|
|
39
|
+
if (dateInput == null) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const date = new Date(dateInput);
|
|
44
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/lib/fs.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { promises as fs, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { Logger } from '../logger.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reads and parses a JSON file asynchronously.
|
|
8
|
+
* @param {string} filePath - The absolute path to the JSON file
|
|
9
|
+
* @returns {Promise<unknown>} The parsed JSON content
|
|
10
|
+
* @throws {Error} If the file cannot be read or parsed
|
|
11
|
+
*/
|
|
12
|
+
export async function readJson(filePath) {
|
|
13
|
+
const rawFile = await fs.readFile(filePath, 'utf-8');
|
|
14
|
+
return JSON.parse(rawFile);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Reads and parses a JSON file synchronously.
|
|
19
|
+
* @param {string} filePath - The absolute path to the JSON file
|
|
20
|
+
* @returns {unknown | null} The parsed JSON content, or null if file is absent or invalid
|
|
21
|
+
*/
|
|
22
|
+
export function readJsonSync(filePath) {
|
|
23
|
+
try {
|
|
24
|
+
const rawFile = readFileSync(filePath, 'utf-8');
|
|
25
|
+
return JSON.parse(rawFile);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
Logger.debug(`Failed to read JSON from ${filePath}: ${error.message}`);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Writes data as JSON to a file.
|
|
34
|
+
* Creates the parent directory if it doesn't exist.
|
|
35
|
+
* @param {string} filePath - The absolute path to the JSON file
|
|
36
|
+
* @param {unknown} data - The data to serialize and write
|
|
37
|
+
* @param {{ mode?: number }} [options] - Options for directory creation
|
|
38
|
+
* @returns {Promise<void>}
|
|
39
|
+
* @throws {Error} If the file cannot be written
|
|
40
|
+
*/
|
|
41
|
+
export async function writeJson(filePath, data, options = {}) {
|
|
42
|
+
/** @type {{ recursive: true, mode?: number }} */
|
|
43
|
+
const mkdirOptions = { recursive: true };
|
|
44
|
+
if (options.mode != null) {
|
|
45
|
+
mkdirOptions.mode = options.mode;
|
|
46
|
+
}
|
|
47
|
+
await fs.mkdir(path.dirname(filePath), mkdirOptions);
|
|
48
|
+
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
|
|
49
|
+
}
|
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
versionCheck,
|
|
10
10
|
versionUpdate,
|
|
11
11
|
} from '../clever-client/operators.js';
|
|
12
|
+
import { config } from '../config/config.js';
|
|
12
13
|
import { Logger } from '../logger.js';
|
|
13
|
-
import { conf } from '../models/configuration.js';
|
|
14
14
|
import { findAddonsByAddonProvider } from '../models/ids-resolver.js';
|
|
15
15
|
import * as Operator from '../models/operator.js';
|
|
16
16
|
import { sendToApi } from '../models/send-to-api.js';
|
|
@@ -179,7 +179,7 @@ export async function operatorList(provider, format) {
|
|
|
179
179
|
export async function operatorOpen(provider, addonIdOrName) {
|
|
180
180
|
const operator = await Operator.getDetails(provider, addonIdOrName);
|
|
181
181
|
await openBrowser(
|
|
182
|
-
`${
|
|
182
|
+
`${config.GOTO_URL}/${operator.addonId}`,
|
|
183
183
|
`🌐 Opening ${styleText('blue', operator.addonId)} in the browser…`,
|
|
184
184
|
);
|
|
185
185
|
}
|
|
@@ -251,9 +251,9 @@ export async function operatorPrint(provider, addonIdOrName, format = 'human') {
|
|
|
251
251
|
const operator = await Operator.getDetails(provider, addonIdOrName);
|
|
252
252
|
|
|
253
253
|
if (provider === 'otoroshi' && format === 'otoroshictl') {
|
|
254
|
-
const
|
|
254
|
+
const otoroshiConfig = await getOtoroshiConfig({ realId: operator.resourceId }).then(sendToApi);
|
|
255
255
|
|
|
256
|
-
Logger.println(
|
|
256
|
+
Logger.println(otoroshiConfig);
|
|
257
257
|
return;
|
|
258
258
|
}
|
|
259
259
|
|