From 35b1a78862759a73bdc7a126fd27e7ce04c4f421 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Thu, 13 Mar 2025 12:58:39 +0100 Subject: [PATCH 1/6] feat: improve logging by fetching bootstrap first --- packages/cli/README.md | 16 ++---- packages/cli/commands/features.ts | 25 ++++++---- packages/cli/index.ts | 18 ++++++- packages/cli/schema.json | 15 ++++-- packages/cli/services/bootstrap.ts | 80 +++++++++++++++++++++++++----- packages/cli/utils/constants.ts | 5 ++ packages/cli/utils/errors.ts | 1 + 7 files changed, 122 insertions(+), 38 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 776722bb..acc68527 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -6,7 +6,7 @@ you can streamline your feature flagging workflow directly from your terminal. ## Quick Start -Get started quickly by running the CLI directly from your project's root directory: +Get started quickly by running the CLI directly without installing it from your project's root directory: initializing the CLI (if not already setup), creating a feature, and generate the types all at once. ```bash @@ -14,23 +14,17 @@ initializing the CLI (if not already setup), creating a feature, and generate th npx @bucketco/cli new ``` -or install it locally +or install it locally and run it using the bin alias: ```bash # npm npm install --save-dev @bucketco/cli -# yarn -yarn add --dev @bucketco/cli -``` - -then - -```bash -# npm npx bucket new # yarn +yarn add --dev @bucketco/cli + yarn bucket new ``` @@ -77,7 +71,7 @@ Here's a comprehensive list of configuration options available in the `bucket.co "appId": "ap123456789", "typesOutput": [ { - "path": "gen/features.ts", + "path": "gen/features.d.ts", "format": "react" } ], diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index 46e4a2fc..79920366 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -5,8 +5,10 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join, relative } from "node:path"; import ora, { Ora } from "ora"; +import { App, getApp } from "../services/bootstrap.js"; import { createFeature, Feature, listFeatures } from "../services/features.js"; import { configStore } from "../stores/config.js"; +import { baseUrlSuffix } from "../utils/constants.js"; import { handleError, MissingAppIdError } from "../utils/errors.js"; import { genFeatureKey, genTypes, KeyFormatPatterns } from "../utils/gen.js"; import { @@ -30,6 +32,10 @@ export const createFeatureAction = async ( let spinner: Ora | undefined; try { if (!appId) throw new MissingAppIdError(); + const app = getApp(appId); + console.log( + `Creating feature for app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}.`, + ); if (!name) { name = await input({ message: "New feature name:", @@ -46,13 +52,11 @@ export const createFeatureAction = async ( }); } - spinner = ora( - `Creating feature for app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, - ).start(); + spinner = ora(`Creating feature...`).start(); const feature = await createFeature(appId, name, key); // todo: would like to link to feature here but we don't have the env id, only app id spinner.succeed( - `Created feature ${chalk.cyan(feature.name)} with key ${chalk.cyan(feature.key)} at ${chalk.cyan(baseUrl)}.`, + `Created feature ${chalk.cyan(feature.name)} with key ${chalk.cyan(feature.key)}${baseUrlSuffix(baseUrl)}.`, ); } catch (error) { spinner?.fail("Feature creation failed."); @@ -66,12 +70,13 @@ export const listFeaturesAction = async () => { try { if (!appId) throw new MissingAppIdError(); + const app = getApp(appId); spinner = ora( - `Loading features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, + `Loading features of app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}...`, ).start(); const features = await listFeatures(appId); spinner.succeed( - `Loaded features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}.`, + `Loaded features of app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}.`, ); console.table( features.map(({ key, name, stage }) => ({ @@ -92,16 +97,18 @@ export const generateTypesAction = async () => { let spinner: Ora | undefined; let features: Feature[] = []; + let app: App | undefined; try { if (!appId) throw new MissingAppIdError(); + app = getApp(appId); spinner = ora( - `Loading features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, + `Loading features of app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}...`, ).start(); features = await listFeatures(appId, { includeRemoteConfigs: true, }); spinner.succeed( - `Loaded features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}.`, + `Loaded features of app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}.`, ); } catch (error) { spinner?.fail("Loading features failed."); @@ -127,7 +134,7 @@ export const generateTypesAction = async () => { ); } - spinner.succeed(`Generated types for ${chalk.cyan(appId)}.`); + spinner.succeed(`Generated types for app ${chalk.cyan(app.name)}.`); } catch (error) { spinner?.fail("Type generation failed."); void handleError(error, "Features Types"); diff --git a/packages/cli/index.ts b/packages/cli/index.ts index cc39c135..1bc90978 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -7,8 +7,10 @@ import { registerAuthCommands } from "./commands/auth.js"; import { registerFeatureCommands } from "./commands/features.js"; import { registerInitCommand } from "./commands/init.js"; import { registerNewCommand } from "./commands/new.js"; +import { bootstrap, getUser } from "./services/bootstrap.js"; import { authStore } from "./stores/auth.js"; import { configStore } from "./stores/config.js"; +import { handleError } from "./utils/errors.js"; import { apiUrlOption, baseUrlOption, debugOption } from "./utils/options.js"; import { stripTrailingSlash } from "./utils/path.js"; @@ -23,7 +25,7 @@ async function main() { program.addOption(apiUrlOption); // Pre-action hook - program.hook("preAction", () => { + program.hook("preAction", async () => { const { debug, baseUrl, apiUrl } = program.opts(); const cleanedBaseUrl = stripTrailingSlash(baseUrl?.trim()); configStore.setConfig({ @@ -33,11 +35,23 @@ async function main() { (cleanedBaseUrl && `${cleanedBaseUrl}/api`), }); + try { + // Load bootstrap data if not already loaded + await bootstrap(); + } catch (error) { + void handleError( + debug ? error : `Unable to reach ${configStore.getConfig("baseUrl")}`, + "Connect", + ); + } + if (debug) { console.debug(chalk.cyan("\nDebug mode enabled")); + const user = getUser(); + console.debug(`Logged in as ${chalk.cyan(user.name ?? user.email)}`); console.debug( "Reading config from", - chalk.green(configStore.getConfigPath()), + chalk.cyan(configStore.getConfigPath()), ); console.table(configStore.getConfig()); } diff --git a/packages/cli/schema.json b/packages/cli/schema.json index fd5100f7..359582f0 100644 --- a/packages/cli/schema.json +++ b/packages/cli/schema.json @@ -4,16 +4,24 @@ "type": "object", "properties": { "baseUrl": { - "type": "string" + "type": "string", + "pattern": "^https?://.*", + "description": "Base URL for the API. Defaults to https://app.bucket.co." }, "apiUrl": { - "type": "string" + "type": "string", + "pattern": "^https?://.*", + "description": "API URL for the API. Defaults to https://app.bucket.co/api." }, "appId": { - "type": "string" + "type": "string", + "minLength": 14, + "maxLength": 14, + "description": "Mandatory ID for the Bucket app. You can find it by calling bucket apps list." }, "typesOutput": { "type": "array", + "description": "List of paths to output the types. The path is relative to the current working directory.", "items": { "type": "object", "properties": { @@ -25,6 +33,7 @@ }, "keyFormat": { "type": "string", + "description": "Format for the suggested feature keys, must match Bucket app settings. Defaults to custom.", "enum": [ "custom", "pascalCase", diff --git a/packages/cli/services/bootstrap.ts b/packages/cli/services/bootstrap.ts index f1f5d20a..f3a9897f 100644 --- a/packages/cli/services/bootstrap.ts +++ b/packages/cli/services/bootstrap.ts @@ -1,15 +1,42 @@ import { KeyFormat } from "../stores/config.js"; import { authRequest } from "../utils/auth.js"; -type BootstrapResponse = { - org: { - apps: { - id: string; - name: string; - demo: boolean; - }[]; - featureKeyFormat?: KeyFormat; - }; +export type BootstrapResponse = { + org: OrgResponse; + user: UserResponse; +}; + +export type OrgResponse = { + id: string; + name: string; + logoUrl: string; + apps: AppResponse[]; + inviteKey: string; + createdAt: Date; + updatedAt: Date; + trialEndsAt: null; + suspendedAt: null; + accessLevel: string; + domain: null; + domainAutoJoin: boolean; + isGlobal: boolean; + featureKeyFormat: KeyFormat; +}; + +export type AppResponse = { + id: string; + name: string; + demo: boolean; +}; + +export type UserResponse = { + id: string; + email: string; + name: string; + createdAt: Date; + updatedAt: Date; + avatarUrl: string; + isAdmin: boolean; }; export type App = { @@ -19,19 +46,46 @@ export type App = { featureKeyFormat: KeyFormat; }; -export async function listApps(): Promise { - const response = await authRequest(`/bootstrap`); - const org = response.org; +let bootstrapResponse: BootstrapResponse | null = null; + +export async function bootstrap(): Promise { + if (!bootstrapResponse) { + bootstrapResponse = await authRequest(`/bootstrap`); + } + return bootstrapResponse; +} + +export function listApps(): App[] { + if (!bootstrapResponse) { + throw new Error("Failed to load bootstrap response"); + } + const org = bootstrapResponse.org; if (!org) { throw new Error("No organization found"); } if (!org.apps?.length) { throw new Error("No apps found"); } - return response.org.apps.map(({ id, name, demo }) => ({ + return bootstrapResponse.org.apps.map(({ id, name, demo }) => ({ name, id, featureKeyFormat: org.featureKeyFormat ?? "custom", demo, })); } + +export function getApp(id: string): App { + const apps = listApps(); + const app = apps.find((a) => a.id === id); + if (!app) { + throw new Error(`App with id ${id} not found`); + } + return app; +} + +export function getUser(): UserResponse { + if (!bootstrapResponse) { + throw new Error("Failed to load bootstrap response"); + } + return bootstrapResponse.user; +} diff --git a/packages/cli/utils/constants.ts b/packages/cli/utils/constants.ts index bed1a97d..3fd60cb9 100644 --- a/packages/cli/utils/constants.ts +++ b/packages/cli/utils/constants.ts @@ -1,4 +1,5 @@ import { join } from "path"; +import chalk from "chalk"; export const CONFIG_FILE_NAME = "bucket.config.json"; export const AUTH_FILE = join( @@ -14,3 +15,7 @@ export const DEFAULT_TYPES_OUTPUT = join("gen", "features.d.ts"); export const loginUrl = (baseUrl: string, localPort: number) => `${baseUrl}/login?redirect_url=` + encodeURIComponent("/cli-login?port=" + localPort); + +export const baseUrlSuffix = (baseUrl: string) => { + return baseUrl !== DEFAULT_BASE_URL ? ` at ${chalk.cyan(baseUrl)}` : ""; +}; diff --git a/packages/cli/utils/errors.ts b/packages/cli/utils/errors.ts index b70f2c78..c0df8ce2 100644 --- a/packages/cli/utils/errors.ts +++ b/packages/cli/utils/errors.ts @@ -47,6 +47,7 @@ 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); } else if (typeof error === "string") { console.error(chalk.red(tag, error)); } else { From 52e77da65bfa5c59f143fb37aebd60a40820a69a Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Thu, 13 Mar 2025 15:08:36 +0100 Subject: [PATCH 2/6] feat: removed key format and created link to feature --- packages/cli/README.md | 4 +- packages/cli/commands/features.ts | 20 +++++----- packages/cli/commands/init.ts | 4 -- packages/cli/commands/new.ts | 5 +-- packages/cli/index.ts | 9 ++++- packages/cli/package.json | 4 +- packages/cli/schema.json | 13 ------- packages/cli/services/bootstrap.ts | 59 +++++++++++++++++------------- packages/cli/services/features.ts | 1 + packages/cli/stores/config.ts | 13 ------- packages/cli/utils/auth.ts | 2 +- packages/cli/utils/constants.ts | 9 ----- packages/cli/utils/options.ts | 7 ---- packages/cli/utils/path.ts | 31 ++++++++++++++++ yarn.lock | 18 +++++++++ 15 files changed, 109 insertions(+), 90 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index acc68527..f49572a2 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -74,8 +74,7 @@ Here's a comprehensive list of configuration options available in the `bucket.co "path": "gen/features.d.ts", "format": "react" } - ], - "keyFormat": "camelCase" + ] } ``` @@ -86,7 +85,6 @@ Here's a comprehensive list of configuration options available in the `bucket.co | `apiUrl` | API URL for Bucket services (overrides baseUrl for API calls). | "https://app.bucket.co/api" | | `appId` | Your Bucket application ID. | Required | | `typesOutput` | Path(s) where TypeScript types will be generated. Can be a string or an array of objects with `path` and `format` properties. Available formats: `react` and `node`. | "gen/features.ts" with format "react" | -| `keyFormat` | Format for feature keys (options: custom, pascalCase, camelCase, snakeCaseUpper, snakeCaseLower, kebabCaseUpper, kebabCaseLower). | "custom" | You can override these settings using command-line options for individual commands. diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index 79920366..1e4cee4a 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -5,20 +5,19 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join, relative } from "node:path"; import ora, { Ora } from "ora"; -import { App, getApp } from "../services/bootstrap.js"; +import { App, getApp, getOrg } from "../services/bootstrap.js"; import { createFeature, Feature, listFeatures } from "../services/features.js"; import { configStore } from "../stores/config.js"; -import { baseUrlSuffix } from "../utils/constants.js"; import { handleError, MissingAppIdError } from "../utils/errors.js"; import { genFeatureKey, genTypes, KeyFormatPatterns } from "../utils/gen.js"; import { appIdOption, featureKeyOption, featureNameArgument, - keyFormatOption, typesFormatOption, typesOutOption, } from "../utils/options.js"; +import { baseUrlSuffix, featureUrl } from "../utils/path.js"; type CreateFeatureArgs = { key?: string; @@ -32,6 +31,7 @@ export const createFeatureAction = async ( let spinner: Ora | undefined; try { if (!appId) throw new MissingAppIdError(); + const org = getOrg(); const app = getApp(appId); console.log( `Creating feature for app ${chalk.cyan(app.name)}${baseUrlSuffix(baseUrl)}.`, @@ -44,7 +44,7 @@ export const createFeatureAction = async ( } if (!key) { - const keyFormat = configStore.getConfig("keyFormat") ?? "custom"; + const keyFormat = org.featureKeyFormat; key = await input({ message: "New feature key:", default: genFeatureKey(name, keyFormat), @@ -54,9 +54,13 @@ export const createFeatureAction = async ( spinner = ora(`Creating feature...`).start(); const feature = await createFeature(appId, name, key); - // todo: would like to link to feature here but we don't have the env id, only app id + const production = app.environments.find((e) => e.isProduction); spinner.succeed( - `Created feature ${chalk.cyan(feature.name)} with key ${chalk.cyan(feature.key)}${baseUrlSuffix(baseUrl)}.`, + `Created feature ${chalk.cyan(feature.name)} with key ${chalk.cyan(feature.key)}${ + production + ? ` at ${chalk.cyan(featureUrl(baseUrl, production, feature))}` + : "" + }.`, ); } catch (error) { spinner?.fail("Feature creation failed."); @@ -150,7 +154,6 @@ export function registerFeatureCommands(cli: Command) { .command("create") .description("Create a new feature.") .addOption(appIdOption) - .addOption(keyFormatOption) .addOption(featureKeyOption) .addArgument(featureNameArgument) .action(createFeatureAction); @@ -171,10 +174,9 @@ export function registerFeatureCommands(cli: Command) { // Update the config with the cli override values featuresCommand.hook("preAction", (_, command) => { - const { appId, keyFormat, out, format } = command.opts(); + const { appId, out, format } = command.opts(); configStore.setConfig({ appId, - keyFormat, typesOutput: out ? [{ path: out, format: format || "react" }] : undefined, }); }); diff --git a/packages/cli/commands/init.ts b/packages/cli/commands/init.ts index 80160230..45ebb2af 100644 --- a/packages/cli/commands/init.ts +++ b/packages/cli/commands/init.ts @@ -63,9 +63,6 @@ export const initAction = async (args: InitArgs = {}) => { }); } - const keyFormat = - apps.find((app) => app.id === appId)?.featureKeyFormat ?? "custom"; - // Get types output path const typesOutput = await input({ message: "Where should we generate the types?", @@ -85,7 +82,6 @@ export const initAction = async (args: InitArgs = {}) => { // Update config configStore.setConfig({ appId, - keyFormat, typesOutput: [{ path: typesOutput, format: typesFormat }], }); diff --git a/packages/cli/commands/new.ts b/packages/cli/commands/new.ts index a8aa5fbe..80135869 100644 --- a/packages/cli/commands/new.ts +++ b/packages/cli/commands/new.ts @@ -7,7 +7,6 @@ import { appIdOption, featureKeyOption, featureNameArgument, - keyFormatOption, typesFormatOption, typesOutOption, } from "../utils/options.js"; @@ -38,7 +37,6 @@ export function registerNewCommand(cli: Command) { "Initialize the Bucket CLI, authenticates, and creates a new feature.", ) .addOption(appIdOption) - .addOption(keyFormatOption) .addOption(typesOutOption) .addOption(typesFormatOption) .addOption(featureKeyOption) @@ -47,10 +45,9 @@ export function registerNewCommand(cli: Command) { // Update the config with the cli override values cli.hook("preAction", (command) => { - const { appId, keyFormat, out, format } = command.opts(); + const { appId, out, format } = command.opts(); configStore.setConfig({ appId, - keyFormat, typesOutput: out ? [{ path: out, format: format || "react" }] : undefined, }); }); diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 1bc90978..43569836 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -14,6 +14,12 @@ import { handleError } from "./utils/errors.js"; import { apiUrlOption, baseUrlOption, debugOption } from "./utils/options.js"; import { stripTrailingSlash } from "./utils/path.js"; +type Options = { + debug?: boolean; + baseUrl?: string; + apiUrl?: string; +}; + async function main() { // Must load tokens and config before anything else await authStore.initialize(); @@ -26,8 +32,9 @@ async function main() { // Pre-action hook program.hook("preAction", async () => { - const { debug, baseUrl, apiUrl } = program.opts(); + const { debug, baseUrl, apiUrl } = program.opts(); const cleanedBaseUrl = stripTrailingSlash(baseUrl?.trim()); + // Set baseUrl and apiUrl in config store, will skip if undefined configStore.setConfig({ baseUrl: cleanedBaseUrl, apiUrl: diff --git a/packages/cli/package.json b/packages/cli/package.json index 3123007a..e6e88510 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -50,12 +50,14 @@ "find-up": "^7.0.0", "json5": "^2.2.3", "open": "^10.1.0", - "ora": "^8.1.0" + "ora": "^8.1.0", + "slug": "^10.0.0" }, "devDependencies": { "@bucketco/eslint-config": "workspace:^", "@bucketco/tsconfig": "workspace:^", "@types/node": "^22.5.1", + "@types/slug": "^5.0.9", "eslint": "^9.21.0", "prettier": "^3.5.2", "shx": "^0.3.4", diff --git a/packages/cli/schema.json b/packages/cli/schema.json index 359582f0..7d10c8f3 100644 --- a/packages/cli/schema.json +++ b/packages/cli/schema.json @@ -30,19 +30,6 @@ }, "required": ["path"] } - }, - "keyFormat": { - "type": "string", - "description": "Format for the suggested feature keys, must match Bucket app settings. Defaults to custom.", - "enum": [ - "custom", - "pascalCase", - "camelCase", - "snakeCaseUpper", - "snakeCaseLower", - "kebabCaseUpper", - "kebabCaseLower" - ] } }, "required": ["appId"] diff --git a/packages/cli/services/bootstrap.ts b/packages/cli/services/bootstrap.ts index f3a9897f..7630087d 100644 --- a/packages/cli/services/bootstrap.ts +++ b/packages/cli/services/bootstrap.ts @@ -1,16 +1,16 @@ -import { KeyFormat } from "../stores/config.js"; import { authRequest } from "../utils/auth.js"; +import { KeyFormat } from "../utils/gen.js"; export type BootstrapResponse = { - org: OrgResponse; - user: UserResponse; + org: Org; + user: User; }; -export type OrgResponse = { +export type Org = { id: string; name: string; logoUrl: string; - apps: AppResponse[]; + apps: App[]; inviteKey: string; createdAt: Date; updatedAt: Date; @@ -23,13 +23,21 @@ export type OrgResponse = { featureKeyFormat: KeyFormat; }; -export type AppResponse = { +export type Environment = { + id: string; + name: string; + isProduction: boolean; + order: number; +}; + +export type App = { id: string; name: string; demo: boolean; + environments: Environment[]; }; -export type UserResponse = { +export type User = { id: string; email: string; name: string; @@ -39,13 +47,6 @@ export type UserResponse = { isAdmin: boolean; }; -export type App = { - id: string; - name: string; - demo: boolean; - featureKeyFormat: KeyFormat; -}; - let bootstrapResponse: BootstrapResponse | null = null; export async function bootstrap(): Promise { @@ -55,23 +56,28 @@ export async function bootstrap(): Promise { return bootstrapResponse; } +export function getOrg(): Org { + if (!bootstrapResponse) { + throw new Error("CLI has not been bootstrapped."); + } + if (!bootstrapResponse.org) { + throw new Error("No organization found."); + } + return bootstrapResponse.org; +} + export function listApps(): App[] { if (!bootstrapResponse) { - throw new Error("Failed to load bootstrap response"); + throw new Error("CLI has not been bootstrapped."); } const org = bootstrapResponse.org; if (!org) { - throw new Error("No organization found"); + throw new Error("No organization found."); } if (!org.apps?.length) { - throw new Error("No apps found"); + throw new Error("No apps found."); } - return bootstrapResponse.org.apps.map(({ id, name, demo }) => ({ - name, - id, - featureKeyFormat: org.featureKeyFormat ?? "custom", - demo, - })); + return bootstrapResponse.org.apps; } export function getApp(id: string): App { @@ -83,9 +89,12 @@ export function getApp(id: string): App { return app; } -export function getUser(): UserResponse { +export function getUser(): User { if (!bootstrapResponse) { - throw new Error("Failed to load bootstrap response"); + throw new Error("CLI has not been bootstrapped."); + } + if (!bootstrapResponse.user) { + throw new Error("No user found."); } return bootstrapResponse.user; } diff --git a/packages/cli/services/features.ts b/packages/cli/services/features.ts index 6ff3baaf..1096b8ba 100644 --- a/packages/cli/services/features.ts +++ b/packages/cli/services/features.ts @@ -16,6 +16,7 @@ export type RemoteConfig = { }; export type Feature = { + id: string; name: string; key: string; remoteConfigs: RemoteConfig[]; diff --git a/packages/cli/stores/config.ts b/packages/cli/stores/config.ts index 21126ae5..8c8dead9 100644 --- a/packages/cli/stores/config.ts +++ b/packages/cli/stores/config.ts @@ -16,17 +16,6 @@ import { import { ConfigValidationError, handleError } from "../utils/errors.js"; import { stripTrailingSlash } from "../utils/path.js"; -export const keyFormats = [ - "custom", - "pascalCase", - "camelCase", - "snakeCaseUpper", - "snakeCaseLower", - "kebabCaseUpper", - "kebabCaseLower", -] as const; -export type KeyFormat = (typeof keyFormats)[number]; - export const typeFormats = ["react", "node"] as const; export type TypeFormat = (typeof typeFormats)[number]; @@ -41,7 +30,6 @@ type Config = { apiUrl: string; appId: string | undefined; typesOutput: TypesOutput[]; - keyFormat: KeyFormat; }; const defaultConfig: Config = { @@ -50,7 +38,6 @@ const defaultConfig: Config = { apiUrl: DEFAULT_API_URL, appId: undefined, typesOutput: [{ path: DEFAULT_TYPES_OUTPUT, format: "react" }], - keyFormat: "custom", }; // Helper to normalize typesOutput to array format diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index 7f4d75e7..bd7217d7 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -4,7 +4,7 @@ import open from "open"; import { authStore } from "../stores/auth.js"; import { configStore } from "../stores/config.js"; -import { loginUrl } from "./constants.js"; +import { loginUrl } from "./path.js"; function corsHeaders(baseUrl: string): Record { return { diff --git a/packages/cli/utils/constants.ts b/packages/cli/utils/constants.ts index 3fd60cb9..844d4174 100644 --- a/packages/cli/utils/constants.ts +++ b/packages/cli/utils/constants.ts @@ -1,5 +1,4 @@ import { join } from "path"; -import chalk from "chalk"; export const CONFIG_FILE_NAME = "bucket.config.json"; export const AUTH_FILE = join( @@ -11,11 +10,3 @@ 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 loginUrl = (baseUrl: string, localPort: number) => - `${baseUrl}/login?redirect_url=` + - encodeURIComponent("/cli-login?port=" + localPort); - -export const baseUrlSuffix = (baseUrl: string) => { - return baseUrl !== DEFAULT_BASE_URL ? ` at ${chalk.cyan(baseUrl)}` : ""; -}; diff --git a/packages/cli/utils/options.ts b/packages/cli/utils/options.ts index 92046ab4..912b9459 100644 --- a/packages/cli/utils/options.ts +++ b/packages/cli/utils/options.ts @@ -1,7 +1,5 @@ import { Argument, Option } from "commander"; -import { keyFormats } from "../stores/config.js"; - import { CONFIG_FILE_NAME } from "./constants.js"; export const debugOption = new Option("--debug", "Enable debug mode"); @@ -36,11 +34,6 @@ export const typesFormatOption = new Option( "Single output format for generated feature types", ).choices(["react", "node"]); -export const keyFormatOption = new Option( - "--key-format [format]", - `Feature key format. Falls back to keyFormat value in ${CONFIG_FILE_NAME}.`, -).choices(keyFormats); - export const featureKeyOption = new Option( "-k, --key [feature key]", "Feature key. If not provided, a key is generated from the feature's name.", diff --git a/packages/cli/utils/path.ts b/packages/cli/utils/path.ts index a608cf1d..5bade673 100644 --- a/packages/cli/utils/path.ts +++ b/packages/cli/utils/path.ts @@ -1,3 +1,34 @@ +import chalk from "chalk"; +import slugMod from "slug"; + +import { DEFAULT_BASE_URL } from "./constants.js"; + +export type UrlArgs = { id: string; name: string }; + +export function slug({ id, name }: UrlArgs) { + return `${slugMod(name).substring(0, 15)}-${id}`; +} + export function stripTrailingSlash(str: T): T { return str?.endsWith("/") ? (str.slice(0, -1) as T) : str; } + +export const baseUrlSuffix = (baseUrl: string) => { + return baseUrl !== DEFAULT_BASE_URL ? ` at ${chalk.cyan(baseUrl)}` : ""; +}; + +export const loginUrl = (baseUrl: string, localPort: number) => + `${baseUrl}/login?redirect_url=` + + encodeURIComponent("/cli-login?port=" + localPort); + +export function environmentUrl(baseUrl: string, environment: UrlArgs): string { + return `${baseUrl}/envs/${slug(environment)}`; +} + +export function featureUrl( + baseUrl: string, + env: UrlArgs, + feature: UrlArgs, +): string { + return `${environmentUrl(baseUrl, env)}/features/${slug(feature)}`; +} diff --git a/yarn.lock b/yarn.lock index 5150e84c..ba547b9d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -456,6 +456,7 @@ __metadata: "@bucketco/tsconfig": "workspace:^" "@inquirer/prompts": "npm:^5.3.8" "@types/node": "npm:^22.5.1" + "@types/slug": "npm:^5.0.9" ajv: "npm:^8.17.1" chalk: "npm:^5.3.0" change-case: "npm:^5.4.4" @@ -468,6 +469,7 @@ __metadata: ora: "npm:^8.1.0" prettier: "npm:^3.5.2" shx: "npm:^0.3.4" + slug: "npm:^10.0.0" typescript: "npm:^5.5.4" vitest: "npm:^3.0.8" bin: @@ -3823,6 +3825,13 @@ __metadata: languageName: node linkType: hard +"@types/slug@npm:^5.0.9": + version: 5.0.9 + resolution: "@types/slug@npm:5.0.9" + checksum: 10c0/6d5366d80d83a8d08b7d33ea14394511997de2d6001d1e463a5141aa10bf0dd1ec801e0e248b8a84239142064f96918638db7ca2c745c85891d6c34c309ad3a6 + languageName: node + linkType: hard + "@types/statuses@npm:^2.0.4": version: 2.0.5 resolution: "@types/statuses@npm:2.0.5" @@ -15226,6 +15235,15 @@ __metadata: languageName: node linkType: hard +"slug@npm:^10.0.0": + version: 10.0.0 + resolution: "slug@npm:10.0.0" + bin: + slug: cli.js + checksum: 10c0/330f7657dd44e526412417cb3225faf88fe5190ebbf1d9345b572d8e84358efa7b25412447aa445f84102e763d9ce1bbe79331aa0e2162ddf0324ae02cb6d3e4 + languageName: node + linkType: hard + "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" From 54c686a96afc5fbb86b718f4cdfc96d6bbd605bd Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Thu, 13 Mar 2025 15:11:52 +0100 Subject: [PATCH 3/6] fix: apps list --- packages/cli/commands/apps.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/commands/apps.ts b/packages/cli/commands/apps.ts index 80ba729f..bba42458 100644 --- a/packages/cli/commands/apps.ts +++ b/packages/cli/commands/apps.ts @@ -12,7 +12,7 @@ export const listAppsAction = async () => { try { const apps = await listApps(); spinner.succeed(`Loaded apps from ${chalk.cyan(baseUrl)}.`); - console.table(apps); + console.table(apps.map(({ name, id, demo }) => ({ name, id, demo }))); } catch (error) { spinner.fail("Failed to list apps."); void handleError(error, "Apps List"); From a230b551af940149d9738f02b853ac4378ecdbf1 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Thu, 13 Mar 2025 15:20:13 +0100 Subject: [PATCH 4/6] refactor: separate line for feature link --- packages/cli/commands/features.ts | 20 +++++++++++++------- packages/cli/utils/gen.ts | 2 +- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index 1e4cee4a..8b22160d 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -9,7 +9,12 @@ import { App, getApp, getOrg } from "../services/bootstrap.js"; import { createFeature, Feature, listFeatures } from "../services/features.js"; import { configStore } from "../stores/config.js"; import { handleError, MissingAppIdError } from "../utils/errors.js"; -import { genFeatureKey, genTypes, KeyFormatPatterns } from "../utils/gen.js"; +import { + genFeatureKey, + genTypes, + indentLines, + KeyFormatPatterns, +} from "../utils/gen.js"; import { appIdOption, featureKeyOption, @@ -54,14 +59,15 @@ export const createFeatureAction = async ( spinner = ora(`Creating feature...`).start(); const feature = await createFeature(appId, name, key); - const production = app.environments.find((e) => e.isProduction); spinner.succeed( - `Created feature ${chalk.cyan(feature.name)} with key ${chalk.cyan(feature.key)}${ - production - ? ` at ${chalk.cyan(featureUrl(baseUrl, production, feature))}` - : "" - }.`, + `Created feature ${chalk.cyan(feature.name)} with key ${chalk.cyan(feature.key)}:`, ); + const production = app.environments.find((e) => e.isProduction); + if (production) { + console.log( + indentLines(chalk.magenta(featureUrl(baseUrl, production, feature))), + ); + } } catch (error) { spinner?.fail("Feature creation failed."); void handleError(error, "Features Create"); diff --git a/packages/cli/utils/gen.ts b/packages/cli/utils/gen.ts index 88363980..d02f21ce 100644 --- a/packages/cli/utils/gen.ts +++ b/packages/cli/utils/gen.ts @@ -69,7 +69,7 @@ export const KeyFormatPatterns: Record = { }, }; -function indentLines(str: string, indent = 2, lineBreak = "\n"): string { +export function indentLines(str: string, indent = 2, lineBreak = "\n"): string { const indentStr = " ".repeat(indent); return str .split(lineBreak) From 3e8d82ccd91e0d2efc3a915717c975f8eb4bc0b9 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Thu, 13 Mar 2025 15:22:04 +0100 Subject: [PATCH 5/6] chore: bump package version --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index e6e88510..27d8c139 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/cli", - "version": "0.2.2", + "version": "0.2.3", "packageManager": "yarn@4.1.1", "description": "CLI for Bucket service", "main": "./dist/index.js", From 86ded9265abba4114d40402401bfbf5a8026f34a Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Thu, 13 Mar 2025 15:38:30 +0100 Subject: [PATCH 6/6] docs: reduced installation recommendations --- packages/cli/README.md | 34 +++++++++++----------------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index f49572a2..752ef99b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4,39 +4,27 @@ Command-line interface for interacting with Bucket services. The CLI allows you features, authentication, and generate TypeScript types for your Bucket features. With this tool, you can streamline your feature flagging workflow directly from your terminal. -## Quick Start +## Usage -Get started quickly by running the CLI directly without installing it from your project's root directory: -initializing the CLI (if not already setup), creating a feature, and generate the types all at once. - -```bash -# Initialize CLI (if not already setup), create a feature, and generate types all at once -npx @bucketco/cli new -``` - -or install it locally and run it using the bin alias: +Get started by installing the CLI locally in your project: ```bash # npm npm install --save-dev @bucketco/cli -npx bucket new - # yarn yarn add --dev @bucketco/cli - -yarn bucket new ``` -### Global installation - -You can also install the CLI globally adding the it to your PATH allowing you to use the shorthand `bucket`, -but we recommend installing it locally instead to better maintain the version. +Then running the `new` command from your project's root directory, +initializing the CLI, creating a feature, and generating the types all at once: ```bash -npm install -g @bucketco/cli +# npm +npx bucket new -bucket +# yarn +yarn bucket new ``` ### Individual commands @@ -45,13 +33,13 @@ Instead of running `new` you can call each step individually. ```bash # Initialize Bucket in your project (if not already setup) -bucket init +npx bucket init # Create a new feature -bucket features create "My Feature" +npx bucket features create "My Feature" # Generate TypeScript types for your features -bucket features types +npx bucket features types ``` ## Configuration