Skip to content

Commit 416db1f

Browse files
committed
feat: add rollout to segment and users
1 parent d06d1b8 commit 416db1f

10 files changed

Lines changed: 292 additions & 91 deletions

File tree

packages/cli/commands/companies.ts

Lines changed: 55 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,8 @@ import { Argument, Command } from "commander";
44
import ora, { Ora } from "ora";
55

66
import { getApp } from "../services/bootstrap.js";
7-
import {
8-
CompanyFeatureAccess,
9-
companyFeatureAccess,
10-
listCompanies,
11-
} from "../services/companies.js";
12-
import { listFeatures } from "../services/features.js";
7+
import { listCompanies } from "../services/companies.js";
8+
import { listFeatures, updateFeatureAccess } from "../services/features.js";
139
import { configStore } from "../stores/config.js";
1410
import {
1511
handleError,
@@ -19,9 +15,11 @@ import {
1915
import {
2016
appIdOption,
2117
companyFilterOption,
22-
companyIdArgument,
18+
companyIdsOption,
2319
disableFeatureOption,
2420
enableFeatureOption,
21+
segmentIdsOption,
22+
userIdsOption,
2523
} from "../utils/options.js";
2624
import { baseUrlSuffix } from "../utils/path.js";
2725

@@ -69,36 +67,53 @@ export const listCompaniesAction = async (options: { filter?: string }) => {
6967
}
7068
};
7169

72-
export const companyFeatureAccessAction = async (
73-
companyId: string,
70+
export const featureAccessAction = async (
7471
featureKey: string | undefined,
75-
options: { enable: boolean; disable: boolean },
72+
options: {
73+
enable: boolean;
74+
disable: boolean;
75+
companies?: string[];
76+
segments?: string[];
77+
users?: string[];
78+
},
7679
) => {
7780
const { baseUrl, appId } = configStore.getConfig();
7881
let spinner: Ora | undefined;
7982

8083
if (!appId) {
81-
return handleError(new MissingAppIdError(), "Company Feature Access");
84+
return handleError(new MissingAppIdError(), "Feature Access");
8285
}
8386

8487
const app = getApp(appId);
8588
const production = app.environments.find((e) => e.isProduction);
8689
if (!production) {
87-
return handleError(new MissingEnvIdError(), "Company Feature Access");
90+
return handleError(new MissingEnvIdError(), "Feature Access");
8891
}
8992

9093
// Validate conflicting options
9194
if (options.enable && options.disable) {
9295
return handleError(
9396
"Cannot both enable and disable a feature.",
94-
"Company Feature Access",
97+
"Feature Access",
9598
);
9699
}
97100

98101
if (!options.enable && !options.disable) {
99102
return handleError(
100103
"Must specify either --enable or --disable.",
101-
"Company Feature Access",
104+
"Feature Access",
105+
);
106+
}
107+
108+
// Validate at least one target is specified
109+
if (
110+
!options.companies?.length &&
111+
!options.segments?.length &&
112+
!options.users?.length
113+
) {
114+
return handleError(
115+
"Must specify at least one target using --companies, --segments, or --users.",
116+
"Feature Access",
102117
);
103118
}
104119

@@ -116,10 +131,7 @@ export const companyFeatureAccessAction = async (
116131
});
117132

118133
if (featuresResponse.data.length === 0) {
119-
return handleError(
120-
"No features found for this app.",
121-
"Company Feature Access",
122-
);
134+
return handleError("No features found for this app.", "Feature Access");
123135
}
124136

125137
spinner.succeed(
@@ -135,33 +147,38 @@ export const companyFeatureAccessAction = async (
135147
});
136148
} catch (error) {
137149
spinner?.fail("Loading features failed.");
138-
return handleError(error, "Company Feature Access");
150+
return handleError(error, "Feature Access");
139151
}
140152
}
141153

142154
// Determine if enabling or disabling
143155
const isEnabled = options.enable;
144156

145157
try {
158+
const targetTypes = [];
159+
if (options.companies?.length) targetTypes.push("companies");
160+
if (options.segments?.length) targetTypes.push("segments");
161+
if (options.users?.length) targetTypes.push("users");
162+
146163
spinner = ora(
147-
`${isEnabled ? "Enabling" : "Disabling"} feature ${chalk.cyan(featureKey)} for company ${chalk.cyan(companyId)}...`,
164+
`${isEnabled ? "Enabling" : "Disabling"} feature ${chalk.cyan(featureKey)} for ${targetTypes.join(", ")}...`,
148165
).start();
149166

150-
const request: CompanyFeatureAccess = {
167+
await updateFeatureAccess(appId, {
151168
envId: production.id,
152-
companyId,
153169
featureKey,
154170
isEnabled,
155-
};
156-
157-
await companyFeatureAccess(appId, request);
171+
companyIds: options.companies,
172+
segmentIds: options.segments,
173+
userIds: options.users,
174+
});
158175

159176
spinner.succeed(
160-
`${isEnabled ? "Enabled" : "Disabled"} feature ${chalk.cyan(featureKey)} for company ${chalk.cyan(companyId)}.`,
177+
`${isEnabled ? "Enabled" : "Disabled"} feature ${chalk.cyan(featureKey)} for ${targetTypes.join(", ")}.`,
161178
);
162179
} catch (error) {
163180
spinner?.fail(`Feature access update failed.`);
164-
void handleError(error, "Company Feature Access");
181+
void handleError(error, "Feature Access");
165182
}
166183
};
167184

@@ -170,8 +187,8 @@ export function registerCompanyCommands(cli: Command) {
170187
"Manage companies.",
171188
);
172189

173-
const companyFeaturesCommand = new Command("features").description(
174-
"Manage company features.",
190+
const featuresCommand = new Command("features").description(
191+
"Manage feature access.",
175192
);
176193

177194
companiesCommand
@@ -182,11 +199,12 @@ export function registerCompanyCommands(cli: Command) {
182199
.action(listCompaniesAction);
183200

184201
// Feature access command
185-
companyFeaturesCommand
202+
featuresCommand
186203
.command("access")
187-
.description("Grant or revoke feature access for a specific company.")
204+
.description(
205+
"Grant or revoke feature access for companies, segments, and users.",
206+
)
188207
.addOption(appIdOption)
189-
.addArgument(companyIdArgument)
190208
.addArgument(
191209
new Argument(
192210
"[featureKey]",
@@ -195,9 +213,12 @@ export function registerCompanyCommands(cli: Command) {
195213
)
196214
.addOption(enableFeatureOption)
197215
.addOption(disableFeatureOption)
198-
.action(companyFeatureAccessAction);
216+
.addOption(companyIdsOption)
217+
.addOption(segmentIdsOption)
218+
.addOption(userIdsOption)
219+
.action(featureAccessAction);
199220

200-
companiesCommand.addCommand(companyFeaturesCommand);
221+
companiesCommand.addCommand(featuresCommand);
201222

202223
// Update the config with the cli override values
203224
companiesCommand.hook("preAction", (_, command) => {

packages/cli/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { registerFeatureCommands } from "./commands/features.js";
1010
import { registerInitCommand } from "./commands/init.js";
1111
import { registerMcpCommand } from "./commands/mcp.js";
1212
import { registerNewCommand } from "./commands/new.js";
13-
import { bootstrap, getUser } from "./services/bootstrap.js";
13+
import { bootstrap, getBucketUser } from "./services/bootstrap.js";
1414
import { authStore } from "./stores/auth.js";
1515
import { configStore } from "./stores/config.js";
1616
import { handleError } from "./utils/errors.js";
@@ -60,7 +60,7 @@ async function main() {
6060

6161
if (debug) {
6262
console.debug(chalk.cyan("\nDebug mode enabled."));
63-
const user = getUser();
63+
const user = getBucketUser();
6464
console.debug(`Logged in as ${chalk.cyan(user.name ?? user.email)}.`);
6565
console.debug(
6666
"Reading config from:",

packages/cli/mcp/responses.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { FlagVersion } from "../services/features.js";
12
import { JSONPrimitive } from "../utils/json.js";
23

34
export function textResponse(message: string, data?: JSONPrimitive) {
@@ -70,6 +71,10 @@ export function companiesResponse(data: JSONPrimitive) {
7071
return textResponse("List of companies.", data);
7172
}
7273

74+
export function usersResponse(data: JSONPrimitive) {
75+
return textResponse("List of users.", data);
76+
}
77+
7378
export function companyFeatureAccessResponse(
7479
isEnabled: boolean,
7580
featureKey: string,
@@ -83,3 +88,7 @@ export function companyFeatureAccessResponse(
8388
export function updateFeatureStageResponse(featureKey: string) {
8489
return textResponse(`Updated flag targeting for feature '${featureKey}'.`);
8590
}
91+
92+
export function updateFeatureAccessResponse(flagVersions: FlagVersion[]) {
93+
return textResponse(flagVersions.map((v) => v.changeDescription).join("\n"));
94+
}

packages/cli/mcp/tools.ts

Lines changed: 58 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,23 @@
11
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import ora from "ora";
23
import { z } from "zod";
34

4-
import { getApp, getOrg } from "../services/bootstrap.js";
5-
import {
6-
CompaniesQuerySchema,
7-
companyFeatureAccess,
8-
CompanyFeatureAccessSchema,
9-
listCompanies,
10-
} from "../services/companies.js";
5+
import { getApp, getOrg, listSegments } from "../services/bootstrap.js";
6+
import { CompaniesQuerySchema, listCompanies } from "../services/companies.js";
117
import {
128
createFeature,
9+
FeatureAccessSchema,
1310
FeatureCreateSchema,
1411
listFeatureNames,
12+
updateFeatureAccess,
1513
} from "../services/features.js";
1614
import { FeedbackQuerySchema, listFeedback } from "../services/feedback.js";
1715
import {
1816
listStages,
1917
updateFeatureStage,
2018
UpdateFeatureStageSchema,
2119
} from "../services/stages.js";
20+
import { listUsers, UsersQuerySchema } from "../services/users.js";
2221
import { configStore } from "../stores/config.js";
2322
import {
2423
handleMcpError,
@@ -31,11 +30,12 @@ import { withDefaults, withDescriptions } from "../utils/schemas.js";
3130

3231
import {
3332
companiesResponse,
34-
companyFeatureAccessResponse,
3533
featureCreateResponse,
3634
featuresResponse,
3735
feedbackResponse,
36+
updateFeatureAccessResponse,
3837
updateFeatureStageResponse,
38+
usersResponse,
3939
} from "./responses.js";
4040

4141
export async function registerMcpTools(
@@ -50,12 +50,16 @@ export async function registerMcpTools(
5050
}
5151
const org = getOrg();
5252
const app = getApp(appId);
53+
const segments = listSegments(appId);
5354
const production = app.environments.find((e) => e.isProduction);
5455
if (!production) {
5556
throw new MissingEnvIdError();
5657
}
5758

59+
// Extend bootstrap spinner for loading stages
60+
const spinner = ora("Bootstrapping...").start();
5861
const stages = await listStages(appId);
62+
spinner.stop();
5963

6064
// Add features tool
6165
mcp.tool(
@@ -128,27 +132,61 @@ export async function registerMcpTools(
128132
},
129133
);
130134

131-
// Add company feature access tool
135+
// Add users tool
132136
mcp.tool(
133-
"companyFeatureAccess",
134-
"Grant or revoke feature access for a specific company of the Bucket feature management service.",
135-
withDefaults(CompanyFeatureAccessSchema, {
137+
"users",
138+
"List of users of the Bucket feature management service.",
139+
withDefaults(UsersQuerySchema, {
136140
envId: production.id,
137141
}).shape,
138142
async (args) => {
139143
try {
140-
await companyFeatureAccess(appId, args);
141-
return companyFeatureAccessResponse(
142-
args.isEnabled,
143-
args.featureKey,
144-
args.companyId,
145-
);
144+
const data = await listUsers(appId, args);
145+
return usersResponse(data);
146+
} catch (error) {
147+
return await handleMcpError(error);
148+
}
149+
},
150+
);
151+
152+
// Add company feature access tool
153+
const segmentNames = segments.map((s) => s.name);
154+
mcp.tool(
155+
"featureAccess",
156+
"Grant or revoke feature access to a specific user, company, or segment of the Bucket feature management service.",
157+
withDefaults(
158+
FeatureAccessSchema.omit({ segmentIds: true }).extend({
159+
segmentNames: z
160+
.array(z.enum(segmentNames as [string, ...string[]]))
161+
.optional()
162+
.describe(
163+
`Segment names to target. Must be one of the following: ${segmentNames.join(", ")}`,
164+
),
165+
}),
166+
{
167+
envId: production.id,
168+
},
169+
).shape,
170+
async (args) => {
171+
try {
172+
const { segmentNames: selectedSegmentNames, ...rest } = args;
173+
const segmentIds =
174+
selectedSegmentNames
175+
?.map((name) => segments.find((s) => s.name === name)?.id)
176+
.filter((id) => id !== undefined) ?? [];
177+
const { flagVersions } = await updateFeatureAccess(appId, {
178+
...rest,
179+
segmentIds,
180+
});
181+
return updateFeatureAccessResponse(flagVersions);
146182
} catch (error) {
147183
return await handleMcpError(error);
148184
}
149185
},
150186
);
151187

188+
// Add update feature stage tool
189+
const stageNames = stages.map((s) => s.name);
152190
mcp.tool(
153191
"updateFeatureStage",
154192
"Update the stage of a feature of the Bucket feature management service.",
@@ -157,9 +195,9 @@ export async function registerMcpTools(
157195
stageId: true,
158196
}).extend({
159197
stageName: z
160-
.enum(stages.map((s) => s.name) as [string, ...string[]])
198+
.enum(stageNames as [string, ...string[]])
161199
.describe(
162-
`The name of the stage. Must be one of the following: ${stages.map((s) => s.name).join(", ")}`,
200+
`The name of the stage. Must be one of the following: ${stageNames.join(", ")}`,
163201
),
164202
}),
165203
{

0 commit comments

Comments
 (0)