feat[cli]: new admin cli

new admin cli for general admin task. only supports api key generation
for now
This commit is contained in:
2025-10-20 00:15:42 +00:00
parent 14e2ee1e28
commit a4544a3f09
8 changed files with 339 additions and 0 deletions

60
apps/cli/README.md Normal file
View File

@@ -0,0 +1,60 @@
# @drexa/cli
Admin CLI tool for managing Drexa resources.
## Usage
From the project root:
```bash
bun drexa <command> [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 <command>
```
## 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`

View File

@@ -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")
})

View File

@@ -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)

17
apps/cli/index.ts Executable file
View File

@@ -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()

23
apps/cli/package.json Normal file
View File

@@ -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"
}
}

106
apps/cli/prompts.ts Normal file
View File

@@ -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<string> {
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<number> {
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<Date | undefined> {
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<boolean> {
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()
}
}

28
apps/cli/tsconfig.json Normal file
View File

@@ -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
}
}