mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge main into massive-terminal-upgrade
Resolves merge conflicts: - apps/server/src/routes/terminal/common.ts: Keep randomBytes import, use @automaker/utils for createLogger - apps/ui/eslint.config.mjs: Use main's explicit globals list with XMLHttpRequest and MediaQueryListEvent additions - apps/ui/src/components/views/terminal-view.tsx: Keep our terminal improvements (killAllSessions, beforeunload, better error handling) - apps/ui/src/config/terminal-themes.ts: Keep our search highlight colors for all themes - apps/ui/src/store/app-store.ts: Keep our terminal settings persistence improvements (merge function) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
583
apps/server/src/services/settings-service.ts
Normal file
583
apps/server/src/services/settings-service.ts
Normal file
@@ -0,0 +1,583 @@
|
||||
/**
|
||||
* Settings Service - Handles reading/writing settings to JSON files
|
||||
*
|
||||
* Provides persistent storage for:
|
||||
* - Global settings (DATA_DIR/settings.json)
|
||||
* - Credentials (DATA_DIR/credentials.json)
|
||||
* - Per-project settings ({projectPath}/.automaker/settings.json)
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
|
||||
import {
|
||||
getGlobalSettingsPath,
|
||||
getCredentialsPath,
|
||||
getProjectSettingsPath,
|
||||
ensureDataDir,
|
||||
ensureAutomakerDir,
|
||||
} from '@automaker/platform';
|
||||
import type {
|
||||
GlobalSettings,
|
||||
Credentials,
|
||||
ProjectSettings,
|
||||
KeyboardShortcuts,
|
||||
AIProfile,
|
||||
ProjectRef,
|
||||
TrashedProjectRef,
|
||||
BoardBackgroundSettings,
|
||||
WorktreeInfo,
|
||||
} from '../types/settings.js';
|
||||
import {
|
||||
DEFAULT_GLOBAL_SETTINGS,
|
||||
DEFAULT_CREDENTIALS,
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
SETTINGS_VERSION,
|
||||
CREDENTIALS_VERSION,
|
||||
PROJECT_SETTINGS_VERSION,
|
||||
} from '../types/settings.js';
|
||||
|
||||
const logger = createLogger('SettingsService');
|
||||
|
||||
/**
|
||||
* Atomic file write - write to temp file then rename
|
||||
*/
|
||||
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
|
||||
const tempPath = `${filePath}.tmp.${Date.now()}`;
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
|
||||
try {
|
||||
await secureFs.writeFile(tempPath, content, 'utf-8');
|
||||
await secureFs.rename(tempPath, filePath);
|
||||
} catch (error) {
|
||||
// Clean up temp file if it exists
|
||||
try {
|
||||
await secureFs.unlink(tempPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely read JSON file with fallback to default
|
||||
*/
|
||||
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
|
||||
try {
|
||||
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
|
||||
return JSON.parse(content) as T;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return defaultValue;
|
||||
}
|
||||
logger.error(`Error reading ${filePath}:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists
|
||||
*/
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await secureFs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SettingsService - Manages persistent storage of user settings and credentials
|
||||
*
|
||||
* Handles reading and writing settings to JSON files with atomic operations
|
||||
* for reliability. Provides three levels of settings:
|
||||
* - Global settings: shared preferences in {dataDir}/settings.json
|
||||
* - Credentials: sensitive API keys in {dataDir}/credentials.json
|
||||
* - Project settings: per-project overrides in {projectPath}/.automaker/settings.json
|
||||
*
|
||||
* All operations are atomic (write to temp file, then rename) to prevent corruption.
|
||||
* Missing files are treated as empty and return defaults on read.
|
||||
* Updates use deep merge for nested objects like keyboardShortcuts and apiKeys.
|
||||
*/
|
||||
export class SettingsService {
|
||||
private dataDir: string;
|
||||
|
||||
/**
|
||||
* Create a new SettingsService instance
|
||||
*
|
||||
* @param dataDir - Absolute path to global data directory (e.g., ~/.automaker)
|
||||
*/
|
||||
constructor(dataDir: string) {
|
||||
this.dataDir = dataDir;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Global Settings
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get global settings with defaults applied for any missing fields
|
||||
*
|
||||
* Reads from {dataDir}/settings.json. If file doesn't exist, returns defaults.
|
||||
* Missing fields are filled in from DEFAULT_GLOBAL_SETTINGS for forward/backward
|
||||
* compatibility during schema migrations.
|
||||
*
|
||||
* @returns Promise resolving to complete GlobalSettings object
|
||||
*/
|
||||
async getGlobalSettings(): Promise<GlobalSettings> {
|
||||
const settingsPath = getGlobalSettingsPath(this.dataDir);
|
||||
const settings = await readJsonFile<GlobalSettings>(settingsPath, DEFAULT_GLOBAL_SETTINGS);
|
||||
|
||||
// Apply any missing defaults (for backwards compatibility)
|
||||
return {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
...settings,
|
||||
keyboardShortcuts: {
|
||||
...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
||||
...settings.keyboardShortcuts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update global settings with partial changes
|
||||
*
|
||||
* Performs a deep merge: nested objects like keyboardShortcuts are merged,
|
||||
* not replaced. Updates are written atomically. Creates dataDir if needed.
|
||||
*
|
||||
* @param updates - Partial GlobalSettings to merge (only provided fields are updated)
|
||||
* @returns Promise resolving to complete updated GlobalSettings
|
||||
*/
|
||||
async updateGlobalSettings(updates: Partial<GlobalSettings>): Promise<GlobalSettings> {
|
||||
await ensureDataDir(this.dataDir);
|
||||
const settingsPath = getGlobalSettingsPath(this.dataDir);
|
||||
|
||||
const current = await this.getGlobalSettings();
|
||||
const updated: GlobalSettings = {
|
||||
...current,
|
||||
...updates,
|
||||
version: SETTINGS_VERSION,
|
||||
};
|
||||
|
||||
// Deep merge keyboard shortcuts if provided
|
||||
if (updates.keyboardShortcuts) {
|
||||
updated.keyboardShortcuts = {
|
||||
...current.keyboardShortcuts,
|
||||
...updates.keyboardShortcuts,
|
||||
};
|
||||
}
|
||||
|
||||
await atomicWriteJson(settingsPath, updated);
|
||||
logger.info('Global settings updated');
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if global settings file exists
|
||||
*
|
||||
* Used to determine if user has previously configured settings.
|
||||
*
|
||||
* @returns Promise resolving to true if {dataDir}/settings.json exists
|
||||
*/
|
||||
async hasGlobalSettings(): Promise<boolean> {
|
||||
const settingsPath = getGlobalSettingsPath(this.dataDir);
|
||||
return fileExists(settingsPath);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Credentials
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get credentials with defaults applied
|
||||
*
|
||||
* Reads from {dataDir}/credentials.json. If file doesn't exist, returns
|
||||
* defaults (empty API keys). Used primarily by backend for API authentication.
|
||||
* UI should use getMaskedCredentials() instead.
|
||||
*
|
||||
* @returns Promise resolving to complete Credentials object
|
||||
*/
|
||||
async getCredentials(): Promise<Credentials> {
|
||||
const credentialsPath = getCredentialsPath(this.dataDir);
|
||||
const credentials = await readJsonFile<Credentials>(credentialsPath, DEFAULT_CREDENTIALS);
|
||||
|
||||
return {
|
||||
...DEFAULT_CREDENTIALS,
|
||||
...credentials,
|
||||
apiKeys: {
|
||||
...DEFAULT_CREDENTIALS.apiKeys,
|
||||
...credentials.apiKeys,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update credentials with partial changes
|
||||
*
|
||||
* Updates individual API keys. Uses deep merge for apiKeys object.
|
||||
* Creates dataDir if needed. Credentials are written atomically.
|
||||
* WARNING: Use only in secure contexts - keys are unencrypted.
|
||||
*
|
||||
* @param updates - Partial Credentials (usually just apiKeys)
|
||||
* @returns Promise resolving to complete updated Credentials object
|
||||
*/
|
||||
async updateCredentials(updates: Partial<Credentials>): Promise<Credentials> {
|
||||
await ensureDataDir(this.dataDir);
|
||||
const credentialsPath = getCredentialsPath(this.dataDir);
|
||||
|
||||
const current = await this.getCredentials();
|
||||
const updated: Credentials = {
|
||||
...current,
|
||||
...updates,
|
||||
version: CREDENTIALS_VERSION,
|
||||
};
|
||||
|
||||
// Deep merge api keys if provided
|
||||
if (updates.apiKeys) {
|
||||
updated.apiKeys = {
|
||||
...current.apiKeys,
|
||||
...updates.apiKeys,
|
||||
};
|
||||
}
|
||||
|
||||
await atomicWriteJson(credentialsPath, updated);
|
||||
logger.info('Credentials updated');
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get masked credentials safe for UI display
|
||||
*
|
||||
* Returns API keys masked for security (first 4 and last 4 chars visible).
|
||||
* Use this for showing credential status in UI without exposing full keys.
|
||||
* Each key includes a 'configured' boolean and masked string representation.
|
||||
*
|
||||
* @returns Promise resolving to masked credentials object with each provider's status
|
||||
*/
|
||||
async getMaskedCredentials(): Promise<{
|
||||
anthropic: { configured: boolean; masked: string };
|
||||
}> {
|
||||
const credentials = await this.getCredentials();
|
||||
|
||||
const maskKey = (key: string): string => {
|
||||
if (!key || key.length < 8) return '';
|
||||
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
|
||||
};
|
||||
|
||||
return {
|
||||
anthropic: {
|
||||
configured: !!credentials.apiKeys.anthropic,
|
||||
masked: maskKey(credentials.apiKeys.anthropic),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if credentials file exists
|
||||
*
|
||||
* Used to determine if user has configured any API keys.
|
||||
*
|
||||
* @returns Promise resolving to true if {dataDir}/credentials.json exists
|
||||
*/
|
||||
async hasCredentials(): Promise<boolean> {
|
||||
const credentialsPath = getCredentialsPath(this.dataDir);
|
||||
return fileExists(credentialsPath);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Project Settings
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get project-specific settings with defaults applied
|
||||
*
|
||||
* Reads from {projectPath}/.automaker/settings.json. If file doesn't exist,
|
||||
* returns defaults. Project settings are optional - missing values fall back
|
||||
* to global settings on the UI side.
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Promise resolving to complete ProjectSettings object
|
||||
*/
|
||||
async getProjectSettings(projectPath: string): Promise<ProjectSettings> {
|
||||
const settingsPath = getProjectSettingsPath(projectPath);
|
||||
const settings = await readJsonFile<ProjectSettings>(settingsPath, DEFAULT_PROJECT_SETTINGS);
|
||||
|
||||
return {
|
||||
...DEFAULT_PROJECT_SETTINGS,
|
||||
...settings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update project-specific settings with partial changes
|
||||
*
|
||||
* Performs a deep merge on boardBackground. Creates .automaker directory
|
||||
* in project if needed. Updates are written atomically.
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param updates - Partial ProjectSettings to merge
|
||||
* @returns Promise resolving to complete updated ProjectSettings
|
||||
*/
|
||||
async updateProjectSettings(
|
||||
projectPath: string,
|
||||
updates: Partial<ProjectSettings>
|
||||
): Promise<ProjectSettings> {
|
||||
await ensureAutomakerDir(projectPath);
|
||||
const settingsPath = getProjectSettingsPath(projectPath);
|
||||
|
||||
const current = await this.getProjectSettings(projectPath);
|
||||
const updated: ProjectSettings = {
|
||||
...current,
|
||||
...updates,
|
||||
version: PROJECT_SETTINGS_VERSION,
|
||||
};
|
||||
|
||||
// Deep merge board background if provided
|
||||
if (updates.boardBackground) {
|
||||
updated.boardBackground = {
|
||||
...current.boardBackground,
|
||||
...updates.boardBackground,
|
||||
};
|
||||
}
|
||||
|
||||
await atomicWriteJson(settingsPath, updated);
|
||||
logger.info(`Project settings updated for ${projectPath}`);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if project settings file exists
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Promise resolving to true if {projectPath}/.automaker/settings.json exists
|
||||
*/
|
||||
async hasProjectSettings(projectPath: string): Promise<boolean> {
|
||||
const settingsPath = getProjectSettingsPath(projectPath);
|
||||
return fileExists(settingsPath);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Migration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Migrate settings from localStorage to file-based storage
|
||||
*
|
||||
* Called during onboarding when UI detects localStorage data but no settings files.
|
||||
* Extracts global settings, credentials, and per-project settings from various
|
||||
* localStorage keys and writes them to the new file-based storage.
|
||||
* Collects errors but continues on partial failures.
|
||||
*
|
||||
* @param localStorageData - Object containing localStorage key/value pairs to migrate
|
||||
* @returns Promise resolving to migration result with success status and error list
|
||||
*/
|
||||
async migrateFromLocalStorage(localStorageData: {
|
||||
'automaker-storage'?: string;
|
||||
'automaker-setup'?: string;
|
||||
'worktree-panel-collapsed'?: string;
|
||||
'file-browser-recent-folders'?: string;
|
||||
'automaker:lastProjectDir'?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
migratedGlobalSettings: boolean;
|
||||
migratedCredentials: boolean;
|
||||
migratedProjectCount: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
const errors: string[] = [];
|
||||
let migratedGlobalSettings = false;
|
||||
let migratedCredentials = false;
|
||||
let migratedProjectCount = 0;
|
||||
|
||||
try {
|
||||
// Parse the main automaker-storage
|
||||
let appState: Record<string, unknown> = {};
|
||||
if (localStorageData['automaker-storage']) {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorageData['automaker-storage']);
|
||||
appState = parsed.state || parsed;
|
||||
} catch (e) {
|
||||
errors.push(`Failed to parse automaker-storage: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract global settings
|
||||
const globalSettings: Partial<GlobalSettings> = {
|
||||
theme: (appState.theme as GlobalSettings['theme']) || 'dark',
|
||||
sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true,
|
||||
chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false,
|
||||
kanbanCardDetailLevel:
|
||||
(appState.kanbanCardDetailLevel as GlobalSettings['kanbanCardDetailLevel']) || 'standard',
|
||||
maxConcurrency: (appState.maxConcurrency as number) || 3,
|
||||
defaultSkipTests:
|
||||
appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true,
|
||||
enableDependencyBlocking:
|
||||
appState.enableDependencyBlocking !== undefined
|
||||
? (appState.enableDependencyBlocking as boolean)
|
||||
: true,
|
||||
useWorktrees: (appState.useWorktrees as boolean) || false,
|
||||
showProfilesOnly: (appState.showProfilesOnly as boolean) || false,
|
||||
defaultPlanningMode:
|
||||
(appState.defaultPlanningMode as GlobalSettings['defaultPlanningMode']) || 'skip',
|
||||
defaultRequirePlanApproval: (appState.defaultRequirePlanApproval as boolean) || false,
|
||||
defaultAIProfileId: (appState.defaultAIProfileId as string | null) || null,
|
||||
muteDoneSound: (appState.muteDoneSound as boolean) || false,
|
||||
enhancementModel:
|
||||
(appState.enhancementModel as GlobalSettings['enhancementModel']) || 'sonnet',
|
||||
keyboardShortcuts:
|
||||
(appState.keyboardShortcuts as KeyboardShortcuts) ||
|
||||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
||||
aiProfiles: (appState.aiProfiles as AIProfile[]) || [],
|
||||
projects: (appState.projects as ProjectRef[]) || [],
|
||||
trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [],
|
||||
projectHistory: (appState.projectHistory as string[]) || [],
|
||||
projectHistoryIndex: (appState.projectHistoryIndex as number) || -1,
|
||||
lastSelectedSessionByProject:
|
||||
(appState.lastSelectedSessionByProject as Record<string, string>) || {},
|
||||
};
|
||||
|
||||
// Add direct localStorage values
|
||||
if (localStorageData['automaker:lastProjectDir']) {
|
||||
globalSettings.lastProjectDir = localStorageData['automaker:lastProjectDir'];
|
||||
}
|
||||
|
||||
if (localStorageData['file-browser-recent-folders']) {
|
||||
try {
|
||||
globalSettings.recentFolders = JSON.parse(
|
||||
localStorageData['file-browser-recent-folders']
|
||||
);
|
||||
} catch {
|
||||
globalSettings.recentFolders = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (localStorageData['worktree-panel-collapsed']) {
|
||||
globalSettings.worktreePanelCollapsed =
|
||||
localStorageData['worktree-panel-collapsed'] === 'true';
|
||||
}
|
||||
|
||||
// Save global settings
|
||||
await this.updateGlobalSettings(globalSettings);
|
||||
migratedGlobalSettings = true;
|
||||
logger.info('Migrated global settings from localStorage');
|
||||
|
||||
// Extract and save credentials
|
||||
if (appState.apiKeys) {
|
||||
const apiKeys = appState.apiKeys as {
|
||||
anthropic?: string;
|
||||
google?: string;
|
||||
openai?: string;
|
||||
};
|
||||
await this.updateCredentials({
|
||||
apiKeys: {
|
||||
anthropic: apiKeys.anthropic || '',
|
||||
google: apiKeys.google || '',
|
||||
openai: apiKeys.openai || '',
|
||||
},
|
||||
});
|
||||
migratedCredentials = true;
|
||||
logger.info('Migrated credentials from localStorage');
|
||||
}
|
||||
|
||||
// Migrate per-project settings
|
||||
const boardBackgroundByProject = appState.boardBackgroundByProject as
|
||||
| Record<string, BoardBackgroundSettings>
|
||||
| undefined;
|
||||
const currentWorktreeByProject = appState.currentWorktreeByProject as
|
||||
| Record<string, { path: string | null; branch: string }>
|
||||
| undefined;
|
||||
const worktreesByProject = appState.worktreesByProject as
|
||||
| Record<string, WorktreeInfo[]>
|
||||
| undefined;
|
||||
|
||||
// Get unique project paths that have per-project settings
|
||||
const projectPaths = new Set<string>();
|
||||
if (boardBackgroundByProject) {
|
||||
Object.keys(boardBackgroundByProject).forEach((p) => projectPaths.add(p));
|
||||
}
|
||||
if (currentWorktreeByProject) {
|
||||
Object.keys(currentWorktreeByProject).forEach((p) => projectPaths.add(p));
|
||||
}
|
||||
if (worktreesByProject) {
|
||||
Object.keys(worktreesByProject).forEach((p) => projectPaths.add(p));
|
||||
}
|
||||
|
||||
// Also check projects list for theme settings
|
||||
const projects = (appState.projects as ProjectRef[]) || [];
|
||||
for (const project of projects) {
|
||||
if (project.theme) {
|
||||
projectPaths.add(project.path);
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate each project's settings
|
||||
for (const projectPath of projectPaths) {
|
||||
try {
|
||||
const projectSettings: Partial<ProjectSettings> = {};
|
||||
|
||||
// Get theme from project object
|
||||
const project = projects.find((p) => p.path === projectPath);
|
||||
if (project?.theme) {
|
||||
projectSettings.theme = project.theme as ProjectSettings['theme'];
|
||||
}
|
||||
|
||||
if (boardBackgroundByProject?.[projectPath]) {
|
||||
projectSettings.boardBackground = boardBackgroundByProject[projectPath];
|
||||
}
|
||||
|
||||
if (currentWorktreeByProject?.[projectPath]) {
|
||||
projectSettings.currentWorktree = currentWorktreeByProject[projectPath];
|
||||
}
|
||||
|
||||
if (worktreesByProject?.[projectPath]) {
|
||||
projectSettings.worktrees = worktreesByProject[projectPath];
|
||||
}
|
||||
|
||||
if (Object.keys(projectSettings).length > 0) {
|
||||
await this.updateProjectSettings(projectPath, projectSettings);
|
||||
migratedProjectCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(`Failed to migrate project settings for ${projectPath}: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Migration complete: ${migratedProjectCount} projects migrated`);
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
migratedGlobalSettings,
|
||||
migratedCredentials,
|
||||
migratedProjectCount,
|
||||
errors,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Migration failed:', error);
|
||||
errors.push(`Migration failed: ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
migratedGlobalSettings,
|
||||
migratedCredentials,
|
||||
migratedProjectCount,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data directory path
|
||||
*
|
||||
* Returns the absolute path to the directory where global settings and
|
||||
* credentials are stored. Useful for logging, debugging, and validation.
|
||||
*
|
||||
* @returns Absolute path to data directory
|
||||
*/
|
||||
getDataDir(): string {
|
||||
return this.dataDir;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user