feat(electron): added key managment ui
This commit is contained in:
@@ -105,7 +105,8 @@ py_installation.install_sources(
|
||||
meson.project_source_root() + '/fourdst/core/bundle.py',
|
||||
meson.project_source_root() + '/fourdst/core/config.py',
|
||||
meson.project_source_root() + '/fourdst/core/platform.py',
|
||||
meson.project_source_root() + '/fourdst/core/utils.py'
|
||||
meson.project_source_root() + '/fourdst/core/utils.py',
|
||||
meson.project_source_root() + '/fourdst/core/keys.py',
|
||||
),
|
||||
subdir: 'fourdst/core'
|
||||
)
|
||||
|
||||
@@ -11,11 +11,10 @@ that now return JSON directly. No more complex stdout mixing or data wrapping.
|
||||
Key Changes:
|
||||
- Core functions return JSON-serializable dictionaries directly
|
||||
- Progress messages go to stderr only (never mixed with JSON output)
|
||||
- Clean JSON output to stdout for Electron to parse
|
||||
- Simplified error handling with consistent JSON error format
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import inspect
|
||||
import traceback
|
||||
@@ -35,7 +34,7 @@ class FourdstEncoder(json.JSONEncoder):
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from fourdst.core import bundle
|
||||
from fourdst.core import bundle, keys
|
||||
|
||||
def main():
|
||||
# Use stderr for all logging to avoid interfering with JSON output on stdout
|
||||
@@ -62,13 +61,25 @@ def main():
|
||||
print(f"[BRIDGE_INFO] Parsed kwargs: {kwargs}", file=log_file, flush=True)
|
||||
|
||||
# Convert path strings to Path objects where needed
|
||||
path_params = ['outputDir', 'output_dir', 'keyPath', 'key_path', 'bundlePath', 'bundle_path', 'path']
|
||||
for key, value in kwargs.items():
|
||||
if isinstance(value, str) and ('path' in key.lower() or 'key' in key.lower()):
|
||||
if isinstance(value, str) and key in path_params:
|
||||
kwargs[key] = Path(value)
|
||||
elif isinstance(value, list) and 'dirs' in key.lower():
|
||||
kwargs[key] = [Path(p) for p in value]
|
||||
|
||||
func = getattr(bundle, command)
|
||||
# Route commands to appropriate modules
|
||||
key_commands = [
|
||||
'list_keys', 'generate_key', 'add_key', 'remove_key',
|
||||
'sync_remotes', 'get_remote_sources', 'add_remote_source', 'remove_remote_source'
|
||||
]
|
||||
|
||||
if command in key_commands:
|
||||
func = getattr(keys, command)
|
||||
module_name = "keys"
|
||||
else:
|
||||
func = getattr(bundle, command)
|
||||
module_name = "bundle"
|
||||
|
||||
# Create progress callback that sends structured progress to stderr
|
||||
# This keeps progress separate from the final JSON result on stdout
|
||||
@@ -87,7 +98,9 @@ def main():
|
||||
if 'progress_callback' in sig.parameters:
|
||||
kwargs['progress_callback'] = progress_callback
|
||||
|
||||
print(f"[BRIDGE_INFO] Calling function `bundle.{command}`...", file=log_file, flush=True)
|
||||
print(f"[BRIDGE_INFO] Calling function `{module_name}.{command}`...", file=log_file, flush=True)
|
||||
print(f"[BRIDGE_DEBUG] Function signature: {func.__name__}{inspect.signature(func)}", file=log_file, flush=True)
|
||||
print(f"[BRIDGE_DEBUG] Kwargs being passed: {kwargs}", file=log_file, flush=True)
|
||||
result = func(**kwargs)
|
||||
print(f"[BRIDGE_INFO] Function returned successfully.", file=log_file, flush=True)
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
<div class="category-icon" style="background-color: #10b981;">LC</div>
|
||||
<span class="category-label">libconstants</span>
|
||||
</div>
|
||||
<div class="category-item" data-category="keys" title="Key Management">
|
||||
<div class="category-icon" style="background-color: #8b5cf6;">KM</div>
|
||||
<span class="category-label">Key Management</span>
|
||||
</div>
|
||||
<div class="category-item" data-category="opat" title="OPAT Core">
|
||||
<div class="category-icon" style="background-color: #f59e0b;">OC</div>
|
||||
<span class="category-label">OPAT Core</span>
|
||||
@@ -71,6 +75,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Management content -->
|
||||
<div class="sidebar-content hidden" data-category="keys">
|
||||
<div class="sidebar-header">
|
||||
<h3>Key Management</h3>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<button id="keys-list-btn" class="nav-button active">Trusted Keys</button>
|
||||
<button id="keys-generate-btn" class="nav-button">Generate Key</button>
|
||||
<button id="keys-add-btn" class="nav-button">Add Key</button>
|
||||
<button id="keys-remotes-btn" class="nav-button">Remote Sources</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- OPAT Core content -->
|
||||
<div class="sidebar-content hidden" data-category="opat">
|
||||
<div class="sidebar-header">
|
||||
@@ -126,6 +143,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon" style="background-color: #8b5cf6;">KM</div>
|
||||
<div class="feature-info">
|
||||
<h3>Key Management</h3>
|
||||
<p>Manage cryptographic keys for bundle signing and verification.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon" style="background-color: #10b981;">LC</div>
|
||||
<div class="feature-info">
|
||||
@@ -455,6 +480,118 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Management Views -->
|
||||
<div id="keys-view" class="hidden">
|
||||
<!-- Trusted Keys View -->
|
||||
<div id="keys-list-view" class="key-management-view">
|
||||
<div class="keys-header">
|
||||
<h3>Trusted Keys</h3>
|
||||
<p>Manage cryptographic keys for bundle signing and verification.</p>
|
||||
</div>
|
||||
<div id="keys-list-container">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🔑</div>
|
||||
<h3>Loading Keys...</h3>
|
||||
<p>Please wait while we load your trusted keys.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generate Key View -->
|
||||
<div id="keys-generate-view" class="key-management-view hidden">
|
||||
<div class="generate-key-header">
|
||||
<h3>Generate New Key Pair</h3>
|
||||
<p>Create a new cryptographic key pair for signing bundles.</p>
|
||||
</div>
|
||||
<div class="generate-key-form">
|
||||
<div class="form-group">
|
||||
<label for="generate-key-name">Key Name:</label>
|
||||
<input type="text" id="generate-key-name" placeholder="author_key" value="author_key">
|
||||
<small>Base name for the generated key files</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="generate-key-type">Key Type:</label>
|
||||
<select id="generate-key-type">
|
||||
<option value="ed25519">Ed25519 (Recommended)</option>
|
||||
<option value="rsa">RSA-2048</option>
|
||||
</select>
|
||||
<small>Ed25519 is faster and more secure than RSA</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="generate-output-dir">Output Directory:</label>
|
||||
<input type="text" id="generate-output-dir" placeholder="." value=".">
|
||||
<small>Directory where keys will be saved</small>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button id="generate-key-btn" class="action-button primary">Generate Key Pair</button>
|
||||
</div>
|
||||
<div class="security-notice">
|
||||
<div class="warning-banner">
|
||||
<span class="warning-icon">⚠️</span>
|
||||
<div class="warning-text">
|
||||
<strong>Security Notice:</strong> Keep your private key secure and never share it.
|
||||
Only share the public key (.pub or .pub.pem files) with others who need to verify your bundles.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Key View -->
|
||||
<div id="keys-add-view" class="key-management-view hidden">
|
||||
<div class="add-key-header">
|
||||
<h3>Add Trusted Key</h3>
|
||||
<p>Add a public key to your trust store for bundle verification.</p>
|
||||
</div>
|
||||
<div class="add-key-form">
|
||||
<div class="form-actions">
|
||||
<button id="add-key-file-btn" class="action-button primary">Select Key File</button>
|
||||
</div>
|
||||
<div class="info-notice">
|
||||
<div class="info-banner">
|
||||
<span class="info-icon">ℹ️</span>
|
||||
<div class="info-text">
|
||||
<strong>Supported formats:</strong> .pub, .pub.pem, and .pem files containing public keys.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remote Sources View -->
|
||||
<div id="keys-remotes-view" class="key-management-view hidden">
|
||||
<div class="remotes-header">
|
||||
<h3>Remote Key Sources</h3>
|
||||
<p>Manage Git repositories that contain trusted public keys.</p>
|
||||
</div>
|
||||
|
||||
<div class="add-remote-form">
|
||||
<h4>Add New Remote Source</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="remote-name">Name:</label>
|
||||
<input type="text" id="remote-name" placeholder="my-keys">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="remote-url">Git Repository URL:</label>
|
||||
<input type="text" id="remote-url" placeholder="https://github.com/user/keys.git">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button id="add-remote-btn" class="action-button primary">Add Remote</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="remotes-list-container">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🌐</div>
|
||||
<h3>Loading Remote Sources...</h3>
|
||||
<p>Please wait while we load your remote key sources.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="create-bundle-form" class="hidden">
|
||||
<!-- The create form will be moved into a modal later -->
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
|
||||
<p>This installer will install the 4DSTAR Bundle Manager, a comprehensive tool for managing 4DSTAR plugin bundles and OPAT data files.</p>
|
||||
|
||||
<div class="highlight">
|
||||
<div>
|
||||
<strong>What's Included:</strong>
|
||||
<ul>
|
||||
<li>4DSTAR Bundle Manager application</li>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// Import modular components
|
||||
const { setupAppEventHandlers, setupThemeHandlers } = require('./main/app-lifecycle');
|
||||
const { setupFileDialogHandlers } = require('./main/file-dialogs');
|
||||
const { setupBundleIPCHandlers } = require('./main/ipc-handlers');
|
||||
const { setupBundleIPCHandlers, setupKeyIPCHandlers } = require('./main/ipc-handlers');
|
||||
|
||||
// Initialize all modules in the correct order
|
||||
function initializeMainProcess() {
|
||||
@@ -21,6 +21,9 @@ function initializeMainProcess() {
|
||||
// Setup bundle operation IPC handlers
|
||||
setupBundleIPCHandlers();
|
||||
|
||||
// Setup key management IPC handlers
|
||||
setupKeyIPCHandlers();
|
||||
|
||||
console.log('[MAIN_PROCESS] All modules initialized successfully');
|
||||
}
|
||||
|
||||
|
||||
@@ -10,16 +10,21 @@ function runPythonCommand(command, kwargs, event) {
|
||||
// Determine executable name based on platform
|
||||
const executableName = process.platform === 'win32' ? 'fourdst-backend.exe' : 'fourdst-backend';
|
||||
|
||||
// Initialize args first
|
||||
let args = [command, JSON.stringify(kwargs)];
|
||||
|
||||
if (app.isPackaged) {
|
||||
// In packaged app, backend is in resources/backend/ directory
|
||||
backendPath = path.join(process.resourcesPath, 'backend', executableName);
|
||||
} else {
|
||||
// In development, use the meson build output
|
||||
backendPath = path.join(buildDir, 'electron', 'dist', 'fourdst-backend', executableName);
|
||||
// In development, use the Python bridge.py directly for faster iteration
|
||||
backendPath = 'python';
|
||||
// Update args to include the bridge.py path as the first argument
|
||||
const bridgePath = path.join(__dirname, '..', 'bridge.py');
|
||||
args.unshift(bridgePath);
|
||||
}
|
||||
|
||||
console.log(`[MAIN_PROCESS] Spawning backend: ${backendPath}`);
|
||||
const args = [command, JSON.stringify(kwargs)];
|
||||
console.log(`[MAIN_PROCESS] With args: [${args.join(', ')}]`);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -29,6 +29,23 @@ const setupFileDialogHandlers = () => {
|
||||
return null;
|
||||
});
|
||||
|
||||
// Key file selection dialog
|
||||
ipcMain.handle('select-key-file', async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
title: 'Select Public Key File',
|
||||
filters: [
|
||||
{ name: 'Public Key Files', extensions: ['pub', 'pem'] },
|
||||
{ name: 'All Files', extensions: ['*'] }
|
||||
]
|
||||
});
|
||||
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
return result.filePaths[0];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Save file dialog
|
||||
ipcMain.handle('select-save-file', async () => {
|
||||
const result = await dialog.showSaveDialog({
|
||||
|
||||
@@ -3,6 +3,69 @@ const { runPythonCommand } = require('./backend-bridge');
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
|
||||
const setupKeyIPCHandlers = () => {
|
||||
// List keys handler
|
||||
ipcMain.handle('list-keys', async (event) => {
|
||||
const kwargs = {};
|
||||
return runPythonCommand('list_keys', kwargs, event);
|
||||
});
|
||||
|
||||
// Generate key handler
|
||||
ipcMain.handle('generate-key', async (event, { keyName, keyType, outputDir }) => {
|
||||
const kwargs = {
|
||||
key_name: keyName,
|
||||
key_type: keyType,
|
||||
output_dir: outputDir
|
||||
};
|
||||
return runPythonCommand('generate_key', kwargs, event);
|
||||
});
|
||||
|
||||
// Add key handler
|
||||
ipcMain.handle('add-key', async (event, keyPath) => {
|
||||
const kwargs = {
|
||||
key_path: keyPath
|
||||
};
|
||||
return runPythonCommand('add_key', kwargs, event);
|
||||
});
|
||||
|
||||
// Remove key handler
|
||||
ipcMain.handle('remove-key', async (event, keyIdentifier) => {
|
||||
const kwargs = {
|
||||
key_identifier: keyIdentifier
|
||||
};
|
||||
return runPythonCommand('remove_key', kwargs, event);
|
||||
});
|
||||
|
||||
// Sync remotes handler
|
||||
ipcMain.handle('sync-remotes', async (event) => {
|
||||
const kwargs = {};
|
||||
return runPythonCommand('sync_remotes', kwargs, event);
|
||||
});
|
||||
|
||||
// Get remote sources handler
|
||||
ipcMain.handle('get-remote-sources', async (event) => {
|
||||
const kwargs = {};
|
||||
return runPythonCommand('get_remote_sources', kwargs, event);
|
||||
});
|
||||
|
||||
// Add remote source handler
|
||||
ipcMain.handle('add-remote-source', async (event, { name, url }) => {
|
||||
const kwargs = {
|
||||
name: name,
|
||||
url: url
|
||||
};
|
||||
return runPythonCommand('add_remote_source', kwargs, event);
|
||||
});
|
||||
|
||||
// Remove remote source handler
|
||||
ipcMain.handle('remove-remote-source', async (event, name) => {
|
||||
const kwargs = {
|
||||
name: name
|
||||
};
|
||||
return runPythonCommand('remove_remote_source', kwargs, event);
|
||||
});
|
||||
};
|
||||
|
||||
const setupBundleIPCHandlers = () => {
|
||||
// Create bundle handler
|
||||
ipcMain.handle('create-bundle', async (event, bundleData) => {
|
||||
@@ -171,5 +234,6 @@ const setupBundleIPCHandlers = () => {
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
setupBundleIPCHandlers
|
||||
setupBundleIPCHandlers,
|
||||
setupKeyIPCHandlers
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ const { ipcRenderer } = require('electron');
|
||||
const stateManager = require('./renderer/state-manager');
|
||||
const domManager = require('./renderer/dom-manager');
|
||||
const bundleOperations = require('./renderer/bundle-operations');
|
||||
const keyOperations = require('./renderer/key-operations');
|
||||
const uiComponents = require('./renderer/ui-components');
|
||||
const eventHandlers = require('./renderer/event-handlers');
|
||||
const opatHandler = require('./renderer/opat-handler');
|
||||
@@ -21,6 +22,7 @@ function initializeModules() {
|
||||
stateManager,
|
||||
domManager,
|
||||
bundleOperations,
|
||||
keyOperations,
|
||||
uiComponents,
|
||||
eventHandlers,
|
||||
opatHandler,
|
||||
@@ -30,6 +32,7 @@ function initializeModules() {
|
||||
|
||||
// Initialize each module with its dependencies
|
||||
bundleOperations.initializeDependencies(deps);
|
||||
keyOperations.initializeDependencies(deps);
|
||||
uiComponents.initializeDependencies(deps);
|
||||
eventHandlers.initializeDependencies(deps);
|
||||
opatHandler.initializeDependencies(deps);
|
||||
@@ -83,6 +86,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
window.stateManager = stateManager;
|
||||
window.domManager = domManager;
|
||||
window.bundleOperations = bundleOperations;
|
||||
window.keyOperations = keyOperations;
|
||||
window.uiComponents = uiComponents;
|
||||
window.eventHandlers = eventHandlers;
|
||||
window.opatHandler = opatHandler;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Extracted from renderer.js to centralize DOM element handling and view management
|
||||
|
||||
// --- DOM ELEMENTS (will be initialized in initializeDOMElements) ---
|
||||
let welcomeScreen, bundleView, createBundleForm;
|
||||
let welcomeScreen, bundleView, keysView, createBundleForm;
|
||||
let openBundleBtn, createBundleBtn;
|
||||
let signBundleBtn, validateBundleBtn, clearBundleBtn, saveMetadataBtn;
|
||||
let saveOptionsModal, overwriteBundleBtn, saveAsNewBtn;
|
||||
@@ -43,6 +43,7 @@ function initializeDOMElements() {
|
||||
// Views
|
||||
welcomeScreen = document.getElementById('welcome-screen');
|
||||
bundleView = document.getElementById('bundle-view');
|
||||
keysView = document.getElementById('keys-view');
|
||||
createBundleForm = document.getElementById('create-bundle-form');
|
||||
|
||||
// Sidebar buttons
|
||||
@@ -89,7 +90,7 @@ function showView(viewId) {
|
||||
const opatView = document.getElementById('opat-view');
|
||||
|
||||
// Hide main content views
|
||||
[welcomeScreen, bundleView, createBundleForm].forEach(view => {
|
||||
[welcomeScreen, bundleView, keysView, createBundleForm].forEach(view => {
|
||||
view.classList.toggle('hidden', view.id !== viewId);
|
||||
});
|
||||
|
||||
@@ -171,6 +172,7 @@ module.exports = {
|
||||
getElements: () => ({
|
||||
welcomeScreen,
|
||||
bundleView,
|
||||
keysView,
|
||||
createBundleForm,
|
||||
openBundleBtn,
|
||||
createBundleBtn,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
const { ipcRenderer } = require('electron');
|
||||
|
||||
// Import dependencies (these will be injected when integrated)
|
||||
let stateManager, domManager, bundleOperations, fillWorkflow, uiComponents, opatHandler;
|
||||
let stateManager, domManager, bundleOperations, keyOperations, fillWorkflow, uiComponents, opatHandler;
|
||||
|
||||
// --- EVENT LISTENERS SETUP ---
|
||||
function setupEventListeners() {
|
||||
@@ -86,6 +86,9 @@ function setupEventListeners() {
|
||||
elements.overwriteBundleBtn.addEventListener('click', () => handleSaveMetadata(false));
|
||||
elements.saveAsNewBtn.addEventListener('click', () => handleSaveMetadata(true));
|
||||
|
||||
// Key Management event listeners
|
||||
setupKeyManagementEventListeners();
|
||||
|
||||
// Signature warning modal event listeners
|
||||
elements.signatureWarningCancel.addEventListener('click', () => {
|
||||
elements.signatureWarningModal.classList.add('hidden');
|
||||
@@ -207,7 +210,7 @@ function showCategoryHomeScreen(category) {
|
||||
const views = [
|
||||
'welcome-screen', 'libplugin-home', 'opat-home',
|
||||
'libconstants-home', 'serif-home', 'opat-view', 'libplugin-view',
|
||||
'bundle-view', 'create-bundle-form'
|
||||
'bundle-view', 'keys-view', 'create-bundle-form'
|
||||
];
|
||||
|
||||
// Hide all views
|
||||
@@ -222,12 +225,28 @@ function showCategoryHomeScreen(category) {
|
||||
'libplugin': 'libplugin-home',
|
||||
'opat': 'opat-home',
|
||||
'libconstants': 'libconstants-home',
|
||||
'serif': 'serif-home'
|
||||
'serif': 'serif-home',
|
||||
'keys': 'keys-view'
|
||||
};
|
||||
|
||||
const viewId = viewMap[category] || 'welcome-screen';
|
||||
const view = document.getElementById(viewId);
|
||||
if (view) view.classList.remove('hidden');
|
||||
if (view) {
|
||||
view.classList.remove('hidden');
|
||||
|
||||
// Initialize key management when showing keys view
|
||||
if (category === 'keys' && keyOperations) {
|
||||
// Show the default keys list view and load keys
|
||||
showKeyManagementView('keys-list-view');
|
||||
keyOperations.loadTrustedKeys();
|
||||
|
||||
// Set the keys-list-btn as active in sidebar
|
||||
const keysSidebarButtons = document.querySelectorAll('#keys-list-btn, #keys-generate-btn, #keys-add-btn, #keys-remotes-btn');
|
||||
keysSidebarButtons.forEach(btn => btn.classList.remove('active'));
|
||||
const keysListBtn = document.getElementById('keys-list-btn');
|
||||
if (keysListBtn) keysListBtn.classList.add('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Setup info modal
|
||||
@@ -411,11 +430,88 @@ async function handleSaveMetadata(saveAsNew = false) {
|
||||
uiComponents.hideSaveOptionsModal();
|
||||
}
|
||||
|
||||
// Setup key management event listeners
|
||||
function setupKeyManagementEventListeners() {
|
||||
// Key management sidebar navigation
|
||||
const keysSidebarButtons = document.querySelectorAll('#keys-list-btn, #keys-generate-btn, #keys-add-btn, #keys-remotes-btn');
|
||||
keysSidebarButtons.forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Update active sidebar button
|
||||
keysSidebarButtons.forEach(btn => btn.classList.remove('active'));
|
||||
button.classList.add('active');
|
||||
|
||||
// Show appropriate key management view
|
||||
const viewMap = {
|
||||
'keys-list-btn': 'keys-list-view',
|
||||
'keys-generate-btn': 'keys-generate-view',
|
||||
'keys-add-btn': 'keys-add-view',
|
||||
'keys-remotes-btn': 'keys-remotes-view'
|
||||
};
|
||||
|
||||
const targetView = viewMap[button.id];
|
||||
if (targetView) {
|
||||
showKeyManagementView(targetView);
|
||||
|
||||
// Load content for specific views
|
||||
if (targetView === 'keys-list-view') {
|
||||
keyOperations.loadTrustedKeys();
|
||||
} else if (targetView === 'keys-remotes-view') {
|
||||
keyOperations.loadRemoteSources();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Generate key button
|
||||
const generateKeyBtn = document.getElementById('generate-key-btn');
|
||||
if (generateKeyBtn) {
|
||||
generateKeyBtn.addEventListener('click', keyOperations.handleGenerateKey);
|
||||
}
|
||||
|
||||
// Add remote button
|
||||
const addRemoteBtn = document.getElementById('add-remote-btn');
|
||||
if (addRemoteBtn) {
|
||||
addRemoteBtn.addEventListener('click', keyOperations.handleAddRemote);
|
||||
}
|
||||
|
||||
// Add key file button
|
||||
const addKeyFileBtn = document.getElementById('add-key-file-btn');
|
||||
if (addKeyFileBtn) {
|
||||
addKeyFileBtn.addEventListener('click', keyOperations.handleAddKey);
|
||||
}
|
||||
|
||||
// Dynamic buttons (will be added dynamically to keys list)
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('sync-remotes-btn')) {
|
||||
keyOperations.handleSyncRemotes();
|
||||
} else if (e.target.classList.contains('remove-remote-btn')) {
|
||||
const remoteName = e.target.dataset.remoteName;
|
||||
keyOperations.handleRemoveRemote(remoteName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show specific key management view
|
||||
function showKeyManagementView(viewId) {
|
||||
// Hide all key management views
|
||||
const keyViews = document.querySelectorAll('.key-management-view');
|
||||
keyViews.forEach(view => view.classList.add('hidden'));
|
||||
|
||||
// Show the target view
|
||||
const targetView = document.getElementById(viewId);
|
||||
if (targetView) {
|
||||
targetView.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize dependencies (called when module is loaded)
|
||||
function initializeDependencies(deps) {
|
||||
stateManager = deps.stateManager;
|
||||
domManager = deps.domManager;
|
||||
bundleOperations = deps.bundleOperations;
|
||||
keyOperations = deps.keyOperations;
|
||||
fillWorkflow = deps.fillWorkflow;
|
||||
uiComponents = deps.uiComponents;
|
||||
opatHandler = deps.opatHandler;
|
||||
@@ -424,6 +520,8 @@ function initializeDependencies(deps) {
|
||||
module.exports = {
|
||||
initializeDependencies,
|
||||
setupEventListeners,
|
||||
setupKeyManagementEventListeners,
|
||||
showKeyManagementView,
|
||||
checkSignatureAndWarn,
|
||||
setupCategoryNavigation,
|
||||
updateWelcomeScreen,
|
||||
|
||||
509
electron/renderer/key-operations.js
Normal file
509
electron/renderer/key-operations.js
Normal file
@@ -0,0 +1,509 @@
|
||||
// Key operations module for the 4DSTAR Bundle Manager
|
||||
// Handles all key management operations (list, generate, add, remove, sync)
|
||||
|
||||
const { ipcRenderer } = require('electron');
|
||||
|
||||
// Dependencies (injected by renderer-refactored.js)
|
||||
let stateManager, domManager, uiComponents;
|
||||
|
||||
// Initialize dependencies
|
||||
function initializeDependencies(deps) {
|
||||
stateManager = deps.stateManager;
|
||||
domManager = deps.domManager;
|
||||
uiComponents = deps.uiComponents;
|
||||
console.log('[KEY_OPERATIONS] Dependencies initialized');
|
||||
}
|
||||
|
||||
// === KEY LISTING ===
|
||||
async function loadKeys() {
|
||||
try {
|
||||
domManager.showSpinner();
|
||||
const result = await ipcRenderer.invoke('list-keys');
|
||||
domManager.hideSpinner();
|
||||
|
||||
if (result.success) {
|
||||
stateManager.setKeysState(result);
|
||||
displayKeys(result);
|
||||
return result;
|
||||
} else {
|
||||
domManager.showModal('Error', `Failed to load keys: ${result.error}`);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
domManager.hideSpinner();
|
||||
domManager.showModal('Error', `Failed to load keys: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function displayKeys(keysData) {
|
||||
const keysContainer = document.getElementById('keys-list-container');
|
||||
if (!keysContainer) return;
|
||||
|
||||
if (keysData.total_count === 0) {
|
||||
keysContainer.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🔑</div>
|
||||
<h3>No Keys Found</h3>
|
||||
<p>No trusted keys are currently installed. Generate or add keys to get started.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the main header with count and add action buttons
|
||||
const mainHeader = document.querySelector('#keys-list-view .keys-header h3');
|
||||
if (mainHeader) {
|
||||
mainHeader.textContent = `Trusted Keys (${keysData.total_count})`;
|
||||
}
|
||||
|
||||
// Add action buttons to the header if they don't exist
|
||||
let keysHeader = document.querySelector('#keys-list-view .keys-header');
|
||||
if (keysHeader && !keysHeader.querySelector('.keys-actions')) {
|
||||
const actionsDiv = document.createElement('div');
|
||||
actionsDiv.className = 'keys-actions';
|
||||
actionsDiv.innerHTML = `
|
||||
<button id="refresh-keys-btn" class="action-button secondary">
|
||||
<span class="icon">🔄</span> Refresh
|
||||
</button>
|
||||
<button id="sync-remotes-btn" class="action-button primary">
|
||||
<span class="icon">🔄</span> Sync Remotes
|
||||
</button>
|
||||
`;
|
||||
keysHeader.appendChild(actionsDiv);
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
for (const [sourceName, keys] of Object.entries(keysData.keys)) {
|
||||
html += `
|
||||
<div class="key-source-section">
|
||||
<div class="source-header">
|
||||
<h4>${sourceName}</h4>
|
||||
<span class="key-count">${keys.length} keys</span>
|
||||
</div>
|
||||
<div class="keys-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Fingerprint</th>
|
||||
<th>Size</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
for (const key of keys) {
|
||||
const shortFingerprint = key.fingerprint.substring(0, 16) + '...';
|
||||
html += `
|
||||
<tr data-key-fingerprint="${key.fingerprint}">
|
||||
<td class="key-name">${key.name}</td>
|
||||
<td class="key-fingerprint" title="${key.fingerprint}">${shortFingerprint}</td>
|
||||
<td class="key-size">${key.size_bytes} bytes</td>
|
||||
<td class="key-actions">
|
||||
<button class="btn btn-small btn-danger remove-key-btn"
|
||||
data-key-fingerprint="${key.fingerprint}"
|
||||
data-key-name="${key.name}">
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
keysContainer.innerHTML = html;
|
||||
|
||||
// Add event listeners for dynamically generated content
|
||||
setupKeyListEventListeners();
|
||||
}
|
||||
|
||||
function setupKeyListEventListeners() {
|
||||
// Refresh keys button
|
||||
const refreshBtn = document.getElementById('refresh-keys-btn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', loadKeys);
|
||||
}
|
||||
|
||||
// Sync remotes button
|
||||
const syncBtn = document.getElementById('sync-remotes-btn');
|
||||
if (syncBtn) {
|
||||
syncBtn.addEventListener('click', handleSyncRemotes);
|
||||
}
|
||||
|
||||
// Remove key buttons - now handled exclusively here (removed from event-handlers.js)
|
||||
const removeButtons = document.querySelectorAll('.remove-key-btn');
|
||||
removeButtons.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const fingerprint = e.target.dataset.keyFingerprint;
|
||||
const keyName = e.target.dataset.keyName;
|
||||
handleRemoveKey(fingerprint, keyName);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// === KEY GENERATION ===
|
||||
async function handleGenerateKey() {
|
||||
const keyName = document.getElementById('generate-key-name')?.value || 'author_key';
|
||||
const keyType = document.getElementById('generate-key-type')?.value || 'ed25519';
|
||||
const outputDir = document.getElementById('generate-output-dir')?.value || '.';
|
||||
|
||||
if (!keyName.trim()) {
|
||||
domManager.showModal('Error', 'Please enter a key name.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
domManager.showSpinner();
|
||||
const result = await ipcRenderer.invoke('generate-key', {
|
||||
keyName: keyName.trim(),
|
||||
keyType: keyType,
|
||||
outputDir: outputDir
|
||||
});
|
||||
domManager.hideSpinner();
|
||||
|
||||
if (result.success) {
|
||||
domManager.showModal('Success', `
|
||||
<div class="key-generation-success">
|
||||
<h4>Key Generated Successfully!</h4>
|
||||
<div class="key-details">
|
||||
<p><strong>Key Type:</strong> ${result.key_type.toUpperCase()}</p>
|
||||
<p><strong>Fingerprint:</strong> <code>${result.fingerprint}</code></p>
|
||||
<p><strong>Private Key:</strong> <code>${result.private_key_path}</code></p>
|
||||
<p><strong>Public Key:</strong> <code>${result.public_key_path}</code></p>
|
||||
</div>
|
||||
<div class="warning">
|
||||
<strong>⚠️ Important:</strong> Keep your private key secure and never share it!
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
// Clear form
|
||||
document.getElementById('generate-key-name').value = '';
|
||||
document.getElementById('generate-output-dir').value = '.';
|
||||
} else {
|
||||
domManager.showModal('Error', `Failed to generate key: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
domManager.hideSpinner();
|
||||
domManager.showModal('Error', `Failed to generate key: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// === KEY ADDITION ===
|
||||
async function handleAddKey() {
|
||||
try {
|
||||
// Use IPC-based file dialog instead of @electron/remote
|
||||
const keyPath = await ipcRenderer.invoke('select-key-file');
|
||||
|
||||
if (!keyPath) {
|
||||
return; // User canceled dialog
|
||||
}
|
||||
|
||||
domManager.showSpinner();
|
||||
const addResult = await ipcRenderer.invoke('add-key', keyPath);
|
||||
domManager.hideSpinner();
|
||||
|
||||
if (addResult.success) {
|
||||
if (addResult.already_existed) {
|
||||
domManager.showModal('Info', `Key '${addResult.key_name}' already exists in trust store.`);
|
||||
} else {
|
||||
domManager.showModal('Success', `
|
||||
<div class="key-add-success-modern">
|
||||
<div class="success-header">
|
||||
<div class="success-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" fill="#10b981" stroke="#059669" stroke-width="2"/>
|
||||
<path d="m9 12 2 2 4-4" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Key Added Successfully!</h3>
|
||||
<p class="success-subtitle">Your public key has been added to the trust store</p>
|
||||
</div>
|
||||
|
||||
<div class="key-details-card">
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Key Name
|
||||
</div>
|
||||
<div class="detail-value">${addResult.key_name}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 15a3 3 0 100-6 3 3 0 000 6z" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
Fingerprint
|
||||
</div>
|
||||
<div class="detail-value fingerprint-value">
|
||||
<code>${addResult.fingerprint}</code>
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('${addResult.fingerprint}'); this.innerHTML='✓ Copied'; setTimeout(() => this.innerHTML='Copy', 2000)">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="success-footer">
|
||||
<div class="info-banner">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="m9 9 1.5 1.5L16 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span>This key can now be used to verify signed bundles</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Refresh keys list
|
||||
await loadKeys();
|
||||
} else {
|
||||
domManager.showModal('Error', `Failed to add key: ${addResult.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
domManager.hideSpinner();
|
||||
domManager.showModal('Error', `Failed to add key: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// === KEY REMOVAL ===
|
||||
async function handleRemoveKey(fingerprint, keyName) {
|
||||
const confirmed = await uiComponents.showConfirmDialog(
|
||||
'Remove Key',
|
||||
`Are you sure you want to remove the key "${keyName}"?\n\nThis action cannot be undone.`
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
domManager.showSpinner();
|
||||
const result = await ipcRenderer.invoke('remove-key', fingerprint);
|
||||
domManager.hideSpinner();
|
||||
|
||||
if (result.success) {
|
||||
domManager.showModal('Success', `Removed ${result.removed_count} key(s) successfully.`);
|
||||
|
||||
// Refresh keys list
|
||||
await loadKeys();
|
||||
} else {
|
||||
domManager.showModal('Error', `Failed to remove key: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
domManager.hideSpinner();
|
||||
domManager.showModal('Error', `Failed to remove key: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// === REMOTE SYNC ===
|
||||
async function handleSyncRemotes() {
|
||||
try {
|
||||
domManager.showSpinner();
|
||||
const result = await ipcRenderer.invoke('sync-remotes');
|
||||
domManager.hideSpinner();
|
||||
|
||||
if (result.success) {
|
||||
const successCount = result.synced_remotes.filter(r => r.status === 'success').length;
|
||||
const failedCount = result.synced_remotes.filter(r => r.status === 'failed').length;
|
||||
|
||||
let message = `
|
||||
<div class="sync-results">
|
||||
<h4>Remote Sync Completed</h4>
|
||||
<p>✅ Successful: ${successCount}</p>
|
||||
<p>❌ Failed: ${failedCount}</p>
|
||||
<p>📦 Total keys synced: ${result.total_keys_synced}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (result.removed_remotes.length > 0) {
|
||||
message += `<p><strong>Removed failing remotes:</strong> ${result.removed_remotes.join(', ')}</p>`;
|
||||
}
|
||||
|
||||
domManager.showModal('Sync Results', message);
|
||||
|
||||
// Refresh keys list
|
||||
await loadKeys();
|
||||
} else {
|
||||
domManager.showModal('Error', `Failed to sync remotes: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
domManager.hideSpinner();
|
||||
domManager.showModal('Error', `Failed to sync remotes: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// === REMOTE MANAGEMENT ===
|
||||
async function loadRemoteSources() {
|
||||
try {
|
||||
const result = await ipcRenderer.invoke('get-remote-sources');
|
||||
|
||||
if (result.success) {
|
||||
displayRemoteSources(result.remotes);
|
||||
return result.remotes;
|
||||
} else {
|
||||
domManager.showModal('Error', `Failed to load remote sources: ${result.error}`);
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
domManager.showModal('Error', `Failed to load remote sources: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function displayRemoteSources(remotes) {
|
||||
const remotesContainer = document.getElementById('remotes-list-container');
|
||||
if (!remotesContainer) return;
|
||||
|
||||
if (remotes.length === 0) {
|
||||
remotesContainer.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🌐</div>
|
||||
<h3>No Remote Sources</h3>
|
||||
<p>No remote key sources are configured. Add remote repositories to sync keys automatically.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<div class="remotes-header">
|
||||
<h3>Remote Key Sources (${remotes.length})</h3>
|
||||
</div>
|
||||
<div class="remotes-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Name</th>
|
||||
<th>URL</th>
|
||||
<th>Keys</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
for (const remote of remotes) {
|
||||
const status = remote.exists ? '✅' : '❌';
|
||||
const statusText = remote.exists ? 'Synced' : 'Not synced';
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td class="remote-status" title="${statusText}">${status}</td>
|
||||
<td class="remote-name">${remote.name}</td>
|
||||
<td class="remote-url" title="${remote.url}">${remote.url}</td>
|
||||
<td class="remote-keys">${remote.keys_count}</td>
|
||||
<td class="remote-actions">
|
||||
<button class="btn btn-small btn-danger remove-remote-btn"
|
||||
data-remote-name="${remote.name}">
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
remotesContainer.innerHTML = html;
|
||||
|
||||
// Add event listeners for remove buttons
|
||||
const removeButtons = document.querySelectorAll('.remove-remote-btn');
|
||||
removeButtons.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const remoteName = e.target.dataset.remoteName;
|
||||
handleRemoveRemoteSource(remoteName);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAddRemoteSource() {
|
||||
const name = document.getElementById('remote-name')?.value;
|
||||
const url = document.getElementById('remote-url')?.value;
|
||||
|
||||
if (!name || !url) {
|
||||
domManager.showModal('Error', 'Please enter both name and URL for the remote source.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
domManager.showSpinner();
|
||||
const result = await ipcRenderer.invoke('add-remote-source', { name: name.trim(), url: url.trim() });
|
||||
domManager.hideSpinner();
|
||||
|
||||
if (result.success) {
|
||||
domManager.showModal('Success', `Remote source '${result.name}' added successfully.`);
|
||||
|
||||
// Clear form
|
||||
document.getElementById('remote-name').value = '';
|
||||
document.getElementById('remote-url').value = '';
|
||||
|
||||
// Refresh remotes list
|
||||
await loadRemoteSources();
|
||||
} else {
|
||||
domManager.showModal('Error', `Failed to add remote source: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
domManager.hideSpinner();
|
||||
domManager.showModal('Error', `Failed to add remote source: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveRemoteSource(remoteName) {
|
||||
const confirmed = await uiComponents.showConfirmDialog(
|
||||
'Remove Remote Source',
|
||||
`Are you sure you want to remove the remote source "${remoteName}"?\n\nThis will also remove all keys synced from this source.`
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
domManager.showSpinner();
|
||||
const result = await ipcRenderer.invoke('remove-remote-source', remoteName);
|
||||
domManager.hideSpinner();
|
||||
|
||||
if (result.success) {
|
||||
domManager.showModal('Success', `Remote source '${result.name}' removed successfully.`);
|
||||
|
||||
// Refresh remotes list and keys list
|
||||
await loadRemoteSources();
|
||||
await loadKeys();
|
||||
} else {
|
||||
domManager.showModal('Error', `Failed to remove remote source: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
domManager.hideSpinner();
|
||||
domManager.showModal('Error', `Failed to remove remote source: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Export functions
|
||||
module.exports = {
|
||||
initializeDependencies,
|
||||
loadKeys,
|
||||
loadTrustedKeys: loadKeys, // Alias for compatibility
|
||||
handleGenerateKey,
|
||||
handleAddKey,
|
||||
handleRemoveKey,
|
||||
handleSyncRemotes,
|
||||
loadRemoteSources,
|
||||
handleAddRemoteSource,
|
||||
handleRemoveRemoteSource
|
||||
};
|
||||
@@ -11,6 +11,10 @@ let pendingOperation = null; // Store the operation to execute after warning con
|
||||
// Current OPAT file state
|
||||
let currentOPATFile = null;
|
||||
|
||||
// Current key management state
|
||||
let currentKeys = null;
|
||||
let keyOperationInProgress = false;
|
||||
|
||||
// Bundle state management
|
||||
const getBundleState = () => ({
|
||||
currentBundle,
|
||||
@@ -67,6 +71,27 @@ const clearOPATFile = () => {
|
||||
currentOPATFile = null;
|
||||
};
|
||||
|
||||
// Key management state functions
|
||||
const setKeysState = (keysData) => {
|
||||
currentKeys = keysData;
|
||||
};
|
||||
|
||||
const getKeysState = () => {
|
||||
return currentKeys;
|
||||
};
|
||||
|
||||
const clearKeysState = () => {
|
||||
currentKeys = null;
|
||||
};
|
||||
|
||||
const setKeyOperationInProgress = (inProgress) => {
|
||||
keyOperationInProgress = inProgress;
|
||||
};
|
||||
|
||||
const getKeyOperationInProgress = () => {
|
||||
return keyOperationInProgress;
|
||||
};
|
||||
|
||||
// Export state management functions
|
||||
module.exports = {
|
||||
// Bundle state
|
||||
@@ -86,6 +111,13 @@ module.exports = {
|
||||
getOPATFile,
|
||||
clearOPATFile,
|
||||
|
||||
// Key management state
|
||||
setKeysState,
|
||||
getKeysState,
|
||||
clearKeysState,
|
||||
setKeyOperationInProgress,
|
||||
getKeyOperationInProgress,
|
||||
|
||||
// Direct state access (for compatibility)
|
||||
getCurrentBundle: () => currentBundle,
|
||||
getCurrentBundlePath: () => currentBundlePath,
|
||||
|
||||
@@ -163,6 +163,14 @@ function hideSaveOptionsModal() {
|
||||
elements.saveOptionsModal.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Show confirmation dialog
|
||||
function showConfirmDialog(title, message) {
|
||||
return new Promise((resolve) => {
|
||||
const confirmed = confirm(`${title}\n\n${message}`);
|
||||
resolve(confirmed);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize dependencies (called when module is loaded)
|
||||
function initializeDependencies(deps) {
|
||||
stateManager = deps.stateManager;
|
||||
@@ -179,5 +187,6 @@ module.exports = {
|
||||
checkForChanges,
|
||||
updateSaveButtonVisibility,
|
||||
showSaveOptionsModal,
|
||||
hideSaveOptionsModal
|
||||
hideSaveOptionsModal,
|
||||
showConfirmDialog
|
||||
};
|
||||
|
||||
@@ -903,6 +903,711 @@ body.dark-mode .opat-table-container ::-webkit-scrollbar-thumb:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
/* ===== KEY MANAGEMENT STYLING ===== */
|
||||
|
||||
/* Key Management Views Container */
|
||||
#keys-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--background-color);
|
||||
}
|
||||
|
||||
/* Individual Key Management Views */
|
||||
.key-management-view {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
background: var(--background-color);
|
||||
}
|
||||
|
||||
/* Key Management Headers */
|
||||
.keys-header,
|
||||
.generate-key-header,
|
||||
.add-key-header,
|
||||
.remotes-header {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.keys-header .keys-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.keys-header h3,
|
||||
.generate-key-header h3,
|
||||
.add-key-header h3,
|
||||
.remotes-header h3 {
|
||||
color: var(--text-color);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.keys-header p,
|
||||
.generate-key-header p,
|
||||
.add-key-header p,
|
||||
.remotes-header p {
|
||||
color: var(--text-light);
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Form Styling */
|
||||
.generate-key-form,
|
||||
.add-key-form {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
background: white;
|
||||
color: var(--text-color);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr auto;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.action-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-button.primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-button.primary:hover {
|
||||
background: var(--secondary-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.action-button.secondary {
|
||||
background: white;
|
||||
color: var(--primary-color);
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.action-button.secondary:hover {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-button.danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-button.danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
/* Notice Banners */
|
||||
.security-notice,
|
||||
.info-notice {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.warning-banner,
|
||||
.info-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
background: #fef3c7;
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.info-banner {
|
||||
background: #dbeafe;
|
||||
border-left-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.warning-icon,
|
||||
.info-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.warning-text,
|
||||
.info-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.warning-text strong,
|
||||
.info-text strong {
|
||||
color: #92400e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-text strong {
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
/* Keys List Container */
|
||||
#keys-list-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Key Source Sections */
|
||||
.key-source-section {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.key-source-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.source-header {
|
||||
background: #f8fafc;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.source-header h4 {
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.key-count {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.keys-table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.keys-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.keys-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.keys-table th,
|
||||
.keys-table td {
|
||||
padding: 1rem 1.5rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.keys-table th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.keys-table td {
|
||||
color: var(--text-color);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.keys-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.keys-table tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.key-fingerprint {
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
background: #f1f5f9;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.key-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #dc2626;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--secondary-color);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Remote Sources Styling */
|
||||
.add-remote-form {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.add-remote-form h4 {
|
||||
color: var(--text-color);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
|
||||
#remotes-list-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.remotes-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.remotes-table th,
|
||||
.remotes-table td {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.remotes-table th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.remotes-table tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
/* Empty States */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: var(--text-color);
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-light);
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Dark Mode Support */
|
||||
body.dark-mode .key-management-view {
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
body.dark-mode .generate-key-form,
|
||||
body.dark-mode .add-key-form,
|
||||
body.dark-mode .add-remote-form,
|
||||
body.dark-mode #keys-list-container,
|
||||
body.dark-mode #remotes-list-container {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
body.dark-mode .source-header {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
body.dark-mode .keys-table th,
|
||||
body.dark-mode .remotes-table th {
|
||||
background: #0f172a;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
body.dark-mode .keys-table tr:hover,
|
||||
body.dark-mode .remotes-table tr:hover {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
body.dark-mode .form-group input,
|
||||
body.dark-mode .form-group select {
|
||||
background: #0f172a;
|
||||
border-color: #334155;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
body.dark-mode .form-group input:focus,
|
||||
body.dark-mode .form-group select:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
body.dark-mode .key-fingerprint {
|
||||
background: #0f172a;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
body.dark-mode .warning-banner {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
body.dark-mode .info-banner {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-left-color: var(--primary-color);
|
||||
}
|
||||
|
||||
body.dark-mode .warning-text strong {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
body.dark-mode .info-text strong {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
/* Modern Key Add Success Modal */
|
||||
.key-add-success-modern {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
margin: 0 auto 1rem auto;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.success-header h3 {
|
||||
color: var(--text-color);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.success-subtitle {
|
||||
color: var(--text-light);
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.key-details-card {
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
min-width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-label svg {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
flex: 1;
|
||||
color: var(--text-color);
|
||||
font-size: 0.95rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.fingerprint-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fingerprint-value code {
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 0.8rem;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: var(--secondary-color);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.success-footer {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.info-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: #dbeafe;
|
||||
border: 1px solid #93c5fd;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
color: #1e40af;
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.info-banner svg {
|
||||
color: #3b82f6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Dark Mode Support for Modern Modal */
|
||||
body.dark-mode .key-details-card {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
body.dark-mode .fingerprint-value code {
|
||||
background: #0f172a;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
body.dark-mode .info-banner {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
body.dark-mode .info-banner svg {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1024px) {
|
||||
.key-management-view {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.key-management-view {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.generate-key-form,
|
||||
.add-key-form,
|
||||
.add-remote-form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.keys-table,
|
||||
.remotes-table {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.keys-table th,
|
||||
.keys-table td,
|
||||
.remotes-table th,
|
||||
.remotes-table td {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.key-actions {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.key-add-success-modern {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.fingerprint-value {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
#welcome-screen h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
3
electron/~/Desktop/test.pem
Normal file
3
electron/~/Desktop/test.pem
Normal file
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEINS0UV/JuxLOW7PyegagGJfCP13oVrBXXBhMBRV0YK5+
|
||||
-----END PRIVATE KEY-----
|
||||
1
electron/~/Desktop/test.pub
Normal file
1
electron/~/Desktop/test.pub
Normal file
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILXntMn/iLZI5hHaU+b9WYzE8E4D04gseoRbexZeIbrU
|
||||
3
electron/~/Desktop/test.pub.pem
Normal file
3
electron/~/Desktop/test.pub.pem
Normal file
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAtee0yf+ItkjmEdpT5v1ZjMTwTgPTiCx6hFt7Fl4hutQ=
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -1,23 +1,20 @@
|
||||
# fourdst/cli/keys/add.py
|
||||
import typer
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from fourdst.cli.common.config import LOCAL_TRUST_STORE_PATH
|
||||
|
||||
MANUAL_KEYS_DIR = LOCAL_TRUST_STORE_PATH / "manual"
|
||||
from fourdst.core.keys import add_key
|
||||
|
||||
def keys_add(
|
||||
key_path: Path = typer.Argument(..., help="Path to the public key file to add.", exists=True, readable=True)
|
||||
):
|
||||
"""Adds a single public key to the local trust store."""
|
||||
MANUAL_KEYS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
result = add_key(key_path)
|
||||
|
||||
destination = MANUAL_KEYS_DIR / key_path.name
|
||||
if destination.exists():
|
||||
# check content
|
||||
if destination.read_bytes() == key_path.read_bytes():
|
||||
typer.secho(f"Key '{key_path.name}' with same content already exists.", fg=typer.colors.YELLOW)
|
||||
return
|
||||
|
||||
shutil.copy(key_path, destination)
|
||||
typer.secho(f"✅ Key '{key_path.name}' added to manual trust store.", fg=typer.colors.GREEN)
|
||||
if result["success"]:
|
||||
if result["already_existed"]:
|
||||
typer.secho(f"Key '{result['key_name']}' with same content already exists.", fg=typer.colors.YELLOW)
|
||||
else:
|
||||
typer.secho(f"✅ Key '{result['key_name']}' added to manual trust store.", fg=typer.colors.GREEN)
|
||||
typer.echo(f"Fingerprint: {result['fingerprint']}")
|
||||
else:
|
||||
typer.secho(f"Error: {result['error']}", fg=typer.colors.RED)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
@@ -1,60 +1,38 @@
|
||||
# fourdst/cli/keys/generate.py
|
||||
|
||||
import typer
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519, rsa
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from fourdst.core.keys import generate_key
|
||||
|
||||
keys_app = typer.Typer()
|
||||
|
||||
@keys_app.command("generate")
|
||||
def keys_generate(
|
||||
key_name: str = typer.Option("author_key", "--name", "-n", help="The base name for the generated key files."),
|
||||
key_type: str = typer.Option("ed25519", "--type", "-t", help="Type of key to generate (ed25519|rsa).", case_sensitive=False)
|
||||
key_type: str = typer.Option("ed25519", "--type", "-t", help="Type of key to generate (ed25519|rsa).", case_sensitive=False),
|
||||
output_dir: str = typer.Option(".", "--output", "-o", help="Directory to save the generated keys.")
|
||||
):
|
||||
"""
|
||||
Generates a new Ed25519 or RSA key pair for signing bundles.
|
||||
"""
|
||||
# Define PEM-formatted key file paths
|
||||
private_key_path = Path(f"{key_name}.pem")
|
||||
public_key_path = Path(f"{key_name}.pub.pem")
|
||||
|
||||
if private_key_path.exists() or public_key_path.exists():
|
||||
print(f"Error: Key files '{private_key_path}' or '{public_key_path}' already exist.", file=sys.stderr)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
# Generate key based on requested type
|
||||
if key_type.lower() == "ed25519":
|
||||
typer.echo("Generating Ed25519 key pair in PEM format via cryptography...")
|
||||
private_key_obj = ed25519.Ed25519PrivateKey.generate()
|
||||
elif key_type.lower() == "rsa":
|
||||
typer.echo("Generating RSA-2048 key pair in PEM format via cryptography...")
|
||||
private_key_obj = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
def progress_callback(message):
|
||||
typer.echo(message)
|
||||
|
||||
result = generate_key(
|
||||
key_name=key_name,
|
||||
key_type=key_type,
|
||||
output_dir=Path(output_dir),
|
||||
progress_callback=progress_callback
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
typer.echo("\n✅ PEM and OpenSSH-compatible keys generated successfully!")
|
||||
typer.echo(f" -> Private Key (KEEP SECRET): {result['private_key_path']}")
|
||||
typer.echo(f" -> Public Key (SHARE): {result['public_key_path']}")
|
||||
typer.echo(f" -> OpenSSH Public Key: {result['openssh_public_key_path']}")
|
||||
typer.echo(f" -> Key Type: {result['key_type'].upper()}")
|
||||
typer.echo(f" -> Fingerprint: {result['fingerprint']}")
|
||||
typer.echo("\nShare the public key with users who need to trust your bundles.")
|
||||
else:
|
||||
typer.secho(f"Unsupported key type: {key_type}", fg=typer.colors.RED)
|
||||
typer.secho(f"Error: {result['error']}", fg=typer.colors.RED)
|
||||
raise typer.Exit(code=1)
|
||||
# Serialize private key to PEM
|
||||
priv_pem = private_key_obj.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
private_key_path.write_bytes(priv_pem)
|
||||
# Derive and serialize public key to PEM
|
||||
public_key_obj = private_key_obj.public_key()
|
||||
pub_pem = public_key_obj.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
public_key_path.write_bytes(pub_pem)
|
||||
# Also write OpenSSH-compatible public key
|
||||
openssh_pub = public_key_obj.public_bytes(
|
||||
encoding=serialization.Encoding.OpenSSH,
|
||||
format=serialization.PublicFormat.OpenSSH
|
||||
)
|
||||
Path(f"{key_name}.pub").write_bytes(openssh_pub)
|
||||
print("\n✅ PEM and OpenSSH-compatible keys generated successfully!")
|
||||
print(f" -> Private Key (KEEP SECRET): {private_key_path.resolve()}")
|
||||
print(f" -> Public Key (SHARE): {public_key_path.resolve()}")
|
||||
print("\nShare the public key with users who need to trust your bundles.")
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
# fourdst/cli/keys/list.py
|
||||
import typer
|
||||
from pathlib import Path
|
||||
from fourdst.cli.common.config import LOCAL_TRUST_STORE_PATH
|
||||
from fourdst.core.keys import list_keys
|
||||
|
||||
def keys_list():
|
||||
"""Lists all trusted public keys."""
|
||||
if not LOCAL_TRUST_STORE_PATH.exists():
|
||||
typer.echo("Trust store not found.")
|
||||
return
|
||||
|
||||
keys_found = False
|
||||
for source_dir in LOCAL_TRUST_STORE_PATH.iterdir():
|
||||
if source_dir.is_dir():
|
||||
keys = list(source_dir.glob("*.pub"))
|
||||
if keys:
|
||||
keys_found = True
|
||||
typer.secho(f"\n--- Source: {source_dir.name} ---", bold=True)
|
||||
for key_file in keys:
|
||||
typer.echo(f" - {key_file.name}")
|
||||
result = list_keys()
|
||||
|
||||
if not keys_found:
|
||||
if not result["success"]:
|
||||
typer.secho(f"Error: {result['error']}", fg=typer.colors.RED)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
if result["total_count"] == 0:
|
||||
typer.echo("No trusted keys found.")
|
||||
return
|
||||
|
||||
typer.echo(f"Found {result['total_count']} trusted keys:\n")
|
||||
|
||||
for source_name, keys in result["keys"].items():
|
||||
typer.secho(f"--- Source: {source_name} ---", bold=True)
|
||||
for key_info in keys:
|
||||
typer.echo(f" - {key_info['name']}")
|
||||
typer.echo(f" Fingerprint: {key_info['fingerprint']}")
|
||||
typer.echo(f" Size: {key_info['size_bytes']} bytes")
|
||||
typer.echo() # Empty line between sources
|
||||
|
||||
@@ -1,31 +1,16 @@
|
||||
# fourdst/cli/keys/remote/add.py
|
||||
import typer
|
||||
import json
|
||||
from pathlib import Path
|
||||
from fourdst.cli.common.config import FOURDST_CONFIG_DIR
|
||||
|
||||
KEY_REMOTES_CONFIG = FOURDST_CONFIG_DIR / "key_remotes.json"
|
||||
from fourdst.core.keys import add_remote_source
|
||||
|
||||
def remote_add(
|
||||
url: str = typer.Argument(..., help="The URL of the Git repository."),
|
||||
name: str = typer.Argument(..., help="A local name for the remote.")
|
||||
):
|
||||
"""Adds a new remote key source."""
|
||||
FOURDST_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
result = add_remote_source(name, url)
|
||||
|
||||
if KEY_REMOTES_CONFIG.exists():
|
||||
with open(KEY_REMOTES_CONFIG, 'r') as f:
|
||||
config = json.load(f)
|
||||
if result["success"]:
|
||||
typer.secho(f"✅ Remote '{result['name']}' added.", fg=typer.colors.GREEN)
|
||||
else:
|
||||
config = {"remotes": []}
|
||||
|
||||
if any(r['name'] == name for r in config['remotes']):
|
||||
typer.secho(f"Error: Remote with name '{name}' already exists.", fg=typer.colors.RED)
|
||||
typer.secho(f"Error: {result['error']}", fg=typer.colors.RED)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
config['remotes'].append({"name": name, "url": url})
|
||||
|
||||
with open(KEY_REMOTES_CONFIG, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
typer.secho(f"✅ Remote '{name}' added.", fg=typer.colors.GREEN)
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
# fourdst/cli/keys/remote/list.py
|
||||
import typer
|
||||
import json
|
||||
from pathlib import Path
|
||||
from fourdst.cli.common.config import FOURDST_CONFIG_DIR
|
||||
|
||||
KEY_REMOTES_CONFIG = FOURDST_CONFIG_DIR / "key_remotes.json"
|
||||
from fourdst.core.keys import get_remote_sources
|
||||
|
||||
def remote_list():
|
||||
"""Lists all configured remote key sources."""
|
||||
if not KEY_REMOTES_CONFIG.exists():
|
||||
typer.echo("No remotes configured.")
|
||||
return
|
||||
|
||||
with open(KEY_REMOTES_CONFIG, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
if not config.get("remotes"):
|
||||
result = get_remote_sources()
|
||||
|
||||
if not result["success"]:
|
||||
typer.secho(f"Error: {result['error']}", fg=typer.colors.RED)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
if not result["remotes"]:
|
||||
typer.echo("No remotes configured.")
|
||||
return
|
||||
|
||||
typer.secho("Configured Key Remotes:", bold=True)
|
||||
for remote in config['remotes']:
|
||||
typer.echo(f" - {remote['name']}: {remote['url']}")
|
||||
for remote in result["remotes"]:
|
||||
status = "✅" if remote["exists"] else "❌"
|
||||
typer.echo(f" {status} {remote['name']}: {remote['url']}")
|
||||
if remote["exists"]:
|
||||
typer.echo(f" Keys: {remote['keys_count']}")
|
||||
else:
|
||||
typer.echo(f" Status: Not synced yet")
|
||||
|
||||
@@ -1,30 +1,15 @@
|
||||
# fourdst/cli/keys/remote/remove.py
|
||||
import typer
|
||||
import json
|
||||
from pathlib import Path
|
||||
from fourdst.cli.common.config import FOURDST_CONFIG_DIR
|
||||
|
||||
KEY_REMOTES_CONFIG = FOURDST_CONFIG_DIR / "key_remotes.json"
|
||||
from fourdst.core.keys import remove_remote_source
|
||||
|
||||
def remote_remove(
|
||||
name: str = typer.Argument(..., help="The name of the remote to remove.")
|
||||
):
|
||||
"""Removes a remote key source."""
|
||||
if not KEY_REMOTES_CONFIG.exists():
|
||||
typer.secho("Error: No remotes configured.", fg=typer.colors.RED)
|
||||
result = remove_remote_source(name)
|
||||
|
||||
if result["success"]:
|
||||
typer.secho(f"✅ Remote '{result['name']}' removed.", fg=typer.colors.GREEN)
|
||||
else:
|
||||
typer.secho(f"Error: {result['error']}", fg=typer.colors.RED)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
with open(KEY_REMOTES_CONFIG, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
original_len = len(config['remotes'])
|
||||
config['remotes'] = [r for r in config['remotes'] if r['name'] != name]
|
||||
|
||||
if len(config['remotes']) == original_len:
|
||||
typer.secho(f"Error: Remote '{name}' not found.", fg=typer.colors.RED)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
with open(KEY_REMOTES_CONFIG, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
typer.secho(f"✅ Remote '{name}' removed.", fg=typer.colors.GREEN)
|
||||
|
||||
@@ -2,60 +2,52 @@
|
||||
import typer
|
||||
import questionary
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
|
||||
from fourdst.cli.common.config import LOCAL_TRUST_STORE_PATH
|
||||
|
||||
def get_key_fingerprint(key_path: Path) -> str:
|
||||
"""Generates a SHA256 fingerprint for a public key."""
|
||||
pub_key_bytes = key_path.read_bytes()
|
||||
# Assuming OpenSSH format, the fingerprint is based on the raw public key bytes
|
||||
# For simplicity, we'll hash the whole file content.
|
||||
return "sha256:" + hashlib.sha256(pub_key_bytes).hexdigest()
|
||||
from fourdst.core.keys import remove_key, list_keys
|
||||
|
||||
def keys_remove(
|
||||
key_path: Path = typer.Argument(None, help="Path to the public key file to remove.", exists=True, readable=True)
|
||||
):
|
||||
"""Removes a single public key from the local trust store."""
|
||||
if not LOCAL_TRUST_STORE_PATH.exists():
|
||||
typer.secho("Trust store not found.", fg=typer.colors.RED)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
if key_path:
|
||||
# Remove by content matching
|
||||
target_content = key_path.read_bytes()
|
||||
key_removed = False
|
||||
for source_dir in LOCAL_TRUST_STORE_PATH.iterdir():
|
||||
if source_dir.is_dir():
|
||||
for pub_key in source_dir.glob("*.pub"):
|
||||
if pub_key.read_bytes() == target_content:
|
||||
pub_key.unlink()
|
||||
typer.secho(f"✅ Removed key '{pub_key.name}' from source '{source_dir.name}'.", fg=typer.colors.GREEN)
|
||||
key_removed = True
|
||||
if not key_removed:
|
||||
typer.secho("No matching key found to remove.", fg=typer.colors.YELLOW)
|
||||
# Remove by path
|
||||
result = remove_key(str(key_path))
|
||||
|
||||
if result["success"]:
|
||||
for removed_key in result["removed_keys"]:
|
||||
typer.secho(f"✅ Removed key '{removed_key['name']}' from source '{removed_key['source']}'.", fg=typer.colors.GREEN)
|
||||
else:
|
||||
typer.secho(f"Error: {result['error']}", fg=typer.colors.YELLOW)
|
||||
else:
|
||||
# Interactive removal
|
||||
all_keys = []
|
||||
for source_dir in LOCAL_TRUST_STORE_PATH.iterdir():
|
||||
if source_dir.is_dir():
|
||||
for pub_key in source_dir.glob("*.pub"):
|
||||
all_keys.append(pub_key)
|
||||
keys_result = list_keys()
|
||||
|
||||
if not all_keys:
|
||||
if not keys_result["success"]:
|
||||
typer.secho(f"Error: {keys_result['error']}", fg=typer.colors.RED)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
if keys_result["total_count"] == 0:
|
||||
typer.echo("No keys to remove.")
|
||||
raise typer.Exit()
|
||||
|
||||
choices = [
|
||||
{
|
||||
"name": f"{key.relative_to(LOCAL_TRUST_STORE_PATH)} ({get_key_fingerprint(key)})",
|
||||
"value": key
|
||||
} for key in all_keys
|
||||
]
|
||||
# Build choices for interactive selection
|
||||
choices = []
|
||||
for source_name, keys in keys_result["keys"].items():
|
||||
for key_info in keys:
|
||||
relative_path = f"{source_name}/{key_info['name']}"
|
||||
choice_name = f"{relative_path} ({key_info['fingerprint']})"
|
||||
choices.append({
|
||||
"name": choice_name,
|
||||
"value": key_info['fingerprint'] # Use fingerprint as identifier
|
||||
})
|
||||
|
||||
selected_to_remove = questionary.checkbox("Select keys to remove:", choices=choices).ask()
|
||||
selected_fingerprints = questionary.checkbox("Select keys to remove:", choices=choices).ask()
|
||||
|
||||
if selected_to_remove:
|
||||
for key_to_remove in selected_to_remove:
|
||||
key_to_remove.unlink()
|
||||
typer.secho(f"✅ Removed key '{key_to_remove.name}'.", fg=typer.colors.GREEN)
|
||||
if selected_fingerprints:
|
||||
for fingerprint in selected_fingerprints:
|
||||
result = remove_key(fingerprint)
|
||||
if result["success"]:
|
||||
for removed_key in result["removed_keys"]:
|
||||
typer.secho(f"✅ Removed key '{removed_key['name']}'.", fg=typer.colors.GREEN)
|
||||
else:
|
||||
typer.secho(f"Error removing key: {result['error']}", fg=typer.colors.RED)
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
# fourdst/cli/keys/sync.py
|
||||
import typer
|
||||
import shutil
|
||||
import json
|
||||
from pathlib import Path
|
||||
import questionary
|
||||
|
||||
from fourdst.cli.common.config import FOURDST_CONFIG_DIR, LOCAL_TRUST_STORE_PATH
|
||||
|
||||
KEY_REMOTES_CONFIG = FOURDST_CONFIG_DIR / "key_remotes.json"
|
||||
REMOTES_DIR = LOCAL_TRUST_STORE_PATH / "remotes"
|
||||
from fourdst.core.keys import sync_remotes, remove_remote_source
|
||||
|
||||
keys_app = typer.Typer()
|
||||
|
||||
@@ -17,50 +11,42 @@ def keys_sync():
|
||||
"""
|
||||
Syncs the local trust store with all configured remote Git repositories.
|
||||
"""
|
||||
if not KEY_REMOTES_CONFIG.exists():
|
||||
typer.secho("No remotes configured. Use 'fourdst-cli keys remote add' to add one.", fg=typer.colors.YELLOW)
|
||||
raise typer.Exit()
|
||||
|
||||
with open(KEY_REMOTES_CONFIG, 'r') as f:
|
||||
config = json.load(f)
|
||||
def progress_callback(message):
|
||||
typer.echo(message)
|
||||
|
||||
remotes = config.get("remotes", [])
|
||||
if not remotes:
|
||||
typer.secho("No remotes configured.", fg=typer.colors.YELLOW)
|
||||
raise typer.Exit()
|
||||
|
||||
REMOTES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
result = sync_remotes(progress_callback=progress_callback)
|
||||
|
||||
remotes_to_remove = []
|
||||
|
||||
for remote in remotes:
|
||||
name = remote['name']
|
||||
url = remote['url']
|
||||
remote_path = REMOTES_DIR / name
|
||||
|
||||
typer.secho(f"--- Syncing remote '{name}' from {url} ---", bold=True)
|
||||
|
||||
try:
|
||||
if remote_path.exists():
|
||||
run_command(["git", "pull"], cwd=remote_path)
|
||||
if not result["success"]:
|
||||
typer.secho(f"Error: {result['error']}", fg=typer.colors.YELLOW)
|
||||
raise typer.Exit()
|
||||
|
||||
# Display results
|
||||
success_count = len([r for r in result["synced_remotes"] if r["status"] == "success"])
|
||||
failed_count = len([r for r in result["synced_remotes"] if r["status"] == "failed"])
|
||||
|
||||
typer.echo(f"\nSync completed:")
|
||||
typer.echo(f" ✅ Successful: {success_count}")
|
||||
typer.echo(f" ❌ Failed: {failed_count}")
|
||||
typer.echo(f" 📦 Total keys synced: {result['total_keys_synced']}")
|
||||
|
||||
# Show details for each remote
|
||||
for remote_info in result["synced_remotes"]:
|
||||
if remote_info["status"] == "success":
|
||||
typer.secho(f" ✅ {remote_info['name']}: {remote_info.get('keys_count', 0)} keys", fg=typer.colors.GREEN)
|
||||
else:
|
||||
typer.secho(f" ❌ {remote_info['name']}: {remote_info['error']}", fg=typer.colors.RED)
|
||||
|
||||
# Handle removed remotes
|
||||
if result["removed_remotes"]:
|
||||
typer.secho(f"\nRemoved failing remotes: {', '.join(result['removed_remotes'])}", fg=typer.colors.YELLOW)
|
||||
|
||||
# Ask about failed remotes that weren't automatically removed
|
||||
failed_remotes = [r for r in result["synced_remotes"] if r["status"] == "failed" and r["name"] not in result["removed_remotes"]]
|
||||
for remote_info in failed_remotes:
|
||||
if questionary.confirm(f"Do you want to remove the failing remote '{remote_info['name']}'?").ask():
|
||||
remove_result = remove_remote_source(remote_info['name'])
|
||||
if remove_result["success"]:
|
||||
typer.secho(f"✅ Removed remote '{remote_info['name']}'", fg=typer.colors.GREEN)
|
||||
else:
|
||||
run_command(["git", "clone", "--depth", "1", url, str(remote_path)])
|
||||
|
||||
# Clean up non-public key files
|
||||
for item in remote_path.iterdir():
|
||||
if item.is_file() and item.suffix != '.pub':
|
||||
item.unlink()
|
||||
|
||||
typer.secho(f"✅ Sync successful for '{name}'.", fg=typer.colors.GREEN)
|
||||
|
||||
except Exception as e:
|
||||
typer.secho(f"⚠️ Failed to sync remote '{name}': {e}", fg=typer.colors.YELLOW)
|
||||
if questionary.confirm(f"Do you want to remove the remote '{name}'?").ask():
|
||||
remotes_to_remove.append(name)
|
||||
|
||||
if remotes_to_remove:
|
||||
config['remotes'] = [r for r in config['remotes'] if r['name'] not in remotes_to_remove]
|
||||
with open(KEY_REMOTES_CONFIG, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
typer.secho(f"Removed failing remotes: {', '.join(remotes_to_remove)}", fg=typer.colors.YELLOW)
|
||||
typer.secho(f"❌ Failed to remove remote '{remote_info['name']}': {remove_result['error']}", fg=typer.colors.RED)
|
||||
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
import os
|
||||
import subprocess
|
||||
import zipfile
|
||||
import docker
|
||||
import io
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
import docker
|
||||
@@ -170,8 +168,11 @@ def build_plugin_in_docker(sdist_path: Path, build_dir: Path, target: dict, plug
|
||||
# Use the tarfile module for robust extraction
|
||||
bits, _ = container.get_archive(str(container_build_dir / "abi_details.txt"))
|
||||
with tarfile.open(fileobj=io.BytesIO(b''.join(bits))) as tar:
|
||||
member = tar.getmembers()[0]
|
||||
extracted_file = tar.extractfile(member)
|
||||
extracted_file = None
|
||||
for member in tar.getmembers():
|
||||
if member.isfile():
|
||||
extracted_file = tar.extractfile(member)
|
||||
break
|
||||
if not extracted_file:
|
||||
raise FileNotFoundError("Could not extract abi_details.txt from container archive.")
|
||||
abi_details_content = extracted_file.read()
|
||||
|
||||
746
fourdst/core/keys.py
Normal file
746
fourdst/core/keys.py
Normal file
@@ -0,0 +1,746 @@
|
||||
# fourdst/core/keys.py
|
||||
"""
|
||||
Core key management functions for 4DSTAR.
|
||||
|
||||
This module provides the core functionality for managing cryptographic keys
|
||||
used for bundle signing and verification. All key operations should go through
|
||||
these functions to maintain consistency between CLI and Electron interfaces.
|
||||
|
||||
ARCHITECTURE:
|
||||
=============
|
||||
- All functions return JSON-serializable dictionaries
|
||||
- Progress callbacks are separate from return values
|
||||
- Consistent error format: {"success": false, "error": "message"}
|
||||
- Functions handle both interactive and programmatic usage
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import shutil
|
||||
import hashlib
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Callable, List
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519, rsa
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
from fourdst.core.config import FOURDST_CONFIG_DIR, LOCAL_TRUST_STORE_PATH
|
||||
from fourdst.core.utils import run_command
|
||||
|
||||
# Configure logging to go to stderr only, never stdout
|
||||
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
|
||||
|
||||
# Key management paths
|
||||
MANUAL_KEYS_DIR = LOCAL_TRUST_STORE_PATH / "manual"
|
||||
REMOTES_DIR = LOCAL_TRUST_STORE_PATH / "remotes"
|
||||
KEY_REMOTES_CONFIG = FOURDST_CONFIG_DIR / "key_remotes.json"
|
||||
|
||||
|
||||
def list_keys(progress_callback: Optional[Callable] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Lists all trusted public keys organized by source.
|
||||
|
||||
Returns:
|
||||
Dict with structure:
|
||||
{
|
||||
"success": bool,
|
||||
"keys": {
|
||||
"source_name": [
|
||||
{
|
||||
"name": str,
|
||||
"path": str,
|
||||
"fingerprint": str,
|
||||
"size_bytes": int
|
||||
}
|
||||
]
|
||||
},
|
||||
"total_count": int
|
||||
}
|
||||
|
||||
On error:
|
||||
{
|
||||
"success": false,
|
||||
"error": "error message"
|
||||
}
|
||||
"""
|
||||
def report_progress(message):
|
||||
if progress_callback:
|
||||
progress_callback(message)
|
||||
else:
|
||||
logging.info(message)
|
||||
|
||||
try:
|
||||
report_progress("Scanning trust store for keys...")
|
||||
|
||||
if not LOCAL_TRUST_STORE_PATH.exists():
|
||||
return {
|
||||
"success": True,
|
||||
"keys": {},
|
||||
"total_count": 0,
|
||||
"message": "Trust store not found - no keys available"
|
||||
}
|
||||
|
||||
keys_by_source = {}
|
||||
total_count = 0
|
||||
|
||||
for source_dir in LOCAL_TRUST_STORE_PATH.iterdir():
|
||||
if source_dir.is_dir():
|
||||
source_keys = []
|
||||
# Look for both .pub and .pub.pem files
|
||||
key_patterns = ["*.pub", "*.pub.pem"]
|
||||
for pattern in key_patterns:
|
||||
for key_file in source_dir.glob(pattern):
|
||||
try:
|
||||
fingerprint = _get_key_fingerprint(key_file)
|
||||
key_info = {
|
||||
"name": key_file.name,
|
||||
"path": str(key_file),
|
||||
"fingerprint": fingerprint,
|
||||
"size_bytes": key_file.stat().st_size
|
||||
}
|
||||
source_keys.append(key_info)
|
||||
total_count += 1
|
||||
except Exception as e:
|
||||
report_progress(f"Warning: Could not process key {key_file}: {e}")
|
||||
|
||||
if source_keys:
|
||||
keys_by_source[source_dir.name] = source_keys
|
||||
|
||||
report_progress(f"Found {total_count} keys across {len(keys_by_source)} sources")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"keys": keys_by_source,
|
||||
"total_count": total_count
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(f"Unexpected error listing keys")
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to list keys: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def generate_key(
|
||||
key_name: str = "author_key",
|
||||
key_type: str = "ed25519",
|
||||
output_dir: Optional[Path] = None,
|
||||
progress_callback: Optional[Callable] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generates a new Ed25519 or RSA key pair for signing bundles.
|
||||
|
||||
Args:
|
||||
key_name: Base name for the generated key files
|
||||
key_type: Type of key to generate ("ed25519" or "rsa")
|
||||
output_dir: Directory to save keys (defaults to current directory)
|
||||
progress_callback: Optional function for progress updates
|
||||
|
||||
Returns:
|
||||
Dict with structure:
|
||||
{
|
||||
"success": bool,
|
||||
"private_key_path": str,
|
||||
"public_key_path": str,
|
||||
"openssh_public_key_path": str,
|
||||
"key_type": str,
|
||||
"fingerprint": str
|
||||
}
|
||||
|
||||
On error:
|
||||
{
|
||||
"success": false,
|
||||
"error": "error message"
|
||||
}
|
||||
"""
|
||||
def report_progress(message):
|
||||
if progress_callback:
|
||||
progress_callback(message)
|
||||
else:
|
||||
logging.info(message)
|
||||
|
||||
try:
|
||||
if output_dir is None:
|
||||
output_dir = Path.cwd()
|
||||
else:
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Define key file paths
|
||||
private_key_path = output_dir / f"{key_name}.pem"
|
||||
public_key_path = output_dir / f"{key_name}.pub.pem"
|
||||
openssh_public_key_path = output_dir / f"{key_name}.pub"
|
||||
|
||||
# Check if files already exist
|
||||
if private_key_path.exists() or public_key_path.exists() or openssh_public_key_path.exists():
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Key files already exist: {private_key_path.name}, {public_key_path.name}, or {openssh_public_key_path.name}"
|
||||
}
|
||||
|
||||
# Generate key based on requested type
|
||||
key_type = key_type.lower()
|
||||
if key_type == "ed25519":
|
||||
report_progress("Generating Ed25519 key pair...")
|
||||
private_key_obj = ed25519.Ed25519PrivateKey.generate()
|
||||
elif key_type == "rsa":
|
||||
report_progress("Generating RSA-2048 key pair...")
|
||||
private_key_obj = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Unsupported key type: {key_type}. Supported types: ed25519, rsa"
|
||||
}
|
||||
|
||||
# Serialize private key to PEM
|
||||
report_progress("Writing private key...")
|
||||
priv_pem = private_key_obj.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
private_key_path.write_bytes(priv_pem)
|
||||
|
||||
# Derive and serialize public key to PEM
|
||||
report_progress("Writing public key...")
|
||||
public_key_obj = private_key_obj.public_key()
|
||||
pub_pem = public_key_obj.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
public_key_path.write_bytes(pub_pem)
|
||||
|
||||
# Also write OpenSSH-compatible public key
|
||||
openssh_pub = public_key_obj.public_bytes(
|
||||
encoding=serialization.Encoding.OpenSSH,
|
||||
format=serialization.PublicFormat.OpenSSH
|
||||
)
|
||||
openssh_public_key_path.write_bytes(openssh_pub)
|
||||
|
||||
# Generate fingerprint
|
||||
fingerprint = _get_key_fingerprint(public_key_path)
|
||||
|
||||
report_progress("Key generation completed successfully!")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"private_key_path": str(private_key_path.resolve()),
|
||||
"public_key_path": str(public_key_path.resolve()),
|
||||
"openssh_public_key_path": str(openssh_public_key_path.resolve()),
|
||||
"key_type": key_type,
|
||||
"fingerprint": fingerprint,
|
||||
"message": f"Generated {key_type.upper()} key pair successfully"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(f"Unexpected error generating key")
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to generate key: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def add_key(
|
||||
key_path: Path,
|
||||
progress_callback: Optional[Callable] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Adds a single public key to the local trust store.
|
||||
|
||||
Args:
|
||||
key_path: Path to the public key file to add
|
||||
progress_callback: Optional function for progress updates
|
||||
|
||||
Returns:
|
||||
Dict with structure:
|
||||
{
|
||||
"success": bool,
|
||||
"key_name": str,
|
||||
"fingerprint": str,
|
||||
"destination_path": str,
|
||||
"already_existed": bool
|
||||
}
|
||||
|
||||
On error:
|
||||
{
|
||||
"success": false,
|
||||
"error": "error message"
|
||||
}
|
||||
"""
|
||||
def report_progress(message):
|
||||
if progress_callback:
|
||||
progress_callback(message)
|
||||
else:
|
||||
logging.info(message)
|
||||
|
||||
try:
|
||||
key_path = Path(key_path)
|
||||
|
||||
if not key_path.exists():
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Key file does not exist: {key_path}"
|
||||
}
|
||||
|
||||
if not key_path.is_file():
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Path is not a file: {key_path}"
|
||||
}
|
||||
|
||||
# Ensure manual keys directory exists
|
||||
MANUAL_KEYS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
destination = MANUAL_KEYS_DIR / key_path.name
|
||||
already_existed = False
|
||||
|
||||
if destination.exists():
|
||||
# Check if content is identical
|
||||
if destination.read_bytes() == key_path.read_bytes():
|
||||
already_existed = True
|
||||
report_progress(f"Key '{key_path.name}' already exists with identical content")
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Key '{key_path.name}' already exists with different content"
|
||||
}
|
||||
else:
|
||||
report_progress(f"Adding key '{key_path.name}' to trust store...")
|
||||
shutil.copy(key_path, destination)
|
||||
|
||||
# Generate fingerprint
|
||||
fingerprint = _get_key_fingerprint(destination)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"key_name": key_path.name,
|
||||
"fingerprint": fingerprint,
|
||||
"destination_path": str(destination),
|
||||
"already_existed": already_existed,
|
||||
"message": f"Key '{key_path.name}' {'already exists in' if already_existed else 'added to'} trust store"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(f"Unexpected error adding key")
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to add key: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def remove_key(
|
||||
key_identifier: str,
|
||||
progress_callback: Optional[Callable] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Removes a key from the trust store by fingerprint, name, or path.
|
||||
|
||||
Args:
|
||||
key_identifier: Key fingerprint, name, or path to identify the key to remove
|
||||
progress_callback: Optional function for progress updates
|
||||
|
||||
Returns:
|
||||
Dict with structure:
|
||||
{
|
||||
"success": bool,
|
||||
"removed_keys": [
|
||||
{
|
||||
"name": str,
|
||||
"path": str,
|
||||
"source": str
|
||||
}
|
||||
],
|
||||
"removed_count": int
|
||||
}
|
||||
|
||||
On error:
|
||||
{
|
||||
"success": false,
|
||||
"error": "error message"
|
||||
}
|
||||
"""
|
||||
def report_progress(message):
|
||||
if progress_callback:
|
||||
progress_callback(message)
|
||||
else:
|
||||
logging.info(message)
|
||||
|
||||
try:
|
||||
if not LOCAL_TRUST_STORE_PATH.exists():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Trust store not found"
|
||||
}
|
||||
|
||||
removed_keys = []
|
||||
|
||||
# Search for matching keys (same patterns as list_keys)
|
||||
for source_dir in LOCAL_TRUST_STORE_PATH.iterdir():
|
||||
if source_dir.is_dir():
|
||||
key_patterns = ["*.pub", "*.pub.pem"]
|
||||
for pattern in key_patterns:
|
||||
for key_file in source_dir.glob(pattern):
|
||||
should_remove = False
|
||||
|
||||
# Check if identifier matches fingerprint, name, or path
|
||||
try:
|
||||
fingerprint = _get_key_fingerprint(key_file)
|
||||
if (key_identifier == fingerprint or
|
||||
key_identifier == key_file.name or
|
||||
key_identifier == str(key_file) or
|
||||
key_identifier == str(key_file.resolve())):
|
||||
should_remove = True
|
||||
except Exception as e:
|
||||
report_progress(f"Warning: Could not process key {key_file}: {e}")
|
||||
continue
|
||||
|
||||
if should_remove:
|
||||
report_progress(f"Removing key '{key_file.name}' from source '{source_dir.name}'")
|
||||
removed_keys.append({
|
||||
"name": key_file.name,
|
||||
"path": str(key_file),
|
||||
"source": source_dir.name
|
||||
})
|
||||
key_file.unlink()
|
||||
|
||||
if not removed_keys:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"No matching key found for identifier: {key_identifier}"
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"removed_keys": removed_keys,
|
||||
"removed_count": len(removed_keys),
|
||||
"message": f"Removed {len(removed_keys)} key(s)"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(f"Unexpected error removing key")
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to remove key: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def sync_remotes(progress_callback: Optional[Callable] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Syncs the local trust store with all configured remote Git repositories.
|
||||
|
||||
Returns:
|
||||
Dict with structure:
|
||||
{
|
||||
"success": bool,
|
||||
"synced_remotes": [
|
||||
{
|
||||
"name": str,
|
||||
"url": str,
|
||||
"status": "success" | "failed",
|
||||
"error": str (if failed)
|
||||
}
|
||||
],
|
||||
"removed_remotes": [str], # Names of remotes that were removed due to failures
|
||||
"total_keys_synced": int
|
||||
}
|
||||
|
||||
On error:
|
||||
{
|
||||
"success": false,
|
||||
"error": "error message"
|
||||
}
|
||||
"""
|
||||
def report_progress(message):
|
||||
if progress_callback:
|
||||
progress_callback(message)
|
||||
else:
|
||||
logging.info(message)
|
||||
|
||||
try:
|
||||
if not KEY_REMOTES_CONFIG.exists():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "No remotes configured. Use remote management to add remotes first."
|
||||
}
|
||||
|
||||
with open(KEY_REMOTES_CONFIG, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
remotes = config.get("remotes", [])
|
||||
if not remotes:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "No remotes configured in config file"
|
||||
}
|
||||
|
||||
REMOTES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
synced_remotes = []
|
||||
remotes_to_remove = []
|
||||
total_keys_synced = 0
|
||||
|
||||
for remote in remotes:
|
||||
name = remote['name']
|
||||
url = remote['url']
|
||||
remote_path = REMOTES_DIR / name
|
||||
|
||||
report_progress(f"Syncing remote '{name}' from {url}")
|
||||
|
||||
try:
|
||||
if remote_path.exists():
|
||||
run_command(["git", "pull"], cwd=remote_path)
|
||||
else:
|
||||
run_command(["git", "clone", "--depth", "1", url, str(remote_path)])
|
||||
|
||||
# Clean up non-public key files and count keys
|
||||
keys_count = 0
|
||||
for item in remote_path.rglob("*"):
|
||||
if item.is_file():
|
||||
if item.suffix == '.pub':
|
||||
keys_count += 1
|
||||
else:
|
||||
item.unlink()
|
||||
|
||||
total_keys_synced += keys_count
|
||||
|
||||
synced_remotes.append({
|
||||
"name": name,
|
||||
"url": url,
|
||||
"status": "success",
|
||||
"keys_count": keys_count
|
||||
})
|
||||
|
||||
report_progress(f"Successfully synced '{name}' ({keys_count} keys)")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
synced_remotes.append({
|
||||
"name": name,
|
||||
"url": url,
|
||||
"status": "failed",
|
||||
"error": error_msg
|
||||
})
|
||||
remotes_to_remove.append(name)
|
||||
report_progress(f"Failed to sync remote '{name}': {error_msg}")
|
||||
|
||||
# Remove failed remotes from config if any
|
||||
if remotes_to_remove:
|
||||
config['remotes'] = [r for r in config['remotes'] if r['name'] not in remotes_to_remove]
|
||||
with open(KEY_REMOTES_CONFIG, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
success_count = len([r for r in synced_remotes if r["status"] == "success"])
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"synced_remotes": synced_remotes,
|
||||
"removed_remotes": remotes_to_remove,
|
||||
"total_keys_synced": total_keys_synced,
|
||||
"message": f"Sync completed: {success_count} successful, {len(remotes_to_remove)} failed"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(f"Unexpected error syncing remotes")
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to sync remotes: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def get_remote_sources(progress_callback: Optional[Callable] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Lists all configured remote key sources.
|
||||
|
||||
Returns:
|
||||
Dict with structure:
|
||||
{
|
||||
"success": bool,
|
||||
"remotes": [
|
||||
{
|
||||
"name": str,
|
||||
"url": str,
|
||||
"local_path": str,
|
||||
"exists": bool,
|
||||
"keys_count": int
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if not KEY_REMOTES_CONFIG.exists():
|
||||
return {
|
||||
"success": True,
|
||||
"remotes": [],
|
||||
"message": "No remotes configured"
|
||||
}
|
||||
|
||||
with open(KEY_REMOTES_CONFIG, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
remotes_info = []
|
||||
for remote in config.get("remotes", []):
|
||||
remote_path = REMOTES_DIR / remote['name']
|
||||
keys_count = len(list(remote_path.glob("*.pub"))) if remote_path.exists() else 0
|
||||
|
||||
remotes_info.append({
|
||||
"name": remote['name'],
|
||||
"url": remote['url'],
|
||||
"local_path": str(remote_path),
|
||||
"exists": remote_path.exists(),
|
||||
"keys_count": keys_count
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"remotes": remotes_info
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(f"Unexpected error getting remote sources")
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to get remote sources: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def add_remote_source(
|
||||
name: str,
|
||||
url: str,
|
||||
progress_callback: Optional[Callable] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Adds a new remote key source.
|
||||
|
||||
Args:
|
||||
name: Name for the remote source
|
||||
url: Git repository URL
|
||||
progress_callback: Optional function for progress updates
|
||||
|
||||
Returns:
|
||||
Dict with structure:
|
||||
{
|
||||
"success": bool,
|
||||
"name": str,
|
||||
"url": str,
|
||||
"message": str
|
||||
}
|
||||
"""
|
||||
try:
|
||||
FOURDST_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load existing config or create new one
|
||||
config = {"remotes": []}
|
||||
if KEY_REMOTES_CONFIG.exists():
|
||||
with open(KEY_REMOTES_CONFIG, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Check if remote already exists
|
||||
for remote in config.get("remotes", []):
|
||||
if remote['name'] == name:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Remote '{name}' already exists"
|
||||
}
|
||||
|
||||
# Add new remote
|
||||
config.setdefault("remotes", []).append({
|
||||
"name": name,
|
||||
"url": url
|
||||
})
|
||||
|
||||
# Save config
|
||||
with open(KEY_REMOTES_CONFIG, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"name": name,
|
||||
"url": url,
|
||||
"message": f"Remote '{name}' added successfully"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(f"Unexpected error adding remote source")
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to add remote source: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def remove_remote_source(
|
||||
name: str,
|
||||
progress_callback: Optional[Callable] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Removes a remote key source.
|
||||
|
||||
Args:
|
||||
name: Name of the remote source to remove
|
||||
progress_callback: Optional function for progress updates
|
||||
|
||||
Returns:
|
||||
Dict with structure:
|
||||
{
|
||||
"success": bool,
|
||||
"name": str,
|
||||
"message": str
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if not KEY_REMOTES_CONFIG.exists():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "No remotes configured"
|
||||
}
|
||||
|
||||
with open(KEY_REMOTES_CONFIG, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
original_len = len(config.get("remotes", []))
|
||||
config["remotes"] = [r for r in config.get("remotes", []) if r['name'] != name]
|
||||
|
||||
if len(config["remotes"]) == original_len:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Remote '{name}' not found"
|
||||
}
|
||||
|
||||
# Save updated config
|
||||
with open(KEY_REMOTES_CONFIG, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
# Remove local directory if it exists
|
||||
remote_path = REMOTES_DIR / name
|
||||
if remote_path.exists():
|
||||
shutil.rmtree(remote_path)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"name": name,
|
||||
"message": f"Remote '{name}' removed successfully"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(f"Unexpected error removing remote source")
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to remove remote source: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def _get_key_fingerprint(key_path: Path) -> str:
|
||||
"""
|
||||
Generates a SHA256 fingerprint for a public key.
|
||||
|
||||
Args:
|
||||
key_path: Path to the public key file
|
||||
|
||||
Returns:
|
||||
SHA256 fingerprint in format "sha256:hexdigest"
|
||||
"""
|
||||
pub_key_bytes = key_path.read_bytes()
|
||||
return "sha256:" + hashlib.sha256(pub_key_bytes).hexdigest()
|
||||
Reference in New Issue
Block a user