Add rootless Podman fixes, and others

improve container startup for rootless Podman, plus related refactors and tests. Key changes:

- Add/modify Audiobookshelf-related code and wiring (src/lib/services/audiobookshelf/api.ts, library service refs) and update documentation TABLEOFCONTENTS to reference ABS implementation.
- Detect user namespace in docker/unified app-start.sh and redis-start.sh and skip gosu when running in rootless Podman to preserve UID mapping; improve startup logging and verification.
- Add utility/service files (auth-token-cache.service.ts, credential-migration.service.ts, cleanup-helpers.ts) and corresponding tests; update chapter-merger and metadata-tagger utilities/tests.
- Update many admin/auth API routes and tests to reflect changes in settings and integrations.
- Remove large AI agent and Audiobookshelf implementation guide docs (AGENTS.md and the implementation guide) and add README note about AI-assisted workflow.

These changes enable Audiobookshelf backend mode, improve compatibility with rootless container runtimes, and include cleanup/refactor work and unit tests.
This commit is contained in:
kikootwo
2026-02-04 14:05:28 -05:00
parent 2ef9ac7be1
commit a0f2ba680d
42 changed files with 1843 additions and 3820 deletions
+4 -4
View File
@@ -1027,8 +1027,8 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
pathMappingEnabled: clientConfig.remotePathMappingEnabled,
});
// Validate required fields
if (!clientConfig.url || !clientConfig.username || !clientConfig.password) {
// Validate required fields (only URL is required - username/password optional for whitelist users)
if (!clientConfig.url) {
throw new Error('qBittorrent is not fully configured. Please check your configuration in admin settings.');
}
@@ -1045,8 +1045,8 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
logger.info('[QBittorrent] Creating service instance...');
qbittorrentService = new QBittorrentService(
clientConfig.url,
clientConfig.username,
clientConfig.password,
clientConfig.username || '',
clientConfig.password || '',
downloadDir,
clientConfig.category || 'readmeabook',
clientConfig.disableSSLVerify,
@@ -11,6 +11,7 @@ import { getLibraryService } from '../services/library';
import { getConfigService } from '../services/config.service';
import { generateFilesHash } from '../utils/files-hash';
import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer';
import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
/**
* Process organize files job
@@ -296,6 +297,18 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
await fs.unlink(downloadPath);
logger.info(`Removed file: ${downloadPath}`);
}
// Clean up empty parent directories (e.g., empty category folders)
// Get download_dir as the boundary - never delete above this
const downloadDir = await configService.get('download_dir') || '/downloads';
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
boundaryPath: downloadDir,
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
});
if (cleanupResult.removedDirectories.length > 0) {
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
}
} catch (fsError) {
// File/directory might already be deleted or not exist
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
@@ -776,6 +789,18 @@ async function processEbookOrganization(
await fs.unlink(downloadPath);
logger.info(`Removed file: ${downloadPath}`);
}
// Clean up empty parent directories (e.g., empty category folders)
// Get download_dir as the boundary - never delete above this
const downloadDir = await configService.get('download_dir') || '/downloads';
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
boundaryPath: downloadDir,
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
});
if (cleanupResult.removedDirectories.length > 0) {
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
}
} catch (fsError) {
// File/directory might already be deleted or not exist
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
+5 -1
View File
@@ -1,6 +1,10 @@
/**
* Component: Audiobookshelf API Client
* Documentation: documentation/features/audiobookshelf-integration.md
*
* Provides API methods for interacting with Audiobookshelf:
* - Library scanning and item fetching
* - Metadata matching (with ASIN for accurate Audible lookup)
* - Item management
*/
import { getConfigService } from '../config.service';
@@ -0,0 +1,233 @@
/**
* Component: Auth Token Cache Service
* Documentation: documentation/backend/services/auth.md
*
* Provides secure server-side storage for Plex OAuth tokens during the
* profile selection flow. Tokens are stored in memory with automatic
* expiration to prevent sensitive data from being exposed in client responses.
*
* Security: This service exists to prevent Plex tokens from being embedded
* in HTML responses or JSON payloads where they could be captured by
* viewing page source or intercepting network traffic.
*/
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('AuthTokenCache');
interface CachedToken {
token: string;
createdAt: number;
expiresAt: number;
}
/**
* Default TTL for cached tokens (5 minutes)
* This is sufficient time for profile selection while minimizing exposure window
*/
const DEFAULT_TTL_MS = 5 * 60 * 1000;
/**
* Cleanup interval - run every minute to remove expired tokens
*/
const CLEANUP_INTERVAL_MS = 60 * 1000;
/**
* AuthTokenCacheService - Singleton service for secure token storage
*
* Uses an in-memory Map for storage. Tokens are automatically expired
* and cleaned up. This is intentionally ephemeral - if the server restarts,
* users in the middle of profile selection will need to re-authenticate,
* which is acceptable for security.
*/
class AuthTokenCacheService {
private cache: Map<string, CachedToken> = new Map();
private cleanupInterval: NodeJS.Timeout | null = null;
private ttlMs: number;
constructor(ttlMs: number = DEFAULT_TTL_MS) {
this.ttlMs = ttlMs;
this.startCleanupInterval();
}
/**
* Store a Plex token for later retrieval
*
* @param pinId - The Plex PIN ID (used as the lookup key)
* @param token - The Plex OAuth token to store
* @param ttlMs - Optional custom TTL for this token
*/
set(pinId: string, token: string, ttlMs?: number): void {
const effectiveTtl = ttlMs ?? this.ttlMs;
const now = Date.now();
this.cache.set(pinId, {
token,
createdAt: now,
expiresAt: now + effectiveTtl,
});
logger.debug('Token cached', {
pinId,
ttlSeconds: Math.round(effectiveTtl / 1000),
cacheSize: this.cache.size,
});
}
/**
* Retrieve a stored token by PIN ID
*
* @param pinId - The Plex PIN ID
* @returns The stored token, or null if not found/expired
*/
get(pinId: string): string | null {
const cached = this.cache.get(pinId);
if (!cached) {
logger.debug('Token not found in cache', { pinId });
return null;
}
// Check if expired
if (Date.now() > cached.expiresAt) {
logger.debug('Token expired', { pinId });
this.cache.delete(pinId);
return null;
}
logger.debug('Token retrieved from cache', { pinId });
return cached.token;
}
/**
* Remove a token from the cache
* Called after successful authentication to clean up
*
* @param pinId - The Plex PIN ID
* @returns true if a token was removed, false if not found
*/
delete(pinId: string): boolean {
const existed = this.cache.has(pinId);
this.cache.delete(pinId);
if (existed) {
logger.debug('Token removed from cache', { pinId, cacheSize: this.cache.size });
}
return existed;
}
/**
* Check if a token exists and is not expired
*
* @param pinId - The Plex PIN ID
* @returns true if token exists and is valid
*/
has(pinId: string): boolean {
const cached = this.cache.get(pinId);
if (!cached) return false;
if (Date.now() > cached.expiresAt) {
this.cache.delete(pinId);
return false;
}
return true;
}
/**
* Get the current cache size (for monitoring)
*/
get size(): number {
return this.cache.size;
}
/**
* Manually trigger cleanup of expired tokens
* Called automatically on interval, but can be called manually if needed
*/
cleanup(): number {
const now = Date.now();
let removed = 0;
for (const [pinId, cached] of this.cache.entries()) {
if (now > cached.expiresAt) {
this.cache.delete(pinId);
removed++;
}
}
if (removed > 0) {
logger.debug('Expired tokens cleaned up', { removed, remaining: this.cache.size });
}
return removed;
}
/**
* Clear all cached tokens
* Use with caution - will force all users in profile selection to re-authenticate
*/
clear(): void {
const count = this.cache.size;
this.cache.clear();
logger.info('Token cache cleared', { tokensRemoved: count });
}
/**
* Start the automatic cleanup interval
*/
private startCleanupInterval(): void {
// Don't start multiple intervals
if (this.cleanupInterval) return;
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, CLEANUP_INTERVAL_MS);
// Don't prevent Node.js from exiting
if (this.cleanupInterval.unref) {
this.cleanupInterval.unref();
}
logger.debug('Cleanup interval started', { intervalMs: CLEANUP_INTERVAL_MS });
}
/**
* Stop the cleanup interval (for testing or shutdown)
*/
stopCleanupInterval(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
logger.debug('Cleanup interval stopped');
}
}
}
// Singleton instance
let instance: AuthTokenCacheService | null = null;
/**
* Get the singleton AuthTokenCacheService instance
*/
export function getAuthTokenCache(): AuthTokenCacheService {
if (!instance) {
instance = new AuthTokenCacheService();
logger.info('Auth token cache initialized');
}
return instance;
}
/**
* Reset the singleton instance (for testing only)
*/
export function resetAuthTokenCache(): void {
if (instance) {
instance.stopCleanupInterval();
instance.clear();
instance = null;
}
}
export { AuthTokenCacheService };
@@ -0,0 +1,169 @@
/**
* Component: Credential Migration Service
* Documentation: documentation/backend/services/config.md
*
* One-time migration to encrypt plaintext credentials stored in the database.
* Runs on startup and auto-detects plaintext vs encrypted values.
*/
import { prisma } from '@/lib/db';
import { getEncryptionService } from './encryption.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('CredentialMigration');
/**
* Check if a value looks like it's already encrypted.
* Encrypted values have format: base64:base64:base64 (iv:authTag:ciphertext)
*/
export function isEncryptedFormat(value: string): boolean {
if (!value || typeof value !== 'string') {
return false;
}
const parts = value.split(':');
if (parts.length !== 3) {
return false;
}
// Check if all parts look like base64
const base64Regex = /^[A-Za-z0-9+/]+=*$/;
return parts.every(part => part.length > 0 && base64Regex.test(part));
}
/**
* Migrate a single configuration key from plaintext to encrypted.
* Returns true if migration was performed, false if already encrypted or not found.
*/
async function migrateConfigKey(key: string): Promise<boolean> {
const config = await prisma.configuration.findUnique({
where: { key },
});
if (!config || !config.value) {
return false;
}
// Skip if already marked as encrypted
if (config.encrypted) {
logger.debug(`Key "${key}" already marked as encrypted, skipping`);
return false;
}
// Skip if value looks like it's already encrypted (format check)
if (isEncryptedFormat(config.value)) {
logger.debug(`Key "${key}" appears to be in encrypted format, updating flag only`);
await prisma.configuration.update({
where: { key },
data: { encrypted: true },
});
return false;
}
// Encrypt the plaintext value
const encryptionService = getEncryptionService();
const encryptedValue = encryptionService.encrypt(config.value);
await prisma.configuration.update({
where: { key },
data: {
value: encryptedValue,
encrypted: true,
},
});
logger.info(`Migrated credential: ${key}`);
return true;
}
/**
* Migrate download_clients JSON to encrypt passwords within.
* Returns true if any passwords were encrypted.
*/
async function migrateDownloadClients(): Promise<boolean> {
const config = await prisma.configuration.findUnique({
where: { key: 'download_clients' },
});
if (!config || !config.value) {
return false;
}
let clients: any[];
try {
clients = JSON.parse(config.value);
} catch (error) {
logger.error('Failed to parse download_clients JSON', { error });
return false;
}
if (!Array.isArray(clients) || clients.length === 0) {
return false;
}
const encryptionService = getEncryptionService();
let migratedCount = 0;
for (const client of clients) {
// Encrypt password if present and not already encrypted
if (client.password && typeof client.password === 'string' && !isEncryptedFormat(client.password)) {
client.password = encryptionService.encrypt(client.password);
migratedCount++;
}
}
if (migratedCount > 0) {
await prisma.configuration.update({
where: { key: 'download_clients' },
data: { value: JSON.stringify(clients) },
});
logger.info(`Migrated ${migratedCount} download client password(s)`);
return true;
}
return false;
}
/**
* Run the credential migration.
* Safe to call multiple times - detects and skips already-encrypted values.
*/
export async function runCredentialMigration(): Promise<void> {
logger.info('Starting credential migration check...');
let totalMigrated = 0;
// Migrate simple config keys
const keysToMigrate = [
'plex_token',
'prowlarr_api_key',
];
for (const key of keysToMigrate) {
try {
const migrated = await migrateConfigKey(key);
if (migrated) {
totalMigrated++;
}
} catch (error) {
logger.error(`Failed to migrate ${key}`, { error: error instanceof Error ? error.message : String(error) });
}
}
// Migrate download client passwords
try {
const migratedClients = await migrateDownloadClients();
if (migratedClients) {
totalMigrated++;
}
} catch (error) {
logger.error('Failed to migrate download client passwords', { error: error instanceof Error ? error.message : String(error) });
}
if (totalMigrated > 0) {
logger.info(`Credential migration complete: ${totalMigrated} item(s) encrypted`);
} else {
logger.info('Credential migration complete: no changes needed');
}
}
@@ -8,6 +8,8 @@
import { randomUUID } from 'crypto';
import { ConfigurationService } from './config.service';
import { getEncryptionService } from './encryption.service';
import { isEncryptedFormat } from './credential-migration.service';
import { RMABLogger } from '@/lib/utils/logger';
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
@@ -86,8 +88,26 @@ export class DownloadClientManager {
if (configValue) {
try {
const clients = JSON.parse(configValue) as DownloadClientConfig[];
this.clientsCache = clients;
return clients;
// Decrypt passwords if they're in encrypted format
const encryptionService = getEncryptionService();
const decryptedClients = clients.map(client => {
if (client.password && isEncryptedFormat(client.password)) {
try {
return {
...client,
password: encryptionService.decrypt(client.password),
};
} catch (error) {
logger.error(`Failed to decrypt password for client ${client.name}`, { error });
return client;
}
}
return client;
});
this.clientsCache = decryptedClients;
return decryptedClients;
} catch (error) {
logger.error('Failed to parse download_clients config', { error });
return [];
+7 -2
View File
@@ -676,9 +676,14 @@ export async function mergeChapters(
args.push('-avoid_negative_ts', 'make_zero'); // Handle negative timestamps
args.push('-max_muxing_queue_size', '9999'); // Prevent buffer overflow on long files
// Add book metadata
// Add book metadata (escape for double-quoted shell context)
// Single quotes do NOT need escaping inside double quotes - they are literal
const escapeMetadata = (val: string): string =>
val.replace(/"/g, '\\"').replace(/'/g, "\\'");
val
.replace(/\\/g, '\\\\') // Backslashes first
.replace(/"/g, '\\"') // Double quotes
.replace(/`/g, '\\`') // Backticks
.replace(/\$/g, '\\$'); // Dollar signs
args.push('-metadata', `title="${escapeMetadata(options.title)}"`);
args.push('-metadata', `album="${escapeMetadata(options.title)}"`);
+275
View File
@@ -0,0 +1,275 @@
/**
* Cleanup Helpers Utility
* Documentation: documentation/phase3/sabnzbd.md
*
* Provides utilities for cleaning up after file organization,
* including removal of empty parent directories.
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import { RMABLogger } from './logger';
const logger = RMABLogger.create('CleanupHelpers');
/**
* Options for removeEmptyParentDirectories
*/
export interface RemoveEmptyParentOptions {
/** The boundary path - will never delete this directory or its parents */
boundaryPath: string;
/** Optional logger context for job-aware logging */
logContext?: { jobId: string; context: string };
}
/**
* Removes empty parent directories after a file/directory has been deleted.
*
* This function walks up the directory tree from the deleted path, removing
* any empty directories until it encounters a non-empty directory or reaches
* the configured boundary path.
*
* Use case: SABnzbd downloads to /downloads/readmeabook/My.Audiobook.Name/
* After deleting the download folder, the category folder (readmeabook) may
* be left empty. This function cleans up those empty parent folders.
*
* Safety features:
* - Will NEVER delete the boundary path itself (e.g., download_dir)
* - Will NEVER delete above the boundary path
* - Gracefully handles ENOENT (already deleted)
* - Gracefully handles permission errors (logs warning, continues)
* - Stops immediately when a non-empty directory is encountered
*
* @param deletedPath - The path that was just deleted (file or directory)
* @param options - Configuration options including boundary path
* @returns Object with details about what was cleaned up
*
* @example
* // After deleting /downloads/readmeabook/My.Audiobook.Name
* await removeEmptyParentDirectories(
* '/downloads/readmeabook/My.Audiobook.Name',
* { boundaryPath: '/downloads' }
* );
* // This will remove /downloads/readmeabook if it's empty
* // but will never touch /downloads
*/
export async function removeEmptyParentDirectories(
deletedPath: string,
options: RemoveEmptyParentOptions
): Promise<{
success: boolean;
removedDirectories: string[];
stoppedAt?: string;
stoppedReason?: 'non_empty' | 'boundary_reached' | 'root_reached' | 'error';
error?: string;
}> {
const log = options.logContext
? RMABLogger.forJob(options.logContext.jobId, options.logContext.context)
: logger;
const removedDirectories: string[] = [];
try {
// Normalize paths for consistent comparison
const normalizedBoundary = normalizePath(options.boundaryPath);
let currentPath = normalizePath(path.dirname(deletedPath));
log.debug('Starting empty parent directory cleanup', {
deletedPath,
boundaryPath: options.boundaryPath,
normalizedBoundary,
startingFrom: currentPath,
});
// Walk up the directory tree
while (true) {
// Safety check: Have we reached the filesystem root?
const parentPath = normalizePath(path.dirname(currentPath));
if (parentPath === currentPath) {
log.debug('Reached filesystem root, stopping cleanup');
return {
success: true,
removedDirectories,
stoppedAt: currentPath,
stoppedReason: 'root_reached',
};
}
// Safety check: Have we reached or passed the boundary?
if (!isPathBelowBoundary(currentPath, normalizedBoundary)) {
log.debug('Reached boundary path, stopping cleanup', {
currentPath,
boundaryPath: normalizedBoundary,
});
return {
success: true,
removedDirectories,
stoppedAt: currentPath,
stoppedReason: 'boundary_reached',
};
}
// Check if the directory is empty
const isEmpty = await isDirectoryEmpty(currentPath);
if (isEmpty === null) {
// Directory doesn't exist (ENOENT) - move to parent
log.debug(`Directory does not exist, moving to parent: ${currentPath}`);
currentPath = parentPath;
continue;
}
if (!isEmpty) {
// Directory is not empty - stop here
log.debug(`Directory not empty, stopping cleanup: ${currentPath}`);
return {
success: true,
removedDirectories,
stoppedAt: currentPath,
stoppedReason: 'non_empty',
};
}
// Directory is empty - try to remove it
try {
await fs.rmdir(currentPath);
removedDirectories.push(currentPath);
log.info(`Removed empty directory: ${currentPath}`);
} catch (removeError) {
const errorCode = (removeError as NodeJS.ErrnoException).code;
if (errorCode === 'ENOENT') {
// Already deleted (race condition) - continue to parent
log.debug(`Directory already deleted: ${currentPath}`);
} else if (errorCode === 'ENOTEMPTY') {
// Directory became non-empty (race condition) - stop
log.debug(`Directory became non-empty: ${currentPath}`);
return {
success: true,
removedDirectories,
stoppedAt: currentPath,
stoppedReason: 'non_empty',
};
} else if (errorCode === 'EACCES' || errorCode === 'EPERM') {
// Permission error - log warning and stop
log.warn(`Permission denied removing directory: ${currentPath}`, {
error: removeError instanceof Error ? removeError.message : String(removeError),
});
return {
success: true, // Partial success - we cleaned what we could
removedDirectories,
stoppedAt: currentPath,
stoppedReason: 'error',
error: `Permission denied: ${currentPath}`,
};
} else {
// Unexpected error - log and stop
log.error(`Failed to remove directory: ${currentPath}`, {
error: removeError instanceof Error ? removeError.message : String(removeError),
errorCode,
});
return {
success: false,
removedDirectories,
stoppedAt: currentPath,
stoppedReason: 'error',
error: removeError instanceof Error ? removeError.message : String(removeError),
};
}
}
// Move to parent directory
currentPath = parentPath;
}
} catch (error) {
log.error('Unexpected error during empty parent cleanup', {
error: error instanceof Error ? error.message : String(error),
deletedPath,
boundaryPath: options.boundaryPath,
});
return {
success: false,
removedDirectories,
stoppedReason: 'error',
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Checks if a directory is empty
*
* @param dirPath - Path to the directory
* @returns true if empty, false if not empty, null if directory doesn't exist
*/
async function isDirectoryEmpty(dirPath: string): Promise<boolean | null> {
try {
const entries = await fs.readdir(dirPath);
return entries.length === 0;
} catch (error) {
const errorCode = (error as NodeJS.ErrnoException).code;
if (errorCode === 'ENOENT') {
// Directory doesn't exist
return null;
}
if (errorCode === 'ENOTDIR') {
// Path is a file, not a directory
return null;
}
// Re-throw other errors
throw error;
}
}
/**
* Checks if a path is strictly below (inside) the boundary path
*
* A path is below the boundary if:
* - It's longer than the boundary path
* - It starts with the boundary path followed by a path separator
*
* @param testPath - The path to test (must be normalized)
* @param boundaryPath - The boundary path (must be normalized)
* @returns true if testPath is strictly below boundaryPath
*/
function isPathBelowBoundary(testPath: string, boundaryPath: string): boolean {
// Ensure both paths don't have trailing slashes for comparison
const normalizedTest = testPath.replace(/\/+$/, '');
const normalizedBoundary = boundaryPath.replace(/\/+$/, '');
// Path must be strictly below boundary, not equal to it
if (normalizedTest === normalizedBoundary) {
return false;
}
// Check if test path is under boundary path
// Must start with boundary + separator to avoid matching /downloads2 when boundary is /downloads
return normalizedTest.startsWith(normalizedBoundary + '/');
}
/**
* Normalizes a file path for consistent comparison
*
* @param filePath - Path to normalize
* @returns Normalized path with forward slashes and no trailing slash
*/
function normalizePath(filePath: string): string {
// Convert backslashes to forward slashes
let normalized = filePath.replace(/\\/g, '/');
// Use path.normalize to handle redundant separators and ..
normalized = path.normalize(normalized);
// Convert backslashes again (path.normalize might add them on Windows)
normalized = normalized.replace(/\\/g, '/');
// Remove trailing slash (except for root '/')
if (normalized.length > 1 && normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
+12 -6
View File
@@ -165,16 +165,22 @@ export async function tagMultipleFiles(
}
/**
* Escape metadata values for shell command
* Removes quotes and special characters that could break the command
* Escape metadata values for shell command (double-quoted context)
*
* In double-quoted shell strings, only these characters need escaping:
* - Backslashes (must be first to avoid double-escaping)
* - Double quotes
* - Backticks (command substitution)
* - Dollar signs (variable expansion)
*
* Single quotes do NOT need escaping inside double quotes - they are literal.
*/
function escapeMetadata(value: string): string {
return value
.replace(/\\/g, '\\\\') // Escape backslashes FIRST (before other escapes add backslashes)
.replace(/"/g, '\\"') // Escape double quotes
.replace(/'/g, "\\'") // Escape single quotes
.replace(/`/g, '\\`') // Escape backticks
.replace(/\$/g, '\\$') // Escape dollar signs
.replace(/\\/g, '\\\\'); // Escape backslashes
.replace(/`/g, '\\`') // Escape backticks (prevents command substitution)
.replace(/\$/g, '\\$'); // Escape dollar signs (prevents variable expansion)
}
/**