Skip to content

Commit bd8fea1

Browse files
authored
SP-1383: gate early-access commands behind feature flag, replace beta (#393)
1 parent 7af5ac7 commit bd8fea1

7 files changed

Lines changed: 143 additions & 12 deletions

File tree

docs/user-guide/deployment-commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Deployment Commands (beta)
1+
# Deployment Commands
22

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

src/commands/deployment/module.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,10 @@ import { DeploymentService } from "./deployment.service";
66
class Module extends IModule {
77

88
public register(context: Context, configurator: Configurator): void {
9-
const deploymentCommand = configurator.command("deployment").beta()
9+
const deploymentCommand = configurator.command("deployment")
1010
.description("Create deployments, list their history, check active deployments, and retrieve deployables and targets");
1111

1212
deploymentCommand.command("create")
13-
.beta()
1413
.description("Create a new deployment")
1514
.requiredOption("--packageKey <packageKey>", "Identifier of the package to deploy")
1615
.requiredOption("--packageVersion <packageVersion>", "Version of the package to deploy")
@@ -20,10 +19,9 @@ class Module extends IModule {
2019
.action(this.createDeployment);
2120

2221
const listCommand = deploymentCommand.command("list")
23-
.description("List deployment history, active deployments, deployables or targets").beta();
22+
.description("List deployment history, active deployments, deployables or targets");
2423

2524
listCommand.command("history")
26-
.beta()
2725
.description("List deployment history")
2826
.option("--packageKey <packageKey>", "Filter deployment history by package key")
2927
.option("--targetId <targetId>", "Filter deployment history by target ID")
@@ -36,7 +34,6 @@ class Module extends IModule {
3634
.action(this.listDeploymentHistory);
3735

3836
listCommand.command("active")
39-
.beta()
4037
.description("Get the active deployment(s) for a given target or package.\n"+
4138
"You can use the command to list the active deployment(s) for a specific target or for a specific package.\n" +
4239
"The targetIds filter is available only for getting the active deployments for a given package. \n" +
@@ -50,14 +47,12 @@ class Module extends IModule {
5047
.action(this.listActiveDeployments);
5148

5249
listCommand.command("deployables")
53-
.beta()
5450
.description("List all deployables")
5551
.option("--flavor <flavor>", "Filter deployables by flavor")
5652
.option("--json", "Return the response as a JSON file")
5753
.action(this.listDeployables);
5854

5955
listCommand.command("targets")
60-
.beta()
6156
.description("List all targets for a given deployable type and package key")
6257
.requiredOption("--deployableType <deployableType>", "The type of the deployable")
6358
.requiredOption("--packageKey <packageKey>", "Identifier of the package to list targets for")

src/core/command/module-handler.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as fs from "fs";
33
import { Command, CommandOptions, Option, OptionValues } from "commander";
44
import { Context } from "./cli-context";
55
import { GracefulError, logger } from "../utils/logger";
6+
import { isFeatureDisabledError } from "../feature-flag/feature-disabled-error";
67
import * as chalk from "chalk";
78

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

131132
constructor(
132-
private program: Command,
133-
private ctx: Context
133+
private readonly program: Command,
134+
private readonly ctx: Context
134135
) {}
135136

136137
/**
@@ -157,8 +158,8 @@ export class CommandConfig {
157158
private deprecationMessage: string;
158159

159160
constructor(
160-
private cmd: Command,
161-
private ctx: Context
161+
private readonly cmd: Command,
162+
private readonly ctx: Context
162163
) {}
163164

164165
public command(nameAndArgs: string, opts?: CommandOptions): CommandConfig {
@@ -220,6 +221,14 @@ export class CommandConfig {
220221
logger.error(error.message);
221222
return;
222223
}
224+
// Backend gates early-access features; translate its "feature disabled"
225+
// rejection into a clear message instead of a raw error.
226+
if (isFeatureDisabledError(error)) {
227+
logger.error(
228+
`'${this.cmd.name()}' is not enabled for your team. Contact support to request access.`
229+
);
230+
return;
231+
}
223232
logger.error(`An unexpected error occured executing a command: ${error}`);
224233
process.exitCode = 1;
225234
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Recognizes the backend's "feature not enabled" response so the CLI can surface a
3+
* clear message instead of a raw error. Enforcement stays entirely in the backend
4+
* (pacman); the CLI only translates the response — it never decides entitlement.
5+
*
6+
* The backend rejects a flag-gated request with 403 and a machine-readable body:
7+
* {"errorCode":"feature-disabled", ...} (pacman's standard ErrorTransport). Keying
8+
* off the stable code (rather than a free-text message) keeps this robust.
9+
*/
10+
export const FEATURE_DISABLED_CODE = "feature-disabled";
11+
12+
/**
13+
* True when the given error represents a backend "feature disabled" rejection.
14+
* HttpClient rejects with the stringified response body (often wrapped as
15+
* "FatalError: {json}"), so we extract and parse the embedded JSON payload.
16+
*/
17+
export function isFeatureDisabledError(error: unknown): boolean {
18+
return extractErrorCode(error) === FEATURE_DISABLED_CODE;
19+
}
20+
21+
function extractErrorCode(error: unknown): string | undefined {
22+
let text = "";
23+
if (error instanceof Error) {
24+
text = error.message;
25+
} else if (typeof error === "string") {
26+
text = error;
27+
}
28+
const jsonStart = text.indexOf("{");
29+
if (jsonStart === -1) {
30+
return undefined;
31+
}
32+
try {
33+
const payload = JSON.parse(text.slice(jsonStart));
34+
return typeof payload.errorCode === "string" ? payload.errorCode : undefined;
35+
} catch {
36+
return undefined;
37+
}
38+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import Module = require("../../../src/commands/deployment/module");
2+
import { testContext } from "../../utls/test-context";
3+
import { createMockConfigurator } from "../../utls/configurator-mock";
4+
5+
describe("Deployment Module", () => {
6+
describe("register", () => {
7+
it("registers the deployment command groups without throwing", () => {
8+
const mockConfigurator = createMockConfigurator();
9+
10+
expect(() => new Module().register(testContext, mockConfigurator)).not.toThrow();
11+
12+
expect(mockConfigurator.command).toHaveBeenCalledWith("deployment");
13+
expect(mockConfigurator.command).toHaveBeenCalledWith("create");
14+
expect(mockConfigurator.command).toHaveBeenCalledWith("list");
15+
expect(mockConfigurator.command).toHaveBeenCalledWith("history");
16+
expect(mockConfigurator.command).toHaveBeenCalledWith("active");
17+
expect(mockConfigurator.command).toHaveBeenCalledWith("deployables");
18+
expect(mockConfigurator.command).toHaveBeenCalledWith("targets");
19+
});
20+
21+
it("wires an action handler for every leaf subcommand", () => {
22+
const mockConfigurator = createMockConfigurator();
23+
24+
new Module().register(testContext, mockConfigurator);
25+
26+
// create, history, active, deployables, targets
27+
const expectedLeafCommands = 5;
28+
expect(mockConfigurator.action).toHaveBeenCalledTimes(expectedLeafCommands);
29+
for (const call of mockConfigurator.action.mock.calls) {
30+
expect(typeof call[0]).toBe("function");
31+
}
32+
});
33+
});
34+
});

tests/core/command/module-handler.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,22 @@ describe("CommandConfig action error handling", () => {
5959
)
6060
).toBe(true);
6161
});
62+
63+
it("translates a backend feature-disabled error into a clear message (exit 0)", async () => {
64+
await runCommand(async () => {
65+
throw new Error('{"errorCode":"feature-disabled","feature":"pacman.branching"}');
66+
});
67+
68+
expect(process.exitCode ?? 0).toBe(0);
69+
expect(
70+
loggingTestTransport.logMessages.some(entry =>
71+
String(entry.message).includes("is not enabled for your team")
72+
)
73+
).toBe(true);
74+
expect(
75+
loggingTestTransport.logMessages.some(entry =>
76+
String(entry.message).includes("An unexpected error occured executing a command")
77+
)
78+
).toBe(false);
79+
});
6280
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { isFeatureDisabledError } from "../../../src/core/feature-flag/feature-disabled-error";
2+
import { FatalError } from "../../../src/core/utils/logger";
3+
4+
describe("isFeatureDisabledError", () => {
5+
it("returns true for a backend feature-disabled body", () => {
6+
expect(isFeatureDisabledError(new Error('{"errorCode":"feature-disabled","feature":"pacman.branching"}'))).toBe(true);
7+
});
8+
9+
it("returns true when the body is wrapped by FatalError (prefixed)", () => {
10+
expect(isFeatureDisabledError(new FatalError('FatalError: {"errorCode":"feature-disabled"}'))).toBe(true);
11+
});
12+
13+
it("returns true for a raw string payload", () => {
14+
expect(isFeatureDisabledError('{"errorCode":"feature-disabled"}')).toBe(true);
15+
});
16+
17+
it("returns false for a different backend error code", () => {
18+
expect(isFeatureDisabledError(new Error('{"errorCode":"optimistic-lock"}'))).toBe(false);
19+
});
20+
21+
it("returns false for a non-JSON error", () => {
22+
expect(isFeatureDisabledError(new Error("Backend responded with status code 403"))).toBe(false);
23+
});
24+
25+
it("returns false when a brace is present but the payload is not valid JSON", () => {
26+
expect(isFeatureDisabledError(new Error("something { not valid json"))).toBe(false);
27+
});
28+
29+
it("returns false when errorCode is not a string", () => {
30+
expect(isFeatureDisabledError(new Error('{"errorCode":123}'))).toBe(false);
31+
});
32+
33+
it("returns false for undefined / non-error input", () => {
34+
expect(isFeatureDisabledError(undefined)).toBe(false);
35+
expect(isFeatureDisabledError(42)).toBe(false);
36+
});
37+
});

0 commit comments

Comments
 (0)