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.
Files changed (69) hide show
  1. package/README.md +10 -0
  2. package/bin/clever.js +25 -22
  3. package/package.json +2 -2
  4. package/src/clever-client/auth-bridge.js +0 -25
  5. package/src/commands/README.md +1 -0
  6. package/src/commands/accesslogs/accesslogs.command.js +4 -1
  7. package/src/commands/accesslogs/accesslogs.docs.md +1 -1
  8. package/src/commands/addon/addon.create.command.js +23 -20
  9. package/src/commands/config/config.set.command.js +3 -3
  10. package/src/commands/config-provider/config-provider.args.js +8 -0
  11. package/src/commands/config-provider/config-provider.command.js +10 -0
  12. package/src/commands/config-provider/config-provider.docs.md +109 -0
  13. package/src/commands/config-provider/config-provider.get.command.js +36 -0
  14. package/src/commands/config-provider/config-provider.import.command.js +40 -0
  15. package/src/commands/config-provider/config-provider.list.command.js +45 -0
  16. package/src/commands/config-provider/config-provider.open.command.js +17 -0
  17. package/src/commands/config-provider/config-provider.rm.command.js +25 -0
  18. package/src/commands/config-provider/config-provider.set.command.js +37 -0
  19. package/src/commands/create/create.command.js +7 -6
  20. package/src/commands/curl/curl.command.js +18 -19
  21. package/src/commands/deploy/deploy.command.js +14 -8
  22. package/src/commands/deploy/deploy.docs.md +20 -0
  23. package/src/commands/diag/diag.command.js +52 -39
  24. package/src/commands/features/features.disable.command.js +1 -2
  25. package/src/commands/features/features.enable.command.js +1 -2
  26. package/src/commands/features/features.info.command.js +1 -1
  27. package/src/commands/features/features.list.command.js +1 -2
  28. package/src/commands/global.commands.js +22 -0
  29. package/src/commands/global.options.js +1 -1
  30. package/src/commands/login/login.command.js +119 -20
  31. package/src/commands/login/login.docs.md +9 -2
  32. package/src/commands/logout/logout.command.js +39 -7
  33. package/src/commands/logout/logout.docs.md +7 -1
  34. package/src/commands/logs/logs.command.js +7 -8
  35. package/src/commands/logs/logs.docs.md +1 -1
  36. package/src/commands/ng/ng.get-config.command.js +3 -3
  37. package/src/commands/profile/profile.command.js +13 -37
  38. package/src/commands/profile/profile.docs.md +28 -0
  39. package/src/commands/profile/profile.list.command.js +44 -0
  40. package/src/commands/profile/profile.switch.command.js +80 -0
  41. package/src/commands/restart/restart.command.js +3 -2
  42. package/src/commands/ssh/ssh.command.js +2 -2
  43. package/src/commands/tokens/tokens.command.js +3 -3
  44. package/src/commands/tokens/tokens.create.command.js +4 -4
  45. package/src/config/cache.js +60 -0
  46. package/src/config/config.js +233 -0
  47. package/src/config/features.js +167 -0
  48. package/src/config/paths.js +23 -0
  49. package/src/lib/date-utils.js +48 -0
  50. package/src/lib/fs.js +49 -0
  51. package/src/lib/operator-commands.js +4 -4
  52. package/src/lib/profile.js +116 -0
  53. package/src/logger.js +123 -69
  54. package/src/logger.types.d.ts +5 -0
  55. package/src/models/app_configuration.js +42 -39
  56. package/src/models/application_configuration.js +30 -30
  57. package/src/models/config-provider.js +34 -0
  58. package/src/models/git-isomorphic.js +153 -0
  59. package/src/models/git-system.js +182 -0
  60. package/src/models/git.js +150 -128
  61. package/src/models/ids-resolver.js +1 -1
  62. package/src/models/log.js +141 -90
  63. package/src/models/send-to-api.js +58 -33
  64. package/src/models/user.js +0 -11
  65. package/src/models/utils.js +2 -2
  66. package/src/experimental-features.js +0 -91
  67. package/src/lib/format-date.js +0 -3
  68. package/src/models/configuration.js +0 -140
  69. 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', conf.CONSOLE_URL).href;
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" ${conf.AUTH_BRIDGE_HOST}/v2/self
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
- `${conf.GOTO_URL}/${operator.addonId}`,
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 config = await getOtoroshiConfig({ realId: operator.resourceId }).then(sendToApi);
254
+ const otoroshiConfig = await getOtoroshiConfig({ realId: operator.resourceId }).then(sendToApi);
255
255
 
256
- Logger.println(config);
256
+ Logger.println(otoroshiConfig);
257
257
  return;
258
258
  }
259
259