diff --git a/docs/user-guide/deployment-commands.md b/docs/user-guide/deployment-commands.md index 08cc0714..469ee746 100644 --- a/docs/user-guide/deployment-commands.md +++ b/docs/user-guide/deployment-commands.md @@ -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. diff --git a/src/commands/deployment/module.ts b/src/commands/deployment/module.ts index 0cf9ead1..2605a4c8 100644 --- a/src/commands/deployment/module.ts +++ b/src/commands/deployment/module.ts @@ -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 ", "Identifier of the package to deploy") .requiredOption("--packageVersion ", "Version of the package to deploy") @@ -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 ", "Filter deployment history by package key") .option("--targetId ", "Filter deployment history by target ID") @@ -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" + @@ -50,14 +47,12 @@ class Module extends IModule { .action(this.listActiveDeployments); listCommand.command("deployables") - .beta() .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() .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/core/command/module-handler.ts b/src/core/command/module-handler.ts index d4853679..8a4dd8ee 100644 --- a/src/core/command/module-handler.ts +++ b/src/core/command/module-handler.ts @@ -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 { @@ -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; } diff --git a/src/core/feature-flag/feature-disabled-error.ts b/src/core/feature-flag/feature-disabled-error.ts new file mode 100644 index 00000000..4999a38b --- /dev/null +++ b/src/core/feature-flag/feature-disabled-error.ts @@ -0,0 +1,33 @@ +/** + * 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 { + const text = error instanceof Error ? error.message : typeof error === "string" ? 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; + } +} diff --git a/tests/core/command/module-handler.spec.ts b/tests/core/command/module-handler.spec.ts index 956851d9..37508d5a 100644 --- a/tests/core/command/module-handler.spec.ts +++ b/tests/core/command/module-handler.spec.ts @@ -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); + }); }); diff --git a/tests/core/feature-flag/feature-disabled-error.spec.ts b/tests/core/feature-flag/feature-disabled-error.spec.ts new file mode 100644 index 00000000..c766dfaa --- /dev/null +++ b/tests/core/feature-flag/feature-disabled-error.spec.ts @@ -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); + }); +});