From b693330581a82f76daf7ec2c112f89ac11764ad2 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 23 Jul 2025 09:56:25 +0100 Subject: [PATCH 01/10] chore: enhance CLI with API key support and clean up unused code - Added support for an API key option in the CLI, allowing users to authenticate without a personal token. - Updated the `authStore` to handle API keys and modified the `getToken` method accordingly. - Removed the `companies` command and related code as it was no longer needed. - Cleaned up error handling in various commands to ensure consistent logging and user feedback. - Improved the handling of API URLs and base URLs in the CLI commands. --- .vscode/settings.json | 6 +- packages/cli/commands/apps.ts | 5 +- packages/cli/commands/auth.ts | 23 +++- packages/cli/commands/companies.ts | 83 ------------- packages/cli/commands/features.ts | 192 ++++------------------------- packages/cli/commands/init.ts | 6 +- packages/cli/commands/mcp.ts | 12 +- packages/cli/commands/rules.ts | 2 +- packages/cli/index.ts | 36 ++++-- packages/cli/services/bootstrap.ts | 45 ++----- packages/cli/services/companies.ts | 80 ------------ packages/cli/services/features.ts | 126 ++----------------- packages/cli/services/feedback.ts | 103 ---------------- packages/cli/services/stages.ts | 95 -------------- packages/cli/services/users.ts | 56 --------- packages/cli/stores/auth.ts | 15 ++- packages/cli/stores/config.ts | 8 +- packages/cli/utils/auth.ts | 58 ++++++--- packages/cli/utils/errors.ts | 38 ++++-- packages/cli/utils/gen.ts | 6 +- packages/cli/utils/options.ts | 41 +----- 21 files changed, 208 insertions(+), 828 deletions(-) delete mode 100644 packages/cli/commands/companies.ts delete mode 100644 packages/cli/services/companies.ts delete mode 100644 packages/cli/services/feedback.ts delete mode 100644 packages/cli/services/stages.ts delete mode 100644 packages/cli/services/users.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 4944fbf8..cb84ca83 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,5 +39,9 @@ "**/*.lock": true }, "typescript.tsdk": "node_modules/typescript/lib", - "cSpell.words": ["bucketco", "openfeature"] + "cSpell.words": [ + "booleanish", + "bucketco", + "openfeature" + ] } diff --git a/packages/cli/commands/apps.ts b/packages/cli/commands/apps.ts index e2711b3d..209d77d4 100644 --- a/packages/cli/commands/apps.ts +++ b/packages/cli/commands/apps.ts @@ -9,13 +9,14 @@ import { handleError } from "../utils/errors.js"; export const listAppsAction = async () => { const baseUrl = configStore.getConfig("baseUrl"); const spinner = ora(`Loading apps from ${chalk.cyan(baseUrl)}...`).start(); + try { - const apps = await listApps(); + const apps = listApps(); spinner.succeed(`Loaded apps from ${chalk.cyan(baseUrl)}.`); console.table(apps.map(({ name, id, demo }) => ({ name, id, demo }))); } catch (error) { spinner.fail("Failed to list apps."); - void handleError(error, "Apps List"); + handleError(error, "Apps List"); } }; diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index b2e6ffc8..33b4e3f4 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -10,24 +10,41 @@ import { handleError } from "../utils/errors.js"; export const loginAction = async () => { const { baseUrl, apiUrl } = configStore.getConfig(); + if (authStore.getToken(baseUrl).isApiKey) { + await handleError( + "Login is not allowed when an API token was supplied.", + "Login", + ); + } + try { await waitForAccessToken(baseUrl, apiUrl); console.log(`Logged in to ${chalk.cyan(baseUrl)} successfully!`); } catch (error) { console.error("Login failed."); - void handleError(error, "Login"); + await handleError(error, "Login"); } }; export const logoutAction = async () => { const baseUrl = configStore.getConfig("baseUrl"); + + if (authStore.getToken(baseUrl).isApiKey) { + await handleError( + "Logout is not allowed when an API token was supplied.", + "Logout", + ); + } + const spinner = ora("Logging out...").start(); + try { - await authStore.setToken(baseUrl, undefined); + await authStore.setToken(baseUrl, null); + spinner.succeed("Logged out successfully!"); } catch (error) { spinner.fail("Logout failed."); - void handleError(error, "Logout"); + await handleError(error, "Logout"); } }; diff --git a/packages/cli/commands/companies.ts b/packages/cli/commands/companies.ts deleted file mode 100644 index d9ee95ca..00000000 --- a/packages/cli/commands/companies.ts +++ /dev/null @@ -1,83 +0,0 @@ -import chalk from "chalk"; -import { Command } from "commander"; -import ora, { Ora } from "ora"; - -import { getApp } from "../services/bootstrap.js"; -import { listCompanies } from "../services/companies.js"; -import { configStore } from "../stores/config.js"; -import { - handleError, - MissingAppIdError, - MissingEnvIdError, -} from "../utils/errors.js"; -import { appIdOption, companyFilterOption } from "../utils/options.js"; -import { baseUrlSuffix } from "../utils/urls.js"; - -export const listCompaniesAction = async (options: { filter?: string }) => { - const { baseUrl, appId } = configStore.getConfig(); - let spinner: Ora | undefined; - - if (!appId) { - return handleError(new MissingAppIdError(), "Companies List"); - } - - try { - const app = getApp(appId); - const production = app.environments.find((e) => e.isProduction); - if (!production) { - return handleError(new MissingEnvIdError(), "Companies List"); - } - - spinner = ora( - `Loading companies for app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}...`, - ).start(); - - const companiesResponse = await listCompanies(appId, { - envId: production.id, - // Use the filter for name/ID filtering if provided - idNameFilter: options.filter, - }); - - spinner.succeed( - `Loaded companies for app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}.`, - ); - - console.table( - companiesResponse.data.map(({ id, name, userCount, lastSeen }) => ({ - id, - name: name || "(unnamed)", - users: userCount, - lastSeen: lastSeen ? new Date(lastSeen).toLocaleDateString() : "Never", - })), - ); - - console.log(`Total companies: ${companiesResponse.totalCount}`); - } catch (error) { - spinner?.fail("Loading companies failed."); - void handleError(error, "Companies List"); - } -}; - -export function registerCompanyCommands(cli: Command) { - const companiesCommand = new Command("companies").description( - "Manage companies.", - ); - - companiesCommand - .command("list") - .alias("ls") - .description("List all companies.") - .addOption(appIdOption) - .addOption(companyFilterOption) - .action(listCompaniesAction); - - // Update the config with the cli override values - companiesCommand.hook("preAction", (_, command) => { - const { appId } = command.opts(); - configStore.setConfig({ - appId, - }); - }); - - cli.addCommand(companiesCommand); -} diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index 08f3c9a5..62d659d4 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -1,16 +1,11 @@ -import { input, select } from "@inquirer/prompts"; +import { input } from "@inquirer/prompts"; import chalk from "chalk"; -import { Argument, Command } from "commander"; +import { Command } from "commander"; import { relative } from "node:path"; import ora, { Ora } from "ora"; import { App, getApp, getOrg } from "../services/bootstrap.js"; -import { - createFeature, - Feature, - listFeatures, - updateFeatureAccess, -} from "../services/features.js"; +import { createFeature, Feature, listFeatures } from "../services/features.js"; import { configStore } from "../stores/config.js"; import { handleError, @@ -26,20 +21,13 @@ import { } from "../utils/gen.js"; import { appIdOption, - companyIdsOption, - disableFeatureOption, - enableFeatureOption, featureKeyOption, featureNameArgument, - segmentIdsOption, typesFormatOption, typesOutOption, - userIdsOption, } from "../utils/options.js"; import { baseUrlSuffix, featureUrl } from "../utils/urls.js"; -const lf = new Intl.ListFormat("en"); - type CreateFeatureArgs = { key?: string; }; @@ -52,14 +40,14 @@ export const createFeatureAction = async ( let spinner: Ora | undefined; if (!appId) { - return handleError(new MissingAppIdError(), "Features Create"); + handleError(new MissingAppIdError(), "Features Create"); } let app: App; try { app = getApp(appId); } catch (error) { - return handleError(error, "Features Create"); + handleError(error, "Features Create"); } const production = app.environments.find((e) => e.isProduction); @@ -69,6 +57,7 @@ export const createFeatureAction = async ( console.log( `Creating feature for app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}.`, ); + if (!name) { name = await input({ message: "New feature name:", @@ -88,6 +77,7 @@ export const createFeatureAction = async ( spinner = ora(`Creating feature...`).start(); const feature = await createFeature(appId, { name, key }); + spinner.succeed( `Created feature ${chalk.cyan(feature.name)} with key ${chalk.cyan(feature.key)}:`, ); @@ -98,7 +88,7 @@ export const createFeatureAction = async ( } } catch (error) { spinner?.fail("Feature creation failed."); - void handleError(error, "Features Create"); + handleError(error, "Features Create"); } }; @@ -107,24 +97,28 @@ export const listFeaturesAction = async () => { let spinner: Ora | undefined; if (!appId) { - return handleError(new MissingAppIdError(), "Features Create"); + handleError(new MissingAppIdError(), "Features Create"); } try { const app = getApp(appId); const production = app.environments.find((e) => e.isProduction); if (!production) { - return handleError(new MissingEnvIdError(), "Features Types"); + handleError(new MissingEnvIdError(), "Features Types"); } + spinner = ora( `Loading features of app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}...`, ).start(); + const featuresResponse = await listFeatures(appId, { envId: production.id, }); + spinner.succeed( `Loaded features of app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}.`, ); + console.table( featuresResponse.data.map(({ key, name, stage }) => ({ name, @@ -134,7 +128,7 @@ export const listFeaturesAction = async () => { ); } catch (error) { spinner?.fail("Loading features failed."); - void handleError(error, "Features List"); + handleError(error, "Features List"); } }; @@ -146,35 +140,37 @@ export const generateTypesAction = async () => { let features: Feature[] = []; if (!appId) { - return handleError(new MissingAppIdError(), "Features Types"); + handleError(new MissingAppIdError(), "Features Types"); } let app: App; try { app = getApp(appId); } catch (error) { - return handleError(error, "Features Types"); + handleError(error, "Features Types"); } const production = app.environments.find((e) => e.isProduction); if (!production) { - return handleError(new MissingEnvIdError(), "Features Types"); + handleError(new MissingEnvIdError(), "Features Types"); } try { spinner = ora( `Loading features of app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}...`, ).start(); + features = await listFeatures(appId, { envId: production.id, includeRemoteConfigs: true, }).then((res) => res.data); + spinner.succeed( `Loaded features of app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}.`, ); } catch (error) { spinner?.fail("Loading features failed."); - return handleError(error, "Features Types"); + handleError(error, "Features Types"); } try { @@ -183,7 +179,7 @@ export const generateTypesAction = async () => { // Generate types for each output configuration for (const output of typesOutput) { - const types = await genTypes(features, output.format); + const types = genTypes(features, output.format); const outPath = await writeTypesToFile(types, output.path, projectPath); spinner.succeed( `Generated ${output.format} types in ${chalk.cyan(relative(projectPath, outPath))}.`, @@ -193,129 +189,7 @@ export const generateTypesAction = async () => { spinner.succeed(`Generated types for app ${chalk.cyan(app.name)}.`); } catch (error) { spinner?.fail("Type generation failed."); - void handleError(error, "Features Types"); - } -}; - -export const featureAccessAction = async ( - featureKey: string | undefined, - options: { - enable: boolean; - disable: boolean; - companies?: string[]; - segments?: string[]; - users?: string[]; - }, -) => { - const { baseUrl, appId } = configStore.getConfig(); - let spinner: Ora | undefined; - - if (!appId) { - return handleError(new MissingAppIdError(), "Feature Access"); - } - - let app: App; - try { - app = getApp(appId); - } catch (error) { - return handleError(error, "Features Types"); - } - - const production = app.environments.find((e) => e.isProduction); - if (!production) { - return handleError(new MissingEnvIdError(), "Feature Access"); - } - - // Validate conflicting options - if (options.enable && options.disable) { - return handleError( - "Cannot both enable and disable a feature.", - "Feature Access", - ); - } - - if (!options.enable && !options.disable) { - return handleError( - "Must specify either --enable or --disable.", - "Feature Access", - ); - } - - // Validate at least one target is specified - if ( - !options.companies?.length && - !options.segments?.length && - !options.users?.length - ) { - return handleError( - "Must specify at least one target using --companies, --segments, or --users.", - "Feature Access", - ); - } - - // If feature key is not provided, let user select one - if (!featureKey) { - try { - spinner = ora( - `Loading features for app ${chalk.cyan(app.name)}${baseUrlSuffix( - baseUrl, - )}...`, - ).start(); - - const featuresResponse = await listFeatures(appId, { - envId: production.id, - }); - - if (featuresResponse.data.length === 0) { - return handleError("No features found for this app.", "Feature Access"); - } - - spinner.succeed( - `Loaded features for app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}.`, - ); - - featureKey = await select({ - message: "Select a feature to manage access:", - choices: featuresResponse.data.map((feature) => ({ - name: `${feature.name} (${feature.key})`, - value: feature.key, - })), - }); - } catch (error) { - spinner?.fail("Loading features failed."); - return handleError(error, "Feature Access"); - } - } - - // Determine if enabling or disabling - const isEnabled = options.enable; - - const targets = [ - ...(options.users?.map((id) => chalk.cyan(`user ID ${id}`)) ?? []), - ...(options.companies?.map((id) => chalk.cyan(`company ID ${id}`)) ?? []), - ...(options.segments?.map((id) => chalk.cyan(`segment ID ${id}`)) ?? []), - ]; - - try { - spinner = ora( - `${isEnabled ? "Enabling" : "Disabling"} feature ${chalk.cyan(featureKey)} for ${lf.format(targets)}...`, - ).start(); - - await updateFeatureAccess(appId, { - envId: production.id, - featureKey, - isEnabled, - companyIds: options.companies, - segmentIds: options.segments, - userIds: options.users, - }); - - spinner.succeed( - `${isEnabled ? "Enabled" : "Disabled"} feature ${chalk.cyan(featureKey)} for specified ${lf.format(targets)}.`, - ); - } catch (error) { - spinner?.fail(`Feature access update failed.`); - void handleError(error, "Feature Access"); + handleError(error, "Features Types"); } }; @@ -347,26 +221,6 @@ export function registerFeatureCommands(cli: Command) { .addOption(typesFormatOption) .action(generateTypesAction); - // Add feature access command - featuresCommand - .command("access") - .description( - "Grant or revoke feature access for companies, segments, and users.", - ) - .addOption(appIdOption) - .addArgument( - new Argument( - "[featureKey]", - "Feature key. If not provided, you'll be prompted to select one", - ), - ) - .addOption(enableFeatureOption) - .addOption(disableFeatureOption) - .addOption(companyIdsOption) - .addOption(segmentIdsOption) - .addOption(userIdsOption) - .action(featureAccessAction); - // Update the config with the cli override values featuresCommand.hook("preAction", (_, command) => { const { appId, out, format } = command.opts(); diff --git a/packages/cli/commands/init.ts b/packages/cli/commands/init.ts index 2b8cf7b1..704646ea 100644 --- a/packages/cli/commands/init.ts +++ b/packages/cli/commands/init.ts @@ -36,8 +36,7 @@ export const initAction = async (args: InitArgs = {}) => { spinner.succeed(`Loaded apps from ${chalk.cyan(baseUrl)}.`); } catch (error) { spinner?.fail("Loading apps failed."); - void handleError(error, "Initialization"); - return; + handleError(error, "Initialization"); } try { @@ -83,12 +82,13 @@ export const initAction = async (args: InitArgs = {}) => { // Create config file spinner = ora("Creating configuration...").start(); await configStore.saveConfigFile(args.overwrite); + spinner.succeed( `Configuration created at ${chalk.cyan(relative(process.cwd(), configStore.getConfigPath()!))}.`, ); } catch (error) { spinner?.fail("Configuration creation failed."); - void handleError(error, "Initialization"); + handleError(error, "Initialization"); } }; diff --git a/packages/cli/commands/mcp.ts b/packages/cli/commands/mcp.ts index e76a5837..566f5619 100644 --- a/packages/cli/commands/mcp.ts +++ b/packages/cli/commands/mcp.ts @@ -61,11 +61,13 @@ export const mcpAction = async (options: { if (!app) { throw new Error(`Could not find app with ID: ${options.appId}`); } + selectedAppId = app.id; } else { // Otherwise, show selection prompt const nonDemoApp = apps.find((app) => !app.demo); const longestName = Math.max(...apps.map((app) => app.name.length)); + selectedAppId = await select({ message: "Select an app", default: config.appId ?? nonDemoApp?.id, @@ -78,7 +80,7 @@ export const mcpAction = async (options: { selectedApp = apps.find((app) => app.id === selectedAppId)!; } catch (error) { spinner?.fail("Loading apps failed."); - return handleError(error, "MCP Configuration"); + handleError(error, "MCP Configuration"); } // Determine Config Path @@ -116,6 +118,7 @@ export const mcpAction = async (options: { spinner = ora( `Reading configuration file: ${chalk.cyan(displayConfigPath)}...`, ).start(); + let editorConfig: any = {}; if (await fileExists(configPath)) { const content = await readFile(configPath, "utf-8"); @@ -141,6 +144,7 @@ export const mcpAction = async (options: { selectedEditor, configPathType, ); + // Check for existing Bucket servers const existingBucketEntries = Object.keys(serversConfig).filter((key) => /bucket/i.test(key), @@ -195,14 +199,17 @@ export const mcpAction = async (options: { spinner = ora( `Writing configuration to ${chalk.cyan(displayConfigPath)}...`, ).start(); + try { // Ensure the directory exists before writing await mkdir(dirname(configPath), { recursive: true }); const configString = stringifyJSON(editorConfig, null, 2); + await writeFile(configPath, configString); spinner.succeed( `Configuration updated successfully in ${chalk.cyan(displayConfigPath)}.`, ); + console.log( chalk.grey( "You may need to restart your editor for changes to take effect.", @@ -212,7 +219,8 @@ export const mcpAction = async (options: { spinner.fail( `Failed to write configuration file ${chalk.cyan(displayConfigPath)}.`, ); - void handleError(error, "MCP Configuration"); + + handleError(error, "MCP Configuration"); } }; diff --git a/packages/cli/commands/rules.ts b/packages/cli/commands/rules.ts index 99b83e59..58fb6c4c 100644 --- a/packages/cli/commands/rules.ts +++ b/packages/cli/commands/rules.ts @@ -101,7 +101,7 @@ ${chalk.grey("These rules should be committed to your project's version control. ); } catch (error) { spinner.fail("Failed to add rules."); - void handleError(error, "Rules"); + handleError(error, "Rules"); } } else { console.log("Skipping adding rules."); diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 17373213..cd51bf67 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -5,7 +5,6 @@ import ora from "ora"; import { registerAppCommands } from "./commands/apps.js"; import { registerAuthCommands } from "./commands/auth.js"; -import { registerCompanyCommands } from "./commands/companies.js"; import { registerFeatureCommands } from "./commands/features.js"; import { registerInitCommand } from "./commands/init.js"; import { registerMcpCommand } from "./commands/mcp.js"; @@ -16,7 +15,12 @@ import { authStore } from "./stores/auth.js"; import { configStore } from "./stores/config.js"; import { commandName } from "./utils/commander.js"; import { handleError } from "./utils/errors.js"; -import { apiUrlOption, baseUrlOption, debugOption } from "./utils/options.js"; +import { + apiKeyOption, + apiUrlOption, + baseUrlOption, + debugOption, +} from "./utils/options.js"; import { stripTrailingSlash } from "./utils/urls.js"; const skipBootstrapCommands = [/^login/, /^logout/, /^rules/]; @@ -25,6 +29,7 @@ type Options = { debug?: boolean; baseUrl?: string; apiUrl?: string; + apiKey?: string; }; async function main() { @@ -36,17 +41,27 @@ async function main() { program.addOption(debugOption); program.addOption(baseUrlOption); program.addOption(apiUrlOption); + program.addOption(apiKeyOption); // Pre-action hook program.hook("preAction", async (_, actionCommand) => { - const { debug, baseUrl, apiUrl } = program.opts(); + const { debug, baseUrl, apiUrl, apiKey } = program.opts(); const cleanedBaseUrl = stripTrailingSlash(baseUrl?.trim()); + const cleanedApiUrl = stripTrailingSlash(apiUrl?.trim()); + + if (apiKey) { + console.info( + chalk.yellow( + "API key supplied. Using it instead of normal personal authentication.", + ), + ); + authStore.useApiKey(apiKey); + } + // Set baseUrl and apiUrl in config store, will skip if undefined configStore.setConfig({ baseUrl: cleanedBaseUrl, - apiUrl: - stripTrailingSlash(apiUrl) || - (cleanedBaseUrl && `${cleanedBaseUrl}/api`), + apiUrl: cleanedApiUrl || (cleanedBaseUrl && `${cleanedBaseUrl}/api`), }); // Skip bootstrapping for commands that don't require it @@ -54,18 +69,14 @@ async function main() { !skipBootstrapCommands.some((cmd) => cmd.test(commandName(actionCommand))) ) { const spinner = ora("Bootstrapping...").start(); + try { // Load bootstrap data if not already loaded await bootstrap(); spinner.stop(); } catch (error) { spinner.fail("Bootstrap failed."); - void handleError( - debug - ? error - : `Unable to reach ${configStore.getConfig("baseUrl")}.`, - "Connect", - ); + handleError(error, "Connect"); } } @@ -87,7 +98,6 @@ async function main() { registerAuthCommands(program); registerAppCommands(program); registerFeatureCommands(program); - registerCompanyCommands(program); registerMcpCommand(program); registerRulesCommand(program); diff --git a/packages/cli/services/bootstrap.ts b/packages/cli/services/bootstrap.ts index 8dfaf302..cd17b274 100644 --- a/packages/cli/services/bootstrap.ts +++ b/packages/cli/services/bootstrap.ts @@ -1,29 +1,6 @@ import { authRequest } from "../utils/auth.js"; import { KeyFormat } from "../utils/gen.js"; -export type BootstrapResponse = { - org: Org; - user: BucketUser; - segments: { [appId: string]: Segment[] }; -}; - -export type Org = { - id: string; - name: string; - logoUrl: string; - apps: App[]; - inviteKey: string; - createdAt: Date; - updatedAt: Date; - trialEndsAt: null; - suspendedAt: null; - accessLevel: string; - domain: null; - domainAutoJoin: boolean; - isGlobal: boolean; - featureKeyFormat: KeyFormat; -}; - export type Environment = { id: string; name: string; @@ -42,17 +19,18 @@ export type BucketUser = { id: string; email: string; name: string; - createdAt: Date; - updatedAt: Date; - avatarUrl: string; - isAdmin: boolean; }; -export type Segment = { +export type Org = { id: string; name: string; - system: boolean; - isAllSegment: boolean; + apps: App[]; + featureKeyFormat: KeyFormat; +}; + +export type BootstrapResponse = { + org: Org; + user: BucketUser; }; let bootstrapResponse: BootstrapResponse | null = null; @@ -106,10 +84,3 @@ export function getBucketUser(): BucketUser { } return bootstrapResponse.user; } - -export function listSegments(appId: string): Segment[] { - if (!bootstrapResponse) { - throw new Error("CLI has not been bootstrapped."); - } - return bootstrapResponse.segments[appId]; -} diff --git a/packages/cli/services/companies.ts b/packages/cli/services/companies.ts deleted file mode 100644 index 8684be63..00000000 --- a/packages/cli/services/companies.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { z } from "zod"; - -import { authRequest } from "../utils/auth.js"; -import { - EnvironmentQuerySchema, - PaginationQueryBaseSchema, -} from "../utils/schemas.js"; -import { - FunnelStep, - PaginatedResponse, - SatisfactionScore, -} from "../utils/types.js"; - -export type FeatureMetric = { - funnelStep: FunnelStep | null; - eventCount: number; - firstUsed: string | null; - lastUsed: string | null; - frequency: number | null; - satisfaction: SatisfactionScore; -}; - -export type CompanyName = { - id: string; - name: string | null; - avatarUrl: string | null; -}; - -export type Company = CompanyName & { - firstSeen: string | null; - lastSeen: string | null; - userCount: number; - eventCount: number; - feedbackCount: number; - attributes: Record; - featureMetrics: Record; -}; - -export type CompaniesResponse = PaginatedResponse; - -export const CompaniesSortByColumns = [ - "name", - "id", - "firstSeen", - "lastSeen", - "feedbackCount", - "userCount", -] as const; - -export const CompaniesSortBySchema = z - .enum(CompaniesSortByColumns) - .describe("Column to sort companies by"); - -export const CompaniesQuerySchema = EnvironmentQuerySchema.merge( - PaginationQueryBaseSchema(), -) - .extend({ - sortBy: CompaniesSortBySchema.default("name"), - idNameFilter: z.string().optional(), - }) - .strict(); - -export type CompaniesQuery = z.input; - -export async function listCompanies( - appId: string, - query: CompaniesQuery, -): Promise { - const { envId, ...body } = CompaniesQuerySchema.parse(query); - return authRequest(`/apps/${appId}/companies/search`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - params: { - envId, - }, - body: JSON.stringify(body), - }); -} diff --git a/packages/cli/services/features.ts b/packages/cli/services/features.ts index 236daa1a..ec4cb061 100644 --- a/packages/cli/services/features.ts +++ b/packages/cli/services/features.ts @@ -3,13 +3,16 @@ import { z } from "zod"; import { authRequest } from "../utils/auth.js"; import { booleanish, - EnvironmentQuery, EnvironmentQuerySchema, sortTypeSchema, } from "../utils/schemas.js"; import { PaginatedResponse } from "../utils/types.js"; -import { Stage } from "./stages.js"; +export type Stage = { + id: string; + name: string; + order: number; +}; export type FeatureSourceType = "event" | "attribute"; @@ -34,26 +37,10 @@ export type FeatureName = { parentFeatureId: string | null; }; -export type Flag = { - id: string; - currentVersions: { - id: string; - environment: { - id: string; - }; - targetingMode: string; - segmentIds: string[]; - companyIds: string[]; - userIds: string[]; - customRules: any; - }[]; -}; - export type Feature = FeatureName & { description: string | null; remoteConfigs: RemoteConfig[]; stage: Stage | null; - flagId: string | null; }; export type FeaturesResponse = PaginatedResponse; @@ -110,36 +97,14 @@ export async function listFeatures(appId: string, query: FeaturesQuery) { }); } -export async function listFeatureNames(appId: string) { - return authRequest(`/apps/${appId}/features/names`); -} - -type FeatureResponse = { - feature: Feature; +type CreateFeatureResponse = { + feature: FeatureName & { + description: string | null; + }; }; -export async function getFeature( - appId: string, - featureId: string, - query: EnvironmentQuery, -) { - return authRequest(`/apps/${appId}/features/${featureId}`, { - params: EnvironmentQuerySchema.parse(query), - }).then(({ feature }) => feature); -} - -export async function getFlag( - appId: string, - flagId: string, - query: EnvironmentQuery, -) { - return await authRequest(`/apps/${appId}/flags/${flagId}`, { - params: EnvironmentQuerySchema.parse(query), - }); -} - export async function createFeature(appId: string, featureData: FeatureCreate) { - return authRequest(`/apps/${appId}/features`, { + return authRequest(`/apps/${appId}/features`, { method: "POST", headers: { "Content-Type": "application/json", @@ -150,74 +115,3 @@ export async function createFeature(appId: string, featureData: FeatureCreate) { }), }).then(({ feature }) => feature); } - -export const FeatureAccessSchema = EnvironmentQuerySchema.extend({ - featureKey: z.string().describe("Feature key"), - isEnabled: booleanish.describe( - "Set feature to enabled or disabled for the targeted users, companies and segments.", - ), - userIds: z.array(z.string()).optional().describe("User IDs to target"), - companyIds: z.array(z.string()).optional().describe("Company IDs to target"), - segmentIds: z.array(z.string()).optional().describe("Segment IDs to target"), -}).strict(); - -export type FeatureAccess = z.input; - -export type FlagVersion = { - id: string; - version: number; - changeDescription: string; - targetingMode: string; - userIds: string[]; - companyIds: string[]; - segmentIds: string[]; -}; - -export type UpdateFeatureAccessResponse = { - flagVersions: FlagVersion[]; -}; - -export async function updateFeatureAccess(appId: string, query: FeatureAccess) { - const { - envId, - featureKey, - isEnabled, - companyIds = [], - segmentIds = [], - userIds = [], - } = FeatureAccessSchema.parse(query); - - const targets = [ - ...companyIds.map((id) => ({ - key: featureKey, - companyId: id, - enabled: isEnabled, - })), - ...segmentIds.map((id) => ({ - key: featureKey, - segmentId: id, - enabled: isEnabled, - })), - ...userIds.map((id) => ({ - key: featureKey, - userId: id, - enabled: isEnabled, - })), - ]; - - return authRequest( - `/apps/${appId}/features/targeting`, - { - method: "PATCH", - params: { - envId, - }, - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - targets, - }), - }, - ); -} diff --git a/packages/cli/services/feedback.ts b/packages/cli/services/feedback.ts deleted file mode 100644 index 586edfa4..00000000 --- a/packages/cli/services/feedback.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { z } from "zod"; - -import { authRequest } from "../utils/auth.js"; -import { - EnvironmentQuerySchema, - ExternalIdSchema, - PaginationQueryBaseSchema, -} from "../utils/schemas.js"; -import { - FeedbackSource, - FunnelStep, - FunnelStepList, - PaginatedResponse, - SatisfactionScore, -} from "../utils/types.js"; - -export type Feedback = { - id: string; - companyId: string | null; - companyName: string | null; - companyAvatarUrl: string | null; - companyFunnelStep: FunnelStep | null; - featureId: string; - featureName: string | null; - userId: string | null; - userName: string | null; - userEmail: string | null; - userAvatarUrl: string | null; - question: string | null; - score: SatisfactionScore; - comment: string | null; - source: FeedbackSource | null; - timestamp: string; - updatedAt: string; -}; - -export type FeedbackResponse = PaginatedResponse; - -export const SatisfactionScoreFilterSchema = z.coerce - .number() - .int() - .gte(0) - .lte(5) - .array() - .describe("Array of satisfaction scores (0-5)"); - -export const FeedbackListSortByColumns = [ - "score", - "comment", - "userName", - "userEmail", - "companyName", - "companyFunnelStep", - "timestamp", -] as const; - -export const FeedbackListSortBySchema = z - .enum(FeedbackListSortByColumns) - .describe("Column to sort feedback by"); -export type FeedbackListSortBy = z.infer; - -export const FeedbackQuerySchema = EnvironmentQuerySchema.merge( - PaginationQueryBaseSchema({ - sortOrder: "desc", - }), -) - .extend({ - sortBy: FeedbackListSortBySchema.default("timestamp").describe( - "Field to sort feedback by", - ), - satisfaction: SatisfactionScoreFilterSchema.optional() - .default([0, 1, 2, 3, 4, 5]) - .describe("Filter by satisfaction scores (0-5)"), - featureId: z - .string() - .length(14) - .optional() - .describe("Filter by feature ID"), - companyId: ExternalIdSchema.optional().describe("Filter by company ID"), - funnelSteps: z - .enum(FunnelStepList) - .array() - .optional() - .default(["company", "segment", "tried", "adopted", "retained"]) - .describe("Filter by funnel steps"), - segmentId: z - .string() - .length(14) - .optional() - .describe("Filter by segment ID"), - }) - .strict(); - -export type FeedbackQuery = z.input; - -export async function listFeedback( - appId: string, - query: FeedbackQuery, -): Promise { - return authRequest(`/apps/${appId}/feedback`, { - params: FeedbackQuerySchema.parse(query), - }); -} diff --git a/packages/cli/services/stages.ts b/packages/cli/services/stages.ts deleted file mode 100644 index d365b0c8..00000000 --- a/packages/cli/services/stages.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { z } from "zod"; - -import { authRequest } from "../utils/auth.js"; -import { EnvironmentQuerySchema } from "../utils/schemas.js"; - -import { getFeature, getFlag } from "./features.js"; - -export type Stage = { - id: string; - name: string; - order: number; -}; - -type StagesResponse = { - stages: Stage[]; -}; - -export async function listStages(appId: string): Promise { - const response = await authRequest(`/apps/${appId}/stages`); - return response.stages; -} - -export const FeatureTargetingModes = ["none", "some", "everyone"] as const; -export type FeatureTargetingMode = (typeof FeatureTargetingModes)[number]; -export const UpdateFeatureStageSchema = EnvironmentQuerySchema.extend({ - featureId: z.string().describe("The ID of the feature to update."), - stageId: z - .string() - .describe("The ID of the stage to transition the feature to."), - targetingMode: z.enum(["none", "some", "everyone"]) - .describe(`The overarching targeting mode for the feature. - - none: Feature is disabled for all targets. - - some: Feature is enabled only for specified targets. - - everyone: Feature is enabled for all targets.`), - changeDescription: z.string().describe("The reason for the change."), -}); -export type UpdateFeatureStageQuery = z.input; - -export async function updateFeatureStage( - appId: string, - query: UpdateFeatureStageQuery, -) { - const { featureId, stageId, targetingMode, envId, changeDescription } = - UpdateFeatureStageSchema.parse(query); - - const feature = await getFeature(appId, featureId, { - envId, - }); - - if (!feature) { - throw new Error(`Feature not found for ID ${featureId}.`); - } - - const flag = await getFlag(appId, feature.flagId!, { - envId, - }); - - const currFlagVersion = flag.currentVersions; - const envFlagVersion = currFlagVersion.find( - (v) => v.environment.id === envId, - ); - if (!envFlagVersion) { - throw new Error(`Flag version not found for environment ${envId}.`); - } - envFlagVersion.targetingMode = targetingMode; - const body = { - stageId, - changeDescription, - versions: [ - { - environmentId: envId, - targetingMode: envFlagVersion.targetingMode, - segmentIds: envFlagVersion.segmentIds, - companyIds: envFlagVersion.companyIds, - userIds: envFlagVersion.userIds, - customRules: envFlagVersion.customRules, - }, - ], - }; - - await authRequest(`/apps/${appId}/flags/${flag.id}/versions`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - params: { - envId, - "currentVersionIds[]": flag.currentVersions.map((v) => v.id), - }, - body: JSON.stringify(body), - }); - return { - feature, - }; -} diff --git a/packages/cli/services/users.ts b/packages/cli/services/users.ts deleted file mode 100644 index 2ccb0456..00000000 --- a/packages/cli/services/users.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { z } from "zod"; - -import { authRequest } from "../utils/auth.js"; -import { - EnvironmentQuerySchema, - PaginationQueryBaseSchema, -} from "../utils/schemas.js"; -import { PaginatedResponse } from "../utils/types.js"; - -export type UserName = { - id: string; - name: string | null; - email: string | null; - avatarUrl: string | null; -}; - -export type User = UserName & { - firstSeen: string | null; - lastSeen: string | null; - eventCount: number; -}; - -export type UsersResponse = PaginatedResponse; - -export const UsersSortByColumns = [ - "id", - "name", - "email", - "avatarUrl", - "firstSeen", - "lastSeen", - "eventCount", -] as const; - -export const UsersSortBySchema = z - .enum(UsersSortByColumns) - .describe("Column to sort users by"); - -export const UsersQuerySchema = EnvironmentQuerySchema.merge( - PaginationQueryBaseSchema(), -) - .extend({ - sortBy: UsersSortBySchema.default("name"), - }) - .strict(); - -export type UsersQuery = z.input; - -export async function listUsers( - appId: string, - query: UsersQuery, -): Promise { - return authRequest(`/apps/${appId}/users`, { - params: UsersQuerySchema.parse(query), - }); -} diff --git a/packages/cli/stores/auth.ts b/packages/cli/stores/auth.ts index 919fd2a0..142ccb3a 100644 --- a/packages/cli/stores/auth.ts +++ b/packages/cli/stores/auth.ts @@ -5,6 +5,7 @@ import { AUTH_FILE } from "../utils/constants.js"; class AuthStore { protected tokens: Map = new Map(); + protected apiKey: string | undefined; async initialize() { await this.loadTokenFile(); @@ -31,23 +32,33 @@ class AuthStore { const content = Array.from(newTokens.entries()) .map(([baseUrl, token]) => `${baseUrl}|${token}`) .join("\n"); + await mkdir(dirname(AUTH_FILE), { recursive: true }); await writeFile(AUTH_FILE, content); + this.tokens = newTokens; } getToken(baseUrl: string) { - return this.tokens.get(baseUrl); + return { + token: this.apiKey || this.tokens.get(baseUrl), + isApiKey: !!this.apiKey, + }; } - async setToken(baseUrl: string, newToken?: string) { + async setToken(baseUrl: string, newToken: string | null) { if (newToken) { this.tokens.set(baseUrl, newToken); } else { this.tokens.delete(baseUrl); } + await this.saveTokenFile(this.tokens); } + + useApiKey(key: string) { + this.apiKey = key; + } } export const authStore = new AuthStore(); diff --git a/packages/cli/stores/config.ts b/packages/cli/stores/config.ts index ab04762c..37c8638e 100644 --- a/packages/cli/stores/config.ts +++ b/packages/cli/stores/config.ts @@ -78,17 +78,17 @@ class ConfigStore { const schemaPath = join(moduleRoot, "schema.json"); const content = await readFile(schemaPath, "utf-8"); const parsed = parseJSON(content) as unknown as Config; + const ajv = new Ajv(); this.validateConfig = ajv.compile(parsed); } catch { - void handleError(new Error("Failed to load the config schema"), "Config"); + handleError(new Error("Failed to load the config schema"), "Config"); } } protected async loadConfigFile() { if (!this.validateConfig) { - void handleError(new Error("Failed to load the config schema"), "Config"); - return; + handleError(new Error("Failed to load the config schema"), "Config"); } // Load the client version from the module's package.json metadata @@ -126,7 +126,7 @@ class ConfigStore { parsed.typesOutput = normalizeTypesOutput(parsed.typesOutput); if (!this.validateConfig!(parsed)) { - void handleError( + handleError( new ConfigValidationError(this.validateConfig!.errors), "Config", ); diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index ca948b5f..ca1676ec 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -10,9 +10,12 @@ import { CLIENT_VERSION_HEADER_NAME, CLIENT_VERSION_HEADER_VALUE, } from "./constants.js"; +import { ResponseError } from "./errors.js"; import { ParamType } from "./types.js"; import { errorUrl, loginUrl, successUrl } from "./urls.js"; +const maxRetryCount = 1; + interface waitForAccessToken { accessToken: string; expiresAt: Date; @@ -132,14 +135,21 @@ export async function authRequest>( retryCount = 0, ): Promise { const { baseUrl, apiUrl } = configStore.getConfig(); - const token = authStore.getToken(baseUrl); + const { token, isApiKey } = authStore.getToken(baseUrl); if (!token) { const accessToken = await waitForAccessToken(baseUrl, apiUrl); + await authStore.setToken(baseUrl, accessToken.accessToken); return authRequest(url, options); } + + if (url.startsWith("/")) { + url = url.slice(1); + } + const resolvedUrl = new URL(`${apiUrl}/${url}`); + if (options?.params) { Object.entries(options.params).forEach(([key, value]) => { if (value !== null && value !== undefined) { @@ -152,26 +162,46 @@ export async function authRequest>( }); } - const response = await fetch(resolvedUrl, { - ...options, - headers: { - ...options?.headers, - Authorization: `Bearer ${token}`, - [CLIENT_VERSION_HEADER_NAME]: CLIENT_VERSION_HEADER_VALUE( - configStore.getClientVersion() ?? "unknown", - ), - }, - }); + let response: Response | undefined; + + try { + response = await fetch(resolvedUrl, { + ...options, + headers: { + ...options?.headers, + Authorization: `Bearer ${token}`, + [CLIENT_VERSION_HEADER_NAME]: CLIENT_VERSION_HEADER_VALUE( + configStore.getClientVersion() ?? "unknown", + ), + }, + }); + } catch (error: unknown) { + const message = + error && typeof error == "object" && "message" in error + ? error.message + : "unknown"; + + throw new Error(`Failed to connect to "${resolvedUrl}". Error: ${message}`); + } if (!response.ok) { if (response.status === 401) { - await authStore.setToken(baseUrl, undefined); - if (retryCount < 1) { + if (isApiKey) { + throw new Error( + `The provided API key is not valid for "${resolvedUrl}".`, + ); + } + + await authStore.setToken(baseUrl, null); + + if (retryCount < maxRetryCount) { await waitForAccessToken(baseUrl, apiUrl); return authRequest(url, options, retryCount + 1); } } - throw response; + + const data = await response.json(); + throw new ResponseError(data); } return response.json(); diff --git a/packages/cli/utils/errors.ts b/packages/cli/utils/errors.ts index 7e45e79e..e5bae154 100644 --- a/packages/cli/utils/errors.ts +++ b/packages/cli/utils/errors.ts @@ -34,17 +34,37 @@ export class ConfigValidationError extends Error { } } -export async function handleError(error: unknown, tag: string) { +type ResponseErrorData = { + error?: { + message?: string; + code?: string; + }; + validationErrors?: { path: string[]; message: string }[]; +}; + +export class ResponseError extends Error { + public readonly data: ResponseErrorData; + + constructor(response: ResponseErrorData) { + super(response.error?.message ?? response.error?.code); + this.data = response; + this.name = "ResponseError"; + } +} + +export function handleError(error: unknown, tag: string): never { tag = chalk.bold(`\n[${tag}] error:`); if (error instanceof ExitPromptError) { process.exit(0); - } else if (error instanceof Response) { - const data = await error.json(); - console.error(chalk.red(tag, data.error?.message ?? data.error?.code)); - if (data.validationErrors) { + } else if (error instanceof ResponseError) { + console.error( + chalk.red(tag, error.data.error?.message ?? error.data.error?.code), + ); + + if (error.data.validationErrors) { console.table( - data.validationErrors.map( + error.data.validationErrors.map( ({ path, message }: { path: string[]; message: string }) => ({ path: path.join("."), error: message, @@ -54,11 +74,15 @@ export async function handleError(error: unknown, tag: string) { } } else if (error instanceof Error) { console.error(chalk.red(tag, error.message)); - if (error.cause) console.error(error.cause); + + if (error.cause) { + console.error(error.cause); + } } else if (typeof error === "string") { console.error(chalk.red(tag, error)); } else { console.error(chalk.red(tag ?? "An unknown error occurred:", error)); } + process.exit(1); } diff --git a/packages/cli/utils/gen.ts b/packages/cli/utils/gen.ts index ca4b15e4..16ce97e0 100644 --- a/packages/cli/utils/gen.ts +++ b/packages/cli/utils/gen.ts @@ -97,7 +97,11 @@ export function genTypes(features: Feature[], format: GenFormat = "react") { const configDefs = new Map(); features.forEach(({ key, name, remoteConfigs }) => { const definition = genRemoteConfig(remoteConfigs); - if (!definition) return; + + if (!definition) { + return; + } + const configName = `${pascalCase(name)}ConfigPayload`; configDefs.set(key, { name: configName, definition }); }); diff --git a/packages/cli/utils/options.ts b/packages/cli/utils/options.ts index c9d10238..6c8b097a 100644 --- a/packages/cli/utils/options.ts +++ b/packages/cli/utils/options.ts @@ -17,6 +17,11 @@ export const apiUrlOption = new Option( `Bucket API URL (useful if behind a proxy). Falls back to apiUrl value in ${CONFIG_FILE_NAME} or baseUrl with /api appended.`, ); +export const apiKeyOption = new Option( + "--api-key [key]", + `Bucket API key. Can be used in CI/CD pipelines where logging in is not possible.`, +); + export const appIdOption = new Option( "-a, --appId [appId]", `Bucket App ID. Falls back to appId value in ${CONFIG_FILE_NAME}.`, @@ -47,42 +52,6 @@ export const featureKeyOption = new Option( "Feature key. If not provided, a key is generated from the feature's name.", ); -export const companyFilterOption = new Option( - "-f, --filter [name]", - "Filter companies by name or ID.", -); - -export const companyIdArgument = new Argument("", "Company ID"); -export const featureKeyArgument = new Argument( - "[featureKey]", - "Feature key. If not provided, you'll be prompted to select one.", -); - -export const enableFeatureOption = new Option( - "--enable", - "Enable the feature for the target.", -).conflicts("disable"); - -export const disableFeatureOption = new Option( - "--disable", - "Disable the feature for the target.", -).conflicts("enable"); - -export const userIdsOption = new Option( - "--users ", - "User IDs to target. Can be specified multiple times.", -); - -export const companyIdsOption = new Option( - "--companies ", - "Company IDs to target. Can be specified multiple times.", -); - -export const segmentIdsOption = new Option( - "--segments ", - "Segment IDs to target. Can be specified multiple times.", -); - export const editorOption = new Option( "-e, --editor [editor]", "Specify the editor to configure for MCP.", From 54735ead7e9749272b165669e30f1420bef84a0b Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 23 Jul 2025 10:38:53 +0100 Subject: [PATCH 02/10] chore: update CLI to check for the latest version and enhance type generation - Integrated a background version check in the CLI to notify users of available updates. - Added a `--check-only` option to the `generateTypesAction` for validating if types are up to date without generating them. - Introduced a new utility function to check types in files, improving type management. - Updated dependencies in `package.json` and `yarn.lock` to include `@types/semver` and `semver` version 7.7.2. --- packages/cli/commands/features.ts | 49 ++++++++++++++++++----- packages/cli/index.ts | 19 +++++++++ packages/cli/package.json | 2 + packages/cli/stores/config.ts | 20 +++------- packages/cli/utils/constants.ts | 6 +++ packages/cli/utils/gen.ts | 20 +++++++++- packages/cli/utils/options.ts | 5 +++ packages/cli/utils/version.ts | 64 +++++++++++++++++++++++++++++++ yarn.lock | 11 +++++- 9 files changed, 170 insertions(+), 26 deletions(-) create mode 100644 packages/cli/utils/version.ts diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index 62d659d4..fb6a224b 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -13,6 +13,7 @@ import { MissingEnvIdError, } from "../utils/errors.js"; import { + checkTypesInFile, genFeatureKey, genTypes, indentLines, @@ -23,18 +24,19 @@ import { appIdOption, featureKeyOption, featureNameArgument, + typesCheckOnlyOption, typesFormatOption, typesOutOption, } from "../utils/options.js"; import { baseUrlSuffix, featureUrl } from "../utils/urls.js"; -type CreateFeatureArgs = { +type CreateFeatureOptions = { key?: string; }; export const createFeatureAction = async ( name: string | undefined, - { key }: CreateFeatureArgs, + { key }: CreateFeatureOptions, ) => { const { baseUrl, appId } = configStore.getConfig(); let spinner: Ora | undefined; @@ -132,7 +134,13 @@ export const listFeaturesAction = async () => { } }; -export const generateTypesAction = async () => { +type GenerateTypesOptions = { + checkOnly?: boolean; +}; + +export const generateTypesAction = async ({ + checkOnly, +}: GenerateTypesOptions = {}) => { const { baseUrl, appId } = configStore.getConfig(); const typesOutput = configStore.getConfig("typesOutput"); @@ -174,19 +182,41 @@ export const generateTypesAction = async () => { } try { - spinner = ora("Generating feature types...").start(); + spinner = ora( + `${checkOnly ? "Checking" : "Generating"} feature types...`, + ).start(); const projectPath = configStore.getProjectPath(); // Generate types for each output configuration for (const output of typesOutput) { const types = genTypes(features, output.format); - const outPath = await writeTypesToFile(types, output.path, projectPath); - spinner.succeed( - `Generated ${output.format} types in ${chalk.cyan(relative(projectPath, outPath))}.`, - ); + + if (checkOnly) { + const { fullPath, isUpToDate } = await checkTypesInFile( + types, + output.path, + projectPath, + ); + + if (!isUpToDate) { + spinner.fail(`Types are not up to date in ${chalk.cyan(fullPath)}.`); + handleError(`Type check failed.`, "Features Types"); + } else { + spinner.succeed( + `Validated ${output.format} types in ${chalk.cyan(relative(projectPath, fullPath))}.`, + ); + } + } else { + const outPath = await writeTypesToFile(types, output.path, projectPath); + spinner.succeed( + `"Generated ${output.format} types in ${chalk.cyan(relative(projectPath, outPath))}.`, + ); + } } - spinner.succeed(`Generated types for app ${chalk.cyan(app.name)}.`); + spinner.succeed( + `${checkOnly ? "Checked" : "Generated"} types for app ${chalk.cyan(app.name)}.`, + ); } catch (error) { spinner?.fail("Type generation failed."); handleError(error, "Features Types"); @@ -219,6 +249,7 @@ export function registerFeatureCommands(cli: Command) { .addOption(appIdOption) .addOption(typesOutOption) .addOption(typesFormatOption) + .addOption(typesCheckOnlyOption) .action(generateTypesAction); // Update the config with the cli override values diff --git a/packages/cli/index.ts b/packages/cli/index.ts index cd51bf67..ef326b4b 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -22,6 +22,7 @@ import { debugOption, } from "./utils/options.js"; import { stripTrailingSlash } from "./utils/urls.js"; +import { checkLatest as checkLatestVersion } from "./utils/version.js"; const skipBootstrapCommands = [/^login/, /^logout/, /^rules/]; @@ -33,6 +34,9 @@ type Options = { }; async function main() { + // Start a version check in the background + const cliVersionCheckPromise = checkLatestVersion(); + // Must load tokens and config before anything else await authStore.initialize(); await configStore.initialize(); @@ -80,6 +84,21 @@ async function main() { } } + try { + const { latestVersion, currentVersion, isNewerAvailable } = + await cliVersionCheckPromise; + + if (isNewerAvailable) { + console.info( + `A new version of the CLI is available: ${chalk.yellow( + currentVersion, + )} -> ${chalk.green(latestVersion)}. Update to ensure you have the latest features and bug fixes.`, + ); + } + } catch { + // Ignore errors + } + if (debug) { console.debug(chalk.cyan("\nDebug mode enabled.")); const user = getBucketUser(); diff --git a/packages/cli/package.json b/packages/cli/package.json index bae2174d..f68726aa 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -52,6 +52,7 @@ "find-up": "^7.0.0", "open": "^10.1.0", "ora": "^8.1.0", + "semver": "^7.7.2", "slug": "^10.0.0", "zod": "^3.24.2" }, @@ -60,6 +61,7 @@ "@bucketco/tsconfig": "workspace:^", "@types/express": "^5.0.0", "@types/node": "^22.5.1", + "@types/semver": "^7.7.0", "@types/slug": "^5.0.9", "eslint": "^9.21.0", "prettier": "^3.5.2", diff --git a/packages/cli/stores/config.ts b/packages/cli/stores/config.ts index 37c8638e..d6b79434 100644 --- a/packages/cli/stores/config.ts +++ b/packages/cli/stores/config.ts @@ -8,17 +8,18 @@ import equal from "fast-deep-equal"; import { findUp } from "find-up"; import { readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; import { CONFIG_FILE_NAME, DEFAULT_API_URL, DEFAULT_BASE_URL, DEFAULT_TYPES_OUTPUT, + MODULE_ROOT, SCHEMA_URL, } from "../utils/constants.js"; import { ConfigValidationError, handleError } from "../utils/errors.js"; import { stripTrailingSlash } from "../utils/urls.js"; +import { current as currentVersion } from "../utils/version.js"; export const typeFormats = ["react", "node"] as const; export type TypeFormat = (typeof typeFormats)[number]; @@ -44,11 +45,6 @@ const defaultConfig: Config = { typesOutput: [{ path: DEFAULT_TYPES_OUTPUT, format: "react" }], }; -const moduleRoot = fileURLToPath(import.meta.url).substring( - 0, - fileURLToPath(import.meta.url).lastIndexOf("cli") + 3, -); - // Helper to normalize typesOutput to array format export function normalizeTypesOutput( output?: string | TypesOutput[], @@ -75,7 +71,7 @@ class ConfigStore { protected async createValidator() { try { // Using current config store file, resolve the schema.json path - const schemaPath = join(moduleRoot, "schema.json"); + const schemaPath = join(MODULE_ROOT, "schema.json"); const content = await readFile(schemaPath, "utf-8"); const parsed = parseJSON(content) as unknown as Config; @@ -93,14 +89,8 @@ class ConfigStore { // Load the client version from the module's package.json metadata try { - const moduleMetadata = await readFile( - join(moduleRoot, "package.json"), - "utf-8", - ); - const moduleMetadataParsed = parseJSON(moduleMetadata) as unknown as { - version: string; - }; - this.clientVersion = moduleMetadataParsed.version; + const { version } = await currentVersion(); + this.clientVersion = version; } catch { // Should not be the case, but ignore if no package.json is found } diff --git a/packages/cli/utils/constants.ts b/packages/cli/utils/constants.ts index 2260266d..e0bb2425 100644 --- a/packages/cli/utils/constants.ts +++ b/packages/cli/utils/constants.ts @@ -1,5 +1,6 @@ import os from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; export const CLIENT_VERSION_HEADER_NAME = "bucket-sdk-version"; export const CLIENT_VERSION_HEADER_VALUE = (version: string) => @@ -12,3 +13,8 @@ export const SCHEMA_URL = `https://unpkg.com/@bucketco/cli@latest/schema.json`; export const DEFAULT_BASE_URL = "https://app.bucket.co"; export const DEFAULT_API_URL = `${DEFAULT_BASE_URL}/api`; export const DEFAULT_TYPES_OUTPUT = join("gen", "features.d.ts"); + +export const MODULE_ROOT = fileURLToPath(import.meta.url).substring( + 0, + fileURLToPath(import.meta.url).lastIndexOf("cli") + 3, +); diff --git a/packages/cli/utils/gen.ts b/packages/cli/utils/gen.ts index 16ce97e0..5103d8a3 100644 --- a/packages/cli/utils/gen.ts +++ b/packages/cli/utils/gen.ts @@ -1,5 +1,5 @@ import { camelCase, kebabCase, pascalCase, snakeCase } from "change-case"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join } from "node:path"; import { Feature, RemoteConfig } from "../services/features.js"; @@ -146,3 +146,21 @@ export async function writeTypesToFile( return fullPath; } + +export async function checkTypesInFile( + types: string, + outPath: string, + projectPath: string, +) { + const fullPath = isAbsolute(outPath) ? outPath : join(projectPath, outPath); + + try { + const existingTypes = await readFile(fullPath, "utf-8"); + + return { fullPath, isUpToDate: existingTypes === types }; + } catch { + // File doesn't exist, so it's not up to date + } + + return { fullPath, isUpToDate: false }; +} diff --git a/packages/cli/utils/options.ts b/packages/cli/utils/options.ts index 6c8b097a..e080d244 100644 --- a/packages/cli/utils/options.ts +++ b/packages/cli/utils/options.ts @@ -42,6 +42,11 @@ export const typesFormatOption = new Option( "Single output format for generated feature types", ).choices(["react", "node"]); +export const typesCheckOnlyOption = new Option( + "--check-only", + "Only checks if types are up to date and exits with a non-zero code if they are not.", +); + export const featureNameArgument = new Argument( "[name]", "Feature's name. If not provided, you'll be prompted to enter one.", diff --git a/packages/cli/utils/version.ts b/packages/cli/utils/version.ts new file mode 100644 index 00000000..d29cf22d --- /dev/null +++ b/packages/cli/utils/version.ts @@ -0,0 +1,64 @@ +import { readFile } from "fs/promises"; +import { join } from "path"; +import { gt } from "semver"; + +import { MODULE_ROOT } from "./constants.js"; + +export async function current() { + try { + const packageJsonPath = join(MODULE_ROOT, "package.json"); + const packageJsonContent = await readFile(packageJsonPath, "utf-8"); + const packageInfo: { + version: string; + name: string; + } = JSON.parse(packageJsonContent); + + return { + version: packageInfo.version, + name: packageInfo.name, + }; + } catch (error) { + throw new Error( + `Failed to read current version: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } +} + +async function getLatestVersionFromNpm(packageName: string): Promise { + try { + const response = await fetch(`https://registry.npmjs.org/${packageName}`, { + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch package info: ${response.status} ${response.statusText}`, + ); + } + + const data: { + "dist-tags": { + latest: string; + }; + } = await response.json(); + + return data["dist-tags"].latest; + } catch (error) { + throw new Error( + `Failed to fetch latest version from npm: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } +} + +export async function checkLatest() { + const { version: currentVersion, name: packageName } = await current(); + + const latestVersion = await getLatestVersionFromNpm(packageName); + const isNewerAvailable = gt(latestVersion, currentVersion); + + return { + currentVersion, + latestVersion, + isNewerAvailable, + }; +} diff --git a/yarn.lock b/yarn.lock index 969da669..80d70672 100644 --- a/yarn.lock +++ b/yarn.lock @@ -724,6 +724,7 @@ __metadata: "@inquirer/prompts": "npm:^5.3.8" "@types/express": "npm:^5.0.0" "@types/node": "npm:^22.5.1" + "@types/semver": "npm:^7.7.0" "@types/slug": "npm:^5.0.9" ajv: "npm:^8.17.1" chalk: "npm:^5.3.0" @@ -737,6 +738,7 @@ __metadata: open: "npm:^10.1.0" ora: "npm:^8.1.0" prettier: "npm:^3.5.2" + semver: "npm:^7.7.2" shx: "npm:^0.3.4" slug: "npm:^10.0.0" typescript: "npm:^5.5.4" @@ -4330,6 +4332,13 @@ __metadata: languageName: node linkType: hard +"@types/semver@npm:^7.7.0": + version: 7.7.0 + resolution: "@types/semver@npm:7.7.0" + checksum: 10c0/6b5f65f647474338abbd6ee91a6bbab434662ddb8fe39464edcbcfc96484d388baad9eb506dff217b6fc1727a88894930eb1f308617161ac0f376fe06be4e1ee + languageName: node + linkType: hard + "@types/send@npm:*": version: 0.17.4 resolution: "@types/send@npm:0.17.4" @@ -16425,7 +16434,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.3.6": +"semver@npm:^7.3.6, semver@npm:^7.7.2": version: 7.7.2 resolution: "semver@npm:7.7.2" bin: From 8b33f044b7e080b0d76d9c0ebc58da2628207348 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 23 Jul 2025 10:51:22 +0100 Subject: [PATCH 03/10] chore: release CLI version 2.0.0 with API key support and documentation updates - Bumped CLI version to 2.0.0 in package.json. - Added `--api-key` option for non-interactive authentication in README.md. - Enhanced documentation for using the CLI in CI/CD pipelines. - Updated success message in `generateTypesAction` for clarity. - Removed redundant success message in `generateTypesAction` to streamline output. --- packages/cli/README.md | 45 +++++++++++++++++++++++++++++++ packages/cli/commands/features.ts | 6 +---- packages/cli/package.json | 2 +- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 83784c2e..772bfef9 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -241,6 +241,7 @@ These options can be used with any command: - `--debug`: Enable debug mode for verbose output. - `--base-url `: Set the base URL for Bucket API. - `--api-url `: Set the API URL directly (overrides base URL). +- `--api-key `: Bucket API key for non-interactive authentication. - `--help`: Display help information for a command. ## AI-Assisted Development @@ -297,6 +298,50 @@ The command will guide you through: _**Note: The setup uses [mcp-remote](https://github.com/geelen/mcp-remote) as a compatibility layer allowing the remote hosted Bucket MCP server to work with all editors/clients that support MCP STDIO servers. If your editor/client supports HTTP Streaming with OAuth you can connect to the Bucket MCP server directly.**_ +## Using in CI/CD Pipelines (Beta) + +The Bucket CLI is designed to work seamlessly in CI/CD pipelines. For automated environments where interactive login is not possible, use the `--api-key` option. + +```bash +# Generate types in CI/CD +npx bucket apps list --api-key $BUCKET_API_KEY +``` + +**Important restrictions:** + +- When using `--api-key`, the `login` and `logout` commands are disabled. +- API keys bypass all interactive authentication flows. +- Only _read-only_ access to Bucket API is granted at the moment. +- API keys are bound to one app only. Commands such as `apps list` will only return the bound app. +- Store API keys securely using your CI/CD platform's secret management. + +### Primary Use Case: Type Validation in CI/CD + +Use the `--check-only` flag with `features types` to validate that generated types are up-to-date: + +```bash +# Check if types are current (exits with non-zero code if not) +npx bucket features types --check-only --api-key $BUCKET_API_KEY --app-id ap123456789 +``` + +This is particularly useful for: + +- **Pull Request validation**: Ensure developers have regenerated types after feature changes. +- **Build verification**: Confirm types are synchronized before deployment. +- **Automated quality checks**: Catch type drift in your CI pipeline. + +Example CI workflow: + +```yaml +# GitHub Actions example +- name: Validate Bucket types + run: npx bucket features types --check-only --api-key ${{ secrets.BUCKET_API_KEY }} + +- name: Generate types if validation fails + if: failure() + run: npx bucket features types --api-key ${{ secrets.BUCKET_API_KEY }} +``` + ## Development ```bash diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index fb6a224b..eadc2f45 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -203,7 +203,7 @@ export const generateTypesAction = async ({ handleError(`Type check failed.`, "Features Types"); } else { spinner.succeed( - `Validated ${output.format} types in ${chalk.cyan(relative(projectPath, fullPath))}.`, + `All ${output.format} types are up to date in ${chalk.cyan(relative(projectPath, fullPath))}.`, ); } } else { @@ -213,10 +213,6 @@ export const generateTypesAction = async ({ ); } } - - spinner.succeed( - `${checkOnly ? "Checked" : "Generated"} types for app ${chalk.cyan(app.name)}.`, - ); } catch (error) { spinner?.fail("Type generation failed."); handleError(error, "Features Types"); diff --git a/packages/cli/package.json b/packages/cli/package.json index f68726aa..069a7733 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/cli", - "version": "1.0.1", + "version": "2.0.0", "packageManager": "yarn@4.1.1", "description": "CLI for Bucket service", "main": "./dist/index.js", From 73672b79ccf8244fde6a24aaf76514c638293a7c Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 23 Jul 2025 10:56:26 +0100 Subject: [PATCH 04/10] Update packages/cli/commands/features.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/cli/commands/features.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index eadc2f45..15eda5f4 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -209,7 +209,7 @@ export const generateTypesAction = async ({ } else { const outPath = await writeTypesToFile(types, output.path, projectPath); spinner.succeed( - `"Generated ${output.format} types in ${chalk.cyan(relative(projectPath, outPath))}.`, + `Generated ${output.format} types in ${chalk.cyan(relative(projectPath, outPath))}.`, ); } } From 823abc8c6e72845035435db6921733e04aff0d51 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 23 Jul 2025 10:56:46 +0100 Subject: [PATCH 05/10] Update packages/cli/commands/auth.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/cli/commands/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index 33b4e3f4..6ed594db 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -11,7 +11,7 @@ export const loginAction = async () => { const { baseUrl, apiUrl } = configStore.getConfig(); if (authStore.getToken(baseUrl).isApiKey) { - await handleError( + handleError( "Login is not allowed when an API token was supplied.", "Login", ); From 959ff8a30b9f39df9429736a3c8f1585669d130e Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 23 Jul 2025 10:56:52 +0100 Subject: [PATCH 06/10] Update packages/cli/commands/auth.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/cli/commands/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index 6ed594db..4bb57792 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -30,7 +30,7 @@ export const logoutAction = async () => { const baseUrl = configStore.getConfig("baseUrl"); if (authStore.getToken(baseUrl).isApiKey) { - await handleError( + handleError( "Logout is not allowed when an API token was supplied.", "Logout", ); From 2a878e8d493fdd34bdd5d13159ff1ece26a0c59e Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Wed, 23 Jul 2025 10:57:03 +0100 Subject: [PATCH 07/10] Update packages/cli/commands/auth.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/cli/commands/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index 4bb57792..4e3de953 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -44,7 +44,7 @@ export const logoutAction = async () => { spinner.succeed("Logged out successfully!"); } catch (error) { spinner.fail("Logout failed."); - await handleError(error, "Logout"); + handleError(error, "Logout"); } }; From ba8488b740ffa1d58188d08e5a6a18671517ce86 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Thu, 24 Jul 2025 11:08:34 +0100 Subject: [PATCH 08/10] refactor(auth): enhance login and logout actions with improved token handling and error messages - Simplified token retrieval in `loginAction` and `logoutAction` for better readability. - Added checks for existing tokens to prevent unnecessary login/logout attempts. - Improved error handling in `waitForAccessToken` with more descriptive messages. - Removed unused `loginUrl` utility function to streamline code. - Introduced `getOAuthServerUrls` to fetch OAuth server metadata dynamically. --- packages/cli/commands/auth.ts | 23 +++++- packages/cli/utils/auth.ts | 149 ++++++++++++++++++++++++++++------ packages/cli/utils/urls.ts | 7 -- 3 files changed, 142 insertions(+), 37 deletions(-) diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index 4e3de953..490346f5 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -9,33 +9,48 @@ import { handleError } from "../utils/errors.js"; export const loginAction = async () => { const { baseUrl, apiUrl } = configStore.getConfig(); + const { token, isApiKey } = authStore.getToken(baseUrl); - if (authStore.getToken(baseUrl).isApiKey) { + if (isApiKey) { handleError( "Login is not allowed when an API token was supplied.", "Login", ); } + if (token) { + console.log("Already logged in, nothing to do."); + return; + } + try { - await waitForAccessToken(baseUrl, apiUrl); + const { accessToken } = await waitForAccessToken(baseUrl, apiUrl); + await authStore.setToken(baseUrl, accessToken); + console.log(`Logged in to ${chalk.cyan(baseUrl)} successfully!`); } catch (error) { console.error("Login failed."); - await handleError(error, "Login"); + handleError(error, "Login"); } }; export const logoutAction = async () => { const baseUrl = configStore.getConfig("baseUrl"); - if (authStore.getToken(baseUrl).isApiKey) { + const { token, isApiKey } = authStore.getToken(baseUrl); + + if (isApiKey) { handleError( "Logout is not allowed when an API token was supplied.", "Logout", ); } + if (!token) { + console.log("Not logged in, nothing to do."); + return; + } + const spinner = ora("Logging out...").start(); try { diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index ca1676ec..aabb1061 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -12,7 +12,7 @@ import { } from "./constants.js"; import { ResponseError } from "./errors.js"; import { ParamType } from "./types.js"; -import { errorUrl, loginUrl, successUrl } from "./urls.js"; +import { errorUrl, successUrl } from "./urls.js"; const maxRetryCount = 1; @@ -21,16 +21,48 @@ interface waitForAccessToken { expiresAt: Date; } +async function getOAuthServerUrls(apiUrl: string) { + const { protocol, host } = new URL(apiUrl); + const wellKnownUrl = `${protocol}//${host}/.well-known/oauth-authorization-server`; + + const response = await fetch(wellKnownUrl, { + signal: AbortSignal.timeout(5000), + }); + + if (response.ok) { + const data = (await response.json()) as { + authorization_endpoint: string; + token_endpoint: string; + registration_endpoint: string; + issuer: string; + }; + + return { + registrationEndpoint: + data.registration_endpoint ?? `${data.issuer}/oauth/register`, + authorizationEndpoint: data.authorization_endpoint, + tokenEndpoint: data.token_endpoint, + issuer: data.issuer, + }; + } + + throw new Error("Failed to fetch OAuth server metadata"); +} + export async function waitForAccessToken(baseUrl: string, apiUrl: string) { - let resolve: (args: waitForAccessToken) => void, - reject: (arg0: Error) => void; + const { authorizationEndpoint, tokenEndpoint, registrationEndpoint } = + await getOAuthServerUrls(apiUrl); + + let resolve: (args: waitForAccessToken) => void; + let reject: (arg0: Error) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); - // PCKE code verifier and challenge - const codeVerifier = crypto.randomUUID(); + // PKCE code verifier and challenge + const codeVerifier = crypto.randomBytes(32).toString("base64url"); const codeChallenge = crypto .createHash("sha256") .update(codeVerifier) @@ -54,32 +86,103 @@ export async function waitForAccessToken(baseUrl: string, apiUrl: string) { server.closeAllConnections(); } - const server = http.createServer(async (req, res) => { + const server = http.createServer(); + + server.listen(); + + const address = server.address(); + if (address == null || typeof address !== "object") { + throw new Error("Could not start server"); + } + + const redirectUri = `http://localhost:${address.port}/callback`; + + const registrationResponse = await fetch(registrationEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_name: "Bucket CLI", + token_endpoint_auth_method: "none", + grant_types: ["authorization_code"], + redirect_uris: [redirectUri], + }), + signal: AbortSignal.timeout(5000), + }); + + if (!registrationResponse.ok) { + throw new Error(`Could not register client with OAuth server`); + } + + const registrationData = (await registrationResponse.json()) as { + client_id: string; + }; + + const clientId = registrationData.client_id; + + const params = { + response_type: "code", + client_id: clientId, + redirect_uri: redirectUri, + state: crypto.randomUUID(), + code_challenge: codeChallenge, + code_challenge_method: "S256", + }; + + const browserUrl = `${authorizationEndpoint}?${new URLSearchParams(params).toString()}`; + + server.on("request", async (req, res) => { + if (!clientId || !redirectUri) { + res.writeHead(500).end("Could not authenticate: something went wrong"); + + cleanupAndReject( + new Error("Could not authenticate: something went wrong"), + ); + return; + } + const url = new URL(req.url ?? "/", "http://127.0.0.1"); - if (url.pathname !== "/cli-login") { + if (url.pathname !== "/callback") { res.writeHead(404).end("Invalid path"); - cleanupAndReject(new Error("Could not authenticate: Invalid path")); + + cleanupAndReject(new Error("Could not authenticate: invalid path")); return; } - const code = url.searchParams.get("code"); + const error = url.searchParams.get("error"); + if (error) { + res.writeHead(400).end("Could not authenticate"); + + const errorDescription = url.searchParams.get("error_description"); + cleanupAndReject( + new Error(`Could not authenticate: ${errorDescription || error} `), + ); + return; + } + const code = url.searchParams.get("code"); if (!code) { res.writeHead(400).end("Could not authenticate"); + cleanupAndReject(new Error("Could not authenticate: no code provided")); return; } - const response = await fetch(`${apiUrl}/oauth/cli/access-token`, { + const response = await fetch(tokenEndpoint, { method: "POST", headers: { - "Content-Type": "application/json", + "Content-Type": "application/x-www-form-urlencoded", }, - body: JSON.stringify({ + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: clientId, code, - codeVerifier, + code_verifier: codeVerifier, + redirect_uri: redirectUri, }), + signal: AbortSignal.timeout(5000), }); if (!response.ok) { @@ -91,7 +194,11 @@ export async function waitForAccessToken(baseUrl: string, apiUrl: string) { ), }) .end("Could not authenticate"); - cleanupAndReject(new Error("Could not authenticate")); + + const json = await response.json(); + cleanupAndReject( + new Error(`Could not authenticate: ${JSON.stringify(json)}`), + ); return; } res @@ -104,20 +211,11 @@ export async function waitForAccessToken(baseUrl: string, apiUrl: string) { cleanup(); resolve({ - accessToken: jsonResponse.accessToken, - expiresAt: new Date(jsonResponse.expiresAt), + accessToken: jsonResponse.access_token, + expiresAt: new Date(Date.now() + jsonResponse.expires_in * 1000), }); }); - server.listen(); - const address = server.address(); - if (address == null || typeof address !== "object") { - throw new Error("Could not start server"); - } - - const port = address.port; - const browserUrl = loginUrl(apiUrl, port, codeChallenge); - console.log( `Opened web browser to facilitate login: ${chalk.cyan(browserUrl)}`, ); @@ -195,7 +293,6 @@ export async function authRequest>( await authStore.setToken(baseUrl, null); if (retryCount < maxRetryCount) { - await waitForAccessToken(baseUrl, apiUrl); return authRequest(url, options, retryCount + 1); } } diff --git a/packages/cli/utils/urls.ts b/packages/cli/utils/urls.ts index ccb22f17..5b85944e 100644 --- a/packages/cli/utils/urls.ts +++ b/packages/cli/utils/urls.ts @@ -17,13 +17,6 @@ export const successUrl = (baseUrl: string) => `${baseUrl}/cli-login/success`; export const errorUrl = (baseUrl: string, error: string) => `${baseUrl}/cli-login/error?error=${error}`; -export const loginUrl = ( - baseUrl: string, - localPort: number, - codeChallenge: string, -) => - `${baseUrl}/oauth/cli/authorize?port=${localPort}&codeChallenge=${codeChallenge}`; - export const baseUrlSuffix = (baseUrl: string) => { return baseUrl !== DEFAULT_BASE_URL ? ` at ${chalk.cyan(baseUrl)}` : ""; }; From 6a57fb93c9b5f2b7bbcb7bf840ed0becfb4d3351 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Fri, 25 Jul 2025 16:22:58 +0100 Subject: [PATCH 09/10] refactor(cli): streamline type generation and enhance authentication flow - Removed the `checkOnly` option from `generateTypesAction` to simplify the type generation process. - Improved error handling in `waitForAccessToken` with more descriptive messages and a consistent timeout mechanism. - Introduced a new `registerClient` function to encapsulate client registration logic with the OAuth server. - Added a `DEFAULT_AUTH_TIMEOUT` constant for configurable authentication timeout. - Cleaned up unused code related to type checking in files to enhance maintainability. --- .vscode/settings.json | 3 +- packages/cli/commands/features.ts | 40 +------ packages/cli/utils/auth.ts | 188 ++++++++++++++++++------------ packages/cli/utils/constants.ts | 2 + packages/cli/utils/gen.ts | 20 +--- packages/cli/utils/options.ts | 5 - 6 files changed, 126 insertions(+), 132 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index cb84ca83..28999edc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -42,6 +42,7 @@ "cSpell.words": [ "booleanish", "bucketco", - "openfeature" + "openfeature", + "PKCE" ] } diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index 15eda5f4..14491cb3 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -13,7 +13,6 @@ import { MissingEnvIdError, } from "../utils/errors.js"; import { - checkTypesInFile, genFeatureKey, genTypes, indentLines, @@ -24,7 +23,6 @@ import { appIdOption, featureKeyOption, featureNameArgument, - typesCheckOnlyOption, typesFormatOption, typesOutOption, } from "../utils/options.js"; @@ -134,13 +132,7 @@ export const listFeaturesAction = async () => { } }; -type GenerateTypesOptions = { - checkOnly?: boolean; -}; - -export const generateTypesAction = async ({ - checkOnly, -}: GenerateTypesOptions = {}) => { +export const generateTypesAction = async () => { const { baseUrl, appId } = configStore.getConfig(); const typesOutput = configStore.getConfig("typesOutput"); @@ -182,36 +174,17 @@ export const generateTypesAction = async ({ } try { - spinner = ora( - `${checkOnly ? "Checking" : "Generating"} feature types...`, - ).start(); + spinner = ora(`Generating feature types...`).start(); const projectPath = configStore.getProjectPath(); // Generate types for each output configuration for (const output of typesOutput) { const types = genTypes(features, output.format); - if (checkOnly) { - const { fullPath, isUpToDate } = await checkTypesInFile( - types, - output.path, - projectPath, - ); - - if (!isUpToDate) { - spinner.fail(`Types are not up to date in ${chalk.cyan(fullPath)}.`); - handleError(`Type check failed.`, "Features Types"); - } else { - spinner.succeed( - `All ${output.format} types are up to date in ${chalk.cyan(relative(projectPath, fullPath))}.`, - ); - } - } else { - const outPath = await writeTypesToFile(types, output.path, projectPath); - spinner.succeed( - `Generated ${output.format} types in ${chalk.cyan(relative(projectPath, outPath))}.`, - ); - } + const outPath = await writeTypesToFile(types, output.path, projectPath); + spinner.succeed( + `Generated ${output.format} types in ${chalk.cyan(relative(projectPath, outPath))}.`, + ); } } catch (error) { spinner?.fail("Type generation failed."); @@ -245,7 +218,6 @@ export function registerFeatureCommands(cli: Command) { .addOption(appIdOption) .addOption(typesOutOption) .addOption(typesFormatOption) - .addOption(typesCheckOnlyOption) .action(generateTypesAction); // Update the config with the cli override values diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index aabb1061..3f95aa4a 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -9,6 +9,7 @@ import { configStore } from "../stores/config.js"; import { CLIENT_VERSION_HEADER_NAME, CLIENT_VERSION_HEADER_VALUE, + DEFAULT_AUTH_TIMEOUT, } from "./constants.js"; import { ResponseError } from "./errors.js"; import { ParamType } from "./types.js"; @@ -49,18 +50,77 @@ async function getOAuthServerUrls(apiUrl: string) { throw new Error("Failed to fetch OAuth server metadata"); } -export async function waitForAccessToken(baseUrl: string, apiUrl: string) { - const { authorizationEndpoint, tokenEndpoint, registrationEndpoint } = - await getOAuthServerUrls(apiUrl); +async function registerClient( + registrationEndpoint: string, + redirectUri: string, +) { + const registrationResponse = await fetch(registrationEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_name: "Bucket CLI", + token_endpoint_auth_method: "none", + grant_types: ["authorization_code"], + redirect_uris: [redirectUri], + }), + signal: AbortSignal.timeout(5000), + }); - let resolve: (args: waitForAccessToken) => void; - let reject: (arg0: Error) => void; + if (!registrationResponse.ok) { + throw new Error(`Could not register client with OAuth server`); + } - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; + const registrationData = (await registrationResponse.json()) as { + client_id: string; + }; + + return registrationData.client_id; +} + +async function exchangeCodeForToken( + tokenEndpoint: string, + clientId: string, + code: string, + codeVerifier: string, + redirectUri: string, +) { + const response = await fetch(tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: clientId, + code, + code_verifier: codeVerifier, + redirect_uri: redirectUri, + }), + signal: AbortSignal.timeout(5000), }); + if (!response.ok) { + let errorDescription: string | undefined; + + try { + const jsonResponse = await response.json(); + errorDescription = jsonResponse.error_description || jsonResponse.error; + } catch { + // ignore + } + + return { error: errorDescription ?? "unknown error" }; + } + + return { + accessToken: (await response.json()).access_token, + expiresAt: new Date(Date.now() + (await response.json()).expires_in * 1000), + }; +} + +function createChallenge() { // PKCE code verifier and challenge const codeVerifier = crypto.randomBytes(32).toString("base64url"); const codeChallenge = crypto @@ -71,13 +131,34 @@ export async function waitForAccessToken(baseUrl: string, apiUrl: string) { .replace(/\+/g, "-") .replace(/\//g, "_"); + const state = crypto.randomUUID(); + + return { codeVerifier, codeChallenge, state }; +} + +export async function waitForAccessToken(baseUrl: string, apiUrl: string) { + const { authorizationEndpoint, tokenEndpoint, registrationEndpoint } = + await getOAuthServerUrls(apiUrl); + + let resolve: (args: waitForAccessToken) => void; + let reject: (arg0: Error) => void; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + const { codeVerifier, codeChallenge, state } = createChallenge(); + const timeout = setTimeout(() => { - cleanupAndReject(new Error("Authentication timed out after 60 seconds")); - }, 60000); + cleanupAndReject( + `authentication timed out after ${DEFAULT_AUTH_TIMEOUT / 1000} seconds`, + ); + }, DEFAULT_AUTH_TIMEOUT); - function cleanupAndReject(error: Error) { + function cleanupAndReject(message: string) { cleanup(); - reject(error); + reject(new Error(`Could not authenticate: ${message}`)); } function cleanup() { @@ -95,37 +176,16 @@ export async function waitForAccessToken(baseUrl: string, apiUrl: string) { throw new Error("Could not start server"); } - const redirectUri = `http://localhost:${address.port}/callback`; - - const registrationResponse = await fetch(registrationEndpoint, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - client_name: "Bucket CLI", - token_endpoint_auth_method: "none", - grant_types: ["authorization_code"], - redirect_uris: [redirectUri], - }), - signal: AbortSignal.timeout(5000), - }); - - if (!registrationResponse.ok) { - throw new Error(`Could not register client with OAuth server`); - } - - const registrationData = (await registrationResponse.json()) as { - client_id: string; - }; + const callbackPath = "/oauth_callback"; + const redirectUri = `http://localhost:${address.port}${callbackPath}`; - const clientId = registrationData.client_id; + const clientId = await registerClient(registrationEndpoint, redirectUri); const params = { response_type: "code", client_id: clientId, redirect_uri: redirectUri, - state: crypto.randomUUID(), + state, code_challenge: codeChallenge, code_challenge_method: "S256", }; @@ -134,20 +194,18 @@ export async function waitForAccessToken(baseUrl: string, apiUrl: string) { server.on("request", async (req, res) => { if (!clientId || !redirectUri) { - res.writeHead(500).end("Could not authenticate: something went wrong"); + res.writeHead(500).end("Something went wrong"); - cleanupAndReject( - new Error("Could not authenticate: something went wrong"), - ); + cleanupAndReject("something went wrong"); return; } const url = new URL(req.url ?? "/", "http://127.0.0.1"); - if (url.pathname !== "/callback") { + if (url.pathname !== callbackPath) { res.writeHead(404).end("Invalid path"); - cleanupAndReject(new Error("Could not authenticate: invalid path")); + cleanupAndReject("invalid path"); return; } @@ -156,9 +214,7 @@ export async function waitForAccessToken(baseUrl: string, apiUrl: string) { res.writeHead(400).end("Could not authenticate"); const errorDescription = url.searchParams.get("error_description"); - cleanupAndReject( - new Error(`Could not authenticate: ${errorDescription || error} `), - ); + cleanupAndReject(`${errorDescription || error} `); return; } @@ -166,54 +222,40 @@ export async function waitForAccessToken(baseUrl: string, apiUrl: string) { if (!code) { res.writeHead(400).end("Could not authenticate"); - cleanupAndReject(new Error("Could not authenticate: no code provided")); + cleanupAndReject("no code provided"); return; } - const response = await fetch(tokenEndpoint, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: clientId, - code, - code_verifier: codeVerifier, - redirect_uri: redirectUri, - }), - signal: AbortSignal.timeout(5000), - }); + const response = await exchangeCodeForToken( + tokenEndpoint, + clientId, + code, + codeVerifier, + redirectUri, + ); - if (!response.ok) { + if ("error" in response) { res .writeHead(302, { location: errorUrl( baseUrl, - "Could not authenticate: Unable to fetch access token", + "Could not authenticate: unable to fetch access token", ), }) .end("Could not authenticate"); - const json = await response.json(); - cleanupAndReject( - new Error(`Could not authenticate: ${JSON.stringify(json)}`), - ); + cleanupAndReject(JSON.stringify(response.error)); return; } + res .writeHead(302, { location: successUrl(baseUrl), }) .end("Authentication successful"); - const jsonResponse = await response.json(); - cleanup(); - resolve({ - accessToken: jsonResponse.access_token, - expiresAt: new Date(Date.now() + jsonResponse.expires_in * 1000), - }); + resolve(response); }); console.log( diff --git a/packages/cli/utils/constants.ts b/packages/cli/utils/constants.ts index e0bb2425..f48909b9 100644 --- a/packages/cli/utils/constants.ts +++ b/packages/cli/utils/constants.ts @@ -14,6 +14,8 @@ export const DEFAULT_BASE_URL = "https://app.bucket.co"; export const DEFAULT_API_URL = `${DEFAULT_BASE_URL}/api`; export const DEFAULT_TYPES_OUTPUT = join("gen", "features.d.ts"); +export const DEFAULT_AUTH_TIMEOUT = 60000; // 60 seconds + export const MODULE_ROOT = fileURLToPath(import.meta.url).substring( 0, fileURLToPath(import.meta.url).lastIndexOf("cli") + 3, diff --git a/packages/cli/utils/gen.ts b/packages/cli/utils/gen.ts index 5103d8a3..16ce97e0 100644 --- a/packages/cli/utils/gen.ts +++ b/packages/cli/utils/gen.ts @@ -1,5 +1,5 @@ import { camelCase, kebabCase, pascalCase, snakeCase } from "change-case"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join } from "node:path"; import { Feature, RemoteConfig } from "../services/features.js"; @@ -146,21 +146,3 @@ export async function writeTypesToFile( return fullPath; } - -export async function checkTypesInFile( - types: string, - outPath: string, - projectPath: string, -) { - const fullPath = isAbsolute(outPath) ? outPath : join(projectPath, outPath); - - try { - const existingTypes = await readFile(fullPath, "utf-8"); - - return { fullPath, isUpToDate: existingTypes === types }; - } catch { - // File doesn't exist, so it's not up to date - } - - return { fullPath, isUpToDate: false }; -} diff --git a/packages/cli/utils/options.ts b/packages/cli/utils/options.ts index e080d244..6c8b097a 100644 --- a/packages/cli/utils/options.ts +++ b/packages/cli/utils/options.ts @@ -42,11 +42,6 @@ export const typesFormatOption = new Option( "Single output format for generated feature types", ).choices(["react", "node"]); -export const typesCheckOnlyOption = new Option( - "--check-only", - "Only checks if types are up to date and exits with a non-zero code if they are not.", -); - export const featureNameArgument = new Argument( "[name]", "Feature's name. If not provided, you'll be prompted to enter one.", From fc57c4456eb8232af21cf17c9a2079f85ea98b37 Mon Sep 17 00:00:00 2001 From: Alexandru Ciobanu Date: Thu, 31 Jul 2025 08:37:34 +0100 Subject: [PATCH 10/10] refactor(auth): improve error handling and response parsing in token exchange - Renamed variable for clarity from `jsonResponse` to `errorResponse` to better reflect its purpose. - Consolidated response parsing to avoid multiple calls to `response.json()`, enhancing performance and readability. - Improved error handling by ensuring a consistent structure for error messages returned during token exchange. --- packages/cli/utils/auth.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index 3f95aa4a..8cbc619f 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -105,8 +105,8 @@ async function exchangeCodeForToken( let errorDescription: string | undefined; try { - const jsonResponse = await response.json(); - errorDescription = jsonResponse.error_description || jsonResponse.error; + const errorResponse = await response.json(); + errorDescription = errorResponse.error_description || errorResponse.error; } catch { // ignore } @@ -114,9 +114,14 @@ async function exchangeCodeForToken( return { error: errorDescription ?? "unknown error" }; } + const successResponse = (await response.json()) as { + access_token: string; + expires_in: number; + }; + return { - accessToken: (await response.json()).access_token, - expiresAt: new Date(Date.now() + (await response.json()).expires_in * 1000), + accessToken: successResponse.access_token, + expiresAt: new Date(Date.now() + successResponse.expires_in * 1000), }; }