import type { StandardSchemaV1 } from '@standard-schema/spec'; import { applyValues } from '../core/args.ts'; import { ConfigError } from '../core/errors.ts'; import { defineInterceptor } from '../core/interceptors.ts'; import { thenMaybe } from '../core/results.ts'; import type { AnyPadroneBuilder, CommandTypesBase, InterceptorValidateContext } from '../types/index.ts'; import type { WithAsync } from '../util/type-utils.ts'; import { getRootCommand } from '../util/utils.ts'; // ── Types ──────────────────────────────────────────────────────────────── export type PadroneConfigOptions = { /** Config file names to auto-detect (e.g. `['config.json', '.myapprc']`). First found is used. */ files?: string | string[]; /** Schema to validate and transform config file data into the args shape. */ schema?: StandardSchemaV1; /** Disable this extension. */ disabled?: boolean; /** Whether to add `--config` / `-c` flag support. Defaults to `true`. */ flag?: boolean; /** Whether subcommands inherit this interceptor. Defaults to `true`. */ inherit?: boolean; /** * Search for config files in the user's platform-specific config directory. * - `true` — use the program name as the subdirectory (e.g. program `'myapp'` → `~/.config/myapp/`). * - `string` — use a custom app name as the subdirectory. * - `false` — disable (default). * * Directories searched (after cwd): * - **Linux**: `$XDG_CONFIG_HOME/` or `~/.config/` * - **macOS**: `~/Library/Application Support/` (or `$XDG_CONFIG_HOME/` when set) * - **Windows**: `%APPDATA%\` * * Config files found in cwd always take precedence over XDG paths. */ xdg?: string | boolean; /** * Custom config loader. When provided, replaces the built-in file system loader. * Useful for testing or non-CLI environments. */ loadConfig?: ( files: string | string[], xdgAppName?: string, ) => Record | undefined | Promise | undefined>; }; // ── File system config loader ─────────────────────────────────────────── // Lazily resolved Node.js modules — cached after first import to keep loadConfig sync after initialization. let _fs: typeof import('node:fs') | undefined; let _path: typeof import('node:path') | undefined; async function initNodeModules(): Promise { if (_fs && _path) return; _fs = await import('node:fs'); _path = await import('node:path'); } // Eagerly start caching node modules so loadConfig is sync by the time it's called. try { if (typeof process !== 'undefined') initNodeModules(); } catch { // Non-CLI environments (browser, edge) — ignore } function getUserConfigDir(path: typeof import('node:path'), appName: string): string | undefined { const platform = process.platform; // Respect XDG_CONFIG_HOME on all platforms when explicitly set const xdgHome = process.env.XDG_CONFIG_HOME; if (xdgHome) return path.join(xdgHome, appName); const home = process.env.HOME || process.env.USERPROFILE; if (!home) return undefined; if (platform === 'win32') { const appData = process.env.APPDATA; return appData ? path.join(appData, appName) : path.join(home, 'AppData', 'Roaming', appName); } if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', appName); // Linux and other Unix — default XDG path return path.join(home, '.config', appName); } function resolveConfigPath(fs: any, path: any, cwd: string, files: string | string[], xdgAppName?: string): string | undefined { if (typeof files === 'string') { const abs = path.isAbsolute(files) ? files : path.resolve(cwd, files); if (!fs.existsSync(abs)) { console.error(`Config file not found: ${abs}`); return undefined; } return abs; } // Search in cwd first for (const candidate of files) { const abs = path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate); if (fs.existsSync(abs)) return abs; } // Then search in the user config directory (XDG / platform-specific) if (xdgAppName) { const configDir = getUserConfigDir(path, xdgAppName); if (configDir) { for (const candidate of files) { const abs = path.join(configDir, candidate); if (fs.existsSync(abs)) return abs; } } } return undefined; } function loadConfigSync( fs: typeof import('node:fs'), path: typeof import('node:path'), files: string | string[], xdgAppName?: string, ): Record | undefined | Promise | undefined> { const cwd = process.cwd(); const absolutePath = resolveConfigPath(fs, path, cwd, files, xdgAppName); if (!absolutePath) return undefined; const getContent = () => fs.readFileSync(absolutePath, 'utf-8'); const ext = path.extname(absolutePath).toLowerCase(); if (ext === '.yaml' || ext === '.yml') return Bun.YAML.parse(getContent()) as any; if (ext === '.toml') return Bun.TOML.parse(getContent()) as any; if (ext === '.jsonc') return Bun.JSONC.parse(getContent()) as any; if (ext === '.json') { if (Bun.JSONC) return Bun.JSONC.parse(getContent()) as any; try { return JSON.parse(getContent()); } catch { return Bun.JSONC.parse(getContent()) as any; } } if (ext === '.js' || ext === '.cjs' || ext === '.mjs' || ext === '.ts' || ext === '.cts' || ext === '.mts') { return import(/* @vite-ignore */ absolutePath).then((mod) => mod.default ?? mod); } // Unknown extension — try JSON try { return JSON.parse(getContent()); } catch { console.error(`Unable to parse config file: ${absolutePath}`); return undefined; } } /** * Built-in config file loader. Directly accesses the file system. * Returns `undefined` in non-CLI environments where `node:fs` is unavailable. */ function loadConfig( files: string | string[], xdgAppName?: string, ): Record | undefined | Promise | undefined> { if (typeof process === 'undefined') return undefined; try { if (_fs && _path) return loadConfigSync(_fs, _path, files, xdgAppName); return initNodeModules().then(() => loadConfigSync(_fs!, _path!, files, xdgAppName)); } catch { return undefined; } } // ── Extension ──────────────────────────────────────────────────────────── /** * Extension that handles config file loading, validation, and merging into command arguments. * * Features: * - `--config` / `-c` flag for explicit config file path (can be disabled via `flag: false`) * - Auto-detection of config files from a list of candidate names * - Optional schema validation and transformation of config data * - Directly accesses the file system (gracefully no-ops in non-CLI environments) * * Config values have the lowest precedence (CLI > stdin > env > config). * * Not included in the default built-in extensions — must be explicitly added: * ```ts * createPadrone('my-cli') * .extend(padroneConfig({ * files: ['config.json', '.myapprc'], * schema: z.object({ port: z.number(), host: z.string() }), * })) * ``` */ export function padroneConfig(options?: PadroneConfigOptions): (builder: T) => WithAsync { if (options?.disabled) { const disabled = defineInterceptor({ id: 'padrone:config', name: 'padrone:config', order: -999, disabled: true }, () => ({})); return ((builder: AnyPadroneBuilder) => builder.intercept(disabled)) as any; } const configFiles = options?.files ? (Array.isArray(options.files) ? options.files : [options.files]) : undefined; const configSchema = options?.schema; const flagEnabled = options?.flag !== false; const inherit = options?.inherit; const xdgOption = options?.xdg; const configLoader = options?.loadConfig ?? loadConfig; const interceptor = defineInterceptor( { id: 'padrone:config', name: 'padrone:config', order: -999, ...(inherit === false && { inherit: false }) }, () => ({ validate(ctx: InterceptorValidateContext, next) { // Extract --config / -c from rawArgs let explicitConfigPath: string | undefined; if (flagEnabled) { explicitConfigPath = (ctx.rawArgs.config ?? ctx.rawArgs.c) as string | undefined; if (typeof explicitConfigPath === 'string') { delete ctx.rawArgs.config; delete ctx.rawArgs.c; } } // Skip entirely when there's nothing to load if (!explicitConfigPath && !configFiles) return next(); // Resolve XDG app name: true → derive from root command name, string → use as-is let xdgAppName: string | undefined; if (typeof xdgOption === 'string') xdgAppName = xdgOption; else if (xdgOption === true) xdgAppName = getRootCommand(ctx.command).name; // Load config data: explicit --config flag takes priority, then auto-detect const configDataOrPromise = configLoader(explicitConfigPath ?? configFiles ?? [], xdgAppName); const applyConfig = (configData: Record | undefined) => { if (!configData) return next(); // Validate against schema if provided if (configSchema) { const validated = configSchema['~standard'].validate(configData); return thenMaybe(validated, (result) => { if (result.issues) { const issueMessages = result.issues .map((i: StandardSchemaV1.Issue) => ` - ${i.path?.join('.') || 'root'}: ${i.message}`) .join('\n'); throw new ConfigError(`Invalid config file:\n${issueMessages}`, { command: ctx.command.path || ctx.command.name, }); } const validatedData = result.value as Record; const mergedRawArgs = applyValues(ctx.rawArgs, validatedData); return next({ rawArgs: mergedRawArgs }); }); } // No schema — pass through as-is const mergedRawArgs = applyValues(ctx.rawArgs, configData); return next({ rawArgs: mergedRawArgs }); }; return thenMaybe(configDataOrPromise, applyConfig); }, }), ); return ((builder: AnyPadroneBuilder) => builder.intercept(interceptor)) as any; }