@contrail/cli 3.5.4 → 3.5.5-alpha.5c010ac

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@ $ npm install -g @contrail/cli
17
17
  $ contrail COMMAND
18
18
  running command...
19
19
  $ contrail (--version)
20
- @contrail/cli/3.5.4 linux-x64 node-v24.20.0
20
+ @contrail/cli/3.5.5-alpha.5c010ac linux-x64 node-v24.20.0
21
21
  $ contrail --help [COMMAND]
22
22
  USAGE
23
23
  $ contrail COMMAND
@@ -51,6 +51,8 @@ To get instructions on how to set up autocomplete for your shell, run `contrail
51
51
  * [`contrail config list`](#contrail-config-list)
52
52
  * [`contrail config set`](#contrail-config-set)
53
53
  * [`contrail config use [NAME]`](#contrail-config-use-name)
54
+ * [`contrail feature-flags export`](#contrail-feature-flags-export)
55
+ * [`contrail feature-flags import`](#contrail-feature-flags-import)
54
56
  * [`contrail file upload FILELOCATION CONTENTTYPE`](#contrail-file-upload-filelocation-contenttype)
55
57
  * [`contrail groups export`](#contrail-groups-export)
56
58
  * [`contrail groups import GROUPSFILE`](#contrail-groups-import-groupsfile)
@@ -537,6 +539,53 @@ EXAMPLES
537
539
  $ contrail config use demo-org
538
540
  ```
539
541
 
542
+ ## `contrail feature-flags export`
543
+
544
+ Export the enabled feature flags for your Org
545
+
546
+ ```
547
+ USAGE
548
+ $ contrail feature-flags export [-l error|warn|info|debug] [-e true|false] [-d <value>] [-f]
549
+
550
+ FLAGS
551
+ -d, --outDir=<value> [default: .] The directory to which to export the feature flags. Defaults to the
552
+ current directory
553
+ -e, --alwaysConfirmEnv=<option> [default: true] The Environment variables
554
+ <options: true|false>
555
+ -f, --overwrite Replaces any existing export files in outDir
556
+ -l, --logLevel=<option> The Log Level
557
+ <options: error|warn|info|debug>
558
+
559
+ DESCRIPTION
560
+ Export the enabled feature flags for your Org
561
+
562
+ EXAMPLES
563
+ $ contrail feature-flags export
564
+ ```
565
+
566
+ ## `contrail feature-flags import`
567
+
568
+ Upload an extracted set of feature flags
569
+
570
+ ```
571
+ USAGE
572
+ $ contrail feature-flags import [-l error|warn|info|debug] [-e true|false] [-d <value>]
573
+
574
+ FLAGS
575
+ -d, --dir=<value> [default: .] The directory from which to import the feature flags. Defaults to the
576
+ current directory. The feature flags will be loaded from $dir/feature-flags.yml
577
+ -e, --alwaysConfirmEnv=<option> [default: true] The Environment variables
578
+ <options: true|false>
579
+ -l, --logLevel=<option> The Log Level
580
+ <options: error|warn|info|debug>
581
+
582
+ DESCRIPTION
583
+ Upload an extracted set of feature flags
584
+
585
+ EXAMPLES
586
+ $ contrail feature-flags import
587
+ ```
588
+
540
589
  ## `contrail file upload FILELOCATION CONTENTTYPE`
541
590
 
542
591
  Uploads a file to the VibeIQ platform
@@ -0,0 +1,3 @@
1
+ export declare function getFeatureFlags(options?: any): Promise<any[]>;
2
+ export declare function createFeatureFlag(featureName: string): Promise<any>;
3
+ export declare function deleteFeatureFlag(id: string): Promise<any>;
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deleteFeatureFlag = exports.createFeatureFlag = exports.getFeatureFlags = void 0;
4
+ const sdk_1 = require("@contrail/sdk");
5
+ // a feature flag record exists only for features that are turned ON for the org,
6
+ // and carries no configuration beyond its featureName
7
+ async function getFeatureFlags(options) {
8
+ return new sdk_1.Entities().get({
9
+ entityName: 'feature-flag',
10
+ ...options,
11
+ });
12
+ }
13
+ exports.getFeatureFlags = getFeatureFlags;
14
+ // the API treats create as an upsert on featureName, so creating an already
15
+ // enabled flag returns the existing record instead of failing
16
+ async function createFeatureFlag(featureName) {
17
+ return new sdk_1.Entities().create({
18
+ entityName: 'feature-flag',
19
+ object: { featureName },
20
+ });
21
+ }
22
+ exports.createFeatureFlag = createFeatureFlag;
23
+ async function deleteFeatureFlag(id) {
24
+ return new sdk_1.Entities().delete({
25
+ entityName: 'feature-flag',
26
+ id,
27
+ });
28
+ }
29
+ exports.deleteFeatureFlag = deleteFeatureFlag;
@@ -1,2 +1,3 @@
1
1
  export declare function getUserOrgs(options?: any): Promise<any[]>;
2
2
  export declare function getGroups(options?: any): Promise<any[]>;
3
+ export declare function getCurrentUserWithPlatformRoles(): Promise<any>;
package/dist/api/users.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getGroups = exports.getUserOrgs = void 0;
3
+ exports.getCurrentUserWithPlatformRoles = exports.getGroups = exports.getUserOrgs = void 0;
4
4
  const sdk_1 = require("@contrail/sdk");
5
5
  async function getUserOrgs(options) {
6
6
  return new sdk_1.Entities().get({
@@ -16,3 +16,12 @@ async function getGroups(options) {
16
16
  });
17
17
  }
18
18
  exports.getGroups = getGroups;
19
+ // GET /users/current only populates platformRoles when it is asked for as a relation
20
+ async function getCurrentUserWithPlatformRoles() {
21
+ return new sdk_1.Entities().get({
22
+ entityName: 'user',
23
+ id: 'current',
24
+ relations: ['platformRoles'],
25
+ });
26
+ }
27
+ exports.getCurrentUserWithPlatformRoles = getCurrentUserWithPlatformRoles;
@@ -0,0 +1,19 @@
1
+ import { BaseCommand } from '../../basecommand';
2
+ export declare const FEATURE_FLAGS_FILE = "feature-flags.yml";
3
+ export default class ExportFeatureFlags extends BaseCommand {
4
+ static description: string;
5
+ static examples: string[];
6
+ static flags: {
7
+ outDir: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
8
+ overwrite: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
9
+ alwaysConfirmEnv: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
10
+ logLevel: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
11
+ };
12
+ run(): Promise<void>;
13
+ }
14
+ /**
15
+ * Writes the org's enabled feature flags to $outDir/feature-flags.yml as a list of
16
+ * `- featureName: X` entries. An org with no flags enabled still gets a file (an empty
17
+ * list), so the export stays a faithful snapshot of what is turned on.
18
+ */
19
+ export declare function exportFeatureFlags(outDir: string, overwrite: boolean): Promise<void>;
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.exportFeatureFlags = exports.FEATURE_FLAGS_FILE = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const path = tslib_1.__importStar(require("path"));
6
+ const chalk_1 = tslib_1.__importDefault(require("chalk"));
7
+ const fs_1 = tslib_1.__importDefault(require("fs"));
8
+ const login_1 = require("../../auth/login");
9
+ const core_1 = require("@oclif/core");
10
+ const file_helpers_1 = require("../../helpers/file-helpers");
11
+ const error_helpers_1 = require("../../helpers/error-helpers");
12
+ const feature_flags_1 = require("../../api/feature-flags");
13
+ const basecommand_1 = require("../../basecommand");
14
+ exports.FEATURE_FLAGS_FILE = 'feature-flags.yml';
15
+ class ExportFeatureFlags extends basecommand_1.BaseCommand {
16
+ static description = 'Export the enabled feature flags for your Org';
17
+ static examples = [
18
+ `$ contrail feature-flags export
19
+ `,
20
+ ];
21
+ static flags = {
22
+ ...basecommand_1.BaseCommand.baseFlags,
23
+ outDir: core_1.Flags.string({
24
+ char: 'd',
25
+ description: 'The directory to which to export the feature flags. Defaults to the current directory',
26
+ required: false,
27
+ default: '.',
28
+ }),
29
+ overwrite: core_1.Flags.boolean({
30
+ char: 'f',
31
+ description: 'Replaces any existing export files in outDir',
32
+ required: false,
33
+ default: false,
34
+ }),
35
+ };
36
+ async run() {
37
+ try {
38
+ await (0, login_1.authUser)();
39
+ this.log(`${chalk_1.default.blue.bold('START')} Getting Feature Flags.`);
40
+ this.log(`${chalk_1.default.yellow.bold('FEATURE FLAGS')} Exporting`);
41
+ await exportFeatureFlags(this.flags.outDir, this.flags.overwrite);
42
+ this.log(`${chalk_1.default.yellow.bold('FEATURE FLAGS')} Exported`);
43
+ }
44
+ catch (e) {
45
+ this.log(`${chalk_1.default.red.bold('FAIL')} Feature Flags Export Failed. ${(0, error_helpers_1.describeError)(e)}`);
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+ this.log(`${chalk_1.default.green.bold('COMPLETE')} Feature Flags Export Complete.`);
50
+ }
51
+ }
52
+ exports.default = ExportFeatureFlags;
53
+ /**
54
+ * Writes the org's enabled feature flags to $outDir/feature-flags.yml as a list of
55
+ * `- featureName: X` entries. An org with no flags enabled still gets a file (an empty
56
+ * list), so the export stays a faithful snapshot of what is turned on.
57
+ */
58
+ async function exportFeatureFlags(outDir, overwrite) {
59
+ const featureFlags = await (0, feature_flags_1.getFeatureFlags)();
60
+ const featureNames = [
61
+ ...new Set((featureFlags ?? []).map((flag) => flag?.featureName).filter(Boolean)),
62
+ ].sort();
63
+ fs_1.default.mkdirSync(path.resolve(outDir), { recursive: true });
64
+ await (0, file_helpers_1.saveData)(featureNames.map((featureName) => ({ featureName })), path.join(outDir, exports.FEATURE_FLAGS_FILE), overwrite);
65
+ }
66
+ exports.exportFeatureFlags = exportFeatureFlags;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const fs_1 = tslib_1.__importDefault(require("fs"));
5
+ const export_1 = tslib_1.__importStar(require("./export"));
6
+ const file_helpers_1 = require("../../helpers/file-helpers");
7
+ const feature_flags_1 = require("../../api/feature-flags");
8
+ const login_1 = require("../../auth/login");
9
+ jest.mock('../../helpers/file-helpers');
10
+ jest.mock('../../api/feature-flags');
11
+ jest.mock('../../auth/login');
12
+ const mocked = (fn) => fn;
13
+ describe('exportFeatureFlags', () => {
14
+ let mkdirSyncSpy;
15
+ beforeEach(() => {
16
+ jest.clearAllMocks();
17
+ mkdirSyncSpy = jest.spyOn(fs_1.default, 'mkdirSync').mockReturnValue(undefined);
18
+ mocked(file_helpers_1.saveData).mockResolvedValue(undefined);
19
+ });
20
+ afterEach(() => {
21
+ mkdirSyncSpy.mockRestore();
22
+ });
23
+ it('writes the enabled flags sorted, without org specific fields', async () => {
24
+ mocked(feature_flags_1.getFeatureFlags).mockResolvedValue([
25
+ { id: '1', orgId: 'org-a', featureName: 'SHOWROOM' },
26
+ { id: '2', orgId: 'org-a', featureName: 'DEV_TABS' },
27
+ ]);
28
+ await (0, export_1.exportFeatureFlags)('out-dir', true);
29
+ expect(mocked(file_helpers_1.saveData)).toHaveBeenCalledWith([{ featureName: 'DEV_TABS' }, { featureName: 'SHOWROOM' }], 'out-dir/feature-flags.yml', true);
30
+ });
31
+ it('still writes a file when no flags are enabled, so the export is a full snapshot', async () => {
32
+ mocked(feature_flags_1.getFeatureFlags).mockResolvedValue([]);
33
+ await (0, export_1.exportFeatureFlags)('out-dir', false);
34
+ expect(mocked(file_helpers_1.saveData)).toHaveBeenCalledWith([], 'out-dir/feature-flags.yml', false);
35
+ });
36
+ });
37
+ describe('ExportFeatureFlags command', () => {
38
+ let mkdirSyncSpy;
39
+ let logs;
40
+ // oclif's init() parses argv and reads the on-disk config, so drive run() with flags set directly
41
+ const runCommand = async () => {
42
+ const command = new export_1.default([], {});
43
+ command.flags = { outDir: 'out-dir', overwrite: true };
44
+ jest.spyOn(command, 'log').mockImplementation((message) => logs.push(String(message)));
45
+ await command.run();
46
+ };
47
+ beforeEach(() => {
48
+ jest.clearAllMocks();
49
+ logs = [];
50
+ process.exitCode = undefined;
51
+ mkdirSyncSpy = jest.spyOn(fs_1.default, 'mkdirSync').mockReturnValue(undefined);
52
+ mocked(login_1.authUser).mockResolvedValue({});
53
+ mocked(file_helpers_1.saveData).mockResolvedValue(undefined);
54
+ mocked(feature_flags_1.getFeatureFlags).mockResolvedValue([]);
55
+ });
56
+ afterEach(() => {
57
+ mkdirSyncSpy.mockRestore();
58
+ process.exitCode = undefined;
59
+ });
60
+ it('leaves the exit code alone on success', async () => {
61
+ await runCommand();
62
+ expect(process.exitCode).toBeUndefined();
63
+ expect(logs.join('\n')).toContain('Feature Flags Export Complete');
64
+ });
65
+ const failures = [
66
+ [
67
+ 'authentication fails',
68
+ () => {
69
+ mocked(login_1.authUser).mockRejectedValue(new Error('boom'));
70
+ },
71
+ ],
72
+ [
73
+ 'the API read fails',
74
+ () => {
75
+ mocked(feature_flags_1.getFeatureFlags).mockRejectedValue(new Error('boom'));
76
+ },
77
+ ],
78
+ [
79
+ 'the directory cannot be created',
80
+ () => {
81
+ mkdirSyncSpy.mockImplementation(() => {
82
+ throw new Error('boom');
83
+ });
84
+ },
85
+ ],
86
+ [
87
+ 'the file write fails',
88
+ () => {
89
+ mocked(file_helpers_1.saveData).mockRejectedValue(new Error('boom'));
90
+ },
91
+ ],
92
+ ];
93
+ it.each(failures)('exits non-zero when %s, so automation does not read it as a success', async (_, fail) => {
94
+ fail();
95
+ await runCommand();
96
+ expect(process.exitCode).toBe(1);
97
+ const output = logs.join('\n');
98
+ expect(output).toContain('Feature Flags Export Failed');
99
+ expect(output).not.toContain('Feature Flags Export Complete');
100
+ });
101
+ it('reports the API message rather than a bare "Error"', async () => {
102
+ // the SDK's ApiError has an undefined message and keeps the body in details
103
+ mocked(feature_flags_1.getFeatureFlags).mockRejectedValue({
104
+ details: '{"message":"Forbidden resource","statusCode":403}',
105
+ });
106
+ await runCommand();
107
+ expect(logs.join('\n')).toContain('Forbidden resource');
108
+ });
109
+ });
@@ -0,0 +1,28 @@
1
+ import { BaseCommand } from '../../basecommand';
2
+ export declare const REQUIRED_PLATFORM_ROLES: string[];
3
+ export default class ImportFeatureFlags extends BaseCommand {
4
+ static description: string;
5
+ static examples: string[];
6
+ static flags: {
7
+ dir: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
8
+ alwaysConfirmEnv: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
9
+ logLevel: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
10
+ };
11
+ run(): Promise<void>;
12
+ }
13
+ /**
14
+ * The feature flags API only lets a platform admin/manager create or delete flags, and a
15
+ * missing role surfaces as a generic 403 partway through the run. Checking up front stops
16
+ * the command before it half-applies the file.
17
+ */
18
+ export declare function assertCanManageFeatureFlags(): Promise<void>;
19
+ /**
20
+ * Makes the target org's enabled feature flags match $dir/feature-flags.yml: enables every
21
+ * flag in the file and disables any flag the target has that the file does not, the same
22
+ * way groups import reconciles groups. Feature flags are not editable in place, so there is
23
+ * no update step.
24
+ *
25
+ * Failures are collected and rethrown so an import never reports success while the org is
26
+ * missing flags.
27
+ */
28
+ export declare function uploadFeatureFlags(dir: string): Promise<void>;
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.uploadFeatureFlags = exports.assertCanManageFeatureFlags = exports.REQUIRED_PLATFORM_ROLES = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const path = tslib_1.__importStar(require("path"));
6
+ const chalk_1 = tslib_1.__importDefault(require("chalk"));
7
+ const fs_1 = tslib_1.__importDefault(require("fs"));
8
+ const login_1 = require("../../auth/login");
9
+ const core_1 = require("@oclif/core");
10
+ const file_helpers_1 = require("../../helpers/file-helpers");
11
+ const error_helpers_1 = require("../../helpers/error-helpers");
12
+ const feature_flags_1 = require("../../api/feature-flags");
13
+ const users_1 = require("../../api/users");
14
+ const export_1 = require("./export");
15
+ const basecommand_1 = require("../../basecommand");
16
+ const logger_1 = tslib_1.__importDefault(require("../../logger"));
17
+ exports.REQUIRED_PLATFORM_ROLES = ['PLATFORM_ADMIN', 'PLATFORM_MANAGER'];
18
+ class ImportFeatureFlags extends basecommand_1.BaseCommand {
19
+ static description = 'Upload an extracted set of feature flags';
20
+ static examples = [
21
+ `$ contrail feature-flags import
22
+ `,
23
+ ];
24
+ static flags = {
25
+ ...basecommand_1.BaseCommand.baseFlags,
26
+ dir: core_1.Flags.string({
27
+ char: 'd',
28
+ description: `The directory from which to import the feature flags. Defaults to the current directory. The feature flags will be loaded from $dir/${export_1.FEATURE_FLAGS_FILE}`,
29
+ required: false,
30
+ default: '.',
31
+ }),
32
+ };
33
+ async run() {
34
+ try {
35
+ await (0, login_1.authUser)();
36
+ await assertCanManageFeatureFlags();
37
+ this.log(`${chalk_1.default.blue.bold('START')} Uploading Feature Flags.`);
38
+ this.log(`${chalk_1.default.yellow.bold('FEATURE FLAGS')} Uploading`);
39
+ await uploadFeatureFlags(this.flags.dir);
40
+ this.log(`${chalk_1.default.yellow.bold('FEATURE FLAGS')} Uploaded`);
41
+ }
42
+ catch (e) {
43
+ this.log(`${chalk_1.default.red.bold('FAIL')} Feature Flags Upload Failed. ${(0, error_helpers_1.describeError)(e)}`);
44
+ process.exitCode = 1;
45
+ return;
46
+ }
47
+ this.log(`${chalk_1.default.green.bold('COMPLETE')} Feature Flags Upload Complete.`);
48
+ }
49
+ }
50
+ exports.default = ImportFeatureFlags;
51
+ /**
52
+ * The feature flags API only lets a platform admin/manager create or delete flags, and a
53
+ * missing role surfaces as a generic 403 partway through the run. Checking up front stops
54
+ * the command before it half-applies the file.
55
+ */
56
+ async function assertCanManageFeatureFlags() {
57
+ let platformRoles;
58
+ try {
59
+ const currentUser = await (0, users_1.getCurrentUserWithPlatformRoles)();
60
+ platformRoles = normalizePlatformRoles(currentUser?.platformRoles);
61
+ }
62
+ catch (e) {
63
+ throw new Error(`Could not read your platform roles. ${(0, error_helpers_1.describeError)(e)}`);
64
+ }
65
+ if (!platformRoles.some((role) => exports.REQUIRED_PLATFORM_ROLES.includes(role))) {
66
+ throw new Error(`Importing feature flags requires the ${exports.REQUIRED_PLATFORM_ROLES.join(' or ')} platform role. ` +
67
+ `Your roles: ${platformRoles.length ? platformRoles.join(', ') : 'none'}.`);
68
+ }
69
+ }
70
+ exports.assertCanManageFeatureFlags = assertCanManageFeatureFlags;
71
+ /**
72
+ * Makes the target org's enabled feature flags match $dir/feature-flags.yml: enables every
73
+ * flag in the file and disables any flag the target has that the file does not, the same
74
+ * way groups import reconciles groups. Feature flags are not editable in place, so there is
75
+ * no update step.
76
+ *
77
+ * Failures are collected and rethrown so an import never reports success while the org is
78
+ * missing flags.
79
+ */
80
+ async function uploadFeatureFlags(dir) {
81
+ const filePath = path.join(dir, export_1.FEATURE_FLAGS_FILE);
82
+ if (!fs_1.default.existsSync(filePath)) {
83
+ logger_1.default.info(`${chalk_1.default.yellow.bold('FEATURE FLAGS')} No feature flags found in ${dir}. Skipping.`);
84
+ return;
85
+ }
86
+ const data = (0, file_helpers_1.loadData)(filePath);
87
+ if (!Array.isArray(data)) {
88
+ throw new Error(`${filePath} must contain a YAML list of feature flags`);
89
+ }
90
+ const featureNamesToEnable = new Set(data.map(toFeatureName).filter(Boolean));
91
+ const existingFlags = (await (0, feature_flags_1.getFeatureFlags)()) ?? [];
92
+ const enabledFeatureNames = new Set(existingFlags.map((flag) => flag?.featureName));
93
+ const toCreate = [...featureNamesToEnable].filter((name) => !enabledFeatureNames.has(name));
94
+ const toDelete = existingFlags.filter((flag) => !featureNamesToEnable.has(flag?.featureName));
95
+ const failures = [];
96
+ for (const featureName of toCreate) {
97
+ try {
98
+ await (0, feature_flags_1.createFeatureFlag)(featureName);
99
+ logger_1.default.info(`${chalk_1.default.yellow.bold('FEATURE FLAGS')} Enabled ${featureName}`);
100
+ }
101
+ catch (e) {
102
+ failures.push(`could not enable ${featureName}: ${(0, error_helpers_1.describeError)(e)}`);
103
+ }
104
+ }
105
+ const removed = [];
106
+ for (const flag of toDelete) {
107
+ try {
108
+ await (0, feature_flags_1.deleteFeatureFlag)(flag.id);
109
+ logger_1.default.info(`${chalk_1.default.yellow.bold('FEATURE FLAGS')} Disabled ${flag.featureName}`);
110
+ removed.push(flag.featureName);
111
+ }
112
+ catch (e) {
113
+ failures.push(`could not disable ${flag.featureName}: ${(0, error_helpers_1.describeError)(e)}`);
114
+ }
115
+ }
116
+ // call out the removals explicitly: they are the only destructive part of the import
117
+ if (removed.length) {
118
+ const removedList = removed.sort().join(', ');
119
+ logger_1.default.info(`${chalk_1.default.yellow.bold('FEATURE FLAGS')} The following ${removed.length} flag(s) were ` +
120
+ `enabled in this org but not in the source, so they were removed: ${removedList}`);
121
+ }
122
+ if (failures.length) {
123
+ throw new Error(`Failed to apply feature flags. ${failures.join('; ')}`);
124
+ }
125
+ }
126
+ exports.uploadFeatureFlags = uploadFeatureFlags;
127
+ // the API returns platformRoles as an array, but some contexts carry it as a JSON string
128
+ function normalizePlatformRoles(platformRoles) {
129
+ if (Array.isArray(platformRoles)) {
130
+ return platformRoles;
131
+ }
132
+ if (typeof platformRoles === 'string') {
133
+ try {
134
+ const parsed = JSON.parse(platformRoles);
135
+ return Array.isArray(parsed) ? parsed : [];
136
+ }
137
+ catch {
138
+ return [];
139
+ }
140
+ }
141
+ return [];
142
+ }
143
+ // entries are exported as `- featureName: X`, but a hand written `- X` is accepted too
144
+ function toFeatureName(entry) {
145
+ const featureName = typeof entry === 'string' ? entry : entry?.featureName;
146
+ if (typeof featureName !== 'string' || !featureName.trim()) {
147
+ logger_1.default.info(`${chalk_1.default.yellow.bold('FEATURE FLAGS')} Skipping entry with no featureName`);
148
+ return undefined;
149
+ }
150
+ return featureName.trim();
151
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,125 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const fs_1 = tslib_1.__importDefault(require("fs"));
5
+ const import_1 = require("./import");
6
+ const file_helpers_1 = require("../../helpers/file-helpers");
7
+ const feature_flags_1 = require("../../api/feature-flags");
8
+ const users_1 = require("../../api/users");
9
+ const logger_1 = tslib_1.__importDefault(require("../../logger"));
10
+ jest.mock('../../helpers/file-helpers');
11
+ jest.mock('../../api/feature-flags');
12
+ jest.mock('../../api/users');
13
+ const mocked = (fn) => fn;
14
+ describe('assertCanManageFeatureFlags', () => {
15
+ beforeEach(() => {
16
+ jest.clearAllMocks();
17
+ });
18
+ it.each(['PLATFORM_ADMIN', 'PLATFORM_MANAGER'])('allows a %s', async (role) => {
19
+ mocked(users_1.getCurrentUserWithPlatformRoles).mockResolvedValue({ platformRoles: [role] });
20
+ await expect((0, import_1.assertCanManageFeatureFlags)()).resolves.toBeUndefined();
21
+ });
22
+ it('accepts platformRoles serialized as a JSON string', async () => {
23
+ mocked(users_1.getCurrentUserWithPlatformRoles).mockResolvedValue({
24
+ platformRoles: '["PLATFORM_MANAGER"]',
25
+ });
26
+ await expect((0, import_1.assertCanManageFeatureFlags)()).resolves.toBeUndefined();
27
+ });
28
+ it('rejects a user with no platform role', async () => {
29
+ mocked(users_1.getCurrentUserWithPlatformRoles).mockResolvedValue({ platformRoles: [] });
30
+ await expect((0, import_1.assertCanManageFeatureFlags)()).rejects.toThrow('requires the PLATFORM_ADMIN or PLATFORM_MANAGER platform role');
31
+ });
32
+ it('rejects a user holding only some other platform role', async () => {
33
+ mocked(users_1.getCurrentUserWithPlatformRoles).mockResolvedValue({
34
+ platformRoles: ['PLATFORM_USER'],
35
+ });
36
+ await expect((0, import_1.assertCanManageFeatureFlags)()).rejects.toThrow('Your roles: PLATFORM_USER.');
37
+ });
38
+ it('rejects when the roles could not be read', async () => {
39
+ mocked(users_1.getCurrentUserWithPlatformRoles).mockRejectedValue(new Error('Forbidden resource'));
40
+ await expect((0, import_1.assertCanManageFeatureFlags)()).rejects.toThrow('Could not read your platform roles. Forbidden resource');
41
+ });
42
+ });
43
+ describe('uploadFeatureFlags', () => {
44
+ let existsSyncSpy;
45
+ beforeEach(() => {
46
+ jest.clearAllMocks();
47
+ existsSyncSpy = jest.spyOn(fs_1.default, 'existsSync').mockReturnValue(true);
48
+ mocked(feature_flags_1.createFeatureFlag).mockResolvedValue({});
49
+ mocked(feature_flags_1.deleteFeatureFlag).mockResolvedValue({});
50
+ mocked(feature_flags_1.getFeatureFlags).mockResolvedValue([]);
51
+ });
52
+ afterEach(() => {
53
+ existsSyncSpy.mockRestore();
54
+ });
55
+ it('skips when the export has no feature-flags.yml', async () => {
56
+ existsSyncSpy.mockReturnValue(false);
57
+ await (0, import_1.uploadFeatureFlags)('export-dir');
58
+ expect(mocked(feature_flags_1.getFeatureFlags)).not.toHaveBeenCalled();
59
+ expect(mocked(feature_flags_1.createFeatureFlag)).not.toHaveBeenCalled();
60
+ });
61
+ it('enables every flag from the export that the target org is missing', async () => {
62
+ mocked(file_helpers_1.loadData).mockReturnValue([{ featureName: 'SHOWROOM' }, { featureName: 'PROMOTE' }]);
63
+ mocked(feature_flags_1.getFeatureFlags).mockResolvedValue([{ id: '1', featureName: 'SHOWROOM' }]);
64
+ await (0, import_1.uploadFeatureFlags)('export-dir');
65
+ expect(mocked(feature_flags_1.createFeatureFlag)).toHaveBeenCalledTimes(1);
66
+ expect(mocked(feature_flags_1.createFeatureFlag)).toHaveBeenCalledWith('PROMOTE');
67
+ });
68
+ it('disables flags the target org has that the export does not, so the orgs match', async () => {
69
+ mocked(file_helpers_1.loadData).mockReturnValue([{ featureName: 'SHOWROOM' }]);
70
+ mocked(feature_flags_1.getFeatureFlags).mockResolvedValue([
71
+ { id: '1', featureName: 'SHOWROOM' },
72
+ { id: '2', featureName: 'DEV_TABS' },
73
+ ]);
74
+ await (0, import_1.uploadFeatureFlags)('export-dir');
75
+ expect(mocked(feature_flags_1.createFeatureFlag)).not.toHaveBeenCalled();
76
+ expect(mocked(feature_flags_1.deleteFeatureFlag)).toHaveBeenCalledTimes(1);
77
+ expect(mocked(feature_flags_1.deleteFeatureFlag)).toHaveBeenCalledWith('2');
78
+ });
79
+ it('summarizes what it removed, and why, at the end of the run', async () => {
80
+ const infoSpy = jest.spyOn(logger_1.default, 'info').mockImplementation(() => undefined);
81
+ mocked(file_helpers_1.loadData).mockReturnValue([{ featureName: 'SHOWROOM' }]);
82
+ mocked(feature_flags_1.getFeatureFlags).mockResolvedValue([
83
+ { id: '1', featureName: 'SHOWROOM' },
84
+ { id: '2', featureName: 'PROMOTE' },
85
+ { id: '3', featureName: 'DEV_TABS' },
86
+ ]);
87
+ await (0, import_1.uploadFeatureFlags)('export-dir');
88
+ const summary = infoSpy.mock.calls.map(([message]) => String(message)).at(-1);
89
+ expect(summary).toContain('2 flag(s) were enabled in this org but not in the source');
90
+ expect(summary).toContain('DEV_TABS, PROMOTE');
91
+ infoSpy.mockRestore();
92
+ });
93
+ it('does not print a removal summary when nothing was removed', async () => {
94
+ const infoSpy = jest.spyOn(logger_1.default, 'info').mockImplementation(() => undefined);
95
+ mocked(file_helpers_1.loadData).mockReturnValue([{ featureName: 'SHOWROOM' }]);
96
+ mocked(feature_flags_1.getFeatureFlags).mockResolvedValue([{ id: '1', featureName: 'SHOWROOM' }]);
97
+ await (0, import_1.uploadFeatureFlags)('export-dir');
98
+ const messages = infoSpy.mock.calls.map(([message]) => String(message));
99
+ expect(messages.some((message) => message.includes('so they were removed'))).toBe(false);
100
+ infoSpy.mockRestore();
101
+ });
102
+ it('accepts plain feature names and ignores entries with no featureName', async () => {
103
+ mocked(file_helpers_1.loadData).mockReturnValue(['SHOWROOM', { featureName: ' PROMOTE ' }, {}, null]);
104
+ await (0, import_1.uploadFeatureFlags)('export-dir');
105
+ expect(mocked(feature_flags_1.createFeatureFlag).mock.calls.map(([name]) => name)).toEqual([
106
+ 'SHOWROOM',
107
+ 'PROMOTE',
108
+ ]);
109
+ });
110
+ it('rejects when the file is not a list', async () => {
111
+ mocked(file_helpers_1.loadData).mockReturnValue({ featureName: 'SHOWROOM' });
112
+ await expect((0, import_1.uploadFeatureFlags)('export-dir')).rejects.toThrow('must contain a YAML list of feature flags');
113
+ });
114
+ it('applies what it can and then rejects, rather than reporting success', async () => {
115
+ mocked(file_helpers_1.loadData).mockReturnValue([{ featureName: 'SHOWROOM' }, { featureName: 'PROMOTE' }]);
116
+ mocked(feature_flags_1.createFeatureFlag).mockImplementation(async (featureName) => {
117
+ if (featureName === 'SHOWROOM') {
118
+ throw new Error('Forbidden resource');
119
+ }
120
+ return {};
121
+ });
122
+ await expect((0, import_1.uploadFeatureFlags)('export-dir')).rejects.toThrow('could not enable SHOWROOM: Forbidden resource');
123
+ expect(mocked(feature_flags_1.createFeatureFlag)).toHaveBeenCalledWith('PROMOTE');
124
+ });
125
+ });
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.5.4",
2
+ "version": "3.5.5-alpha.5c010ac",
3
3
  "commands": {
4
4
  "app:create": {
5
5
  "id": "app:create",
@@ -672,6 +672,116 @@
672
672
  }
673
673
  }
674
674
  },
675
+ "feature-flags:export": {
676
+ "id": "feature-flags:export",
677
+ "description": "Export the enabled feature flags for your Org",
678
+ "strict": true,
679
+ "pluginName": "@contrail/cli",
680
+ "pluginAlias": "@contrail/cli",
681
+ "pluginType": "core",
682
+ "aliases": [],
683
+ "examples": [
684
+ "$ contrail feature-flags export\n"
685
+ ],
686
+ "flags": {
687
+ "logLevel": {
688
+ "name": "logLevel",
689
+ "type": "option",
690
+ "char": "l",
691
+ "description": "The Log Level",
692
+ "required": false,
693
+ "multiple": false,
694
+ "options": [
695
+ "error",
696
+ "warn",
697
+ "info",
698
+ "debug"
699
+ ]
700
+ },
701
+ "alwaysConfirmEnv": {
702
+ "name": "alwaysConfirmEnv",
703
+ "type": "option",
704
+ "char": "e",
705
+ "description": "The Environment variables",
706
+ "required": false,
707
+ "multiple": false,
708
+ "options": [
709
+ "true",
710
+ "false"
711
+ ],
712
+ "default": "true"
713
+ },
714
+ "outDir": {
715
+ "name": "outDir",
716
+ "type": "option",
717
+ "char": "d",
718
+ "description": "The directory to which to export the feature flags. Defaults to the current directory",
719
+ "required": false,
720
+ "multiple": false,
721
+ "default": "."
722
+ },
723
+ "overwrite": {
724
+ "name": "overwrite",
725
+ "type": "boolean",
726
+ "char": "f",
727
+ "description": "Replaces any existing export files in outDir",
728
+ "required": false,
729
+ "allowNo": false
730
+ }
731
+ },
732
+ "args": {}
733
+ },
734
+ "feature-flags:import": {
735
+ "id": "feature-flags:import",
736
+ "description": "Upload an extracted set of feature flags",
737
+ "strict": true,
738
+ "pluginName": "@contrail/cli",
739
+ "pluginAlias": "@contrail/cli",
740
+ "pluginType": "core",
741
+ "aliases": [],
742
+ "examples": [
743
+ "$ contrail feature-flags import\n"
744
+ ],
745
+ "flags": {
746
+ "logLevel": {
747
+ "name": "logLevel",
748
+ "type": "option",
749
+ "char": "l",
750
+ "description": "The Log Level",
751
+ "required": false,
752
+ "multiple": false,
753
+ "options": [
754
+ "error",
755
+ "warn",
756
+ "info",
757
+ "debug"
758
+ ]
759
+ },
760
+ "alwaysConfirmEnv": {
761
+ "name": "alwaysConfirmEnv",
762
+ "type": "option",
763
+ "char": "e",
764
+ "description": "The Environment variables",
765
+ "required": false,
766
+ "multiple": false,
767
+ "options": [
768
+ "true",
769
+ "false"
770
+ ],
771
+ "default": "true"
772
+ },
773
+ "dir": {
774
+ "name": "dir",
775
+ "type": "option",
776
+ "char": "d",
777
+ "description": "The directory from which to import the feature flags. Defaults to the current directory. The feature flags will be loaded from $dir/feature-flags.yml",
778
+ "required": false,
779
+ "multiple": false,
780
+ "default": "."
781
+ }
782
+ },
783
+ "args": {}
784
+ },
675
785
  "file:upload": {
676
786
  "id": "file:upload",
677
787
  "description": "Uploads a file to the VibeIQ platform",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrail/cli",
3
- "version": "3.5.4",
3
+ "version": "3.5.5-alpha.5c010ac",
4
4
  "description": "VibeIQ's Contrail Platform CLI.",
5
5
  "bin": {
6
6
  "contrail-cli": "./bin/run",
@@ -116,6 +116,9 @@
116
116
  "org-config": {
117
117
  "description": "Export and import org-level configuration"
118
118
  },
119
+ "feature-flags": {
120
+ "description": "Export and import the org's enabled feature flags"
121
+ },
119
122
  "webhooks": {
120
123
  "description": "Manage VibeIQ webhooks"
121
124
  },