Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ junit.xml

.next
eslint-report.json
bucket.config.json
3 changes: 3 additions & 0 deletions packages/cli/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dist
eslint-report.json
gen
1 change: 1 addition & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# cli
31 changes: 31 additions & 0 deletions packages/cli/commands/apps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import chalk from "chalk";
import { Command } from "commander";
import ora from "ora";

import { listApps } from "../services/bootstrap.js";
import { configStore } from "../stores/config.js";
import { handleError } from "../utils/errors.js";

export const listAppsAction = async () => {
const baseUrl = configStore.getConfig("baseUrl");
const spinner = ora(`Loading apps from ${chalk.cyan(baseUrl)}...`).start();
try {
const apps = await listApps();
spinner.succeed(`Loaded apps from ${chalk.cyan(baseUrl)}`);
console.table(apps);
} catch (error) {
spinner.fail("Failed to list apps");
void handleError(error, "Apps List");
}
};

export function registerAppCommands(cli: Command) {
const appsCommand = new Command("apps").description("Manage apps");

appsCommand
.command("list")
.description("List all available apps")
.action(listAppsAction);

cli.addCommand(appsCommand);
}
38 changes: 38 additions & 0 deletions packages/cli/commands/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import chalk from "chalk";
import { Command } from "commander";
import ora from "ora";

import { authStore } from "../stores/auth.js";
import { configStore } from "../stores/config.js";
import { authenticateUser } from "../utils/auth.js";
import { handleError } from "../utils/errors.js";

export const loginAction = async () => {
const baseUrl = configStore.getConfig("baseUrl");
const spinner = ora(`Logging in to ${chalk.cyan(baseUrl)}...`).start();
try {
await authenticateUser(baseUrl);
spinner.succeed(`Logged in to ${chalk.cyan(baseUrl)} successfully! 🎉`);
} catch (error) {
spinner.fail("Login failed");
void handleError(error, "Login");
}
};

export const logoutAction = async () => {
const baseUrl = configStore.getConfig("baseUrl");
const spinner = ora("Logging out...").start();
try {
await authStore.setToken(baseUrl, undefined);
spinner.succeed("Logged out successfully! 👋");
} catch (error) {
spinner.fail("Logout failed");
void handleError(error, "Logout");
}
};

export function registerAuthCommands(cli: Command) {
cli.command("login").description("Login to Bucket").action(loginAction);

cli.command("logout").description("Logout from Bucket").action(logoutAction);
}
154 changes: 154 additions & 0 deletions packages/cli/commands/features.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { input } from "@inquirer/prompts";
import chalk from "chalk";
import { Command } from "commander";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join, relative } from "node:path";
import ora, { Ora } from "ora";

import { createFeature, listFeatures } from "../services/features.js";
import { configStore } from "../stores/config.js";
import { handleError, MissingAppIdError } from "../utils/errors.js";
import { genDTS, genFeatureKey, KeyFormatPatterns } from "../utils/gen.js";
import {
appIdOption,
featureKeyOption,
featureNameArgument,
keyFormatOption,
typesOutOption,
} from "../utils/options.js";

type CreateFeatureArgs = {
key?: string;
};

export const createFeatureAction = async (
name: string | undefined,
{ key }: CreateFeatureArgs,
) => {
const { baseUrl, appId } = configStore.getConfig();
let spinner: Ora | undefined;
try {
if (!appId) throw new MissingAppIdError();
if (!name) {
name = await input({
message: "New feature name:",
validate: (text) => text.length > 0 || "Name is required",
});
}

if (!key) {
const keyFormat = configStore.getConfig("keyFormat") ?? "custom";
key = await input({
message: "New feature key:",
default: genFeatureKey(name, keyFormat),
validate: KeyFormatPatterns[keyFormat].validate,
});
}

spinner = ora(
`Creating feature for app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`,
).start();
const feature = await createFeature(appId, name, key);
// todo: would like to link to feature here but we don't have the env id, only app id
spinner.succeed(
`Created feature ${chalk.cyan(feature.name)} with key ${chalk.cyan(feature.key)} at ${chalk.cyan(baseUrl)}. 🎉`,
);
} catch (error) {
spinner?.fail("Feature creation failed");
void handleError(error, "Features Create");
}
};

export const listFeaturesAction = async () => {
const { baseUrl, appId } = configStore.getConfig();
let spinner: Ora | undefined;

try {
if (!appId) throw new MissingAppIdError();
spinner = ora(
`Loading features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`,
).start();
const features = await listFeatures(appId);
spinner.succeed(
`Loaded features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}`,
);
console.table(features);
} catch (error) {
spinner?.fail("Loading features failed");
void handleError(error, "Features List");
}
};

export const generateTypesAction = async () => {
const { baseUrl, appId, typesPath } = configStore.getConfig();

let spinner: Ora | undefined;
let featureKeys: string[] = [];
try {
if (!appId) throw new MissingAppIdError();
spinner = ora(
`Loading features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}...`,
).start();
featureKeys = (await listFeatures(appId)).map(({ key }) => key);
spinner.succeed(
`Loaded features of app ${chalk.cyan(appId)} at ${chalk.cyan(baseUrl)}`,
);
} catch (error) {
spinner?.fail("Loading features failed");
void handleError(error, "Features Types");
return;
}

try {
spinner = ora("Generating feature types...").start();
const types = genDTS(featureKeys);
const projectPath = configStore.getProjectPath();
const outPath = isAbsolute(typesPath)
? typesPath
: join(projectPath, typesPath);
await mkdir(dirname(outPath), { recursive: true });
await writeFile(outPath, types);
spinner.succeed(
`Generated types for ${chalk.cyan(appId)} in ${chalk.cyan(relative(projectPath, outPath))}.`,
);
} catch (error) {
spinner?.fail("Type generation failed");
void handleError(error, "Features Types");
}
};

export function registerFeatureCommands(cli: Command) {
const featuresCommand = new Command("features").description(
"Manage features",
);

featuresCommand
.command("create")
.description("Create a new feature")
.addOption(appIdOption)
.addOption(keyFormatOption)
.addOption(featureKeyOption)
.addArgument(featureNameArgument)
.action(createFeatureAction);

featuresCommand
.command("list")
.description("List all features")
.addOption(appIdOption)
.action(listFeaturesAction);

featuresCommand
.command("types")
.description("Generate feature types")
.addOption(appIdOption)
.addOption(typesOutOption)
.action(generateTypesAction);

// Update the config with the cli override values
featuresCommand.hook("preAction", (_, command) => {
const { appId, keyFormat, out } = command.opts();
configStore.setConfig({ appId, keyFormat, typesPath: out });
});

cli.addCommand(featuresCommand);
}
102 changes: 102 additions & 0 deletions packages/cli/commands/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { input, select } from "@inquirer/prompts";
import chalk from "chalk";
import { Command } from "commander";
import { relative } from "node:path";
import ora, { Ora } from "ora";

import { App, listApps } from "../services/bootstrap.js";
import { configStore } from "../stores/config.js";
import { chalkBrand, DEFAULT_TYPES_PATH } from "../utils/constants.js";
import { handleError } from "../utils/errors.js";
import { initOverrideOption } from "../utils/options.js";

type InitArgs = {
force?: boolean;
};

export const initAction = async (args: InitArgs = {}) => {
let spinner: Ora | undefined;
let apps: App[] = [];

try {
// Check if config already exists
const configPath = configStore.getConfigPath();
if (configPath && !args.force) {
throw new Error(
"Bucket is already initialized. Use --force to overwrite.",
);
}

console.log(chalkBrand("\nWelcome to Bucket! 🪣\n"));
const baseUrl = configStore.getConfig("baseUrl");

// Load apps
spinner = ora(`Loading apps from ${chalk.cyan(baseUrl)}...`).start();
apps = await listApps();
spinner.succeed(`Loaded apps from ${chalk.cyan(baseUrl)}`);
} catch (error) {
spinner?.fail("Loading apps failed");
void handleError(error, "Initialization");
return;
}

try {
let appId: string | undefined;
const nonDemoApps = apps.filter((app) => !app.demo);

// If there is only one non-demo app, select it automatically
if (apps.length === 0) {
throw new Error("You don't have any apps yet. Please create one.");
} else if (nonDemoApps.length === 1) {
appId = nonDemoApps[0].id;
console.log(
chalk.gray(
`Automatically selected app ${nonDemoApps[0].name} (${appId})`,
),
);
} else {
appId = await select({
message: "Select an app",
choices: apps.map((app) => ({
name: app.name,
value: app.id,
description: app.demo ? "Demo" : undefined,
})),
});
}

const keyFormat =
apps.find((app) => app.id === appId)?.featureKeyFormat ?? "custom";

// Get types output path
const typesPath = await input({
message: "Where should we generate the types?",
default: DEFAULT_TYPES_PATH,
});

// Update config
configStore.setConfig({
appId,
keyFormat,
typesPath,
});

// Create config file
spinner = ora("Creating configuration...").start();
await configStore.saveConfigFile(args.force);
spinner.succeed(
`Configuration created at ${chalk.cyan(relative(process.cwd(), configStore.getConfigPath()!))}`,
);
} catch (error) {
spinner?.fail("Configuration creation failed");
void handleError(error, "Initialization");
}
};

export function registerInitCommand(cli: Command) {
cli
.command("init")
.description("Initialize a new Bucket configuration")
.addOption(initOverrideOption)
.action(initAction);
}
51 changes: 51 additions & 0 deletions packages/cli/commands/new.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Command } from "commander";
import { findUp } from "find-up";

import { configStore } from "../stores/config.js";
import { CONFIG_FILE_NAME } from "../utils/constants.js";
import {
appIdOption,
featureKeyOption,
featureNameArgument,
keyFormatOption,
typesOutOption,
} from "../utils/options.js";

import { createFeatureAction, generateTypesAction } from "./features.js";
import { initAction } from "./init.js";

type NewArgs = {
appId?: string;
out: string;
key?: string;
};

export const newAction = async (name: string | undefined, { key }: NewArgs) => {
if (!(await findUp(CONFIG_FILE_NAME))) {
await initAction();
}
await createFeatureAction(name, {
key,
});
await generateTypesAction();
};

export function registerNewCommand(cli: Command) {
cli
.command("new")
.description(
"Initialize the Bucket CLI, authenticates, and creates a new feature",
)
.addOption(appIdOption)
.addOption(keyFormatOption)
.addOption(typesOutOption)
.addOption(featureKeyOption)
.addArgument(featureNameArgument)
.action(newAction);

// Update the config with the cli override values
cli.hook("preAction", (command) => {
const { appId, keyFormat, out } = command.opts();
configStore.setConfig({ appId, keyFormat, typesPath: out });
});
}
3 changes: 3 additions & 0 deletions packages/cli/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import base from "@bucketco/eslint-config/base.js";

export default [...base, { ignores: ["dist/", "gen/"] }];
Loading