diff --git a/apps/cli/README.md b/apps/cli/README.md new file mode 100644 index 0000000..09637da --- /dev/null +++ b/apps/cli/README.md @@ -0,0 +1,60 @@ +# @drexa/cli + +Admin CLI tool for managing Drexa resources. + +## Usage + +From the project root: + +```bash +bun drexa [subcommand] [options] +``` + +## Commands + +### `generate apikey` + +Generate a new API key for authentication. + +```bash +bun drexa generate apikey +``` + +The command will interactively prompt you for (using Node.js readline): +- **Prefix**: A short identifier for the key (e.g., 'proxy', 'admin'). Cannot contain dashes. +- **Key byte length**: Length of the key in bytes (default: 32) +- **Description**: A description of what this key is for +- **Expiration date**: Optional expiration date in YYYY-MM-DD format + +The command will output: +- **Unhashed key**: Save this securely - it won't be shown again +- **Hashed key**: Store this in your database +- **Description**: The description you provided +- **Expiration date**: When the key expires (if set) + +## Development + +Run the CLI directly: + +```bash +bun run apps/cli/index.ts +``` + +## Project Structure + +``` +apps/cli/ +├── index.ts # Main entry point +├── prompts.ts # Interactive prompt utilities +└── commands/ # Command structure mirrors CLI structure + └── generate/ + ├── index.ts # Generate command group + └── apikey.ts # API key generation command +``` + +## Adding New Commands + +1. Create a new directory under `commands/` for command groups +2. Create command files following the pattern in `commands/generate/apikey.ts` +3. Export commands from an `index.ts` in the command group directory +4. Register the command group in the main `index.ts` diff --git a/apps/cli/commands/generate/apikey.ts b/apps/cli/commands/generate/apikey.ts new file mode 100644 index 0000000..90d0306 --- /dev/null +++ b/apps/cli/commands/generate/apikey.ts @@ -0,0 +1,71 @@ +import { generateApiKey, newPrefix } from "@drexa/auth" +import chalk from "chalk" +import { Command } from "commander" +import { + promptNumber, + promptOptionalDate, + promptText, +} from "../../prompts.ts" + +export const apikeyCommand = new Command("apikey") + .description("Generate a new API key") + .action(async () => { + console.log(chalk.bold.blue("\n🔑 Generate API Key\n")) + + // Prompt for all required information + const prefixInput = await promptText( + "Enter API key prefix (e.g., 'proxy', 'admin'):", + ) + const prefix = newPrefix(prefixInput) + + if (!prefix) { + console.error( + chalk.red( + '✗ Invalid prefix: cannot contain "-" character. Please use alphanumeric characters only.', + ), + ) + process.exit(1) + } + + const keyByteLength = await promptNumber("Enter key byte length:", 32) + const description = await promptText("Enter description:") + const expiresAt = await promptOptionalDate("Enter expiration date") + + console.log(chalk.dim("\n⏳ Generating API key...\n")) + + // Generate the API key + const result = await generateApiKey({ + prefix, + keyByteLength, + description, + expiresAt, + }) + + // Display results + console.log(chalk.green.bold("✓ API Key Generated Successfully!\n")) + console.log(chalk.gray("─".repeat(60))) + console.log( + chalk.yellow.bold( + "\n⚠️ IMPORTANT: Save the unhashed key now. It won't be shown again!\n", + ), + ) + console.log(chalk.bold("Unhashed Key ") + chalk.dim("(save this):")) + console.log(chalk.green(` ${result.unhashedKey}\n`)) + console.log(chalk.gray("─".repeat(60))) + console.log( + chalk.bold("\nHashed Key ") + chalk.dim("(store this in your database):"), + ) + console.log(chalk.dim(` ${result.hashedKey}\n`)) + console.log(chalk.bold("Description:")) + console.log(chalk.white(` ${result.description}\n`)) + + if (result.expiresAt) { + console.log(chalk.bold("Expires At:")) + console.log(chalk.yellow(` ${result.expiresAt.toISOString()}\n`)) + } else { + console.log(chalk.bold("Expires At:")) + console.log(chalk.dim(" Never\n")) + } + + console.log(chalk.gray("─".repeat(60)) + "\n") + }) diff --git a/apps/cli/commands/generate/index.ts b/apps/cli/commands/generate/index.ts new file mode 100644 index 0000000..e6f6252 --- /dev/null +++ b/apps/cli/commands/generate/index.ts @@ -0,0 +1,6 @@ +import { Command } from "commander" +import { apikeyCommand } from "./apikey.ts" + +export const generateCommand = new Command("generate") + .description("Generate various resources") + .addCommand(apikeyCommand) diff --git a/apps/cli/index.ts b/apps/cli/index.ts new file mode 100755 index 0000000..a7e73e2 --- /dev/null +++ b/apps/cli/index.ts @@ -0,0 +1,17 @@ +#!/usr/bin/env bun + +import { Command } from "commander" +import { generateCommand } from "./commands/generate/index.ts" + +const program = new Command() + +program + .name("drexa") + .description("Drexa CLI - Admin tools for managing Drexa resources") + .version("0.1.0") + +// Register command groups +program.addCommand(generateCommand) + +// Parse command line arguments +program.parse() diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 0000000..36262fd --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,23 @@ +{ + "name": "@drexa/cli", + "version": "0.1.0", + "private": true, + "type": "module", + "bin": { + "drexa": "./index.ts" + }, + "scripts": { + "cli": "bun run index.ts" + }, + "dependencies": { + "@drexa/auth": "workspace:*", + "chalk": "^5.3.0", + "commander": "^12.1.0" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/apps/cli/prompts.ts b/apps/cli/prompts.ts new file mode 100644 index 0000000..674875e --- /dev/null +++ b/apps/cli/prompts.ts @@ -0,0 +1,106 @@ +import * as readline from "node:readline/promises" +import chalk from "chalk" + +function createReadlineInterface() { + return readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) +} + +export async function promptText(message: string): Promise { + const rl = createReadlineInterface() + try { + const input = await rl.question(chalk.cyan(`${message} `)) + + if (!input || input.trim() === "") { + console.error(chalk.red("✗ Input is required")) + process.exit(1) + } + return input.trim() + } finally { + rl.close() + } +} + +export async function promptNumber( + message: string, + defaultValue?: number, +): Promise { + const rl = createReadlineInterface() + try { + const defaultStr = defaultValue ? chalk.dim(` (default: ${defaultValue})`) : "" + const input = await rl.question(chalk.cyan(`${message}${defaultStr} `)) + + if ((!input || input.trim() === "") && defaultValue !== undefined) { + return defaultValue + } + + if (!input || input.trim() === "") { + console.error(chalk.red("✗ Input is required")) + process.exit(1) + } + + const num = Number.parseInt(input.trim(), 10) + if (Number.isNaN(num) || num <= 0) { + console.error(chalk.red("✗ Please enter a valid positive number")) + process.exit(1) + } + + return num + } finally { + rl.close() + } +} + +export async function promptOptionalDate( + message: string, +): Promise { + const rl = createReadlineInterface() + try { + const input = await rl.question( + chalk.cyan(`${message} `) + chalk.dim("(optional, format: YYYY-MM-DD) "), + ) + + if (!input || input.trim() === "") { + return undefined + } + + const date = new Date(input.trim()) + if (Number.isNaN(date.getTime())) { + console.error(chalk.red("✗ Invalid date format. Please use YYYY-MM-DD")) + process.exit(1) + } + + if (date < new Date()) { + console.error(chalk.red("✗ Expiration date must be in the future")) + process.exit(1) + } + + return date + } finally { + rl.close() + } +} + +export async function promptConfirm( + message: string, + defaultValue = false, +): Promise { + const rl = createReadlineInterface() + try { + const defaultStr = defaultValue + ? chalk.dim(" (Y/n)") + : chalk.dim(" (y/N)") + const input = await rl.question(chalk.cyan(`${message}${defaultStr} `)) + + if (!input || input.trim() === "") { + return defaultValue + } + + const normalized = input.toLowerCase().trim() + return normalized === "y" || normalized === "yes" + } finally { + rl.close() + } +} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 0000000..d4467c8 --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/bun.lock b/bun.lock index 961f31f..3f84113 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,24 @@ "convex": "^1.27.0", }, }, + "apps/cli": { + "name": "@drexa/cli", + "version": "0.1.0", + "bin": { + "drexa": "./index.ts", + }, + "dependencies": { + "@drexa/auth": "workspace:*", + "chalk": "^5.3.0", + "commander": "^12.1.0", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, "apps/drive-web": { "name": "@fileone/web", "version": "0.1.0", @@ -87,7 +105,9 @@ "packages/convex": { "name": "@fileone/convex", "dependencies": { + "@drexa/auth": "workspace:*", "@fileone/path": "workspace:*", + "hash-wasm": "^4.12.0", }, "peerDependencies": { "@convex-dev/better-auth": "^0.8.9", @@ -194,6 +214,8 @@ "@drexa/auth": ["@drexa/auth@workspace:packages/auth"], + "@drexa/cli": ["@drexa/cli@workspace:apps/cli"], + "@drexa/file-proxy": ["@drexa/file-proxy@workspace:apps/file-proxy"], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], @@ -542,6 +564,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], @@ -556,6 +580,8 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -612,6 +638,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "hash-wasm": ["hash-wasm@4.12.0", "", {}, "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ=="], + "hono": ["hono@4.10.1", "", {}, "sha512-rpGNOfacO4WEPClfkEt1yfl8cbu10uB1lNpiI33AKoiAHwOS8lV748JiLx4b5ozO/u4qLjIvfpFsPXdY5Qjkmg=="], "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],