diff --git a/packages/cli/commands/apps.ts b/packages/cli/commands/apps.ts index ee91edc8..80ba729f 100644 --- a/packages/cli/commands/apps.ts +++ b/packages/cli/commands/apps.ts @@ -11,20 +11,20 @@ export const listAppsAction = async () => { const spinner = ora(`Loading apps from ${chalk.cyan(baseUrl)}...`).start(); try { const apps = await listApps(); - spinner.succeed(`Loaded apps from ${chalk.cyan(baseUrl)}`); + spinner.succeed(`Loaded apps from ${chalk.cyan(baseUrl)}.`); console.table(apps); } catch (error) { - spinner.fail("Failed to list apps"); + spinner.fail("Failed to list apps."); void handleError(error, "Apps List"); } }; export function registerAppCommands(cli: Command) { - const appsCommand = new Command("apps").description("Manage apps"); + const appsCommand = new Command("apps").description("Manage apps."); appsCommand .command("list") - .description("List all available apps") + .description("List all available apps.") .action(listAppsAction); cli.addCommand(appsCommand); diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index 6b207573..55882a04 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -14,7 +14,7 @@ export const loginAction = async () => { await authenticateUser(baseUrl); spinner.succeed(`Logged in to ${chalk.cyan(baseUrl)} successfully! 🎉`); } catch (error) { - spinner.fail("Login failed"); + spinner.fail("Login failed."); void handleError(error, "Login"); } }; @@ -26,13 +26,13 @@ export const logoutAction = async () => { await authStore.setToken(baseUrl, undefined); spinner.succeed("Logged out successfully! 👋"); } catch (error) { - spinner.fail("Logout failed"); + spinner.fail("Logout failed."); void handleError(error, "Logout"); } }; export function registerAuthCommands(cli: Command) { - cli.command("login").description("Login to Bucket").action(loginAction); + cli.command("login").description("Login to Bucket.").action(loginAction); - cli.command("logout").description("Logout from Bucket").action(logoutAction); + cli.command("logout").description("Logout from Bucket.").action(logoutAction); } diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index 27b21e35..739a3057 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -6,6 +6,7 @@ import { dirname, isAbsolute, join, relative } from "node:path"; import ora, { Ora } from "ora"; import { createFeature, Feature, listFeatures } from "../services/features.js"; +import { listStages, Stage } from "../services/stages.js"; import { configStore } from "../stores/config.js"; import { handleError, MissingAppIdError } from "../utils/errors.js"; import { genFeatureKey, genTypes, KeyFormatPatterns } from "../utils/gen.js"; @@ -33,7 +34,7 @@ export const createFeatureAction = async ( if (!name) { name = await input({ message: "New feature name:", - validate: (text) => text.length > 0 || "Name is required", + validate: (text) => text.length > 0 || "Name is required.", }); } @@ -55,7 +56,7 @@ export const createFeatureAction = async ( `Created feature ${chalk.cyan(feature.name)} with key ${chalk.cyan(feature.key)} at ${chalk.cyan(baseUrl)}. 🎉`, ); } catch (error) { - spinner?.fail("Feature creation failed"); + spinner?.fail("Feature creation failed."); void handleError(error, "Features Create"); } }; @@ -71,11 +72,17 @@ export const listFeaturesAction = async () => { ).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(appId)} at ${chalk.cyan(baseUrl)}.`, + ); + console.table( + features.map(({ key, name, stage }) => ({ + key, + name, + stage: stage?.name, + })), ); - console.table(features); } catch (error) { - spinner?.fail("Loading features failed"); + spinner?.fail("Loading features failed."); void handleError(error, "Features List"); } }; @@ -86,6 +93,7 @@ export const generateTypesAction = async () => { let spinner: Ora | undefined; let features: Feature[] = []; + let stages: Stage[] = []; try { if (!appId) throw new MissingAppIdError(); spinner = ora( @@ -95,10 +103,20 @@ export const generateTypesAction = async () => { includeRemoteConfigs: true, }); spinner.succeed( - `Loaded features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}`, + `Loaded features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}.`, ); } catch (error) { - spinner?.fail("Loading features failed"); + spinner?.fail("Loading features failed."); + void handleError(error, "Features Types"); + return; + } + + try { + spinner = ora(`Loading stages...`).start(); + stages = await listStages(appId); + spinner.succeed(`Loaded stages.`); + } catch (error) { + spinner?.fail("Loading stages failed."); void handleError(error, "Features Types"); return; } @@ -109,33 +127,33 @@ export const generateTypesAction = async () => { // Generate types for each output configuration for (const output of typesOutput) { - const types = await genTypes(features, output.format); + const types = await genTypes(features, stages, output.format); const outPath = isAbsolute(output.path) ? output.path : join(projectPath, output.path); await mkdir(dirname(outPath), { recursive: true }); await writeFile(outPath, types); - spinner.text = `Generated ${output.format} types for ${chalk.cyan(appId)} in ${chalk.cyan(relative(projectPath, outPath))}.`; + spinner.succeed( + `Generated ${output.format} types in ${chalk.cyan(relative(projectPath, outPath))}.`, + ); } - spinner.succeed( - `Generated types for ${chalk.cyan(appId)} in ${typesOutput.length} location(s).`, - ); + spinner.succeed(`Generated types for ${chalk.cyan(appId)}.`); } catch (error) { - spinner?.fail("Type generation failed"); + spinner?.fail("Type generation failed."); void handleError(error, "Features Types"); } }; export function registerFeatureCommands(cli: Command) { const featuresCommand = new Command("features").description( - "Manage features", + "Manage features.", ); featuresCommand .command("create") - .description("Create a new feature") + .description("Create a new feature.") .addOption(appIdOption) .addOption(keyFormatOption) .addOption(featureKeyOption) @@ -144,13 +162,13 @@ export function registerFeatureCommands(cli: Command) { featuresCommand .command("list") - .description("List all features") + .description("List all features.") .addOption(appIdOption) .action(listFeaturesAction); featuresCommand .command("types") - .description("Generate feature types") + .description("Generate feature types.") .addOption(appIdOption) .addOption(typesOutOption) .addOption(typesFormatOption) diff --git a/packages/cli/commands/init.ts b/packages/cli/commands/init.ts index a0667d78..6b2688dc 100644 --- a/packages/cli/commands/init.ts +++ b/packages/cli/commands/init.ts @@ -33,9 +33,9 @@ export const initAction = async (args: InitArgs = {}) => { // Load apps spinner = ora(`Loading apps from ${chalk.cyan(baseUrl)}...`).start(); apps = await listApps(); - spinner.succeed(`Loaded apps from ${chalk.cyan(baseUrl)}`); + spinner.succeed(`Loaded apps from ${chalk.cyan(baseUrl)}.`); } catch (error) { - spinner?.fail("Loading apps failed"); + spinner?.fail("Loading apps failed."); void handleError(error, "Initialization"); return; } @@ -50,17 +50,15 @@ export const initAction = async (args: InitArgs = {}) => { } else if (nonDemoApps.length === 1) { appId = nonDemoApps[0].id; console.log( - chalk.gray( - `Automatically selected app ${nonDemoApps[0].name} (${appId})`, - ), + `Automatically selected app ${chalk.cyan(nonDemoApps[0].name)} (${chalk.cyan(appId)}).`, ); } else { + const longestName = Math.max(...apps.map((app) => app.name.length)); appId = await select({ message: "Select an app", choices: apps.map((app) => ({ - name: app.name, + name: `${app.name.padEnd(longestName, " ")}${app.demo ? " [Demo]" : ""}`, value: app.id, - description: app.demo ? "Demo" : undefined, })), }); } @@ -95,10 +93,10 @@ export const initAction = async (args: InitArgs = {}) => { spinner = ora("Creating configuration...").start(); await configStore.saveConfigFile(args.overwrite); spinner.succeed( - `Configuration created at ${chalk.cyan(relative(process.cwd(), configStore.getConfigPath()!))}`, + `Configuration created at ${chalk.cyan(relative(process.cwd(), configStore.getConfigPath()!))}.`, ); } catch (error) { - spinner?.fail("Configuration creation failed"); + spinner?.fail("Configuration creation failed."); void handleError(error, "Initialization"); } }; @@ -106,7 +104,7 @@ export const initAction = async (args: InitArgs = {}) => { export function registerInitCommand(cli: Command) { cli .command("init") - .description("Initialize a new Bucket configuration") + .description("Initialize a new Bucket configuration.") .addOption(overwriteOption) .action(initAction); } diff --git a/packages/cli/commands/new.ts b/packages/cli/commands/new.ts index e2777d0f..a8aa5fbe 100644 --- a/packages/cli/commands/new.ts +++ b/packages/cli/commands/new.ts @@ -35,7 +35,7 @@ export function registerNewCommand(cli: Command) { cli .command("new") .description( - "Initialize the Bucket CLI, authenticates, and creates a new feature", + "Initialize the Bucket CLI, authenticates, and creates a new feature.", ) .addOption(appIdOption) .addOption(keyFormatOption) diff --git a/packages/cli/package.json b/packages/cli/package.json index bda762ce..e8639666 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/cli", - "version": "0.1.4", + "version": "0.2.0", "packageManager": "yarn@4.1.1", "description": "CLI for Bucket service", "main": "./dist/index.js", diff --git a/packages/cli/services/features.ts b/packages/cli/services/features.ts index 5014c717..6ff3baaf 100644 --- a/packages/cli/services/features.ts +++ b/packages/cli/services/features.ts @@ -1,5 +1,7 @@ import { authRequest } from "../utils/auth.js"; +import { Stage } from "./stages.js"; + export type RemoteConfigVariant = { key?: string; payload?: any; @@ -17,6 +19,7 @@ export type Feature = { name: string; key: string; remoteConfigs: RemoteConfig[]; + stage: Stage | null; }; export type FeaturesResponse = { diff --git a/packages/cli/services/stages.ts b/packages/cli/services/stages.ts new file mode 100644 index 00000000..381df957 --- /dev/null +++ b/packages/cli/services/stages.ts @@ -0,0 +1,16 @@ +import { authRequest } from "../utils/auth.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; +} diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index d95e90c2..7f4d75e7 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -19,7 +19,7 @@ export async function authenticateUser(baseUrl: string) { let isResolved = false; const server = http.createServer(async (req, res) => { - const url = new URL(req.url ?? "/", "http://localhost"); + const url = new URL(req.url ?? "/", "http://127.0.0.1"); const headers = corsHeaders(baseUrl); // Ensure we don't process requests after resolution @@ -63,8 +63,8 @@ export async function authenticateUser(baseUrl: string) { }); const timeout = setTimeout(() => { - cleanupAndReject(new Error("Authentication timed out after 30 seconds")); - }, 30000); + cleanupAndReject(new Error("Authentication timed out after 60 seconds")); + }, 60000); function cleanupAndResolve(token: string) { if (isResolved) return; @@ -94,9 +94,7 @@ export async function authenticateUser(baseUrl: string) { const address = server.address(); if (address && typeof address === "object") { const port = address.port; - void open(loginUrl(baseUrl, port), { - newInstance: true, - }); + void open(loginUrl(baseUrl, port)); } }); } diff --git a/packages/cli/utils/gen.ts b/packages/cli/utils/gen.ts index 88363980..02515e0d 100644 --- a/packages/cli/utils/gen.ts +++ b/packages/cli/utils/gen.ts @@ -1,6 +1,7 @@ import { camelCase, kebabCase, pascalCase, snakeCase } from "change-case"; import { Feature, RemoteConfig } from "../services/features.js"; +import { Stage } from "../services/stages.js"; import { JSONToType } from "./json.js"; @@ -89,7 +90,11 @@ export function genRemoteConfig(remoteConfigs?: RemoteConfig[]) { ); } -export function genTypes(features: Feature[], format: GenFormat = "react") { +export function genTypes( + features: Feature[], + stages: Stage[], + format: GenFormat = "react", +) { const configDefs = new Map(); features.forEach(({ key, name, remoteConfigs }) => { const definition = genRemoteConfig(remoteConfigs); @@ -105,6 +110,11 @@ export function genTypes(features: Feature[], format: GenFormat = "react") { import "@bucketco/${format}-sdk"; declare module "@bucketco/${format}-sdk" { + ${ + stages.length + ? /* ts */ `export type Stage = ${stages.map(({ name }) => `"${name}"`).join(" | ")};\n` + : "" + } export interface Features { ${features .map(({ key }) => { diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index c8beb05c..075186fd 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/node-sdk", - "version": "1.6.1", + "version": "1.6.2", "license": "MIT", "repository": { "type": "git", diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index ea620bd1..fd1dc14d 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -148,7 +148,7 @@ export type FeatureRemoteConfig = * Describes a feature */ export interface Feature< - TConfig extends FeatureRemoteConfig | undefined = EmptyFeatureRemoteConfig, + TConfig extends FeatureType["config"] | undefined = EmptyFeatureRemoteConfig, > { /** * The key of the feature. @@ -163,7 +163,11 @@ export interface Feature< /* * Optional user-defined configuration. */ - config: TConfig extends undefined ? EmptyFeatureRemoteConfig : TConfig; + config: TConfig extends undefined + ? EmptyFeatureRemoteConfig + : TConfig & { + key: string; + }; /** * Track feature usage in Bucket.