diff --git a/bin/clever.js b/bin/clever.js index 826773a..53f4d69 100755 --- a/bin/clever.js +++ b/bin/clever.js @@ -34,6 +34,7 @@ import * as diag from '../src/commands/diag.js'; import * as domain from '../src/commands/domain.js'; import * as drain from '../src/commands/drain.js'; import * as env from '../src/commands/env.js'; +import * as features from '../src/commands/features.js'; import * as link from '../src/commands/link.js'; import * as login from '../src/commands/login.js'; import * as logout from '../src/commands/logout.js'; @@ -102,6 +103,11 @@ function run () { }), drainUrl: cliparse.argument('drain-url', { description: 'Drain URL' }), fqdn: cliparse.argument('fqdn', { description: 'Domain name of the application' }), + features: cliparse.argument('features', { + description: 'Comma-separated list of experimental features to manage', + parser: Parsers.commaSeparated, + }), + featureId: cliparse.argument('feature', { description: 'Experimental feature to manage' }), notificationName: cliparse.argument('name', { description: 'Notification name' }), notificationId: cliparse.argument('notification-id', { description: 'Notification ID' }), webhookUrl: cliparse.argument('url', { description: 'Webhook URL' }), @@ -666,6 +672,28 @@ function run () { commands: [envSetCommand, envRemoveCommand, envImportCommand, envImportVarsFromLocalEnvCommand], }, env.list); + // EXPERIMENTAL FEATURES COMMAND + const listFeaturesCommand = cliparse.command('list', { + description: 'List available experimental features', + options: [opts.humanJsonOutputFormat], + }, features.list); + const infoFeaturesCommand = cliparse.command('info', { + description: 'Display info about an experimental feature', + args: [args.featureId], + }, features.info); + const enableFeatureCommand = cliparse.command('enable', { + description: 'Enable experimental features', + args: [args.features], + }, features.enable); + const disableFeatureCommand = cliparse.command('disable', { + description: 'Disable experimental features', + args: [args.features], + }, features.disable); + const featuresCommands = cliparse.command('features', { + description: 'Manage Clever Tools experimental features', + commands: [enableFeatureCommand, disableFeatureCommand, listFeaturesCommand, infoFeaturesCommand], + }, features.list); + // LINK COMMAND const appLinkCommand = cliparse.command('link', { description: 'Link this repo to an existing application', @@ -881,7 +909,7 @@ function run () { // Patch help command description cliparseCommands.helpCommand.description = 'Display help about the Clever Cloud CLI'; - const commands = _sortBy([ + const commands = [ accesslogsCommand, activityCommand, addonCommands, @@ -900,6 +928,7 @@ function run () { drainCommands, emailNotificationsCommand, envCommands, + featuresCommands, cliparseCommands.helpCommand, loginCommand, logoutCommand, @@ -918,7 +947,7 @@ function run () { tcpRedirsCommands, versionCommand, webhooksCommand, - ], 'name'); + ]; // CLI PARSER const cliParser = cliparse.cli({ @@ -927,7 +956,7 @@ function run () { version: getPackageJson().version, options: [opts.color, opts.updateNotifier, opts.verbose], helpCommand: false, - commands, + commands: _sortBy(commands, 'name'), }); // Make sure argv[0] is always "node" diff --git a/src/commands/features.js b/src/commands/features.js new file mode 100644 index 0000000..14f685b --- /dev/null +++ b/src/commands/features.js @@ -0,0 +1,93 @@ +import { getFeatures, setFeature } from '../models/configuration.js'; +import { EXPERIMENTAL_FEATURES } from '../experimental-features.js'; +import { formatTable as initFormatTable } from '../format-table.js'; +import { Logger } from '../logger.js'; + +const formatTable = initFormatTable(); + +export async function list (params) { + const { format } = params.options; + + const featuresConf = await getFeatures(); + // Add status from configuration file and remove instructions + const features = Object.entries(EXPERIMENTAL_FEATURES).map(([id, feature]) => { + const enabled = featuresConf[id] === true; + return { + id, + status: feature.status, + description: feature.description, + enabled, + }; + }); + + // For each feature, print the object with the id, status, description and enabled + switch (format) { + case 'json': { + Logger.printJson(features); + break; + } + case 'human': + default: { + const headers = ['ID', 'STATUS', 'DESCRIPTION', 'ENABLED']; + + Logger.println(formatTable([ + headers, + ...features.map((feature) => [ + feature.id, + feature.status, + feature.description, + feature.enabled, + ]), + ])); + } + } +} + +export async function info (params) { + const { feature } = params.namedArgs; + const availableFeatures = Object.keys(EXPERIMENTAL_FEATURES); + + if (!availableFeatures.includes(feature)) { + throw new Error(`Unavailable feature: ${feature}`); + } + + Logger.println(EXPERIMENTAL_FEATURES[feature].instructions); +} + +export async function enable (params) { + const { features } = params.namedArgs; + const availableFeatures = Object.keys(EXPERIMENTAL_FEATURES); + + const unknownFeatures = features.filter((feature) => !availableFeatures.includes(feature)); + if (unknownFeatures.length > 0) { + throw new Error(`Unavailable feature(s): ${unknownFeatures.join(', ')}`); + } + + for (const featureName of features) { + await setFeature(featureName, true); + Logger.println(`Experimental feature '${featureName}' enabled`); + } + + if (features.length === 1) { + Logger.println(EXPERIMENTAL_FEATURES[features[0]].instructions); + } + else { + Logger.println(); + Logger.println("To learn more about these experimental features, use 'clever features info FEATURE_NAME'"); + } +} + +export async function disable (params) { + const { features } = params.namedArgs; + const availableFeatures = Object.keys(EXPERIMENTAL_FEATURES); + + const unknownFeatures = features.filter((feature) => !availableFeatures.includes(feature)); + if (unknownFeatures.length > 0) { + throw new Error(`Unavailable feature(s): ${unknownFeatures.join(', ')}`); + } + + for (const featureName of features) { + await setFeature(featureName, false); + Logger.println(`Experimental feature '${featureName}' disabled`); + } +} diff --git a/src/experimental-features.js b/src/experimental-features.js new file mode 100644 index 0000000..3740780 --- /dev/null +++ b/src/experimental-features.js @@ -0,0 +1 @@ +export const EXPERIMENTAL_FEATURES = {}; diff --git a/src/models/configuration.js b/src/models/configuration.js index bca3624..b2e5482 100644 --- a/src/models/configuration.js +++ b/src/models/configuration.js @@ -11,6 +11,7 @@ const env = commonEnv(Logger); const CONFIG_FILES = { MAIN: 'clever-tools.json', IDS_CACHE: 'ids-cache.json', + EXPERIMENTAL_FEATURES_FILE: 'clever-tools-experimental-features.json', }; function getConfigPath (configFile) { @@ -85,6 +86,32 @@ export async function writeIdsCache (ids) { } } +export async function getFeatures () { + Logger.debug('Get features configuration from ' + conf.EXPERIMENTAL_FEATURES_FILE); + try { + const rawFile = await fs.readFile(conf.EXPERIMENTAL_FEATURES_FILE); + return JSON.parse(rawFile); + } + catch (error) { + if (error.code !== 'ENOENT') { + throw new Error(`Cannot get experimental features configuration from ${conf.EXPERIMENTAL_FEATURES_FILE}`); + } + return {}; + } +} + +export async function setFeature (feature, value) { + const currentFeatures = await getFeatures(); + const newFeatures = { ...currentFeatures, ...{ [feature]: value } }; + + try { + await fs.writeFile(conf.EXPERIMENTAL_FEATURES_FILE, JSON.stringify(newFeatures, null, 2)); + } + catch (error) { + throw new Error(`Cannot write experimental features configuration to ${conf.EXPERIMENTAL_FEATURES_FILE}`); + } +} + export const conf = env.getOrElseAll({ API_HOST: 'https://api.clever-cloud.com', // API_HOST: 'https://ccapi-preprod.cleverapps.io', @@ -98,6 +125,7 @@ export const conf = env.getOrElseAll({ SSH_GATEWAY: 'ssh@sshgateway-clevercloud-customers.services.clever-cloud.com', CONFIGURATION_FILE: getConfigPath(CONFIG_FILES.MAIN), + EXPERIMENTAL_FEATURES_FILE: getConfigPath(CONFIG_FILES.EXPERIMENTAL_FEATURES_FILE), CONSOLE_TOKEN_URL: 'https://console.clever-cloud.com/cli-oauth', // CONSOLE_TOKEN_URL: 'https://next-console.cleverapps.io/cli-oauth',