From 397a6363c86f6f2a11e2be5b1364c8b4c1a908a9 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Wed, 26 Feb 2025 15:59:50 +0100 Subject: [PATCH 1/6] feat: base implementation of the new CLI --- .gitignore | 1 + packages/cli/.prettierignore | 3 + packages/cli/README.md | 1 + packages/cli/commands/apps.ts | 30 ++ packages/cli/commands/auth.ts | 39 +++ packages/cli/commands/features.ts | 169 ++++++++++ packages/cli/commands/init.ts | 102 ++++++ packages/cli/commands/new.ts | 64 ++++ packages/cli/eslint.config.js | 3 + packages/cli/index.ts | 52 +++ packages/cli/package.json | 45 +++ packages/cli/schema.json | 32 ++ packages/cli/services/bootstrap.ts | 37 +++ packages/cli/services/features.ts | 47 +++ packages/cli/tsconfig.eslint.json | 3 + packages/cli/tsconfig.json | 11 + packages/cli/utils/auth.ts | 180 +++++++++++ packages/cli/utils/config.ts | 155 +++++++++ packages/cli/utils/constants.ts | 23 ++ packages/cli/utils/error.ts | 27 ++ packages/cli/utils/gen.ts | 101 ++++++ packages/cli/utils/options.ts | 47 +++ packages/eslint-config/base.js | 4 +- yarn.lock | 498 ++++++++++++++++++++++++++++- 24 files changed, 1669 insertions(+), 5 deletions(-) create mode 100644 packages/cli/.prettierignore create mode 100644 packages/cli/README.md create mode 100644 packages/cli/commands/apps.ts create mode 100644 packages/cli/commands/auth.ts create mode 100644 packages/cli/commands/features.ts create mode 100644 packages/cli/commands/init.ts create mode 100644 packages/cli/commands/new.ts create mode 100644 packages/cli/eslint.config.js create mode 100755 packages/cli/index.ts create mode 100644 packages/cli/package.json create mode 100644 packages/cli/schema.json create mode 100644 packages/cli/services/bootstrap.ts create mode 100644 packages/cli/services/features.ts create mode 100644 packages/cli/tsconfig.eslint.json create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/cli/utils/auth.ts create mode 100644 packages/cli/utils/config.ts create mode 100644 packages/cli/utils/constants.ts create mode 100644 packages/cli/utils/error.ts create mode 100644 packages/cli/utils/gen.ts create mode 100644 packages/cli/utils/options.ts diff --git a/.gitignore b/.gitignore index abe7f314..c25132d4 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ junit.xml .next eslint-report.json +bucket.config.json diff --git a/packages/cli/.prettierignore b/packages/cli/.prettierignore new file mode 100644 index 00000000..3dc99d75 --- /dev/null +++ b/packages/cli/.prettierignore @@ -0,0 +1,3 @@ +dist +eslint-report.json +gen diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 00000000..ecfe9dff --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1 @@ +# cli diff --git a/packages/cli/commands/apps.ts b/packages/cli/commands/apps.ts new file mode 100644 index 00000000..8b4c4ee8 --- /dev/null +++ b/packages/cli/commands/apps.ts @@ -0,0 +1,30 @@ +import chalk from "chalk"; +import { Command, program } from "commander"; +import ora from "ora"; + +import { listApps } from "../services/bootstrap.js"; +import { handleError } from "../utils/error.js"; + +export const listAppsAction = async () => { + const { baseUrl } = program.opts(); + const spinner = ora(`Loading apps from ${chalk.cyan(baseUrl)}...`).start(); + try { + const apps = await listApps(); + spinner.succeed(`Loaded apps from ${chalk.cyan(baseUrl)}`); + console.table(apps); + } catch (error) { + spinner.fail("Failed to list apps"); + handleError(error, "Apps List"); + } +}; + +export function registerAppCommands(program: Command) { + const appsCommand = new Command("apps").description("Manage apps"); + + appsCommand + .command("list") + .description("List all available apps") + .action(listAppsAction); + + program.addCommand(appsCommand); +} diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts new file mode 100644 index 00000000..526773bf --- /dev/null +++ b/packages/cli/commands/auth.ts @@ -0,0 +1,39 @@ +import chalk from "chalk"; +import { Command, program } from "commander"; +import ora from "ora"; + +import { authenticateUser, setToken } from "../utils/auth.js"; +import { handleError } from "../utils/error.js"; + +export const loginAction = async () => { + const { baseUrl } = program.opts(); + const spinner = ora(`Logging in to ${chalk.cyan(baseUrl)}...`).start(); + try { + await authenticateUser(); + spinner.succeed(`Logged in to ${chalk.cyan(baseUrl)} successfully! 🎉`); + } catch (error) { + spinner.fail("Login failed"); + handleError(error, "Login"); + } +}; + +export const logoutAction = async () => { + const { baseUrl } = program.opts(); + const spinner = ora("Logging out...").start(); + try { + await setToken(baseUrl, undefined); + spinner.succeed("Logged out successfully! 👋"); + } catch (error) { + spinner.fail("Logout failed"); + handleError(error, "Logout"); + } +}; + +export function registerAuthCommands(program: Command) { + program.command("login").description("Login to Bucket").action(loginAction); + + program + .command("logout") + .description("Logout from Bucket") + .action(logoutAction); +} diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts new file mode 100644 index 00000000..f58b9c33 --- /dev/null +++ b/packages/cli/commands/features.ts @@ -0,0 +1,169 @@ +import { input } from "@inquirer/prompts"; +import chalk from "chalk"; +import { Command, program } from "commander"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join } from "node:path"; +import ora, { Ora } from "ora"; + +import { createFeature, listFeatures } from "../services/features.js"; +import { getConfig, getProjectPath } from "../utils/config.js"; +import { handleError } from "../utils/error.js"; +import { genDTS, genFeatureKey, KeyFormatPatterns } from "../utils/gen.js"; +import { options } from "../utils/options.js"; + +type AppIdArgs = { + appId: string; +}; + +type CreateFeatureArgs = AppIdArgs & { + key?: string; +}; + +type GenerateTypesArgs = AppIdArgs & { + out: string; +}; + +export const createFeatureAction = async ( + name: string | undefined, + { appId, key }: CreateFeatureArgs, +) => { + const { baseUrl } = program.opts(); + let spinner: Ora | undefined; + let existingKeys: string[] = []; + try { + spinner = ora( + `Loading features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, + ).start(); + const features = await listFeatures(appId); + existingKeys = features.map((f) => f.key); + spinner.succeed( + `Loaded features of app ${chalk.cyan(appId)} at from ${chalk.cyan(baseUrl)}`, + ); + } catch (error) { + spinner?.fail("Loading features failed"); + handleError(error, "Features Create"); + } + + try { + if (!name) { + name = await input({ + message: "New feature name:", + validate: (input) => input.length > 0 || "Name is required", + }); + } + + if (!key) { + const keyFormat = getConfig("keyFormat") ?? "custom"; + key = await input({ + message: "New feature key:", + default: genFeatureKey(name, keyFormat, existingKeys), + validate: KeyFormatPatterns[keyFormat].validate, + }); + } + + 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)}. 🎉`, + ); + } catch (error) { + spinner?.fail("Feature creation failed"); + handleError(error, "Features Create"); + } +}; + +export const listFeaturesAction = async ({ appId }: AppIdArgs) => { + const { baseUrl } = program.opts(); + const spinner = ora( + `Loading features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, + ).start(); + try { + const features = await listFeatures(appId); + spinner.succeed( + `Loaded features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}`, + ); + console.table(features); + } catch (error) { + spinner.fail("Loading features failed"); + handleError(error, "Features List"); + } +}; + +export const generateTypesAction = async ({ + appId, + out, +}: GenerateTypesArgs) => { + const { baseUrl } = program.opts(); + let spinner: Ora | undefined; + let featureKeys: string[] = []; + try { + spinner = ora( + `Loading features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, + ).start(); + featureKeys = (await listFeatures(appId)).map(({ key }) => key); + spinner.succeed( + `Loaded features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}`, + ); + } catch (error) { + spinner?.fail("Loading features failed"); + handleError(error, "Features Types"); + } + + try { + spinner = ora("Generating feature types...").start(); + const types = genDTS(featureKeys); + const outPath = isAbsolute(out) ? out : join(getProjectPath(), out); + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, types); + spinner.succeed("Generated feature types successfully"); + console.log(chalk.green(`Generated types for ${appId}.`)); + } catch (error) { + spinner?.fail("Type generation failed"); + handleError(error, "Features Types"); + } +}; + +export function registerFeatureCommands(program: Command) { + const featuresCommand = new Command("features").description( + "Manage features", + ); + + featuresCommand + .command("create") + .description("Create a new feature") + .requiredOption( + options.appId.flags, + options.appId.description, + getConfig(options.appId.configKey), + ) + .option(options.featureKey.flags, options.featureKey.description) + .argument(options.featureName.flags, options.featureName.description) + .action(createFeatureAction); + + featuresCommand + .command("list") + .description("List all features") + .requiredOption( + options.appId.flags, + options.appId.description, + getConfig(options.appId.configKey), + ) + .action(listFeaturesAction); + + featuresCommand + .command("types") + .description("Generate feature types") + .requiredOption( + options.appId.flags, + options.appId.description, + getConfig(options.appId.configKey), + ) + .requiredOption( + options.typesOut.flags, + options.typesOut.description, + getConfig(options.typesOut.configKey) ?? options.typesOut.fallback, + ) + .action(generateTypesAction); + + program.addCommand(featuresCommand); +} diff --git a/packages/cli/commands/init.ts b/packages/cli/commands/init.ts new file mode 100644 index 00000000..9fe8f0f4 --- /dev/null +++ b/packages/cli/commands/init.ts @@ -0,0 +1,102 @@ +import { input, select } from "@inquirer/prompts"; +import chalk from "chalk"; +import { Command, program } from "commander"; +import { relative } from "node:path"; +import ora, { Ora } from "ora"; + +import { App, listApps } from "../services/bootstrap.js"; +import { getConfigPath, saveConfig } from "../utils/config.js"; +import { chalkBrand, DEFAULT_TYPES_PATH } from "../utils/constants.js"; +import { handleError } from "../utils/error.js"; +import { options } from "../utils/options.js"; + +type InitArgs = { + force?: boolean; +}; + +export const initAction = async (args: InitArgs) => { + let spinner: Ora | undefined; + let apps: App[] = []; + + try { + // Check if already initialized + const configPath = getConfigPath(); + if (configPath && !args.force) { + throw new Error( + "Bucket is already initialized. Use --force to overwrite.", + ); + } + + console.log(chalkBrand("\nWelcome to Bucket! 🪣\n")); + const { baseUrl } = program.opts(); + + // Load apps + spinner = ora(`Loading apps from ${chalk.cyan(baseUrl)}...`).start(); + apps = await listApps(); + spinner.succeed(`Loaded apps from ${chalk.cyan(baseUrl)}`); + } catch (error) { + spinner?.fail("Loading apps failed"); + handleError(error, "Initialization"); + } + + try { + const { baseUrl, apiUrl } = program.opts(); + let appId: string | undefined; + const nonDemoApps = apps.filter((app) => !app.demo); + + // If there is only one non-demo app, select it automatically + if (nonDemoApps.length === 1) { + appId = nonDemoApps[0].id; + console.log( + chalk.gray( + `Automatically selected app ${nonDemoApps[0].name} (${appId})`, + ), + ); + } else { + appId = await select({ + message: "Select an app", + choices: apps.map((app) => ({ + name: app.name, + value: app.id, + description: app.demo ? "Demo" : undefined, + })), + }); + } + + const keyFormat = + apps.find((app) => app.id === appId)?.featureKeyFormat ?? "custom"; + + // Get types output path + const typesPath = await input({ + message: "Where should we generate the types?", + default: DEFAULT_TYPES_PATH, + }); + + // Create config file + spinner = ora("Creating configuration...").start(); + await saveConfig( + { + baseUrl, + apiUrl, + appId, + typesPath, + keyFormat, + }, + args.force, + ); + spinner.succeed( + `Configuration created at ${chalk.cyan(relative(process.cwd(), getConfigPath()!))}`, + ); + } catch (error) { + spinner?.fail("Configuration creation failed"); + handleError(error, "Initialization"); + } +}; + +export function registerInitCommand(program: Command) { + program + .command("init") + .description("Initialize a new Bucket configuration") + .option(options.initOverride.flags, options.initOverride.description) + .action(initAction); +} diff --git a/packages/cli/commands/new.ts b/packages/cli/commands/new.ts new file mode 100644 index 00000000..7b1e8b4f --- /dev/null +++ b/packages/cli/commands/new.ts @@ -0,0 +1,64 @@ +import { Command } from "commander"; +import { findUp } from "find-up"; + +import { getConfig } from "../utils/config.js"; +import { CONFIG_FILE_NAME } from "../utils/constants.js"; +import { handleError } from "../utils/error.js"; +import { options } from "../utils/options.js"; + +import { createFeatureAction, generateTypesAction } from "./features.js"; +import { initAction } from "./init.js"; + +type NewArgs = { + appId?: string; + out: string; + key?: string; +}; + +export const newAction = async ( + name: string | undefined, + { appId, out, key }: NewArgs, +) => { + try { + if (!(await findUp(CONFIG_FILE_NAME))) { + await initAction({}); + } + appId = appId ?? getConfig("appId"); + if (!appId) { + throw new Error( + "App ID is required. Please provide it with --appId or in the config file.", + ); + } + await createFeatureAction(name, { + appId, + key, + }); + await generateTypesAction({ + appId, + out, + }); + } catch (error) { + handleError(error, "New"); + } +}; + +export function registerNewCommand(program: Command) { + program + .command("new") + .description( + "Initialize the Bucket CLI, authenticates, and creates a new feature", + ) + .option( + options.appId.flags, + options.appId.description, + getConfig(options.appId.configKey), + ) + .option( + options.typesOut.flags, + options.typesOut.description, + getConfig(options.typesOut.configKey) ?? options.typesOut.fallback, + ) + .option(options.featureKey.flags, options.featureKey.description) + .argument(options.featureName.flags, options.featureName.description) + .action(newAction); +} diff --git a/packages/cli/eslint.config.js b/packages/cli/eslint.config.js new file mode 100644 index 00000000..fbe7855a --- /dev/null +++ b/packages/cli/eslint.config.js @@ -0,0 +1,3 @@ +import base from "@bucketco/eslint-config/base.js"; + +export default [...base, { ignores: ["dist/", "gen/"] }]; diff --git a/packages/cli/index.ts b/packages/cli/index.ts new file mode 100755 index 00000000..07669503 --- /dev/null +++ b/packages/cli/index.ts @@ -0,0 +1,52 @@ +#!/usr/bin/env node --no-warnings=ExperimentalWarning +import chalk from "chalk"; +import { program } from "commander"; + +import { registerAppCommands } from "./commands/apps.js"; +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 { loadTokens } from "./utils/auth.js"; +import { getConfig, getConfigPath, loadConfig } from "./utils/config.js"; +import { options } from "./utils/options.js"; + +async function main() { + // Must load tokens and config before anything else + await loadTokens(); + await loadConfig(); + + // Global options + program.option(options.debug.flags, options.debug.description, false); + program.requiredOption( + options.baseUrl.flags, + options.baseUrl.description, + getConfig(options.baseUrl.configKey) ?? options.baseUrl.fallback, + ); + program.option( + options.apiUrl.flags, + options.apiUrl.description, + getConfig(options.apiUrl.configKey), + ); + + // Pre-action hook + program.hook("preAction", () => { + const { debug } = program.opts(); + if (debug) { + console.debug(chalk.cyan("\nDebug mode enabled")); + console.debug("Reading config from", chalk.green(getConfigPath())); + console.table(getConfig()); + } + }); + + // Main program + registerNewCommand(program); + registerInitCommand(program); + registerAuthCommands(program); + registerAppCommands(program); + registerFeatureCommands(program); + + program.parse(process.argv); +} + +main(); diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000..c4e2b7b9 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,45 @@ +{ + "name": "@bucketco/cli", + "version": "0.0.1", + "packageManager": "yarn@4.1.1", + "description": "CLI for Bucket service", + "main": "./dist/index.js", + "type": "module", + "engines": { + "node": ">=18.0.0" + }, + "bin": { + "bucket": "./dist/index.js" + }, + "files": [ + "dist", + "schema.json" + ], + "scripts": { + "build": "tsc", + "bucket": "yarn build && node dist/index.js", + "lint": "eslint .", + "lint:ci": "eslint --output-file eslint-report.json --format json .", + "prettier": "prettier --check .", + "format": "yarn lint --fix && yarn prettier --write" + }, + "dependencies": { + "@inquirer/prompts": "^5.3.8", + "ajv": "^8.17.1", + "chalk": "^5.3.0", + "change-case": "^5.4.4", + "commander": "^12.1.0", + "find-up": "^7.0.0", + "json5": "^2.2.3", + "open": "^10.1.0", + "ora": "^8.1.0" + }, + "devDependencies": { + "@bucketco/eslint-config": "workspace:^", + "@bucketco/tsconfig": "workspace:^", + "@types/node": "^22.5.1", + "eslint": "^9.21.0", + "prettier": "^3.5.2", + "typescript": "^5.5.4" + } +} diff --git a/packages/cli/schema.json b/packages/cli/schema.json new file mode 100644 index 00000000..e8b54954 --- /dev/null +++ b/packages/cli/schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Bucket cli schema", + "type": "object", + "properties": { + "baseUrl": { + "type": "string" + }, + "apiUrl": { + "type": "string" + }, + "appId": { + "type": "string" + }, + "typesPath": { + "type": "string" + }, + "keyFormat": { + "type": "string", + "enum": [ + "custom", + "pascalCase", + "camelCase", + "snakeCaseUpper", + "snakeCaseLower", + "kebabCaseUpper", + "kebabCaseLower" + ] + } + }, + "required": ["appId"] +} diff --git a/packages/cli/services/bootstrap.ts b/packages/cli/services/bootstrap.ts new file mode 100644 index 00000000..16045e7b --- /dev/null +++ b/packages/cli/services/bootstrap.ts @@ -0,0 +1,37 @@ +import { authRequest } from "../utils/auth.js"; +import { KeyFormat } from "../utils/config.js"; + +type BootstrapResponse = { + org: { + apps: { + id: string; + name: string; + demo: boolean; + }[]; + featureKeyFormat?: KeyFormat; + }; +}; + +export type App = { + id: string; + name: string; + demo: boolean; + featureKeyFormat: KeyFormat; +}; + +export async function listApps(): Promise { + const response = await authRequest(`/bootstrap`); + const org = response.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 }) => ({ + id, + name, + demo, + featureKeyFormat: org.featureKeyFormat ?? "custom", + })); +} diff --git a/packages/cli/services/features.ts b/packages/cli/services/features.ts new file mode 100644 index 00000000..a0763baf --- /dev/null +++ b/packages/cli/services/features.ts @@ -0,0 +1,47 @@ +import { authRequest } from "../utils/auth.js"; + +type Feature = { + name: string; + key: string; +}; + +type FeaturesResponse = { + data: Feature[]; +}; + +export async function listFeatures(appId: string): Promise { + const response = await authRequest( + `/apps/${appId}/features`, + ); + + return response.data.map(({ name, key }) => ({ + name, + key, + })); +} + +type FeatureResponse = { + feature: Feature; +}; + +export async function createFeature( + appId: string, + name: string, + key: string, +): Promise { + const response = await authRequest( + `/apps/${appId}/features`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name, + key, + source: "event", + }), + }, + ); + return response.feature; +} diff --git a/packages/cli/tsconfig.eslint.json b/packages/cli/tsconfig.eslint.json new file mode 100644 index 00000000..fc8520e7 --- /dev/null +++ b/packages/cli/tsconfig.eslint.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.json" +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 00000000..a25cfde5 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@bucketco/tsconfig/library", + "compilerOptions": { + "outDir": "./dist/", + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext" + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.spec.ts"] +} diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts new file mode 100644 index 00000000..b3835138 --- /dev/null +++ b/packages/cli/utils/auth.ts @@ -0,0 +1,180 @@ +import { mkdir, readFile, writeFile } from "fs/promises"; +import http from "http"; +import { dirname } from "path"; +import { program } from "commander"; +import open from "open"; + +import { AUTH_FILE, loginUrl } from "./constants.js"; + +let tokens: Map = new Map(); + +export async function loadTokens() { + try { + const content = await readFile(AUTH_FILE, "utf-8"); + tokens = new Map( + content + .split("\n") + .filter(Boolean) + .map((line) => { + const [baseUrl, token] = line.split("|"); + return [baseUrl, token]; + }), + ); + } catch { + // No tokens file found + } +} + +async function saveTokens(newTokens: Map) { + 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); + tokens = newTokens; +} + +export function getToken(baseUrl: string) { + return tokens.get(baseUrl); +} + +export async function setToken(baseUrl: string, newToken?: string) { + if (newToken) { + tokens.set(baseUrl, newToken); + } else { + tokens.delete(baseUrl); + } + await saveTokens(tokens); +} + +function corsHeaders(baseUrl: string): Record { + return { + "Access-Control-Allow-Origin": baseUrl, + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Headers": "Authorization", + }; +} + +export async function authenticateUser() { + return new Promise((resolve, reject) => { + const { baseUrl } = program.opts(); + let isResolved = false; + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://localhost"); + const headers = corsHeaders(baseUrl); + + // Ensure we don't process requests after resolution + if (isResolved) { + res.writeHead(503, headers).end(); + return; + } + + if (url.pathname !== "/cli-login") { + res.writeHead(404).end("Invalid path"); + cleanupAndReject(new Error("Could not authenticate: Invalid path")); + return; + } + + // Handle preflight request + if (req.method === "OPTIONS") { + res.writeHead(200, headers); + res.end(); + return; + } + + if (!req.headers.authorization?.startsWith("Bearer ")) { + res.writeHead(400, headers).end("Could not authenticate"); + cleanupAndReject(new Error("Could not authenticate")); + return; + } + + const token = req.headers.authorization.slice("Bearer ".length); + headers["Content-Type"] = "application/json"; + res.writeHead(200, headers); + res.end(JSON.stringify({ result: "success" })); + + try { + await setToken(baseUrl, token); + cleanupAndResolve(token); + } catch (error) { + cleanupAndReject( + error instanceof Error ? error : new Error("Failed to store token"), + ); + } + }); + + const timeout = setTimeout(() => { + cleanupAndReject(new Error("Authentication timed out after 30 seconds")); + }, 30000); + + function cleanupAndResolve(token: string) { + if (isResolved) return; + isResolved = true; + cleanup(); + resolve(token); + } + + function cleanupAndReject(error: Error) { + if (isResolved) return; + isResolved = true; + cleanup(); + reject(error); + } + + function cleanup() { + clearTimeout(timeout); + server.close(); + // Force-close any remaining connections + server.getConnections((err, count) => { + if (err || count === 0) return; + server.closeAllConnections(); + }); + } + + server.listen(); + const address = server.address(); + if (address && typeof address === "object") { + const port = address.port; + open(loginUrl(baseUrl, port), { + newInstance: true, + }); + } + }); +} + +export async function authRequest>( + url: string, + options?: RequestInit, + retryCount = 0, +): Promise { + const { baseUrl, apiUrl } = program.opts(); + const token = getToken(baseUrl); + const resolvedApiUrl = apiUrl ?? `${baseUrl}/api`; + + if (!token) { + await authenticateUser(); + return authRequest(url, options); + } + + const response = await fetch(`${resolvedApiUrl}${url}`, { + ...options, + headers: { + ...options?.headers, + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + if (response.status === 401) { + await setToken(baseUrl, undefined); + if (retryCount < 1) { + await authenticateUser(); + return authRequest(url, options, retryCount + 1); + } + } + throw response; + } + + return response.json(); +} diff --git a/packages/cli/utils/config.ts b/packages/cli/utils/config.ts new file mode 100644 index 00000000..4a007957 --- /dev/null +++ b/packages/cli/utils/config.ts @@ -0,0 +1,155 @@ +import { readFile, writeFile } from "fs/promises"; +import { createRequire } from "module"; +import { dirname, join } from "path"; +import { Ajv } from "ajv"; +import { findUp } from "find-up"; +import JSON5 from "json5"; + +import { + CONFIG_FILE_NAME, + DEFAULT_API_URL, + DEFAULT_BASE_URL, + DEFAULT_TYPES_PATH, + SCHEMA_URL, +} from "./constants.js"; +import { handleError } from "./error.js"; + +// https://github.com/nodejs/node/issues/51347#issuecomment-2111337854 +const schema = createRequire(import.meta.url)("../../schema.json"); + +const ajv = new Ajv(); +const validateConfig = ajv.compile(schema); + +export const keyFormats = [ + "custom", + "pascalCase", + "camelCase", + "snakeCaseUpper", + "snakeCaseLower", + "kebabCaseUpper", + "kebabCaseLower", +] as const; + +export type KeyFormat = (typeof keyFormats)[number]; + +class ConfigValidationError extends Error { + constructor(errors: typeof validateConfig.errors) { + const messages = errors + ?.map((e) => { + const path = e.instancePath || "config"; + const value = e.params?.allowedValues + ? `: ${e.params.allowedValues.join(", ")}` + : ""; + return `${path}: ${e.message}${value}`; + }) + .join("\n"); + super(messages); + this.name = "ConfigValidationError"; + } +} + +type Config = { + $schema?: string; + baseUrl?: string; + apiUrl?: string; + appId: string; + typesPath?: string; + keyFormat?: KeyFormat; +}; + +let config: Config | undefined; +let configPath: string | undefined; +let projectPath: string | undefined; + +/** + * Instantly return a specified key's value or the entire config object. + */ +export function getConfig(): Config | undefined; +export function getConfig( + key: K, +): Config[K] | undefined; +export function getConfig(key?: K) { + return key ? config?.[key] : config; +} + +/** + * Return the path to the config file. + */ +export function getConfigPath() { + return configPath; +} + +/** + * Return the path to the project root. + */ +export function getProjectPath() { + return projectPath ?? process.cwd(); +} + +/** + * Load the configuration file. + */ +export async function loadConfig() { + try { + const packageJSONPath = await findUp("package.json"); + configPath = await findUp(CONFIG_FILE_NAME); + projectPath = dirname(configPath ?? packageJSONPath ?? process.cwd()); + if (!configPath) return; + const content = await readFile(configPath, "utf-8"); + const parsed = JSON5.parse(content); + if (!validateConfig(parsed)) { + handleError(new ConfigValidationError(validateConfig.errors), "Config"); + } + config = parsed; + } catch { + // No config file found + } +} + +/** + * Create a new config file with initial values. + * @param newConfig The configuration object to write + * @param overwrite If true, overwrites existing config file. Defaults to false + */ +const getDefaultConfig = (): Partial => ({ + baseUrl: DEFAULT_BASE_URL, + apiUrl: DEFAULT_API_URL, + typesPath: DEFAULT_TYPES_PATH, + keyFormat: "custom", +}); + +export async function saveConfig(newConfig: Config, overwrite = false) { + if (!validateConfig(newConfig)) { + handleError(new ConfigValidationError(validateConfig.errors), "Config"); + } + + const defaults = getDefaultConfig(); + const configWithoutDefaults: Config = { + $schema: SCHEMA_URL, + appId: newConfig.appId, + }; + + // Only include non-default values + Object.entries(newConfig).forEach(([key, value]) => { + if (key === "$schema") return; // Using our own schema URL + if (key === "appId") return; // Already included + if (value !== defaults[key as keyof typeof defaults]) { + (configWithoutDefaults as any)[key] = value; + } + }); + + const configJSON = JSON.stringify(configWithoutDefaults, null, 2); + + if (configPath) { + if (!overwrite) { + throw new Error("Config file already exists"); + } + await writeFile(configPath, configJSON); + config = newConfig; + } else { + // Write to the nearest package.json directory + configPath = join(getProjectPath(), CONFIG_FILE_NAME); + await writeFile(configPath, configJSON); + config = newConfig; + } +} diff --git a/packages/cli/utils/constants.ts b/packages/cli/utils/constants.ts new file mode 100644 index 00000000..855f2dc7 --- /dev/null +++ b/packages/cli/utils/constants.ts @@ -0,0 +1,23 @@ +import { createRequire } from "module"; +import { join } from "path"; +import chalk from "chalk"; + +// https://github.com/nodejs/node/issues/51347#issuecomment-2111337854 +const packageJson = createRequire(import.meta.url)("../../package.json"); + +export const CONFIG_FILE_NAME = "bucket.config.json"; +export const AUTH_FILE = join( + process.env.HOME ?? process.env.USERPROFILE ?? "", + ".bucket-auth", +); +export const SCHEMA_URL = `https://unpkg.com/@bucketco/cli@${packageJson.version}/schema.json`; + +export const DEFAULT_BASE_URL = "https://app.bucket.co"; +export const DEFAULT_API_URL = `${DEFAULT_BASE_URL}/api`; +export const DEFAULT_TYPES_PATH = join("gen", "features.ts"); + +export const loginUrl = (baseUrl: string, localPort: number) => + `${baseUrl}/login?redirect_url=` + + encodeURIComponent("/cli-login?port=" + localPort); + +export const chalkBrand = chalk.hex("#847CFB"); diff --git a/packages/cli/utils/error.ts b/packages/cli/utils/error.ts new file mode 100644 index 00000000..786ef687 --- /dev/null +++ b/packages/cli/utils/error.ts @@ -0,0 +1,27 @@ +import chalk from "chalk"; + +export async function handleError(error: unknown, tag: string) { + tag = chalk.bold(`\n[${tag}] error:`); + + if (error instanceof Response) { + const data = await error.json(); + console.error(chalk.red(tag, data.error?.message ?? data.error?.code)); + if (data.validationErrors) { + console.table( + data.validationErrors.map( + ({ path, message }: { path: string[]; message: string }) => ({ + path: path.join("."), + error: message, + }), + ), + ); + } + } else if (error instanceof Error) { + console.error(chalk.red(tag, error.message)); + } 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 new file mode 100644 index 00000000..780e3e46 --- /dev/null +++ b/packages/cli/utils/gen.ts @@ -0,0 +1,101 @@ +import { camelCase, kebabCase, pascalCase, snakeCase } from "change-case"; + +// Keep in sync with Bucket main repo +export const KeyFormats = [ + "custom", + "pascalCase", + "camelCase", + "snakeCaseUpper", + "snakeCaseLower", + "kebabCaseUpper", + "kebabCaseLower", +] as const; + +export type KeyFormat = (typeof KeyFormats)[number]; + +type KeyFormatPattern = { + transform: (key: string) => string; + validate: (key: string) => true | string; +}; + +export const KeyFormatPatterns: Record = { + custom: { + transform: (key) => key?.trim(), + validate: (key) => + /^[\p{L}\p{N}\p{P}\p{S}\p{Z}]+$/u.test(key) || + "Key must contain only letters, numbers, punctuation, symbols, or spaces", + }, + pascalCase: { + transform: (key) => pascalCase(key), + validate: (key) => + /^[\p{Lu}][\p{L}\p{N}]*$/u.test(key) || + "Key must start with uppercase letter and contain only letters and numbers", + }, + camelCase: { + transform: (key) => camelCase(key), + validate: (key) => + /^[\p{Ll}][\p{L}\p{N}]*$/u.test(key) || + "Key must start with lowercase letter and contain only letters and numbers", + }, + snakeCaseUpper: { + transform: (key) => snakeCase(key).toUpperCase(), + validate: (key) => + /^[\p{Lu}][\p{Lu}\p{N}]*(?:_[\p{Lu}\p{N}]+)*$/u.test(key) || + "Key must be uppercase with words separated by underscores", + }, + snakeCaseLower: { + transform: (key) => snakeCase(key).toLowerCase(), + validate: (key) => + /^[\p{Ll}][\p{Ll}\p{N}]*(?:_[\p{Ll}\p{N}]+)*$/u.test(key) || + "Key must be lowercase with words separated by underscores", + }, + kebabCaseUpper: { + transform: (key) => kebabCase(key).toUpperCase(), + validate: (key) => + /^[\p{Lu}][\p{Lu}\p{N}]*(?:-[\p{Lu}\p{N}]+)*$/u.test(key) || + "Key must be uppercase with words separated by hyphens", + }, + kebabCaseLower: { + transform: (key) => kebabCase(key).toLowerCase(), + validate: (key) => + /^[\p{Ll}][\p{Ll}\p{N}]*(?:-[\p{Ll}\p{N}]+)*$/u.test(key) || + "Key must be lowercase with words separated by hyphens", + }, +}; + +function nextAvailableKey( + baseKey: string, + format: KeyFormat, + existingKeys: string[], + attempt: number = 1, +): string { + const separator = format.includes("snake") ? "_" : "-"; + const suggestedKey = + attempt === 1 ? baseKey : `${baseKey}${separator}${attempt}`; + + return existingKeys.includes(suggestedKey) + ? nextAvailableKey(baseKey, format, existingKeys, attempt + 1) + : suggestedKey; +} + +export function genFeatureKey( + input: string, + format: KeyFormat, + existingKeys: string[], +): string { + const key = KeyFormatPatterns[format].transform(input); + return nextAvailableKey(key, format, existingKeys); +} + +export const genDTS = (keys: string[]) => { + return /* ts */ ` +// DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET CLI AND WILL BE OVERWRITTEN. +// eslint-disable +// prettier-ignore +declare module "@bucketco/react-sdk" { + interface Features { +${keys.map((key) => ` "${key}": boolean;`).join("\n")} + } +} +`.trim(); +}; diff --git a/packages/cli/utils/options.ts b/packages/cli/utils/options.ts new file mode 100644 index 00000000..7092331b --- /dev/null +++ b/packages/cli/utils/options.ts @@ -0,0 +1,47 @@ +import { + CONFIG_FILE_NAME, + DEFAULT_BASE_URL, + DEFAULT_TYPES_PATH, +} from "./constants.js"; + +export const options = { + debug: { + flags: "--debug", + description: "Enable debug mode", + }, + baseUrl: { + flags: "--base-url [url]", + description: "Specify the Bucket service URL (useful if behind a proxy).", + configKey: "baseUrl", + fallback: DEFAULT_BASE_URL, + }, + apiUrl: { + flags: "--api-url [url]", + description: `Specify the Bucket API URL (useful if behind a proxy). Falls back to apiUrl value in ${CONFIG_FILE_NAME} or baseUrl with /api appended.`, + configKey: "apiUrl", + }, + appId: { + flags: "-a, --appId ", + description: `Specify the app ID. Falls back to appId value in ${CONFIG_FILE_NAME}.`, + configKey: "appId", + }, + initOverride: { + flags: "-f, --force", + description: "Force initialization and overwrite existing configuration.", + }, + typesOut: { + flags: "-o, --out [path]", + description: `Specify the output path for generated feature types. Falls back to typesPath value in ${CONFIG_FILE_NAME} or ${DEFAULT_TYPES_PATH}`, + configKey: "typesPath", + fallback: DEFAULT_TYPES_PATH, + }, + featureName: { + flags: "[name]", + description: "Specify the feature's name.", + }, + featureKey: { + flags: "-k, --key [feature key]", + description: + "Specify the feature key. If not provided, a key is generated from the feature's name.", + }, +} as const; diff --git a/packages/eslint-config/base.js b/packages/eslint-config/base.js index 1c6bfd06..83f4a5a2 100644 --- a/packages/eslint-config/base.js +++ b/packages/eslint-config/base.js @@ -163,9 +163,9 @@ module.exports = [ ...tsPlugin.configs.recommended.rules, // Typescript Specific - "@typescript-eslint/no-unused-vars": "off", // handled by unused-imports + "@typescript-eslint/no-unused-vars": ["off"], // handled by unused-imports "@typescript-eslint/explicit-module-boundary-types": ["off"], - "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/no-floating-promises": ["error"], "@typescript-eslint/switch-exhaustiveness-check": ["warn"], "@typescript-eslint/no-non-null-assertion": ["off"], "@typescript-eslint/no-empty-function": ["warn"], diff --git a/yarn.lock b/yarn.lock index 36b66f99..faaf38e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -460,6 +460,30 @@ __metadata: languageName: unknown linkType: soft +"@bucketco/cli@workspace:packages/cli": + version: 0.0.0-use.local + resolution: "@bucketco/cli@workspace:packages/cli" + dependencies: + "@bucketco/eslint-config": "workspace:^" + "@bucketco/tsconfig": "workspace:^" + "@inquirer/prompts": "npm:^5.3.8" + "@types/node": "npm:^22.5.1" + ajv: "npm:^8.17.1" + chalk: "npm:^5.3.0" + change-case: "npm:^5.4.4" + commander: "npm:^12.1.0" + eslint: "npm:^9.21.0" + find-up: "npm:^7.0.0" + json5: "npm:^2.2.3" + open: "npm:^10.1.0" + ora: "npm:^8.1.0" + prettier: "npm:^3.5.2" + typescript: "npm:^5.5.4" + bin: + bucket: ./dist/index.js + languageName: unknown + linkType: soft + "@bucketco/eslint-config@npm:0.0.2, @bucketco/eslint-config@npm:~0.0.2, @bucketco/eslint-config@workspace:^, @bucketco/eslint-config@workspace:packages/eslint-config": version: 0.0.0-use.local resolution: "@bucketco/eslint-config@workspace:packages/eslint-config" @@ -1451,6 +1475,19 @@ __metadata: languageName: node linkType: hard +"@inquirer/checkbox@npm:^2.4.7": + version: 2.4.7 + resolution: "@inquirer/checkbox@npm:2.4.7" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/figures": "npm:^1.0.5" + "@inquirer/type": "npm:^1.5.2" + ansi-escapes: "npm:^4.3.2" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 10c0/92bd2727f316e2304b150ef32eb3023200512de154b49b6e121f468c2ad6fa58cb3b37d9dc91d1a2dc039e932dd934df5ce481acb2555cacac2b0308acb05576 + languageName: node + linkType: hard + "@inquirer/confirm@npm:^3.0.0": version: 3.1.17 resolution: "@inquirer/confirm@npm:3.1.17" @@ -1461,6 +1498,37 @@ __metadata: languageName: node linkType: hard +"@inquirer/confirm@npm:^3.1.22": + version: 3.1.22 + resolution: "@inquirer/confirm@npm:3.1.22" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + checksum: 10c0/99e1a17e62a674d8e440a11bf4e9d5a62666247823b091314e52ee40929a6a6e8ce60086ec653bbeb59117bfc940d807c6f4b604cf5cf51f24009b9d09d5bf98 + languageName: node + linkType: hard + +"@inquirer/core@npm:^9.0.10": + version: 9.0.10 + resolution: "@inquirer/core@npm:9.0.10" + dependencies: + "@inquirer/figures": "npm:^1.0.5" + "@inquirer/type": "npm:^1.5.2" + "@types/mute-stream": "npm:^0.0.4" + "@types/node": "npm:^22.1.0" + "@types/wrap-ansi": "npm:^3.0.0" + ansi-escapes: "npm:^4.3.2" + cli-spinners: "npm:^2.9.2" + cli-width: "npm:^4.1.0" + mute-stream: "npm:^1.0.0" + signal-exit: "npm:^4.1.0" + strip-ansi: "npm:^6.0.1" + wrap-ansi: "npm:^6.2.0" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 10c0/117f50a55b5ebee8bfc62ea6adec87035f28ee7ace1efea67895c3d32ab50bf569ecd3ca33c457d0c7ae4240b9fe4d7b698ab70946ac561ab579d0920ddc98bb + languageName: node + linkType: hard + "@inquirer/core@npm:^9.0.5": version: 9.0.5 resolution: "@inquirer/core@npm:9.0.5" @@ -1482,6 +1550,28 @@ __metadata: languageName: node linkType: hard +"@inquirer/editor@npm:^2.1.22": + version: 2.1.22 + resolution: "@inquirer/editor@npm:2.1.22" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + external-editor: "npm:^3.1.0" + checksum: 10c0/a2e65362ed187695450b092c8f5698661002e4e24e1e800dfbbfeaa8e71f60b5d5e1dfa98b9402457c02cab1762ac2b20d3364c11db0b5572aa61caf55f5beba + languageName: node + linkType: hard + +"@inquirer/expand@npm:^2.1.22": + version: 2.1.22 + resolution: "@inquirer/expand@npm:2.1.22" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 10c0/0f9d3447ca6b9e24e0179b4ec53f463647a8323d8a041bc3321f19a2914176117a264bcc6deb723e3f9ec718d3faf672f3f840f0898bbff4371fa899b239b411 + languageName: node + linkType: hard + "@inquirer/figures@npm:^1.0.5": version: 1.0.5 resolution: "@inquirer/figures@npm:1.0.5" @@ -1489,6 +1579,91 @@ __metadata: languageName: node linkType: hard +"@inquirer/input@npm:^2.2.9": + version: 2.2.9 + resolution: "@inquirer/input@npm:2.2.9" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + checksum: 10c0/0fcdc5d9c17712f9a2c79f39d1e03ed4a58cfe1dd1095209b4c83621dba2cb94db03b7df0df34e22584bce9e53403a284c76721def66a452e4751666d945d99f + languageName: node + linkType: hard + +"@inquirer/number@npm:^1.0.10": + version: 1.0.10 + resolution: "@inquirer/number@npm:1.0.10" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + checksum: 10c0/efa7c49322d8f36eeb8afb704bba673c10fcf7992babc8ad5f25d4c8db0fbafc0007439abdef05a462171b37a68b981981859587ff5b71e79002ffac65be859a + languageName: node + linkType: hard + +"@inquirer/password@npm:^2.1.22": + version: 2.1.22 + resolution: "@inquirer/password@npm:2.1.22" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + ansi-escapes: "npm:^4.3.2" + checksum: 10c0/5cd5bab0026d673539710f424e6f7dda8abea4863a0cddf982278b15a250f5a2be0a0f17fdf970a900d33187a64ad987d24d038cfbed2599b5a2a97c169bbddc + languageName: node + linkType: hard + +"@inquirer/prompts@npm:^5.3.8": + version: 5.3.8 + resolution: "@inquirer/prompts@npm:5.3.8" + dependencies: + "@inquirer/checkbox": "npm:^2.4.7" + "@inquirer/confirm": "npm:^3.1.22" + "@inquirer/editor": "npm:^2.1.22" + "@inquirer/expand": "npm:^2.1.22" + "@inquirer/input": "npm:^2.2.9" + "@inquirer/number": "npm:^1.0.10" + "@inquirer/password": "npm:^2.1.22" + "@inquirer/rawlist": "npm:^2.2.4" + "@inquirer/search": "npm:^1.0.7" + "@inquirer/select": "npm:^2.4.7" + checksum: 10c0/2c49afb5e9f7d825c1489d8c587f985418e890508802e1483d1cb8da46644e9803b2f0a8b71f53f0ff5d9273ca39e28faeadf7d6691467eb5c0dbbde900e5233 + languageName: node + linkType: hard + +"@inquirer/rawlist@npm:^2.2.4": + version: 2.2.4 + resolution: "@inquirer/rawlist@npm:2.2.4" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 10c0/d7c5e0784bb357f6465b7ca08a22310f124fb61db6cce7a1860305d8d25dcdfa66db216d4cc52873d68ae379376984cf8d9cd14880fbca41b7b03802be49caf8 + languageName: node + linkType: hard + +"@inquirer/search@npm:^1.0.7": + version: 1.0.7 + resolution: "@inquirer/search@npm:1.0.7" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/figures": "npm:^1.0.5" + "@inquirer/type": "npm:^1.5.2" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 10c0/29179cc32236689b956cccdc092b537c67e841c5cd0a6473b92f9e71f56c0fb737baa4856bf76572f07c0c3999b6b5ea1ce3b74ef56504e179098f700a85a5cf + languageName: node + linkType: hard + +"@inquirer/select@npm:^2.4.7": + version: 2.4.7 + resolution: "@inquirer/select@npm:2.4.7" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/figures": "npm:^1.0.5" + "@inquirer/type": "npm:^1.5.2" + ansi-escapes: "npm:^4.3.2" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 10c0/34e120a263ca2e7edeab08ef6ca24d0966135e8d1a9d6f167fbc03fa8f057391228d58292281609a25d51eb9d59d0b1d00146bd2a5811c5d3880800321cfe8e6 + languageName: node + linkType: hard + "@inquirer/type@npm:^1.5.1": version: 1.5.1 resolution: "@inquirer/type@npm:1.5.1" @@ -1498,6 +1673,15 @@ __metadata: languageName: node linkType: hard +"@inquirer/type@npm:^1.5.2": + version: 1.5.2 + resolution: "@inquirer/type@npm:1.5.2" + dependencies: + mute-stream: "npm:^1.0.0" + checksum: 10c0/e2c91562c07440620bd805a60438b78c188d2727d86f396a68c480e4357469a72cd80bd2c158faa6b987671911566bd4fc12976f4bdda1a3441594e318c40058 + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -3289,6 +3473,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^22.1.0, @types/node@npm:^22.5.1": + version: 22.5.1 + resolution: "@types/node@npm:22.5.1" + dependencies: + undici-types: "npm:~6.19.2" + checksum: 10c0/35373176d8a1d4e16004a1ed303e68d39e4c6341024dc056f2577982df98c1a045a6b677f12ed557796f09bbf7d621f428f6874cc37ed28f7b336fa604b5f6a6 + languageName: node + linkType: hard + "@types/node@npm:^22.12.0": version: 22.12.0 resolution: "@types/node@npm:22.12.0" @@ -3648,6 +3841,9 @@ __metadata: version: 2.1.9 resolution: "@vitest/pretty-format@npm:2.1.9" dependencies: + "@vitest/spy": "npm:2.1.9" + "@vitest/utils": "npm:2.1.9" + chai: "npm:^5.1.2" tinyrainbow: "npm:^1.2.0" checksum: 10c0/155f9ede5090eabed2a73361094bb35ed4ec6769ae3546d2a2af139166569aec41bb80e031c25ff2da22b71dd4ed51e5468e66a05e6aeda5f14b32e30bc18f00 languageName: node @@ -4345,7 +4541,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.0.0": +"ajv@npm:^8.0.0, ajv@npm:^8.17.1": version: 8.17.1 resolution: "ajv@npm:8.17.1" dependencies: @@ -5007,6 +5203,15 @@ __metadata: languageName: node linkType: hard +"bundle-name@npm:^4.1.0": + version: 4.1.0 + resolution: "bundle-name@npm:4.1.0" + dependencies: + run-applescript: "npm:^7.0.0" + checksum: 10c0/8e575981e79c2bcf14d8b1c027a3775c095d362d1382312f444a7c861b0e21513c0bd8db5bd2b16e50ba0709fa622d4eab6b53192d222120305e68359daece29 + languageName: node + linkType: hard + "busboy@npm:1.6.0": version: 1.6.0 resolution: "busboy@npm:1.6.0" @@ -5281,6 +5486,20 @@ __metadata: languageName: node linkType: hard +"chalk@npm:^5.3.0": + version: 5.3.0 + resolution: "chalk@npm:5.3.0" + checksum: 10c0/8297d436b2c0f95801103ff2ef67268d362021b8210daf8ddbe349695333eb3610a71122172ff3b0272f1ef2cf7cc2c41fdaa4715f52e49ffe04c56340feed09 + languageName: node + linkType: hard + +"change-case@npm:^5.4.4": + version: 5.4.4 + resolution: "change-case@npm:5.4.4" + checksum: 10c0/2a9c2b9c9ad6ab2491105aaf506db1a9acaf543a18967798dcce20926c6a173aa63266cb6189f3086e3c14bf7ae1f8ea4f96ecc466fcd582310efa00372f3734 + languageName: node + linkType: hard + "chardet@npm:^0.7.0": version: 0.7.0 resolution: "chardet@npm:0.7.0" @@ -5360,6 +5579,15 @@ __metadata: languageName: node linkType: hard +"cli-cursor@npm:^5.0.0": + version: 5.0.0 + resolution: "cli-cursor@npm:5.0.0" + dependencies: + restore-cursor: "npm:^5.0.0" + checksum: 10c0/7ec62f69b79f6734ab209a3e4dbdc8af7422d44d360a7cb1efa8a0887bbe466a6e625650c466fe4359aee44dbe2dc0b6994b583d40a05d0808a5cb193641d220 + languageName: node + linkType: hard + "cli-spinners@npm:2.6.1": version: 2.6.1 resolution: "cli-spinners@npm:2.6.1" @@ -5516,6 +5744,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^12.1.0": + version: 12.1.0 + resolution: "commander@npm:12.1.0" + checksum: 10c0/6e1996680c083b3b897bfc1cfe1c58dfbcd9842fd43e1aaf8a795fbc237f65efcc860a3ef457b318e73f29a4f4a28f6403c3d653d021d960e4632dd45bde54a9 + languageName: node + linkType: hard + "commander@npm:^2.20.0": version: 2.20.3 resolution: "commander@npm:2.20.3" @@ -6058,6 +6293,23 @@ __metadata: languageName: node linkType: hard +"default-browser-id@npm:^5.0.0": + version: 5.0.0 + resolution: "default-browser-id@npm:5.0.0" + checksum: 10c0/957fb886502594c8e645e812dfe93dba30ed82e8460d20ce39c53c5b0f3e2afb6ceaec2249083b90bdfbb4cb0f34e1f73fde3d68cac00becdbcfd894156b5ead + languageName: node + linkType: hard + +"default-browser@npm:^5.2.1": + version: 5.2.1 + resolution: "default-browser@npm:5.2.1" + dependencies: + bundle-name: "npm:^4.1.0" + default-browser-id: "npm:^5.0.0" + checksum: 10c0/73f17dc3c58026c55bb5538749597db31f9561c0193cd98604144b704a981c95a466f8ecc3c2db63d8bfd04fb0d426904834cfc91ae510c6aeb97e13c5167c4d + languageName: node + linkType: hard + "defaults@npm:^1.0.3": version: 1.0.4 resolution: "defaults@npm:1.0.4" @@ -6096,6 +6348,13 @@ __metadata: languageName: node linkType: hard +"define-lazy-prop@npm:^3.0.0": + version: 3.0.0 + resolution: "define-lazy-prop@npm:3.0.0" + checksum: 10c0/5ab0b2bf3fa58b3a443140bbd4cd3db1f91b985cc8a246d330b9ac3fc0b6a325a6d82bddc0b055123d745b3f9931afeea74a5ec545439a1630b9c8512b0eeb49 + languageName: node + linkType: hard + "define-properties@npm:^1.1.3, define-properties@npm:^1.1.4, define-properties@npm:^1.2.0": version: 1.2.0 resolution: "define-properties@npm:1.2.0" @@ -6287,6 +6546,13 @@ __metadata: languageName: node linkType: hard +"emoji-regex@npm:^10.3.0": + version: 10.4.0 + resolution: "emoji-regex@npm:10.4.0" + checksum: 10c0/a3fcedfc58bfcce21a05a5f36a529d81e88d602100145fcca3dc6f795e3c8acc4fc18fe773fbf9b6d6e9371205edb3afa2668ec3473fa2aa7fd47d2a9d46482d + languageName: node + linkType: hard + "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -7439,7 +7705,7 @@ __metadata: languageName: node linkType: hard -"external-editor@npm:^3.0.3": +"external-editor@npm:^3.0.3, external-editor@npm:^3.1.0": version: 3.1.0 resolution: "external-editor@npm:3.1.0" dependencies: @@ -7619,6 +7885,17 @@ __metadata: languageName: node linkType: hard +"find-up@npm:^7.0.0": + version: 7.0.0 + resolution: "find-up@npm:7.0.0" + dependencies: + locate-path: "npm:^7.2.0" + path-exists: "npm:^5.0.0" + unicorn-magic: "npm:^0.1.0" + checksum: 10c0/e6ee3e6154560bc0ab3bc3b7d1348b31513f9bdf49a5dd2e952495427d559fa48cdf33953e85a309a323898b43fa1bfbc8b80c880dfc16068384783034030008 + languageName: node + linkType: hard + "flat-cache@npm:^3.0.4": version: 3.0.4 resolution: "flat-cache@npm:3.0.4" @@ -7895,6 +8172,13 @@ __metadata: languageName: node linkType: hard +"get-east-asian-width@npm:^1.0.0": + version: 1.2.0 + resolution: "get-east-asian-width@npm:1.2.0" + checksum: 10c0/914b1e217cf38436c24b4c60b4c45289e39a45bf9e65ef9fd343c2815a1a02b8a0215aeec8bf9c07c516089004b6e3826332481f40a09529fcadbf6e579f286b + languageName: node + linkType: hard + "get-func-name@npm:^2.0.1, get-func-name@npm:^2.0.2": version: 2.0.2 resolution: "get-func-name@npm:2.0.2" @@ -9117,6 +9401,15 @@ __metadata: languageName: node linkType: hard +"is-docker@npm:^3.0.0": + version: 3.0.0 + resolution: "is-docker@npm:3.0.0" + bin: + is-docker: cli.js + checksum: 10c0/d2c4f8e6d3e34df75a5defd44991b6068afad4835bb783b902fa12d13ebdb8f41b2a199dcb0b5ed2cb78bfee9e4c0bbdb69c2d9646f4106464674d3e697a5856 + languageName: node + linkType: hard + "is-extglob@npm:^2.1.1": version: 2.1.1 resolution: "is-extglob@npm:2.1.1" @@ -9167,6 +9460,17 @@ __metadata: languageName: node linkType: hard +"is-inside-container@npm:^1.0.0": + version: 1.0.0 + resolution: "is-inside-container@npm:1.0.0" + dependencies: + is-docker: "npm:^3.0.0" + bin: + is-inside-container: cli.js + checksum: 10c0/a8efb0e84f6197e6ff5c64c52890fa9acb49b7b74fed4da7c95383965da6f0fa592b4dbd5e38a79f87fc108196937acdbcd758fcefc9b140e479b39ce1fcd1cd + languageName: node + linkType: hard + "is-interactive@npm:^1.0.0": version: 1.0.0 resolution: "is-interactive@npm:1.0.0" @@ -9174,6 +9478,13 @@ __metadata: languageName: node linkType: hard +"is-interactive@npm:^2.0.0": + version: 2.0.0 + resolution: "is-interactive@npm:2.0.0" + checksum: 10c0/801c8f6064f85199dc6bf99b5dd98db3282e930c3bc197b32f2c5b89313bb578a07d1b8a01365c4348c2927229234f3681eb861b9c2c92bee72ff397390fa600 + languageName: node + linkType: hard + "is-lambda@npm:^1.0.1": version: 1.0.1 resolution: "is-lambda@npm:1.0.1" @@ -9447,6 +9758,20 @@ __metadata: languageName: node linkType: hard +"is-unicode-supported@npm:^1.3.0": + version: 1.3.0 + resolution: "is-unicode-supported@npm:1.3.0" + checksum: 10c0/b8674ea95d869f6faabddc6a484767207058b91aea0250803cbf1221345cb0c56f466d4ecea375dc77f6633d248d33c47bd296fb8f4cdba0b4edba8917e83d8a + languageName: node + linkType: hard + +"is-unicode-supported@npm:^2.0.0": + version: 2.0.0 + resolution: "is-unicode-supported@npm:2.0.0" + checksum: 10c0/3013dfb8265fe9f9a0d1e9433fc4e766595631a8d85d60876c457b4bedc066768dab1477c553d02e2f626d88a4e019162706e04263c94d74994ef636a33b5f94 + languageName: node + linkType: hard + "is-weakmap@npm:^2.0.2": version: 2.0.2 resolution: "is-weakmap@npm:2.0.2" @@ -9491,6 +9816,15 @@ __metadata: languageName: node linkType: hard +"is-wsl@npm:^3.1.0": + version: 3.1.0 + resolution: "is-wsl@npm:3.1.0" + dependencies: + is-inside-container: "npm:^1.0.0" + checksum: 10c0/d3317c11995690a32c362100225e22ba793678fe8732660c6de511ae71a0ff05b06980cf21f98a6bf40d7be0e9e9506f859abe00a1118287d63e53d0a3d06947 + languageName: node + linkType: hard + "isarray@npm:^2.0.5": version: 2.0.5 resolution: "isarray@npm:2.0.5" @@ -10192,6 +10526,15 @@ __metadata: languageName: node linkType: hard +"locate-path@npm:^7.2.0": + version: 7.2.0 + resolution: "locate-path@npm:7.2.0" + dependencies: + p-locate: "npm:^6.0.0" + checksum: 10c0/139e8a7fe11cfbd7f20db03923cacfa5db9e14fa14887ea121345597472b4a63c1a42a8a5187defeeff6acf98fd568da7382aa39682d38f0af27433953a97751 + languageName: node + linkType: hard + "lodash.get@npm:^4.4.2": version: 4.4.2 resolution: "lodash.get@npm:4.4.2" @@ -10237,6 +10580,16 @@ __metadata: languageName: node linkType: hard +"log-symbols@npm:^6.0.0": + version: 6.0.0 + resolution: "log-symbols@npm:6.0.0" + dependencies: + chalk: "npm:^5.3.0" + is-unicode-supported: "npm:^1.3.0" + checksum: 10c0/36636cacedba8f067d2deb4aad44e91a89d9efb3ead27e1846e7b82c9a10ea2e3a7bd6ce28a7ca616bebc60954ff25c67b0f92d20a6a746bb3cc52c3701891f6 + languageName: node + linkType: hard + "loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" @@ -10579,6 +10932,13 @@ __metadata: languageName: node linkType: hard +"mimic-function@npm:^5.0.0": + version: 5.0.1 + resolution: "mimic-function@npm:5.0.1" + checksum: 10c0/f3d9464dd1816ecf6bdf2aec6ba32c0728022039d992f178237d8e289b48764fee4131319e72eedd4f7f094e22ded0af836c3187a7edc4595d28dd74368fd81d + languageName: node + linkType: hard + "min-indent@npm:^1.0.0": version: 1.0.1 resolution: "min-indent@npm:1.0.1" @@ -11715,6 +12075,27 @@ __metadata: languageName: node linkType: hard +"onetime@npm:^7.0.0": + version: 7.0.0 + resolution: "onetime@npm:7.0.0" + dependencies: + mimic-function: "npm:^5.0.0" + checksum: 10c0/5cb9179d74b63f52a196a2e7037ba2b9a893245a5532d3f44360012005c9cadb60851d56716ebff18a6f47129dab7168022445df47c2aff3b276d92585ed1221 + languageName: node + linkType: hard + +"open@npm:^10.1.0": + version: 10.1.0 + resolution: "open@npm:10.1.0" + dependencies: + default-browser: "npm:^5.2.1" + define-lazy-prop: "npm:^3.0.0" + is-inside-container: "npm:^1.0.0" + is-wsl: "npm:^3.1.0" + checksum: 10c0/c86d0b94503d5f735f674158d5c5d339c25ec2927562f00ee74590727292ed23e1b8d9336cb41ffa7e1fa4d3641d29b199b4ea37c78cb557d72b511743e90ebb + languageName: node + linkType: hard + "open@npm:^8.4.0": version: 8.4.2 resolution: "open@npm:8.4.2" @@ -11782,6 +12163,23 @@ __metadata: languageName: node linkType: hard +"ora@npm:^8.1.0": + version: 8.1.0 + resolution: "ora@npm:8.1.0" + dependencies: + chalk: "npm:^5.3.0" + cli-cursor: "npm:^5.0.0" + cli-spinners: "npm:^2.9.2" + is-interactive: "npm:^2.0.0" + is-unicode-supported: "npm:^2.0.0" + log-symbols: "npm:^6.0.0" + stdin-discarder: "npm:^0.2.2" + string-width: "npm:^7.2.0" + strip-ansi: "npm:^7.1.0" + checksum: 10c0/4ac9a6dd7fe915a354680f33ced21ee96d13d3c5ab0dc00b3c3ba9e3695ed141b1d045222990f5a71a9a91f801042a0b0d32e58dfc5509ff9b81efdd3fcf6339 + languageName: node + linkType: hard + "os-tmpdir@npm:~1.0.2": version: 1.0.2 resolution: "os-tmpdir@npm:1.0.2" @@ -11841,6 +12239,15 @@ __metadata: languageName: node linkType: hard +"p-limit@npm:^4.0.0": + version: 4.0.0 + resolution: "p-limit@npm:4.0.0" + dependencies: + yocto-queue: "npm:^1.0.0" + checksum: 10c0/a56af34a77f8df2ff61ddfb29431044557fcbcb7642d5a3233143ebba805fc7306ac1d448de724352861cb99de934bc9ab74f0d16fe6a5460bdbdf938de875ad + languageName: node + linkType: hard + "p-limit@npm:^5.0.0": version: 5.0.0 resolution: "p-limit@npm:5.0.0" @@ -11877,6 +12284,15 @@ __metadata: languageName: node linkType: hard +"p-locate@npm:^6.0.0": + version: 6.0.0 + resolution: "p-locate@npm:6.0.0" + dependencies: + p-limit: "npm:^4.0.0" + checksum: 10c0/d72fa2f41adce59c198270aa4d3c832536c87a1806e0f69dffb7c1a7ca998fb053915ca833d90f166a8c082d3859eabfed95f01698a3214c20df6bb8de046312 + languageName: node + linkType: hard + "p-map-series@npm:2.1.0": version: 2.1.0 resolution: "p-map-series@npm:2.1.0" @@ -12063,6 +12479,13 @@ __metadata: languageName: node linkType: hard +"path-exists@npm:^5.0.0": + version: 5.0.0 + resolution: "path-exists@npm:5.0.0" + checksum: 10c0/b170f3060b31604cde93eefdb7392b89d832dfbc1bed717c9718cbe0f230c1669b7e75f87e19901da2250b84d092989a0f9e44d2ef41deb09aa3ad28e691a40a + languageName: node + linkType: hard + "path-is-absolute@npm:^1.0.0": version: 1.0.1 resolution: "path-is-absolute@npm:1.0.1" @@ -13426,6 +13849,16 @@ __metadata: languageName: node linkType: hard +"restore-cursor@npm:^5.0.0": + version: 5.1.0 + resolution: "restore-cursor@npm:5.1.0" + dependencies: + onetime: "npm:^7.0.0" + signal-exit: "npm:^4.1.0" + checksum: 10c0/c2ba89131eea791d1b25205bdfdc86699767e2b88dee2a590b1a6caa51737deac8bad0260a5ded2f7c074b7db2f3a626bcf1fcf3cdf35974cbeea5e2e6764f60 + languageName: node + linkType: hard + "retry@npm:^0.12.0": version: 0.12.0 resolution: "retry@npm:0.12.0" @@ -13685,6 +14118,13 @@ __metadata: languageName: node linkType: hard +"run-applescript@npm:^7.0.0": + version: 7.0.0 + resolution: "run-applescript@npm:7.0.0" + checksum: 10c0/bd821bbf154b8e6c8ecffeaf0c33cebbb78eb2987476c3f6b420d67ab4c5301faa905dec99ded76ebb3a7042b4e440189ae6d85bbbd3fc6e8d493347ecda8bfe + languageName: node + linkType: hard + "run-async@npm:^2.4.0": version: 2.4.1 resolution: "run-async@npm:2.4.1" @@ -14339,6 +14779,13 @@ __metadata: languageName: node linkType: hard +"stdin-discarder@npm:^0.2.2": + version: 0.2.2 + resolution: "stdin-discarder@npm:0.2.2" + checksum: 10c0/c78375e82e956d7a64be6e63c809c7f058f5303efcaf62ea48350af072bacdb99c06cba39209b45a071c1acbd49116af30df1df9abb448df78a6005b72f10537 + languageName: node + linkType: hard + "stop-iteration-iterator@npm:^1.0.0": version: 1.0.0 resolution: "stop-iteration-iterator@npm:1.0.0" @@ -14391,6 +14838,17 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^7.2.0": + version: 7.2.0 + resolution: "string-width@npm:7.2.0" + dependencies: + emoji-regex: "npm:^10.3.0" + get-east-asian-width: "npm:^1.0.0" + strip-ansi: "npm:^7.1.0" + checksum: 10c0/eb0430dd43f3199c7a46dcbf7a0b34539c76fe3aa62763d0b0655acdcbdf360b3f66f3d58ca25ba0205f42ea3491fa00f09426d3b7d3040e506878fc7664c9b9 + languageName: node + linkType: hard + "string.prototype.includes@npm:^2.0.0": version: 2.0.0 resolution: "string.prototype.includes@npm:2.0.0" @@ -14573,7 +15031,7 @@ __metadata: languageName: node linkType: hard -"strip-ansi@npm:^7.0.1": +"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0": version: 7.1.0 resolution: "strip-ansi@npm:7.1.0" dependencies: @@ -15449,6 +15907,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:^5.5.4": + version: 5.5.4 + resolution: "typescript@npm:5.5.4" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/422be60f89e661eab29ac488c974b6cc0a660fb2228003b297c3d10c32c90f3bcffc1009b43876a082515a3c376b1eefcce823d6e78982e6878408b9a923199c + languageName: node + linkType: hard + "typescript@npm:^5.7.3": version: 5.7.3 resolution: "typescript@npm:5.7.3" @@ -15479,6 +15947,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A^5.5.4#optional!builtin": + version: 5.5.4 + resolution: "typescript@patch:typescript@npm%3A5.5.4#optional!builtin::version=5.5.4&hash=5adc0c" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/10dd9881baba22763de859e8050d6cb6e2db854197495c6f1929b08d1eb2b2b00d0b5d9b0bcee8472f1c3f4a7ef6a5d7ebe0cfd703f853aa5ae465b8404bc1ba + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A^5.7.3#optional!builtin": version: 5.7.3 resolution: "typescript@patch:typescript@npm%3A5.7.3#optional!builtin::version=5.7.3&hash=5adc0c" @@ -15543,6 +16021,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~6.19.2": + version: 6.19.8 + resolution: "undici-types@npm:6.19.8" + checksum: 10c0/078afa5990fba110f6824823ace86073b4638f1d5112ee26e790155f481f2a868cc3e0615505b6f4282bdf74a3d8caad715fd809e870c2bb0704e3ea6082f344 + languageName: node + linkType: hard + "undici-types@npm:~6.20.0": version: 6.20.0 resolution: "undici-types@npm:6.20.0" @@ -15550,6 +16035,13 @@ __metadata: languageName: node linkType: hard +"unicorn-magic@npm:^0.1.0": + version: 0.1.0 + resolution: "unicorn-magic@npm:0.1.0" + checksum: 10c0/e4ed0de05b0a05e735c7d8a2930881e5efcfc3ec897204d5d33e7e6247f4c31eac92e383a15d9a6bccb7319b4271ee4bea946e211bf14951fec6ff2cbbb66a92 + languageName: node + linkType: hard + "union@npm:~0.5.0": version: 0.5.0 resolution: "union@npm:0.5.0" From ff9f5218e3e008730b338d0f6ab249691ce9dd24 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Wed, 26 Feb 2025 16:06:43 +0100 Subject: [PATCH 2/6] style: fix lint errors --- packages/cli/commands/apps.ts | 6 +++--- packages/cli/commands/auth.ts | 13 +++++-------- packages/cli/commands/features.ts | 16 ++++++++-------- packages/cli/commands/init.ts | 8 ++++---- packages/cli/commands/new.ts | 6 +++--- packages/cli/index.ts | 2 +- packages/cli/utils/auth.ts | 2 +- packages/cli/utils/config.ts | 10 ++++++++-- vitest.workspace.js | 2 -- 9 files changed, 33 insertions(+), 32 deletions(-) diff --git a/packages/cli/commands/apps.ts b/packages/cli/commands/apps.ts index 8b4c4ee8..deaa01e8 100644 --- a/packages/cli/commands/apps.ts +++ b/packages/cli/commands/apps.ts @@ -14,11 +14,11 @@ export const listAppsAction = async () => { console.table(apps); } catch (error) { spinner.fail("Failed to list apps"); - handleError(error, "Apps List"); + void handleError(error, "Apps List"); } }; -export function registerAppCommands(program: Command) { +export function registerAppCommands(cli: Command) { const appsCommand = new Command("apps").description("Manage apps"); appsCommand @@ -26,5 +26,5 @@ export function registerAppCommands(program: Command) { .description("List all available apps") .action(listAppsAction); - program.addCommand(appsCommand); + cli.addCommand(appsCommand); } diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index 526773bf..65abec7f 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -13,7 +13,7 @@ export const loginAction = async () => { spinner.succeed(`Logged in to ${chalk.cyan(baseUrl)} successfully! 🎉`); } catch (error) { spinner.fail("Login failed"); - handleError(error, "Login"); + void handleError(error, "Login"); } }; @@ -25,15 +25,12 @@ export const logoutAction = async () => { spinner.succeed("Logged out successfully! 👋"); } catch (error) { spinner.fail("Logout failed"); - handleError(error, "Logout"); + void handleError(error, "Logout"); } }; -export function registerAuthCommands(program: Command) { - program.command("login").description("Login to Bucket").action(loginAction); +export function registerAuthCommands(cli: Command) { + cli.command("login").description("Login to Bucket").action(loginAction); - program - .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 f58b9c33..086b7846 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -41,14 +41,14 @@ export const createFeatureAction = async ( ); } catch (error) { spinner?.fail("Loading features failed"); - handleError(error, "Features Create"); + void handleError(error, "Features Create"); } try { if (!name) { name = await input({ message: "New feature name:", - validate: (input) => input.length > 0 || "Name is required", + validate: (text) => text.length > 0 || "Name is required", }); } @@ -68,7 +68,7 @@ export const createFeatureAction = async ( ); } catch (error) { spinner?.fail("Feature creation failed"); - handleError(error, "Features Create"); + void handleError(error, "Features Create"); } }; @@ -85,7 +85,7 @@ export const listFeaturesAction = async ({ appId }: AppIdArgs) => { console.table(features); } catch (error) { spinner.fail("Loading features failed"); - handleError(error, "Features List"); + void handleError(error, "Features List"); } }; @@ -106,7 +106,7 @@ export const generateTypesAction = async ({ ); } catch (error) { spinner?.fail("Loading features failed"); - handleError(error, "Features Types"); + void handleError(error, "Features Types"); } try { @@ -119,11 +119,11 @@ export const generateTypesAction = async ({ console.log(chalk.green(`Generated types for ${appId}.`)); } catch (error) { spinner?.fail("Type generation failed"); - handleError(error, "Features Types"); + void handleError(error, "Features Types"); } }; -export function registerFeatureCommands(program: Command) { +export function registerFeatureCommands(cli: Command) { const featuresCommand = new Command("features").description( "Manage features", ); @@ -165,5 +165,5 @@ export function registerFeatureCommands(program: Command) { ) .action(generateTypesAction); - program.addCommand(featuresCommand); + cli.addCommand(featuresCommand); } diff --git a/packages/cli/commands/init.ts b/packages/cli/commands/init.ts index 9fe8f0f4..dd5cdc65 100644 --- a/packages/cli/commands/init.ts +++ b/packages/cli/commands/init.ts @@ -36,7 +36,7 @@ export const initAction = async (args: InitArgs) => { spinner.succeed(`Loaded apps from ${chalk.cyan(baseUrl)}`); } catch (error) { spinner?.fail("Loading apps failed"); - handleError(error, "Initialization"); + void handleError(error, "Initialization"); } try { @@ -89,12 +89,12 @@ export const initAction = async (args: InitArgs) => { ); } catch (error) { spinner?.fail("Configuration creation failed"); - handleError(error, "Initialization"); + void handleError(error, "Initialization"); } }; -export function registerInitCommand(program: Command) { - program +export function registerInitCommand(cli: Command) { + cli .command("init") .description("Initialize a new Bucket configuration") .option(options.initOverride.flags, options.initOverride.description) diff --git a/packages/cli/commands/new.ts b/packages/cli/commands/new.ts index 7b1e8b4f..c9571cb2 100644 --- a/packages/cli/commands/new.ts +++ b/packages/cli/commands/new.ts @@ -38,12 +38,12 @@ export const newAction = async ( out, }); } catch (error) { - handleError(error, "New"); + void handleError(error, "New"); } }; -export function registerNewCommand(program: Command) { - program +export function registerNewCommand(cli: Command) { + cli .command("new") .description( "Initialize the Bucket CLI, authenticates, and creates a new feature", diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 07669503..0e80c1a8 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -49,4 +49,4 @@ async function main() { program.parse(process.argv); } -main(); +void main(); diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index b3835138..5e0faea4 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -136,7 +136,7 @@ export async function authenticateUser() { const address = server.address(); if (address && typeof address === "object") { const port = address.port; - open(loginUrl(baseUrl, port), { + void open(loginUrl(baseUrl, port), { newInstance: true, }); } diff --git a/packages/cli/utils/config.ts b/packages/cli/utils/config.ts index 4a007957..c024f936 100644 --- a/packages/cli/utils/config.ts +++ b/packages/cli/utils/config.ts @@ -98,7 +98,10 @@ export async function loadConfig() { const content = await readFile(configPath, "utf-8"); const parsed = JSON5.parse(content); if (!validateConfig(parsed)) { - handleError(new ConfigValidationError(validateConfig.errors), "Config"); + void handleError( + new ConfigValidationError(validateConfig.errors), + "Config", + ); } config = parsed; } catch { @@ -120,7 +123,10 @@ const getDefaultConfig = (): Partial => ({ export async function saveConfig(newConfig: Config, overwrite = false) { if (!validateConfig(newConfig)) { - handleError(new ConfigValidationError(validateConfig.errors), "Config"); + void handleError( + new ConfigValidationError(validateConfig.errors), + "Config", + ); } const defaults = getDefaultConfig(); diff --git a/vitest.workspace.js b/vitest.workspace.js index 686bcea3..1ff8757f 100644 --- a/vitest.workspace.js +++ b/vitest.workspace.js @@ -6,6 +6,4 @@ export default defineWorkspace([ "./packages/openfeature-node-provider/vite.config.js", "./packages/node-sdk/vite.config.js", "./packages/react-sdk/vite.config.mjs", - "./packages/tracking-sdk/vite.e2e.config.js", - "./packages/tracking-sdk/vite.config.js", ]); From e77b38a1130fa20c23d628ea7d699f4caa38ba2e Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Thu, 27 Feb 2025 15:19:46 +0100 Subject: [PATCH 3/6] fix: cleanup and comments --- packages/cli/commands/auth.ts | 2 +- packages/cli/commands/init.ts | 4 +- packages/cli/index.ts | 17 ++++++--- packages/cli/utils/auth.ts | 14 +++---- packages/cli/utils/config.ts | 70 ++++++++++++++++++++--------------- 5 files changed, 62 insertions(+), 45 deletions(-) diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index 65abec7f..786d86bb 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -9,7 +9,7 @@ export const loginAction = async () => { const { baseUrl } = program.opts(); const spinner = ora(`Logging in to ${chalk.cyan(baseUrl)}...`).start(); try { - await authenticateUser(); + await authenticateUser(baseUrl); spinner.succeed(`Logged in to ${chalk.cyan(baseUrl)} successfully! 🎉`); } catch (error) { spinner.fail("Login failed"); diff --git a/packages/cli/commands/init.ts b/packages/cli/commands/init.ts index dd5cdc65..1d56832f 100644 --- a/packages/cli/commands/init.ts +++ b/packages/cli/commands/init.ts @@ -5,7 +5,7 @@ import { relative } from "node:path"; import ora, { Ora } from "ora"; import { App, listApps } from "../services/bootstrap.js"; -import { getConfigPath, saveConfig } from "../utils/config.js"; +import { createConfigFile, getConfigPath } from "../utils/config.js"; import { chalkBrand, DEFAULT_TYPES_PATH } from "../utils/constants.js"; import { handleError } from "../utils/error.js"; import { options } from "../utils/options.js"; @@ -74,7 +74,7 @@ export const initAction = async (args: InitArgs) => { // Create config file spinner = ora("Creating configuration...").start(); - await saveConfig( + await createConfigFile( { baseUrl, apiUrl, diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 0e80c1a8..81f0aaed 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -1,4 +1,6 @@ -#!/usr/bin/env node --no-warnings=ExperimentalWarning +#!/usr/bin/env node +import { resolve } from "path"; +import { fileURLToPath } from "url"; import chalk from "chalk"; import { program } from "commander"; @@ -7,14 +9,14 @@ 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 { loadTokens } from "./utils/auth.js"; -import { getConfig, getConfigPath, loadConfig } from "./utils/config.js"; +import { loadTokenFile } from "./utils/auth.js"; +import { getConfig, getConfigPath, loadConfigFile } from "./utils/config.js"; import { options } from "./utils/options.js"; async function main() { // Must load tokens and config before anything else - await loadTokens(); - await loadConfig(); + await loadTokenFile(); + await loadConfigFile(); // Global options program.option(options.debug.flags, options.debug.description, false); @@ -49,4 +51,7 @@ async function main() { program.parse(process.argv); } -void main(); +// Run the main function if this file is run directly and not imported +if (resolve(fileURLToPath(import.meta.url)) === resolve(process.argv[1])) { + void main(); +} diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index 5e0faea4..ef709cec 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -8,7 +8,7 @@ import { AUTH_FILE, loginUrl } from "./constants.js"; let tokens: Map = new Map(); -export async function loadTokens() { +export async function loadTokenFile() { try { const content = await readFile(AUTH_FILE, "utf-8"); tokens = new Map( @@ -25,7 +25,7 @@ export async function loadTokens() { } } -async function saveTokens(newTokens: Map) { +async function saveTokenFile(newTokens: Map) { const content = Array.from(newTokens.entries()) .map(([baseUrl, token]) => `${baseUrl}|${token}`) .join("\n"); @@ -44,7 +44,7 @@ export async function setToken(baseUrl: string, newToken?: string) { } else { tokens.delete(baseUrl); } - await saveTokens(tokens); + await saveTokenFile(tokens); } function corsHeaders(baseUrl: string): Record { @@ -55,9 +55,8 @@ function corsHeaders(baseUrl: string): Record { }; } -export async function authenticateUser() { +export async function authenticateUser(baseUrl: string) { return new Promise((resolve, reject) => { - const { baseUrl } = program.opts(); let isResolved = false; const server = http.createServer(async (req, res) => { @@ -148,12 +147,13 @@ export async function authRequest>( options?: RequestInit, retryCount = 0, ): Promise { + // todo: rework to remove reliance on program.opts() when used in non-cli contexts const { baseUrl, apiUrl } = program.opts(); const token = getToken(baseUrl); const resolvedApiUrl = apiUrl ?? `${baseUrl}/api`; if (!token) { - await authenticateUser(); + await authenticateUser(baseUrl); return authRequest(url, options); } @@ -169,7 +169,7 @@ export async function authRequest>( if (response.status === 401) { await setToken(baseUrl, undefined); if (retryCount < 1) { - await authenticateUser(); + await authenticateUser(baseUrl); return authRequest(url, options, retryCount + 1); } } diff --git a/packages/cli/utils/config.ts b/packages/cli/utils/config.ts index c024f936..317ff72d 100644 --- a/packages/cli/utils/config.ts +++ b/packages/cli/utils/config.ts @@ -14,12 +14,6 @@ import { } from "./constants.js"; import { handleError } from "./error.js"; -// https://github.com/nodejs/node/issues/51347#issuecomment-2111337854 -const schema = createRequire(import.meta.url)("../../schema.json"); - -const ajv = new Ajv(); -const validateConfig = ajv.compile(schema); - export const keyFormats = [ "custom", "pascalCase", @@ -32,6 +26,21 @@ export const keyFormats = [ export type KeyFormat = (typeof keyFormats)[number]; +type Config = { + $schema?: string; + baseUrl?: string; + apiUrl?: string; + appId: string; + typesPath?: string; + keyFormat?: KeyFormat; +}; + +// https://github.com/nodejs/node/issues/51347#issuecomment-2111337854 +// todo: rework this as it's too fragile and breaks if path isn't exactly right +const schema = createRequire(import.meta.url)("../../schema.json"); +const ajv = new Ajv(); +const validateConfig = ajv.compile(schema); + class ConfigValidationError extends Error { constructor(errors: typeof validateConfig.errors) { const messages = errors @@ -48,15 +57,6 @@ class ConfigValidationError extends Error { } } -type Config = { - $schema?: string; - baseUrl?: string; - apiUrl?: string; - appId: string; - typesPath?: string; - keyFormat?: KeyFormat; -}; - let config: Config | undefined; let configPath: string | undefined; let projectPath: string | undefined; @@ -86,42 +86,53 @@ export function getProjectPath() { return projectPath ?? process.cwd(); } +/** + * Returns default configuration values + */ +const getDefaultConfig = (): Partial => ({ + baseUrl: DEFAULT_BASE_URL, + apiUrl: DEFAULT_API_URL, + typesPath: DEFAULT_TYPES_PATH, + keyFormat: "custom", +}); + /** * Load the configuration file. */ -export async function loadConfig() { +export async function loadConfigFile() { try { const packageJSONPath = await findUp("package.json"); configPath = await findUp(CONFIG_FILE_NAME); projectPath = dirname(configPath ?? packageJSONPath ?? process.cwd()); + if (!configPath) return; + const content = await readFile(configPath, "utf-8"); const parsed = JSON5.parse(content); + if (!validateConfig(parsed)) { void handleError( new ConfigValidationError(validateConfig.errors), "Config", ); } + config = parsed; } catch { // No config file found } } +export function updateConfig(newConfig: Partial) { + config = { ...config, ...newConfig } as Config; +} + /** * Create a new config file with initial values. * @param newConfig The configuration object to write * @param overwrite If true, overwrites existing config file. Defaults to false */ -const getDefaultConfig = (): Partial => ({ - baseUrl: DEFAULT_BASE_URL, - apiUrl: DEFAULT_API_URL, - typesPath: DEFAULT_TYPES_PATH, - keyFormat: "custom", -}); - -export async function saveConfig(newConfig: Config, overwrite = false) { +export async function createConfigFile(newConfig: Config, overwrite = false) { if (!validateConfig(newConfig)) { void handleError( new ConfigValidationError(validateConfig.errors), @@ -146,16 +157,17 @@ export async function saveConfig(newConfig: Config, overwrite = false) { const configJSON = JSON.stringify(configWithoutDefaults, null, 2); + if (configPath && !overwrite) { + throw new Error("Config file already exists"); + } + if (configPath) { - if (!overwrite) { - throw new Error("Config file already exists"); - } await writeFile(configPath, configJSON); - config = newConfig; } else { // Write to the nearest package.json directory configPath = join(getProjectPath(), CONFIG_FILE_NAME); await writeFile(configPath, configJSON); - config = newConfig; } + + config = newConfig; } From 435d0d6f44eb35a9f912b392dac98af096645b72 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Fri, 28 Feb 2025 13:28:37 +0100 Subject: [PATCH 4/6] feat: rewrite to use config store to no be reliant on program for options --- packages/cli/commands/apps.ts | 7 +- packages/cli/commands/auth.ts | 14 +- packages/cli/commands/features.ts | 96 ++++++------ packages/cli/commands/init.ts | 44 +++--- packages/cli/commands/new.ts | 63 +++----- packages/cli/index.ts | 35 ++--- packages/cli/services/bootstrap.ts | 2 +- packages/cli/stores/auth.ts | 53 +++++++ packages/cli/stores/config.ts | 162 +++++++++++++++++++ packages/cli/utils/auth.ts | 59 +------ packages/cli/utils/config.ts | 173 --------------------- packages/cli/utils/{error.ts => errors.ts} | 26 ++++ packages/cli/utils/options.ts | 86 +++++----- 13 files changed, 408 insertions(+), 412 deletions(-) create mode 100644 packages/cli/stores/auth.ts create mode 100644 packages/cli/stores/config.ts delete mode 100644 packages/cli/utils/config.ts rename packages/cli/utils/{error.ts => errors.ts} (54%) diff --git a/packages/cli/commands/apps.ts b/packages/cli/commands/apps.ts index deaa01e8..ee91edc8 100644 --- a/packages/cli/commands/apps.ts +++ b/packages/cli/commands/apps.ts @@ -1,12 +1,13 @@ import chalk from "chalk"; -import { Command, program } from "commander"; +import { Command } from "commander"; import ora from "ora"; import { listApps } from "../services/bootstrap.js"; -import { handleError } from "../utils/error.js"; +import { configStore } from "../stores/config.js"; +import { handleError } from "../utils/errors.js"; export const listAppsAction = async () => { - const { baseUrl } = program.opts(); + const baseUrl = configStore.getConfig("baseUrl"); const spinner = ora(`Loading apps from ${chalk.cyan(baseUrl)}...`).start(); try { const apps = await listApps(); diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index 786d86bb..6b207573 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -1,12 +1,14 @@ import chalk from "chalk"; -import { Command, program } from "commander"; +import { Command } from "commander"; import ora from "ora"; -import { authenticateUser, setToken } from "../utils/auth.js"; -import { handleError } from "../utils/error.js"; +import { authStore } from "../stores/auth.js"; +import { configStore } from "../stores/config.js"; +import { authenticateUser } from "../utils/auth.js"; +import { handleError } from "../utils/errors.js"; export const loginAction = async () => { - const { baseUrl } = program.opts(); + const baseUrl = configStore.getConfig("baseUrl"); const spinner = ora(`Logging in to ${chalk.cyan(baseUrl)}...`).start(); try { await authenticateUser(baseUrl); @@ -18,10 +20,10 @@ export const loginAction = async () => { }; export const logoutAction = async () => { - const { baseUrl } = program.opts(); + const baseUrl = configStore.getConfig("baseUrl"); const spinner = ora("Logging out...").start(); try { - await setToken(baseUrl, undefined); + await authStore.setToken(baseUrl, undefined); spinner.succeed("Logged out successfully! 👋"); } catch (error) { spinner.fail("Logout failed"); diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index 086b7846..fdbfa88b 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -1,36 +1,35 @@ import { input } from "@inquirer/prompts"; import chalk from "chalk"; -import { Command, program } from "commander"; +import { Command } from "commander"; import { mkdir, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join } from "node:path"; import ora, { Ora } from "ora"; import { createFeature, listFeatures } from "../services/features.js"; -import { getConfig, getProjectPath } from "../utils/config.js"; -import { handleError } from "../utils/error.js"; +import { configStore } from "../stores/config.js"; +import { handleError, MissingAppIdError } from "../utils/errors.js"; import { genDTS, genFeatureKey, KeyFormatPatterns } from "../utils/gen.js"; -import { options } from "../utils/options.js"; - -type AppIdArgs = { - appId: string; -}; - -type CreateFeatureArgs = AppIdArgs & { +import { + appIdOption, + featureKeyOption, + featureNameArgument, + typesOutOption, +} from "../utils/options.js"; + +type CreateFeatureArgs = { key?: string; }; -type GenerateTypesArgs = AppIdArgs & { - out: string; -}; - export const createFeatureAction = async ( name: string | undefined, - { appId, key }: CreateFeatureArgs, + { key }: CreateFeatureArgs, ) => { - const { baseUrl } = program.opts(); + const { baseUrl, appId } = configStore.getConfig(); let spinner: Ora | undefined; let existingKeys: string[] = []; + try { + if (!appId) throw new MissingAppIdError(); spinner = ora( `Loading features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, ).start(); @@ -42,6 +41,7 @@ export const createFeatureAction = async ( } catch (error) { spinner?.fail("Loading features failed"); void handleError(error, "Features Create"); + return; } try { @@ -53,7 +53,7 @@ export const createFeatureAction = async ( } if (!key) { - const keyFormat = getConfig("keyFormat") ?? "custom"; + const keyFormat = configStore.getConfig("keyFormat") ?? "custom"; key = await input({ message: "New feature key:", default: genFeatureKey(name, keyFormat, existingKeys), @@ -72,31 +72,32 @@ export const createFeatureAction = async ( } }; -export const listFeaturesAction = async ({ appId }: AppIdArgs) => { - const { baseUrl } = program.opts(); - const spinner = ora( - `Loading features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, - ).start(); +export const listFeaturesAction = async () => { + const { baseUrl, appId } = configStore.getConfig(); + let spinner: Ora | undefined; + try { + if (!appId) throw new MissingAppIdError(); + spinner = ora( + `Loading features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, + ).start(); const features = await listFeatures(appId); spinner.succeed( `Loaded features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}`, ); console.table(features); } catch (error) { - spinner.fail("Loading features failed"); + spinner?.fail("Loading features failed"); void handleError(error, "Features List"); } }; -export const generateTypesAction = async ({ - appId, - out, -}: GenerateTypesArgs) => { - const { baseUrl } = program.opts(); +export const generateTypesAction = async () => { + const { baseUrl, appId, typesPath } = configStore.getConfig(); let spinner: Ora | undefined; let featureKeys: string[] = []; try { + if (!appId) throw new MissingAppIdError(); spinner = ora( `Loading features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, ).start(); @@ -107,12 +108,15 @@ export const generateTypesAction = async ({ } catch (error) { spinner?.fail("Loading features failed"); void handleError(error, "Features Types"); + return; } try { spinner = ora("Generating feature types...").start(); const types = genDTS(featureKeys); - const outPath = isAbsolute(out) ? out : join(getProjectPath(), out); + const outPath = isAbsolute(typesPath) + ? typesPath + : join(configStore.getProjectPath(), typesPath); await mkdir(dirname(outPath), { recursive: true }); await writeFile(outPath, types); spinner.succeed("Generated feature types successfully"); @@ -131,39 +135,29 @@ export function registerFeatureCommands(cli: Command) { featuresCommand .command("create") .description("Create a new feature") - .requiredOption( - options.appId.flags, - options.appId.description, - getConfig(options.appId.configKey), - ) - .option(options.featureKey.flags, options.featureKey.description) - .argument(options.featureName.flags, options.featureName.description) + .addOption(appIdOption) + .addOption(featureKeyOption) + .addArgument(featureNameArgument) .action(createFeatureAction); featuresCommand .command("list") .description("List all features") - .requiredOption( - options.appId.flags, - options.appId.description, - getConfig(options.appId.configKey), - ) + .addOption(appIdOption) .action(listFeaturesAction); featuresCommand .command("types") .description("Generate feature types") - .requiredOption( - options.appId.flags, - options.appId.description, - getConfig(options.appId.configKey), - ) - .requiredOption( - options.typesOut.flags, - options.typesOut.description, - getConfig(options.typesOut.configKey) ?? options.typesOut.fallback, - ) + .addOption(appIdOption) + .addOption(typesOutOption) .action(generateTypesAction); + // Update the config with the cli override values + featuresCommand.hook("preAction", (command) => { + const { appId, out } = command.opts(); + configStore.setConfig({ appId, typesPath: out }); + }); + cli.addCommand(featuresCommand); } diff --git a/packages/cli/commands/init.ts b/packages/cli/commands/init.ts index 1d56832f..7bba51e8 100644 --- a/packages/cli/commands/init.ts +++ b/packages/cli/commands/init.ts @@ -1,26 +1,26 @@ import { input, select } from "@inquirer/prompts"; import chalk from "chalk"; -import { Command, program } from "commander"; +import { Command } from "commander"; import { relative } from "node:path"; import ora, { Ora } from "ora"; import { App, listApps } from "../services/bootstrap.js"; -import { createConfigFile, getConfigPath } from "../utils/config.js"; +import { configStore } from "../stores/config.js"; import { chalkBrand, DEFAULT_TYPES_PATH } from "../utils/constants.js"; -import { handleError } from "../utils/error.js"; -import { options } from "../utils/options.js"; +import { handleError } from "../utils/errors.js"; +import { initOverrideOption } from "../utils/options.js"; type InitArgs = { force?: boolean; }; -export const initAction = async (args: InitArgs) => { +export const initAction = async (args: InitArgs = {}) => { let spinner: Ora | undefined; let apps: App[] = []; try { - // Check if already initialized - const configPath = getConfigPath(); + // Check if config already exists + const configPath = configStore.getConfigPath(); if (configPath && !args.force) { throw new Error( "Bucket is already initialized. Use --force to overwrite.", @@ -28,7 +28,7 @@ export const initAction = async (args: InitArgs) => { } console.log(chalkBrand("\nWelcome to Bucket! 🪣\n")); - const { baseUrl } = program.opts(); + const baseUrl = configStore.getConfig("baseUrl"); // Load apps spinner = ora(`Loading apps from ${chalk.cyan(baseUrl)}...`).start(); @@ -37,15 +37,17 @@ export const initAction = async (args: InitArgs) => { } catch (error) { spinner?.fail("Loading apps failed"); void handleError(error, "Initialization"); + return; } try { - const { baseUrl, apiUrl } = program.opts(); let appId: string | undefined; const nonDemoApps = apps.filter((app) => !app.demo); // If there is only one non-demo app, select it automatically - if (nonDemoApps.length === 1) { + if (apps.length === 0) { + throw new Error("You don't have any apps yet. Please create one."); + } else if (nonDemoApps.length === 1) { appId = nonDemoApps[0].id; console.log( chalk.gray( @@ -72,20 +74,18 @@ export const initAction = async (args: InitArgs) => { default: DEFAULT_TYPES_PATH, }); + // Update config + configStore.setConfig({ + appId, + keyFormat, + typesPath, + }); + // Create config file spinner = ora("Creating configuration...").start(); - await createConfigFile( - { - baseUrl, - apiUrl, - appId, - typesPath, - keyFormat, - }, - args.force, - ); + await configStore.saveConfigFile(args.force); spinner.succeed( - `Configuration created at ${chalk.cyan(relative(process.cwd(), getConfigPath()!))}`, + `Configuration created at ${chalk.cyan(relative(process.cwd(), configStore.getConfigPath()!))}`, ); } catch (error) { spinner?.fail("Configuration creation failed"); @@ -97,6 +97,6 @@ export function registerInitCommand(cli: Command) { cli .command("init") .description("Initialize a new Bucket configuration") - .option(options.initOverride.flags, options.initOverride.description) + .addOption(initOverrideOption) .action(initAction); } diff --git a/packages/cli/commands/new.ts b/packages/cli/commands/new.ts index c9571cb2..3aa4fb36 100644 --- a/packages/cli/commands/new.ts +++ b/packages/cli/commands/new.ts @@ -1,10 +1,14 @@ import { Command } from "commander"; import { findUp } from "find-up"; -import { getConfig } from "../utils/config.js"; +import { configStore } from "../stores/config.js"; import { CONFIG_FILE_NAME } from "../utils/constants.js"; -import { handleError } from "../utils/error.js"; -import { options } from "../utils/options.js"; +import { + appIdOption, + featureKeyOption, + featureNameArgument, + typesOutOption, +} from "../utils/options.js"; import { createFeatureAction, generateTypesAction } from "./features.js"; import { initAction } from "./init.js"; @@ -15,31 +19,14 @@ type NewArgs = { key?: string; }; -export const newAction = async ( - name: string | undefined, - { appId, out, key }: NewArgs, -) => { - try { - if (!(await findUp(CONFIG_FILE_NAME))) { - await initAction({}); - } - appId = appId ?? getConfig("appId"); - if (!appId) { - throw new Error( - "App ID is required. Please provide it with --appId or in the config file.", - ); - } - await createFeatureAction(name, { - appId, - key, - }); - await generateTypesAction({ - appId, - out, - }); - } catch (error) { - void handleError(error, "New"); +export const newAction = async (name: string | undefined, { key }: NewArgs) => { + if (!(await findUp(CONFIG_FILE_NAME))) { + await initAction(); } + await createFeatureAction(name, { + key, + }); + await generateTypesAction(); }; export function registerNewCommand(cli: Command) { @@ -48,17 +35,15 @@ export function registerNewCommand(cli: Command) { .description( "Initialize the Bucket CLI, authenticates, and creates a new feature", ) - .option( - options.appId.flags, - options.appId.description, - getConfig(options.appId.configKey), - ) - .option( - options.typesOut.flags, - options.typesOut.description, - getConfig(options.typesOut.configKey) ?? options.typesOut.fallback, - ) - .option(options.featureKey.flags, options.featureKey.description) - .argument(options.featureName.flags, options.featureName.description) + .addOption(appIdOption) + .addOption(typesOutOption) + .addOption(featureKeyOption) + .addArgument(featureNameArgument) .action(newAction); + + // Update the config with the cli override values + cli.hook("preAction", (command) => { + const { appId, out } = command.opts(); + configStore.setConfig({ appId, typesPath: out }); + }); } diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 81f0aaed..00aae282 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -9,35 +9,32 @@ 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 { loadTokenFile } from "./utils/auth.js"; -import { getConfig, getConfigPath, loadConfigFile } from "./utils/config.js"; -import { options } from "./utils/options.js"; +import { authStore } from "./stores/auth.js"; +import { configStore } from "./stores/config.js"; +import { apiUrlOption, baseUrlOption, debugOption } from "./utils/options.js"; async function main() { // Must load tokens and config before anything else - await loadTokenFile(); - await loadConfigFile(); + await authStore.initialize(); + await configStore.initialize(); // Global options - program.option(options.debug.flags, options.debug.description, false); - program.requiredOption( - options.baseUrl.flags, - options.baseUrl.description, - getConfig(options.baseUrl.configKey) ?? options.baseUrl.fallback, - ); - program.option( - options.apiUrl.flags, - options.apiUrl.description, - getConfig(options.apiUrl.configKey), - ); + program.addOption(debugOption); + program.addOption(baseUrlOption); + program.addOption(apiUrlOption); // Pre-action hook program.hook("preAction", () => { - const { debug } = program.opts(); + const { debug, baseUrl, apiUrl } = program.opts(); + configStore.setConfig({ baseUrl, apiUrl: apiUrl ?? baseUrl + "/api" }); + if (debug) { console.debug(chalk.cyan("\nDebug mode enabled")); - console.debug("Reading config from", chalk.green(getConfigPath())); - console.table(getConfig()); + console.debug( + "Reading config from", + chalk.green(configStore.getConfigPath()), + ); + console.table(configStore.getConfig()); } }); diff --git a/packages/cli/services/bootstrap.ts b/packages/cli/services/bootstrap.ts index 16045e7b..054f213d 100644 --- a/packages/cli/services/bootstrap.ts +++ b/packages/cli/services/bootstrap.ts @@ -1,5 +1,5 @@ +import { KeyFormat } from "../stores/config.js"; import { authRequest } from "../utils/auth.js"; -import { KeyFormat } from "../utils/config.js"; type BootstrapResponse = { org: { diff --git a/packages/cli/stores/auth.ts b/packages/cli/stores/auth.ts new file mode 100644 index 00000000..919fd2a0 --- /dev/null +++ b/packages/cli/stores/auth.ts @@ -0,0 +1,53 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +import { AUTH_FILE } from "../utils/constants.js"; + +class AuthStore { + protected tokens: Map = new Map(); + + async initialize() { + await this.loadTokenFile(); + } + + protected async loadTokenFile() { + try { + const content = await readFile(AUTH_FILE, "utf-8"); + this.tokens = new Map( + content + .split("\n") + .filter(Boolean) + .map((line) => { + const [baseUrl, token] = line.split("|"); + return [baseUrl, token]; + }), + ); + } catch { + // No tokens file found + } + } + + protected async saveTokenFile(newTokens: Map) { + 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); + } + + async setToken(baseUrl: string, newToken?: string) { + if (newToken) { + this.tokens.set(baseUrl, newToken); + } else { + this.tokens.delete(baseUrl); + } + await this.saveTokenFile(this.tokens); + } +} + +export const authStore = new AuthStore(); diff --git a/packages/cli/stores/config.ts b/packages/cli/stores/config.ts new file mode 100644 index 00000000..88d284dc --- /dev/null +++ b/packages/cli/stores/config.ts @@ -0,0 +1,162 @@ +import { Ajv, ValidateFunction } from "ajv"; +import { findUp } from "find-up"; +import JSON5 from "json5"; +import { readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { + CONFIG_FILE_NAME, + DEFAULT_API_URL, + DEFAULT_BASE_URL, + DEFAULT_TYPES_PATH, + SCHEMA_URL, +} from "../utils/constants.js"; +import { ConfigValidationError, handleError } from "../utils/errors.js"; + +export const keyFormats = [ + "custom", + "pascalCase", + "camelCase", + "snakeCaseUpper", + "snakeCaseLower", + "kebabCaseUpper", + "kebabCaseLower", +] as const; + +export type KeyFormat = (typeof keyFormats)[number]; + +type Config = { + $schema: string; + baseUrl: string; + apiUrl: string; + appId: string | undefined; + typesPath: string; + keyFormat: KeyFormat; +}; + +const defaultConfig: Config = { + $schema: SCHEMA_URL, + baseUrl: DEFAULT_BASE_URL, + apiUrl: DEFAULT_API_URL, + appId: undefined, + typesPath: DEFAULT_TYPES_PATH, + keyFormat: "custom", +}; + +class ConfigStore { + protected config: Config = { ...defaultConfig }; + protected configPath: string | undefined; + protected projectPath: string | undefined; + protected validateConfig: ValidateFunction | undefined; + + async initialize() { + await this.createValidator(); + await this.loadConfigFile(); + } + + protected async createValidator() { + try { + const schemaPath = await findUp("schema.json"); + if (!schemaPath) return; + const content = await readFile(schemaPath, "utf-8"); + const parsed = JSON5.parse(content); + const ajv = new Ajv(); + this.validateConfig = ajv.compile(parsed); + } catch { + void 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; + } + + try { + const packageJSONPath = await findUp("package.json"); + this.configPath = await findUp(CONFIG_FILE_NAME); + this.projectPath = dirname( + this.configPath ?? packageJSONPath ?? process.cwd(), + ); + + if (!this.configPath) return; + + const content = await readFile(this.configPath, "utf-8"); + const parsed = JSON5.parse(content); + + if (!this.validateConfig!(parsed)) { + void handleError( + new ConfigValidationError(this.validateConfig!.errors), + "Config", + ); + } + + this.config = parsed; + } catch { + // No config file found + } + } + + /** + * Create a new config file with initial values. + * @param overwrite If true, overwrites existing config file. Defaults to false + */ + async saveConfigFile(overwrite = false) { + const configWithoutDefaults: Partial = { + ...this.config, + }; + + // Only include non-default values and $schema + for (const untypedKey in configWithoutDefaults) { + const key = untypedKey as keyof Config; + if ( + !["$schema"].includes(key) && + configWithoutDefaults[key] === defaultConfig[key] + ) { + delete configWithoutDefaults[key]; + } + } + + const configJSON = JSON.stringify(configWithoutDefaults, null, 2); + + if (this.configPath && !overwrite) { + throw new Error("Config file already exists"); + } + + if (this.configPath) { + await writeFile(this.configPath, configJSON); + } else { + // Write to the project path + const packageJSONPath = await findUp("package.json"); + this.projectPath = dirname(packageJSONPath ?? process.cwd()); + this.configPath = join(this.projectPath, CONFIG_FILE_NAME); + await writeFile(this.configPath, configJSON); + } + } + + getConfig(): Config; + getConfig(key: K): Config[K]; + getConfig(key?: K) { + return key ? this.config?.[key] : this.config; + } + + getConfigPath() { + return this.configPath; + } + + getProjectPath() { + return this.projectPath ?? process.cwd(); + } + + setConfig(newConfig: Partial) { + // Update the config with new values skipping undefined values + for (const untypedKey in newConfig) { + const key = untypedKey as keyof Config; + if (newConfig[key] === undefined) continue; + (this.config as any)[key] = newConfig[key]; + } + } +} + +export const configStore = new ConfigStore(); diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index ef709cec..09777677 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -1,51 +1,10 @@ -import { mkdir, readFile, writeFile } from "fs/promises"; import http from "http"; -import { dirname } from "path"; -import { program } from "commander"; import open from "open"; -import { AUTH_FILE, loginUrl } from "./constants.js"; - -let tokens: Map = new Map(); - -export async function loadTokenFile() { - try { - const content = await readFile(AUTH_FILE, "utf-8"); - tokens = new Map( - content - .split("\n") - .filter(Boolean) - .map((line) => { - const [baseUrl, token] = line.split("|"); - return [baseUrl, token]; - }), - ); - } catch { - // No tokens file found - } -} - -async function saveTokenFile(newTokens: Map) { - 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); - tokens = newTokens; -} +import { authStore } from "../stores/auth.js"; +import { configStore } from "../stores/config.js"; -export function getToken(baseUrl: string) { - return tokens.get(baseUrl); -} - -export async function setToken(baseUrl: string, newToken?: string) { - if (newToken) { - tokens.set(baseUrl, newToken); - } else { - tokens.delete(baseUrl); - } - await saveTokenFile(tokens); -} +import { loginUrl } from "./constants.js"; function corsHeaders(baseUrl: string): Record { return { @@ -94,7 +53,7 @@ export async function authenticateUser(baseUrl: string) { res.end(JSON.stringify({ result: "success" })); try { - await setToken(baseUrl, token); + await authStore.setToken(baseUrl, token); cleanupAndResolve(token); } catch (error) { cleanupAndReject( @@ -147,17 +106,15 @@ export async function authRequest>( options?: RequestInit, retryCount = 0, ): Promise { - // todo: rework to remove reliance on program.opts() when used in non-cli contexts - const { baseUrl, apiUrl } = program.opts(); - const token = getToken(baseUrl); - const resolvedApiUrl = apiUrl ?? `${baseUrl}/api`; + const { baseUrl, apiUrl } = configStore.getConfig(); + const token = authStore.getToken(baseUrl); if (!token) { await authenticateUser(baseUrl); return authRequest(url, options); } - const response = await fetch(`${resolvedApiUrl}${url}`, { + const response = await fetch(`${apiUrl}${url}`, { ...options, headers: { ...options?.headers, @@ -167,7 +124,7 @@ export async function authRequest>( if (!response.ok) { if (response.status === 401) { - await setToken(baseUrl, undefined); + await authStore.setToken(baseUrl, undefined); if (retryCount < 1) { await authenticateUser(baseUrl); return authRequest(url, options, retryCount + 1); diff --git a/packages/cli/utils/config.ts b/packages/cli/utils/config.ts deleted file mode 100644 index 317ff72d..00000000 --- a/packages/cli/utils/config.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { readFile, writeFile } from "fs/promises"; -import { createRequire } from "module"; -import { dirname, join } from "path"; -import { Ajv } from "ajv"; -import { findUp } from "find-up"; -import JSON5 from "json5"; - -import { - CONFIG_FILE_NAME, - DEFAULT_API_URL, - DEFAULT_BASE_URL, - DEFAULT_TYPES_PATH, - SCHEMA_URL, -} from "./constants.js"; -import { handleError } from "./error.js"; - -export const keyFormats = [ - "custom", - "pascalCase", - "camelCase", - "snakeCaseUpper", - "snakeCaseLower", - "kebabCaseUpper", - "kebabCaseLower", -] as const; - -export type KeyFormat = (typeof keyFormats)[number]; - -type Config = { - $schema?: string; - baseUrl?: string; - apiUrl?: string; - appId: string; - typesPath?: string; - keyFormat?: KeyFormat; -}; - -// https://github.com/nodejs/node/issues/51347#issuecomment-2111337854 -// todo: rework this as it's too fragile and breaks if path isn't exactly right -const schema = createRequire(import.meta.url)("../../schema.json"); -const ajv = new Ajv(); -const validateConfig = ajv.compile(schema); - -class ConfigValidationError extends Error { - constructor(errors: typeof validateConfig.errors) { - const messages = errors - ?.map((e) => { - const path = e.instancePath || "config"; - const value = e.params?.allowedValues - ? `: ${e.params.allowedValues.join(", ")}` - : ""; - return `${path}: ${e.message}${value}`; - }) - .join("\n"); - super(messages); - this.name = "ConfigValidationError"; - } -} - -let config: Config | undefined; -let configPath: string | undefined; -let projectPath: string | undefined; - -/** - * Instantly return a specified key's value or the entire config object. - */ -export function getConfig(): Config | undefined; -export function getConfig( - key: K, -): Config[K] | undefined; -export function getConfig(key?: K) { - return key ? config?.[key] : config; -} - -/** - * Return the path to the config file. - */ -export function getConfigPath() { - return configPath; -} - -/** - * Return the path to the project root. - */ -export function getProjectPath() { - return projectPath ?? process.cwd(); -} - -/** - * Returns default configuration values - */ -const getDefaultConfig = (): Partial => ({ - baseUrl: DEFAULT_BASE_URL, - apiUrl: DEFAULT_API_URL, - typesPath: DEFAULT_TYPES_PATH, - keyFormat: "custom", -}); - -/** - * Load the configuration file. - */ -export async function loadConfigFile() { - try { - const packageJSONPath = await findUp("package.json"); - configPath = await findUp(CONFIG_FILE_NAME); - projectPath = dirname(configPath ?? packageJSONPath ?? process.cwd()); - - if (!configPath) return; - - const content = await readFile(configPath, "utf-8"); - const parsed = JSON5.parse(content); - - if (!validateConfig(parsed)) { - void handleError( - new ConfigValidationError(validateConfig.errors), - "Config", - ); - } - - config = parsed; - } catch { - // No config file found - } -} - -export function updateConfig(newConfig: Partial) { - config = { ...config, ...newConfig } as Config; -} - -/** - * Create a new config file with initial values. - * @param newConfig The configuration object to write - * @param overwrite If true, overwrites existing config file. Defaults to false - */ -export async function createConfigFile(newConfig: Config, overwrite = false) { - if (!validateConfig(newConfig)) { - void handleError( - new ConfigValidationError(validateConfig.errors), - "Config", - ); - } - - const defaults = getDefaultConfig(); - const configWithoutDefaults: Config = { - $schema: SCHEMA_URL, - appId: newConfig.appId, - }; - - // Only include non-default values - Object.entries(newConfig).forEach(([key, value]) => { - if (key === "$schema") return; // Using our own schema URL - if (key === "appId") return; // Already included - if (value !== defaults[key as keyof typeof defaults]) { - (configWithoutDefaults as any)[key] = value; - } - }); - - const configJSON = JSON.stringify(configWithoutDefaults, null, 2); - - if (configPath && !overwrite) { - throw new Error("Config file already exists"); - } - - if (configPath) { - await writeFile(configPath, configJSON); - } else { - // Write to the nearest package.json directory - configPath = join(getProjectPath(), CONFIG_FILE_NAME); - await writeFile(configPath, configJSON); - } - - config = newConfig; -} diff --git a/packages/cli/utils/error.ts b/packages/cli/utils/errors.ts similarity index 54% rename from packages/cli/utils/error.ts rename to packages/cli/utils/errors.ts index 786ef687..f2cf0c79 100644 --- a/packages/cli/utils/error.ts +++ b/packages/cli/utils/errors.ts @@ -1,5 +1,31 @@ +import { ErrorObject } from "ajv"; import chalk from "chalk"; +export class MissingAppIdError extends Error { + constructor() { + super( + "App ID is required. Please provide it with --appId or in the config file.", + ); + this.name = "MissingAppIdError"; + } +} + +export class ConfigValidationError extends Error { + constructor(errors?: ErrorObject[] | null) { + const messages = errors + ?.map((e) => { + const path = e.instancePath || "config"; + const value = e.params?.allowedValues + ? `: ${e.params.allowedValues.join(", ")}` + : ""; + return `${path}: ${e.message}${value}`; + }) + .join("\n"); + super(messages); + this.name = "ConfigValidationError"; + } +} + export async function handleError(error: unknown, tag: string) { tag = chalk.bold(`\n[${tag}] error:`); diff --git a/packages/cli/utils/options.ts b/packages/cli/utils/options.ts index 7092331b..7c78b5a7 100644 --- a/packages/cli/utils/options.ts +++ b/packages/cli/utils/options.ts @@ -1,47 +1,39 @@ -import { - CONFIG_FILE_NAME, - DEFAULT_BASE_URL, - DEFAULT_TYPES_PATH, -} from "./constants.js"; - -export const options = { - debug: { - flags: "--debug", - description: "Enable debug mode", - }, - baseUrl: { - flags: "--base-url [url]", - description: "Specify the Bucket service URL (useful if behind a proxy).", - configKey: "baseUrl", - fallback: DEFAULT_BASE_URL, - }, - apiUrl: { - flags: "--api-url [url]", - description: `Specify the Bucket API URL (useful if behind a proxy). Falls back to apiUrl value in ${CONFIG_FILE_NAME} or baseUrl with /api appended.`, - configKey: "apiUrl", - }, - appId: { - flags: "-a, --appId ", - description: `Specify the app ID. Falls back to appId value in ${CONFIG_FILE_NAME}.`, - configKey: "appId", - }, - initOverride: { - flags: "-f, --force", - description: "Force initialization and overwrite existing configuration.", - }, - typesOut: { - flags: "-o, --out [path]", - description: `Specify the output path for generated feature types. Falls back to typesPath value in ${CONFIG_FILE_NAME} or ${DEFAULT_TYPES_PATH}`, - configKey: "typesPath", - fallback: DEFAULT_TYPES_PATH, - }, - featureName: { - flags: "[name]", - description: "Specify the feature's name.", - }, - featureKey: { - flags: "-k, --key [feature key]", - description: - "Specify the feature key. If not provided, a key is generated from the feature's name.", - }, -} as const; +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"); + +export const baseUrlOption = new Option( + "--base-url [url]", + `Bucket service URL (useful if behind a proxy). Falls back to baseUrl value in ${CONFIG_FILE_NAME}.`, +); + +export const apiUrlOption = new Option( + "--api-url [url]", + `Bucket API URL (useful if behind a proxy). Falls back to apiUrl value in ${CONFIG_FILE_NAME} or baseUrl with /api appended.`, +); + +export const appIdOption = new Option( + "-a, --appId [appId]", + `Bucket App ID. Falls back to appId value in ${CONFIG_FILE_NAME}.`, +); + +export const initOverrideOption = new Option( + "-f, --force", + "Force initialization and overwrite existing configuration.", +); + +export const typesOutOption = new Option( + "-o, --out [path]", + `Output path for generated feature types. Falls back to typesPath value in ${CONFIG_FILE_NAME}.`, +); + +export const featureKeyOption = new Option( + "-k, --key [feature key]", + "Feature key. If not provided, a key is generated from the feature's name.", +).choices(keyFormats); + +export const featureNameArgument = new Argument("[name]", "Feature's name."); From 0b939ddd3fc226d5d94651c61f07ada04e50126e Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Fri, 28 Feb 2025 14:45:27 +0100 Subject: [PATCH 5/6] fix: incorrect config, urls and messages --- packages/cli/commands/features.ts | 12 ++++++++---- packages/cli/index.ts | 5 ++++- packages/cli/stores/config.ts | 2 +- packages/cli/utils/constants.ts | 4 ++-- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index fdbfa88b..225cb92f 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -2,7 +2,7 @@ import { input } from "@inquirer/prompts"; import chalk from "chalk"; import { Command } from "commander"; import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, isAbsolute, join } from "node:path"; +import { dirname, isAbsolute, join, relative } from "node:path"; import ora, { Ora } from "ora"; import { createFeature, listFeatures } from "../services/features.js"; @@ -63,6 +63,7 @@ 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 spinner.succeed( `Created feature ${chalk.cyan(feature.name)} with key ${chalk.cyan(feature.key)}. 🎉`, ); @@ -94,6 +95,7 @@ export const listFeaturesAction = async () => { export const generateTypesAction = async () => { const { baseUrl, appId, typesPath } = configStore.getConfig(); + let spinner: Ora | undefined; let featureKeys: string[] = []; try { @@ -114,13 +116,15 @@ export const generateTypesAction = async () => { try { spinner = ora("Generating feature types...").start(); const types = genDTS(featureKeys); + const projectPath = configStore.getProjectPath(); const outPath = isAbsolute(typesPath) ? typesPath - : join(configStore.getProjectPath(), typesPath); + : join(projectPath, typesPath); await mkdir(dirname(outPath), { recursive: true }); await writeFile(outPath, types); - spinner.succeed("Generated feature types successfully"); - console.log(chalk.green(`Generated types for ${appId}.`)); + spinner.succeed( + `Generated types for ${chalk.cyan(appId)} in ${chalk.cyan(relative(projectPath, outPath))}.`, + ); } 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 00aae282..e4ad154a 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -26,7 +26,10 @@ async function main() { // Pre-action hook program.hook("preAction", () => { const { debug, baseUrl, apiUrl } = program.opts(); - configStore.setConfig({ baseUrl, apiUrl: apiUrl ?? baseUrl + "/api" }); + configStore.setConfig({ + baseUrl, + apiUrl: apiUrl || (baseUrl && `${baseUrl}/api`), + }); if (debug) { console.debug(chalk.cyan("\nDebug mode enabled")); diff --git a/packages/cli/stores/config.ts b/packages/cli/stores/config.ts index 88d284dc..76cfa375 100644 --- a/packages/cli/stores/config.ts +++ b/packages/cli/stores/config.ts @@ -92,7 +92,7 @@ class ConfigStore { ); } - this.config = parsed; + this.config = { ...this.config, ...parsed }; } catch { // No config file found } diff --git a/packages/cli/utils/constants.ts b/packages/cli/utils/constants.ts index 855f2dc7..7fe752f4 100644 --- a/packages/cli/utils/constants.ts +++ b/packages/cli/utils/constants.ts @@ -16,8 +16,8 @@ export const DEFAULT_BASE_URL = "https://app.bucket.co"; export const DEFAULT_API_URL = `${DEFAULT_BASE_URL}/api`; export const DEFAULT_TYPES_PATH = join("gen", "features.ts"); +export const chalkBrand = chalk.hex("#847CFB"); + export const loginUrl = (baseUrl: string, localPort: number) => `${baseUrl}/login?redirect_url=` + encodeURIComponent("/cli-login?port=" + localPort); - -export const chalkBrand = chalk.hex("#847CFB"); From ff3881001ca714eb83bb8ba7a5549ca2f71779c6 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Fri, 28 Feb 2025 15:26:15 +0100 Subject: [PATCH 6/6] fix: correct choice, speed up create feature, remove force closure warning --- packages/cli/commands/features.ts | 33 ++++++++++--------------------- packages/cli/commands/new.ts | 6 ++++-- packages/cli/utils/errors.ts | 5 ++++- packages/cli/utils/gen.ts | 24 ++-------------------- packages/cli/utils/options.ts | 7 ++++++- 5 files changed, 26 insertions(+), 49 deletions(-) diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index 225cb92f..c36f54e1 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -13,6 +13,7 @@ import { appIdOption, featureKeyOption, featureNameArgument, + keyFormatOption, typesOutOption, } from "../utils/options.js"; @@ -26,25 +27,8 @@ export const createFeatureAction = async ( ) => { const { baseUrl, appId } = configStore.getConfig(); let spinner: Ora | undefined; - let existingKeys: string[] = []; - try { if (!appId) throw new MissingAppIdError(); - spinner = ora( - `Loading features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, - ).start(); - const features = await listFeatures(appId); - existingKeys = features.map((f) => f.key); - spinner.succeed( - `Loaded features of app ${chalk.cyan(appId)} at from ${chalk.cyan(baseUrl)}`, - ); - } catch (error) { - spinner?.fail("Loading features failed"); - void handleError(error, "Features Create"); - return; - } - - try { if (!name) { name = await input({ message: "New feature name:", @@ -56,16 +40,18 @@ export const createFeatureAction = async ( const keyFormat = configStore.getConfig("keyFormat") ?? "custom"; key = await input({ message: "New feature key:", - default: genFeatureKey(name, keyFormat, existingKeys), + default: genFeatureKey(name, keyFormat), validate: KeyFormatPatterns[keyFormat].validate, }); } - spinner = ora("Creating feature...").start(); + spinner = ora( + `Creating feature for app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`, + ).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)}. 🎉`, + `Created feature ${chalk.cyan(feature.name)} with key ${chalk.cyan(feature.key)} at ${chalk.cyan(baseUrl)}. 🎉`, ); } catch (error) { spinner?.fail("Feature creation failed"); @@ -140,6 +126,7 @@ export function registerFeatureCommands(cli: Command) { .command("create") .description("Create a new feature") .addOption(appIdOption) + .addOption(keyFormatOption) .addOption(featureKeyOption) .addArgument(featureNameArgument) .action(createFeatureAction); @@ -158,9 +145,9 @@ export function registerFeatureCommands(cli: Command) { .action(generateTypesAction); // Update the config with the cli override values - featuresCommand.hook("preAction", (command) => { - const { appId, out } = command.opts(); - configStore.setConfig({ appId, typesPath: out }); + featuresCommand.hook("preAction", (_, command) => { + const { appId, keyFormat, out } = command.opts(); + configStore.setConfig({ appId, keyFormat, typesPath: out }); }); cli.addCommand(featuresCommand); diff --git a/packages/cli/commands/new.ts b/packages/cli/commands/new.ts index 3aa4fb36..d3f0a13a 100644 --- a/packages/cli/commands/new.ts +++ b/packages/cli/commands/new.ts @@ -7,6 +7,7 @@ import { appIdOption, featureKeyOption, featureNameArgument, + keyFormatOption, typesOutOption, } from "../utils/options.js"; @@ -36,6 +37,7 @@ export function registerNewCommand(cli: Command) { "Initialize the Bucket CLI, authenticates, and creates a new feature", ) .addOption(appIdOption) + .addOption(keyFormatOption) .addOption(typesOutOption) .addOption(featureKeyOption) .addArgument(featureNameArgument) @@ -43,7 +45,7 @@ export function registerNewCommand(cli: Command) { // Update the config with the cli override values cli.hook("preAction", (command) => { - const { appId, out } = command.opts(); - configStore.setConfig({ appId, typesPath: out }); + const { appId, keyFormat, out } = command.opts(); + configStore.setConfig({ appId, keyFormat, typesPath: out }); }); } diff --git a/packages/cli/utils/errors.ts b/packages/cli/utils/errors.ts index f2cf0c79..b70f2c78 100644 --- a/packages/cli/utils/errors.ts +++ b/packages/cli/utils/errors.ts @@ -1,3 +1,4 @@ +import { ExitPromptError } from "@inquirer/core"; import { ErrorObject } from "ajv"; import chalk from "chalk"; @@ -29,7 +30,9 @@ export class ConfigValidationError extends Error { export async function handleError(error: unknown, tag: string) { tag = chalk.bold(`\n[${tag}] error:`); - if (error instanceof Response) { + 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) { diff --git a/packages/cli/utils/gen.ts b/packages/cli/utils/gen.ts index 780e3e46..8e44b0e5 100644 --- a/packages/cli/utils/gen.ts +++ b/packages/cli/utils/gen.ts @@ -63,28 +63,8 @@ export const KeyFormatPatterns: Record = { }, }; -function nextAvailableKey( - baseKey: string, - format: KeyFormat, - existingKeys: string[], - attempt: number = 1, -): string { - const separator = format.includes("snake") ? "_" : "-"; - const suggestedKey = - attempt === 1 ? baseKey : `${baseKey}${separator}${attempt}`; - - return existingKeys.includes(suggestedKey) - ? nextAvailableKey(baseKey, format, existingKeys, attempt + 1) - : suggestedKey; -} - -export function genFeatureKey( - input: string, - format: KeyFormat, - existingKeys: string[], -): string { - const key = KeyFormatPatterns[format].transform(input); - return nextAvailableKey(key, format, existingKeys); +export function genFeatureKey(input: string, format: KeyFormat): string { + return KeyFormatPatterns[format].transform(input); } export const genDTS = (keys: string[]) => { diff --git a/packages/cli/utils/options.ts b/packages/cli/utils/options.ts index 7c78b5a7..e07025b4 100644 --- a/packages/cli/utils/options.ts +++ b/packages/cli/utils/options.ts @@ -31,9 +31,14 @@ export const typesOutOption = new Option( `Output path for generated feature types. Falls back to typesPath value in ${CONFIG_FILE_NAME}.`, ); +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.", -).choices(keyFormats); +); export const featureNameArgument = new Argument("[name]", "Feature's name.");