mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-14 20:25:26 +02:00
552 lines
15 KiB
JavaScript
552 lines
15 KiB
JavaScript
/**
|
|
* Session Aliases Library for Claude Code
|
|
* Manages session aliases stored in $ECC_AGENT_DATA_HOME/session-aliases.json (default ~/.claude).
|
|
*/
|
|
|
|
const path = require('path');
|
|
const { readFileWithSharingRetry, writeFileAtomic } = require('./atomic-write');
|
|
const { acquireSettingsLock } = require('./install/claude-settings-lock');
|
|
|
|
const {
|
|
getClaudeDir,
|
|
log
|
|
} = require('./utils');
|
|
|
|
// Aliases file path
|
|
function getAliasesPath() {
|
|
return path.join(getClaudeDir(), 'session-aliases.json');
|
|
}
|
|
|
|
// Current alias storage format version
|
|
const ALIAS_VERSION = '1.0';
|
|
|
|
/**
|
|
* Default aliases file structure
|
|
*/
|
|
function getDefaultAliases() {
|
|
return {
|
|
version: ALIAS_VERSION,
|
|
aliases: {},
|
|
metadata: {
|
|
totalCount: 0,
|
|
lastUpdated: new Date().toISOString()
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Load aliases from file
|
|
* @returns {object} Aliases object
|
|
*/
|
|
function loadAliases() {
|
|
const aliasesPath = getAliasesPath();
|
|
|
|
let content;
|
|
try {
|
|
content = readFileWithSharingRetry(aliasesPath, 'utf8');
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') return getDefaultAliases();
|
|
// An unreadable published snapshot is not an empty store. Fail closed so
|
|
// a transaction cannot replace existing aliases with a default snapshot.
|
|
throw error;
|
|
}
|
|
if (!content) {
|
|
return getDefaultAliases();
|
|
}
|
|
|
|
try {
|
|
const data = JSON.parse(content);
|
|
|
|
// Validate structure
|
|
if (!data.aliases || typeof data.aliases !== 'object') {
|
|
log('[Aliases] Invalid aliases file structure, resetting');
|
|
return getDefaultAliases();
|
|
}
|
|
|
|
// Ensure version field
|
|
if (!data.version) {
|
|
data.version = ALIAS_VERSION;
|
|
}
|
|
|
|
// Ensure metadata
|
|
if (!data.metadata) {
|
|
data.metadata = {
|
|
totalCount: Object.keys(data.aliases).length,
|
|
lastUpdated: new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
return data;
|
|
} catch (err) {
|
|
log(`[Aliases] Error parsing aliases file: ${err.message}`);
|
|
return getDefaultAliases();
|
|
}
|
|
}
|
|
|
|
// Reject callback reentry instead of allowing a nested write to be overwritten
|
|
// by the outer transaction's snapshot.
|
|
const activeWrites = new Set();
|
|
|
|
/**
|
|
* Run a synchronous alias transaction with exclusive access to its snapshot.
|
|
* Lock acquisition or reentry failure returns the caller's failure value.
|
|
* Release errors are logged without replacing the callback's result or error:
|
|
* a successfully published snapshot remains committed even if cleanup fails.
|
|
* @template T
|
|
* @param {function(): T} callback - Transaction to execute while holding the lock
|
|
* @param {T} failure - Result when the transaction cannot start
|
|
* @returns {T} The transaction result, or failure if it could not start
|
|
*/
|
|
function withAliasesLock(callback, failure) {
|
|
const aliasesPath = path.resolve(getAliasesPath());
|
|
if (activeWrites.has(aliasesPath)) {
|
|
log('[Aliases] Cannot write aliases from inside an alias transaction');
|
|
return failure;
|
|
}
|
|
|
|
let release;
|
|
try {
|
|
release = acquireSettingsLock(aliasesPath, { label: 'session aliases', timeoutMs: 5000 });
|
|
} catch (error) {
|
|
log(`[Aliases] Error locking aliases: ${error.message}`);
|
|
return failure;
|
|
}
|
|
|
|
activeWrites.add(aliasesPath);
|
|
let result;
|
|
let primaryError;
|
|
let callbackFailed = false;
|
|
let releaseError;
|
|
try {
|
|
result = callback();
|
|
} catch (error) {
|
|
callbackFailed = true;
|
|
primaryError = error;
|
|
} finally {
|
|
try {
|
|
release();
|
|
} catch (error) {
|
|
releaseError = error;
|
|
}
|
|
activeWrites.delete(aliasesPath);
|
|
}
|
|
if (releaseError) {
|
|
log(`[Aliases] Error releasing aliases lock: ${releaseError.message}`);
|
|
}
|
|
if (callbackFailed) throw primaryError;
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Publish a complete alias snapshot while the caller holds the transaction lock.
|
|
* @param {object} aliases - Snapshot to publish without mutating the caller
|
|
* @returns {boolean} Whether atomic replacement succeeded
|
|
*/
|
|
function saveAliasesUnlocked(aliases) {
|
|
try {
|
|
const snapshot = {
|
|
...aliases,
|
|
metadata: {
|
|
totalCount: Object.keys(aliases.aliases).length,
|
|
lastUpdated: new Date().toISOString()
|
|
}
|
|
};
|
|
writeFileAtomic(getAliasesPath(), JSON.stringify(snapshot, null, 2));
|
|
return true;
|
|
} catch (error) {
|
|
log(`[Aliases] Error saving aliases: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Atomically replace the complete snapshot. This does not merge snapshots
|
|
* loaded by callers before acquiring the lock; use the mutators for updates.
|
|
* @param {object} aliases - Complete aliases snapshot
|
|
* @returns {boolean} Success status
|
|
*/
|
|
function saveAliases(aliases) {
|
|
return withAliasesLock(() => saveAliasesUnlocked(aliases), false);
|
|
}
|
|
|
|
/**
|
|
* Resolve an alias to get session path
|
|
* @param {string} alias - Alias name to resolve
|
|
* @returns {object|null} Alias data or null if not found
|
|
*/
|
|
function resolveAlias(alias) {
|
|
if (!alias) return null;
|
|
|
|
// Validate alias name (alphanumeric, dash, underscore)
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(alias)) {
|
|
return null;
|
|
}
|
|
|
|
const data = loadAliases();
|
|
const aliasData = data.aliases[alias];
|
|
|
|
if (!aliasData) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
alias,
|
|
sessionPath: aliasData.sessionPath,
|
|
createdAt: aliasData.createdAt,
|
|
title: aliasData.title || null
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Set or update an alias for a session
|
|
* @param {string} alias - Alias name (alphanumeric, dash, underscore)
|
|
* @param {string} sessionPath - Session directory path
|
|
* @param {string} title - Optional title for the alias
|
|
* @returns {object} Result with success status and message
|
|
*/
|
|
function setAliasUnlocked(alias, sessionPath, title = null) {
|
|
// Validate alias name
|
|
if (!alias || alias.length === 0) {
|
|
return { success: false, error: 'Alias name cannot be empty' };
|
|
}
|
|
|
|
// Validate session path
|
|
if (!sessionPath || typeof sessionPath !== 'string' || sessionPath.trim().length === 0) {
|
|
return { success: false, error: 'Session path cannot be empty' };
|
|
}
|
|
|
|
if (alias.length > 128) {
|
|
return { success: false, error: 'Alias name cannot exceed 128 characters' };
|
|
}
|
|
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(alias)) {
|
|
return { success: false, error: 'Alias name must contain only letters, numbers, dashes, and underscores' };
|
|
}
|
|
|
|
// Reserved alias names
|
|
const reserved = ['list', 'help', 'remove', 'delete', 'create', 'set'];
|
|
if (reserved.includes(alias.toLowerCase())) {
|
|
return { success: false, error: `'${alias}' is a reserved alias name` };
|
|
}
|
|
|
|
const data = loadAliases();
|
|
const existing = data.aliases[alias];
|
|
const isNew = !existing;
|
|
|
|
data.aliases[alias] = {
|
|
sessionPath,
|
|
createdAt: existing ? existing.createdAt : new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
title: title || null
|
|
};
|
|
|
|
if (saveAliasesUnlocked(data)) {
|
|
return {
|
|
success: true,
|
|
isNew,
|
|
alias,
|
|
sessionPath,
|
|
title: data.aliases[alias].title
|
|
};
|
|
}
|
|
|
|
return { success: false, error: 'Failed to save alias' };
|
|
}
|
|
|
|
/**
|
|
* List all aliases
|
|
* @param {object} options - Options object
|
|
* @param {string} options.search - Filter aliases by name (partial match)
|
|
* @param {number} options.limit - Maximum number of aliases to return
|
|
* @returns {Array} Array of alias objects
|
|
*/
|
|
function listAliases(options = {}) {
|
|
const { search = null, limit = null } = options;
|
|
const data = loadAliases();
|
|
|
|
let aliases = Object.entries(data.aliases).map(([name, info]) => ({
|
|
name,
|
|
sessionPath: info.sessionPath,
|
|
createdAt: info.createdAt,
|
|
updatedAt: info.updatedAt,
|
|
title: info.title
|
|
}));
|
|
|
|
// Sort by updated time (newest first)
|
|
aliases.sort((a, b) => (new Date(b.updatedAt || b.createdAt || 0).getTime() || 0) - (new Date(a.updatedAt || a.createdAt || 0).getTime() || 0));
|
|
|
|
// Apply search filter
|
|
if (search) {
|
|
const searchLower = search.toLowerCase();
|
|
aliases = aliases.filter(a =>
|
|
a.name.toLowerCase().includes(searchLower) ||
|
|
(a.title && a.title.toLowerCase().includes(searchLower))
|
|
);
|
|
}
|
|
|
|
// Apply limit
|
|
if (limit && limit > 0) {
|
|
aliases = aliases.slice(0, limit);
|
|
}
|
|
|
|
return aliases;
|
|
}
|
|
|
|
/**
|
|
* Delete an alias
|
|
* @param {string} alias - Alias name to delete
|
|
* @returns {object} Result with success status
|
|
*/
|
|
function deleteAliasUnlocked(alias) {
|
|
const data = loadAliases();
|
|
|
|
if (!data.aliases[alias]) {
|
|
return { success: false, error: `Alias '${alias}' not found` };
|
|
}
|
|
|
|
const deleted = data.aliases[alias];
|
|
delete data.aliases[alias];
|
|
|
|
if (saveAliasesUnlocked(data)) {
|
|
return {
|
|
success: true,
|
|
alias,
|
|
deletedSessionPath: deleted.sessionPath
|
|
};
|
|
}
|
|
|
|
return { success: false, error: 'Failed to delete alias' };
|
|
}
|
|
|
|
/**
|
|
* Rename an alias
|
|
* @param {string} oldAlias - Current alias name
|
|
* @param {string} newAlias - New alias name
|
|
* @returns {object} Result with success status
|
|
*/
|
|
function renameAliasUnlocked(oldAlias, newAlias) {
|
|
const data = loadAliases();
|
|
|
|
if (!data.aliases[oldAlias]) {
|
|
return { success: false, error: `Alias '${oldAlias}' not found` };
|
|
}
|
|
|
|
// Validate new alias name (same rules as setAlias)
|
|
if (!newAlias || newAlias.length === 0) {
|
|
return { success: false, error: 'New alias name cannot be empty' };
|
|
}
|
|
|
|
if (newAlias.length > 128) {
|
|
return { success: false, error: 'New alias name cannot exceed 128 characters' };
|
|
}
|
|
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(newAlias)) {
|
|
return { success: false, error: 'New alias name must contain only letters, numbers, dashes, and underscores' };
|
|
}
|
|
|
|
const reserved = ['list', 'help', 'remove', 'delete', 'create', 'set'];
|
|
if (reserved.includes(newAlias.toLowerCase())) {
|
|
return { success: false, error: `'${newAlias}' is a reserved alias name` };
|
|
}
|
|
|
|
if (data.aliases[newAlias]) {
|
|
return { success: false, error: `Alias '${newAlias}' already exists` };
|
|
}
|
|
|
|
const aliasData = data.aliases[oldAlias];
|
|
delete data.aliases[oldAlias];
|
|
|
|
aliasData.updatedAt = new Date().toISOString();
|
|
data.aliases[newAlias] = aliasData;
|
|
|
|
if (saveAliasesUnlocked(data)) {
|
|
return {
|
|
success: true,
|
|
oldAlias,
|
|
newAlias,
|
|
sessionPath: aliasData.sessionPath
|
|
};
|
|
}
|
|
|
|
// Atomic replacement leaves the original snapshot intact on failure.
|
|
return { success: false, error: 'Failed to save renamed alias' };
|
|
}
|
|
|
|
/**
|
|
* Get session path by alias (convenience function)
|
|
* @param {string} aliasOrId - Alias name or session ID
|
|
* @returns {string|null} Session path or null if not found
|
|
*/
|
|
function resolveSessionAlias(aliasOrId) {
|
|
// First try to resolve as alias
|
|
const resolved = resolveAlias(aliasOrId);
|
|
if (resolved) {
|
|
return resolved.sessionPath;
|
|
}
|
|
|
|
// If not an alias, return as-is (might be a session path)
|
|
return aliasOrId;
|
|
}
|
|
|
|
/**
|
|
* Update alias title
|
|
* @param {string} alias - Alias name
|
|
* @param {string|null} title - New title (string or null to clear)
|
|
* @returns {object} Result with success status
|
|
*/
|
|
function updateAliasTitleUnlocked(alias, title) {
|
|
if (title !== null && typeof title !== 'string') {
|
|
return { success: false, error: 'Title must be a string or null' };
|
|
}
|
|
|
|
const data = loadAliases();
|
|
|
|
if (!data.aliases[alias]) {
|
|
return { success: false, error: `Alias '${alias}' not found` };
|
|
}
|
|
|
|
data.aliases[alias].title = title || null;
|
|
data.aliases[alias].updatedAt = new Date().toISOString();
|
|
|
|
if (saveAliasesUnlocked(data)) {
|
|
return {
|
|
success: true,
|
|
alias,
|
|
title
|
|
};
|
|
}
|
|
|
|
return { success: false, error: 'Failed to update alias title' };
|
|
}
|
|
|
|
/**
|
|
* Get all aliases for a specific session
|
|
* @param {string} sessionPath - Session path to find aliases for
|
|
* @returns {Array} Array of alias names
|
|
*/
|
|
function getAliasesForSession(sessionPath) {
|
|
const data = loadAliases();
|
|
const aliases = [];
|
|
|
|
for (const [name, info] of Object.entries(data.aliases)) {
|
|
if (info.sessionPath === sessionPath) {
|
|
aliases.push({
|
|
name,
|
|
createdAt: info.createdAt,
|
|
title: info.title
|
|
});
|
|
}
|
|
}
|
|
|
|
return aliases;
|
|
}
|
|
|
|
/**
|
|
* Clean up aliases for non-existent sessions
|
|
* @param {Function} sessionExists - Function to check if session exists
|
|
* @returns {object} Cleanup result
|
|
*/
|
|
function cleanupAliasesUnlocked(sessionExists) {
|
|
if (typeof sessionExists !== 'function') {
|
|
return { totalChecked: 0, removed: 0, removedAliases: [], error: 'sessionExists must be a function' };
|
|
}
|
|
|
|
const data = loadAliases();
|
|
const removed = [];
|
|
|
|
for (const [name, info] of Object.entries(data.aliases)) {
|
|
if (!sessionExists(info.sessionPath)) {
|
|
removed.push({ name, sessionPath: info.sessionPath });
|
|
delete data.aliases[name];
|
|
}
|
|
}
|
|
|
|
if (removed.length > 0 && !saveAliasesUnlocked(data)) {
|
|
log('[Aliases] Failed to save after cleanup');
|
|
return {
|
|
success: false,
|
|
totalChecked: Object.keys(data.aliases).length + removed.length,
|
|
removed: removed.length,
|
|
removedAliases: removed,
|
|
error: 'Failed to save after cleanup'
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
totalChecked: Object.keys(data.aliases).length + removed.length,
|
|
removed: removed.length,
|
|
removedAliases: removed
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create or update an alias using the latest snapshot under the transaction lock.
|
|
* @param {string} alias - Alias name (alphanumeric, dash, underscore)
|
|
* @param {string} sessionPath - Session directory path
|
|
* @param {string|null} title - Optional title for the alias
|
|
* @returns {object} Result with success status and the saved alias details
|
|
*/
|
|
function setAlias(alias, sessionPath, title = null) {
|
|
return withAliasesLock(() => setAliasUnlocked(alias, sessionPath, title),
|
|
{ success: false, error: 'Failed to save alias' });
|
|
}
|
|
|
|
/**
|
|
* Delete an alias without overwriting other writers' completed changes.
|
|
* @param {string} alias - Alias name to delete
|
|
* @returns {object} Result with success status and the deleted session path
|
|
*/
|
|
function deleteAlias(alias) {
|
|
return withAliasesLock(() => deleteAliasUnlocked(alias),
|
|
{ success: false, error: 'Failed to delete alias' });
|
|
}
|
|
|
|
/**
|
|
* Rename an existing alias in one locked read-modify-write transaction.
|
|
* @param {string} oldAlias - Current alias name
|
|
* @param {string} newAlias - New unused alias name
|
|
* @returns {object} Result with success status and the renamed alias details
|
|
*/
|
|
function renameAlias(oldAlias, newAlias) {
|
|
return withAliasesLock(() => renameAliasUnlocked(oldAlias, newAlias),
|
|
{ success: false, error: 'Failed to save renamed alias' });
|
|
}
|
|
|
|
/**
|
|
* Update an existing alias title using the latest locked snapshot.
|
|
* @param {string} alias - Alias name
|
|
* @param {string|null} title - New title, or null to clear it
|
|
* @returns {object} Result with success status and the updated title
|
|
*/
|
|
function updateAliasTitle(alias, title) {
|
|
return withAliasesLock(() => updateAliasTitleUnlocked(alias, title),
|
|
{ success: false, error: 'Failed to update alias title' });
|
|
}
|
|
|
|
/**
|
|
* Remove aliases whose sessions no longer exist in a single locked transaction.
|
|
* The synchronous callback must not write aliases; its exceptions propagate.
|
|
* @param {function(string): boolean} sessionExists - Check whether a session exists
|
|
* @returns {object} Checked count, removed aliases, and any operation failure
|
|
*/
|
|
function cleanupAliases(sessionExists) {
|
|
return withAliasesLock(() => cleanupAliasesUnlocked(sessionExists),
|
|
{ success: false, totalChecked: 0, removed: 0, removedAliases: [], error: 'Failed to save after cleanup' });
|
|
}
|
|
|
|
module.exports = {
|
|
getAliasesPath,
|
|
loadAliases,
|
|
saveAliases,
|
|
resolveAlias,
|
|
setAlias,
|
|
listAliases,
|
|
deleteAlias,
|
|
renameAlias,
|
|
resolveSessionAlias,
|
|
updateAliasTitle,
|
|
getAliasesForSession,
|
|
cleanupAliases
|
|
};
|