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
2 changes: 1 addition & 1 deletion docs/user-guide/deployment-commands.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Deployment Commands (beta)
# Deployment Commands

The **deployment** command group allows you to create deployments, list their history, check active deployments, and retrieve deployables and targets.

Expand Down
9 changes: 2 additions & 7 deletions src/commands/deployment/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +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")
.description("Create deployments, list their history, check active deployments, and retrieve deployables and targets");

deploymentCommand.command("create")
.beta()
.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 +19,9 @@ 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");

listCommand.command("history")
.beta()
.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 +34,6 @@ class Module extends IModule {
.action(this.listDeploymentHistory);

listCommand.command("active")
.beta()
.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 +47,12 @@ class Module extends IModule {
.action(this.listActiveDeployments);

listCommand.command("deployables")
.beta()
.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()
.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
17 changes: 13 additions & 4 deletions src/core/command/module-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as fs from "fs";
import { Command, CommandOptions, Option, OptionValues } from "commander";
import { Context } from "./cli-context";
import { GracefulError, logger } from "../utils/logger";
import { isFeatureDisabledError } from "../feature-flag/feature-disabled-error";
import * as chalk from "chalk";

export abstract class IModule {
Expand Down Expand Up @@ -129,8 +130,8 @@ export class Configurator {
public rootCommandMap = new Map<string, CommandConfig>();

constructor(
private program: Command,
private ctx: Context
private readonly program: Command,
private readonly ctx: Context
) {}

/**
Expand All @@ -157,8 +158,8 @@ export class CommandConfig {
private deprecationMessage: string;

constructor(
private cmd: Command,
private ctx: Context
private readonly cmd: Command,
private readonly ctx: Context
) {}

public command(nameAndArgs: string, opts?: CommandOptions): CommandConfig {
Expand Down Expand Up @@ -220,6 +221,14 @@ export class CommandConfig {
logger.error(error.message);
return;
}
// Backend gates early-access features; translate its "feature disabled"
// rejection into a clear message instead of a raw error.
if (isFeatureDisabledError(error)) {
logger.error(
`'${this.cmd.name()}' is not enabled for your team. Contact support to request access.`
);
return;
}
logger.error(`An unexpected error occured executing a command: ${error}`);
process.exitCode = 1;
}
Expand Down
38 changes: 38 additions & 0 deletions src/core/feature-flag/feature-disabled-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Recognizes the backend's "feature not enabled" response so the CLI can surface a
* clear message instead of a raw error. Enforcement stays entirely in the backend
* (pacman); the CLI only translates the response — it never decides entitlement.
*
* The backend rejects a flag-gated request with 403 and a machine-readable body:
* {"errorCode":"feature-disabled", ...} (pacman's standard ErrorTransport). Keying
* off the stable code (rather than a free-text message) keeps this robust.
*/
export const FEATURE_DISABLED_CODE = "feature-disabled";

/**
* True when the given error represents a backend "feature disabled" rejection.
* HttpClient rejects with the stringified response body (often wrapped as
* "FatalError: {json}"), so we extract and parse the embedded JSON payload.
*/
export function isFeatureDisabledError(error: unknown): boolean {
return extractErrorCode(error) === FEATURE_DISABLED_CODE;
}

function extractErrorCode(error: unknown): string | undefined {
let text = "";
if (error instanceof Error) {
text = error.message;
} else if (typeof error === "string") {
text = error;
}
const jsonStart = text.indexOf("{");
if (jsonStart === -1) {
return undefined;
}
try {
const payload = JSON.parse(text.slice(jsonStart));
return typeof payload.errorCode === "string" ? payload.errorCode : undefined;
} catch {
return undefined;
}
}
34 changes: 34 additions & 0 deletions tests/commands/deployment/module.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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("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");
}
});
});
});
18 changes: 18 additions & 0 deletions tests/core/command/module-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,22 @@ describe("CommandConfig action error handling", () => {
)
).toBe(true);
});

it("translates a backend feature-disabled error into a clear message (exit 0)", async () => {
await runCommand(async () => {
throw new Error('{"errorCode":"feature-disabled","feature":"pacman.branching"}');
});

expect(process.exitCode ?? 0).toBe(0);
expect(
loggingTestTransport.logMessages.some(entry =>
String(entry.message).includes("is not enabled for your team")
)
).toBe(true);
expect(
loggingTestTransport.logMessages.some(entry =>
String(entry.message).includes("An unexpected error occured executing a command")
)
).toBe(false);
});
});
37 changes: 37 additions & 0 deletions tests/core/feature-flag/feature-disabled-error.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { isFeatureDisabledError } from "../../../src/core/feature-flag/feature-disabled-error";
import { FatalError } from "../../../src/core/utils/logger";

describe("isFeatureDisabledError", () => {
it("returns true for a backend feature-disabled body", () => {
expect(isFeatureDisabledError(new Error('{"errorCode":"feature-disabled","feature":"pacman.branching"}'))).toBe(true);
});

it("returns true when the body is wrapped by FatalError (prefixed)", () => {
expect(isFeatureDisabledError(new FatalError('FatalError: {"errorCode":"feature-disabled"}'))).toBe(true);
});

it("returns true for a raw string payload", () => {
expect(isFeatureDisabledError('{"errorCode":"feature-disabled"}')).toBe(true);
});

it("returns false for a different backend error code", () => {
expect(isFeatureDisabledError(new Error('{"errorCode":"optimistic-lock"}'))).toBe(false);
});

it("returns false for a non-JSON error", () => {
expect(isFeatureDisabledError(new Error("Backend responded with status code 403"))).toBe(false);
});

it("returns false when a brace is present but the payload is not valid JSON", () => {
expect(isFeatureDisabledError(new Error("something { not valid json"))).toBe(false);
});

it("returns false when errorCode is not a string", () => {
expect(isFeatureDisabledError(new Error('{"errorCode":123}'))).toBe(false);
});

it("returns false for undefined / non-error input", () => {
expect(isFeatureDisabledError(undefined)).toBe(false);
expect(isFeatureDisabledError(42)).toBe(false);
});
});
Loading