Implement Phase 1: Foundation
- Set up TypeScript project with strict mode - Configure tsup build system (ESM output) - Configure vitest for testing (36 tests passing) - Configure ESLint with flat config for TypeScript - Implement Commander.js CLI with all 4 commands: - init: Configure API keys with validation - new: Create new Ralph Method project (stub) - validate: Validate PROMPT.md files - research: Research topics via Perplexity (stub) - Implement config management: - Keys stored in ~/.ralph-generator/config.json - File permissions set to 600 - Environment variables override file config - Implement logging utility with verbosity levels - Implement atomic file writes utility All Phase 1 acceptance criteria met. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
92
src/utils/config.ts
Normal file
92
src/utils/config.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { readFile, writeFile, mkdir, chmod, stat } from 'fs/promises';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import type { Config } from '../types/index.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
const CONFIG_DIR = join(homedir(), '.ralph-generator');
|
||||
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
||||
|
||||
export function getConfigDir(): string {
|
||||
return CONFIG_DIR;
|
||||
}
|
||||
|
||||
export function getConfigFile(): string {
|
||||
return CONFIG_FILE;
|
||||
}
|
||||
|
||||
export async function ensureConfigDir(): Promise<void> {
|
||||
try {
|
||||
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (error.code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadConfig(): Promise<Config> {
|
||||
// Environment variables take precedence
|
||||
const envConfig: Config = {
|
||||
claudeApiKey: process.env.CLAUDE_API_KEY,
|
||||
perplexityApiKey: process.env.PERPLEXITY_API_KEY,
|
||||
};
|
||||
|
||||
try {
|
||||
const content = await readFile(CONFIG_FILE, 'utf-8');
|
||||
const fileConfig = JSON.parse(content) as Config;
|
||||
|
||||
// Merge: env vars override file config
|
||||
return {
|
||||
claudeApiKey: envConfig.claudeApiKey || fileConfig.claudeApiKey,
|
||||
perplexityApiKey: envConfig.perplexityApiKey || fileConfig.perplexityApiKey,
|
||||
};
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (error.code === 'ENOENT') {
|
||||
logger.debug('No config file found, using environment variables only');
|
||||
return envConfig;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveConfig(config: Config): Promise<void> {
|
||||
await ensureConfigDir();
|
||||
|
||||
const content = JSON.stringify(config, null, 2);
|
||||
await writeFile(CONFIG_FILE, content, { encoding: 'utf-8', mode: 0o600 });
|
||||
|
||||
// Ensure file permissions are correct (user read/write only)
|
||||
await chmod(CONFIG_FILE, 0o600);
|
||||
}
|
||||
|
||||
export async function clearConfig(): Promise<void> {
|
||||
try {
|
||||
await writeFile(CONFIG_FILE, '{}', { encoding: 'utf-8', mode: 0o600 });
|
||||
logger.info('Configuration cleared');
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function configExists(): Promise<boolean> {
|
||||
try {
|
||||
await stat(CONFIG_FILE);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasValidKeys(): Promise<{ claude: boolean; perplexity: boolean }> {
|
||||
const config = await loadConfig();
|
||||
return {
|
||||
claude: !!config.claudeApiKey && config.claudeApiKey.length > 0,
|
||||
perplexity: !!config.perplexityApiKey && config.perplexityApiKey.length > 0,
|
||||
};
|
||||
}
|
||||
52
src/utils/files.ts
Normal file
52
src/utils/files.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { readFile, writeFile, mkdir, stat, rename, unlink } from 'fs/promises';
|
||||
import { dirname, join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export async function ensureDir(path: string): Promise<void> {
|
||||
await mkdir(path, { recursive: true });
|
||||
}
|
||||
|
||||
export async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readFileContent(path: string): Promise<string> {
|
||||
return readFile(path, 'utf-8');
|
||||
}
|
||||
|
||||
// Atomic file write: write to temp, then rename
|
||||
export async function writeFileAtomic(path: string, content: string): Promise<void> {
|
||||
const dir = dirname(path);
|
||||
await ensureDir(dir);
|
||||
|
||||
const tempFile = join(tmpdir(), `ralph-${randomUUID()}.tmp`);
|
||||
|
||||
try {
|
||||
await writeFile(tempFile, content, 'utf-8');
|
||||
await rename(tempFile, path);
|
||||
} catch (err) {
|
||||
// Clean up temp file on error
|
||||
try {
|
||||
await unlink(tempFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readJsonFile<T>(path: string): Promise<T> {
|
||||
const content = await readFile(path, 'utf-8');
|
||||
return JSON.parse(content) as T;
|
||||
}
|
||||
|
||||
export async function writeJsonFile<T>(path: string, data: T): Promise<void> {
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
await writeFileAtomic(path, content);
|
||||
}
|
||||
67
src/utils/logger.ts
Normal file
67
src/utils/logger.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
let currentLevel: LogLevel = 'info';
|
||||
|
||||
export function setLogLevel(level: LogLevel): void {
|
||||
currentLevel = level;
|
||||
}
|
||||
|
||||
export function getLogLevel(): LogLevel {
|
||||
return currentLevel;
|
||||
}
|
||||
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|
||||
}
|
||||
|
||||
export function debug(message: string, ...args: unknown[]): void {
|
||||
if (shouldLog('debug')) {
|
||||
console.log(chalk.gray(`[DEBUG] ${message}`), ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export function info(message: string, ...args: unknown[]): void {
|
||||
if (shouldLog('info')) {
|
||||
console.log(chalk.blue(`[INFO] ${message}`), ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export function warn(message: string, ...args: unknown[]): void {
|
||||
if (shouldLog('warn')) {
|
||||
console.log(chalk.yellow(`[WARN] ${message}`), ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export function error(message: string, ...args: unknown[]): void {
|
||||
if (shouldLog('error')) {
|
||||
console.error(chalk.red(`[ERROR] ${message}`), ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export function success(message: string, ...args: unknown[]): void {
|
||||
console.log(chalk.green(message), ...args);
|
||||
}
|
||||
|
||||
export function log(message: string, ...args: unknown[]): void {
|
||||
console.log(message, ...args);
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug,
|
||||
info,
|
||||
warn,
|
||||
error,
|
||||
success,
|
||||
log,
|
||||
setLevel: setLogLevel,
|
||||
getLevel: getLogLevel,
|
||||
};
|
||||
Reference in New Issue
Block a user