@twelvehart/envctl
Version:
Environment variable context manager for development workflows
433 lines (427 loc) • 18.7 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.EnvManager = void 0;
const tslib_1 = require("tslib");
const storage_1 = require("./storage");
const os_1 = tslib_1.__importDefault(require("os"));
const path_1 = tslib_1.__importDefault(require("path"));
const fs_extra_1 = tslib_1.__importDefault(require("fs-extra"));
class EnvManager {
storage;
deps;
constructor(deps) {
this.storage = new storage_1.Storage();
this.deps = {
os: os_1.default,
path: path_1.default,
fs: fs_extra_1.default,
...deps,
};
}
getShellRcFile = (homeDir, shell) => {
// Extract shell name from path (e.g., '/usr/bin/zsh' -> 'zsh')
const shellName = shell.split('/').pop() || '';
switch (shellName) {
case 'zsh':
return this.deps.path.join(homeDir, '.zshrc');
case 'bash':
return this.deps.path.join(homeDir, '.bashrc');
case 'fish':
return this.deps.path.join(homeDir, '.config', 'fish', 'config.fish');
default:
return this.deps.path.join(homeDir, '.bashrc');
}
};
createProfile = async (name) => {
const existing = await this.storage.loadProfile(name);
if (existing) {
throw new Error(`Profile '${name}' already exists`);
}
const profile = {
name,
variables: {},
createdAt: new Date(),
updatedAt: new Date(),
};
await this.storage.saveProfile(profile);
};
addVariable = async (profileName, key, value) => {
const profile = await this.storage.loadProfile(profileName);
if (!profile) {
throw new Error(`Profile '${profileName}' does not exist`);
}
profile.variables[key] = value;
profile.updatedAt = new Date();
await this.storage.saveProfile(profile);
};
addVariablesFromFile = async (profileName, filePath) => {
const profile = await this.storage.loadProfile(profileName);
if (!profile) {
throw new Error(`Profile '${profileName}' does not exist`);
}
const variables = await this.storage.parseEnvFile(filePath);
Object.assign(profile.variables, variables);
profile.updatedAt = new Date();
await this.storage.saveProfile(profile);
return Object.keys(variables).length;
};
removeVariable = async (profileName, key) => {
const profile = await this.storage.loadProfile(profileName);
if (!profile) {
throw new Error(`Profile '${profileName}' does not exist`);
}
if (!(key in profile.variables)) {
throw new Error(`Variable '${key}' not found in profile '${profileName}'`);
}
delete profile.variables[key];
profile.updatedAt = new Date();
await this.storage.saveProfile(profile);
};
loadProfile = async (profileName) => {
const profile = await this.storage.loadProfile(profileName);
if (!profile) {
throw new Error(`Profile '${profileName}' does not exist`);
}
const currentlyLoaded = await this.storage.getCurrentlyLoadedProfile();
if (currentlyLoaded === profileName) {
return await this.reloadCurrentProfile(profileName, profile);
}
if (currentlyLoaded && currentlyLoaded !== profileName) {
throw new Error(`Profile '${currentlyLoaded}' is already loaded. Use 'envctl switch ${profileName}' to switch profiles.`);
}
return await this.generateLoadCommands(profileName, profile);
};
reloadCurrentProfile = async (profileName, profile) => {
const commands = [];
const backupFile = this.getSessionBackupPath();
// First, restore from backup (unload current state)
for (const key of Object.keys(profile.variables)) {
commands.push(`if grep -q "^${key}=" ${backupFile} 2>/dev/null; then`);
commands.push(` export ${key}="$(grep "^${key}=" ${backupFile} | cut -d'=' -f2-)"`);
commands.push(`else`);
commands.push(` unset ${key}`);
commands.push(`fi`);
}
// Then create fresh backup and load new values
commands.push(`echo "# envctl-profile:${profileName}" > ${backupFile}`);
for (const key of Object.keys(profile.variables)) {
commands.push(`[ -n "\${${key}+x}" ] && echo "${key}=$${key}" >> ${backupFile}`);
}
for (const [key, value] of Object.entries(profile.variables)) {
commands.push(`export ${key}="${value}"`);
}
return commands.join('\n');
};
getSessionBackupPath = () => {
// Generate shell command to determine backup path using shell PID ($$) instead of PPID
// This ensures shell commands and Node.js CLI processes use the same session ID
// Node.js CLI's PPID = Shell's PID, so both will use the same identifier
// Include Docker/containerized environment fallback for consistent behavior
return '$(if [ -n "${TERM_PROGRAM}" ]; then echo "${HOME}/.envctl/backup-$$-${SHLVL:-1}-${TERM_PROGRAM}.env"; elif [ -n "${SSH_TTY}" ]; then echo "${HOME}/.envctl/backup-$$-${SHLVL:-1}-ssh.env"; elif [ -n "${TERM}" ]; then echo "${HOME}/.envctl/backup-$$-${SHLVL:-1}-$(echo ${TERM} | cut -d\'-\' -f1).env"; elif [ "${SHLVL:-1}" = "2" ]; then echo "${HOME}/.envctl/backup-$$-${SHLVL:-1}-dumb.env"; else echo "${HOME}/.envctl/backup-$$-${SHLVL:-1}.env"; fi)';
};
generateLoadCommands = async (profileName, profile) => {
const backupCommands = [];
const setCommands = [];
const backupFile = this.getSessionBackupPath();
// Create backup file with profile marker
const createBackupCommand = `echo "# envctl-profile:${profileName}" > ${backupFile}`;
for (const key of Object.keys(profile.variables)) {
backupCommands.push(`[ -n "\${${key}+x}" ] && echo "${key}=$${key}" >> ${backupFile}`);
}
for (const [key, value] of Object.entries(profile.variables)) {
setCommands.push(`export ${key}="${value}"`);
}
return [createBackupCommand, ...backupCommands, ...setCommands].join('\n');
};
unloadProfile = async () => {
const currentlyLoaded = await this.storage.getCurrentlyLoadedProfile();
if (!currentlyLoaded) {
throw new Error('No profile is currently loaded');
}
if (currentlyLoaded === 'unknown') {
const backupFile = this.getSessionBackupPath();
const commands = `rm -f ${backupFile}`;
return { commands, profileName: 'unknown' };
}
const profile = await this.storage.loadProfile(currentlyLoaded);
if (!profile) {
throw new Error(`Profile '${currentlyLoaded}' not found`);
}
const commands = [];
const backupFile = this.getSessionBackupPath();
for (const key of Object.keys(profile.variables)) {
commands.push(`if grep -q "^${key}=" ${backupFile} 2>/dev/null; then`);
commands.push(` export ${key}="$(grep "^${key}=" ${backupFile} | cut -d'=' -f2-)"`);
commands.push(`else`);
commands.push(` unset ${key}`);
commands.push(`fi`);
}
commands.push(`rm -f ${backupFile}`);
return {
commands: commands.join('\n'),
profileName: currentlyLoaded,
};
};
switchProfile = async (profileName) => {
const newProfile = await this.storage.loadProfile(profileName);
if (!newProfile) {
throw new Error(`Profile '${profileName}' does not exist`);
}
const currentlyLoaded = await this.storage.getCurrentlyLoadedProfile();
if (!currentlyLoaded) {
const commands = await this.generateLoadCommands(profileName, newProfile);
return { commands, to: profileName };
}
if (currentlyLoaded === profileName) {
const commands = await this.reloadCurrentProfile(profileName, newProfile);
return { commands, from: profileName, to: profileName };
}
const commands = [];
const backupFile = this.getSessionBackupPath();
if (currentlyLoaded !== 'unknown') {
const currentProfile = await this.storage.loadProfile(currentlyLoaded);
if (currentProfile) {
for (const key of Object.keys(currentProfile.variables)) {
commands.push(`if grep -q "^${key}=" ${backupFile} 2>/dev/null; then`);
commands.push(` export ${key}="$(grep "^${key}=" ${backupFile} | cut -d'=' -f2-)"`);
commands.push(`else`);
commands.push(` unset ${key}`);
commands.push(`fi`);
}
}
}
commands.push(`echo "# envctl-profile:${profileName}" > ${backupFile}`);
for (const key of Object.keys(newProfile.variables)) {
commands.push(`[ -n "\${${key}+x}" ] && echo "${key}=$${key}" >> ${backupFile}`);
}
for (const [key, value] of Object.entries(newProfile.variables)) {
commands.push(`export ${key}="${value}"`);
}
return { commands: commands.join('\n'), from: currentlyLoaded, to: profileName };
};
getStatus = async () => {
const currentProfile = await this.storage.getCurrentlyLoadedProfile();
const allSessions = await this.getSessions();
const currentSessionId = this.storage['getSessionId']();
const currentSession = {
sessionId: currentSessionId,
};
if (currentProfile) {
currentSession.profileName = currentProfile;
if (currentProfile !== 'unknown') {
const profile = await this.storage.loadProfile(currentProfile);
currentSession.variableCount = profile ? Object.keys(profile.variables).length : 0;
}
}
const otherSessions = allSessions.filter((session) => session.sessionId !== currentSessionId);
return {
currentSession,
otherSessions,
totalSessions: allSessions.length,
};
};
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
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"
}
return `${shellPid}-${shlvl}${terminalContext}`;
}
getSessions = async () => {
return await this.storage.listActiveSessions();
};
listProfiles = async () => {
const profiles = await this.storage.listProfiles();
const allSessions = await this.storage.listActiveSessions();
const result = [];
for (const name of profiles) {
const profile = await this.storage.loadProfile(name);
const loadedInSessions = allSessions
.filter((session) => session.profileName === name)
.map((session) => session.sessionId);
result.push({
name,
isLoaded: loadedInSessions.length > 0,
variableCount: profile ? Object.keys(profile.variables).length : 0,
loadedInSessions: loadedInSessions.length > 0 ? loadedInSessions : undefined,
});
}
return result.sort((a, b) => a.name.localeCompare(b.name));
};
getProfile = async (name) => {
return await this.storage.loadProfile(name);
};
deleteProfile = async (name) => {
const currentlyLoaded = await this.storage.getCurrentlyLoadedProfile();
if (currentlyLoaded === name) {
throw new Error(`Cannot delete profile '${name}' while it is loaded. Unload it first.`);
}
const deleted = await this.storage.deleteProfile(name);
if (!deleted) {
throw new Error(`Profile '${name}' does not exist`);
}
};
exportProfile = async (name) => {
const profile = await this.storage.loadProfile(name);
if (!profile) {
throw new Error(`Profile '${name}' does not exist`);
}
return Object.entries(profile.variables)
.map(([key, value]) => `${key}=${value}`)
.join('\n');
};
setupShellIntegration = async () => {
const homeDir = this.deps.os.homedir();
const shell = process.env['SHELL'] || '';
const rcFile = this.getShellRcFile(homeDir, shell);
const integrationFile = this.deps.path.join(homeDir, '.envctl-integration.sh');
const scriptPath = this.deps.path.join(__dirname, '..', 'shell-integration.sh');
let scriptContent;
if (await this.deps.fs.pathExists(scriptPath)) {
scriptContent = await this.deps.fs.readFile(scriptPath, 'utf-8');
}
else {
// Fallback: embedded script content
scriptContent = `#!/bin/bash
# envctl Shell Integration
# Auto-generated by envctl setup
# Function to load a profile
envctl-load() {
if [ -z "$1" ]; then
echo "Usage: envctl-load <profile>"
return 1
fi
local commands
commands=$(envctl load "$1" 2>/dev/null)
if [ $? -eq 0 ]; then
eval "$commands"
echo "✓ Loaded profile '$1'"
else
echo "✗ Failed to load profile '$1'"
envctl load "$1" # Show the error
return 1
fi
}
# Function to unload current profile
envctl-unload() {
local commands
commands=$(envctl unload 2>/dev/null)
if [ $? -eq 0 ]; then
eval "$commands"
echo "✓ Unloaded profile"
else
echo "✗ Failed to unload profile"
envctl unload # Show the error
return 1
fi
}
# Function to switch profiles
envctl-switch() {
if [ -z "$1" ]; then
echo "Usage: envctl-switch <profile>"
return 1
fi
local commands
commands=$(envctl switch "$1" 2>/dev/null)
if [ $? -eq 0 ]; then
eval "$commands"
echo "✓ Switched to profile '$1'"
else
echo "✗ Failed to switch to profile '$1'"
envctl switch "$1" # Show the error
return 1
fi
}
# Aliases for convenience
alias ecl='envctl-load'
alias ecu='envctl-unload'
alias ecs='envctl status'
alias ecls='envctl list'
alias ecsw='envctl-switch'
`;
}
await this.deps.fs.writeFile(integrationFile, scriptContent);
await this.deps.fs.chmod(integrationFile, 0o755);
const sourceLine = `source ~/.envctl-integration.sh`;
let rcContent = '';
if (await this.deps.fs.pathExists(rcFile)) {
rcContent = await this.deps.fs.readFile(rcFile, 'utf-8');
}
if (!rcContent.includes(sourceLine)) {
const newContent = `${rcContent}\n# envctl shell integration\n${sourceLine}\n`;
await this.deps.fs.writeFile(rcFile, newContent);
}
return { rcFile, integrationFile };
};
unsetupShellIntegration = async () => {
const homeDir = this.deps.os.homedir();
const shell = process.env['SHELL'] || '';
const rcFile = this.getShellRcFile(homeDir, shell);
const integrationFile = this.deps.path.join(homeDir, '.envctl-integration.sh');
const removed = [];
if (await this.deps.fs.pathExists(integrationFile)) {
await this.deps.fs.remove(integrationFile);
removed.push(integrationFile);
}
if (await this.deps.fs.pathExists(rcFile)) {
const rcContent = await this.deps.fs.readFile(rcFile, 'utf-8');
const lines = rcContent.split('\n');
// Filter out envctl-related lines
const filteredLines = lines.filter((line) => {
const trimmed = line.trim();
return !(trimmed === '# envctl shell integration' || trimmed === 'source ~/.envctl-integration.sh');
});
// Only write back if we actually removed something
if (filteredLines.length !== lines.length) {
await this.deps.fs.writeFile(rcFile, filteredLines.join('\n'));
removed.push(`${rcFile} (removed envctl lines)`);
}
}
return { rcFile, integrationFile, removed };
};
cleanupAllData = async () => {
const { getConfig } = await Promise.resolve().then(() => tslib_1.__importStar(require('./config')));
const config = getConfig();
const removed = [];
try {
const currentlyLoaded = await this.storage.getCurrentlyLoadedProfile();
if (currentlyLoaded) {
const result = await this.unloadProfile();
// Execute the unload commands in our process (this is cleanup, so it's okay)
// Note: In a real cleanup scenario, we'd just remove the files directly
removed.push(`Unloaded current profile '${result.profileName}'`);
}
}
catch {
// Ignore errors - profile might not exist or be corrupted
}
// Remove entire config directory
if (await this.deps.fs.pathExists(config.configDir)) {
await this.deps.fs.remove(config.configDir);
removed.push(config.configDir);
}
// Remove backup file (backup.env is stored in the config directory, so it should be removed with the config dir)
// But let's check for it separately in case it exists elsewhere
const backupFile = this.deps.path.join(config.configDir, 'backup.env');
if (await this.deps.fs.pathExists(backupFile)) {
await this.deps.fs.remove(backupFile);
removed.push(backupFile);
}
return { removed };
};
}
exports.EnvManager = EnvManager;
//# sourceMappingURL=env-manager.js.map