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,11 +6,11 @@ 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()

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.

FYI, the deployment feature is GA, so this beta() marker was already a leftover, and moving it off here is the right cleanup. One thing still hanging around: docs/user-guide/deployment-commands.md is titled "Deployment Commands (beta)", so the docs and the CLI now disagree. We know fully removing the beta tag isn't the point of this PR, so feel free to leave it for a followup PR if it's too much hassle to fold in here.

.description("Create deployments, list their history, check active deployments, and retrieve deployables and targets");

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 @@ -20,10 +20,10 @@ class Module extends IModule {
.action(this.createDeployment);

const listCommand = deploymentCommand.command("list")
.description("List deployment history, active deployments, deployables or targets").beta();
.description("List deployment history, active deployments, deployables or targets").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 @@ -36,7 +36,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 @@ -50,14 +50,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.

60 changes: 34 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,24 @@ export class CommandConfig {
return this;
}

public beta(): CommandConfig {
(this.cmd as any).isBeta = true;
/**
* Marks a command as early access (internal, not shown in help). With a feature
* key, the command is gated at run time on that backend flag.
*/
public earlyAccess(featureKey?: string): this {

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.

Zooming out from deployments: collapsing beta() and betaOption() into earlyAccess() also removes our only way to tell a user "this command (or option) is beta and may change". Deployment is GA so it doesn't need that, but I think the beta/betaOption capability (or something equivalent) is worth keeping around for future commands that genuinely are early-access or unstable. betaOption in particular was unused, but the ability to flag a single option that way is the kind of thing we'll likely want again rather than rebuild later. Could we keep the mechanism even if nothing uses it today?

const command = this.cmd as any
command.isEarlyAccess = true;

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.

Small thing that might trip us up later: isEarlyAccess gets set here but nothing reads it anymore. It was only ever used by the old CustomHelp to render the (beta) label, and that's gone in this PR. The doc comment also says "not shown in help", but that isn't actually true now: these commands still appear in --help, just without a label. Could we pick one direction, either genuinely hide them (cmd.hidden = true) or drop the unused flag?

(nit: missing semicolon after const command = this.cmd as any.)

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 +232,30 @@ 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) {
/** Blocks an early-access command (one with a feature key) unless its flag is enabled for the team. */
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);

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.

Bigger-picture question on the approach: this turns the CLI into a second source of truth for "is this command allowed". It reads /api/team/features and decides, while the command's real authorization still lives on the backend. If those two ever drift (flag present but the call still 403s, or the command works without the flag), the user is back to a confusing experience. Did we weigh the other direction, letting the command run and translating its actual 403 into the friendly "not enabled, contact support" message? That keeps a single source of truth, avoids the extra round-trip, and can't fall out of sync with the backend. This approach isn't wrong, but since nothing uses a key yet, it feels like a good moment to settle on which model we want.

} catch (error) {
// No profile: let the standard "no profile provided" error surface instead of masking it.
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.`

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.

A couple of edge cases worth thinking through here:

  • This is fail-closed, so if /api/team/features is down or slow, even a team that is entitled gets blocked. For an early-access gate that's probably an acceptable trade-off, but let's make it a deliberate one.
  • An expired or invalid token comes back as a 401 and gets reported as "could not verify whether X is enabled", which points the user at the wrong problem. Might be worth letting real auth errors surface as auth errors.

);
}

if (!enabled) {
throw new GracefulError(
`'${this.cmd.name()}' is not enabled for your team. Contact support to request access.`
);
}
}
}
6 changes: 6 additions & 0 deletions src/core/feature-flag/early-access-features.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** Backend feature-flag keys for early-access commands (passed to `earlyAccess(...)`). */
export const EarlyAccessFeature = {
BRANCHING: "pacman.branching",
} as const;

export type EarlyAccessFeatureKey = (typeof EarlyAccessFeature)[keyof typeof EarlyAccessFeature];
25 changes: 25 additions & 0 deletions src/core/feature-flag/feature-flag.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Context } from "../command/cli-context";
import { HttpClient } from "../http/http-client";

interface TeamFeature {
key: string;
}

/**
* Checks whether a backend feature flag is enabled for the current team via
* `GET /api/team/features` — a flag is enabled when its key is in the returned 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);
}
}
19 changes: 19 additions & 0 deletions tests/commands/action-flows/module.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Module = require("../../../src/commands/action-flows/module");
import { testContext } from "../../utls/test-context";
import { createMockConfigurator } from "../../utls/configurator-mock";

describe("Action Flows Module", () => {
describe("register", () => {
it("registers the action-flows command groups without throwing", () => {
const mockConfigurator = createMockConfigurator();

expect(() => new Module().register(testContext, mockConfigurator)).not.toThrow();

expect(mockConfigurator.command).toHaveBeenCalledWith("analyze");
expect(mockConfigurator.command).toHaveBeenCalledWith("export");
expect(mockConfigurator.command).toHaveBeenCalledWith("import");
expect(mockConfigurator.command).toHaveBeenCalledWith("pull");
expect(mockConfigurator.command).toHaveBeenCalledWith("push");
});
});
});
23 changes: 23 additions & 0 deletions tests/commands/data-pipeline/module.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Module = require("../../../src/commands/data-pipeline/module");
import { testContext } from "../../utls/test-context";
import { createMockConfigurator } from "../../utls/configurator-mock";

describe("Data Pipeline Module", () => {
describe("register", () => {
it("registers the data-pool and connection command groups without throwing", () => {
const mockConfigurator = createMockConfigurator();

expect(() => new Module().register(testContext, mockConfigurator)).not.toThrow();

// data-pool commands
expect(mockConfigurator.command).toHaveBeenCalledWith("export");
expect(mockConfigurator.command).toHaveBeenCalledWith("import");
expect(mockConfigurator.command).toHaveBeenCalledWith("list");
expect(mockConfigurator.command).toHaveBeenCalledWith("pull");
expect(mockConfigurator.command).toHaveBeenCalledWith("push");
// connection commands
expect(mockConfigurator.command).toHaveBeenCalledWith("get");
expect(mockConfigurator.command).toHaveBeenCalledWith("set");
});
});
});
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