diff --git a/.vscode/settings.json b/.vscode/settings.json index 4944fbf8..28999edc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,5 +39,10 @@ "**/*.lock": true }, "typescript.tsdk": "node_modules/typescript/lib", - "cSpell.words": ["bucketco", "openfeature"] + "cSpell.words": [ + "booleanish", + "bucketco", + "openfeature", + "PKCE" + ] } 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/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..490346f5 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -9,25 +9,57 @@ import { handleError } from "../utils/errors.js"; export const loginAction = async () => { const { baseUrl, apiUrl } = configStore.getConfig(); + const { token, isApiKey } = authStore.getToken(baseUrl); + + 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."); - void handleError(error, "Login"); + handleError(error, "Login"); } }; export const logoutAction = async () => { const baseUrl = configStore.getConfig("baseUrl"); + + 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 { - 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"); + 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..14491cb3 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,40 +21,33 @@ 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 = { +type CreateFeatureOptions = { key?: string; }; export const createFeatureAction = async ( name: string | undefined, - { key }: CreateFeatureArgs, + { key }: CreateFeatureOptions, ) => { const { baseUrl, appId } = configStore.getConfig(); 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,176 +140,55 @@ 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 { - spinner = ora("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 = 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))}.`, ); } - - 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 +220,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..ef326b4b 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,8 +15,14 @@ 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"; +import { checkLatest as checkLatestVersion } from "./utils/version.js"; const skipBootstrapCommands = [/^login/, /^logout/, /^rules/]; @@ -25,9 +30,13 @@ type Options = { debug?: boolean; baseUrl?: string; apiUrl?: string; + apiKey?: string; }; 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(); @@ -36,17 +45,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,19 +73,30 @@ 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"); + } + } + + 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) { @@ -87,7 +117,6 @@ async function main() { registerAuthCommands(program); registerAppCommands(program); registerFeatureCommands(program); - registerCompanyCommands(program); registerMcpCommand(program); registerRulesCommand(program); diff --git a/packages/cli/package.json b/packages/cli/package.json index bae2174d..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", @@ -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/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..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,32 +71,26 @@ 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; + 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 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 } @@ -126,7 +116,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..8cbc619f 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -9,25 +9,125 @@ 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"; -import { errorUrl, loginUrl, successUrl } from "./urls.js"; +import { errorUrl, successUrl } from "./urls.js"; + +const maxRetryCount = 1; interface waitForAccessToken { accessToken: string; expiresAt: Date; } -export async function waitForAccessToken(baseUrl: string, apiUrl: string) { - let resolve: (args: waitForAccessToken) => void, - reject: (arg0: Error) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; +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"); +} + +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), }); - // PCKE code verifier and challenge - const codeVerifier = crypto.randomUUID(); + if (!registrationResponse.ok) { + throw new Error(`Could not register client with OAuth server`); + } + + 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 errorResponse = await response.json(); + errorDescription = errorResponse.error_description || errorResponse.error; + } catch { + // ignore + } + + return { error: errorDescription ?? "unknown error" }; + } + + const successResponse = (await response.json()) as { + access_token: string; + expires_in: number; + }; + + return { + accessToken: successResponse.access_token, + expiresAt: new Date(Date.now() + successResponse.expires_in * 1000), + }; +} + +function createChallenge() { + // PKCE code verifier and challenge + const codeVerifier = crypto.randomBytes(32).toString("base64url"); const codeChallenge = crypto .createHash("sha256") .update(codeVerifier) @@ -36,13 +136,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() { @@ -51,70 +172,97 @@ 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 callbackPath = "/oauth_callback"; + const redirectUri = `http://localhost:${address.port}${callbackPath}`; + + const clientId = await registerClient(registrationEndpoint, redirectUri); + + const params = { + response_type: "code", + client_id: clientId, + redirect_uri: redirectUri, + state, + 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("Something went wrong"); + + cleanupAndReject("something went wrong"); + return; + } + const url = new URL(req.url ?? "/", "http://127.0.0.1"); - if (url.pathname !== "/cli-login") { + if (url.pathname !== callbackPath) { res.writeHead(404).end("Invalid path"); - cleanupAndReject(new Error("Could not authenticate: Invalid path")); + + cleanupAndReject("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(`${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")); + + cleanupAndReject("no code provided"); return; } - const response = await fetch(`${apiUrl}/oauth/cli/access-token`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - code, - codeVerifier, - }), - }); + 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"); - cleanupAndReject(new Error("Could not authenticate")); + + cleanupAndReject(JSON.stringify(response.error)); return; } + res .writeHead(302, { location: successUrl(baseUrl), }) .end("Authentication successful"); - const jsonResponse = await response.json(); - cleanup(); - resolve({ - accessToken: jsonResponse.accessToken, - expiresAt: new Date(jsonResponse.expiresAt), - }); + resolve(response); }); - 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)}`, ); @@ -132,14 +280,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 +307,45 @@ 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) { - await waitForAccessToken(baseUrl, apiUrl); + if (isApiKey) { + throw new Error( + `The provided API key is not valid for "${resolvedUrl}".`, + ); + } + + await authStore.setToken(baseUrl, null); + + if (retryCount < maxRetryCount) { 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/constants.ts b/packages/cli/utils/constants.ts index 2260266d..f48909b9 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,10 @@ 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 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/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.", 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)}` : ""; }; 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 e5c9e6d7..62534b0f 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" @@ -16427,7 +16436,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: