Files
automaker/libs/platform/src/rc-file-manager.ts
Dhanush Santosh 88864ad6bc feature/custom terminal configs (#717)
* feat(terminal): Add core infrastructure for custom terminal configurations

- Add TerminalConfig types to settings schema (global & project-specific)
- Create RC generator with hex-to-xterm-256 color mapping
- Create RC file manager for .automaker/terminal/ directory
- Add terminal theme color data (40 themes) to platform package
- Integrate terminal config injection into TerminalService
- Support bash, zsh, and sh with proper env var injection (BASH_ENV, ZDOTDIR, ENV)
- Add onThemeChange hook for theme synchronization

Part of custom terminal configurations feature implementation.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat(terminal): Wire terminal service with settings service

- Pass SettingsService to TerminalService constructor
- Initialize terminal service with settings service dependency
- Enable terminal config injection to work with actual settings

This completes Steps 1-4 of the terminal configuration plan:
- RC Generator (color mapping, prompt formats)
- RC File Manager (file I/O, atomic writes)
- Settings Schema (GlobalSettings + ProjectSettings)
- Terminal Service Integration (env var injection)

Next steps: Settings UI and theme change hooks.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat(terminal): Add Settings UI and theme change synchronization

Complete Steps 5 & 6 of terminal configuration implementation:

Settings UI Components:
- Add PromptPreview component with live theme-aware rendering
- Add TerminalConfigSection with comprehensive controls:
  * Enable/disable toggle with confirmation dialog
  * Custom prompt toggle
  * Prompt format selector (4 formats)
  * Git branch/status toggles
  * Custom aliases textarea
  * Custom env vars key-value editor with validation
  * Info box explaining behavior
- Integrate into existing TerminalSection

Theme Change Hook:
- Add theme detection in update-global settings route
- Regenerate RC files for all projects when theme changes
- Skip projects with terminal config disabled
- Error handling with per-project logging
- Inject terminal service with settings service dependency

This completes the full terminal configuration feature:
✓ RC Generator (color mapping, prompts)
✓ RC File Manager (file I/O, versioning)
✓ Settings Schema (types, defaults)
✓ Terminal Service Integration (env vars, PTY spawn)
✓ Settings UI (comprehensive controls, preview)
✓ Theme Synchronization (automatic RC regeneration)

New terminals will use custom prompts matching app theme.
Existing terminals unaffected. User RC files preserved.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(terminal): Add error handling and explicit field mapping for terminal config

- Add try-catch block to handleToggleEnabled
- Explicitly set all required terminalConfig fields
- Add console logging for debugging
- Show error toast if update fails
- Include rcFileVersion: 1 in config object

This should fix the issue where the toggle doesn't enable after
clicking OK in the confirmation dialog.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(terminal): Use React Query mutation hook for settings updates

The issue was that `updateGlobalSettings` doesn't exist in the app store.
The correct pattern is to use the `useUpdateGlobalSettings` hook from
use-settings-mutations.ts, which is a React Query mutation.

Changes:
- Import useUpdateGlobalSettings from mutations hook
- Use mutation.mutate() instead of direct function call
- Add proper onSuccess/onError callbacks
- Remove async/await pattern (React Query handles this)

This fixes the toggle not enabling after clicking OK in the confirmation dialog.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(terminal): Use React Query hook for globalSettings instead of store

The root cause: Component was reading globalSettings from the app store,
which doesn't update reactively when the mutation completes.

Solution: Use useGlobalSettings() React Query hook which:
- Automatically refetches when the mutation invalidates the cache
- Triggers re-render with updated data
- Makes the toggle reflect the new state

Now the flow is:
1. User clicks toggle → confirmation dialog
2. Click OK → mutation.mutate() called
3. Mutation succeeds → invalidates queryKeys.settings.global()
4. Query refetches → component re-renders with new globalSettings
5. Toggle shows enabled state ✓

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* debug(terminal): Add detailed logging for terminal config application

Add logging to track:
- When terminal config check happens
- CWD being used
- Global and project enabled states
- Effective enabled state

This will help diagnose why RC files aren't being generated
when opening terminals in Automaker.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Fix terminal rc updates and bash rcfile loading

* feat(terminal): add banner on shell start

* feat(terminal): colorize banner per theme

* chore(terminal): bump rc version for banner colors

* feat(terminal): match banner colors to launcher

* feat(terminal): add prompt customization controls

* feat: integrate oh-my-posh prompt themes

* fix: resolve oh-my-posh theme path

* fix: correct oh-my-posh config invocation

* docs: add terminal theme screenshot

* fix: address review feedback and stabilize e2e test

* ui: split terminal config into separate card

* fix: enable cross-platform Warp terminal detection

- Remove macOS-only platform restriction for Warp
- Add Linux CLI alias 'warp-terminal' (primary on Linux)
- Add CLI launch handler using --cwd flag
- Fixes issue where Warp was not detected on Linux systems

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 20:34:33 +05:30

309 lines
8.9 KiB
TypeScript

/**
* RC File Manager - Manage shell configuration files in .automaker/terminal/
*
* This module handles file I/O operations for generating and managing shell RC files,
* including version checking and regeneration logic.
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { createHash } from 'node:crypto';
import type { ThemeMode } from '@automaker/types';
import {
generateBashrc,
generateZshrc,
generateCommonFunctions,
generateThemeColors,
type TerminalConfig,
type TerminalTheme,
} from './rc-generator.js';
/**
* Current RC file format version
*/
export const RC_FILE_VERSION = 11;
const RC_SIGNATURE_FILENAME = 'config.sha256';
/**
* Get the terminal directory path
*/
export function getTerminalDir(projectPath: string): string {
return path.join(projectPath, '.automaker', 'terminal');
}
/**
* Get the themes directory path
*/
export function getThemesDir(projectPath: string): string {
return path.join(getTerminalDir(projectPath), 'themes');
}
/**
* Get RC file path for specific shell
*/
export function getRcFilePath(projectPath: string, shell: 'bash' | 'zsh' | 'sh'): string {
const terminalDir = getTerminalDir(projectPath);
switch (shell) {
case 'bash':
return path.join(terminalDir, 'bashrc.sh');
case 'zsh':
return path.join(terminalDir, '.zshrc'); // Zsh looks for .zshrc in ZDOTDIR
case 'sh':
return path.join(terminalDir, 'common.sh');
}
}
/**
* Ensure terminal directory exists
*/
export async function ensureTerminalDir(projectPath: string): Promise<void> {
const terminalDir = getTerminalDir(projectPath);
const themesDir = getThemesDir(projectPath);
await fs.mkdir(terminalDir, { recursive: true, mode: 0o755 });
await fs.mkdir(themesDir, { recursive: true, mode: 0o755 });
}
/**
* Write RC file with atomic write (write to temp, then rename)
*/
async function atomicWriteFile(
filePath: string,
content: string,
mode: number = 0o644
): Promise<void> {
const tempPath = `${filePath}.tmp`;
await fs.writeFile(tempPath, content, { encoding: 'utf8', mode });
await fs.rename(tempPath, filePath);
}
function sortObjectKeys(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => sortObjectKeys(item));
}
if (value && typeof value === 'object') {
const sortedEntries = Object.entries(value as Record<string, unknown>)
.filter(([, entryValue]) => entryValue !== undefined)
.sort(([left], [right]) => left.localeCompare(right));
const sortedObject: Record<string, unknown> = {};
for (const [key, entryValue] of sortedEntries) {
sortedObject[key] = sortObjectKeys(entryValue);
}
return sortedObject;
}
return value;
}
function buildConfigSignature(theme: ThemeMode, config: TerminalConfig): string {
const payload = { theme, config: sortObjectKeys(config) };
const serializedPayload = JSON.stringify(payload);
return createHash('sha256').update(serializedPayload).digest('hex');
}
async function readSignatureFile(projectPath: string): Promise<string | null> {
const signaturePath = path.join(getTerminalDir(projectPath), RC_SIGNATURE_FILENAME);
try {
const signature = await fs.readFile(signaturePath, 'utf8');
return signature.trim() || null;
} catch {
return null;
}
}
async function writeSignatureFile(projectPath: string, signature: string): Promise<void> {
const signaturePath = path.join(getTerminalDir(projectPath), RC_SIGNATURE_FILENAME);
await atomicWriteFile(signaturePath, `${signature}\n`, 0o644);
}
/**
* Check current RC file version
*/
export async function checkRcFileVersion(projectPath: string): Promise<number | null> {
const versionPath = path.join(getTerminalDir(projectPath), 'version.txt');
try {
const content = await fs.readFile(versionPath, 'utf8');
const version = parseInt(content.trim(), 10);
return isNaN(version) ? null : version;
} catch (error) {
return null; // File doesn't exist or can't be read
}
}
/**
* Write version file
*/
async function writeVersionFile(projectPath: string, version: number): Promise<void> {
const versionPath = path.join(getTerminalDir(projectPath), 'version.txt');
await atomicWriteFile(versionPath, `${version}\n`, 0o644);
}
/**
* Check if RC files need regeneration
*/
export async function needsRegeneration(
projectPath: string,
theme: ThemeMode,
config: TerminalConfig
): Promise<boolean> {
const currentVersion = await checkRcFileVersion(projectPath);
// Regenerate if version doesn't match or files don't exist
if (currentVersion !== RC_FILE_VERSION) {
return true;
}
const expectedSignature = buildConfigSignature(theme, config);
const existingSignature = await readSignatureFile(projectPath);
if (!existingSignature || existingSignature !== expectedSignature) {
return true;
}
// Check if critical files exist
const bashrcPath = getRcFilePath(projectPath, 'bash');
const zshrcPath = getRcFilePath(projectPath, 'zsh');
const commonPath = path.join(getTerminalDir(projectPath), 'common.sh');
const themeFilePath = path.join(getThemesDir(projectPath), `${theme}.sh`);
try {
await Promise.all([
fs.access(bashrcPath),
fs.access(zshrcPath),
fs.access(commonPath),
fs.access(themeFilePath),
]);
return false; // All files exist
} catch {
return true; // Some files are missing
}
}
/**
* Write all theme color files (all 40 themes)
*/
export async function writeAllThemeFiles(
projectPath: string,
terminalThemes: Record<ThemeMode, TerminalTheme>
): Promise<void> {
const themesDir = getThemesDir(projectPath);
await fs.mkdir(themesDir, { recursive: true, mode: 0o755 });
const themeEntries = Object.entries(terminalThemes);
await Promise.all(
themeEntries.map(async ([themeName, theme]) => {
const themeFilePath = path.join(themesDir, `${themeName}.sh`);
const content = generateThemeColors(theme);
await atomicWriteFile(themeFilePath, content, 0o644);
})
);
}
/**
* Write a single theme color file
*/
export async function writeThemeFile(
projectPath: string,
theme: ThemeMode,
themeColors: TerminalTheme
): Promise<void> {
const themesDir = getThemesDir(projectPath);
await fs.mkdir(themesDir, { recursive: true, mode: 0o755 });
const themeFilePath = path.join(themesDir, `${theme}.sh`);
const content = generateThemeColors(themeColors);
await atomicWriteFile(themeFilePath, content, 0o644);
}
/**
* Write all RC files
*/
export async function writeRcFiles(
projectPath: string,
theme: ThemeMode,
config: TerminalConfig,
themeColors: TerminalTheme,
allThemes: Record<ThemeMode, TerminalTheme>
): Promise<void> {
await ensureTerminalDir(projectPath);
// Write common functions file
const commonPath = path.join(getTerminalDir(projectPath), 'common.sh');
const commonContent = generateCommonFunctions(config);
await atomicWriteFile(commonPath, commonContent, 0o644);
// Write bashrc
const bashrcPath = getRcFilePath(projectPath, 'bash');
const bashrcContent = generateBashrc(themeColors, config);
await atomicWriteFile(bashrcPath, bashrcContent, 0o644);
// Write zshrc
const zshrcPath = getRcFilePath(projectPath, 'zsh');
const zshrcContent = generateZshrc(themeColors, config);
await atomicWriteFile(zshrcPath, zshrcContent, 0o644);
// Write all theme files (40 themes)
await writeAllThemeFiles(projectPath, allThemes);
// Write version file
await writeVersionFile(projectPath, RC_FILE_VERSION);
// Write config signature for change detection
const signature = buildConfigSignature(theme, config);
await writeSignatureFile(projectPath, signature);
}
/**
* Ensure RC files are up to date
*/
export async function ensureRcFilesUpToDate(
projectPath: string,
theme: ThemeMode,
config: TerminalConfig,
themeColors: TerminalTheme,
allThemes: Record<ThemeMode, TerminalTheme>
): Promise<void> {
const needsRegen = await needsRegeneration(projectPath, theme, config);
if (needsRegen) {
await writeRcFiles(projectPath, theme, config, themeColors, allThemes);
}
}
/**
* Delete terminal directory (for disable flow)
*/
export async function deleteTerminalDir(projectPath: string): Promise<void> {
const terminalDir = getTerminalDir(projectPath);
try {
await fs.rm(terminalDir, { recursive: true, force: true });
} catch (error) {
// Ignore errors if directory doesn't exist
}
}
/**
* Create user-custom.sh placeholder if it doesn't exist
*/
export async function ensureUserCustomFile(projectPath: string): Promise<void> {
const userCustomPath = path.join(getTerminalDir(projectPath), 'user-custom.sh');
try {
await fs.access(userCustomPath);
} catch {
// File doesn't exist, create it
const content = `#!/bin/sh
# Automaker User Customizations
# Add your custom shell configuration here
# This file will not be overwritten by Automaker
# Example: Add custom aliases
# alias myalias='command'
# Example: Add custom environment variables
# export MY_VAR="value"
`;
await atomicWriteFile(userCustomPath, content, 0o644);
}
}