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..ee91edc8 --- /dev/null +++ b/packages/cli/commands/apps.ts @@ -0,0 +1,31 @@ +import chalk from "chalk"; +import { Command } from "commander"; +import ora from "ora"; + +import { listApps } from "../services/bootstrap.js"; +import { configStore } from "../stores/config.js"; +import { handleError } from "../utils/errors.js"; + +export const listAppsAction = async () => { + const baseUrl = configStore.getConfig("baseUrl"); + const spinner = ora(`Loading apps from ${chalk.cyan(baseUrl)}...`).start(); + try { + const apps = await listApps(); + spinner.succeed(`Loaded apps from ${chalk.cyan(baseUrl)}`); + console.table(apps); + } catch (error) { + spinner.fail("Failed to list apps"); + void handleError(error, "Apps List"); + } +}; + +export function registerAppCommands(cli: Command) { + const appsCommand = new Command("apps").description("Manage apps"); + + appsCommand + .command("list") + .description("List all available apps") + .action(listAppsAction); + + cli.addCommand(appsCommand); +} diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts new file mode 100644 index 00000000..6b207573 --- /dev/null +++ b/packages/cli/commands/auth.ts @@ -0,0 +1,38 @@ +import chalk from "chalk"; +import { Command } from "commander"; +import ora from "ora"; + +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 = configStore.getConfig("baseUrl"); + const spinner = ora(`Logging in to ${chalk.cyan(baseUrl)}...`).start(); + try { + await authenticateUser(baseUrl); + spinner.succeed(`Logged in to ${chalk.cyan(baseUrl)} successfully! 🎉`); + } catch (error) { + spinner.fail("Login failed"); + void handleError(error, "Login"); + } +}; + +export const logoutAction = async () => { + const baseUrl = configStore.getConfig("baseUrl"); + const spinner = ora("Logging out...").start(); + try { + await authStore.setToken(baseUrl, undefined); + spinner.succeed("Logged out successfully! 👋"); + } catch (error) { + spinner.fail("Logout failed"); + void handleError(error, "Logout"); + } +}; + +export function registerAuthCommands(cli: Command) { + cli.command("login").description("Login to Bucket").action(loginAction); + + cli.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..c36f54e1 --- /dev/null +++ b/packages/cli/commands/features.ts @@ -0,0 +1,154 @@ +import { input } from "@inquirer/prompts"; +import chalk from "chalk"; +import { Command } from "commander"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative } from "node:path"; +import ora, { Ora } from "ora"; + +import { createFeature, listFeatures } from "../services/features.js"; +import { configStore } from "../stores/config.js"; +import { handleError, MissingAppIdError } from "../utils/errors.js"; +import { genDTS, genFeatureKey, KeyFormatPatterns } from "../utils/gen.js"; +import { + appIdOption, + featureKeyOption, + featureNameArgument, + keyFormatOption, + typesOutOption, +} from "../utils/options.js"; + +type CreateFeatureArgs = { + key?: string; +}; + +export const createFeatureAction = async ( + name: string | undefined, + { key }: CreateFeatureArgs, +) => { + const { baseUrl, appId } = configStore.getConfig(); + let spinner: Ora | undefined; + try { + if (!appId) throw new MissingAppIdError(); + if (!name) { + name = await input({ + message: "New feature name:", + validate: (text) => text.length > 0 || "Name is required", + }); + } + + if (!key) { + const keyFormat = configStore.getConfig("keyFormat") ?? "custom"; + key = await input({ + message: "New feature key:", + default: genFeatureKey(name, keyFormat), + validate: KeyFormatPatterns[keyFormat].validate, + }); + } + + 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)} at ${chalk.cyan(baseUrl)}. 🎉`, + ); + } catch (error) { + spinner?.fail("Feature creation failed"); + void handleError(error, "Features Create"); + } +}; + +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"); + void handleError(error, "Features List"); + } +}; + +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(); + 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"); + void handleError(error, "Features Types"); + return; + } + + try { + spinner = ora("Generating feature types...").start(); + const types = genDTS(featureKeys); + const projectPath = configStore.getProjectPath(); + const outPath = isAbsolute(typesPath) + ? typesPath + : join(projectPath, typesPath); + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, types); + 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"); + } +}; + +export function registerFeatureCommands(cli: Command) { + const featuresCommand = new Command("features").description( + "Manage features", + ); + + featuresCommand + .command("create") + .description("Create a new feature") + .addOption(appIdOption) + .addOption(keyFormatOption) + .addOption(featureKeyOption) + .addArgument(featureNameArgument) + .action(createFeatureAction); + + featuresCommand + .command("list") + .description("List all features") + .addOption(appIdOption) + .action(listFeaturesAction); + + featuresCommand + .command("types") + .description("Generate feature types") + .addOption(appIdOption) + .addOption(typesOutOption) + .action(generateTypesAction); + + // Update the config with the cli override values + 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/init.ts b/packages/cli/commands/init.ts new file mode 100644 index 00000000..7bba51e8 --- /dev/null +++ b/packages/cli/commands/init.ts @@ -0,0 +1,102 @@ +import { input, select } from "@inquirer/prompts"; +import chalk from "chalk"; +import { Command } from "commander"; +import { relative } from "node:path"; +import ora, { Ora } from "ora"; + +import { App, listApps } from "../services/bootstrap.js"; +import { configStore } from "../stores/config.js"; +import { chalkBrand, DEFAULT_TYPES_PATH } from "../utils/constants.js"; +import { handleError } from "../utils/errors.js"; +import { initOverrideOption } from "../utils/options.js"; + +type InitArgs = { + force?: boolean; +}; + +export const initAction = async (args: InitArgs = {}) => { + let spinner: Ora | undefined; + let apps: App[] = []; + + try { + // Check if config already exists + const configPath = configStore.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 = configStore.getConfig("baseUrl"); + + // 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"); + void handleError(error, "Initialization"); + return; + } + + try { + let appId: string | undefined; + const nonDemoApps = apps.filter((app) => !app.demo); + + // If there is only one non-demo app, select it automatically + 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( + `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, + }); + + // Update config + configStore.setConfig({ + appId, + keyFormat, + typesPath, + }); + + // Create config file + spinner = ora("Creating configuration...").start(); + await configStore.saveConfigFile(args.force); + spinner.succeed( + `Configuration created at ${chalk.cyan(relative(process.cwd(), configStore.getConfigPath()!))}`, + ); + } catch (error) { + spinner?.fail("Configuration creation failed"); + void handleError(error, "Initialization"); + } +}; + +export function registerInitCommand(cli: Command) { + cli + .command("init") + .description("Initialize a new Bucket configuration") + .addOption(initOverrideOption) + .action(initAction); +} diff --git a/packages/cli/commands/new.ts b/packages/cli/commands/new.ts new file mode 100644 index 00000000..d3f0a13a --- /dev/null +++ b/packages/cli/commands/new.ts @@ -0,0 +1,51 @@ +import { Command } from "commander"; +import { findUp } from "find-up"; + +import { configStore } from "../stores/config.js"; +import { CONFIG_FILE_NAME } from "../utils/constants.js"; +import { + appIdOption, + featureKeyOption, + featureNameArgument, + keyFormatOption, + typesOutOption, +} 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, { key }: NewArgs) => { + if (!(await findUp(CONFIG_FILE_NAME))) { + await initAction(); + } + await createFeatureAction(name, { + key, + }); + await generateTypesAction(); +}; + +export function registerNewCommand(cli: Command) { + cli + .command("new") + .description( + "Initialize the Bucket CLI, authenticates, and creates a new feature", + ) + .addOption(appIdOption) + .addOption(keyFormatOption) + .addOption(typesOutOption) + .addOption(featureKeyOption) + .addArgument(featureNameArgument) + .action(newAction); + + // Update the config with the cli override values + cli.hook("preAction", (command) => { + const { appId, keyFormat, out } = command.opts(); + configStore.setConfig({ appId, keyFormat, typesPath: out }); + }); +} 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..e4ad154a --- /dev/null +++ b/packages/cli/index.ts @@ -0,0 +1,57 @@ +#!/usr/bin/env node +import { resolve } from "path"; +import { fileURLToPath } from "url"; +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 { 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 authStore.initialize(); + await configStore.initialize(); + + // Global options + program.addOption(debugOption); + program.addOption(baseUrlOption); + program.addOption(apiUrlOption); + + // Pre-action hook + program.hook("preAction", () => { + const { debug, baseUrl, apiUrl } = program.opts(); + configStore.setConfig({ + baseUrl, + apiUrl: apiUrl || (baseUrl && `${baseUrl}/api`), + }); + + if (debug) { + console.debug(chalk.cyan("\nDebug mode enabled")); + console.debug( + "Reading config from", + chalk.green(configStore.getConfigPath()), + ); + console.table(configStore.getConfig()); + } + }); + + // Main program + registerNewCommand(program); + registerInitCommand(program); + registerAuthCommands(program); + registerAppCommands(program); + registerFeatureCommands(program); + + program.parse(process.argv); +} + +// 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/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..054f213d --- /dev/null +++ b/packages/cli/services/bootstrap.ts @@ -0,0 +1,37 @@ +import { KeyFormat } from "../stores/config.js"; +import { authRequest } from "../utils/auth.js"; + +type BootstrapResponse = { + org: { + apps: { + id: string; + name: string; + demo: boolean; + }[]; + featureKeyFormat?: KeyFormat; + }; +}; + +export type 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/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..76cfa375 --- /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 = { ...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/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..09777677 --- /dev/null +++ b/packages/cli/utils/auth.ts @@ -0,0 +1,137 @@ +import http from "http"; +import open from "open"; + +import { authStore } from "../stores/auth.js"; +import { configStore } from "../stores/config.js"; + +import { loginUrl } from "./constants.js"; + +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(baseUrl: string) { + return new Promise((resolve, reject) => { + 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 authStore.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; + void open(loginUrl(baseUrl, port), { + newInstance: true, + }); + } + }); +} + +export async function authRequest>( + url: string, + options?: RequestInit, + retryCount = 0, +): Promise { + const { baseUrl, apiUrl } = configStore.getConfig(); + const token = authStore.getToken(baseUrl); + + if (!token) { + await authenticateUser(baseUrl); + return authRequest(url, options); + } + + const response = await fetch(`${apiUrl}${url}`, { + ...options, + headers: { + ...options?.headers, + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + if (response.status === 401) { + await authStore.setToken(baseUrl, undefined); + if (retryCount < 1) { + await authenticateUser(baseUrl); + return authRequest(url, options, retryCount + 1); + } + } + throw response; + } + + return response.json(); +} diff --git a/packages/cli/utils/constants.ts b/packages/cli/utils/constants.ts new file mode 100644 index 00000000..7fe752f4 --- /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 chalkBrand = chalk.hex("#847CFB"); + +export const loginUrl = (baseUrl: string, localPort: number) => + `${baseUrl}/login?redirect_url=` + + encodeURIComponent("/cli-login?port=" + localPort); diff --git a/packages/cli/utils/errors.ts b/packages/cli/utils/errors.ts new file mode 100644 index 00000000..b70f2c78 --- /dev/null +++ b/packages/cli/utils/errors.ts @@ -0,0 +1,56 @@ +import { ExitPromptError } from "@inquirer/core"; +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:`); + + 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) { + 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..8e44b0e5 --- /dev/null +++ b/packages/cli/utils/gen.ts @@ -0,0 +1,81 @@ +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", + }, +}; + +export function genFeatureKey(input: string, format: KeyFormat): string { + return KeyFormatPatterns[format].transform(input); +} + +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..e07025b4 --- /dev/null +++ b/packages/cli/utils/options.ts @@ -0,0 +1,44 @@ +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 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.", +); + +export const featureNameArgument = new Argument("[name]", "Feature's name."); 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 709ab375..953dcf2c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -448,6 +448,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" @@ -1430,6 +1454,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" @@ -1440,6 +1477,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" @@ -1461,6 +1529,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" @@ -1468,6 +1558,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" @@ -1477,6 +1652,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" @@ -3268,6 +3452,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" @@ -3627,6 +3820,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 @@ -4324,7 +4520,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: @@ -4986,6 +5182,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" @@ -5260,6 +5465,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" @@ -5339,6 +5558,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" @@ -5495,6 +5723,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" @@ -6037,6 +6272,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" @@ -6075,6 +6327,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" @@ -6266,6 +6525,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" @@ -7418,7 +7684,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: @@ -7598,6 +7864,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" @@ -7874,6 +8151,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" @@ -9096,6 +9380,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" @@ -9146,6 +9439,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" @@ -9153,6 +9457,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" @@ -9426,6 +9737,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" @@ -9470,6 +9795,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" @@ -10171,6 +10505,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" @@ -10216,6 +10559,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" @@ -10558,6 +10911,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" @@ -11694,6 +12054,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" @@ -11761,6 +12142,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" @@ -11820,6 +12218,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" @@ -11856,6 +12263,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" @@ -12042,6 +12458,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" @@ -13405,6 +13828,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" @@ -13664,6 +14097,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" @@ -14318,6 +14758,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" @@ -14370,6 +14817,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" @@ -14552,7 +15010,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: @@ -15428,6 +15886,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" @@ -15458,6 +15926,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" @@ -15522,6 +16000,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" @@ -15529,6 +16014,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"