Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ npm-debug.log
.idea
*.iml
.DS_Store

# Test coverage output
coverage/
14 changes: 7 additions & 7 deletions src/commands/deployment/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <packageKey>", "Identifier of the package to deploy")
.requiredOption("--packageVersion <packageVersion>", "Version of the package to deploy")
Expand All @@ -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 <packageKey>", "Filter deployment history by package key")
.option("--targetId <targetId>", "Filter deployment history by target ID")
Expand All @@ -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" +
Expand All @@ -48,14 +48,14 @@ class Module extends IModule {
.action(this.listActiveDeployments);

listCommand.command("deployables")
.beta()
.earlyAccess()
.description("List all deployables")
.option("--flavor <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 <deployableType>", "The type of the deployable")
.requiredOption("--packageKey <packageKey>", "Identifier of the package to list targets for")
Expand Down
6 changes: 0 additions & 6 deletions src/content-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);

Expand Down
19 changes: 0 additions & 19 deletions src/core/command/CustomHelp.ts

This file was deleted.

68 changes: 42 additions & 26 deletions src/core/command/module-handler.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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<void> => {
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) {
Expand All @@ -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<void> {
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;
Comment thread
tneum-celonis marked this conversation as resolved.
}
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.`
);
}
}
}
13 changes: 13 additions & 0 deletions src/core/feature-flag/early-access-features.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Backend feature-flag keys for early-access commands.

@kuvia Jing Sun (kuvia) Jul 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you clean up the excessive comments a bit? I'm not sure they're all useful, for example this one

*
* 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];
34 changes: 34 additions & 0 deletions src/core/feature-flag/feature-flag.service.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
const features: TeamFeature[] = await this.httpClient().get(FeatureFlagService.TEAM_FEATURES_URL);
return Array.isArray(features) && features.some(feature => feature?.key === featureKey);
}
}
44 changes: 44 additions & 0 deletions tests/commands/deployment/module.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
}
});
});
});
Loading
Loading