Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06be613e36 | |||
| da078af73f | |||
| 01ea581f8a | |||
| 552aaebf16 |
@@ -47,8 +47,7 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi
|
|||||||
|
|
||||||
Each project can independently use one of:
|
Each project can independently use one of:
|
||||||
|
|
||||||
- **`/login`** (OAuth): User runs `claude login` inside the terminal. Token persisted in the config volume.
|
- **Anthropic** (OAuth): User runs `claude login` inside the terminal on first use. Token persisted in the config volume across restarts and resets.
|
||||||
- **API Key**: Stored in the OS keychain, injected as `ANTHROPIC_API_KEY` env var.
|
|
||||||
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token).
|
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token).
|
||||||
|
|
||||||
### Container Spawning (Sibling Containers)
|
### Container Spawning (Sibling Containers)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 918 B |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 91 KiB |
@@ -124,26 +124,15 @@ pub async fn start_project_container(
|
|||||||
let settings = state.settings_store.get();
|
let settings = state.settings_store.get();
|
||||||
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
||||||
|
|
||||||
// Get API key only if auth mode requires it
|
// Validate auth mode requirements
|
||||||
let api_key = match project.auth_mode {
|
if project.auth_mode == AuthMode::Bedrock {
|
||||||
AuthMode::ApiKey => {
|
let bedrock = project.bedrock_config.as_ref()
|
||||||
let key = secure::get_api_key()?
|
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?;
|
||||||
.ok_or_else(|| "No API key configured. Please set your Anthropic API key in Settings.".to_string())?;
|
// Region can come from per-project or global
|
||||||
Some(key)
|
if bedrock.aws_region.is_empty() && settings.global_aws.aws_region.is_none() {
|
||||||
|
return Err("AWS region is required for Bedrock auth mode. Set it per-project or in global AWS settings.".to_string());
|
||||||
}
|
}
|
||||||
AuthMode::Login => {
|
}
|
||||||
None
|
|
||||||
}
|
|
||||||
AuthMode::Bedrock => {
|
|
||||||
let bedrock = project.bedrock_config.as_ref()
|
|
||||||
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?;
|
|
||||||
// Region can come from per-project or global
|
|
||||||
if bedrock.aws_region.is_empty() && settings.global_aws.aws_region.is_none() {
|
|
||||||
return Err("AWS region is required for Bedrock auth mode. Set it per-project or in global AWS settings.".to_string());
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update status to starting
|
// Update status to starting
|
||||||
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
|
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
|
||||||
@@ -169,7 +158,6 @@ pub async fn start_project_container(
|
|||||||
let needs_recreation = docker::container_needs_recreation(
|
let needs_recreation = docker::container_needs_recreation(
|
||||||
&existing_id,
|
&existing_id,
|
||||||
&project,
|
&project,
|
||||||
api_key.as_deref(),
|
|
||||||
settings.global_claude_instructions.as_deref(),
|
settings.global_claude_instructions.as_deref(),
|
||||||
&settings.global_custom_env_vars,
|
&settings.global_custom_env_vars,
|
||||||
)
|
)
|
||||||
@@ -181,7 +169,6 @@ pub async fn start_project_container(
|
|||||||
docker::remove_container(&existing_id).await?;
|
docker::remove_container(&existing_id).await?;
|
||||||
let new_id = docker::create_container(
|
let new_id = docker::create_container(
|
||||||
&project,
|
&project,
|
||||||
api_key.as_deref(),
|
|
||||||
&docker_socket,
|
&docker_socket,
|
||||||
&image_name,
|
&image_name,
|
||||||
aws_config_path.as_deref(),
|
aws_config_path.as_deref(),
|
||||||
@@ -198,7 +185,6 @@ pub async fn start_project_container(
|
|||||||
} else {
|
} else {
|
||||||
let new_id = docker::create_container(
|
let new_id = docker::create_container(
|
||||||
&project,
|
&project,
|
||||||
api_key.as_deref(),
|
|
||||||
&docker_socket,
|
&docker_socket,
|
||||||
&image_name,
|
&image_name,
|
||||||
aws_config_path.as_deref(),
|
aws_config_path.as_deref(),
|
||||||
|
|||||||
@@ -2,24 +2,8 @@ use tauri::State;
|
|||||||
|
|
||||||
use crate::docker;
|
use crate::docker;
|
||||||
use crate::models::AppSettings;
|
use crate::models::AppSettings;
|
||||||
use crate::storage::secure;
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_api_key(key: String) -> Result<(), String> {
|
|
||||||
secure::store_api_key(&key)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn has_api_key() -> Result<bool, String> {
|
|
||||||
secure::has_api_key()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn delete_api_key() -> Result<(), String> {
|
|
||||||
secure::delete_api_key()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, String> {
|
pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, String> {
|
||||||
Ok(state.settings_store.get())
|
Ok(state.settings_store.get())
|
||||||
|
|||||||
@@ -2,26 +2,13 @@ use bollard::container::{
|
|||||||
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
|
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
|
||||||
StartContainerOptions, StopContainerOptions,
|
StartContainerOptions, StopContainerOptions,
|
||||||
};
|
};
|
||||||
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum};
|
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBinding};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
use super::client::get_docker;
|
use super::client::get_docker;
|
||||||
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, Project, ProjectPath};
|
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, PortMapping, Project, ProjectPath};
|
||||||
|
|
||||||
/// Compute a fingerprint for the API key so we can detect when it changes
|
|
||||||
/// without storing the actual key in Docker labels.
|
|
||||||
fn compute_api_key_fingerprint(api_key: Option<&str>) -> String {
|
|
||||||
match api_key {
|
|
||||||
Some(key) => {
|
|
||||||
let mut hasher = DefaultHasher::new();
|
|
||||||
key.hash(&mut hasher);
|
|
||||||
format!("{:x}", hasher.finish())
|
|
||||||
}
|
|
||||||
None => String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute a fingerprint string for the custom environment variables.
|
/// Compute a fingerprint string for the custom environment variables.
|
||||||
/// Sorted alphabetically so order changes do not cause spurious recreation.
|
/// Sorted alphabetically so order changes do not cause spurious recreation.
|
||||||
@@ -108,6 +95,20 @@ fn compute_paths_fingerprint(paths: &[ProjectPath]) -> String {
|
|||||||
format!("{:x}", hasher.finish())
|
format!("{:x}", hasher.finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute a fingerprint for port mappings so we can detect changes.
|
||||||
|
/// Sorted so order changes don't cause spurious recreation.
|
||||||
|
fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String {
|
||||||
|
let mut parts: Vec<String> = port_mappings
|
||||||
|
.iter()
|
||||||
|
.map(|p| format!("{}:{}:{}", p.host_port, p.container_port, p.protocol))
|
||||||
|
.collect();
|
||||||
|
parts.sort();
|
||||||
|
let joined = parts.join(",");
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
joined.hash(&mut hasher);
|
||||||
|
format!("{:x}", hasher.finish())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
|
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let container_name = project.container_name();
|
let container_name = project.container_name();
|
||||||
@@ -140,7 +141,6 @@ pub async fn find_existing_container(project: &Project) -> Result<Option<String>
|
|||||||
|
|
||||||
pub async fn create_container(
|
pub async fn create_container(
|
||||||
project: &Project,
|
project: &Project,
|
||||||
api_key: Option<&str>,
|
|
||||||
docker_socket_path: &str,
|
docker_socket_path: &str,
|
||||||
image_name: &str,
|
image_name: &str,
|
||||||
aws_config_path: Option<&str>,
|
aws_config_path: Option<&str>,
|
||||||
@@ -189,10 +189,6 @@ pub async fn create_container(
|
|||||||
log::debug!("Skipping HOST_UID/HOST_GID on Windows — Docker Desktop's Linux VM handles user mapping");
|
log::debug!("Skipping HOST_UID/HOST_GID on Windows — Docker Desktop's Linux VM handles user mapping");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(key) = api_key {
|
|
||||||
env_vars.push(format!("ANTHROPIC_API_KEY={}", key));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref token) = project.git_token {
|
if let Some(ref token) = project.git_token {
|
||||||
env_vars.push(format!("GIT_TOKEN={}", token));
|
env_vars.push(format!("GIT_TOKEN={}", token));
|
||||||
}
|
}
|
||||||
@@ -273,11 +269,27 @@ pub async fn create_container(
|
|||||||
let custom_env_fingerprint = compute_env_fingerprint(&merged_env);
|
let custom_env_fingerprint = compute_env_fingerprint(&merged_env);
|
||||||
env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint));
|
env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint));
|
||||||
|
|
||||||
// Claude instructions (global + per-project)
|
// Claude instructions (global + per-project, plus port mapping info)
|
||||||
let combined_instructions = merge_claude_instructions(
|
let mut combined_instructions = merge_claude_instructions(
|
||||||
global_claude_instructions,
|
global_claude_instructions,
|
||||||
project.claude_instructions.as_deref(),
|
project.claude_instructions.as_deref(),
|
||||||
);
|
);
|
||||||
|
if !project.port_mappings.is_empty() {
|
||||||
|
let mut port_lines: Vec<String> = Vec::new();
|
||||||
|
port_lines.push("## Available Port Mappings".to_string());
|
||||||
|
port_lines.push("The following ports are mapped from the host to this container. Use these container ports when starting services that need to be accessible from the host:".to_string());
|
||||||
|
for pm in &project.port_mappings {
|
||||||
|
port_lines.push(format!(
|
||||||
|
"- Host port {} -> Container port {} ({})",
|
||||||
|
pm.host_port, pm.container_port, pm.protocol
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let port_info = port_lines.join("\n");
|
||||||
|
combined_instructions = Some(match combined_instructions {
|
||||||
|
Some(existing) => format!("{}\n\n{}", existing, port_info),
|
||||||
|
None => port_info,
|
||||||
|
});
|
||||||
|
}
|
||||||
if let Some(ref instructions) = combined_instructions {
|
if let Some(ref instructions) = combined_instructions {
|
||||||
env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions));
|
env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions));
|
||||||
}
|
}
|
||||||
@@ -364,18 +376,34 @@ pub async fn create_container(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Port mappings
|
||||||
|
let mut exposed_ports: HashMap<String, HashMap<(), ()>> = HashMap::new();
|
||||||
|
let mut port_bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
|
||||||
|
for pm in &project.port_mappings {
|
||||||
|
let container_key = format!("{}/{}", pm.container_port, pm.protocol);
|
||||||
|
exposed_ports.insert(container_key.clone(), HashMap::new());
|
||||||
|
port_bindings.insert(
|
||||||
|
container_key,
|
||||||
|
Some(vec![PortBinding {
|
||||||
|
host_ip: Some("0.0.0.0".to_string()),
|
||||||
|
host_port: Some(pm.host_port.to_string()),
|
||||||
|
}]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let mut labels = HashMap::new();
|
let mut labels = HashMap::new();
|
||||||
labels.insert("triple-c.managed".to_string(), "true".to_string());
|
labels.insert("triple-c.managed".to_string(), "true".to_string());
|
||||||
labels.insert("triple-c.project-id".to_string(), project.id.clone());
|
labels.insert("triple-c.project-id".to_string(), project.id.clone());
|
||||||
labels.insert("triple-c.project-name".to_string(), project.name.clone());
|
labels.insert("triple-c.project-name".to_string(), project.name.clone());
|
||||||
labels.insert("triple-c.auth-mode".to_string(), format!("{:?}", project.auth_mode));
|
labels.insert("triple-c.auth-mode".to_string(), format!("{:?}", project.auth_mode));
|
||||||
labels.insert("triple-c.api-key-fingerprint".to_string(), compute_api_key_fingerprint(api_key));
|
|
||||||
labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths));
|
labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths));
|
||||||
labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project));
|
labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project));
|
||||||
|
labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings));
|
||||||
labels.insert("triple-c.image".to_string(), image_name.to_string());
|
labels.insert("triple-c.image".to_string(), image_name.to_string());
|
||||||
|
|
||||||
let host_config = HostConfig {
|
let host_config = HostConfig {
|
||||||
mounts: Some(mounts),
|
mounts: Some(mounts),
|
||||||
|
port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) },
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -392,6 +420,7 @@ pub async fn create_container(
|
|||||||
labels: Some(labels),
|
labels: Some(labels),
|
||||||
working_dir: Some(working_dir),
|
working_dir: Some(working_dir),
|
||||||
host_config: Some(host_config),
|
host_config: Some(host_config),
|
||||||
|
exposed_ports: if exposed_ports.is_empty() { None } else { Some(exposed_ports) },
|
||||||
tty: Some(true),
|
tty: Some(true),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -453,7 +482,6 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
|||||||
pub async fn container_needs_recreation(
|
pub async fn container_needs_recreation(
|
||||||
container_id: &str,
|
container_id: &str,
|
||||||
project: &Project,
|
project: &Project,
|
||||||
api_key: Option<&str>,
|
|
||||||
global_claude_instructions: Option<&str>,
|
global_claude_instructions: Option<&str>,
|
||||||
global_custom_env_vars: &[EnvVar],
|
global_custom_env_vars: &[EnvVar],
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
@@ -492,14 +520,6 @@ pub async fn container_needs_recreation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── API key fingerprint ─────────────────────────────────────────────
|
|
||||||
let expected_api_key_fp = compute_api_key_fingerprint(api_key);
|
|
||||||
let container_api_key_fp = get_label("triple-c.api-key-fingerprint").unwrap_or_default();
|
|
||||||
if container_api_key_fp != expected_api_key_fp {
|
|
||||||
log::info!("API key fingerprint mismatch, triggering recreation");
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Project paths fingerprint ──────────────────────────────────────────
|
// ── Project paths fingerprint ──────────────────────────────────────────
|
||||||
let expected_paths_fp = compute_paths_fingerprint(&project.paths);
|
let expected_paths_fp = compute_paths_fingerprint(&project.paths);
|
||||||
match get_label("triple-c.paths-fingerprint") {
|
match get_label("triple-c.paths-fingerprint") {
|
||||||
@@ -516,6 +536,14 @@ pub async fn container_needs_recreation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Port mappings fingerprint ──────────────────────────────────────────
|
||||||
|
let expected_ports_fp = compute_ports_fingerprint(&project.port_mappings);
|
||||||
|
let container_ports_fp = get_label("triple-c.ports-fingerprint").unwrap_or_default();
|
||||||
|
if container_ports_fp != expected_ports_fp {
|
||||||
|
log::info!("Port mappings fingerprint mismatch (container={:?}, expected={:?})", container_ports_fp, expected_ports_fp);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Bedrock config fingerprint ───────────────────────────────────────
|
// ── Bedrock config fingerprint ───────────────────────────────────────
|
||||||
let expected_bedrock_fp = compute_bedrock_fingerprint(project);
|
let expected_bedrock_fp = compute_bedrock_fingerprint(project);
|
||||||
let container_bedrock_fp = get_label("triple-c.bedrock-fingerprint").unwrap_or_default();
|
let container_bedrock_fp = get_label("triple-c.bedrock-fingerprint").unwrap_or_default();
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ pub fn run() {
|
|||||||
exec_manager: ExecSessionManager::new(),
|
exec_manager: ExecSessionManager::new(),
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.ico")) {
|
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||||
Ok(icon) => {
|
Ok(icon) => {
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
let _ = window.set_icon(icon);
|
let _ = window.set_icon(icon);
|
||||||
@@ -79,9 +79,6 @@ pub fn run() {
|
|||||||
commands::project_commands::stop_project_container,
|
commands::project_commands::stop_project_container,
|
||||||
commands::project_commands::rebuild_project_container,
|
commands::project_commands::rebuild_project_container,
|
||||||
// Settings
|
// Settings
|
||||||
commands::settings_commands::set_api_key,
|
|
||||||
commands::settings_commands::has_api_key,
|
|
||||||
commands::settings_commands::delete_api_key,
|
|
||||||
commands::settings_commands::get_settings,
|
commands::settings_commands::get_settings,
|
||||||
commands::settings_commands::update_settings,
|
commands::settings_commands::update_settings,
|
||||||
commands::settings_commands::pull_image,
|
commands::settings_commands::pull_image,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ fn default_true() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn default_global_instructions() -> Option<String> {
|
fn default_global_instructions() -> Option<String> {
|
||||||
Some("If the project is not initialized with git, recommend to the user to initialize and use git to track changes. This makes it easier to revert should something break.".to_string())
|
Some("If the project is not initialized with git, recommend to the user to initialize and use git to track changes. This makes it easier to revert should something break.\n\nUse subagents frequently. For long-running tasks, break the work into parallel subagents where possible. When handling multiple separate tasks, delegate each to its own subagent so they can run concurrently.".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ pub struct ProjectPath {
|
|||||||
pub mount_name: String,
|
pub mount_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct PortMapping {
|
||||||
|
pub host_port: u16,
|
||||||
|
pub container_port: u16,
|
||||||
|
#[serde(default = "default_protocol")]
|
||||||
|
pub protocol: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_protocol() -> String {
|
||||||
|
"tcp".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Project {
|
pub struct Project {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -30,6 +42,8 @@ pub struct Project {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub custom_env_vars: Vec<EnvVar>,
|
pub custom_env_vars: Vec<EnvVar>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub port_mappings: Vec<PortMapping>,
|
||||||
|
#[serde(default)]
|
||||||
pub claude_instructions: Option<String>,
|
pub claude_instructions: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
@@ -46,20 +60,21 @@ pub enum ProjectStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// How the project authenticates with Claude.
|
/// How the project authenticates with Claude.
|
||||||
/// - `Login`: User runs `claude login` inside the container (OAuth, persisted via config volume)
|
/// - `Anthropic`: User runs `claude login` inside the container (OAuth via Anthropic Console,
|
||||||
/// - `ApiKey`: Uses the API key stored in the OS keychain
|
/// persisted in the config volume)
|
||||||
/// - `Bedrock`: Uses AWS Bedrock with per-project AWS credentials
|
/// - `Bedrock`: Uses AWS Bedrock with per-project AWS credentials
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum AuthMode {
|
pub enum AuthMode {
|
||||||
Login,
|
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
|
||||||
ApiKey,
|
#[serde(alias = "login", alias = "api_key")]
|
||||||
|
Anthropic,
|
||||||
Bedrock,
|
Bedrock,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AuthMode {
|
impl Default for AuthMode {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::Login
|
Self::Anthropic
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +128,7 @@ impl Project {
|
|||||||
git_user_name: None,
|
git_user_name: None,
|
||||||
git_user_email: None,
|
git_user_email: None,
|
||||||
custom_env_vars: Vec::new(),
|
custom_env_vars: Vec::new(),
|
||||||
|
port_mappings: Vec::new(),
|
||||||
claude_instructions: None,
|
claude_instructions: None,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
|
|||||||
@@ -1,42 +1,3 @@
|
|||||||
const SERVICE_NAME: &str = "triple-c";
|
|
||||||
const API_KEY_USER: &str = "anthropic-api-key";
|
|
||||||
|
|
||||||
pub fn store_api_key(key: &str) -> Result<(), String> {
|
|
||||||
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
|
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
|
||||||
entry
|
|
||||||
.set_password(key)
|
|
||||||
.map_err(|e| format!("Failed to store API key: {}", e))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_api_key() -> Result<Option<String>, String> {
|
|
||||||
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
|
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
|
||||||
match entry.get_password() {
|
|
||||||
Ok(key) => Ok(Some(key)),
|
|
||||||
Err(keyring::Error::NoEntry) => Ok(None),
|
|
||||||
Err(e) => Err(format!("Failed to retrieve API key: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete_api_key() -> Result<(), String> {
|
|
||||||
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
|
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
|
||||||
match entry.delete_credential() {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(keyring::Error::NoEntry) => Ok(()),
|
|
||||||
Err(e) => Err(format!("Failed to delete API key: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn has_api_key() -> Result<bool, String> {
|
|
||||||
match get_api_key() {
|
|
||||||
Ok(Some(_)) => Ok(true),
|
|
||||||
Ok(None) => Ok(false),
|
|
||||||
Err(e) => Err(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Store a per-project secret in the OS keychain.
|
/// Store a per-project secret in the OS keychain.
|
||||||
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
|
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
|
||||||
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useAppState } from "./store/appState";
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { checkDocker, checkImage } = useDocker();
|
const { checkDocker, checkImage } = useDocker();
|
||||||
const { checkApiKey, loadSettings } = useSettings();
|
const { loadSettings } = useSettings();
|
||||||
const { refresh } = useProjects();
|
const { refresh } = useProjects();
|
||||||
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
||||||
const { sessions, activeSessionId } = useAppState(
|
const { sessions, activeSessionId } = useAppState(
|
||||||
@@ -25,7 +25,6 @@ export default function App() {
|
|||||||
checkDocker().then((available) => {
|
checkDocker().then((available) => {
|
||||||
if (available) checkImage();
|
if (available) checkImage();
|
||||||
});
|
});
|
||||||
checkApiKey();
|
|
||||||
refresh();
|
refresh();
|
||||||
|
|
||||||
// Update detection
|
// Update detection
|
||||||
|
|||||||
157
app/src/components/projects/PortMappingsModal.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import type { PortMapping } from "../../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
portMappings: PortMapping[];
|
||||||
|
disabled: boolean;
|
||||||
|
onSave: (mappings: PortMapping[]) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PortMappingsModal({ portMappings: initial, disabled, onSave, onClose }: Props) {
|
||||||
|
const [mappings, setMappings] = useState<PortMapping[]>(initial);
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleOverlayClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === overlayRef.current) onClose();
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatePort = (index: number, field: "host_port" | "container_port", value: string) => {
|
||||||
|
const updated = [...mappings];
|
||||||
|
const num = parseInt(value, 10);
|
||||||
|
updated[index] = { ...updated[index], [field]: isNaN(num) ? 0 : num };
|
||||||
|
setMappings(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateProtocol = (index: number, value: string) => {
|
||||||
|
const updated = [...mappings];
|
||||||
|
updated[index] = { ...updated[index], protocol: value };
|
||||||
|
setMappings(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMapping = async (index: number) => {
|
||||||
|
const updated = mappings.filter((_, i) => i !== index);
|
||||||
|
setMappings(updated);
|
||||||
|
try { await onSave(updated); } catch (err) {
|
||||||
|
console.error("Failed to remove port mapping:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMapping = async () => {
|
||||||
|
const updated = [...mappings, { host_port: 0, container_port: 0, protocol: "tcp" }];
|
||||||
|
setMappings(updated);
|
||||||
|
try { await onSave(updated); } catch (err) {
|
||||||
|
console.error("Failed to add port mapping:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = async () => {
|
||||||
|
try { await onSave(mappings); } catch (err) {
|
||||||
|
console.error("Failed to update port mappings:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
>
|
||||||
|
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[36rem] shadow-xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Port Mappings</h2>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-4">
|
||||||
|
Map host ports to container ports. Services can be started after the container is running.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{disabled && (
|
||||||
|
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
||||||
|
Container must be stopped to change port mappings.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{mappings.length === 0 && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">No port mappings configured.</p>
|
||||||
|
)}
|
||||||
|
{mappings.length > 0 && (
|
||||||
|
<div className="flex gap-2 items-center text-xs text-[var(--text-secondary)] px-0.5">
|
||||||
|
<span className="w-[30%]">Host Port</span>
|
||||||
|
<span className="w-[30%]">Container Port</span>
|
||||||
|
<span className="w-[25%]">Protocol</span>
|
||||||
|
<span className="w-[15%]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mappings.map((pm, i) => (
|
||||||
|
<div key={i} className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
value={pm.host_port || ""}
|
||||||
|
onChange={(e) => updatePort(i, "host_port", e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="8080"
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-[30%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
value={pm.container_port || ""}
|
||||||
|
onChange={(e) => updatePort(i, "container_port", e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="8080"
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-[30%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={pm.protocol}
|
||||||
|
onChange={(e) => { updateProtocol(i, e.target.value); handleBlur(); }}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-[25%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="tcp">TCP</option>
|
||||||
|
<option value="udp">UDP</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => removeMapping(i)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-[15%] px-2 py-1.5 text-sm text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors text-center"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<button
|
||||||
|
onClick={addMapping}
|
||||||
|
disabled={disabled}
|
||||||
|
className="text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add port mapping
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ const mockProject: Project = {
|
|||||||
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
|
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
|
||||||
container_id: null,
|
container_id: null,
|
||||||
status: "stopped",
|
status: "stopped",
|
||||||
auth_mode: "login",
|
auth_mode: "anthropic",
|
||||||
bedrock_config: null,
|
bedrock_config: null,
|
||||||
allow_docker_access: false,
|
allow_docker_access: false,
|
||||||
ssh_key_path: null,
|
ssh_key_path: null,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useProjects } from "../../hooks/useProjects";
|
|||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
import EnvVarsModal from "./EnvVarsModal";
|
import EnvVarsModal from "./EnvVarsModal";
|
||||||
|
import PortMappingsModal from "./PortMappingsModal";
|
||||||
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -20,6 +21,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||||
|
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
|
||||||
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
||||||
const isSelected = selectedProjectId === project.id;
|
const isSelected = selectedProjectId === project.id;
|
||||||
const isStopped = project.status === "stopped" || project.status === "error";
|
const isStopped = project.status === "stopped" || project.status === "error";
|
||||||
@@ -32,6 +34,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
const [gitToken, setGitToken] = useState(project.git_token ?? "");
|
const [gitToken, setGitToken] = useState(project.git_token ?? "");
|
||||||
const [claudeInstructions, setClaudeInstructions] = useState(project.claude_instructions ?? "");
|
const [claudeInstructions, setClaudeInstructions] = useState(project.claude_instructions ?? "");
|
||||||
const [envVars, setEnvVars] = useState(project.custom_env_vars ?? []);
|
const [envVars, setEnvVars] = useState(project.custom_env_vars ?? []);
|
||||||
|
const [portMappings, setPortMappings] = useState(project.port_mappings ?? []);
|
||||||
|
|
||||||
// Bedrock local state for text fields
|
// Bedrock local state for text fields
|
||||||
const [bedrockRegion, setBedrockRegion] = useState(project.bedrock_config?.aws_region ?? "us-east-1");
|
const [bedrockRegion, setBedrockRegion] = useState(project.bedrock_config?.aws_region ?? "us-east-1");
|
||||||
@@ -51,6 +54,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
setGitToken(project.git_token ?? "");
|
setGitToken(project.git_token ?? "");
|
||||||
setClaudeInstructions(project.claude_instructions ?? "");
|
setClaudeInstructions(project.claude_instructions ?? "");
|
||||||
setEnvVars(project.custom_env_vars ?? []);
|
setEnvVars(project.custom_env_vars ?? []);
|
||||||
|
setPortMappings(project.port_mappings ?? []);
|
||||||
setBedrockRegion(project.bedrock_config?.aws_region ?? "us-east-1");
|
setBedrockRegion(project.bedrock_config?.aws_region ?? "us-east-1");
|
||||||
setBedrockAccessKeyId(project.bedrock_config?.aws_access_key_id ?? "");
|
setBedrockAccessKeyId(project.bedrock_config?.aws_access_key_id ?? "");
|
||||||
setBedrockSecretKey(project.bedrock_config?.aws_secret_access_key ?? "");
|
setBedrockSecretKey(project.bedrock_config?.aws_secret_access_key ?? "");
|
||||||
@@ -267,26 +271,15 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<div className="flex items-center gap-1 text-xs">
|
<div className="flex items-center gap-1 text-xs">
|
||||||
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
|
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("login"); }}
|
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("anthropic"); }}
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className={`px-2 py-0.5 rounded transition-colors ${
|
className={`px-2 py-0.5 rounded transition-colors ${
|
||||||
project.auth_mode === "login"
|
project.auth_mode === "anthropic"
|
||||||
? "bg-[var(--accent)] text-white"
|
? "bg-[var(--accent)] text-white"
|
||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
||||||
} disabled:opacity-50`}
|
} disabled:opacity-50`}
|
||||||
>
|
>
|
||||||
/login
|
Anthropic
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("api_key"); }}
|
|
||||||
disabled={!isStopped}
|
|
||||||
className={`px-2 py-0.5 rounded transition-colors ${
|
|
||||||
project.auth_mode === "api_key"
|
|
||||||
? "bg-[var(--accent)] text-white"
|
|
||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
|
||||||
} disabled:opacity-50`}
|
|
||||||
>
|
|
||||||
API key
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("bedrock"); }}
|
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("bedrock"); }}
|
||||||
@@ -535,6 +528,19 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Port Mappings */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Port Mappings{portMappings.length > 0 && ` (${portMappings.length})`}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPortMappingsModal(true)}
|
||||||
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Claude Instructions */}
|
{/* Claude Instructions */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-xs text-[var(--text-secondary)]">
|
<label className="text-xs text-[var(--text-secondary)]">
|
||||||
@@ -693,6 +699,18 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showPortMappingsModal && (
|
||||||
|
<PortMappingsModal
|
||||||
|
portMappings={portMappings}
|
||||||
|
disabled={!isStopped}
|
||||||
|
onSave={async (mappings) => {
|
||||||
|
setPortMappings(mappings);
|
||||||
|
await update({ ...project, port_mappings: mappings });
|
||||||
|
}}
|
||||||
|
onClose={() => setShowPortMappingsModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showClaudeInstructionsModal && (
|
{showClaudeInstructionsModal && (
|
||||||
<ClaudeInstructionsModal
|
<ClaudeInstructionsModal
|
||||||
instructions={claudeInstructions}
|
instructions={claudeInstructions}
|
||||||
|
|||||||
@@ -1,68 +1,10 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
|
||||||
|
|
||||||
export default function ApiKeyInput() {
|
export default function ApiKeyInput() {
|
||||||
const { hasKey, saveApiKey, removeApiKey } = useSettings();
|
|
||||||
const [key, setKey] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!key.trim()) return;
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await saveApiKey(key.trim());
|
|
||||||
setKey("");
|
|
||||||
} catch (e) {
|
|
||||||
setError(String(e));
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Authentication</label>
|
<label className="block text-sm font-medium mb-1">Authentication</label>
|
||||||
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
||||||
Each project can use <strong>claude login</strong> (OAuth, run inside the terminal), an <strong>API key</strong>, or <strong>AWS Bedrock</strong>. Set auth mode per-project.
|
Each project can use <strong>claude login</strong> (OAuth, run inside the terminal) or <strong>AWS Bedrock</strong>. Set auth mode per-project.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-1 mt-3">
|
|
||||||
API Key (for projects using API key mode)
|
|
||||||
</label>
|
|
||||||
{hasKey ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-[var(--success)]">Key configured</span>
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
try { await removeApiKey(); } catch (e) { setError(String(e)); }
|
|
||||||
}}
|
|
||||||
className="text-xs text-[var(--error)] hover:underline"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={key}
|
|
||||||
onChange={(e) => setKey(e.target.value)}
|
|
||||||
placeholder="sk-ant-..."
|
|
||||||
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving || !key.trim()}
|
|
||||||
className="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{saving ? "Saving..." : "Save Key"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && <div className="text-xs text-[var(--error)] mt-1">{error}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,39 +5,13 @@ import * as commands from "../lib/tauri-commands";
|
|||||||
import type { AppSettings } from "../lib/types";
|
import type { AppSettings } from "../lib/types";
|
||||||
|
|
||||||
export function useSettings() {
|
export function useSettings() {
|
||||||
const { hasKey, setHasKey, appSettings, setAppSettings } = useAppState(
|
const { appSettings, setAppSettings } = useAppState(
|
||||||
useShallow(s => ({
|
useShallow(s => ({
|
||||||
hasKey: s.hasKey,
|
|
||||||
setHasKey: s.setHasKey,
|
|
||||||
appSettings: s.appSettings,
|
appSettings: s.appSettings,
|
||||||
setAppSettings: s.setAppSettings,
|
setAppSettings: s.setAppSettings,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const checkApiKey = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const has = await commands.hasApiKey();
|
|
||||||
setHasKey(has);
|
|
||||||
return has;
|
|
||||||
} catch {
|
|
||||||
setHasKey(false);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [setHasKey]);
|
|
||||||
|
|
||||||
const saveApiKey = useCallback(
|
|
||||||
async (key: string) => {
|
|
||||||
await commands.setApiKey(key);
|
|
||||||
setHasKey(true);
|
|
||||||
},
|
|
||||||
[setHasKey],
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeApiKey = useCallback(async () => {
|
|
||||||
await commands.deleteApiKey();
|
|
||||||
setHasKey(false);
|
|
||||||
}, [setHasKey]);
|
|
||||||
|
|
||||||
const loadSettings = useCallback(async () => {
|
const loadSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const settings = await commands.getSettings();
|
const settings = await commands.getSettings();
|
||||||
@@ -59,10 +33,6 @@ export function useSettings() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasKey,
|
|
||||||
checkApiKey,
|
|
||||||
saveApiKey,
|
|
||||||
removeApiKey,
|
|
||||||
appSettings,
|
appSettings,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ export const rebuildProjectContainer = (projectId: string) =>
|
|||||||
invoke<Project>("rebuild_project_container", { projectId });
|
invoke<Project>("rebuild_project_container", { projectId });
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
export const setApiKey = (key: string) =>
|
|
||||||
invoke<void>("set_api_key", { key });
|
|
||||||
export const hasApiKey = () => invoke<boolean>("has_api_key");
|
|
||||||
export const deleteApiKey = () => invoke<void>("delete_api_key");
|
|
||||||
export const getSettings = () => invoke<AppSettings>("get_settings");
|
export const getSettings = () => invoke<AppSettings>("get_settings");
|
||||||
export const updateSettings = (settings: AppSettings) =>
|
export const updateSettings = (settings: AppSettings) =>
|
||||||
invoke<AppSettings>("update_settings", { settings });
|
invoke<AppSettings>("update_settings", { settings });
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export interface ProjectPath {
|
|||||||
mount_name: string;
|
mount_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PortMapping {
|
||||||
|
host_port: number;
|
||||||
|
container_port: number;
|
||||||
|
protocol: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -22,6 +28,7 @@ export interface Project {
|
|||||||
git_user_name: string | null;
|
git_user_name: string | null;
|
||||||
git_user_email: string | null;
|
git_user_email: string | null;
|
||||||
custom_env_vars: EnvVar[];
|
custom_env_vars: EnvVar[];
|
||||||
|
port_mappings: PortMapping[];
|
||||||
claude_instructions: string | null;
|
claude_instructions: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -34,7 +41,7 @@ export type ProjectStatus =
|
|||||||
| "stopping"
|
| "stopping"
|
||||||
| "error";
|
| "error";
|
||||||
|
|
||||||
export type AuthMode = "login" | "api_key" | "bedrock";
|
export type AuthMode = "anthropic" | "bedrock";
|
||||||
|
|
||||||
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
|
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ interface AppState {
|
|||||||
setDockerAvailable: (available: boolean | null) => void;
|
setDockerAvailable: (available: boolean | null) => void;
|
||||||
imageExists: boolean | null;
|
imageExists: boolean | null;
|
||||||
setImageExists: (exists: boolean | null) => void;
|
setImageExists: (exists: boolean | null) => void;
|
||||||
hasKey: boolean | null;
|
|
||||||
setHasKey: (has: boolean | null) => void;
|
|
||||||
|
|
||||||
// App settings
|
// App settings
|
||||||
appSettings: AppSettings | null;
|
appSettings: AppSettings | null;
|
||||||
setAppSettings: (settings: AppSettings) => void;
|
setAppSettings: (settings: AppSettings) => void;
|
||||||
@@ -85,9 +82,6 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
setDockerAvailable: (available) => set({ dockerAvailable: available }),
|
setDockerAvailable: (available) => set({ dockerAvailable: available }),
|
||||||
imageExists: null,
|
imageExists: null,
|
||||||
setImageExists: (exists) => set({ imageExists: exists }),
|
setImageExists: (exists) => set({ imageExists: exists }),
|
||||||
hasKey: null,
|
|
||||||
setHasKey: (has) => set({ hasKey: has }),
|
|
||||||
|
|
||||||
// App settings
|
// App settings
|
||||||
appSettings: null,
|
appSettings: null,
|
||||||
setAppSettings: (settings) => set({ appSettings: settings }),
|
setAppSettings: (settings) => set({ appSettings: settings }),
|
||||||
|
|||||||
BIN
triple-c-app-logov2.png
Normal file
|
After Width: | Height: | Size: 191 KiB |