diff --git a/app/config.json b/app/config.json new file mode 100644 index 0000000..8104e90 --- /dev/null +++ b/app/config.json @@ -0,0 +1,12 @@ +{ + "database": { + "host": "localhost", + "port": 5432, + "name": "mydb" + }, + "features": { + "premium": true, + "maxUsers": 100 + }, + "apiKeys": ["key1", "key2"] +} diff --git a/app/config.types.ts b/app/config.types.ts new file mode 100644 index 0000000..174dc01 --- /dev/null +++ b/app/config.types.ts @@ -0,0 +1,9 @@ +// Auto-generated from config.json. Do not edit manually. + +interface UserConfig { + database: { host: string; port: number; name: string }; + features: { premium: boolean; maxUsers: number }; + apiKeys: string[]; +} + +export type { UserConfig }; diff --git a/app/djs.config.ts b/app/djs.config.ts index ad0d4f6..46bfc69 100644 --- a/app/djs.config.ts +++ b/app/djs.config.ts @@ -13,5 +13,6 @@ export default { }, experimental: { cron: true, + userConfig: true, }, } satisfies Config; diff --git a/app/src/events/ready-with-config.ts.example b/app/src/events/ready-with-config.ts.example new file mode 100644 index 0000000..d2bdf7f --- /dev/null +++ b/app/src/events/ready-with-config.ts.example @@ -0,0 +1,20 @@ +import { EventListner } from "@djs-core/runtime"; +import type { UserConfig } from "../../config.types"; +import { ActivityType, Events } from "discord.js"; + +export default new EventListner() + .event(Events.ClientReady) + .run((client) => { + client.user?.setActivity({ name: "🥖 bread", type: ActivityType.Custom }); + + // Example: Access user config + if (client.conf) { + console.log("User config loaded:"); + console.log( + ` Database: ${client.conf.database.host}:${client.conf.database.port}`, + ); + console.log(` Features - Premium: ${client.conf.features.premium}`); + console.log(` Features - Max Users: ${client.conf.features.maxUsers}`); + console.log(` API Keys: ${client.conf.apiKeys.length} configured`); + } + }); diff --git a/docs/USER_CONFIG.md b/docs/USER_CONFIG.md new file mode 100644 index 0000000..703ea9a --- /dev/null +++ b/docs/USER_CONFIG.md @@ -0,0 +1,114 @@ +# User Config Feature + +This feature allows you to define custom configuration in a `config.json` file and access it with full TypeScript type safety through `client.conf`. + +## Setup + +### 1. Enable User Config + +In your `djs.config.ts`, enable the experimental `userConfig` feature: + +```typescript +import type { Config } from "@djs-core/dev"; + +export default { + token: process.env.TOKEN, + servers: ["your-server-id"], + experimental: { + userConfig: true, // Enable user config + }, +} satisfies Config; +``` + +### 2. Create config.json + +Create a `config.json` file in your project root with your custom configuration: + +```json +{ + "database": { + "host": "localhost", + "port": 5432, + "name": "mydb" + }, + "features": { + "premium": true, + "maxUsers": 100 + }, + "apiKeys": ["key1", "key2"] +} +``` + +### 3. Types Auto-Generated + +When you run `dev`, `build`, or `start`, the TypeScript types are **automatically generated** from your `config.json`: + +```bash +bun djs-core dev # Types auto-generated +bun djs-core build # Types auto-generated +bun djs-core start # Types auto-generated +``` + +A `config.types.ts` file will be created automatically: + +```typescript +// Auto-generated from config.json. Do not edit manually. + +interface UserConfig { + database: { host: string; port: number; name: string }; + features: { premium: boolean; maxUsers: number }; + apiKeys: string[]; +} + +export type { UserConfig }; +``` + +**In dev mode**, types are regenerated automatically when you modify `config.json`. + +### 4. Use the Config in Your Code + +Access your config through `client.conf` with full type safety: + +```typescript +import { EventListner } from "@djs-core/runtime"; +import type { UserConfig } from "../config.types"; +import { Events } from "discord.js"; + +export default new EventListner() + .event(Events.ClientReady) + .run((client) => { + // Access your config with full TypeScript autocomplete and type checking + console.log(`Database: ${client.conf.database.host}:${client.conf.database.port}`); + console.log(`Premium enabled: ${client.conf.features.premium}`); + console.log(`Max users: ${client.conf.features.maxUsers}`); + console.log(`API Keys: ${client.conf.apiKeys.length}`); + }); +``` + +## Manual Type Generation (Optional) + +If you need to generate types manually (e.g., for IDE refresh), you can use: + +```bash +bun djs-core generate-config-types +``` + +This is optional since types are auto-generated when running dev/build/start. + +## Notes + +- The `config.json` file is loaded at runtime +- The `config.types.ts` file is auto-generated and should not be edited manually +- Types are **automatically regenerated** when you run `dev`, `build`, or `start` +- In **dev mode**, types regenerate when you modify `config.json` +- The `client.conf` property is typed as `UserConfig` when using the generic type parameter +- If `userConfig` is not enabled, `client.conf` will be `undefined` +- Empty arrays in config.json are typed as `unknown[]` - add at least one element for better type inference + +## Example Use Cases + +- Database connection settings +- Feature flags +- API keys and credentials (ensure proper security measures) +- Custom bot configuration per environment +- Application-specific settings diff --git a/packages/dev/commands/build.ts b/packages/dev/commands/build.ts index 6764f9c..bbc9f53 100644 --- a/packages/dev/commands/build.ts +++ b/packages/dev/commands/build.ts @@ -4,6 +4,7 @@ import fs from "fs/promises"; import path from "path"; import pc from "picocolors"; import { banner, PATH_ALIASES } from "../utils/common"; +import { autoGenerateConfigTypes } from "../utils/config-type-generator"; declare const Bun: typeof import("bun"); @@ -76,6 +77,7 @@ function buildGeneratedEntry(opts: { eventFiles: string[]; cronFiles: string[]; hasCronEnabled: boolean; + hasUserConfigEnabled: boolean; }): string { const { genDir, @@ -169,6 +171,7 @@ function buildGeneratedEntry(opts: { import config from "../djs.config.ts"; import { DjsClient, type Route } from "@djs-core/runtime"; import { Events } from "discord.js"; +${opts.hasUserConfigEnabled ? 'import type { UserConfig } from "../config.types.ts";\nimport userConfigData from "../config.json" with { type: "json" };' : ""} ${imports.join("\n")} @@ -222,7 +225,11 @@ ${sortedCrons.map((c) => ` [${JSON.stringify(c.id)}, ${c.varName}],`).join("\ : "" } - const client = new DjsClient({ djsConfig: config }); + // Load user config if enabled. The type assertion is safe because: + // 1. The config.json is parsed and validated at build time + // 2. The UserConfig type is auto-generated from config.json structure + // 3. Any runtime mismatch will be caught during bot initialization +${opts.hasUserConfigEnabled ? " const client = new DjsClient({ djsConfig: config, userConfig: userConfigData as UserConfig });" : " const client = new DjsClient({ djsConfig: config });"} client.eventsHandler.set(events); @@ -305,9 +312,15 @@ export function registerBuildCommand(cli: CAC) { const configModule = await import(path.join(botRoot, "djs.config.ts")); const config = configModule.default as { - experimental?: { cron?: boolean }; + experimental?: { cron?: boolean; userConfig?: boolean }; }; const hasCronEnabled = config.experimental?.cron === true; + const hasUserConfigEnabled = config.experimental?.userConfig === true; + + // Auto-generate config types if userConfig is enabled + if (hasUserConfigEnabled) { + await autoGenerateConfigTypes(botRoot, true); + } const code = buildGeneratedEntry({ genDir, @@ -322,6 +335,7 @@ export function registerBuildCommand(cli: CAC) { eventFiles, cronFiles, hasCronEnabled, + hasUserConfigEnabled, }); await fs.writeFile(entryPath, code, "utf8"); diff --git a/packages/dev/commands/dev.ts b/packages/dev/commands/dev.ts index 557b23c..cb6395d 100644 --- a/packages/dev/commands/dev.ts +++ b/packages/dev/commands/dev.ts @@ -17,6 +17,7 @@ import chokidar from "chokidar"; import path from "path"; import pc from "picocolors"; import { banner, PATH_ALIASES, runBot } from "../utils/common"; +import { autoGenerateConfigTypes } from "../utils/config-type-generator"; type SelectMenu = | StringSelectMenu @@ -392,9 +393,35 @@ export function registerDevCommand(cli: CAC) { .on("change", (p) => processFile("change", p)) .on("unlink", (p) => processFile("unlink", p)); + // Watch config.json for changes if userConfig is enabled + let configWatcher: chokidar.FSWatcher | null = null; + if (config.experimental?.userConfig) { + const configJsonPath = path.join(root, "config.json"); + configWatcher = chokidar.watch(configJsonPath, { + ignoreInitial: true, + }); + + configWatcher.on("change", async () => { + console.log( + `${pc.cyan("ℹ")} config.json changed, regenerating types...`, + ); + await autoGenerateConfigTypes(root); + }); + + configWatcher.on("add", async () => { + console.log( + `${pc.green("✓")} config.json created, generating types...`, + ); + await autoGenerateConfigTypes(root); + }); + } + process.on("SIGINT", async () => { console.log(pc.dim("\nShutting down...")); await watcher.close(); + if (configWatcher) { + await configWatcher.close(); + } await client.destroy(); process.exit(0); }); diff --git a/packages/dev/commands/generate-config-types.ts b/packages/dev/commands/generate-config-types.ts new file mode 100644 index 0000000..8457ddf --- /dev/null +++ b/packages/dev/commands/generate-config-types.ts @@ -0,0 +1,41 @@ +import type { CAC } from "cac"; +import fs from "fs/promises"; +import path from "path"; +import pc from "picocolors"; +import { banner } from "../utils/common"; +import { generateTypesFromJson } from "../utils/config-type-generator"; + +export function registerGenerateConfigTypesCommand(cli: CAC) { + cli + .command("generate-config-types", "Generate TypeScript types from config.json") + .option("-p, --path ", "Custom project path", { default: "." }) + .action(async (options: { path: string }) => { + console.log(banner); + console.log(`${pc.cyan("ℹ")} Generating config types...`); + + const projectRoot = path.resolve(process.cwd(), options.path); + const configJsonPath = path.join(projectRoot, "config.json"); + const outputPath = path.join(projectRoot, "config.types.ts"); + + try { + await fs.access(configJsonPath); + } catch { + console.error( + pc.red( + `❌ config.json not found at ${configJsonPath}\n Create a config.json file first.`, + ), + ); + process.exit(1); + } + + try { + await generateTypesFromJson(configJsonPath, outputPath); + console.log( + pc.green(`✓ Types generated successfully at ${outputPath}`), + ); + } catch (error: unknown) { + console.error(pc.red("❌ Error generating types:"), error); + process.exit(1); + } + }); +} diff --git a/packages/dev/index.ts b/packages/dev/index.ts index bf87ddf..ec66766 100644 --- a/packages/dev/index.ts +++ b/packages/dev/index.ts @@ -3,6 +3,7 @@ import { cac } from "cac"; import type { Config } from "../utils/types/config"; import { registerBuildCommand } from "./commands/build"; import { registerDevCommand } from "./commands/dev"; +import { registerGenerateConfigTypesCommand } from "./commands/generate-config-types"; import { registerStartCommand } from "./commands/start"; export type { Config }; @@ -12,5 +13,6 @@ const cli = cac("djs-core").version("2.0.0").help(); registerStartCommand(cli); registerDevCommand(cli); registerBuildCommand(cli); +registerGenerateConfigTypesCommand(cli); cli.parse(); diff --git a/packages/dev/utils/common.ts b/packages/dev/utils/common.ts index 8b59b4c..66d7eef 100644 --- a/packages/dev/utils/common.ts +++ b/packages/dev/utils/common.ts @@ -26,6 +26,7 @@ import fs from "fs/promises"; import path, { resolve } from "path"; import pc from "picocolors"; import type { Config } from "../../utils/types/config"; +import { autoGenerateConfigTypes } from "./config-type-generator"; export const banner = ` ${pc.bold(pc.blue("djs-core"))} ${pc.dim(`v1.0.0`)} @@ -54,6 +55,11 @@ export async function runBot(projectPath: string) { console.log(`${pc.green("✓")} Config loaded`); + // Auto-generate config types if userConfig is enabled + if (config.experimental?.userConfig) { + await autoGenerateConfigTypes(root); + } + const commands: Route[] = []; const buttons: Button[] = []; const contextMenus: ContextMenu[] = []; @@ -137,7 +143,23 @@ export async function runBot(projectPath: string) { console.log(`${pc.green("✓")} Loaded ${pc.bold(tasks.size)} cron tasks`); } - const client = new DjsClient({ djsConfig: config }); + let userConfig: unknown = undefined; + if (config.experimental?.userConfig) { + try { + const configJsonPath = path.join(root, "config.json"); + const configJsonContent = await fs.readFile(configJsonPath, "utf-8"); + userConfig = JSON.parse(configJsonContent); + console.log(`${pc.green("✓")} User config loaded`); + } catch (error) { + console.warn( + pc.yellow( + "⚠️ userConfig is enabled but config.json not found or invalid", + ), + ); + } + } + + const client = new DjsClient({ djsConfig: config, userConfig }); client.eventsHandler.set(events); diff --git a/packages/dev/utils/config-type-generator.ts b/packages/dev/utils/config-type-generator.ts new file mode 100644 index 0000000..90bb654 --- /dev/null +++ b/packages/dev/utils/config-type-generator.ts @@ -0,0 +1,138 @@ +import fs from "fs/promises"; +import path from "path"; +import pc from "picocolors"; + +function inferType(value: unknown): string { + if (value === null) return "null"; + if (value === undefined) return "undefined"; + if (typeof value === "string") return "string"; + if (typeof value === "number") return "number"; + if (typeof value === "boolean") return "boolean"; + + if (Array.isArray(value)) { + if (value.length === 0) return "unknown[]"; + const firstElement = value[0]; + const elementType = inferType(firstElement); + const allSameType = value.every((v) => inferType(v) === elementType); + return allSameType ? `${elementType}[]` : "unknown[]"; + } + + if (typeof value === "object") { + const entries = Object.entries(value); + if (entries.length === 0) return "Record"; + + const properties = entries + .map(([key, val]) => { + const valueType = inferType(val); + return `${key}: ${valueType}`; + }) + .join("; "); + + return `{ ${properties} }`; + } + + return "unknown"; +} + +function generateTypeDefinition( + obj: unknown, + typeName: string, + indent = 0, +): string { + if (obj === null) return "null"; + if (obj === undefined) return "undefined"; + + const indentStr = " ".repeat(indent); + const nextIndentStr = " ".repeat(indent + 1); + + if (Array.isArray(obj)) { + if (obj.length === 0) return "unknown[]"; + const firstElement = obj[0]; + const elementType = inferType(firstElement); + return `${elementType}[]`; + } + + if (typeof obj === "object") { + const entries = Object.entries(obj); + if (entries.length === 0) return "Record"; + + const properties = entries + .map(([key, value]) => { + const valueType = inferType(value); + return `${nextIndentStr}${key}: ${valueType};`; + }) + .join("\n"); + + return `${indent === 0 ? "" : "\n"}${indentStr}interface ${typeName} {\n${properties}\n${indentStr}}`; + } + + return inferType(obj); +} + +export async function generateTypesFromJson( + configJsonPath: string, + outputPath: string, +): Promise { + const jsonContent = await fs.readFile(configJsonPath, "utf-8"); + const config = JSON.parse(jsonContent); + + const typeDefinition = generateTypeDefinition(config, "UserConfig"); + + const fileContent = `// Auto-generated from config.json. Do not edit manually. + +${typeDefinition} + +export type { UserConfig }; +`; + + await fs.writeFile(outputPath, fileContent, "utf-8"); +} + +/** + * Auto-generate config types if userConfig is enabled and config.json exists + * This is called automatically by dev/build/start commands + */ +export async function autoGenerateConfigTypes( + projectRoot: string, + silent = false, +): Promise { + const configJsonPath = path.join(projectRoot, "config.json"); + const outputPath = path.join(projectRoot, "config.types.ts"); + + try { + await fs.access(configJsonPath); + } catch { + // config.json doesn't exist, skip generation + if (!silent) { + console.log( + pc.yellow( + "⚠️ userConfig enabled but config.json not found. Skipping type generation.", + ), + ); + } + return false; + } + + try { + await generateTypesFromJson(configJsonPath, outputPath); + if (!silent) { + console.log(pc.green("✓ Config types auto-generated")); + } + return true; + } catch (error: unknown) { + if (!silent) { + console.warn( + pc.yellow( + `⚠️ Error generating config types from ${configJsonPath}`, + ), + ); + console.warn( + pc.dim(" Possible causes: invalid JSON syntax, file permissions"), + ); + if (error instanceof Error) { + console.warn(pc.dim(` ${error.message}`)); + } + } + return false; + } +} diff --git a/packages/runtime/DjsClient.ts b/packages/runtime/DjsClient.ts index d5b7e40..b779404 100644 --- a/packages/runtime/DjsClient.ts +++ b/packages/runtime/DjsClient.ts @@ -25,7 +25,7 @@ import ModalHandler from "./handler/ModalHandler"; import SelectMenuHandler from "./handler/SelectMenuHandler"; import { cleanupExpiredTokens } from "./store/DataStore"; -export default class DjsClient extends Client { +export default class DjsClient extends Client { public eventsHandler: EventHandler = new EventHandler(this); public commandsHandler: CommandHandler = new CommandHandler(this); public buttonsHandler: ButtonHandler = new ButtonHandler(this); @@ -36,8 +36,9 @@ export default class DjsClient extends Client { new ApplicationCommandHandler(this); public cronHandler: CronHandler = new CronHandler(this); private readonly djsConfig: Config; + public readonly conf?: UserConfig; - constructor({ djsConfig }: { djsConfig: Config }) { + constructor({ djsConfig, userConfig }: { djsConfig: Config; userConfig?: UserConfig }) { super({ intents: [ IntentsBitField.Flags.Guilds, @@ -47,6 +48,7 @@ export default class DjsClient extends Client { ], }); this.djsConfig = djsConfig; + this.conf = userConfig as UserConfig; if (djsConfig.servers && djsConfig.servers.length > 0) { this.commandsHandler.setGuilds(djsConfig.servers); diff --git a/packages/utils/types/config.d.ts b/packages/utils/types/config.d.ts index 50c3511..39ab646 100644 --- a/packages/utils/types/config.d.ts +++ b/packages/utils/types/config.d.ts @@ -8,5 +8,6 @@ export interface Config { }; experimental?: { cron?: boolean; + userConfig?: boolean; }; }