@twelvehart/envctl
Version:
Environment variable context manager for development workflows
229 lines • 9.94 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.Storage = void 0;
const tslib_1 = require("tslib");
const fs_extra_1 = tslib_1.__importDefault(require("fs-extra"));
const path_1 = tslib_1.__importDefault(require("path"));
const config_1 = require("./config");
class Storage {
config;
constructor() {
this.config = (0, config_1.getConfig)();
// Don't await in constructor, ensure directories when needed
}
ensureDirectories = async () => {
await fs_extra_1.default.ensureDir(this.config.configDir);
await fs_extra_1.default.ensureDir(this.config.profilesDir);
};
getSessionId() {
// Universal approach: Use shell PID + SHLVL combination
// The key insight: shell commands and Node.js CLI have parent-child relationship
// Shell creates backup with shell's PPID, but Node.js CLI should use shell's PID
// Solution: Node.js CLI uses its PPID (which is shell's PID) as the session base
const shellPid = process.ppid || 0; // Node.js CLI's PPID = Shell's PID
const shlvl = process.env.SHLVL || '1';
// Add terminal context if available for additional uniqueness
// Handle Docker environment where Node.js might not inherit TERM properly
let terminalContext = '';
if (process.env.TERM_PROGRAM) {
terminalContext = `-${process.env.TERM_PROGRAM}`;
}
else if (process.env.SSH_TTY) {
terminalContext = '-ssh';
}
else if (process.env.TERM) {
terminalContext = `-${process.env.TERM.split('-')[0]}`; // e.g., "xterm" from "xterm-256color"
}
else if (shlvl === '2') {
// In Docker/containerized environments, SHLVL=2 often means TERM=dumb
// This handles the case where shell sees TERM=dumb but Node.js sees undefined
terminalContext = '-dumb';
}
return `${shellPid}-${shlvl}${terminalContext}`;
}
get sessionBackupFilePath() {
const sessionId = this.getSessionId();
return path_1.default.join(this.config.configDir, `backup-${sessionId}.env`);
}
isSessionActive = (sessionId) => {
try {
// Extract shell PID from session ID (format: shellPid-shlvl-terminalContext)
const parts = sessionId.split('-');
const shellPid = parseInt(parts[0], 10);
if (isNaN(shellPid))
return false;
// Use kill -0 to check if shell process exists (doesn't actually kill)
process.kill(shellPid, 0);
return true;
}
catch {
// If we can't signal the process, it's not active
return false;
}
};
cleanupOrphanedBackups = async () => {
try {
const files = await fs_extra_1.default.readdir(this.config.configDir);
const backupFiles = files.filter((file) => file.startsWith('backup-') && file.endsWith('.env'));
for (const file of backupFiles) {
const sessionId = file.replace('backup-', '').replace('.env', '');
// Skip current session
if (sessionId === this.getSessionId())
continue;
// Check file age - don't clean up files less than 5 minutes old
// This prevents cleaning up active sessions from different processes
const filePath = path_1.default.join(this.config.configDir, file);
try {
const stats = await fs_extra_1.default.stat(filePath);
const fileAge = Date.now() - stats.mtime.getTime();
const fiveMinutes = 5 * 60 * 1000;
if (fileAge < fiveMinutes) {
continue; // Skip recent files
}
}
catch {
// If we can't stat the file, skip it
continue;
}
// Check if session is still active
if (!this.isSessionActive(sessionId)) {
await fs_extra_1.default.remove(filePath);
}
}
}
catch {
// Ignore cleanup errors - they shouldn't break normal operations
}
};
get backupFilePath() {
return this.sessionBackupFilePath;
}
saveProfile = async (profile) => {
await this.ensureDirectories();
const profilePath = path_1.default.join(this.config.profilesDir, `${profile.name}.json`);
await fs_extra_1.default.writeJson(profilePath, profile, { spaces: 2 });
};
loadProfile = async (name) => {
await this.ensureDirectories();
const profilePath = path_1.default.join(this.config.profilesDir, `${name}.json`);
if (!(await fs_extra_1.default.pathExists(profilePath))) {
return null;
}
return await fs_extra_1.default.readJson(profilePath);
};
deleteProfile = async (name) => {
await this.ensureDirectories();
const profilePath = path_1.default.join(this.config.profilesDir, `${name}.json`);
if (!(await fs_extra_1.default.pathExists(profilePath))) {
return false;
}
await fs_extra_1.default.remove(profilePath);
return true;
};
listProfiles = async () => {
await this.ensureDirectories();
const files = await fs_extra_1.default.readdir(this.config.profilesDir);
return files.filter((file) => file.endsWith('.json')).map((file) => path_1.default.basename(file, '.json'));
};
getCurrentlyLoadedProfile = async () => {
await this.ensureDirectories();
await this.cleanupOrphanedBackups();
// Only check the current session - strict session awareness
if (await fs_extra_1.default.pathExists(this.backupFilePath)) {
const content = await fs_extra_1.default.readFile(this.backupFilePath, 'utf-8');
const lines = content.split('\n');
const firstLine = lines[0]?.trim();
if (firstLine?.startsWith('# envctl-profile:')) {
const profileName = firstLine.replace('# envctl-profile:', '').trim();
return profileName;
}
return 'unknown';
}
// No profile loaded in current session
return null;
};
saveBackup = async (variables) => {
await this.ensureDirectories();
await this.cleanupOrphanedBackups();
if (Object.keys(variables).length === 0) {
if (await fs_extra_1.default.pathExists(this.backupFilePath)) {
await fs_extra_1.default.remove(this.backupFilePath);
}
return;
}
const envContent = Object.entries(variables)
.map(([key, value]) => `${key}=${value}`)
.join('\n');
await fs_extra_1.default.writeFile(this.backupFilePath, `${envContent}\n`);
};
loadBackup = async () => {
await this.ensureDirectories();
if (!(await fs_extra_1.default.pathExists(this.backupFilePath))) {
return {};
}
return await this.parseEnvFile(this.backupFilePath);
};
clearBackup = async () => {
await this.ensureDirectories();
await this.cleanupOrphanedBackups();
if (await fs_extra_1.default.pathExists(this.backupFilePath)) {
await fs_extra_1.default.remove(this.backupFilePath);
}
};
listActiveSessions = async () => {
await this.ensureDirectories();
await this.cleanupOrphanedBackups();
const sessions = [];
try {
const files = await fs_extra_1.default.readdir(this.config.configDir);
const backupFiles = files.filter((file) => file.startsWith('backup-') && file.endsWith('.env'));
for (const file of backupFiles) {
const sessionId = file.replace('backup-', '').replace('.env', '');
try {
const filePath = path_1.default.join(this.config.configDir, file);
const content = await fs_extra_1.default.readFile(filePath, 'utf-8');
const lines = content.split('\n');
const firstLine = lines[0]?.trim();
let profileName = 'unknown'; // Default to unknown
if (firstLine?.startsWith('# envctl-profile:')) {
profileName = firstLine.replace('# envctl-profile:', '').trim();
}
// Only add sessions that have content (skip empty files)
if (content.trim()) {
sessions.push({ sessionId, profileName });
}
}
catch {
// Skip files we can't read
}
}
}
catch {
// Return empty array if we can't read the directory
}
return sessions;
};
parseEnvFile = async (filePath) => {
const content = await fs_extra_1.default.readFile(filePath, 'utf-8');
const variables = {};
for (const line of content.split('\n')) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('# envctl-profile:')) {
continue;
}
if (!trimmedLine || trimmedLine.startsWith('#')) {
continue;
}
const equalIndex = trimmedLine.indexOf('=');
if (equalIndex === -1) {
continue;
}
const key = trimmedLine.substring(0, equalIndex).trim();
const value = trimmedLine.substring(equalIndex + 1).trim();
variables[key] = value.replace(/^["']|["']$/g, '');
}
return variables;
};
}
exports.Storage = Storage;
//# sourceMappingURL=storage.js.map