From b491173eced606b734f059805318dd694cc2a7dd Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Thu, 29 Aug 2024 22:03:21 +0100 Subject: [PATCH 01/61] feat: initial cli commit --- packages/cli/.prettierignore | 3 + packages/cli/README.md | 1 + packages/cli/commands/apps.ts | 46 ++++ packages/cli/commands/auth.ts | 34 +++ packages/cli/commands/features.ts | 77 +++++++ packages/cli/eslint.config.js | 3 + packages/cli/index.ts | 22 ++ packages/cli/package.json | 31 +++ packages/cli/services/apps.ts | 22 ++ packages/cli/services/features.ts | 53 +++++ packages/cli/tsconfig.eslint.json | 3 + packages/cli/tsconfig.json | 11 + packages/cli/utils/auth.ts | 103 +++++++++ packages/cli/utils/config.ts | 24 +++ packages/cli/utils/constants.ts | 15 ++ packages/cli/utils/gen.ts | 18 ++ packages/eslint-config/base.js | 15 +- yarn.lock | 346 +++++++++++++++++++++++++++++- 18 files changed, 819 insertions(+), 8 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/eslint.config.js create mode 100755 packages/cli/index.ts create mode 100644 packages/cli/package.json create mode 100644 packages/cli/services/apps.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/gen.ts 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..adf36101 --- /dev/null +++ b/packages/cli/commands/apps.ts @@ -0,0 +1,46 @@ +import { select } from "@inquirer/prompts"; +import chalk from "chalk"; +import { Command } from "commander"; + +import { listApps } from "../services/apps.js"; +import { checkAuth } from "../utils/auth.js"; +import { writeConfig } from "../utils/config.js"; + +export const appCommand = new Command("apps").description("Manage apps"); + +appCommand + .command("list") + .description("List all available apps") + .action(async () => { + checkAuth(); + try { + const apps = await listApps(); + console.log(chalk.green("Available apps:")); + console.table(apps); + } catch (error) { + console.error(chalk.red("Error listing apps:", error)); + } + }); + +appCommand + .command("select") + .description("Select app") + .action(async () => { + checkAuth(); + try { + const apps = await listApps(); + + const answer = await select({ + message: "Select an app", + choices: apps.map((app) => ({ + name: app.name, + value: app.id, + description: app.demo ? "Demo" : undefined, + })), + }); + + await writeConfig("appId", answer); + } catch (error) { + console.error(chalk.red("Error listing apps:", error)); + } + }); diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts new file mode 100644 index 00000000..b44c57ca --- /dev/null +++ b/packages/cli/commands/auth.ts @@ -0,0 +1,34 @@ +import chalk from "chalk"; +import { Command } from "commander"; + +import { + authenticateUser, + removeSessionCookie, + saveSessionCookie, +} from "../utils/auth.js"; + +export const authCommand = new Command("auth").description( + "Manage authentication", +); + +authCommand + .command("login") + .description("Login to Bucket") + .action(async () => { + try { + // Initiate the auth process + const cookies = await authenticateUser(); + await saveSessionCookie(cookies); + console.log(chalk.green("Logged in successfully.")); + } catch (error) { + console.error(chalk.red("Authentication failed."), error); + } + }); + +authCommand + .command("logout") + .description("Logout from Bucket") + .action(() => { + removeSessionCookie(); + console.log(chalk.green("Logged out successfully.")); + }); diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts new file mode 100644 index 00000000..013e4ab0 --- /dev/null +++ b/packages/cli/commands/features.ts @@ -0,0 +1,77 @@ +import chalk from "chalk"; +import { Command } from "commander"; +import { outputFile } from "fs-extra/esm"; + +import { + createFeature, + genFeatureTypes, + listFeatures, + rolloutFeature, +} from "../services/features.js"; +import { checkAuth } from "../utils/auth.js"; +import { GEN_TYPES_FILE } from "../utils/constants.js"; + +export const featuresCommand = new Command("features").description( + "Manage features", +); + +featuresCommand + .command("list") + .description("List all features") + .requiredOption("-a, --appId ", "Get all features in app") + .action(async (options) => { + checkAuth(); + try { + const features = await listFeatures(options.appId); + console.log(chalk.green("Features:")); + console.table(features); + } catch (error) { + if (error instanceof Error) { + console.error(chalk.red("Error fetching features:", error.message)); + } + } + }); + +featuresCommand + .command("create") + .description("Create a new feature") + .action(async () => { + checkAuth(); + try { + await createFeature(); + } catch (error) { + if (error instanceof Error) { + console.error(chalk.red("Error creating feature:", error.message)); + } + } + }); + +featuresCommand + .command("types") + .description("Generate feature types") + .requiredOption("-a, --appId ", "Get all features in app") + .action(async (options) => { + checkAuth(); + try { + const types = await genFeatureTypes(options.appId); + await outputFile(GEN_TYPES_FILE, types); + } catch (error) { + if (error instanceof Error) { + console.error(chalk.red("Error listing feature types:", error.message)); + } + } + }); + +featuresCommand + .command("rollout") + .description("Rollout a feature") + .action(async () => { + checkAuth(); + try { + await rolloutFeature(); + } catch (error) { + if (error instanceof Error) { + console.error(chalk.red("Error rolling out feature:", error.message)); + } + } + }); diff --git a/packages/cli/eslint.config.js b/packages/cli/eslint.config.js new file mode 100644 index 00000000..cc05170e --- /dev/null +++ b/packages/cli/eslint.config.js @@ -0,0 +1,3 @@ +const base = require("@bucketco/eslint-config/base"); + +module.exports = [...base, { ignores: ["dist/", "gen/"] }]; diff --git a/packages/cli/index.ts b/packages/cli/index.ts new file mode 100755 index 00000000..ac0471bb --- /dev/null +++ b/packages/cli/index.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env node +import { program } from "commander"; + +import { appCommand } from "./commands/apps.js"; +import { authCommand } from "./commands/auth.js"; +import { featuresCommand } from "./commands/features.js"; +import { loadSessionCookie } from "./utils/auth.js"; + +async function main() { + // Main program + program + .addCommand(authCommand) + .addCommand(appCommand) + .addCommand(featuresCommand); + + // Load the access token before parsing arguments + await loadSessionCookie(); + + program.parse(process.argv); +} + +main(); diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000..fe32bd3f --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,31 @@ +{ + "name": "bucket-cli", + "version": "1.0.0", + "packageManager": "yarn@4.1.1", + "description": "CLI for Bucket service", + "main": "./dist/index.js", + "type": "module", + "bin": { + "bucket": "./dist/index.js" + }, + "scripts": { + "build": "tsc", + "start": "yarn build && node dist/index.js" + }, + "dependencies": { + "@inquirer/prompts": "^5.3.8", + "axios": "^1.7.5", + "chalk": "^5.3.0", + "commander": "^12.1.0", + "fs-extra": "^11.2.0", + "open": "^10.1.0" + }, + "devDependencies": { + "@bucketco/eslint-config": "workspace:^", + "@bucketco/tsconfig": "workspace:^", + "@types/fs-extra": "^11.0.4", + "@types/node": "^22.5.1", + "ts-node": "^10.9.2", + "typescript": "^5.5.4" + } +} diff --git a/packages/cli/services/apps.ts b/packages/cli/services/apps.ts new file mode 100644 index 00000000..26e56ded --- /dev/null +++ b/packages/cli/services/apps.ts @@ -0,0 +1,22 @@ +import { AxiosError } from "axios"; +import chalk from "chalk"; + +import { authRequest } from "../utils/auth.js"; + +type App = { + id: string; + name: string; + demo: boolean; +}; + +export async function listApps(): Promise { + try { + const response = await authRequest(`/bootstrap`); + return response.org.apps; + } catch (error) { + if (error instanceof AxiosError && error.response) { + console.error(chalk.red("Authentication failed."), error.response.data); + } + return []; + } +} diff --git a/packages/cli/services/features.ts b/packages/cli/services/features.ts new file mode 100644 index 00000000..45cec604 --- /dev/null +++ b/packages/cli/services/features.ts @@ -0,0 +1,53 @@ +import { AxiosError } from "axios"; +import chalk from "chalk"; + +import { authRequest } from "../utils/auth.js"; +import { genDTS } from "../utils/gen.js"; + +type Feature = { + name: string; + createdAt: string; +}; + +export async function listFeatures(appId: string): Promise { + try { + const response = await authRequest( + `/apps/${appId}/features?envId=enPa3R6khIKcWA`, + ); + return response.data.map(({ name, createdAt }: Feature) => ({ + name, + createdAt, + })); + } catch (error) { + if (error instanceof AxiosError && error.response) { + console.dir(error.response.data, { depth: null }); + } + throw error; + } +} + +export async function createFeature() { + // Implement feature creation logic here + console.log("Feature creation not implemented yet."); +} + +export async function genFeatureTypes(appId: string) { + try { + const response = await authRequest( + `/apps/${appId}/features?envId=enPa3R6khIKcWA`, + ); + return genDTS( + response.data.map(({ flagKey }: { flagKey: string }) => flagKey), + ) as string; + } catch (error) { + if (error instanceof AxiosError && error.response) { + console.error(chalk.red("Authentication failed."), error.response.data); + } + throw error; + } +} + +export async function rolloutFeature() { + // Implement feature rollout logic here + console.log("Feature rollout not implemented yet."); +} 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..2ea5c9d1 --- /dev/null +++ b/packages/cli/utils/auth.ts @@ -0,0 +1,103 @@ +import http from "http"; +import axios, { AxiosError, AxiosRequestConfig } from "axios"; +import chalk from "chalk"; +import open from "open"; + +import { readConfig, writeConfig } from "./config.js"; +import { API_BASE_URL } from "./constants.js"; + +/** + * @return {Promise} + */ +export async function authenticateUser() { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://localhost"); + + if (url.pathname !== "/") { + res.writeHead(404).end("Not Found"); + server.close(); + reject(new Error("Authentication failed")); + return; + } + + if (!req.headers.cookie?.includes("session.sig")) { + res.writeHead(400).end("No session cookie found"); + server.close(); + reject(new Error("Authentication failed: No session cookie")); + return; + } + + res.end("OK"); + server.close(); + resolve(req.headers.cookie); + }); + + server.listen(); + const address = server.address(); + if (address && typeof address === "object") { + const port = address.port; + const redirect = `http://localhost:${port}`; + open(`${API_BASE_URL}/auth/google?redirect=${redirect}`, { + newInstance: true, + }); + } + }); +} + +let sessionCookies = ""; + +export function checkAuth() { + if (!sessionCookies) { + console.log( + chalk.red( + 'You are not authenticated. Please run "bucket auth login" first.', + ), + ); + process.exit(1); + } +} + +export async function saveSessionCookie(cookies: string) { + await writeConfig("sessionCookies", cookies); + sessionCookies = cookies; +} + +export async function loadSessionCookie() { + sessionCookies = await readConfig("sessionCookies"); +} + +export function removeSessionCookie() { + saveSessionCookie(""); + sessionCookies = ""; +} + +export function getSessionCookie() { + return sessionCookies; +} + +export async function authRequest(url: string, options?: AxiosRequestConfig) { + checkAuth(); + try { + const response = await axios({ + ...options, + url: `${API_BASE_URL}${url}`, + headers: { + ...options?.headers, + Cookie: sessionCookies, + }, + }); + return response.data; + } catch (error) { + if ( + error instanceof AxiosError && + error.response && + error.response.status === 401 + ) { + console.log(chalk.red("Your session has expired. Please login again.")); + removeSessionCookie(); + process.exit(1); + } + throw error; + } +} diff --git a/packages/cli/utils/config.ts b/packages/cli/utils/config.ts new file mode 100644 index 00000000..1e6cf21c --- /dev/null +++ b/packages/cli/utils/config.ts @@ -0,0 +1,24 @@ +import { readJson, writeJson } from "fs-extra/esm"; + +import { CONFIG_FILE } from "./constants.js"; + +/** + * Read a value from the config file. + */ +export async function readConfig(key?: string) { + try { + const config = await readJson(CONFIG_FILE); + return key ? config[key] : config; + } catch (error) { + return {}; + } +} + +/** + * Write a new value to the config file. + */ +export async function writeConfig(key: string, value: string) { + const config = await readConfig(); + config[key] = value; + await writeJson(CONFIG_FILE, config); +} diff --git a/packages/cli/utils/constants.ts b/packages/cli/utils/constants.ts new file mode 100644 index 00000000..3cc0cfa3 --- /dev/null +++ b/packages/cli/utils/constants.ts @@ -0,0 +1,15 @@ +import path from "path"; + +export const API_BASE_URL = + "http://localhost:3100/api" ?? "https://app.bucket.co/api"; + +export const CONFIG_FILE = path.join( + process.env.HOME || "", + ".bucket-cli-config.json", +); + +export const GEN_TYPES_FILE = path.join( + process.cwd(), + "gen", + "feature-flag-types.ts", +); diff --git a/packages/cli/utils/gen.ts b/packages/cli/utils/gen.ts new file mode 100644 index 00000000..20de7302 --- /dev/null +++ b/packages/cli/utils/gen.ts @@ -0,0 +1,18 @@ +export const genDTS = (keys: string[]) => { + return /* ts */ ` +// DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET-CLI AND WILL BE OVERWRITTEN. +const availableFeatures = [ + "${keys.join('",\n "')}" +] as const; + +type ArrayToRecord = { + [Key in T[number]]: boolean; +}; + +export type AvailableFeatures = ArrayToRecord; + +declare module "@bucketco/react-sdk" { + interface Features extends AvailableFeatures {} +} +`.trim(); +}; diff --git a/packages/eslint-config/base.js b/packages/eslint-config/base.js index 2306b805..72fa2a92 100644 --- a/packages/eslint-config/base.js +++ b/packages/eslint-config/base.js @@ -10,6 +10,11 @@ const prettierConfig = require("eslint-config-prettier"); module.exports = [ { + // Blacklisted Folders, including **/node_modules/ and .git/ + ignores: ["build/", "**/gen"], + }, + { + // All files files: [ "**/*.js", "**/*.cjs", @@ -33,7 +38,6 @@ module.exports = [ // This is required to avoid ecmaVersion < 2015 error or 'import' / 'export' error ecmaVersion: "latest", sourceType: "module", - project: "./tsconfig.json", }, }, settings: { @@ -96,7 +100,7 @@ module.exports = [ languageOptions: { parser: tsParser, parserOptions: { - project: "./tsconfig.eslint.json", + project: "./tsconfig.json", }, }, settings: { @@ -117,13 +121,12 @@ module.exports = [ // Typescript Specific "@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/switch-exhaustiveness-check": ["warn"], "@typescript-eslint/no-non-null-assertion": ["off"], - "@typescript-eslint/no-empty-function": ["warn"], + "@typescript-eslint/no-empty-function": ["off"], "@typescript-eslint/no-explicit-any": ["off"], - "@typescript-eslint/no-use-before-define": ["off"], - "@typescript-eslint/no-shadow": ["warn"], + "@typescript-eslint/no-use-before-define": ["off"], // todo: discuss enabling this rule + "@typescript-eslint/no-shadow": ["off"], // todo: discuss enabling this rule }, }, { diff --git a/yarn.lock b/yarn.lock index 4d2264ef..998419ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1873,6 +1873,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" @@ -1883,6 +1896,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" @@ -1904,6 +1948,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" @@ -1911,6 +1977,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" @@ -1920,6 +2071,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" @@ -3641,6 +3801,16 @@ __metadata: languageName: node linkType: hard +"@types/fs-extra@npm:^11.0.4": + version: 11.0.4 + resolution: "@types/fs-extra@npm:11.0.4" + dependencies: + "@types/jsonfile": "npm:*" + "@types/node": "npm:*" + checksum: 10c0/9e34f9b24ea464f3c0b18c3f8a82aefc36dc524cc720fc2b886e5465abc66486ff4e439ea3fb2c0acebf91f6d3f74e514f9983b1f02d4243706bdbb7511796ad + languageName: node + linkType: hard + "@types/istanbul-lib-coverage@npm:^2.0.1": version: 2.0.4 resolution: "@types/istanbul-lib-coverage@npm:2.0.4" @@ -3680,6 +3850,15 @@ __metadata: languageName: node linkType: hard +"@types/jsonfile@npm:*": + version: 6.1.4 + resolution: "@types/jsonfile@npm:6.1.4" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/b12d068b021e4078f6ac4441353965769be87acf15326173e2aea9f3bf8ead41bd0ad29421df5bbeb0123ec3fc02eb0a734481d52903704a1454a1845896b9eb + languageName: node + linkType: hard + "@types/minimatch@npm:^3.0.3": version: 3.0.5 resolution: "@types/minimatch@npm:3.0.5" @@ -3737,6 +3916,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:~20.14.9": version: 20.14.9 resolution: "@types/node@npm:20.14.9" @@ -5300,6 +5488,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.7.5": + version: 1.7.5 + resolution: "axios@npm:1.7.5" + dependencies: + follow-redirects: "npm:^1.15.6" + form-data: "npm:^4.0.0" + proxy-from-env: "npm:^1.1.0" + checksum: 10c0/1d5daeb28b3d1bb2a7b9f0743433c4bfbeaddc15461e50ebde487eec6c009af2515749d5261096dd430c90cd891bd310bcba5ec3967bae2033c4a307f58a6ad3 + languageName: node + linkType: hard + "axobject-query@npm:~3.1.1": version: 3.1.1 resolution: "axobject-query@npm:3.1.1" @@ -5438,6 +5637,27 @@ __metadata: languageName: node linkType: hard +"bucket-cli@workspace:packages/cli": + version: 0.0.0-use.local + resolution: "bucket-cli@workspace:packages/cli" + dependencies: + "@bucketco/eslint-config": "workspace:^" + "@bucketco/tsconfig": "workspace:^" + "@inquirer/prompts": "npm:^5.3.8" + "@types/fs-extra": "npm:^11.0.4" + "@types/node": "npm:^22.5.1" + axios: "npm:^1.7.5" + chalk: "npm:^5.3.0" + commander: "npm:^12.1.0" + fs-extra: "npm:^11.2.0" + open: "npm:^10.1.0" + ts-node: "npm:^10.9.2" + typescript: "npm:^5.5.4" + bin: + bucket: ./dist/index.js + languageName: unknown + linkType: soft + "buffer-from@npm:^1.0.0": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" @@ -5471,6 +5691,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" @@ -5734,6 +5963,13 @@ __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 + "chardet@npm:^0.7.0": version: 0.7.0 resolution: "chardet@npm:0.7.0" @@ -5976,6 +6212,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" @@ -6537,6 +6780,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" @@ -6575,6 +6835,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" @@ -7678,7 +7945,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: @@ -7911,7 +8178,7 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:^11.1.0, fs-extra@npm:^11.1.1": +"fs-extra@npm:^11.1.0, fs-extra@npm:^11.1.1, fs-extra@npm:^11.2.0": version: 11.2.0 resolution: "fs-extra@npm:11.2.0" dependencies: @@ -9134,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" @@ -9175,6 +9451,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" @@ -9429,6 +9716,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" @@ -11660,6 +11956,18 @@ __metadata: 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" @@ -13610,6 +13918,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" @@ -15142,6 +15457,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@patch:typescript@npm%3A5.3.3#optional!builtin, typescript@patch:typescript@npm%3A^5.3.3#optional!builtin": version: 5.3.3 resolution: "typescript@patch:typescript@npm%3A5.3.3#optional!builtin::version=5.3.3&hash=e012d7" @@ -15182,6 +15507,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 + "ufo@npm:^1.5.3": version: 1.5.4 resolution: "ufo@npm:1.5.4" @@ -15217,6 +15552,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 + "union@npm:~0.5.0": version: 0.5.0 resolution: "union@npm:0.5.0" From 24f7750d05c80bbbcd009549ee7c3f4b518d447d Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Sat, 31 Aug 2024 11:29:19 +0100 Subject: [PATCH 02/61] feat: implemented create feature --- packages/cli/commands/apps.ts | 77 ++++++++-------- packages/cli/commands/auth.ts | 56 ++++++------ packages/cli/commands/envs.ts | 61 +++++++++++++ packages/cli/commands/features.ts | 137 ++++++++++++++++------------- packages/cli/index.ts | 23 ++--- packages/cli/package.json | 1 + packages/cli/services/apps.ts | 22 ----- packages/cli/services/bootstrap.ts | 31 +++++++ packages/cli/services/features.ts | 82 ++++++++--------- packages/cli/utils/auth.ts | 56 ++++-------- packages/cli/utils/config.ts | 34 +++++-- packages/cli/utils/error.ts | 31 +++++++ packages/cli/utils/gen.ts | 23 ++++- yarn.lock | 45 ++++++++++ 14 files changed, 439 insertions(+), 240 deletions(-) create mode 100644 packages/cli/commands/envs.ts delete mode 100644 packages/cli/services/apps.ts create mode 100644 packages/cli/services/bootstrap.ts create mode 100644 packages/cli/utils/error.ts diff --git a/packages/cli/commands/apps.ts b/packages/cli/commands/apps.ts index adf36101..d7f70ad0 100644 --- a/packages/cli/commands/apps.ts +++ b/packages/cli/commands/apps.ts @@ -2,45 +2,50 @@ import { select } from "@inquirer/prompts"; import chalk from "chalk"; import { Command } from "commander"; -import { listApps } from "../services/apps.js"; +import { listApps } from "../services/bootstrap.js"; import { checkAuth } from "../utils/auth.js"; -import { writeConfig } from "../utils/config.js"; +import { writeConfigFile } from "../utils/config.js"; +import { handleError } from "../utils/error.js"; -export const appCommand = new Command("apps").description("Manage apps"); +export function registerAppsCommands(program: Command) { + const appsCommand = new Command("apps").description("Manage apps"); -appCommand - .command("list") - .description("List all available apps") - .action(async () => { - checkAuth(); - try { - const apps = await listApps(); - console.log(chalk.green("Available apps:")); - console.table(apps); - } catch (error) { - console.error(chalk.red("Error listing apps:", error)); - } - }); + appsCommand + .command("list") + .description("List all available apps") + .action(async () => { + checkAuth(); + try { + const apps = await listApps(); + console.log(chalk.green("Available apps:")); + console.table(apps); + } catch (error) { + handleError(error, "Failed to list apps:"); + } + }); -appCommand - .command("select") - .description("Select app") - .action(async () => { - checkAuth(); - try { - const apps = await listApps(); + appsCommand + .command("select") + .description("Select app") + .action(async () => { + checkAuth(); + try { + const apps = await listApps(); - const answer = await select({ - message: "Select an app", - choices: apps.map((app) => ({ - name: app.name, - value: app.id, - description: app.demo ? "Demo" : undefined, - })), - }); + const answer = await select({ + message: "Select an app", + choices: apps.map((app) => ({ + name: app.name, + value: app.id, + description: app.demo ? "Demo" : undefined, + })), + }); - await writeConfig("appId", answer); - } catch (error) { - console.error(chalk.red("Error listing apps:", error)); - } - }); + await writeConfigFile("appId", answer); + } catch (error) { + handleError(error, "Failed to select app:"); + } + }); + + program.addCommand(appsCommand); +} diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index b44c57ca..95e585bf 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -1,34 +1,34 @@ import chalk from "chalk"; import { Command } from "commander"; -import { - authenticateUser, - removeSessionCookie, - saveSessionCookie, -} from "../utils/auth.js"; +import { authenticateUser } from "../utils/auth.js"; +import { writeConfigFile } from "../utils/config.js"; +import { handleError } from "../utils/error.js"; -export const authCommand = new Command("auth").description( - "Manage authentication", -); +export function registerAuthCommands(program: Command) { + const authCommand = new Command("auth").description("Manage authentication"); -authCommand - .command("login") - .description("Login to Bucket") - .action(async () => { - try { - // Initiate the auth process - const cookies = await authenticateUser(); - await saveSessionCookie(cookies); - console.log(chalk.green("Logged in successfully.")); - } catch (error) { - console.error(chalk.red("Authentication failed."), error); - } - }); + authCommand + .command("login") + .description("Login to Bucket") + .requiredOption("-a, --appId ", "Get all features in app") + .action(async () => { + try { + // Initiate the auth process + await authenticateUser(); + console.log(chalk.green("Logged in successfully!")); + } catch (error) { + handleError(error, "Authentication failed:"); + } + }); -authCommand - .command("logout") - .description("Logout from Bucket") - .action(() => { - removeSessionCookie(); - console.log(chalk.green("Logged out successfully.")); - }); + authCommand + .command("logout") + .description("Logout from Bucket") + .action(async () => { + await writeConfigFile("sessionCookies", undefined); + console.log(chalk.green("Logged out successfully!")); + }); + + program.addCommand(authCommand); +} diff --git a/packages/cli/commands/envs.ts b/packages/cli/commands/envs.ts new file mode 100644 index 00000000..6d428cfb --- /dev/null +++ b/packages/cli/commands/envs.ts @@ -0,0 +1,61 @@ +import { select } from "@inquirer/prompts"; +import chalk from "chalk"; +import { Command } from "commander"; + +import { listEnvs } from "../services/bootstrap.js"; +import { checkAuth } from "../utils/auth.js"; +import { getConfig, writeConfigFile } from "../utils/config.js"; +import { CONFIG_FILE } from "../utils/constants.js"; +import { handleError } from "../utils/error.js"; + +export function registerEnvsCommands(program: Command) { + const envsCommand = new Command("envs").description("Manage envs"); + + envsCommand + .command("list") + .description("List all available environments for the app") + .requiredOption( + "-a, --appId ", + `Get all environments for the app. Falls back to appId stored in ${CONFIG_FILE}.`, + getConfig("appId"), + ) + .action(async ({ appId }) => { + checkAuth(); + try { + const envs = await listEnvs(appId); + console.log(chalk.green(`Available environments for app ${appId}:`)); + console.table(envs); + } catch (error) { + handleError(error, "Failed to list environment:"); + } + }); + + envsCommand + .command("select") + .description("Select environment") + .requiredOption( + "-a, --appId ", + `Get all environments for the app. Falls back to appId stored in ${CONFIG_FILE}.`, + getConfig("appId"), + ) + .action(async ({ appId }) => { + checkAuth(); + try { + const envs = await listEnvs(appId); + + const answer = await select({ + message: "Select an environment", + choices: envs.map(({ id, name }) => ({ + name, + value: id, + })), + }); + + await writeConfigFile("envId", answer); + } catch (error) { + handleError(error, "Failed to select environment:"); + } + }); + + program.addCommand(envsCommand); +} diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index 013e4ab0..73123a18 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -6,72 +6,91 @@ import { createFeature, genFeatureTypes, listFeatures, - rolloutFeature, } from "../services/features.js"; import { checkAuth } from "../utils/auth.js"; -import { GEN_TYPES_FILE } from "../utils/constants.js"; +import { getConfig } from "../utils/config.js"; +import { CONFIG_FILE, GEN_TYPES_FILE } from "../utils/constants.js"; +import { handleError } from "../utils/error.js"; -export const featuresCommand = new Command("features").description( - "Manage features", -); +export function registerFeaturesCommands(program: Command) { + const featuresCommand = new Command("features").description( + "Manage features", + ); -featuresCommand - .command("list") - .description("List all features") - .requiredOption("-a, --appId ", "Get all features in app") - .action(async (options) => { - checkAuth(); - try { - const features = await listFeatures(options.appId); - console.log(chalk.green("Features:")); - console.table(features); - } catch (error) { - if (error instanceof Error) { - console.error(chalk.red("Error fetching features:", error.message)); + featuresCommand + .command("list") + .description("List all features") + .requiredOption( + "-a, --appId ", + `Get all features in the app. Falls back to appId stored in ${CONFIG_FILE}.`, + getConfig("appId"), + ) + .action(async ({ appId }) => { + checkAuth(); + try { + const features = await listFeatures(appId); + console.log(chalk.green(`Features in ${appId}:`)); + console.table(features); + } catch (error) { + handleError(error, "Failed to list features:"); } - } - }); + }); -featuresCommand - .command("create") - .description("Create a new feature") - .action(async () => { - checkAuth(); - try { - await createFeature(); - } catch (error) { - if (error instanceof Error) { - console.error(chalk.red("Error creating feature:", error.message)); + featuresCommand + .command("types") + .description("Generate feature types") + .requiredOption( + "-a, --appId ", + `Generate types for features in the app. Falls back to appId stored in ${CONFIG_FILE}.`, + getConfig("appId"), + ) + .option( + "-o, --out ", + `Generate types for features at the output path. Falls back to ${GEN_TYPES_FILE}.`, + GEN_TYPES_FILE, + ) + .action(async ({ appId, out }) => { + checkAuth(); + try { + const types = await genFeatureTypes(appId); + await outputFile(out, types); + console.log(chalk.green(`Generated features for ${appId}.`)); + } catch (error) { + handleError(error, "Failed to generate feature types:"); } - } - }); + }); -featuresCommand - .command("types") - .description("Generate feature types") - .requiredOption("-a, --appId ", "Get all features in app") - .action(async (options) => { - checkAuth(); - try { - const types = await genFeatureTypes(options.appId); - await outputFile(GEN_TYPES_FILE, types); - } catch (error) { - if (error instanceof Error) { - console.error(chalk.red("Error listing feature types:", error.message)); + featuresCommand + .command("create") + .description("Create a new feature") + .argument("", "Name of the feature") + .requiredOption( + "-a, --appId ", + `Get all features in the app. Falls back to appId stored in ${CONFIG_FILE}.`, + getConfig("appId"), + ) + .requiredOption( + "-e, --envId ", + `Get all features in the env. Falls back to envId stored in ${CONFIG_FILE}.`, + getConfig("envId"), + ) + .option( + "-k, --key ", + `Create a feature in the app with the given feature key. Falls back to a slug of the feature name.`, + ) + .action(async (name, { appId, envId, key }) => { + checkAuth(); + try { + const feature = await createFeature(appId, envId, name, key); + console.log( + chalk.green( + `Created feature ${feature.name} with key ${feature.key}.`, + ), + ); + } catch (error) { + handleError(error, "Failed to create feature:"); } - } - }); + }); -featuresCommand - .command("rollout") - .description("Rollout a feature") - .action(async () => { - checkAuth(); - try { - await rolloutFeature(); - } catch (error) { - if (error instanceof Error) { - console.error(chalk.red("Error rolling out feature:", error.message)); - } - } - }); + program.addCommand(featuresCommand); +} diff --git a/packages/cli/index.ts b/packages/cli/index.ts index ac0471bb..c5856819 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -1,20 +1,21 @@ #!/usr/bin/env node import { program } from "commander"; -import { appCommand } from "./commands/apps.js"; -import { authCommand } from "./commands/auth.js"; -import { featuresCommand } from "./commands/features.js"; -import { loadSessionCookie } from "./utils/auth.js"; +import { registerAppsCommands } from "./commands/apps.js"; +import { registerAuthCommands } from "./commands/auth.js"; +import { registerEnvsCommands } from "./commands/envs.js"; +import { registerFeaturesCommands } from "./commands/features.js"; +import { readConfigFile } from "./utils/config.js"; async function main() { - // Main program - program - .addCommand(authCommand) - .addCommand(appCommand) - .addCommand(featuresCommand); + // Read the config file + await readConfigFile(); - // Load the access token before parsing arguments - await loadSessionCookie(); + // Main program + registerAuthCommands(program); + registerAppsCommands(program); + registerEnvsCommands(program); + registerFeaturesCommands(program); program.parse(process.argv); } diff --git a/packages/cli/package.json b/packages/cli/package.json index fe32bd3f..7a26b9ae 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@inquirer/prompts": "^5.3.8", + "@sindresorhus/slugify": "^2.2.1", "axios": "^1.7.5", "chalk": "^5.3.0", "commander": "^12.1.0", diff --git a/packages/cli/services/apps.ts b/packages/cli/services/apps.ts deleted file mode 100644 index 26e56ded..00000000 --- a/packages/cli/services/apps.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { AxiosError } from "axios"; -import chalk from "chalk"; - -import { authRequest } from "../utils/auth.js"; - -type App = { - id: string; - name: string; - demo: boolean; -}; - -export async function listApps(): Promise { - try { - const response = await authRequest(`/bootstrap`); - return response.org.apps; - } catch (error) { - if (error instanceof AxiosError && error.response) { - console.error(chalk.red("Authentication failed."), error.response.data); - } - return []; - } -} diff --git a/packages/cli/services/bootstrap.ts b/packages/cli/services/bootstrap.ts new file mode 100644 index 00000000..0b398517 --- /dev/null +++ b/packages/cli/services/bootstrap.ts @@ -0,0 +1,31 @@ +import { authRequest } from "../utils/auth.js"; + +type Environment = { + id: string; + name: string; + isProduction: boolean; + order: number; +}; + +type App = { + id: string; + name: string; + demo: boolean; + environments: Environment[]; +}; + +type BootstrapResponse = { + org: { + apps: App[]; + }; +}; + +export async function listApps() { + const response = await authRequest(`/bootstrap`); + return response.org.apps.map(({ id, name, demo }) => ({ id, name, demo })); +} + +export async function listEnvs(appId: string) { + const response = await authRequest(`/bootstrap`); + return response.org.apps.find(({ id }) => id === appId)?.environments ?? []; +} diff --git a/packages/cli/services/features.ts b/packages/cli/services/features.ts index 45cec604..5eb364e0 100644 --- a/packages/cli/services/features.ts +++ b/packages/cli/services/features.ts @@ -1,53 +1,55 @@ -import { AxiosError } from "axios"; -import chalk from "chalk"; - import { authRequest } from "../utils/auth.js"; -import { genDTS } from "../utils/gen.js"; +import { genDTS, genFeatureKey } from "../utils/gen.js"; type Feature = { + id: string; name: string; - createdAt: string; + key: string; }; -export async function listFeatures(appId: string): Promise { - try { - const response = await authRequest( - `/apps/${appId}/features?envId=enPa3R6khIKcWA`, - ); - return response.data.map(({ name, createdAt }: Feature) => ({ - name, - createdAt, - })); - } catch (error) { - if (error instanceof AxiosError && error.response) { - console.dir(error.response.data, { depth: null }); - } - throw error; - } -} +type FeatureNamesResponse = Feature[]; -export async function createFeature() { - // Implement feature creation logic here - console.log("Feature creation not implemented yet."); +export async function listFeatures(appId: string) { + const response = await authRequest( + `/apps/${appId}/features/names`, + ); + return response.map(({ name, key }) => ({ + name, + key, + })); } export async function genFeatureTypes(appId: string) { - try { - const response = await authRequest( - `/apps/${appId}/features?envId=enPa3R6khIKcWA`, - ); - return genDTS( - response.data.map(({ flagKey }: { flagKey: string }) => flagKey), - ) as string; - } catch (error) { - if (error instanceof AxiosError && error.response) { - console.error(chalk.red("Authentication failed."), error.response.data); - } - throw error; - } + const response = await authRequest( + `/apps/${appId}/features/names`, + ); + return genDTS(response.map(({ key }) => key)); } -export async function rolloutFeature() { - // Implement feature rollout logic here - console.log("Feature rollout not implemented yet."); +type FeatureResponse = { + feature: Feature; +}; + +export async function createFeature( + appId: string, + envId: string, + name: string, + key: string | undefined, +) { + const features = await listFeatures(appId); + const response = await authRequest( + `/apps/${appId}/features?envId=${envId}`, + { + method: "POST", + data: { + name, + key: genFeatureKey( + key ?? name, + features.map(({ key }) => key), + ), + source: "event", + }, + }, + ); + return response.feature; } diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index 2ea5c9d1..87b02b24 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -1,9 +1,8 @@ import http from "http"; import axios, { AxiosError, AxiosRequestConfig } from "axios"; -import chalk from "chalk"; import open from "open"; -import { readConfig, writeConfig } from "./config.js"; +import { getConfig, writeConfigFile } from "./config.js"; import { API_BASE_URL } from "./constants.js"; /** @@ -15,21 +14,22 @@ export async function authenticateUser() { const url = new URL(req.url ?? "/", "http://localhost"); if (url.pathname !== "/") { - res.writeHead(404).end("Not Found"); + res.writeHead(404).end("Invalid path"); server.close(); - reject(new Error("Authentication failed")); + reject(new Error("Could not authenticate: Invalid path")); return; } if (!req.headers.cookie?.includes("session.sig")) { - res.writeHead(400).end("No session cookie found"); + res.writeHead(400).end("Could not authenticate"); server.close(); - reject(new Error("Authentication failed: No session cookie")); + reject(new Error("Could not authenticate")); return; } - res.end("OK"); + res.end("You can now close this tab."); server.close(); + writeConfigFile("sessionCookies", req.headers.cookie); resolve(req.headers.cookie); }); @@ -45,38 +45,18 @@ export async function authenticateUser() { }); } -let sessionCookies = ""; - export function checkAuth() { - if (!sessionCookies) { - console.log( - chalk.red( - 'You are not authenticated. Please run "bucket auth login" first.', - ), + if (!getConfig("sessionCookies")) { + throw new Error( + 'You are not authenticated. Please run "bucket auth login" first.', ); - process.exit(1); } } -export async function saveSessionCookie(cookies: string) { - await writeConfig("sessionCookies", cookies); - sessionCookies = cookies; -} - -export async function loadSessionCookie() { - sessionCookies = await readConfig("sessionCookies"); -} - -export function removeSessionCookie() { - saveSessionCookie(""); - sessionCookies = ""; -} - -export function getSessionCookie() { - return sessionCookies; -} - -export async function authRequest(url: string, options?: AxiosRequestConfig) { +export async function authRequest>( + url: string, + options?: AxiosRequestConfig, +): Promise { checkAuth(); try { const response = await axios({ @@ -84,7 +64,7 @@ export async function authRequest(url: string, options?: AxiosRequestConfig) { url: `${API_BASE_URL}${url}`, headers: { ...options?.headers, - Cookie: sessionCookies, + Cookie: getConfig("sessionCookies"), }, }); return response.data; @@ -94,9 +74,9 @@ export async function authRequest(url: string, options?: AxiosRequestConfig) { error.response && error.response.status === 401 ) { - console.log(chalk.red("Your session has expired. Please login again.")); - removeSessionCookie(); - process.exit(1); + writeConfigFile("sessionCookies", undefined); + error.message = "Your session has expired. Please login again."; + throw error; } throw error; } diff --git a/packages/cli/utils/config.ts b/packages/cli/utils/config.ts index 1e6cf21c..7579cb38 100644 --- a/packages/cli/utils/config.ts +++ b/packages/cli/utils/config.ts @@ -2,12 +2,33 @@ import { readJson, writeJson } from "fs-extra/esm"; import { CONFIG_FILE } from "./constants.js"; +type Config = { + sessionCookies?: string; + appId?: string; + envId?: string; +}; + +let config: Config = {}; + +/** + * Instantly return a specified key's value or the entire config object. + */ +export function getConfig(): Config; +export function getConfig(key: keyof Config): string | undefined; +export function getConfig(key?: keyof Config) { + return key ? config[key] : config; +} + /** - * Read a value from the config file. + * Read the config file and return either a specified key's value or the entire config object. */ -export async function readConfig(key?: string) { +export async function readConfigFile(): Promise; +export async function readConfigFile( + key: keyof Config, +): Promise; +export async function readConfigFile(key?: keyof Config) { try { - const config = await readJson(CONFIG_FILE); + config = await readJson(CONFIG_FILE); return key ? config[key] : config; } catch (error) { return {}; @@ -17,8 +38,11 @@ export async function readConfig(key?: string) { /** * Write a new value to the config file. */ -export async function writeConfig(key: string, value: string) { - const config = await readConfig(); +export async function writeConfigFile( + key: keyof Config, + value: string | undefined, +) { + const config = await readConfigFile(); config[key] = value; await writeJson(CONFIG_FILE, config); } diff --git a/packages/cli/utils/error.ts b/packages/cli/utils/error.ts new file mode 100644 index 00000000..7867e562 --- /dev/null +++ b/packages/cli/utils/error.ts @@ -0,0 +1,31 @@ +import { AxiosError } from "axios"; +import chalk from "chalk"; + +export function handleError(error: unknown, message?: string | null) { + if (error instanceof AxiosError && error.response?.data) { + const data = error.response.data; + console.error( + chalk.red( + message ?? "Network request error:", + 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(message, error.message); + } else if (typeof error === "string") { + console.error(message, error); + } else { + console.error(message ?? "An unknown error occurred:", error); + } + process.exit(1); +} diff --git a/packages/cli/utils/gen.ts b/packages/cli/utils/gen.ts index 20de7302..ec672dea 100644 --- a/packages/cli/utils/gen.ts +++ b/packages/cli/utils/gen.ts @@ -1,6 +1,9 @@ +import slugify from "@sindresorhus/slugify"; + export const genDTS = (keys: string[]) => { return /* ts */ ` -// DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET-CLI AND WILL BE OVERWRITTEN. +// DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET CLI AND WILL BE OVERWRITTEN. + const availableFeatures = [ "${keys.join('",\n "')}" ] as const; @@ -16,3 +19,21 @@ declare module "@bucketco/react-sdk" { } `.trim(); }; + +export function genFeatureKey(input: string, existingKeys: string[]): string { + const keySlug = slugify(input); + + if (!existingKeys.includes(keySlug)) { + return keySlug; + } else { + const lastPart = keySlug.split("-").pop(); + + if (!lastPart || isNaN(Number(lastPart))) { + return `${keySlug}-1`; + } else { + const base = keySlug.slice(0, keySlug.length - lastPart.length); + const newNumber = Number(lastPart) + 1; + return `${base}${newNumber}`; + } + } +} diff --git a/yarn.lock b/yarn.lock index 998419ed..44a8856a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3639,6 +3639,25 @@ __metadata: languageName: node linkType: hard +"@sindresorhus/slugify@npm:^2.2.1": + version: 2.2.1 + resolution: "@sindresorhus/slugify@npm:2.2.1" + dependencies: + "@sindresorhus/transliterate": "npm:^1.0.0" + escape-string-regexp: "npm:^5.0.0" + checksum: 10c0/c3fe41d917347f0e2a1e25a48225afffde8ef379a26217e749d5267e965f564c6a555fa17475b637d6fd84645f42e1e4b530477b57110fa80428024a0fadba25 + languageName: node + linkType: hard + +"@sindresorhus/transliterate@npm:^1.0.0": + version: 1.6.0 + resolution: "@sindresorhus/transliterate@npm:1.6.0" + dependencies: + escape-string-regexp: "npm:^5.0.0" + checksum: 10c0/c5552abd98eb4ab3a8653ccb7addf24e0b6f2aa2a4c420689033f8c9d292abb2222fc08e330adf4055580ac78fe810b7467ed012cdf38f4d64175c42571b8b15 + languageName: node + linkType: hard + "@swc/counter@npm:^0.1.3": version: 0.1.3 resolution: "@swc/counter@npm:0.1.3" @@ -5644,6 +5663,7 @@ __metadata: "@bucketco/eslint-config": "workspace:^" "@bucketco/tsconfig": "workspace:^" "@inquirer/prompts": "npm:^5.3.8" + "@sindresorhus/slugify": "npm:^2.2.1" "@types/fs-extra": "npm:^11.0.4" "@types/node": "npm:^22.5.1" axios: "npm:^1.7.5" @@ -5651,6 +5671,8 @@ __metadata: commander: "npm:^12.1.0" fs-extra: "npm:^11.2.0" open: "npm:^10.1.0" + slug: "npm:^9.1.0" + slugify: "npm:^1.6.6" ts-node: "npm:^10.9.2" typescript: "npm:^5.5.4" bin: @@ -7494,6 +7516,13 @@ __metadata: languageName: node linkType: hard +"escape-string-regexp@npm:^5.0.0": + version: 5.0.0 + resolution: "escape-string-regexp@npm:5.0.0" + checksum: 10c0/6366f474c6f37a802800a435232395e04e9885919873e382b157ab7e8f0feb8fed71497f84a6f6a81a49aab41815522f5839112bd38026d203aea0c91622df95 + languageName: node + linkType: hard + "eslint-config-next@npm:14.2.5": version: 14.2.5 resolution: "eslint-config-next@npm:14.2.5" @@ -14272,6 +14301,22 @@ __metadata: languageName: node linkType: hard +"slug@npm:^9.1.0": + version: 9.1.0 + resolution: "slug@npm:9.1.0" + bin: + slug: cli.js + checksum: 10c0/edc3e957de63e2d78d3809e0bf217da34ccb6ca313a83e2c1598ede2bd001644978d15eb69650a7b238c84adacbbab7318c4601c082aa433a3a2fd5e39523c2d + languageName: node + linkType: hard + +"slugify@npm:^1.6.6": + version: 1.6.6 + resolution: "slugify@npm:1.6.6" + checksum: 10c0/e7e63f08f389a371d6228bc19d64ec84360bf0a538333446cc49dbbf3971751a6d180d2f31551188dd007a65ca771e69f574e0283290a7825a818e90b75ef44d + languageName: node + linkType: hard + "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" From 491b2247ca39365cb344cf396c4412bb453ccf6e Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Sat, 31 Aug 2024 11:44:02 +0100 Subject: [PATCH 03/61] feat: added spinners --- packages/cli/commands/apps.ts | 9 +- packages/cli/commands/auth.ts | 16 +++- packages/cli/commands/envs.ts | 9 +- packages/cli/commands/features.ts | 10 +++ packages/cli/package.json | 3 +- yarn.lock | 136 +++++++++++++++++++++++++----- 6 files changed, 156 insertions(+), 27 deletions(-) diff --git a/packages/cli/commands/apps.ts b/packages/cli/commands/apps.ts index d7f70ad0..136a65ea 100644 --- a/packages/cli/commands/apps.ts +++ b/packages/cli/commands/apps.ts @@ -1,6 +1,7 @@ import { select } from "@inquirer/prompts"; import chalk from "chalk"; import { Command } from "commander"; +import ora from "ora"; import { listApps } from "../services/bootstrap.js"; import { checkAuth } from "../utils/auth.js"; @@ -14,12 +15,15 @@ export function registerAppsCommands(program: Command) { .command("list") .description("List all available apps") .action(async () => { + const spinner = ora("Loading apps...").start(); checkAuth(); try { const apps = await listApps(); + spinner.succeed(); console.log(chalk.green("Available apps:")); console.table(apps); } catch (error) { + spinner.fail(); handleError(error, "Failed to list apps:"); } }); @@ -28,10 +32,11 @@ export function registerAppsCommands(program: Command) { .command("select") .description("Select app") .action(async () => { + const spinner = ora("Loading apps...").start(); checkAuth(); try { const apps = await listApps(); - + spinner.succeed(); const answer = await select({ message: "Select an app", choices: apps.map((app) => ({ @@ -40,9 +45,9 @@ export function registerAppsCommands(program: Command) { description: app.demo ? "Demo" : undefined, })), }); - await writeConfigFile("appId", answer); } catch (error) { + spinner.fail(); handleError(error, "Failed to select app:"); } }); diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index 95e585bf..5d612cfe 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -1,5 +1,6 @@ import chalk from "chalk"; import { Command } from "commander"; +import ora from "ora"; import { authenticateUser } from "../utils/auth.js"; import { writeConfigFile } from "../utils/config.js"; @@ -11,13 +12,15 @@ export function registerAuthCommands(program: Command) { authCommand .command("login") .description("Login to Bucket") - .requiredOption("-a, --appId ", "Get all features in app") .action(async () => { + const spinner = ora("Logging in...").start(); try { // Initiate the auth process await authenticateUser(); + spinner.succeed(); console.log(chalk.green("Logged in successfully!")); } catch (error) { + spinner.fail(); handleError(error, "Authentication failed:"); } }); @@ -26,8 +29,15 @@ export function registerAuthCommands(program: Command) { .command("logout") .description("Logout from Bucket") .action(async () => { - await writeConfigFile("sessionCookies", undefined); - console.log(chalk.green("Logged out successfully!")); + const spinner = ora("Logging out...").start(); + try { + await writeConfigFile("sessionCookies", undefined); + spinner.succeed(); + console.log(chalk.green("Logged out successfully!")); + } catch (error) { + spinner.fail(); + handleError(error, "Logout failed:"); + } }); program.addCommand(authCommand); diff --git a/packages/cli/commands/envs.ts b/packages/cli/commands/envs.ts index 6d428cfb..f6a978e8 100644 --- a/packages/cli/commands/envs.ts +++ b/packages/cli/commands/envs.ts @@ -1,6 +1,7 @@ import { select } from "@inquirer/prompts"; import chalk from "chalk"; import { Command } from "commander"; +import ora from "ora"; import { listEnvs } from "../services/bootstrap.js"; import { checkAuth } from "../utils/auth.js"; @@ -20,12 +21,15 @@ export function registerEnvsCommands(program: Command) { getConfig("appId"), ) .action(async ({ appId }) => { + const spinner = ora("Loading environments...").start(); checkAuth(); try { const envs = await listEnvs(appId); + spinner.succeed(); console.log(chalk.green(`Available environments for app ${appId}:`)); console.table(envs); } catch (error) { + spinner.fail(); handleError(error, "Failed to list environment:"); } }); @@ -39,10 +43,11 @@ export function registerEnvsCommands(program: Command) { getConfig("appId"), ) .action(async ({ appId }) => { + const spinner = ora("Loading environments...").start(); checkAuth(); try { const envs = await listEnvs(appId); - + spinner.succeed(); const answer = await select({ message: "Select an environment", choices: envs.map(({ id, name }) => ({ @@ -50,9 +55,9 @@ export function registerEnvsCommands(program: Command) { value: id, })), }); - await writeConfigFile("envId", answer); } catch (error) { + spinner.fail(); handleError(error, "Failed to select environment:"); } }); diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index 73123a18..27e0773f 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -1,6 +1,7 @@ import chalk from "chalk"; import { Command } from "commander"; import { outputFile } from "fs-extra/esm"; +import ora from "ora"; import { createFeature, @@ -26,12 +27,15 @@ export function registerFeaturesCommands(program: Command) { getConfig("appId"), ) .action(async ({ appId }) => { + const spinner = ora("Loading features...").start(); checkAuth(); try { const features = await listFeatures(appId); + spinner.succeed(); console.log(chalk.green(`Features in ${appId}:`)); console.table(features); } catch (error) { + spinner.fail(); handleError(error, "Failed to list features:"); } }); @@ -50,12 +54,15 @@ export function registerFeaturesCommands(program: Command) { GEN_TYPES_FILE, ) .action(async ({ appId, out }) => { + const spinner = ora("Generating feature types...").start(); checkAuth(); try { const types = await genFeatureTypes(appId); await outputFile(out, types); + spinner.succeed(); console.log(chalk.green(`Generated features for ${appId}.`)); } catch (error) { + spinner.fail(); handleError(error, "Failed to generate feature types:"); } }); @@ -79,15 +86,18 @@ export function registerFeaturesCommands(program: Command) { `Create a feature in the app with the given feature key. Falls back to a slug of the feature name.`, ) .action(async (name, { appId, envId, key }) => { + const spinner = ora("Creating feature...").start(); checkAuth(); try { const feature = await createFeature(appId, envId, name, key); + spinner.succeed(); console.log( chalk.green( `Created feature ${feature.name} with key ${feature.key}.`, ), ); } catch (error) { + spinner.fail(); handleError(error, "Failed to create feature:"); } }); diff --git a/packages/cli/package.json b/packages/cli/package.json index 7a26b9ae..b84c4adf 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,7 +19,8 @@ "chalk": "^5.3.0", "commander": "^12.1.0", "fs-extra": "^11.2.0", - "open": "^10.1.0" + "open": "^10.1.0", + "ora": "^8.1.0" }, "devDependencies": { "@bucketco/eslint-config": "workspace:^", diff --git a/yarn.lock b/yarn.lock index 44a8856a..1338b13f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5671,8 +5671,7 @@ __metadata: commander: "npm:^12.1.0" fs-extra: "npm:^11.2.0" open: "npm:^10.1.0" - slug: "npm:^9.1.0" - slugify: "npm:^1.6.6" + ora: "npm:^8.1.0" ts-node: "npm:^10.9.2" typescript: "npm:^5.5.4" bin: @@ -6071,6 +6070,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" @@ -7082,6 +7090,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" @@ -8355,6 +8370,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" @@ -9498,6 +9520,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" @@ -9710,6 +9739,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" @@ -10505,6 +10548,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" @@ -10812,6 +10865,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" @@ -11985,6 +12045,15 @@ __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" @@ -12064,6 +12133,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" @@ -13760,6 +13846,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" @@ -14301,22 +14397,6 @@ __metadata: languageName: node linkType: hard -"slug@npm:^9.1.0": - version: 9.1.0 - resolution: "slug@npm:9.1.0" - bin: - slug: cli.js - checksum: 10c0/edc3e957de63e2d78d3809e0bf217da34ccb6ca313a83e2c1598ede2bd001644978d15eb69650a7b238c84adacbbab7318c4601c082aa433a3a2fd5e39523c2d - languageName: node - linkType: hard - -"slugify@npm:^1.6.6": - version: 1.6.6 - resolution: "slugify@npm:1.6.6" - checksum: 10c0/e7e63f08f389a371d6228bc19d64ec84360bf0a538333446cc49dbbf3971751a6d180d2f31551188dd007a65ca771e69f574e0283290a7825a818e90b75ef44d - languageName: node - linkType: hard - "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" @@ -14515,6 +14595,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" @@ -14567,6 +14654,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" @@ -14701,7 +14799,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: From 1908278e6b5b908d281cac242684ee79c89ce27e Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Mon, 9 Dec 2024 19:24:00 +0100 Subject: [PATCH 04/61] updated Cli login mechanism --- packages/cli/commands/auth.ts | 2 +- packages/cli/utils/auth.ts | 32 ++++++++++++++++++++++---------- packages/cli/utils/config.ts | 2 +- packages/cli/utils/constants.ts | 7 +++++-- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index 5d612cfe..f7520340 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -31,7 +31,7 @@ export function registerAuthCommands(program: Command) { .action(async () => { const spinner = ora("Logging out...").start(); try { - await writeConfigFile("sessionCookies", undefined); + await writeConfigFile("token", undefined); spinner.succeed(); console.log(chalk.green("Logged out successfully!")); } catch (error) { diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index 87b02b24..cee53f8c 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -3,7 +3,7 @@ import axios, { AxiosError, AxiosRequestConfig } from "axios"; import open from "open"; import { getConfig, writeConfigFile } from "./config.js"; -import { API_BASE_URL } from "./constants.js"; +import { API_BASE_URL, loginUrl } from "./constants.js"; /** * @return {Promise} @@ -13,32 +13,44 @@ export async function authenticateUser() { const server = http.createServer((req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); - if (url.pathname !== "/") { + if (url.pathname !== "/cli-login") { res.writeHead(404).end("Invalid path"); server.close(); reject(new Error("Could not authenticate: Invalid path")); return; } - if (!req.headers.cookie?.includes("session.sig")) { + // Handle preflight request + if (req.method === "OPTIONS") { + res.writeHead(200, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST", + "Access-Control-Allow-Headers": "Authorization", + }); + res.end(); + return; + } + + if (!req.headers.authorization?.startsWith("Bearer ")) { res.writeHead(400).end("Could not authenticate"); server.close(); reject(new Error("Could not authenticate")); return; } + const token = req.headers.authorization.slice("Bearer ".length); + res.end("You can now close this tab."); server.close(); - writeConfigFile("sessionCookies", req.headers.cookie); - resolve(req.headers.cookie); + writeConfigFile("token", token); + resolve(token); }); server.listen(); const address = server.address(); if (address && typeof address === "object") { const port = address.port; - const redirect = `http://localhost:${port}`; - open(`${API_BASE_URL}/auth/google?redirect=${redirect}`, { + open(loginUrl(port), { newInstance: true, }); } @@ -46,7 +58,7 @@ export async function authenticateUser() { } export function checkAuth() { - if (!getConfig("sessionCookies")) { + if (!getConfig("token")) { throw new Error( 'You are not authenticated. Please run "bucket auth login" first.', ); @@ -64,7 +76,7 @@ export async function authRequest>( url: `${API_BASE_URL}${url}`, headers: { ...options?.headers, - Cookie: getConfig("sessionCookies"), + Authorization: "Bearer " + getConfig("token"), }, }); return response.data; @@ -74,7 +86,7 @@ export async function authRequest>( error.response && error.response.status === 401 ) { - writeConfigFile("sessionCookies", undefined); + writeConfigFile("token", undefined); error.message = "Your session has expired. Please login again."; throw error; } diff --git a/packages/cli/utils/config.ts b/packages/cli/utils/config.ts index 7579cb38..539cdf13 100644 --- a/packages/cli/utils/config.ts +++ b/packages/cli/utils/config.ts @@ -3,7 +3,7 @@ import { readJson, writeJson } from "fs-extra/esm"; import { CONFIG_FILE } from "./constants.js"; type Config = { - sessionCookies?: string; + token?: string; appId?: string; envId?: string; }; diff --git a/packages/cli/utils/constants.ts b/packages/cli/utils/constants.ts index 3cc0cfa3..534b6cce 100644 --- a/packages/cli/utils/constants.ts +++ b/packages/cli/utils/constants.ts @@ -1,7 +1,10 @@ import path from "path"; -export const API_BASE_URL = - "http://localhost:3100/api" ?? "https://app.bucket.co/api"; +const baseUrl = process.env.BUCKET_BASE_URL ?? "https://app.bucket.co"; +export const loginUrl = (localPort: number) => + `${baseUrl}/login?redirect_url=` + + encodeURIComponent("/cli-login?port=" + localPort); +export const API_BASE_URL = `${baseUrl}/api`; export const CONFIG_FILE = path.join( process.env.HOME || "", From e1d26babeec2105f5450cfe4f0611437e1a1e271 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Sun, 19 Jan 2025 19:20:56 +0100 Subject: [PATCH 05/61] wip cli --- packages/browser-sdk/package.json | 4 +- packages/browser-sdk/src/client.ts | 8 +- packages/browser-sdk/vite.config.mjs | 2 +- packages/cli/commands/apps.ts | 56 --- packages/cli/commands/auth.ts | 44 -- packages/cli/commands/envs.ts | 66 --- packages/cli/commands/features.ts | 109 ++--- packages/cli/index.ts | 12 +- packages/cli/package.json | 14 +- packages/cli/services/bootstrap.ts | 31 -- packages/cli/services/features.ts | 74 ++-- packages/cli/utils/auth.ts | 170 ++++---- packages/cli/utils/config.ts | 61 +-- packages/cli/utils/constants.ts | 19 +- packages/cli/utils/gen.ts | 95 ++++- packages/react-sdk/vite.config.mjs | 3 +- yarn.lock | 577 ++++++++++++++++++++++----- 17 files changed, 754 insertions(+), 591 deletions(-) delete mode 100644 packages/cli/commands/apps.ts delete mode 100644 packages/cli/commands/auth.ts delete mode 100644 packages/cli/commands/envs.ts delete mode 100644 packages/cli/services/bootstrap.ts diff --git a/packages/browser-sdk/package.json b/packages/browser-sdk/package.json index bf9bcc07..1c7c8c41 100644 --- a/packages/browser-sdk/package.json +++ b/packages/browser-sdk/package.json @@ -21,7 +21,8 @@ "lint:ci": "eslint --output-file eslint-report.json --format json .", "prettier": "prettier --check .", "format": "yarn lint --fix && yarn prettier --write", - "preversion": "yarn lint && yarn prettier && yarn vitest run -c vitest.config.ts && yarn build" + "preversion": "yarn lint && yarn prettier && yarn vitest run -c vitest.config.ts && yarn build", + "postinstall": "node --eval \"import('@bucketco/cli')\" cli features generate --ignore-missing-config" }, "files": [ "dist" @@ -36,6 +37,7 @@ } }, "dependencies": { + "@bucketco/cli": "1.0.0", "@floating-ui/dom": "^1.6.8", "canonical-json": "^0.0.4", "js-cookie": "^3.0.5", diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index a9f0fe92..27da8511 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -19,6 +19,10 @@ import { BucketContext, CompanyContext, UserContext } from "./context"; import { HttpClient } from "./httpClient"; import { Logger, loggerWithPrefix, quietConsoleLogger } from "./logger"; +// file is generated on install and the updated using the CLI +// @ts-expect-error - generated file +import { generatedFeatures } from ".bucket/generated"; + const isMobile = typeof window !== "undefined" && window.innerWidth < 768; const isNode = typeof document === "undefined"; // deno supports "window" but not "document" according to https://remix.run/docs/en/main/guides/gotchas @@ -236,6 +240,8 @@ export class BucketClient { sdkVersion: opts?.sdkVersion, }); + const features = opts?.features ?? generatedFeatures; + this.featuresClient = new FeaturesClient( this.httpClient, // API expects `other` and we have `otherContext`. @@ -245,7 +251,7 @@ export class BucketClient { other: this.context.otherContext, }, this.logger, - opts?.features, + features, ); if ( diff --git a/packages/browser-sdk/vite.config.mjs b/packages/browser-sdk/vite.config.mjs index 73a36a74..bd7029e3 100644 --- a/packages/browser-sdk/vite.config.mjs +++ b/packages/browser-sdk/vite.config.mjs @@ -28,7 +28,7 @@ export default defineConfig({ rollupOptions: { // make sure to externalize deps that shouldn't be bundled // into your library - // external: ["vue"], + external: [".bucket/generated"], output: { // Provide global variables to use in the UMD build // for externalized deps diff --git a/packages/cli/commands/apps.ts b/packages/cli/commands/apps.ts deleted file mode 100644 index 136a65ea..00000000 --- a/packages/cli/commands/apps.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { select } from "@inquirer/prompts"; -import chalk from "chalk"; -import { Command } from "commander"; -import ora from "ora"; - -import { listApps } from "../services/bootstrap.js"; -import { checkAuth } from "../utils/auth.js"; -import { writeConfigFile } from "../utils/config.js"; -import { handleError } from "../utils/error.js"; - -export function registerAppsCommands(program: Command) { - const appsCommand = new Command("apps").description("Manage apps"); - - appsCommand - .command("list") - .description("List all available apps") - .action(async () => { - const spinner = ora("Loading apps...").start(); - checkAuth(); - try { - const apps = await listApps(); - spinner.succeed(); - console.log(chalk.green("Available apps:")); - console.table(apps); - } catch (error) { - spinner.fail(); - handleError(error, "Failed to list apps:"); - } - }); - - appsCommand - .command("select") - .description("Select app") - .action(async () => { - const spinner = ora("Loading apps...").start(); - checkAuth(); - try { - const apps = await listApps(); - spinner.succeed(); - const answer = await select({ - message: "Select an app", - choices: apps.map((app) => ({ - name: app.name, - value: app.id, - description: app.demo ? "Demo" : undefined, - })), - }); - await writeConfigFile("appId", answer); - } catch (error) { - spinner.fail(); - handleError(error, "Failed to select app:"); - } - }); - - program.addCommand(appsCommand); -} diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts deleted file mode 100644 index f7520340..00000000 --- a/packages/cli/commands/auth.ts +++ /dev/null @@ -1,44 +0,0 @@ -import chalk from "chalk"; -import { Command } from "commander"; -import ora from "ora"; - -import { authenticateUser } from "../utils/auth.js"; -import { writeConfigFile } from "../utils/config.js"; -import { handleError } from "../utils/error.js"; - -export function registerAuthCommands(program: Command) { - const authCommand = new Command("auth").description("Manage authentication"); - - authCommand - .command("login") - .description("Login to Bucket") - .action(async () => { - const spinner = ora("Logging in...").start(); - try { - // Initiate the auth process - await authenticateUser(); - spinner.succeed(); - console.log(chalk.green("Logged in successfully!")); - } catch (error) { - spinner.fail(); - handleError(error, "Authentication failed:"); - } - }); - - authCommand - .command("logout") - .description("Logout from Bucket") - .action(async () => { - const spinner = ora("Logging out...").start(); - try { - await writeConfigFile("token", undefined); - spinner.succeed(); - console.log(chalk.green("Logged out successfully!")); - } catch (error) { - spinner.fail(); - handleError(error, "Logout failed:"); - } - }); - - program.addCommand(authCommand); -} diff --git a/packages/cli/commands/envs.ts b/packages/cli/commands/envs.ts deleted file mode 100644 index f6a978e8..00000000 --- a/packages/cli/commands/envs.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { select } from "@inquirer/prompts"; -import chalk from "chalk"; -import { Command } from "commander"; -import ora from "ora"; - -import { listEnvs } from "../services/bootstrap.js"; -import { checkAuth } from "../utils/auth.js"; -import { getConfig, writeConfigFile } from "../utils/config.js"; -import { CONFIG_FILE } from "../utils/constants.js"; -import { handleError } from "../utils/error.js"; - -export function registerEnvsCommands(program: Command) { - const envsCommand = new Command("envs").description("Manage envs"); - - envsCommand - .command("list") - .description("List all available environments for the app") - .requiredOption( - "-a, --appId ", - `Get all environments for the app. Falls back to appId stored in ${CONFIG_FILE}.`, - getConfig("appId"), - ) - .action(async ({ appId }) => { - const spinner = ora("Loading environments...").start(); - checkAuth(); - try { - const envs = await listEnvs(appId); - spinner.succeed(); - console.log(chalk.green(`Available environments for app ${appId}:`)); - console.table(envs); - } catch (error) { - spinner.fail(); - handleError(error, "Failed to list environment:"); - } - }); - - envsCommand - .command("select") - .description("Select environment") - .requiredOption( - "-a, --appId ", - `Get all environments for the app. Falls back to appId stored in ${CONFIG_FILE}.`, - getConfig("appId"), - ) - .action(async ({ appId }) => { - const spinner = ora("Loading environments...").start(); - checkAuth(); - try { - const envs = await listEnvs(appId); - spinner.succeed(); - const answer = await select({ - message: "Select an environment", - choices: envs.map(({ id, name }) => ({ - name, - value: id, - })), - }); - await writeConfigFile("envId", answer); - } catch (error) { - spinner.fail(); - handleError(error, "Failed to select environment:"); - } - }); - - program.addCommand(envsCommand); -} diff --git a/packages/cli/commands/features.ts b/packages/cli/commands/features.ts index 27e0773f..ed4955da 100644 --- a/packages/cli/commands/features.ts +++ b/packages/cli/commands/features.ts @@ -1,105 +1,56 @@ import chalk from "chalk"; import { Command } from "commander"; -import { outputFile } from "fs-extra/esm"; -import ora from "ora"; -import { - createFeature, - genFeatureTypes, - listFeatures, -} from "../services/features.js"; -import { checkAuth } from "../utils/auth.js"; -import { getConfig } from "../utils/config.js"; -import { CONFIG_FILE, GEN_TYPES_FILE } from "../utils/constants.js"; +import { input } from "@inquirer/prompts"; + +import { addFeatureToConfig, genFeatureTypes } from "../services/features.js"; + import { handleError } from "../utils/error.js"; +import { configFileExists, getConfig, loadConfig } from "../utils/config.js"; + export function registerFeaturesCommands(program: Command) { const featuresCommand = new Command("features").description( "Manage features", ); featuresCommand - .command("list") - .description("List all features") - .requiredOption( - "-a, --appId ", - `Get all features in the app. Falls back to appId stored in ${CONFIG_FILE}.`, - getConfig("appId"), - ) - .action(async ({ appId }) => { - const spinner = ora("Loading features...").start(); - checkAuth(); - try { - const features = await listFeatures(appId); - spinner.succeed(); - console.log(chalk.green(`Features in ${appId}:`)); - console.table(features); - } catch (error) { - spinner.fail(); - handleError(error, "Failed to list features:"); - } - }); - - featuresCommand - .command("types") + .command("generate") .description("Generate feature types") - .requiredOption( - "-a, --appId ", - `Generate types for features in the app. Falls back to appId stored in ${CONFIG_FILE}.`, - getConfig("appId"), - ) - .option( - "-o, --out ", - `Generate types for features at the output path. Falls back to ${GEN_TYPES_FILE}.`, - GEN_TYPES_FILE, - ) - .action(async ({ appId, out }) => { - const spinner = ora("Generating feature types...").start(); - checkAuth(); + .option("--ignore-missing-config", "Ignore missing config") + .action(async ({ ignoreMissingConfig }) => { try { - const types = await genFeatureTypes(appId); - await outputFile(out, types); - spinner.succeed(); - console.log(chalk.green(`Generated features for ${appId}.`)); + if (!(await configFileExists()) && ignoreMissingConfig) return; + + await loadConfig(); + + genFeatureTypes(getConfig().sdk, getConfig().features); + console.log(chalk.green(`Generated typed features.`)); } catch (error) { - spinner.fail(); handleError(error, "Failed to generate feature types:"); } }); featuresCommand - .command("create") - .description("Create a new feature") - .argument("", "Name of the feature") - .requiredOption( - "-a, --appId ", - `Get all features in the app. Falls back to appId stored in ${CONFIG_FILE}.`, - getConfig("appId"), - ) - .requiredOption( - "-e, --envId ", - `Get all features in the env. Falls back to envId stored in ${CONFIG_FILE}.`, - getConfig("envId"), - ) - .option( - "-k, --key ", - `Create a feature in the app with the given feature key. Falls back to a slug of the feature name.`, - ) - .action(async (name, { appId, envId, key }) => { - const spinner = ora("Creating feature...").start(); - checkAuth(); + .command("add") + .description("Add a new feature") + .argument("[key]", "Key for the feature") + .action(async (key) => { + await loadConfig(); try { - const feature = await createFeature(appId, envId, name, key); - spinner.succeed(); - console.log( - chalk.green( - `Created feature ${feature.name} with key ${feature.key}.`, - ), - ); + if (key === undefined) { + key = await input({ + message: "Feature key", + required: true, + }); + } + await addFeatureToConfig({ key, access: true, config: undefined }); + console.log(chalk.green(`Added feature "${key}"`)); } catch (error) { - spinner.fail(); handleError(error, "Failed to create feature:"); } + + await genFeatureTypes(getConfig().sdk, getConfig().features); }); program.addCommand(featuresCommand); diff --git a/packages/cli/index.ts b/packages/cli/index.ts index c5856819..7d6ac3e1 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -1,21 +1,13 @@ #!/usr/bin/env node import { program } from "commander"; -import { registerAppsCommands } from "./commands/apps.js"; -import { registerAuthCommands } from "./commands/auth.js"; -import { registerEnvsCommands } from "./commands/envs.js"; import { registerFeaturesCommands } from "./commands/features.js"; -import { readConfigFile } from "./utils/config.js"; +import { registerInitCommands } from "./commands/init.js"; async function main() { - // Read the config file - await readConfigFile(); - // Main program - registerAuthCommands(program); - registerAppsCommands(program); - registerEnvsCommands(program); registerFeaturesCommands(program); + registerInitCommands(program); program.parse(process.argv); } diff --git a/packages/cli/package.json b/packages/cli/package.json index b84c4adf..0e96fe81 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,5 +1,5 @@ { - "name": "bucket-cli", + "name": "@bucketco/cli", "version": "1.0.0", "packageManager": "yarn@4.1.1", "description": "CLI for Bucket service", @@ -10,17 +10,20 @@ }, "scripts": { "build": "tsc", - "start": "yarn build && node dist/index.js" + "start": "yarn build && node dist/index.js", + "test": "vitest" }, "dependencies": { - "@inquirer/prompts": "^5.3.8", + "@inquirer/prompts": "^7.2.3", "@sindresorhus/slugify": "^2.2.1", "axios": "^1.7.5", "chalk": "^5.3.0", "commander": "^12.1.0", + "find-up": "^7.0.0", "fs-extra": "^11.2.0", "open": "^10.1.0", - "ora": "^8.1.0" + "ora": "^8.1.0", + "strip-json-comments": "^5.0.1" }, "devDependencies": { "@bucketco/eslint-config": "workspace:^", @@ -28,6 +31,7 @@ "@types/fs-extra": "^11.0.4", "@types/node": "^22.5.1", "ts-node": "^10.9.2", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "vitest": "^2.1.8" } } diff --git a/packages/cli/services/bootstrap.ts b/packages/cli/services/bootstrap.ts deleted file mode 100644 index 0b398517..00000000 --- a/packages/cli/services/bootstrap.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { authRequest } from "../utils/auth.js"; - -type Environment = { - id: string; - name: string; - isProduction: boolean; - order: number; -}; - -type App = { - id: string; - name: string; - demo: boolean; - environments: Environment[]; -}; - -type BootstrapResponse = { - org: { - apps: App[]; - }; -}; - -export async function listApps() { - const response = await authRequest(`/bootstrap`); - return response.org.apps.map(({ id, name, demo }) => ({ id, name, demo })); -} - -export async function listEnvs(appId: string) { - const response = await authRequest(`/bootstrap`); - return response.org.apps.find(({ id }) => id === appId)?.environments ?? []; -} diff --git a/packages/cli/services/features.ts b/packages/cli/services/features.ts index 5eb364e0..197cfa31 100644 --- a/packages/cli/services/features.ts +++ b/packages/cli/services/features.ts @@ -1,55 +1,33 @@ -import { authRequest } from "../utils/auth.js"; -import { genDTS, genFeatureKey } from "../utils/gen.js"; +import chalk from "chalk"; +import { + ConfigFeatureDefs, + readConfigFile, + writeConfigFile, +} from "../utils/config.js"; +import { FeatureDef, genDTS, OutputType } from "../utils/gen.js"; +import { outputFile } from "fs-extra"; -type Feature = { - id: string; - name: string; - key: string; -}; - -type FeatureNamesResponse = Feature[]; - -export async function listFeatures(appId: string) { - const response = await authRequest( - `/apps/${appId}/features/names`, - ); - return response.map(({ name, key }) => ({ - name, - key, +export async function genFeatureTypes( + outputType: OutputType, + configFeatures: ConfigFeatureDefs, +) { + const features = configFeatures.map((feature) => ({ + key: typeof feature === "string" ? feature : feature.key, + access: typeof feature === "string" ? true : (feature.access ?? true), + config: typeof feature === "string" ? undefined : feature.config, })); -} -export async function genFeatureTypes(appId: string) { - const response = await authRequest( - `/apps/${appId}/features/names`, - ); - return genDTS(response.map(({ key }) => key)); + const generatedTypes = genDTS(outputType, features); + await outputFile(`node_modules/.bucket/generated`, generatedTypes); + console.log(chalk.green("Updated typed features.")); } -type FeatureResponse = { - feature: Feature; -}; +export async function addFeatureToConfig(feature: FeatureDef) { + const config = await readConfigFile(); -export async function createFeature( - appId: string, - envId: string, - name: string, - key: string | undefined, -) { - const features = await listFeatures(appId); - const response = await authRequest( - `/apps/${appId}/features?envId=${envId}`, - { - method: "POST", - data: { - name, - key: genFeatureKey( - key ?? name, - features.map(({ key }) => key), - ), - source: "event", - }, - }, - ); - return response.feature; + if (feature.access && feature.config === undefined) + config.features.push(feature.key); + else config.features.push(feature); + + await writeConfigFile(config); } diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index cee53f8c..49bb3a0d 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -1,95 +1,95 @@ -import http from "http"; -import axios, { AxiosError, AxiosRequestConfig } from "axios"; -import open from "open"; +// import http from "http"; +// import axios, { AxiosError, AxiosRequestConfig } from "axios"; +// import open from "open"; -import { getConfig, writeConfigFile } from "./config.js"; -import { API_BASE_URL, loginUrl } from "./constants.js"; +// import { getConfig, writeConfigFile } from "./config.js"; +// import { API_BASE_URL, loginUrl } from "./constants.js"; -/** - * @return {Promise} - */ -export async function authenticateUser() { - return new Promise((resolve, reject) => { - const server = http.createServer((req, res) => { - const url = new URL(req.url ?? "/", "http://localhost"); +// /** +// * @return {Promise} +// */ +// export async function authenticateUser() { +// return new Promise((resolve, reject) => { +// const server = http.createServer((req, res) => { +// const url = new URL(req.url ?? "/", "http://localhost"); - if (url.pathname !== "/cli-login") { - res.writeHead(404).end("Invalid path"); - server.close(); - reject(new Error("Could not authenticate: Invalid path")); - return; - } +// if (url.pathname !== "/cli-login") { +// res.writeHead(404).end("Invalid path"); +// server.close(); +// reject(new Error("Could not authenticate: Invalid path")); +// return; +// } - // Handle preflight request - if (req.method === "OPTIONS") { - res.writeHead(200, { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST", - "Access-Control-Allow-Headers": "Authorization", - }); - res.end(); - return; - } +// // Handle preflight request +// if (req.method === "OPTIONS") { +// res.writeHead(200, { +// "Access-Control-Allow-Origin": "*", +// "Access-Control-Allow-Methods": "POST", +// "Access-Control-Allow-Headers": "Authorization", +// }); +// res.end(); +// return; +// } - if (!req.headers.authorization?.startsWith("Bearer ")) { - res.writeHead(400).end("Could not authenticate"); - server.close(); - reject(new Error("Could not authenticate")); - return; - } +// if (!req.headers.authorization?.startsWith("Bearer ")) { +// res.writeHead(400).end("Could not authenticate"); +// server.close(); +// reject(new Error("Could not authenticate")); +// return; +// } - const token = req.headers.authorization.slice("Bearer ".length); +// const token = req.headers.authorization.slice("Bearer ".length); - res.end("You can now close this tab."); - server.close(); - writeConfigFile("token", token); - resolve(token); - }); +// res.end("You can now close this tab."); +// server.close(); +// writeConfigFile("token", token); +// resolve(token); +// }); - server.listen(); - const address = server.address(); - if (address && typeof address === "object") { - const port = address.port; - open(loginUrl(port), { - newInstance: true, - }); - } - }); -} +// server.listen(); +// const address = server.address(); +// if (address && typeof address === "object") { +// const port = address.port; +// open(loginUrl(port), { +// newInstance: true, +// }); +// } +// }); +// } -export function checkAuth() { - if (!getConfig("token")) { - throw new Error( - 'You are not authenticated. Please run "bucket auth login" first.', - ); - } -} +// export function checkAuth() { +// if (!getConfig("token")) { +// throw new Error( +// 'You are not authenticated. Please run "bucket auth login" first.', +// ); +// } +// } -export async function authRequest>( - url: string, - options?: AxiosRequestConfig, -): Promise { - checkAuth(); - try { - const response = await axios({ - ...options, - url: `${API_BASE_URL}${url}`, - headers: { - ...options?.headers, - Authorization: "Bearer " + getConfig("token"), - }, - }); - return response.data; - } catch (error) { - if ( - error instanceof AxiosError && - error.response && - error.response.status === 401 - ) { - writeConfigFile("token", undefined); - error.message = "Your session has expired. Please login again."; - throw error; - } - throw error; - } -} +// export async function authRequest>( +// url: string, +// options?: AxiosRequestConfig, +// ): Promise { +// checkAuth(); +// try { +// const response = await axios({ +// ...options, +// url: `${API_BASE_URL}${url}`, +// headers: { +// ...options?.headers, +// Authorization: "Bearer " + getConfig("token"), +// }, +// }); +// return response.data; +// } catch (error) { +// if ( +// error instanceof AxiosError && +// error.response && +// error.response.status === 401 +// ) { +// writeConfigFile("token", undefined); +// error.message = "Your session has expired. Please login again."; +// throw error; +// } +// throw error; +// } +// } diff --git a/packages/cli/utils/config.ts b/packages/cli/utils/config.ts index 539cdf13..8220301e 100644 --- a/packages/cli/utils/config.ts +++ b/packages/cli/utils/config.ts @@ -1,48 +1,63 @@ import { readJson, writeJson } from "fs-extra/esm"; +import { findUp } from "find-up"; -import { CONFIG_FILE } from "./constants.js"; +import { REPO_CONFIG_FILE } from "./constants.js"; +import { Datatype } from "./gen.js"; type Config = { - token?: string; - appId?: string; - envId?: string; + features: ConfigFeatureDefs; + sdk: "browser" | "react"; }; -let config: Config = {}; +export type ConfigFeatureDef = { + key: string; + access?: boolean; + config?: Datatype; +}; + +export type ConfigFeatureDefs = Array; + +let config: Config = { + features: [], + sdk: "browser", +}; /** * Instantly return a specified key's value or the entire config object. */ export function getConfig(): Config; -export function getConfig(key: keyof Config): string | undefined; export function getConfig(key?: keyof Config) { return key ? config[key] : config; } +export async function findRepoConfig() { + return await findUp(REPO_CONFIG_FILE); +} + /** * Read the config file and return either a specified key's value or the entire config object. */ -export async function readConfigFile(): Promise; -export async function readConfigFile( - key: keyof Config, -): Promise; -export async function readConfigFile(key?: keyof Config) { - try { - config = await readJson(CONFIG_FILE); - return key ? config[key] : config; - } catch (error) { - return {}; +export async function readConfigFile() { + const location = await findRepoConfig(); + if (!location) { + throw new Error("No bucket.config.js file found."); } + return await readJson(location); } /** * Write a new value to the config file. */ -export async function writeConfigFile( - key: keyof Config, - value: string | undefined, -) { - const config = await readConfigFile(); - config[key] = value; - await writeJson(CONFIG_FILE, config); +export async function writeConfigFile(config: object, location?: string) { + const path = location ? location : await findRepoConfig(); + if (!path) throw new Error("writeConfigFile: Could not find config file."); + await writeJson(path, config, { spaces: 2 }); +} + +export async function loadConfig() { + config = await readConfigFile(); +} + +export async function configFileExists() { + return !!(await findRepoConfig()); } diff --git a/packages/cli/utils/constants.ts b/packages/cli/utils/constants.ts index 534b6cce..d6c14449 100644 --- a/packages/cli/utils/constants.ts +++ b/packages/cli/utils/constants.ts @@ -1,18 +1 @@ -import path from "path"; - -const baseUrl = process.env.BUCKET_BASE_URL ?? "https://app.bucket.co"; -export const loginUrl = (localPort: number) => - `${baseUrl}/login?redirect_url=` + - encodeURIComponent("/cli-login?port=" + localPort); -export const API_BASE_URL = `${baseUrl}/api`; - -export const CONFIG_FILE = path.join( - process.env.HOME || "", - ".bucket-cli-config.json", -); - -export const GEN_TYPES_FILE = path.join( - process.cwd(), - "gen", - "feature-flag-types.ts", -); +export const REPO_CONFIG_FILE = "bucket.config.json"; diff --git a/packages/cli/utils/gen.ts b/packages/cli/utils/gen.ts index ec672dea..429d3ce6 100644 --- a/packages/cli/utils/gen.ts +++ b/packages/cli/utils/gen.ts @@ -1,24 +1,93 @@ import slugify from "@sindresorhus/slugify"; -export const genDTS = (keys: string[]) => { - return /* ts */ ` +export type Datatype = + | "string" + | "boolean" + | "number" + | { [key: string]: Datatype }; + +export type FeatureDef = { + key: string; + access: boolean; + config?: Datatype; +}; + +export type OutputType = "browser" | "react"; + +const indentString = (str: string, count: number): string => + str.replace(/^/gm, " ".repeat(count)); + +function maybeStringify(value: any, stringify: boolean): string { + return stringify ? `"${value}"` : value; +} + +function output(value: any, stringTypes: boolean, indent: number = 0): string { + if (value === "boolean") return maybeStringify("boolean", stringTypes); + if (value === "string") return maybeStringify("string", stringTypes); + if (value === "number") return maybeStringify("number", stringTypes); + + if (Array.isArray(value)) + return indentString( + `[\n` + + value + .map((v: any) => indentString(output(v, stringTypes, indent), 1)) + .join(", \n") + + `\n]\n`, + indent, + ); + + if (typeof value === "object") + return indentString( + `{\n` + + indentString( + Object.entries(value) + .map(([k, v]) => `${k}: ${output(v, stringTypes)}`) + .join(", \n"), + 1, + ) + + `\n}`, + indent, + ); + + return JSON.stringify(value); +} + +function generateTypescriptOutput(features: FeatureDef[], react: boolean) { + const browserOutput = /* ts */ ` // DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET CLI AND WILL BE OVERWRITTEN. -const availableFeatures = [ - "${keys.join('",\n "')}" -] as const; +export const generatedFeatures = ${output(features, true).trimEnd()} as const; -type ArrayToRecord = { - [Key in T[number]]: boolean; -}; +`; -export type AvailableFeatures = ArrayToRecord; + const reactOutput = ` +declare module "@bucket/react-sdk" ${output( + Object.fromEntries( + features.map((feature) => [ + feature.key, + { + key: feature.key, + access: feature.access, + config: feature.config, + }, + ]), + ), + false, + )} + `.trim(); + return react ? browserOutput + "\n\n" + reactOutput : browserOutput; +} -declare module "@bucketco/react-sdk" { - interface Features extends AvailableFeatures {} +export function genDTS(output: OutputType, features: FeatureDef[]): string { + switch (output) { + case "react": + return generateTypescriptOutput(features, true); + case "browser": + return generateTypescriptOutput(features, false); + default: + throw new Error("Invalid SDK type when generating types."); + } } -`.trim(); -}; export function genFeatureKey(input: string, existingKeys: string[]): string { const keySlug = slugify(input); diff --git a/packages/react-sdk/vite.config.mjs b/packages/react-sdk/vite.config.mjs index 9f73e246..fd9e9bc9 100644 --- a/packages/react-sdk/vite.config.mjs +++ b/packages/react-sdk/vite.config.mjs @@ -24,10 +24,11 @@ export default defineConfig({ formats: ["es", "umd"], }, rollupOptions: { - external: ["react", "react-dom"], + external: ["react", "react-dom", ".bucket/generated"], output: { globals: { react: "React", + ".bucket/generated": "generated", }, }, }, diff --git a/yarn.lock b/yarn.lock index bc007407..51486716 100644 --- a/yarn.lock +++ b/yarn.lock @@ -898,6 +898,7 @@ __metadata: version: 0.0.0-use.local resolution: "@bucketco/browser-sdk@workspace:packages/browser-sdk" dependencies: + "@bucketco/cli": "npm:1.0.0" "@bucketco/eslint-config": "npm:0.0.2" "@bucketco/tsconfig": "npm:0.0.2" "@floating-ui/dom": "npm:^1.6.8" @@ -926,6 +927,32 @@ __metadata: languageName: unknown linkType: soft +"@bucketco/cli@npm:1.0.0, @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:^7.2.3" + "@sindresorhus/slugify": "npm:^2.2.1" + "@types/fs-extra": "npm:^11.0.4" + "@types/node": "npm:^22.5.1" + axios: "npm:^1.7.5" + chalk: "npm:^5.3.0" + commander: "npm:^12.1.0" + find-up: "npm:^7.0.0" + fs-extra: "npm:^11.2.0" + open: "npm:^10.1.0" + ora: "npm:^8.1.0" + strip-json-comments: "npm:^5.0.1" + ts-node: "npm:^10.9.2" + typescript: "npm:^5.5.4" + vitest: "npm:^2.1.8" + 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" @@ -1039,6 +1066,7 @@ __metadata: "@types/react": "npm:^18.3.2" "@types/react-dom": "npm:^18.3.0" "@types/webpack": "npm:^5.28.5" + bucket-cli: ../cli canonical-json: "npm:^0.0.4" eslint: "npm:^8.57.0" jsdom: "npm:^24.1.0" @@ -1884,16 +1912,18 @@ __metadata: languageName: node linkType: hard -"@inquirer/checkbox@npm:^2.4.7": - version: 2.4.7 - resolution: "@inquirer/checkbox@npm:2.4.7" +"@inquirer/checkbox@npm:^4.0.6": + version: 4.0.6 + resolution: "@inquirer/checkbox@npm:4.0.6" dependencies: - "@inquirer/core": "npm:^9.0.10" - "@inquirer/figures": "npm:^1.0.5" - "@inquirer/type": "npm:^1.5.2" + "@inquirer/core": "npm:^10.1.4" + "@inquirer/figures": "npm:^1.0.9" + "@inquirer/type": "npm:^3.0.2" ansi-escapes: "npm:^4.3.2" yoctocolors-cjs: "npm:^2.1.2" - checksum: 10c0/92bd2727f316e2304b150ef32eb3023200512de154b49b6e121f468c2ad6fa58cb3b37d9dc91d1a2dc039e932dd934df5ce481acb2555cacac2b0308acb05576 + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/919e3c5d652f1ccd9d5e8e9678e63981a968ba4a0dffe9d9409d94a1951b398218f7dfb05e57aefcb3c3c1d61ac2333160e370b0ff4632ada7a92ebe07a2ca72 languageName: node linkType: hard @@ -1907,34 +1937,32 @@ __metadata: languageName: node linkType: hard -"@inquirer/confirm@npm:^3.1.22": - version: 3.1.22 - resolution: "@inquirer/confirm@npm:3.1.22" +"@inquirer/confirm@npm:^5.1.3": + version: 5.1.3 + resolution: "@inquirer/confirm@npm:5.1.3" dependencies: - "@inquirer/core": "npm:^9.0.10" - "@inquirer/type": "npm:^1.5.2" - checksum: 10c0/99e1a17e62a674d8e440a11bf4e9d5a62666247823b091314e52ee40929a6a6e8ce60086ec653bbeb59117bfc940d807c6f4b604cf5cf51f24009b9d09d5bf98 + "@inquirer/core": "npm:^10.1.4" + "@inquirer/type": "npm:^3.0.2" + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/ddbca429ebb3a8bf1d10928f4ab0c8eedbf3f74f85ed64c6b26a830f0fbbab5fa964b9ef2eb2c57a10b9afc9ca3921a12e4659f5a83069078cd1a7ce3d0d126d languageName: node linkType: hard -"@inquirer/core@npm:^9.0.10": - version: 9.0.10 - resolution: "@inquirer/core@npm:9.0.10" +"@inquirer/core@npm:^10.1.4": + version: 10.1.4 + resolution: "@inquirer/core@npm:10.1.4" 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" + "@inquirer/figures": "npm:^1.0.9" + "@inquirer/type": "npm:^3.0.2" ansi-escapes: "npm:^4.3.2" - cli-spinners: "npm:^2.9.2" cli-width: "npm:^4.1.0" - mute-stream: "npm:^1.0.0" + mute-stream: "npm:^2.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 + checksum: 10c0/4e6c51713c79a0b22381a08a2d11c37f2d696597d01bdecd7b3482889e53e4ac279c55d663a365798ad52becc37052b571bc3ec85ee8a10054c681d9248b88d3 languageName: node linkType: hard @@ -1959,25 +1987,29 @@ __metadata: languageName: node linkType: hard -"@inquirer/editor@npm:^2.1.22": - version: 2.1.22 - resolution: "@inquirer/editor@npm:2.1.22" +"@inquirer/editor@npm:^4.2.3": + version: 4.2.3 + resolution: "@inquirer/editor@npm:4.2.3" dependencies: - "@inquirer/core": "npm:^9.0.10" - "@inquirer/type": "npm:^1.5.2" + "@inquirer/core": "npm:^10.1.4" + "@inquirer/type": "npm:^3.0.2" external-editor: "npm:^3.1.0" - checksum: 10c0/a2e65362ed187695450b092c8f5698661002e4e24e1e800dfbbfeaa8e71f60b5d5e1dfa98b9402457c02cab1762ac2b20d3364c11db0b5572aa61caf55f5beba + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/0194a660b33e38781c35a6ab531f76beca998e5e30ebc90bb94e2140fd943e0dfcff4f9c650f4f79f74df7dac04c82db254ff8c2d9ef0b55c491523f859a8c2b languageName: node linkType: hard -"@inquirer/expand@npm:^2.1.22": - version: 2.1.22 - resolution: "@inquirer/expand@npm:2.1.22" +"@inquirer/expand@npm:^4.0.6": + version: 4.0.6 + resolution: "@inquirer/expand@npm:4.0.6" dependencies: - "@inquirer/core": "npm:^9.0.10" - "@inquirer/type": "npm:^1.5.2" + "@inquirer/core": "npm:^10.1.4" + "@inquirer/type": "npm:^3.0.2" yoctocolors-cjs: "npm:^2.1.2" - checksum: 10c0/0f9d3447ca6b9e24e0179b4ec53f463647a8323d8a041bc3321f19a2914176117a264bcc6deb723e3f9ec718d3faf672f3f840f0898bbff4371fa899b239b411 + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/2a4990744edf17528c5cf894b9a7a04f202740fb9e2123fb8ced1e623f5bf9716976b037e1b23e88cfce826a85b6052d49784ac2294644e353767b51a0a2f877 languageName: node linkType: hard @@ -1988,88 +2020,109 @@ __metadata: languageName: node linkType: hard -"@inquirer/input@npm:^2.2.9": - version: 2.2.9 - resolution: "@inquirer/input@npm:2.2.9" +"@inquirer/figures@npm:^1.0.9": + version: 1.0.9 + resolution: "@inquirer/figures@npm:1.0.9" + checksum: 10c0/21e1a7c902b2b77f126617b501e0fe0d703fae680a9df472afdae18a3e079756aee85690cef595a14e91d18630118f4a3893aab6832b9232fefc6ab31c804a68 + languageName: node + linkType: hard + +"@inquirer/input@npm:^4.1.3": + version: 4.1.3 + resolution: "@inquirer/input@npm:4.1.3" dependencies: - "@inquirer/core": "npm:^9.0.10" - "@inquirer/type": "npm:^1.5.2" - checksum: 10c0/0fcdc5d9c17712f9a2c79f39d1e03ed4a58cfe1dd1095209b4c83621dba2cb94db03b7df0df34e22584bce9e53403a284c76721def66a452e4751666d945d99f + "@inquirer/core": "npm:^10.1.4" + "@inquirer/type": "npm:^3.0.2" + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/251468b9596fcbff286d0817da7408f2a78230c1f84de23361e6362a8a91e5bf4c42c04f4971a8fe751eb0afc4ab1cef0d3766742fd4e693b4b0cbcc72aa8d97 languageName: node linkType: hard -"@inquirer/number@npm:^1.0.10": - version: 1.0.10 - resolution: "@inquirer/number@npm:1.0.10" +"@inquirer/number@npm:^3.0.6": + version: 3.0.6 + resolution: "@inquirer/number@npm:3.0.6" dependencies: - "@inquirer/core": "npm:^9.0.10" - "@inquirer/type": "npm:^1.5.2" - checksum: 10c0/efa7c49322d8f36eeb8afb704bba673c10fcf7992babc8ad5f25d4c8db0fbafc0007439abdef05a462171b37a68b981981859587ff5b71e79002ffac65be859a + "@inquirer/core": "npm:^10.1.4" + "@inquirer/type": "npm:^3.0.2" + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/26c030735bdc94053dfca50db1e75c7e325b8dcc009f3f9e6f572d89b67d7b23cfb3920ed2fa6fa34c312b5ebb6b86ba5b4e77c277ce463720eba45052c0d253 languageName: node linkType: hard -"@inquirer/password@npm:^2.1.22": - version: 2.1.22 - resolution: "@inquirer/password@npm:2.1.22" +"@inquirer/password@npm:^4.0.6": + version: 4.0.6 + resolution: "@inquirer/password@npm:4.0.6" dependencies: - "@inquirer/core": "npm:^9.0.10" - "@inquirer/type": "npm:^1.5.2" + "@inquirer/core": "npm:^10.1.4" + "@inquirer/type": "npm:^3.0.2" ansi-escapes: "npm:^4.3.2" - checksum: 10c0/5cd5bab0026d673539710f424e6f7dda8abea4863a0cddf982278b15a250f5a2be0a0f17fdf970a900d33187a64ad987d24d038cfbed2599b5a2a97c169bbddc + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/c36f675d350c38156efe255d9b3a052271faff2bfcebf626f5f02092e9110ef8f87a6985e96dd0c2351fa79723d0847bacaa86ae10c1d24526434de96af5503e 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 +"@inquirer/prompts@npm:^7.2.3": + version: 7.2.3 + resolution: "@inquirer/prompts@npm:7.2.3" + dependencies: + "@inquirer/checkbox": "npm:^4.0.6" + "@inquirer/confirm": "npm:^5.1.3" + "@inquirer/editor": "npm:^4.2.3" + "@inquirer/expand": "npm:^4.0.6" + "@inquirer/input": "npm:^4.1.3" + "@inquirer/number": "npm:^3.0.6" + "@inquirer/password": "npm:^4.0.6" + "@inquirer/rawlist": "npm:^4.0.6" + "@inquirer/search": "npm:^3.0.6" + "@inquirer/select": "npm:^4.0.6" + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/52c2e1fd8a4e98bc5fd6a79acf9c7d7e1ecc534013d7c018412a6ed34d15be69a2d10791b539740fed8e538485e359e1cacbec98ca3d04e24c5e9fa9480d7bc6 languageName: node linkType: hard -"@inquirer/rawlist@npm:^2.2.4": - version: 2.2.4 - resolution: "@inquirer/rawlist@npm:2.2.4" +"@inquirer/rawlist@npm:^4.0.6": + version: 4.0.6 + resolution: "@inquirer/rawlist@npm:4.0.6" dependencies: - "@inquirer/core": "npm:^9.0.10" - "@inquirer/type": "npm:^1.5.2" + "@inquirer/core": "npm:^10.1.4" + "@inquirer/type": "npm:^3.0.2" yoctocolors-cjs: "npm:^2.1.2" - checksum: 10c0/d7c5e0784bb357f6465b7ca08a22310f124fb61db6cce7a1860305d8d25dcdfa66db216d4cc52873d68ae379376984cf8d9cd14880fbca41b7b03802be49caf8 + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/c79f0ddd5cf7eae8db27a7080e277c32809d7bd58619f470d8b1598d1aff36f6aac276535ef35801a1dae97bb3763fd248e1067800e6eccd49276206d6cdb945 languageName: node linkType: hard -"@inquirer/search@npm:^1.0.7": - version: 1.0.7 - resolution: "@inquirer/search@npm:1.0.7" +"@inquirer/search@npm:^3.0.6": + version: 3.0.6 + resolution: "@inquirer/search@npm:3.0.6" dependencies: - "@inquirer/core": "npm:^9.0.10" - "@inquirer/figures": "npm:^1.0.5" - "@inquirer/type": "npm:^1.5.2" + "@inquirer/core": "npm:^10.1.4" + "@inquirer/figures": "npm:^1.0.9" + "@inquirer/type": "npm:^3.0.2" yoctocolors-cjs: "npm:^2.1.2" - checksum: 10c0/29179cc32236689b956cccdc092b537c67e841c5cd0a6473b92f9e71f56c0fb737baa4856bf76572f07c0c3999b6b5ea1ce3b74ef56504e179098f700a85a5cf + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/27afe9105b9fd26b5985847f75c82f59156158b6366e35896764cd08ee7bb76e3d9c7110c6ed50ab4d7e13466ea3f0e60492a644e0eb6a0d8c30701b07221ad9 languageName: node linkType: hard -"@inquirer/select@npm:^2.4.7": - version: 2.4.7 - resolution: "@inquirer/select@npm:2.4.7" +"@inquirer/select@npm:^4.0.6": + version: 4.0.6 + resolution: "@inquirer/select@npm:4.0.6" dependencies: - "@inquirer/core": "npm:^9.0.10" - "@inquirer/figures": "npm:^1.0.5" - "@inquirer/type": "npm:^1.5.2" + "@inquirer/core": "npm:^10.1.4" + "@inquirer/figures": "npm:^1.0.9" + "@inquirer/type": "npm:^3.0.2" ansi-escapes: "npm:^4.3.2" yoctocolors-cjs: "npm:^2.1.2" - checksum: 10c0/34e120a263ca2e7edeab08ef6ca24d0966135e8d1a9d6f167fbc03fa8f057391228d58292281609a25d51eb9d59d0b1d00146bd2a5811c5d3880800321cfe8e6 + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/5346007a5f62ff88f36c219b625d6031ba12fea6849b38aab1d3ed1219387004bf1c3a44aeec47a3988c9aeb1934a8a06509fe9e00f39135fa22113a01e1cc37 languageName: node linkType: hard @@ -2082,12 +2135,12 @@ __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 +"@inquirer/type@npm:^3.0.2": + version: 3.0.2 + resolution: "@inquirer/type@npm:3.0.2" + peerDependencies: + "@types/node": ">=18" + checksum: 10c0/fe348db2977fff92cad0ade05b36ec40714326fccd4a174be31663f8923729b4276f1736d892a449627d7fb03235ff44e8aac5aa72b09036d993593b813ef313 languageName: node linkType: hard @@ -2181,6 +2234,13 @@ __metadata: languageName: node linkType: hard +"@jridgewell/sourcemap-codec@npm:^1.5.0": + version: 1.5.0 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.0" + checksum: 10c0/2eb864f276eb1096c3c11da3e9bb518f6d9fc0023c78344cdc037abadc725172c70314bdb360f2d4b7bffec7f5d657ce006816bc5d4ecb35e61b66132db00c18 + languageName: node + linkType: hard + "@jridgewell/trace-mapping@npm:0.3.9": version: 0.3.9 resolution: "@jridgewell/trace-mapping@npm:0.3.9" @@ -3982,7 +4042,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^22.1.0, @types/node@npm:^22.5.1": +"@types/node@npm:^22.5.1": version: 22.5.1 resolution: "@types/node@npm:22.5.1" dependencies: @@ -4352,6 +4412,37 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:2.1.8": + version: 2.1.8 + resolution: "@vitest/expect@npm:2.1.8" + dependencies: + "@vitest/spy": "npm:2.1.8" + "@vitest/utils": "npm:2.1.8" + chai: "npm:^5.1.2" + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/6fbf4abc2360efe4d3671d3425f8bb6012fe2dd932a88720d8b793030b766ba260494822c721d3fc497afe52373515c7e150635a95c25f6e1b567f86155c5408 + languageName: node + linkType: hard + +"@vitest/mocker@npm:2.1.8": + version: 2.1.8 + resolution: "@vitest/mocker@npm:2.1.8" + dependencies: + "@vitest/spy": "npm:2.1.8" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.12" + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/b4113ed8a57c0f60101d02e1b1769357a346ecd55ded499eab384d52106fd4b12d51e9aaa6db98f47de0d56662477be0ed8d46d6dfa84c235f9e1b234709814e + languageName: node + linkType: hard + "@vitest/pretty-format@npm:2.0.4, @vitest/pretty-format@npm:^2.0.4": version: 2.0.4 resolution: "@vitest/pretty-format@npm:2.0.4" @@ -4370,6 +4461,15 @@ __metadata: languageName: node linkType: hard +"@vitest/pretty-format@npm:2.1.8, @vitest/pretty-format@npm:^2.1.8": + version: 2.1.8 + resolution: "@vitest/pretty-format@npm:2.1.8" + dependencies: + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/1dc5c9b1c7c7e78e46a2a16033b6b20be05958bbebc5a5b78f29e32718c80252034804fccd23f34db6b3583239db47e68fc5a8e41942c54b8047cc3b4133a052 + languageName: node + linkType: hard + "@vitest/runner@npm:1.6.0": version: 1.6.0 resolution: "@vitest/runner@npm:1.6.0" @@ -4401,6 +4501,16 @@ __metadata: languageName: node linkType: hard +"@vitest/runner@npm:2.1.8": + version: 2.1.8 + resolution: "@vitest/runner@npm:2.1.8" + dependencies: + "@vitest/utils": "npm:2.1.8" + pathe: "npm:^1.1.2" + checksum: 10c0/d0826a71494adeafc8c6478257f584d11655145c83e2d8f94c17301d7059c7463ad768a69379e394c50838a7435abcc9255a6b7d8894f5ee06b153e314683a75 + languageName: node + linkType: hard + "@vitest/snapshot@npm:1.6.0": version: 1.6.0 resolution: "@vitest/snapshot@npm:1.6.0" @@ -4434,6 +4544,17 @@ __metadata: languageName: node linkType: hard +"@vitest/snapshot@npm:2.1.8": + version: 2.1.8 + resolution: "@vitest/snapshot@npm:2.1.8" + dependencies: + "@vitest/pretty-format": "npm:2.1.8" + magic-string: "npm:^0.30.12" + pathe: "npm:^1.1.2" + checksum: 10c0/8d7a77a52e128630ea737ee0a0fe746d1d325cac5848326861dbf042844da4d5c1a5145539ae0ed1a3f0b0363506e98d86f2679fadf114ec4b987f1eb616867b + languageName: node + linkType: hard + "@vitest/spy@npm:1.6.0": version: 1.6.0 resolution: "@vitest/spy@npm:1.6.0" @@ -4461,6 +4582,15 @@ __metadata: languageName: node linkType: hard +"@vitest/spy@npm:2.1.8": + version: 2.1.8 + resolution: "@vitest/spy@npm:2.1.8" + dependencies: + tinyspy: "npm:^3.0.2" + checksum: 10c0/9740f10772ede004ea7f9ffb8a6c3011341d75d9d7f2d4d181b123a701c4691e942f38cf1700684a3bb5eea3c78addf753fd8cdf78c51d8eadc3bada6fadf8f2 + languageName: node + linkType: hard + "@vitest/utils@npm:1.6.0": version: 1.6.0 resolution: "@vitest/utils@npm:1.6.0" @@ -4497,6 +4627,17 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:2.1.8": + version: 2.1.8 + resolution: "@vitest/utils@npm:2.1.8" + dependencies: + "@vitest/pretty-format": "npm:2.1.8" + loupe: "npm:^3.1.2" + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/d4a29ecd8f6c24c790e4c009f313a044d89e664e331bc9c3cfb57fe1380fb1d2999706dbbfc291f067d6c489602e76d00435309fbc906197c0d01f831ca17d64 + languageName: node + linkType: hard + "@volar/language-core@npm:1.11.1, @volar/language-core@npm:~1.11.1": version: 1.11.1 resolution: "@volar/language-core@npm:1.11.1" @@ -5710,28 +5851,25 @@ __metadata: languageName: node linkType: hard -"bucket-cli@workspace:packages/cli": - version: 0.0.0-use.local - resolution: "bucket-cli@workspace:packages/cli" +"bucket-cli@file:../cli::locator=%40bucketco%2Freact-sdk%40workspace%3Apackages%2Freact-sdk": + version: 1.0.0 + resolution: "bucket-cli@file:../cli#../cli::hash=87a70c&locator=%40bucketco%2Freact-sdk%40workspace%3Apackages%2Freact-sdk" dependencies: - "@bucketco/eslint-config": "workspace:^" - "@bucketco/tsconfig": "workspace:^" - "@inquirer/prompts": "npm:^5.3.8" + "@inquirer/prompts": "npm:^7.2.3" "@sindresorhus/slugify": "npm:^2.2.1" - "@types/fs-extra": "npm:^11.0.4" - "@types/node": "npm:^22.5.1" axios: "npm:^1.7.5" chalk: "npm:^5.3.0" commander: "npm:^12.1.0" + find-up: "npm:^7.0.0" fs-extra: "npm:^11.2.0" open: "npm:^10.1.0" ora: "npm:^8.1.0" - ts-node: "npm:^10.9.2" - typescript: "npm:^5.5.4" + strip-json-comments: "npm:^5.0.1" bin: bucket: ./dist/index.js - languageName: unknown - linkType: soft + checksum: 10c0/7763ae53f92dbf3731af6120e4ee8b6c11ed1ffc12268c2d16d0391c720b6ccdb3193250a794cb86c79bec3abe39b4c35631499b2fda0563b0bf1e9428262f0e + languageName: node + linkType: hard "buffer-from@npm:^1.0.0": version: 1.1.2 @@ -6007,6 +6145,19 @@ __metadata: languageName: node linkType: hard +"chai@npm:^5.1.2": + version: 5.1.2 + resolution: "chai@npm:5.1.2" + dependencies: + assertion-error: "npm:^2.0.1" + check-error: "npm:^2.1.1" + deep-eql: "npm:^5.0.1" + loupe: "npm:^3.1.0" + pathval: "npm:^2.0.0" + checksum: 10c0/6c04ff8495b6e535df9c1b062b6b094828454e9a3c9493393e55b2f4dbff7aa2a29a4645133cad160fb00a16196c4dc03dc9bb37e1f4ba9df3b5f50d7533a736 + languageName: node + linkType: hard + "chalk@npm:4.1.0": version: 4.1.0 resolution: "chalk@npm:4.1.0" @@ -6784,6 +6935,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:^4.3.7": + version: 4.4.0 + resolution: "debug@npm:4.4.0" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/db94f1a182bf886f57b4755f85b3a74c39b5114b9377b7ab375dc2cfa3454f09490cc6c30f829df3fc8042bc8b8995f6567ce5cd96f3bc3688bd24027197d9de + languageName: node + linkType: hard + "decamelize-keys@npm:^1.1.0": version: 1.1.1 resolution: "decamelize-keys@npm:1.1.1" @@ -7424,6 +7587,13 @@ __metadata: languageName: node linkType: hard +"es-module-lexer@npm:^1.5.4": + version: 1.6.0 + resolution: "es-module-lexer@npm:1.6.0" + checksum: 10c0/667309454411c0b95c476025929881e71400d74a746ffa1ff4cb450bd87f8e33e8eef7854d68e401895039ac0bac64e7809acbebb6253e055dd49ea9e3ea9212 + languageName: node + linkType: hard + "es-object-atoms@npm:^1.0.0": version: 1.0.0 resolution: "es-object-atoms@npm:1.0.0" @@ -8036,6 +8206,13 @@ __metadata: languageName: node linkType: hard +"expect-type@npm:^1.1.0": + version: 1.1.0 + resolution: "expect-type@npm:1.1.0" + checksum: 10c0/5af0febbe8fe18da05a6d51e3677adafd75213512285408156b368ca471252565d5ca6e59e4bddab25121f3cfcbbebc6a5489f8cc9db131cc29e69dcdcc7ae15 + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -8189,6 +8366,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" @@ -10566,6 +10754,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" @@ -10650,6 +10847,13 @@ __metadata: languageName: node linkType: hard +"loupe@npm:^3.1.2": + version: 3.1.2 + resolution: "loupe@npm:3.1.2" + checksum: 10c0/b13c02e3ddd6a9d5f8bf84133b3242de556512d824dddeea71cce2dbd6579c8f4d672381c4e742d45cf4423d0701765b4a6e5fbc24701def16bc2b40f8daa96a + languageName: node + linkType: hard + "lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0": version: 10.2.2 resolution: "lru-cache@npm:10.2.2" @@ -10716,6 +10920,15 @@ __metadata: languageName: node linkType: hard +"magic-string@npm:^0.30.12": + version: 0.30.17 + resolution: "magic-string@npm:0.30.17" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.0" + checksum: 10c0/16826e415d04b88378f200fe022b53e638e3838b9e496edda6c0e086d7753a44a6ed187adc72d19f3623810589bf139af1a315541cd6a26ae0771a0193eaf7b8 + languageName: node + linkType: hard + "magicast@npm:^0.3.2": version: 0.3.2 resolution: "magicast@npm:0.3.2" @@ -11225,7 +11438,7 @@ __metadata: languageName: node linkType: hard -"ms@npm:^2.0.0, ms@npm:^2.1.1": +"ms@npm:^2.0.0, ms@npm:^2.1.1, ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 @@ -11337,6 +11550,13 @@ __metadata: languageName: node linkType: hard +"mute-stream@npm:^2.0.0": + version: 2.0.0 + resolution: "mute-stream@npm:2.0.0" + checksum: 10c0/2cf48a2087175c60c8dcdbc619908b49c07f7adcfc37d29236b0c5c612d6204f789104c98cc44d38acab7b3c96f4a3ec2cfdc4934d0738d876dbefa2a12c69f4 + languageName: node + linkType: hard + "mz@npm:^2.7.0": version: 2.7.0 resolution: "mz@npm:2.7.0" @@ -12300,6 +12520,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" @@ -12336,6 +12565,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" @@ -12522,6 +12760,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" @@ -14704,6 +14949,13 @@ __metadata: languageName: node linkType: hard +"std-env@npm:^3.8.0": + version: 3.8.0 + resolution: "std-env@npm:3.8.0" + checksum: 10c0/f560a2902fd0fa3d648d7d0acecbd19d664006f7372c1fba197ed4c216b4c9e48db6e2769b5fe1616d42a9333c9f066c5011935035e85c59f45dc4f796272040 + languageName: node + linkType: hard + "stdin-discarder@npm:^0.2.2": version: 0.2.2 resolution: "stdin-discarder@npm:0.2.2" @@ -14961,6 +15213,13 @@ __metadata: languageName: node linkType: hard +"strip-json-comments@npm:^5.0.1": + version: 5.0.1 + resolution: "strip-json-comments@npm:5.0.1" + checksum: 10c0/c9d9d55a0167c57aa688df3aa20628cf6f46f0344038f189eaa9d159978e80b2bfa6da541a40d83f7bde8a3554596259bf6b70578b2172356536a0e3fa5a0982 + languageName: node + linkType: hard + "strip-literal@npm:^2.0.0": version: 2.1.0 resolution: "strip-literal@npm:2.1.0" @@ -15255,6 +15514,20 @@ __metadata: languageName: node linkType: hard +"tinybench@npm:^2.9.0": + version: 2.9.0 + resolution: "tinybench@npm:2.9.0" + checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c + languageName: node + linkType: hard + +"tinyexec@npm:^0.3.1": + version: 0.3.2 + resolution: "tinyexec@npm:0.3.2" + checksum: 10c0/3efbf791a911be0bf0821eab37a3445c2ba07acc1522b1fa84ae1e55f10425076f1290f680286345ed919549ad67527d07281f1c19d584df3b74326909eb1f90 + languageName: node + linkType: hard + "tinypool@npm:^0.8.3": version: 0.8.4 resolution: "tinypool@npm:0.8.4" @@ -15269,6 +15542,13 @@ __metadata: languageName: node linkType: hard +"tinypool@npm:^1.0.1": + version: 1.0.2 + resolution: "tinypool@npm:1.0.2" + checksum: 10c0/31ac184c0ff1cf9a074741254fe9ea6de95026749eb2b8ec6fd2b9d8ca94abdccda731f8e102e7f32e72ed3b36d32c6975fd5f5523df3f1b6de6c3d8dfd95e63 + languageName: node + linkType: hard + "tinyrainbow@npm:^1.2.0": version: 1.2.0 resolution: "tinyrainbow@npm:1.2.0" @@ -15290,6 +15570,13 @@ __metadata: languageName: node linkType: hard +"tinyspy@npm:^3.0.2": + version: 3.0.2 + resolution: "tinyspy@npm:3.0.2" + checksum: 10c0/55ffad24e346622b59292e097c2ee30a63919d5acb7ceca87fc0d1c223090089890587b426e20054733f97a58f20af2c349fb7cc193697203868ab7ba00bcea0 + languageName: node + linkType: hard + "tmp@npm:^0.0.33": version: 0.0.33 resolution: "tmp@npm:0.0.33" @@ -15864,6 +16151,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" @@ -16112,6 +16406,21 @@ __metadata: languageName: node linkType: hard +"vite-node@npm:2.1.8": + version: 2.1.8 + resolution: "vite-node@npm:2.1.8" + dependencies: + cac: "npm:^6.7.14" + debug: "npm:^4.3.7" + es-module-lexer: "npm:^1.5.4" + pathe: "npm:^1.1.2" + vite: "npm:^5.0.0" + bin: + vite-node: vite-node.mjs + checksum: 10c0/cb28027a7425ba29780e216164c07d36a4ff9eb60d83afcad3bc222fd5a5f3e36030071c819edd6d910940f502d49e52f7564743617bc1c5875485b0952c72d5 + languageName: node + linkType: hard + "vite-plugin-dts@npm:^3.7.0": version: 3.7.0 resolution: "vite-plugin-dts@npm:3.7.0" @@ -16408,6 +16717,56 @@ __metadata: languageName: node linkType: hard +"vitest@npm:^2.1.8": + version: 2.1.8 + resolution: "vitest@npm:2.1.8" + dependencies: + "@vitest/expect": "npm:2.1.8" + "@vitest/mocker": "npm:2.1.8" + "@vitest/pretty-format": "npm:^2.1.8" + "@vitest/runner": "npm:2.1.8" + "@vitest/snapshot": "npm:2.1.8" + "@vitest/spy": "npm:2.1.8" + "@vitest/utils": "npm:2.1.8" + chai: "npm:^5.1.2" + debug: "npm:^4.3.7" + expect-type: "npm:^1.1.0" + magic-string: "npm:^0.30.12" + pathe: "npm:^1.1.2" + std-env: "npm:^3.8.0" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^0.3.1" + tinypool: "npm:^1.0.1" + tinyrainbow: "npm:^1.2.0" + vite: "npm:^5.0.0" + vite-node: "npm:2.1.8" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@types/node": ^18.0.0 || >=20.0.0 + "@vitest/browser": 2.1.8 + "@vitest/ui": 2.1.8 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@types/node": + optional: true + "@vitest/browser": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: vitest.mjs + checksum: 10c0/e70631bad5662d6c60c5cf836a4baf58b890db6654fef1f608fe6a86aa49a2b9f078aac74b719d4d3c87c5c781968cc73590a7935277b48f3d8b6fb9c5b4d276 + languageName: node + linkType: hard + "vscode-uri@npm:^3.0.8": version: 3.0.8 resolution: "vscode-uri@npm:3.0.8" From b6d943172a15b8e5bd72792c39119b68027970d8 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Sun, 19 Jan 2025 19:22:00 +0100 Subject: [PATCH 06/61] more wip --- packages/browser-sdk/src/client.ts | 1 - packages/cli/commands/init.ts | 57 +++++++++++++ .../__snapshots__/features.test.ts.snap | 85 +++++++++++++++++++ packages/cli/services/features.test.ts | 31 +++++++ packages/cli/utils/gen.test.ts | 27 ++++++ 5 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 packages/cli/commands/init.ts create mode 100644 packages/cli/services/__snapshots__/features.test.ts.snap create mode 100644 packages/cli/services/features.test.ts create mode 100644 packages/cli/utils/gen.test.ts diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 27da8511..c8a50dcf 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -18,7 +18,6 @@ import { API_BASE_URL, SSE_REALTIME_BASE_URL } from "./config"; import { BucketContext, CompanyContext, UserContext } from "./context"; import { HttpClient } from "./httpClient"; import { Logger, loggerWithPrefix, quietConsoleLogger } from "./logger"; - // file is generated on install and the updated using the CLI // @ts-expect-error - generated file import { generatedFeatures } from ".bucket/generated"; diff --git a/packages/cli/commands/init.ts b/packages/cli/commands/init.ts new file mode 100644 index 00000000..2f7a946d --- /dev/null +++ b/packages/cli/commands/init.ts @@ -0,0 +1,57 @@ +import chalk from "chalk"; +import { Command } from "commander"; + +import { handleError } from "../utils/error.js"; +import { findRepoConfig, writeConfigFile } from "../utils/config.js"; + +function trySDK(packageName: string) { + try { + require.resolve(packageName); + return true; + } catch (error) { + return false; + } +} + +function detectSDK() { + if (trySDK("@bucketco/react-sdk")) { + return "react"; + } + + if (trySDK("@bucketco/node-sdk")) { + return "node"; + } + + return "browser"; +} + +export function registerInitCommands(program: Command) { + program + .command("init") + .description("Initialize Bucket for new repository") + .option("-s, --sdk ", "SDK to generate types for") + .action(async ({ sdk }) => { + try { + const configFile = await findRepoConfig(); + if (configFile) { + console.log( + chalk.white(`Config file already exists at ${configFile}.`), + ); + return; + } + + const sdkType = sdk ?? detectSDK(); + + await writeConfigFile( + { + features: [], + sdk: sdkType, + }, + "bucket.config.json", + ); + chalk.green(`Bucket config initialized!`); + } catch (error) { + handleError(error, "Failed to generate feature types:"); + } + }); +} diff --git a/packages/cli/services/__snapshots__/features.test.ts.snap b/packages/cli/services/__snapshots__/features.test.ts.snap new file mode 100644 index 00000000..297d66ef --- /dev/null +++ b/packages/cli/services/__snapshots__/features.test.ts.snap @@ -0,0 +1,85 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`genFeatureTypes > should generate correct TypeScript output for browser 1`] = ` +" +// DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET CLI AND WILL BE OVERWRITTEN. + +export const features = [ + { + key: "feature1", + access: true, + config: undefined + }, + { + key: "feature2", + access: false, + config: "string" + }, + { + key: "feature3", + access: true, + config: { + aiModel: "string", + prompt: { + text: "string", + cheekyFactor: "number" + } + } + } +] as const; +" +`; + +exports[`genFeatureTypes > should generate correct TypeScript output for react 1`] = ` +" +// DO NOT EDIT THIS FILE. IT IS GENERATED BY THE BUCKET CLI AND WILL BE OVERWRITTEN. + +export const features = [ + { + key: "feature1", + access: true, + config: undefined + }, + { + key: "feature2", + access: false, + config: "string" + }, + { + key: "feature3", + access: true, + config: { + aiModel: "string", + prompt: { + text: "string", + cheekyFactor: "number" + } + } + } +] as const; + + +declare module "@bucket/react-sdk" { + feature1: { + key: "feature1", + access: true, + config: undefined + }, + feature2: { + key: "feature2", + access: false, + config: string + }, + feature3: { + key: "feature3", + access: true, + config: { + aiModel: string, + prompt: { + text: string, + cheekyFactor: number + } + } + } +}" +`; diff --git a/packages/cli/services/features.test.ts b/packages/cli/services/features.test.ts new file mode 100644 index 00000000..61660b41 --- /dev/null +++ b/packages/cli/services/features.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; + +import { ConfigFeatureDefs } from "../utils/config.js"; +import { genFeatureTypes } from "./features.js"; + +describe("genFeatureTypes", () => { + const features: ConfigFeatureDefs = [ + "feature1", + { key: "feature2", access: false, config: "string" }, + { + key: "feature3", + config: { + aiModel: "string", + prompt: { + text: "string", + cheekyFactor: "number", + }, + }, + }, + ]; + + it("should generate correct TypeScript output for browser", () => { + const output = genFeatureTypes("browser", features); + expect(output).toMatchSnapshot(); + }); + + it("should generate correct TypeScript output for react", () => { + const output = genFeatureTypes("react", features); + expect(output).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/utils/gen.test.ts b/packages/cli/utils/gen.test.ts new file mode 100644 index 00000000..b12ac604 --- /dev/null +++ b/packages/cli/utils/gen.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import { genDTS } from "./gen.js"; +import { ConfigFeatureDefs } from "./config.js"; + +describe("genFeatureTypes", () => { + const features: ConfigFeatureDefs = [ + "feature1", + { key: "feature2", access: false, config: "string" }, + { + key: "feature3", + config: { + aiModel: "string", + prompt: "string", + }, + }, + ]; + + it("should generate correct TypeScript output for browser", () => { + const output = genDTS("browser", features); + expect(output).toMatchSnapshot(); + }); + + it("should generate correct TypeScript output for react", () => { + const output = genDTS("react", features); + expect(output).toMatchSnapshot(); + }); +}); From e243cdbd74d4470d65a14157b9417065f87d0213 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Mon, 20 Jan 2025 12:54:54 +0100 Subject: [PATCH 07/61] extracting from `toolbar` --- packages/browser-sdk/example/browser.html | 27 +- .../browser-sdk/example/typescript/app.ts | 47 ++++ .../browser-sdk/example/typescript/index.html | 23 ++ packages/browser-sdk/src/client.ts | 33 +++ packages/browser-sdk/src/feature/features.ts | 56 ++-- packages/browser-sdk/src/feedback/feedback.ts | 49 +--- .../src/feedback/ui/FeedbackDialog.tsx | 240 +++--------------- .../src/feedback/ui/FeedbackForm.tsx | 4 +- packages/browser-sdk/src/feedback/ui/Plug.tsx | 4 +- .../src/feedback/ui/StarRating.tsx | 17 +- .../ui/config/defaultTranslations.tsx | 3 - packages/browser-sdk/src/feedback/ui/index.ts | 14 +- packages/browser-sdk/src/feedback/ui/types.ts | 33 +-- packages/browser-sdk/src/index.ts | 8 +- packages/browser-sdk/src/toolbar/Switch.tsx | 52 ++++ packages/browser-sdk/src/toolbar/Toolbar.css | 75 ++++++ packages/browser-sdk/src/toolbar/Toolbar.tsx | 173 +++++++++++++ packages/browser-sdk/src/toolbar/index.ts | 23 ++ packages/browser-sdk/src/ui/Dialog.tsx | 202 +++++++++++++++ .../src/{feedback => }/ui/constants.ts | 10 +- .../src/{feedback => }/ui/icons/Check.tsx | 0 .../{feedback => }/ui/icons/CheckCircle.tsx | 0 .../src/{feedback => }/ui/icons/Close.tsx | 0 .../{feedback => }/ui/icons/Dissatisfied.tsx | 0 .../src/{feedback => }/ui/icons/Logo.tsx | 4 +- .../src/{feedback => }/ui/icons/Neutral.tsx | 0 .../src/{feedback => }/ui/icons/Satisfied.tsx | 0 .../ui/icons/VeryDissatisfied.tsx | 0 .../{feedback => }/ui/icons/VerySatisfied.tsx | 0 .../packages/floating-ui-preact-dom/README.md | 0 .../packages/floating-ui-preact-dom/arrow.ts | 0 .../packages/floating-ui-preact-dom/index.ts | 0 .../packages/floating-ui-preact-dom/types.ts | 0 .../floating-ui-preact-dom/useFloating.ts | 0 .../floating-ui-preact-dom/utils/deepEqual.ts | 0 .../floating-ui-preact-dom/utils/getDPR.ts | 0 .../utils/roundByDPR.ts | 0 .../utils/useLatestRef.ts | 0 packages/browser-sdk/src/ui/types.ts | 21 ++ packages/browser-sdk/src/ui/utils.ts | 65 +++++ .../test/e2e/feedback-widget.browser.spec.ts | 5 +- packages/browser-sdk/test/features.test.ts | 1 + packages/node-sdk/src/types.ts | 4 +- packages/react-sdk/src/index.tsx | 11 +- 44 files changed, 864 insertions(+), 340 deletions(-) create mode 100644 packages/browser-sdk/example/typescript/app.ts create mode 100644 packages/browser-sdk/example/typescript/index.html create mode 100644 packages/browser-sdk/src/toolbar/Switch.tsx create mode 100644 packages/browser-sdk/src/toolbar/Toolbar.css create mode 100644 packages/browser-sdk/src/toolbar/Toolbar.tsx create mode 100644 packages/browser-sdk/src/toolbar/index.ts create mode 100644 packages/browser-sdk/src/ui/Dialog.tsx rename packages/browser-sdk/src/{feedback => }/ui/constants.ts (79%) rename packages/browser-sdk/src/{feedback => }/ui/icons/Check.tsx (100%) rename packages/browser-sdk/src/{feedback => }/ui/icons/CheckCircle.tsx (100%) rename packages/browser-sdk/src/{feedback => }/ui/icons/Close.tsx (100%) rename packages/browser-sdk/src/{feedback => }/ui/icons/Dissatisfied.tsx (100%) rename packages/browser-sdk/src/{feedback => }/ui/icons/Logo.tsx (98%) rename packages/browser-sdk/src/{feedback => }/ui/icons/Neutral.tsx (100%) rename packages/browser-sdk/src/{feedback => }/ui/icons/Satisfied.tsx (100%) rename packages/browser-sdk/src/{feedback => }/ui/icons/VeryDissatisfied.tsx (100%) rename packages/browser-sdk/src/{feedback => }/ui/icons/VerySatisfied.tsx (100%) rename packages/browser-sdk/src/{feedback => }/ui/packages/floating-ui-preact-dom/README.md (100%) rename packages/browser-sdk/src/{feedback => }/ui/packages/floating-ui-preact-dom/arrow.ts (100%) rename packages/browser-sdk/src/{feedback => }/ui/packages/floating-ui-preact-dom/index.ts (100%) rename packages/browser-sdk/src/{feedback => }/ui/packages/floating-ui-preact-dom/types.ts (100%) rename packages/browser-sdk/src/{feedback => }/ui/packages/floating-ui-preact-dom/useFloating.ts (100%) rename packages/browser-sdk/src/{feedback => }/ui/packages/floating-ui-preact-dom/utils/deepEqual.ts (100%) rename packages/browser-sdk/src/{feedback => }/ui/packages/floating-ui-preact-dom/utils/getDPR.ts (100%) rename packages/browser-sdk/src/{feedback => }/ui/packages/floating-ui-preact-dom/utils/roundByDPR.ts (100%) rename packages/browser-sdk/src/{feedback => }/ui/packages/floating-ui-preact-dom/utils/useLatestRef.ts (100%) create mode 100644 packages/browser-sdk/src/ui/types.ts create mode 100644 packages/browser-sdk/src/ui/utils.ts diff --git a/packages/browser-sdk/example/browser.html b/packages/browser-sdk/example/browser.html index 175497d1..d74cf493 100644 --- a/packages/browser-sdk/example/browser.html +++ b/packages/browser-sdk/example/browser.html @@ -13,29 +13,50 @@ const publishableKey = urlParams.get("publishableKey"); const featureKey = urlParams.get("featureKey") ?? "huddles"; + diff --git a/packages/browser-sdk/example/typescript/app.ts b/packages/browser-sdk/example/typescript/app.ts new file mode 100644 index 00000000..e16ee9d8 --- /dev/null +++ b/packages/browser-sdk/example/typescript/app.ts @@ -0,0 +1,47 @@ +import { BucketClient } from "../../"; + +const urlParams = new URLSearchParams(window.location.search); +const publishableKey = urlParams.get("publishableKey") ?? "publishableKey"; +const featureKey = urlParams.get("featureKey") ?? "huddles"; + +const featureList = ["huddles"]; + +const bucket = new BucketClient({ + publishableKey, + user: { id: "42" }, + company: { id: "1" }, + toolbar: { + show: true, + position: { placement: "bottom-right" }, + }, + featureList, +}); + +document + .getElementById("startHuddle") + ?.addEventListener("click", () => bucket.track(featureKey)); +document.getElementById("giveFeedback")?.addEventListener("click", (event) => + bucket.requestFeedback({ + featureKey, + position: { type: "POPOVER", anchor: event.currentTarget as HTMLElement }, + }), +); + +bucket.initialize().then(() => { + console.log("Bucket initialized"); + const loadingElem = document.getElementById("loading"); + if (loadingElem) loadingElem.style.display = "none"; +}); + +bucket.onFeaturesUpdated(() => { + const { isEnabled } = bucket.getFeature("huddles"); + + const startHuddleElem = document.getElementById("start-huddle"); + if (isEnabled) { + // show the start-huddle button + if (startHuddleElem) startHuddleElem.style.display = "block"; + } else { + // hide the start-huddle button + if (startHuddleElem) startHuddleElem.style.display = "none"; + } +}); diff --git a/packages/browser-sdk/example/typescript/index.html b/packages/browser-sdk/example/typescript/index.html new file mode 100644 index 00000000..cec72d21 --- /dev/null +++ b/packages/browser-sdk/example/typescript/index.html @@ -0,0 +1,23 @@ + + + + + + Bucket feature management + + + Loading... + + + + + diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 9e16a076..8ad6ba2e 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -14,10 +14,12 @@ import { RequestFeedbackOptions, } from "./feedback/feedback"; import * as feedbackLib from "./feedback/ui"; +import { ToolbarPosition } from "./toolbar/Toolbar"; import { API_BASE_URL, SSE_REALTIME_BASE_URL } from "./config"; import { BucketContext, CompanyContext, UserContext } from "./context"; import { HttpClient } from "./httpClient"; import { Logger, loggerWithPrefix, quietConsoleLogger } from "./logger"; +import { showToolbarToggle } from "./toolbar"; const isMobile = typeof window !== "undefined" && window.innerWidth < 768; const isNode = typeof document === "undefined"; // deno supports "window" but not "document" according to https://remix.run/docs/en/main/guides/gotchas @@ -95,6 +97,15 @@ interface Config { enableTracking: boolean; } +export type ToolbarOptions = + | boolean + | { + show?: boolean; + position?: ToolbarPosition; + }; + +export type FeatureDefinitions = Readonly>; + /** * BucketClient initialization options. */ @@ -166,6 +177,9 @@ export interface InitOptions { */ sdkVersion?: string; enableTracking?: boolean; + + toolbar?: ToolbarOptions; + featureList?: FeatureDefinitions; } const defaultConfig: Config = { @@ -189,6 +203,15 @@ export interface Feature { options: Omit, ) => void; } + +function shouldShowToolbar(opts?: ToolbarOptions) { + return ( + opts === true || + (typeof opts === "object" && opts.show === true) || + window?.location?.hostname === "localhost" + ); +} + /** * BucketClient lets you interact with the Bucket API. * @@ -244,6 +267,7 @@ export class BucketClient { company: this.context.company, other: this.context.otherContext, }, + opts?.featureList || [], this.logger, opts?.features, ); @@ -269,6 +293,15 @@ export class BucketClient { ); } } + + if (shouldShowToolbar(opts.toolbar)) { + this.logger.info("opening toolbar toggler"); + showToolbarToggle({ + bucketClient: this as unknown as BucketClient, + position: + typeof opts.toolbar === "object" ? opts.toolbar.position : undefined, + }); + } } /** diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index c50c723d..951e52d4 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -46,17 +46,24 @@ export type FeaturesOptions = { fallbackFeatures?: string[]; /** - * Timeout in milliseconds + * Timeout in milliseconds when fetching features */ timeoutMs?: number; /** - * If set to true client will return cached value when its stale - * but refetching + * If set to true stale features will be returned while refetching features */ staleWhileRevalidate?: boolean; - staleTimeMs?: number; + + /** + * If set, features will be cached between page loads for this duration + */ expireTimeMs?: number; + + /** + * Stale features will be returned if staleWhileRevalidate is true if no new features can be fetched + */ + staleTimeMs?: number; }; type Config = { @@ -149,25 +156,22 @@ export const FEATURES_EXPIRE_MS = 30 * 24 * 60 * 60 * 1000; // expire entirely a const localStorageFetchedFeaturesKey = `__bucket_fetched_features`; const localStorageOverridesKey = `__bucket_overrides`; -type OverridesFeatures = Record; +type OverridesFeatures = Record; function setOverridesCache(overrides: OverridesFeatures) { localStorage.setItem(localStorageOverridesKey, JSON.stringify(overrides)); } function getOverridesCache(): OverridesFeatures { - try { - const cachedOverrides = JSON.parse( - localStorage.getItem(localStorageOverridesKey) || "{}", - ); + const cachedOverrides = JSON.parse( + localStorage.getItem(localStorageOverridesKey) || "{}", + ); - if (!isObject(cachedOverrides)) { - return {}; - } - return cachedOverrides; - } catch (e) { + if (!isObject(cachedOverrides)) { return {}; } + + return cachedOverrides; } /** @@ -176,7 +180,7 @@ function getOverridesCache(): OverridesFeatures { export class FeaturesClient { private cache: FeatureCache; private fetchedFeatures: FetchedFeatures; - private featureOverrides: OverridesFeatures; + private featureOverrides: OverridesFeatures = {}; private features: RawFeatures = {}; @@ -190,6 +194,7 @@ export class FeaturesClient { constructor( private httpClient: HttpClient, private context: context, + private featureDefinitions: Readonly, logger: Logger, options?: FeaturesOptions & { cache?: FeatureCache; @@ -213,7 +218,18 @@ export class FeaturesClient { this.rateLimiter = options?.rateLimiter ?? new RateLimiter(FEATURE_EVENTS_PER_MIN, this.logger); - this.featureOverrides = getOverridesCache(); + + try { + const storedFeatureOverrides = getOverridesCache(); + for (const key in storedFeatureOverrides) { + if (this.featureDefinitions.includes(key)) { + this.featureOverrides[key] = storedFeatureOverrides[key]; + } + } + } catch (e) { + this.logger.warn("error getting feature overrides from cache", e); + this.featureOverrides = {}; + } } async initialize() { @@ -341,13 +357,13 @@ export class FeaturesClient { }; } - // add any overrides that aren't in the fetched features - for (const key in this.featureOverrides) { - if (!this.features[key]) { + // add any features that aren't in the fetched features + for (const key of this.featureDefinitions) { + if (!mergedFeatures[key]) { mergedFeatures[key] = { key, isEnabled: false, - isEnabledOverride: this.featureOverrides[key], + isEnabledOverride: this.featureOverrides[key] ?? null, }; } } diff --git a/packages/browser-sdk/src/feedback/feedback.ts b/packages/browser-sdk/src/feedback/feedback.ts index b83428a9..e293101c 100644 --- a/packages/browser-sdk/src/feedback/feedback.ts +++ b/packages/browser-sdk/src/feedback/feedback.ts @@ -1,9 +1,9 @@ import { HttpClient } from "../httpClient"; import { Logger } from "../logger"; import { AblySSEChannel, openAblySSEChannel } from "../sse"; +import { Position } from "../ui/types"; import { - FeedbackPosition, FeedbackSubmission, FeedbackTranslations, OpenFeedbackFormOptions, @@ -20,24 +20,13 @@ import { DEFAULT_POSITION } from "./ui"; export type Key = string; export type FeedbackOptions = { - /** - * Enables automatic feedback prompting if it's set up in Bucket - */ enableAutoFeedback?: boolean; - - /** - * - */ autoFeedbackHandler?: FeedbackPromptHandler; - - /** - * With these options you can override the look of the feedback prompt - */ ui?: { /** * Control the placement and behavior of the feedback form. */ - position?: FeedbackPosition; + position?: Position; /** * Add your own custom translations for the feedback form. @@ -46,14 +35,8 @@ export type FeedbackOptions = { translations?: Partial; }; - /** - * @deprecated Use `enableAutoFeedback` instead - */ + // Deprecated enableLiveSatisfaction?: boolean; - - /** - * @deprecated Use `autoFeedbackHandler` instead - */ liveSatisfactionHandler?: FeedbackPromptHandler; }; @@ -69,7 +52,7 @@ export function handleDeprecatedFeedbackOptions( }; } -export type FeatureIdentifier = +type FeatureIdentifier = | { /** * Bucket feature ID. @@ -100,6 +83,9 @@ export type RequestFeedbackData = Omit< * * This can be used for side effects, such as storing a * copy of the feedback in your own application or CRM. + * + * @param {Object} data + * @param data. */ onAfterSubmit?: (data: FeedbackSubmission) => void; } & FeatureIdentifier; @@ -171,29 +157,10 @@ export type Feedback = UnassignedFeedback & { }; export type FeedbackPrompt = { - /** - * Specific question user was asked - */ question: string; - - /** - * Feedback prompt should appear only after this time - */ showAfter: Date; - - /** - * Feedback prompt will not be shown after this time - */ showBefore: Date; - - /** - * Id of the prompt - */ promptId: string; - - /** - * Feature ID from Bucket - */ featureId: string; }; @@ -294,7 +261,7 @@ export class AutoFeedback { private httpClient: HttpClient, private feedbackPromptHandler: FeedbackPromptHandler = createDefaultFeedbackPromptHandler(), private userId: string, - private position: FeedbackPosition = DEFAULT_POSITION, + private position: Position = DEFAULT_POSITION, private feedbackTranslations: Partial = {}, ) {} diff --git a/packages/browser-sdk/src/feedback/ui/FeedbackDialog.tsx b/packages/browser-sdk/src/feedback/ui/FeedbackDialog.tsx index 8a0f771a..0fed0576 100644 --- a/packages/browser-sdk/src/feedback/ui/FeedbackDialog.tsx +++ b/packages/browser-sdk/src/feedback/ui/FeedbackDialog.tsx @@ -1,33 +1,22 @@ import { Fragment, FunctionComponent, h } from "preact"; -import { useCallback, useEffect, useRef, useState } from "preact/hooks"; +import { useCallback, useState } from "preact/hooks"; + +import { feedbackContainerId } from "../../ui/constants"; +import { Dialog } from "../../ui/Dialog"; +import { Close } from "../../ui/icons/Close"; import { DEFAULT_TRANSLATIONS } from "./config/defaultTranslations"; import { useTimer } from "./hooks/useTimer"; -import { Close } from "./icons/Close"; -import { - arrow, - autoUpdate, - flip, - offset, - shift, - useFloating, -} from "./packages/floating-ui-preact-dom"; -import { feedbackContainerId } from "./constants"; import { FeedbackForm } from "./FeedbackForm"; import styles from "./index.css?inline"; import { RadialProgress } from "./RadialProgress"; import { FeedbackScoreSubmission, FeedbackSubmission, - Offset, OpenFeedbackFormOptions, WithRequired, } from "./types"; -type Position = Partial< - Record<"top" | "left" | "right" | "bottom", number | string> ->; - export type FeedbackDialogProps = WithRequired< OpenFeedbackFormOptions, "onSubmit" | "position" @@ -47,96 +36,12 @@ export const FeedbackDialog: FunctionComponent = ({ onSubmit, onScoreSubmit, }) => { - const arrowRef = useRef(null); - const anchor = position.type === "POPOVER" ? position.anchor : null; - const { - refs, - floatingStyles, - middlewareData, - placement: actualPlacement, - } = useFloating({ - elements: { - reference: anchor, - }, - transform: false, - whileElementsMounted: autoUpdate, - middleware: [ - flip({ - padding: 10, - mainAxis: true, - crossAxis: true, - fallbackAxisSideDirection: "end", - }), - shift(), - offset(8), - arrow({ - element: arrowRef, - }), - ], - }); - - let unanchoredPosition: Position = {}; - if (position.type === "DIALOG") { - const offsetY = parseOffset(position.offset?.y); - const offsetX = parseOffset(position.offset?.x); - - switch (position.placement) { - case "top-left": - unanchoredPosition = { - top: offsetY, - left: offsetX, - }; - break; - case "top-right": - unanchoredPosition = { - top: offsetY, - right: offsetX, - }; - break; - case "bottom-left": - unanchoredPosition = { - bottom: offsetY, - left: offsetX, - }; - break; - case "bottom-right": - unanchoredPosition = { - bottom: offsetY, - right: offsetX, - }; - break; - } - } - - const { x: arrowX, y: arrowY } = middlewareData.arrow ?? {}; - - const staticSide = - { - top: "bottom", - right: "left", - bottom: "top", - left: "right", - }[actualPlacement.split("-")[0]] || "bottom"; - - const arrowStyles = { - left: arrowX != null ? `${arrowX}px` : "", - top: arrowY != null ? `${arrowY}px` : "", - right: "", - bottom: "", - [staticSide]: "-4px", - }; - - const close = useCallback(() => { - const dialog = refs.floating.current as HTMLDialogElement | null; - dialog?.close(); - autoClose.stop(); - onClose?.(); - }, [onClose]); - - const dismiss = useCallback(() => { - close(); - onDismiss?.(); - }, [close, onDismiss]); + // const close = useCallback(() => { + // const dialog = refs.floating.current as HTMLDialogElement | null; + // dialog?.close(); + // autoClose.stop(); + // onClose?.(); + // }, [onClose]); const [feedbackId, setFeedbackId] = useState(undefined); const [scoreState, setScoreState] = useState< @@ -170,106 +75,41 @@ export const FeedbackDialog: FunctionComponent = ({ onEnd: close, }); - useEffect(() => { - // Only enable 'quick dismiss' for popovers - if (position.type === "MODAL" || position.type === "DIALOG") return; - - const escapeHandler = (e: KeyboardEvent) => { - if (e.key == "Escape") { - dismiss(); - } - }; - - const clickOutsideHandler = (e: MouseEvent) => { - if ( - !(e.target instanceof Element) || - !e.target.closest(`#${feedbackContainerId}`) - ) { - dismiss(); - } - }; - - const observer = new MutationObserver((mutations) => { - if (position.anchor === null) return; - - mutations.forEach((mutation) => { - const removedNodes = Array.from(mutation.removedNodes); - const hasBeenRemoved = removedNodes.some((node) => { - return node === position.anchor || node.contains(position.anchor); - }); - - if (hasBeenRemoved) { - close(); - } - }); - }); - - window.addEventListener("mousedown", clickOutsideHandler); - window.addEventListener("keydown", escapeHandler); - observer.observe(document.body, { - subtree: true, - childList: true, - }); - - return () => { - window.removeEventListener("mousedown", clickOutsideHandler); - window.removeEventListener("keydown", escapeHandler); - observer.disconnect(); - }; - }, [position.type, close]); - return ( <> - - - - - {anchor && ( -
+ + )} -
+ > ); }; - -function parseOffset(offsetInput?: Offset["x"] | Offset["y"]) { - if (offsetInput === undefined) return "1rem"; - if (typeof offsetInput === "number") return offsetInput + "px"; - - return offsetInput; -} diff --git a/packages/browser-sdk/src/feedback/ui/FeedbackForm.tsx b/packages/browser-sdk/src/feedback/ui/FeedbackForm.tsx index 949ce658..b4a16f67 100644 --- a/packages/browser-sdk/src/feedback/ui/FeedbackForm.tsx +++ b/packages/browser-sdk/src/feedback/ui/FeedbackForm.tsx @@ -1,8 +1,8 @@ import { FunctionComponent, h } from "preact"; import { useCallback, useEffect, useRef, useState } from "preact/hooks"; -import { Check } from "./icons/Check"; -import { CheckCircle } from "./icons/CheckCircle"; +import { Check } from "../../ui/icons/Check"; +import { CheckCircle } from "../../ui/icons/CheckCircle"; import { Button } from "./Button"; import { Plug } from "./Plug"; import { StarRating } from "./StarRating"; diff --git a/packages/browser-sdk/src/feedback/ui/Plug.tsx b/packages/browser-sdk/src/feedback/ui/Plug.tsx index dc8add02..f315708f 100644 --- a/packages/browser-sdk/src/feedback/ui/Plug.tsx +++ b/packages/browser-sdk/src/feedback/ui/Plug.tsx @@ -1,12 +1,12 @@ import { FunctionComponent, h } from "preact"; -import { Logo } from "./icons/Logo"; +import { Logo } from "../../ui/icons/Logo"; export const Plug: FunctionComponent = () => { return ( ); diff --git a/packages/browser-sdk/src/feedback/ui/StarRating.tsx b/packages/browser-sdk/src/feedback/ui/StarRating.tsx index ffe6ec1a..e8e439a5 100644 --- a/packages/browser-sdk/src/feedback/ui/StarRating.tsx +++ b/packages/browser-sdk/src/feedback/ui/StarRating.tsx @@ -1,12 +1,17 @@ import { Fragment, FunctionComponent, h } from "preact"; import { useRef } from "preact/hooks"; -import { Dissatisfied } from "./icons/Dissatisfied"; -import { Neutral } from "./icons/Neutral"; -import { Satisfied } from "./icons/Satisfied"; -import { VeryDissatisfied } from "./icons/VeryDissatisfied"; -import { VerySatisfied } from "./icons/VerySatisfied"; -import { arrow, offset, useFloating } from "./packages/floating-ui-preact-dom"; +import { Dissatisfied } from "../../ui/icons/Dissatisfied"; +import { Neutral } from "../../ui/icons/Neutral"; +import { Satisfied } from "../../ui/icons/Satisfied"; +import { VeryDissatisfied } from "../../ui/icons/VeryDissatisfied"; +import { VerySatisfied } from "../../ui/icons/VerySatisfied"; +import { + arrow, + offset, + useFloating, +} from "../../ui/packages/floating-ui-preact-dom"; + import { FeedbackTranslations } from "./types"; const scores = [ diff --git a/packages/browser-sdk/src/feedback/ui/config/defaultTranslations.tsx b/packages/browser-sdk/src/feedback/ui/config/defaultTranslations.tsx index 9bf80413..d79a8af0 100644 --- a/packages/browser-sdk/src/feedback/ui/config/defaultTranslations.tsx +++ b/packages/browser-sdk/src/feedback/ui/config/defaultTranslations.tsx @@ -1,8 +1,5 @@ import { FeedbackTranslations } from "../types"; -/** - * {@includeCode ./defaultTranslations.tsx} - */ export const DEFAULT_TRANSLATIONS: FeedbackTranslations = { DefaultQuestionLabel: "How satisfied are you with this feature?", QuestionPlaceholder: "Write a comment", diff --git a/packages/browser-sdk/src/feedback/ui/index.ts b/packages/browser-sdk/src/feedback/ui/index.ts index f1e02922..c5e3d957 100644 --- a/packages/browser-sdk/src/feedback/ui/index.ts +++ b/packages/browser-sdk/src/feedback/ui/index.ts @@ -1,10 +1,12 @@ import { h, render } from "preact"; -import { feedbackContainerId, propagatedEvents } from "./constants"; +import { feedbackContainerId, propagatedEvents } from "../../ui/constants"; +import { Position } from "../../ui/types"; + import { FeedbackDialog } from "./FeedbackDialog"; -import { FeedbackPosition, OpenFeedbackFormOptions } from "./types"; +import { OpenFeedbackFormOptions } from "./types"; -export const DEFAULT_POSITION: FeedbackPosition = { +export const DEFAULT_POSITION: Position = { type: "DIALOG", placement: "bottom-right", }; @@ -54,10 +56,4 @@ export function openFeedbackForm(options: OpenFeedbackFormOptions): void { } render(h(FeedbackDialog, { ...options, position }), shadowRoot); - - const dialog = shadowRoot.querySelector("dialog"); - - if (dialog && !dialog.hasAttribute("open")) { - dialog[position.type === "MODAL" ? "showModal" : "show"](); - } } diff --git a/packages/browser-sdk/src/feedback/ui/types.ts b/packages/browser-sdk/src/feedback/ui/types.ts index c3b92a1b..5ad08b0f 100644 --- a/packages/browser-sdk/src/feedback/ui/types.ts +++ b/packages/browser-sdk/src/feedback/ui/types.ts @@ -1,26 +1,6 @@ -export type WithRequired = T & { [P in K]-?: T[P] }; - -export type FeedbackPlacement = - | "bottom-right" - | "bottom-left" - | "top-right" - | "top-left"; +import { Position } from "../../ui/types"; -export type Offset = { - /** - * Offset from the nearest horizontal screen edge after placement is resolved - */ - x?: string | number; - /** - * Offset from the nearest vertical screen edge after placement is resolved - */ - y?: string | number; -}; - -export type FeedbackPosition = - | { type: "MODAL" } - | { type: "DIALOG"; placement: FeedbackPlacement; offset?: Offset } - | { type: "POPOVER"; anchor: HTMLElement | null }; +export type WithRequired = T & { [P in K]-?: T[P] }; export interface FeedbackSubmission { question: string; @@ -46,7 +26,7 @@ export interface OpenFeedbackFormOptions { /** * Control the placement and behavior of the feedback form. */ - position?: FeedbackPosition; + position?: Position; /** * Add your own custom translations for the feedback form. @@ -68,14 +48,7 @@ export interface OpenFeedbackFormOptions { onDismiss?: () => void; } -/** - * You can use this to override text values in the feedback form - * with desired language translation - */ export type FeedbackTranslations = { - /** - * - */ DefaultQuestionLabel: string; QuestionPlaceholder: string; ScoreStatusDescription: string; diff --git a/packages/browser-sdk/src/index.ts b/packages/browser-sdk/src/index.ts index f8cd11db..c5fd77ca 100644 --- a/packages/browser-sdk/src/index.ts +++ b/packages/browser-sdk/src/index.ts @@ -1,4 +1,4 @@ -export type { Feature, InitOptions } from "./client"; +export type { Feature, InitOptions, ToolbarOptions } from "./client"; export { BucketClient } from "./client"; export type { BucketContext, CompanyContext, UserContext } from "./context"; export type { @@ -8,7 +8,6 @@ export type { RawFeatures, } from "./feature/features"; export type { - FeatureIdentifier, Feedback, FeedbackOptions, FeedbackPrompt, @@ -22,15 +21,12 @@ export type { UnassignedFeedback, } from "./feedback/feedback"; export type { DEFAULT_TRANSLATIONS } from "./feedback/ui/config/defaultTranslations"; -export { feedbackContainerId, propagatedEvents } from "./feedback/ui/constants"; export type { - FeedbackPlacement, - FeedbackPosition, FeedbackScoreSubmission, FeedbackSubmission, FeedbackTranslations, - Offset, OnScoreSubmitResult, OpenFeedbackFormOptions, } from "./feedback/ui/types"; export type { Logger } from "./logger"; +export { feedbackContainerId, propagatedEvents } from "./ui/constants"; diff --git a/packages/browser-sdk/src/toolbar/Switch.tsx b/packages/browser-sdk/src/toolbar/Switch.tsx new file mode 100644 index 00000000..0107d978 --- /dev/null +++ b/packages/browser-sdk/src/toolbar/Switch.tsx @@ -0,0 +1,52 @@ +import { FunctionComponent, h } from "preact"; + +interface SwitchProps extends h.JSX.HTMLAttributes { + isOn: boolean; + width?: number; + height?: number; + onColor?: string; + offColor?: string; +} + +export const Switch: FunctionComponent = ({ + isOn, + width = 25, + height = 12, + onColor = "green", + offColor = "gray", + ...props +}) => { + return ( +