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:
Debian
2026-01-10 12:08:24 +00:00
parent 688cfe57ed
commit 606f27d6bb
22 changed files with 5262 additions and 6 deletions

92
src/utils/config.ts Normal file
View 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
View 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
View 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,
};