diff --git a/.gitignore b/.gitignore index 7363130f..74284100 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ npm-debug.log .idea *.iml .DS_Store + +# Test coverage output +coverage/ diff --git a/src/commands/deployment/module.ts b/src/commands/deployment/module.ts index 00f2220c..1925c2d1 100644 --- a/src/commands/deployment/module.ts +++ b/src/commands/deployment/module.ts @@ -6,10 +6,10 @@ import { DeploymentService } from "./deployment.service"; class Module extends IModule { public register(context: Context, configurator: Configurator): void { - const deploymentCommand = configurator.command("deployment").beta(); + const deploymentCommand = configurator.command("deployment").earlyAccess(); deploymentCommand.command("create") - .beta() + .earlyAccess() .description("Create a new deployment") .requiredOption("--packageKey ", "Identifier of the package to deploy") .requiredOption("--packageVersion ", "Version of the package to deploy") @@ -18,10 +18,10 @@ class Module extends IModule { .option("--json", "Return the response as a JSON file") .action(this.createDeployment); - const listCommand = deploymentCommand.command("list").beta(); + const listCommand = deploymentCommand.command("list").earlyAccess(); listCommand.command("history") - .beta() + .earlyAccess() .description("List deployment history") .option("--packageKey ", "Filter deployment history by package key") .option("--targetId ", "Filter deployment history by target ID") @@ -34,7 +34,7 @@ class Module extends IModule { .action(this.listDeploymentHistory); listCommand.command("active") - .beta() + .earlyAccess() .description("Get the active deployment(s) for a given target or package.\n"+ "You can use the command to list the active deployment(s) for a specific target or for a specific package.\n" + "The targetIds filter is available only for getting the active deployments for a given package. \n" + @@ -48,14 +48,14 @@ class Module extends IModule { .action(this.listActiveDeployments); listCommand.command("deployables") - .beta() + .earlyAccess() .description("List all deployables") .option("--flavor ", "Filter deployables by flavor") .option("--json", "Return the response as a JSON file") .action(this.listDeployables); listCommand.command("targets") - .beta() + .earlyAccess() .description("List all targets for a given deployable type and package key") .requiredOption("--deployableType ", "The type of the deployable") .requiredOption("--packageKey ", "Identifier of the package to list targets for") diff --git a/src/content-cli.ts b/src/content-cli.ts index b1ecfa41..b5fdc62e 100644 --- a/src/content-cli.ts +++ b/src/content-cli.ts @@ -6,7 +6,6 @@ import { Configurator, IModuleConstructor, ModuleHandler } from "./core/command/ import { Context } from "./core/command/cli-context"; import { VersionUtils } from "./core/utils/version"; import { logger } from "./core/utils/logger"; -import { ContentCLIHelp } from "./core/command/CustomHelp"; /** * Celonis Content CLI. @@ -38,11 +37,6 @@ function addDefaultOptions(program: Command): void { */ export function createProgram(context: Context, opts: CreateProgramOptions = {}): Command { const program = new Command(); - program.configureHelp({ - formatHelp: (cmd, helper) => new ContentCLIHelp().formatHelp(cmd, helper), - subcommandTerm: cmd => new ContentCLIHelp().subcommandTerm(cmd), - optionTerm: opt => new ContentCLIHelp().optionTerm(opt), - }); program.version(VersionUtils.getCurrentCliVersion()); addDefaultOptions(program); diff --git a/src/core/command/CustomHelp.ts b/src/core/command/CustomHelp.ts deleted file mode 100644 index f5f38364..00000000 --- a/src/core/command/CustomHelp.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Command, Help } from "commander"; -import * as chalk from "chalk"; - -export class ContentCLIHelp extends Help { - - public subcommandTerm(cmd: Command): string { - const base = super.subcommandTerm(cmd); - return (cmd as any).isBeta === true - ? `${base} ${chalk.yellow("(beta)")}` - : base; - } - - public optionTerm(option: any): string { - const term = super.optionTerm(option); - return (option as any).isBeta === true - ? `${term} ${chalk.yellow("(beta)")}` - : term; - } -} diff --git a/src/core/command/module-handler.ts b/src/core/command/module-handler.ts index d4853679..31abe707 100644 --- a/src/core/command/module-handler.ts +++ b/src/core/command/module-handler.ts @@ -1,9 +1,9 @@ import path = require("path"); import * as fs from "fs"; -import { Command, CommandOptions, Option, OptionValues } from "commander"; +import { Command, CommandOptions, OptionValues } from "commander"; import { Context } from "./cli-context"; import { GracefulError, logger } from "../utils/logger"; -import * as chalk from "chalk"; +import { FeatureFlagService } from "../feature-flag/feature-flag.service"; export abstract class IModule { public abstract register(context: Context, commandConfig: Configurator): void; @@ -186,13 +186,6 @@ export class CommandConfig { return this; } - public betaOption(flags: string, description?: string, defaultValue?: string | boolean | string[]): CommandConfig { - const option = new Option(flags, description).default(defaultValue); - (option as any).isBeta = true; - this.cmd.addOption(option); - return this; - } - public requiredOption(flags: string, description?: string, defaultValue?: string | boolean | string[]): CommandConfig { this.cmd.requiredOption(flags, description, defaultValue); return this; @@ -203,17 +196,26 @@ export class CommandConfig { return this; } - public beta(): CommandConfig { - (this.cmd as any).isBeta = true; + /** + * Marks a command as early access (non-GA). Purely internal — it is not shown + * in help. When a feature key is provided, the command is gated at run time: + * before it executes, the CLI checks whether that backend feature flag is + * enabled for the team, and stops with a clear message if it is not. + */ + public earlyAccess(featureKey?: string): this { + const command = this.cmd as any + command.isEarlyAccess = true; + if (featureKey) { + command.earlyAccessFeatureKey = featureKey; + } return this; } public action(handler: CommandHandler): void { this.cmd.action(async (): Promise => { try { - this.printBetaNoticeIfBetaCommand(); - this.printBetaNoticeIfBetaOptions(); this.printDeprecationNoticeIfDeprecated(); + await this.checkEarlyAccessFeatureFlag(); await handler(this.ctx, this.cmd, this.cmd.optsWithGlobals()); } catch (error) { if (error instanceof GracefulError) { @@ -232,22 +234,36 @@ export class CommandConfig { } } - private printBetaNoticeIfBetaCommand(): void { - if ((this.cmd as any).isBeta) { - logger.info(chalk.yellow("This command is in beta and may change in future releases.")); - } - } - - private printBetaNoticeIfBetaOptions(): void { - if ((this.cmd as any).isBeta) { + /** + * For early-access commands that carry a feature key, verify the backend + * feature flag is enabled for the team before the command runs. Throws a + * GracefulError with a clear message when it is not enabled (or cannot be + * verified). Commands with no feature key are left untouched. + */ + private async checkEarlyAccessFeatureFlag(): Promise { + const featureKey = (this.cmd as any).earlyAccessFeatureKey; + if (!featureKey) { return; } - const providedOptions = this.cmd.optsWithGlobals(); - for (const option of this.cmd.options) { - const optionName = option.attributeName(); - if ((option as any).isBeta && Object.hasOwn(providedOptions, optionName)) { - logger.info(chalk.yellow(`The option '${option.long}' is in beta and may change in future releases.`)); + + let enabled: boolean; + try { + enabled = await new FeatureFlagService(this.ctx).isEnabled(featureKey); + } catch (error) { + // With no profile, surface the standard "no profile provided" error + // rather than masking it with an early-access message. + if (!this.ctx.profile) { + throw error; } + throw new GracefulError( + `Could not verify whether '${this.cmd.name()}' is enabled for your team. Please try again later or contact support.` + ); + } + + if (!enabled) { + throw new GracefulError( + `'${this.cmd.name()}' is not enabled for your team. Contact support to request access.` + ); } } } diff --git a/src/core/feature-flag/early-access-features.ts b/src/core/feature-flag/early-access-features.ts new file mode 100644 index 00000000..2e586b5f --- /dev/null +++ b/src/core/feature-flag/early-access-features.ts @@ -0,0 +1,13 @@ +/** + * Backend feature-flag keys for early-access commands. + * + * These mirror the backend `FeatureToggle` keys and are the values passed to + * `CommandConfig.earlyAccess(...)`. Keeping them here gives a single place to + * see every early-access feature key even though the wiring lives on the + * individual commands. + */ +export const EarlyAccessFeature = { + BRANCHING: "pacman.branching", +} as const; + +export type EarlyAccessFeatureKey = (typeof EarlyAccessFeature)[keyof typeof EarlyAccessFeature]; diff --git a/src/core/feature-flag/feature-flag.service.ts b/src/core/feature-flag/feature-flag.service.ts new file mode 100644 index 00000000..61ae4a7d --- /dev/null +++ b/src/core/feature-flag/feature-flag.service.ts @@ -0,0 +1,34 @@ +import { Context } from "../command/cli-context"; +import { HttpClient } from "../http/http-client"; + +/** + * A backend ("paid") feature flag as returned by the team-features endpoint. + * Only the key is needed here — presence in the list means the flag is enabled + * for the calling team. + */ +interface TeamFeature { + key: string; +} + +/** + * Queries whether a backend feature flag is enabled for the current team. + * + * Uses the same endpoint the web frontend relies on to resolve backend/per-team + * ("paid") feature flags: `GET /api/team/features` returns the list of flags + * enabled for the team behind the profile's token. A flag is considered enabled + * when an entry with a matching key is present in that list. + */ +export class FeatureFlagService { + private static readonly TEAM_FEATURES_URL = "/api/team/features"; + + private readonly httpClient: () => HttpClient; + + constructor(context: Context) { + this.httpClient = () => context.httpClient; + } + + public async isEnabled(featureKey: string): Promise { + const features: TeamFeature[] = await this.httpClient().get(FeatureFlagService.TEAM_FEATURES_URL); + return Array.isArray(features) && features.some(feature => feature?.key === featureKey); + } +} diff --git a/tests/commands/deployment/module.spec.ts b/tests/commands/deployment/module.spec.ts new file mode 100644 index 00000000..7b370709 --- /dev/null +++ b/tests/commands/deployment/module.spec.ts @@ -0,0 +1,44 @@ +import Module = require("../../../src/commands/deployment/module"); +import { testContext } from "../../utls/test-context"; +import { createMockConfigurator } from "../../utls/configurator-mock"; + +describe("Deployment Module", () => { + describe("register", () => { + it("registers the deployment command groups without throwing", () => { + const mockConfigurator = createMockConfigurator(); + + expect(() => new Module().register(testContext, mockConfigurator)).not.toThrow(); + + expect(mockConfigurator.command).toHaveBeenCalledWith("deployment"); + expect(mockConfigurator.command).toHaveBeenCalledWith("create"); + expect(mockConfigurator.command).toHaveBeenCalledWith("list"); + expect(mockConfigurator.command).toHaveBeenCalledWith("history"); + expect(mockConfigurator.command).toHaveBeenCalledWith("active"); + expect(mockConfigurator.command).toHaveBeenCalledWith("deployables"); + expect(mockConfigurator.command).toHaveBeenCalledWith("targets"); + }); + + it("marks every deployment command as early access", () => { + const mockConfigurator = createMockConfigurator(); + + new Module().register(testContext, mockConfigurator); + + // deployment, create, list, history, active, deployables, targets + const expectedEarlyAccessCommands = 7; + expect(mockConfigurator.earlyAccess).toHaveBeenCalledTimes(expectedEarlyAccessCommands); + }); + + it("wires an action handler for every leaf subcommand", () => { + const mockConfigurator = createMockConfigurator(); + + new Module().register(testContext, mockConfigurator); + + // create, history, active, deployables, targets + const expectedLeafCommands = 5; + expect(mockConfigurator.action).toHaveBeenCalledTimes(expectedLeafCommands); + for (const call of mockConfigurator.action.mock.calls) { + expect(typeof call[0]).toBe("function"); + } + }); + }); +}); diff --git a/tests/core/command/module-handler.spec.ts b/tests/core/command/module-handler.spec.ts index 956851d9..df58d23b 100644 --- a/tests/core/command/module-handler.spec.ts +++ b/tests/core/command/module-handler.spec.ts @@ -1,8 +1,16 @@ import { Command } from "commander"; import { Configurator } from "../../../src/core/command/module-handler"; +import { Context } from "../../../src/core/command/cli-context"; import { GracefulError } from "../../../src/core/utils/logger"; import { loggingTestTransport } from "../../jest.setup"; import { testContext } from "../../utls/test-context"; +import { mockAxiosGet, mockAxiosGetError } from "../../utls/http-requests-mock"; + +const TEAM_FEATURES_URL = "https://myTeam.celonis.cloud/api/team/features"; + +function loggedMessages(): string[] { + return loggingTestTransport.logMessages.map(entry => String(entry.message)); +} describe("CommandConfig action error handling", () => { let previousExitCode: number | undefined; @@ -60,3 +68,85 @@ describe("CommandConfig action error handling", () => { ).toBe(true); }); }); + +describe("CommandConfig early access gating", () => { + let previousExitCode: number | undefined; + + beforeEach(() => { + previousExitCode = process.exitCode; + process.exitCode = 0; + loggingTestTransport.logMessages = []; + }); + + afterEach(() => { + process.exitCode = previousExitCode; + }); + + async function runEarlyAccessCommand( + featureKey: string | undefined, + context: Context, + handler: () => Promise + ): Promise { + const program = new Command(); + const configurator = new Configurator(program, context); + + configurator + .command("ea-command") + .earlyAccess(featureKey) + .action(async () => { + await handler(); + }); + + program.exitOverride(); + await program.parseAsync(["node", "content-cli", "ea-command"]); + } + + it("runs the command when the feature flag is enabled", async () => { + mockAxiosGet(TEAM_FEATURES_URL, [{ key: "pacman.branching" }]); + const handler = jest.fn(async () => undefined); + + await runEarlyAccessCommand("pacman.branching", testContext, handler); + + expect(handler).toHaveBeenCalled(); + }); + + it("blocks with a clear message when the feature flag is disabled", async () => { + mockAxiosGet(TEAM_FEATURES_URL, []); + const handler = jest.fn(async () => undefined); + + await runEarlyAccessCommand("pacman.branching", testContext, handler); + + expect(handler).not.toHaveBeenCalled(); + expect(process.exitCode ?? 0).toBe(0); + expect(loggedMessages().some(message => message.includes("is not enabled for your team"))).toBe(true); + }); + + it("shows a could-not-verify message when the feature check fails", async () => { + mockAxiosGetError(TEAM_FEATURES_URL, 500, { error: "boom" }); + const handler = jest.fn(async () => undefined); + + await runEarlyAccessCommand("pacman.branching", testContext, handler); + + expect(handler).not.toHaveBeenCalled(); + expect(loggedMessages().some(message => message.includes("Could not verify"))).toBe(true); + }); + + it("surfaces the no-profile error, not the early-access message, when no profile is set", async () => { + const noProfileContext = new Context({}); + const handler = jest.fn(async () => undefined); + + await runEarlyAccessCommand("pacman.branching", noProfileContext, handler); + + expect(handler).not.toHaveBeenCalled(); + expect(loggedMessages().some(message => message.includes("No profile provided"))).toBe(true); + expect(loggedMessages().some(message => message.includes("is not enabled for your team"))).toBe(false); + }); + + it("does not gate commands marked early access without a feature key", async () => { + const handler = jest.fn(async () => undefined); + + await runEarlyAccessCommand(undefined, testContext, handler); + + expect(handler).toHaveBeenCalled(); + }); +}); diff --git a/tests/core/feature-flag/feature-flag.service.spec.ts b/tests/core/feature-flag/feature-flag.service.spec.ts new file mode 100644 index 00000000..8ee83a06 --- /dev/null +++ b/tests/core/feature-flag/feature-flag.service.spec.ts @@ -0,0 +1,53 @@ +import { FeatureFlagService } from "../../../src/core/feature-flag/feature-flag.service"; +import { testContext } from "../../utls/test-context"; +import { mockAxiosGet, mockAxiosGetError } from "../../utls/http-requests-mock"; + +const TEAM_FEATURES_URL = "https://myTeam.celonis.cloud/api/team/features"; + +describe("FeatureFlagService", () => { + it("returns true when the feature key is present in the team features", async () => { + mockAxiosGet(TEAM_FEATURES_URL, [{ key: "pacman.branching" }, { key: "some.other.flag" }]); + + const enabled = await new FeatureFlagService(testContext).isEnabled("pacman.branching"); + + expect(enabled).toBe(true); + }); + + it("returns false when the feature key is not present", async () => { + mockAxiosGet(TEAM_FEATURES_URL, [{ key: "some.other.flag" }]); + + const enabled = await new FeatureFlagService(testContext).isEnabled("pacman.branching"); + + expect(enabled).toBe(false); + }); + + it("returns false when the team has no features enabled", async () => { + mockAxiosGet(TEAM_FEATURES_URL, []); + + const enabled = await new FeatureFlagService(testContext).isEnabled("pacman.branching"); + + expect(enabled).toBe(false); + }); + + it("returns false when the response is not an array", async () => { + mockAxiosGet(TEAM_FEATURES_URL, null); + + const enabled = await new FeatureFlagService(testContext).isEnabled("pacman.branching"); + + expect(enabled).toBe(false); + }); + + it("ignores null entries and still matches a present key", async () => { + mockAxiosGet(TEAM_FEATURES_URL, [null, { key: "pacman.branching" }]); + + const enabled = await new FeatureFlagService(testContext).isEnabled("pacman.branching"); + + expect(enabled).toBe(true); + }); + + it("rejects when the team features endpoint fails", async () => { + mockAxiosGetError(TEAM_FEATURES_URL, 500, { error: "boom" }); + + await expect(new FeatureFlagService(testContext).isEnabled("pacman.branching")).rejects.toBeDefined(); + }); +}); diff --git a/tests/integration/commands/early-access.spec.ts b/tests/integration/commands/early-access.spec.ts new file mode 100644 index 00000000..35e143a8 --- /dev/null +++ b/tests/integration/commands/early-access.spec.ts @@ -0,0 +1,63 @@ +import { IModule, Configurator } from "../../../src/core/command/module-handler"; +import { Context } from "../../../src/core/command/cli-context"; +import { EarlyAccessFeature } from "../../../src/core/feature-flag/early-access-features"; +import { CliRunResult, runCli as runCliProcess } from "../../utls/cli-runner"; +import { mockAxiosGet, mockAxiosGetError } from "../../utls/http-requests-mock"; + +const TEAM_FEATURES_URL = "https://myTeam.celonis.cloud/api/team/features"; + +const handlerSpy = jest.fn(async () => undefined); + +/** + * Throwaway module that registers a single early-access command gated on the + * branching feature flag, so we can exercise the gate end-to-end through the + * real CLI program via runCli. + */ +class EarlyAccessTestModule extends IModule { + public register(_context: Context, configurator: Configurator): void { + configurator + .command("ea-test") + .earlyAccess(EarlyAccessFeature.BRANCHING) + .action(handlerSpy); + } +} + +describe("early access command integration", () => { + beforeEach(() => { + handlerSpy.mockClear(); + }); + + async function runCli(args: string[]): Promise { + return runCliProcess(args, [EarlyAccessTestModule]); + } + + it("runs the command when the feature flag is enabled for the team", async () => { + mockAxiosGet(TEAM_FEATURES_URL, [{ key: EarlyAccessFeature.BRANCHING }]); + + const result = await runCli(["ea-test"]); + + expect(result.exitCode).toBe(0); + expect(handlerSpy).toHaveBeenCalled(); + }); + + it("blocks with a clear message when the feature flag is disabled", async () => { + mockAxiosGet(TEAM_FEATURES_URL, []); + + const result = await runCli(["ea-test"]); + + expect(result.exitCode).toBe(0); + expect(handlerSpy).not.toHaveBeenCalled(); + expect(result.output).toContain("is not enabled for your team"); + expect(result.output).toContain("Contact support to request access"); + }); + + it("shows a could-not-verify message when the feature check fails", async () => { + mockAxiosGetError(TEAM_FEATURES_URL, 500, { error: "boom" }); + + const result = await runCli(["ea-test"]); + + expect(result.exitCode).toBe(0); + expect(handlerSpy).not.toHaveBeenCalled(); + expect(result.output).toContain("Could not verify whether 'ea-test' is enabled"); + }); +}); diff --git a/tests/utls/configurator-mock.ts b/tests/utls/configurator-mock.ts index a22fe19d..cb141810 100644 --- a/tests/utls/configurator-mock.ts +++ b/tests/utls/configurator-mock.ts @@ -17,9 +17,8 @@ export interface MockConfigurator extends Configurator { requiredOption: jest.Mock; alias: jest.Mock; argument: jest.Mock; - betaOption: jest.Mock; deprecationNotice: jest.Mock; - beta: jest.Mock; + earlyAccess: jest.Mock; action: jest.Mock; } @@ -32,9 +31,8 @@ export function createMockConfigurator(): MockConfigurator { chain.requiredOption = jest.fn(() => chain); chain.alias = jest.fn(() => chain); chain.argument = jest.fn(() => chain); - chain.betaOption = jest.fn(() => chain); chain.deprecationNotice = jest.fn(() => chain); - chain.beta = jest.fn(() => chain); + chain.earlyAccess = jest.fn(() => chain); chain.action = jest.fn(); return chain as MockConfigurator;