Add container registry pull, image source settings, and global AWS config
All checks were successful
Build Container / build-container (push) Successful in 1m59s
All checks were successful
Build Container / build-container (push) Successful in 1m59s
Support pulling images from registry (default: repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest), local builds, or custom images via a new settings UI. Add global AWS configuration (config path auto-detect, profile picker, region) that serves as defaults overridable per-project for Bedrock auth. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::docker;
|
use crate::docker;
|
||||||
use crate::models::ContainerInfo;
|
use crate::models::{container_config, ContainerInfo};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -10,8 +10,10 @@ pub async fn check_docker() -> Result<bool, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn check_image_exists() -> Result<bool, String> {
|
pub async fn check_image_exists(state: State<'_, AppState>) -> Result<bool, String> {
|
||||||
docker::image_exists().await
|
let settings = state.settings_store.get();
|
||||||
|
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
||||||
|
docker::image_exists(&image_name).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::docker;
|
use crate::docker;
|
||||||
use crate::models::{AuthMode, Project, ProjectStatus};
|
use crate::models::{container_config, AuthMode, Project, ProjectStatus};
|
||||||
use crate::storage::secure;
|
use crate::storage::secure;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
@@ -57,6 +57,10 @@ pub async fn start_project_container(
|
|||||||
.get(&project_id)
|
.get(&project_id)
|
||||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
// Load settings for image resolution and global AWS
|
||||||
|
let settings = state.settings_store.get();
|
||||||
|
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
||||||
|
|
||||||
// Get API key only if auth mode requires it
|
// Get API key only if auth mode requires it
|
||||||
let api_key = match project.auth_mode {
|
let api_key = match project.auth_mode {
|
||||||
AuthMode::ApiKey => {
|
AuthMode::ApiKey => {
|
||||||
@@ -65,16 +69,14 @@ pub async fn start_project_container(
|
|||||||
Some(key)
|
Some(key)
|
||||||
}
|
}
|
||||||
AuthMode::Login => {
|
AuthMode::Login => {
|
||||||
// Login mode: no API key needed, user runs `claude login` in the container.
|
|
||||||
// Auth state persists in the .claude config volume.
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
AuthMode::Bedrock => {
|
AuthMode::Bedrock => {
|
||||||
// Bedrock mode: no Anthropic API key needed, uses AWS credentials.
|
|
||||||
let bedrock = project.bedrock_config.as_ref()
|
let bedrock = project.bedrock_config.as_ref()
|
||||||
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?;
|
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?;
|
||||||
if bedrock.aws_region.is_empty() {
|
// Region can come from per-project or global
|
||||||
return Err("AWS region is required for Bedrock auth mode.".to_string());
|
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
|
None
|
||||||
}
|
}
|
||||||
@@ -84,12 +86,19 @@ pub async fn start_project_container(
|
|||||||
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
|
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
|
||||||
|
|
||||||
// Ensure image exists
|
// Ensure image exists
|
||||||
if !docker::image_exists().await? {
|
if !docker::image_exists(&image_name).await? {
|
||||||
return Err("Docker image not built. Please build the image first.".to_string());
|
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
|
||||||
|
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine docker socket path
|
// Determine docker socket path
|
||||||
let docker_socket = default_docker_socket();
|
let docker_socket = settings.docker_socket_path
|
||||||
|
.as_deref()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| default_docker_socket());
|
||||||
|
|
||||||
|
// AWS config path from global settings
|
||||||
|
let aws_config_path = settings.global_aws.aws_config_path.clone();
|
||||||
|
|
||||||
// Check for existing container
|
// Check for existing container
|
||||||
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
|
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
|
||||||
@@ -98,7 +107,14 @@ pub async fn start_project_container(
|
|||||||
existing_id
|
existing_id
|
||||||
} else {
|
} else {
|
||||||
// Create new container
|
// Create new container
|
||||||
let new_id = docker::create_container(&project, api_key.as_deref(), &docker_socket).await?;
|
let new_id = docker::create_container(
|
||||||
|
&project,
|
||||||
|
api_key.as_deref(),
|
||||||
|
&docker_socket,
|
||||||
|
&image_name,
|
||||||
|
aws_config_path.as_deref(),
|
||||||
|
&settings.global_aws,
|
||||||
|
).await?;
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
new_id
|
new_id
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::docker;
|
||||||
|
use crate::models::AppSettings;
|
||||||
use crate::storage::secure;
|
use crate::storage::secure;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_api_key(key: String) -> Result<(), String> {
|
pub async fn set_api_key(key: String) -> Result<(), String> {
|
||||||
@@ -14,3 +19,88 @@ pub async fn has_api_key() -> Result<bool, String> {
|
|||||||
pub async fn delete_api_key() -> Result<(), String> {
|
pub async fn delete_api_key() -> Result<(), String> {
|
||||||
secure::delete_api_key()
|
secure::delete_api_key()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, String> {
|
||||||
|
Ok(state.settings_store.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_settings(
|
||||||
|
settings: AppSettings,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<AppSettings, String> {
|
||||||
|
state.settings_store.update(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn pull_image(
|
||||||
|
image_name: String,
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use tauri::Emitter;
|
||||||
|
docker::pull_image(&image_name, move |msg| {
|
||||||
|
let _ = app_handle.emit("image-pull-progress", msg);
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn detect_aws_config() -> Result<Option<String>, String> {
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
let aws_dir = home.join(".aws");
|
||||||
|
if aws_dir.exists() {
|
||||||
|
return Ok(Some(aws_dir.to_string_lossy().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_aws_profiles() -> Result<Vec<String>, String> {
|
||||||
|
let mut profiles = Vec::new();
|
||||||
|
|
||||||
|
let home = match dirs::home_dir() {
|
||||||
|
Some(h) => h,
|
||||||
|
None => return Ok(profiles),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse ~/.aws/credentials
|
||||||
|
let credentials_path = home.join(".aws").join("credentials");
|
||||||
|
if credentials_path.exists() {
|
||||||
|
if let Ok(contents) = std::fs::read_to_string(&credentials_path) {
|
||||||
|
for line in contents.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||||
|
let profile = trimmed[1..trimmed.len() - 1].to_string();
|
||||||
|
if !profiles.contains(&profile) {
|
||||||
|
profiles.push(profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse ~/.aws/config (profiles are prefixed with "profile ")
|
||||||
|
let config_path = home.join(".aws").join("config");
|
||||||
|
if config_path.exists() {
|
||||||
|
if let Ok(contents) = std::fs::read_to_string(&config_path) {
|
||||||
|
for line in contents.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||||
|
let section = &trimmed[1..trimmed.len() - 1];
|
||||||
|
let profile = if let Some(name) = section.strip_prefix("profile ") {
|
||||||
|
name.to_string()
|
||||||
|
} else {
|
||||||
|
section.to_string()
|
||||||
|
};
|
||||||
|
if !profiles.contains(&profile) {
|
||||||
|
profiles.push(profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(profiles)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use super::client::get_docker;
|
use super::client::get_docker;
|
||||||
use crate::models::{container_config, AuthMode, BedrockAuthMethod, ContainerInfo, Project};
|
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, GlobalAwsSettings, Project};
|
||||||
|
|
||||||
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()?;
|
||||||
@@ -42,10 +42,12 @@ pub async fn create_container(
|
|||||||
project: &Project,
|
project: &Project,
|
||||||
api_key: Option<&str>,
|
api_key: Option<&str>,
|
||||||
docker_socket_path: &str,
|
docker_socket_path: &str,
|
||||||
|
image_name: &str,
|
||||||
|
aws_config_path: Option<&str>,
|
||||||
|
global_aws: &GlobalAwsSettings,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let container_name = project.container_name();
|
let container_name = project.container_name();
|
||||||
let image = container_config::full_image_name();
|
|
||||||
|
|
||||||
let mut env_vars: Vec<String> = Vec::new();
|
let mut env_vars: Vec<String> = Vec::new();
|
||||||
|
|
||||||
@@ -55,14 +57,32 @@ pub async fn create_container(
|
|||||||
let uid = std::process::Command::new("id").arg("-u").output();
|
let uid = std::process::Command::new("id").arg("-u").output();
|
||||||
let gid = std::process::Command::new("id").arg("-g").output();
|
let gid = std::process::Command::new("id").arg("-g").output();
|
||||||
if let Ok(out) = uid {
|
if let Ok(out) = uid {
|
||||||
let val = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
if out.status.success() {
|
||||||
env_vars.push(format!("HOST_UID={}", val));
|
let val = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
if !val.is_empty() {
|
||||||
|
log::debug!("Host UID detected: {}", val);
|
||||||
|
env_vars.push(format!("HOST_UID={}", val));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log::debug!("Failed to detect host UID (exit code {:?})", out.status.code());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Ok(out) = gid {
|
if let Ok(out) = gid {
|
||||||
let val = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
if out.status.success() {
|
||||||
env_vars.push(format!("HOST_GID={}", val));
|
let val = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
if !val.is_empty() {
|
||||||
|
log::debug!("Host GID detected: {}", val);
|
||||||
|
env_vars.push(format!("HOST_GID={}", val));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log::debug!("Failed to detect host GID (exit code {:?})", out.status.code());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
log::debug!("Skipping HOST_UID/HOST_GID on Windows — Docker Desktop's Linux VM handles user mapping");
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(key) = api_key {
|
if let Some(key) = api_key {
|
||||||
env_vars.push(format!("ANTHROPIC_API_KEY={}", key));
|
env_vars.push(format!("ANTHROPIC_API_KEY={}", key));
|
||||||
@@ -82,7 +102,16 @@ pub async fn create_container(
|
|||||||
if project.auth_mode == AuthMode::Bedrock {
|
if project.auth_mode == AuthMode::Bedrock {
|
||||||
if let Some(ref bedrock) = project.bedrock_config {
|
if let Some(ref bedrock) = project.bedrock_config {
|
||||||
env_vars.push("CLAUDE_CODE_USE_BEDROCK=1".to_string());
|
env_vars.push("CLAUDE_CODE_USE_BEDROCK=1".to_string());
|
||||||
env_vars.push(format!("AWS_REGION={}", bedrock.aws_region));
|
|
||||||
|
// AWS region: per-project overrides global
|
||||||
|
let region = if !bedrock.aws_region.is_empty() {
|
||||||
|
Some(bedrock.aws_region.clone())
|
||||||
|
} else {
|
||||||
|
global_aws.aws_region.clone()
|
||||||
|
};
|
||||||
|
if let Some(ref r) = region {
|
||||||
|
env_vars.push(format!("AWS_REGION={}", r));
|
||||||
|
}
|
||||||
|
|
||||||
match bedrock.auth_method {
|
match bedrock.auth_method {
|
||||||
BedrockAuthMethod::StaticCredentials => {
|
BedrockAuthMethod::StaticCredentials => {
|
||||||
@@ -97,8 +126,11 @@ pub async fn create_container(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
BedrockAuthMethod::Profile => {
|
BedrockAuthMethod::Profile => {
|
||||||
if let Some(ref profile) = bedrock.aws_profile {
|
// Per-project profile overrides global
|
||||||
env_vars.push(format!("AWS_PROFILE={}", profile));
|
let profile = bedrock.aws_profile.as_ref()
|
||||||
|
.or(global_aws.aws_profile.as_ref());
|
||||||
|
if let Some(p) = profile {
|
||||||
|
env_vars.push(format!("AWS_PROFILE={}", p));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BedrockAuthMethod::BearerToken => {
|
BedrockAuthMethod::BearerToken => {
|
||||||
@@ -148,22 +180,32 @@ pub async fn create_container(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// AWS config mount (read-only, for profile-based auth)
|
// AWS config mount (read-only)
|
||||||
if project.auth_mode == AuthMode::Bedrock {
|
// Mount if: Bedrock profile auth needs it, OR a global aws_config_path is set
|
||||||
|
let should_mount_aws = if project.auth_mode == AuthMode::Bedrock {
|
||||||
if let Some(ref bedrock) = project.bedrock_config {
|
if let Some(ref bedrock) = project.bedrock_config {
|
||||||
if bedrock.auth_method == BedrockAuthMethod::Profile {
|
bedrock.auth_method == BedrockAuthMethod::Profile
|
||||||
if let Some(home) = dirs::home_dir() {
|
} else {
|
||||||
let aws_dir = home.join(".aws");
|
false
|
||||||
if aws_dir.exists() {
|
}
|
||||||
mounts.push(Mount {
|
} else {
|
||||||
target: Some("/home/claude/.aws".to_string()),
|
false
|
||||||
source: Some(aws_dir.to_string_lossy().to_string()),
|
};
|
||||||
typ: Some(MountTypeEnum::BIND),
|
|
||||||
read_only: Some(true),
|
if should_mount_aws || aws_config_path.is_some() {
|
||||||
..Default::default()
|
let aws_dir = aws_config_path
|
||||||
});
|
.map(|p| std::path::PathBuf::from(p))
|
||||||
}
|
.or_else(|| dirs::home_dir().map(|h| h.join(".aws")));
|
||||||
}
|
|
||||||
|
if let Some(ref aws_path) = aws_dir {
|
||||||
|
if aws_path.exists() {
|
||||||
|
mounts.push(Mount {
|
||||||
|
target: Some("/home/claude/.aws".to_string()),
|
||||||
|
source: Some(aws_path.to_string_lossy().to_string()),
|
||||||
|
typ: Some(MountTypeEnum::BIND),
|
||||||
|
read_only: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,7 +232,7 @@ pub async fn create_container(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let config = Config {
|
let config = Config {
|
||||||
image: Some(image),
|
image: Some(image_name.to_string()),
|
||||||
hostname: Some("triple-c".to_string()),
|
hostname: Some("triple-c".to_string()),
|
||||||
env: Some(env_vars),
|
env: Some(env_vars),
|
||||||
labels: Some(labels),
|
labels: Some(labels),
|
||||||
@@ -257,11 +299,17 @@ pub async fn get_container_info(project: &Project) -> Result<Option<ContainerInf
|
|||||||
.map(|s| format!("{:?}", s))
|
.map(|s| format!("{:?}", s))
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
// Read actual image from Docker inspect
|
||||||
|
let image = info
|
||||||
|
.config
|
||||||
|
.and_then(|c| c.image)
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
Ok(Some(ContainerInfo {
|
Ok(Some(ContainerInfo {
|
||||||
container_id: container_id.clone(),
|
container_id: container_id.clone(),
|
||||||
project_id: project.id.clone(),
|
project_id: project.id.clone(),
|
||||||
status,
|
status,
|
||||||
image: container_config::full_image_name(),
|
image,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
Err(_) => Ok(None),
|
Err(_) => Ok(None),
|
||||||
@@ -282,7 +330,6 @@ pub async fn list_sibling_containers() -> Result<Vec<ContainerSummary>, String>
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to list containers: {}", e))?;
|
.map_err(|e| format!("Failed to list containers: {}", e))?;
|
||||||
|
|
||||||
// Filter out Triple-C managed containers
|
|
||||||
let siblings: Vec<ContainerSummary> = all_containers
|
let siblings: Vec<ContainerSummary> = all_containers
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|c| {
|
.filter(|c| {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use bollard::image::{BuildImageOptions, ListImagesOptions};
|
use bollard::image::{BuildImageOptions, CreateImageOptions, ListImagesOptions};
|
||||||
use bollard::models::ImageSummary;
|
use bollard::models::ImageSummary;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -10,13 +10,12 @@ use crate::models::container_config;
|
|||||||
const DOCKERFILE: &str = include_str!("../../../../container/Dockerfile");
|
const DOCKERFILE: &str = include_str!("../../../../container/Dockerfile");
|
||||||
const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh");
|
const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh");
|
||||||
|
|
||||||
pub async fn image_exists() -> Result<bool, String> {
|
pub async fn image_exists(image_name: &str) -> Result<bool, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let full_name = container_config::full_image_name();
|
|
||||||
|
|
||||||
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||||
"reference".to_string(),
|
"reference".to_string(),
|
||||||
vec![full_name],
|
vec![image_name.to_string()],
|
||||||
)]);
|
)]);
|
||||||
|
|
||||||
let images: Vec<ImageSummary> = docker
|
let images: Vec<ImageSummary> = docker
|
||||||
@@ -30,14 +29,65 @@ pub async fn image_exists() -> Result<bool, String> {
|
|||||||
Ok(!images.is_empty())
|
Ok(!images.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn pull_image<F>(image_name: &str, on_progress: F) -> Result<(), String>
|
||||||
|
where
|
||||||
|
F: Fn(String) + Send + 'static,
|
||||||
|
{
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
// Parse image name into from_image and tag
|
||||||
|
let (from_image, tag) = if let Some(pos) = image_name.rfind(':') {
|
||||||
|
// Check that the colon is part of a tag, not a port
|
||||||
|
let after_colon = &image_name[pos + 1..];
|
||||||
|
if after_colon.contains('/') {
|
||||||
|
// The colon is part of a port (e.g., host:port/repo)
|
||||||
|
(image_name, "latest")
|
||||||
|
} else {
|
||||||
|
(&image_name[..pos], after_colon)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(image_name, "latest")
|
||||||
|
};
|
||||||
|
|
||||||
|
let options = CreateImageOptions {
|
||||||
|
from_image,
|
||||||
|
tag,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stream = docker.create_image(Some(options), None, None);
|
||||||
|
|
||||||
|
while let Some(result) = stream.next().await {
|
||||||
|
match result {
|
||||||
|
Ok(info) => {
|
||||||
|
let mut msg_parts = Vec::new();
|
||||||
|
if let Some(ref status) = info.status {
|
||||||
|
msg_parts.push(status.clone());
|
||||||
|
}
|
||||||
|
if let Some(ref progress) = info.progress {
|
||||||
|
msg_parts.push(progress.clone());
|
||||||
|
}
|
||||||
|
if !msg_parts.is_empty() {
|
||||||
|
on_progress(msg_parts.join(" "));
|
||||||
|
}
|
||||||
|
if let Some(ref error) = info.error {
|
||||||
|
return Err(format!("Pull error: {}", error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => return Err(format!("Pull stream error: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn build_image<F>(on_progress: F) -> Result<(), String>
|
pub async fn build_image<F>(on_progress: F) -> Result<(), String>
|
||||||
where
|
where
|
||||||
F: Fn(String) + Send + 'static,
|
F: Fn(String) + Send + 'static,
|
||||||
{
|
{
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let full_name = container_config::full_image_name();
|
let full_name = container_config::local_build_image_name();
|
||||||
|
|
||||||
// Create a tar archive in memory containing Dockerfile and entrypoint.sh
|
|
||||||
let tar_bytes = create_build_context().map_err(|e| format!("Failed to create build context: {}", e))?;
|
let tar_bytes = create_build_context().map_err(|e| format!("Failed to create build context: {}", e))?;
|
||||||
|
|
||||||
let options = BuildImageOptions {
|
let options = BuildImageOptions {
|
||||||
@@ -71,7 +121,6 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
|||||||
{
|
{
|
||||||
let mut archive = tar::Builder::new(&mut buf);
|
let mut archive = tar::Builder::new(&mut buf);
|
||||||
|
|
||||||
// Add Dockerfile
|
|
||||||
let dockerfile_bytes = DOCKERFILE.as_bytes();
|
let dockerfile_bytes = DOCKERFILE.as_bytes();
|
||||||
let mut header = tar::Header::new_gnu();
|
let mut header = tar::Header::new_gnu();
|
||||||
header.set_size(dockerfile_bytes.len() as u64);
|
header.set_size(dockerfile_bytes.len() as u64);
|
||||||
@@ -79,7 +128,6 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
|||||||
header.set_cksum();
|
header.set_cksum();
|
||||||
archive.append_data(&mut header, "Dockerfile", dockerfile_bytes)?;
|
archive.append_data(&mut header, "Dockerfile", dockerfile_bytes)?;
|
||||||
|
|
||||||
// Add entrypoint.sh
|
|
||||||
let entrypoint_bytes = ENTRYPOINT.as_bytes();
|
let entrypoint_bytes = ENTRYPOINT.as_bytes();
|
||||||
let mut header = tar::Header::new_gnu();
|
let mut header = tar::Header::new_gnu();
|
||||||
header.set_size(entrypoint_bytes.len() as u64);
|
header.set_size(entrypoint_bytes.len() as u64);
|
||||||
@@ -90,7 +138,6 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
|||||||
archive.finish()?;
|
archive.finish()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush to make sure all data is written
|
|
||||||
let _ = buf.flush();
|
let _ = buf.flush();
|
||||||
Ok(buf)
|
Ok(buf)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ mod storage;
|
|||||||
|
|
||||||
use docker::exec::ExecSessionManager;
|
use docker::exec::ExecSessionManager;
|
||||||
use storage::projects_store::ProjectsStore;
|
use storage::projects_store::ProjectsStore;
|
||||||
|
use storage::settings_store::SettingsStore;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub projects_store: ProjectsStore,
|
pub projects_store: ProjectsStore,
|
||||||
|
pub settings_store: SettingsStore,
|
||||||
pub exec_manager: ExecSessionManager,
|
pub exec_manager: ExecSessionManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
projects_store: ProjectsStore::new(),
|
projects_store: ProjectsStore::new(),
|
||||||
|
settings_store: SettingsStore::new(),
|
||||||
exec_manager: ExecSessionManager::new(),
|
exec_manager: ExecSessionManager::new(),
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@@ -41,6 +44,11 @@ pub fn run() {
|
|||||||
commands::settings_commands::set_api_key,
|
commands::settings_commands::set_api_key,
|
||||||
commands::settings_commands::has_api_key,
|
commands::settings_commands::has_api_key,
|
||||||
commands::settings_commands::delete_api_key,
|
commands::settings_commands::delete_api_key,
|
||||||
|
commands::settings_commands::get_settings,
|
||||||
|
commands::settings_commands::update_settings,
|
||||||
|
commands::settings_commands::pull_image,
|
||||||
|
commands::settings_commands::detect_aws_config,
|
||||||
|
commands::settings_commands::list_aws_profiles,
|
||||||
// Terminal
|
// Terminal
|
||||||
commands::terminal_commands::open_terminal_session,
|
commands::terminal_commands::open_terminal_session,
|
||||||
commands::terminal_commands::terminal_input,
|
commands::terminal_commands::terminal_input,
|
||||||
|
|||||||
@@ -1,11 +1,55 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ImageSource {
|
||||||
|
Registry,
|
||||||
|
LocalBuild,
|
||||||
|
Custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ImageSource {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Registry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GlobalAwsSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub aws_config_path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub aws_profile: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub aws_region: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GlobalAwsSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
aws_config_path: None,
|
||||||
|
aws_profile: None,
|
||||||
|
aws_region: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AppSettings {
|
pub struct AppSettings {
|
||||||
|
#[serde(default)]
|
||||||
pub default_ssh_key_path: Option<String>,
|
pub default_ssh_key_path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub default_git_user_name: Option<String>,
|
pub default_git_user_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub default_git_user_email: Option<String>,
|
pub default_git_user_email: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub docker_socket_path: Option<String>,
|
pub docker_socket_path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub image_source: ImageSource,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_image_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub global_aws: GlobalAwsSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppSettings {
|
impl Default for AppSettings {
|
||||||
@@ -15,6 +59,9 @@ impl Default for AppSettings {
|
|||||||
default_git_user_name: None,
|
default_git_user_name: None,
|
||||||
default_git_user_email: None,
|
default_git_user_email: None,
|
||||||
docker_socket_path: None,
|
docker_socket_path: None,
|
||||||
|
image_source: ImageSource::default(),
|
||||||
|
custom_image_name: None,
|
||||||
|
global_aws: GlobalAwsSettings::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::app_settings::ImageSource;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ContainerInfo {
|
pub struct ContainerInfo {
|
||||||
pub container_id: String,
|
pub container_id: String,
|
||||||
@@ -8,9 +10,22 @@ pub struct ContainerInfo {
|
|||||||
pub image: String,
|
pub image: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const IMAGE_NAME: &str = "triple-c";
|
pub const LOCAL_IMAGE_NAME: &str = "triple-c";
|
||||||
pub const IMAGE_TAG: &str = "latest";
|
pub const IMAGE_TAG: &str = "latest";
|
||||||
|
pub const REGISTRY_IMAGE: &str = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest";
|
||||||
|
|
||||||
pub fn full_image_name() -> String {
|
pub fn local_build_image_name() -> String {
|
||||||
format!("{IMAGE_NAME}:{IMAGE_TAG}")
|
format!("{LOCAL_IMAGE_NAME}:{IMAGE_TAG}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_image_name(source: &ImageSource, custom: &Option<String>) -> String {
|
||||||
|
match source {
|
||||||
|
ImageSource::Registry => REGISTRY_IMAGE.to_string(),
|
||||||
|
ImageSource::LocalBuild => local_build_image_name(),
|
||||||
|
ImageSource::Custom => custom
|
||||||
|
.as_deref()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or(REGISTRY_IMAGE)
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod projects_store;
|
pub mod projects_store;
|
||||||
pub mod secure;
|
pub mod secure;
|
||||||
|
pub mod settings_store;
|
||||||
|
|
||||||
pub use projects_store::*;
|
pub use projects_store::*;
|
||||||
pub use secure::*;
|
pub use secure::*;
|
||||||
|
pub use settings_store::*;
|
||||||
|
|||||||
76
app/src-tauri/src/storage/settings_store.rs
Normal file
76
app/src-tauri/src/storage/settings_store.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::models::AppSettings;
|
||||||
|
|
||||||
|
pub struct SettingsStore {
|
||||||
|
settings: Mutex<AppSettings>,
|
||||||
|
file_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let data_dir = dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("triple-c");
|
||||||
|
|
||||||
|
fs::create_dir_all(&data_dir).ok();
|
||||||
|
|
||||||
|
let file_path = data_dir.join("settings.json");
|
||||||
|
|
||||||
|
let settings = if file_path.exists() {
|
||||||
|
match fs::read_to_string(&file_path) {
|
||||||
|
Ok(data) => match serde_json::from_str(&data) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to parse settings.json: {}. Using defaults.", e);
|
||||||
|
let backup = file_path.with_extension("json.bak");
|
||||||
|
if let Err(be) = fs::copy(&file_path, &backup) {
|
||||||
|
log::error!("Failed to back up corrupted settings.json: {}", be);
|
||||||
|
}
|
||||||
|
AppSettings::default()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to read settings.json: {}", e);
|
||||||
|
AppSettings::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AppSettings::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
settings: Mutex::new(settings),
|
||||||
|
file_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock(&self) -> std::sync::MutexGuard<'_, AppSettings> {
|
||||||
|
self.settings.lock().unwrap_or_else(|e| e.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(&self, settings: &AppSettings) -> Result<(), String> {
|
||||||
|
let data = serde_json::to_string_pretty(settings)
|
||||||
|
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
|
||||||
|
|
||||||
|
let tmp_path = self.file_path.with_extension("json.tmp");
|
||||||
|
fs::write(&tmp_path, data)
|
||||||
|
.map_err(|e| format!("Failed to write temp settings file: {}", e))?;
|
||||||
|
fs::rename(&tmp_path, &self.file_path)
|
||||||
|
.map_err(|e| format!("Failed to rename settings file: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self) -> AppSettings {
|
||||||
|
self.lock().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&self, new_settings: AppSettings) -> Result<AppSettings, String> {
|
||||||
|
let mut settings = self.lock();
|
||||||
|
*settings = new_settings.clone();
|
||||||
|
self.save(&settings)?;
|
||||||
|
Ok(new_settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,12 +10,13 @@ import { useAppState } from "./store/appState";
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { checkDocker, checkImage } = useDocker();
|
const { checkDocker, checkImage } = useDocker();
|
||||||
const { checkApiKey } = useSettings();
|
const { checkApiKey, loadSettings } = useSettings();
|
||||||
const { refresh } = useProjects();
|
const { refresh } = useProjects();
|
||||||
const { sessions, activeSessionId } = useAppState();
|
const { sessions, activeSessionId } = useAppState();
|
||||||
|
|
||||||
// Initialize on mount
|
// Initialize on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
loadSettings();
|
||||||
checkDocker();
|
checkDocker();
|
||||||
checkImage();
|
checkImage();
|
||||||
checkApiKey();
|
checkApiKey();
|
||||||
|
|||||||
109
app/src/components/settings/AwsSettings.tsx
Normal file
109
app/src/components/settings/AwsSettings.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
import * as commands from "../../lib/tauri-commands";
|
||||||
|
|
||||||
|
export default function AwsSettings() {
|
||||||
|
const { appSettings, saveSettings } = useSettings();
|
||||||
|
const [profiles, setProfiles] = useState<string[]>([]);
|
||||||
|
const [detecting, setDetecting] = useState(false);
|
||||||
|
|
||||||
|
const globalAws = appSettings?.global_aws ?? {
|
||||||
|
aws_config_path: null,
|
||||||
|
aws_profile: null,
|
||||||
|
aws_region: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load profiles when component mounts or aws_config_path changes
|
||||||
|
useEffect(() => {
|
||||||
|
commands.listAwsProfiles().then(setProfiles).catch(() => setProfiles([]));
|
||||||
|
}, [globalAws.aws_config_path]);
|
||||||
|
|
||||||
|
const handleDetect = async () => {
|
||||||
|
setDetecting(true);
|
||||||
|
try {
|
||||||
|
const path = await commands.detectAwsConfig();
|
||||||
|
if (path && appSettings) {
|
||||||
|
const updated = {
|
||||||
|
...appSettings,
|
||||||
|
global_aws: { ...globalAws, aws_config_path: path },
|
||||||
|
};
|
||||||
|
await saveSettings(updated);
|
||||||
|
// Refresh profiles after detection
|
||||||
|
const p = await commands.listAwsProfiles();
|
||||||
|
setProfiles(p);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setDetecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = async (field: string, value: string | null) => {
|
||||||
|
if (!appSettings) return;
|
||||||
|
await saveSettings({
|
||||||
|
...appSettings,
|
||||||
|
global_aws: { ...globalAws, [field]: value || null },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">AWS Configuration</label>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Global AWS defaults for Bedrock projects. Per-project settings override these.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* AWS Config Path */}
|
||||||
|
<div>
|
||||||
|
<span className="text-[var(--text-secondary)] text-xs block mb-1">AWS Config Path</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={globalAws.aws_config_path ?? ""}
|
||||||
|
onChange={(e) => handleChange("aws_config_path", e.target.value)}
|
||||||
|
placeholder="~/.aws"
|
||||||
|
className="flex-1 px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleDetect}
|
||||||
|
disabled={detecting}
|
||||||
|
className="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||||
|
>
|
||||||
|
{detecting ? "..." : "Detect"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{globalAws.aws_config_path && (
|
||||||
|
<span className="text-xs text-[var(--success)] mt-0.5 block">Found</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AWS Profile */}
|
||||||
|
<div>
|
||||||
|
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Profile</span>
|
||||||
|
<select
|
||||||
|
value={globalAws.aws_profile ?? ""}
|
||||||
|
onChange={(e) => handleChange("aws_profile", e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||||
|
>
|
||||||
|
<option value="">None (use default)</option>
|
||||||
|
{profiles.map((p) => (
|
||||||
|
<option key={p} value={p}>{p}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AWS Region */}
|
||||||
|
<div>
|
||||||
|
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Region</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={globalAws.aws_region ?? ""}
|
||||||
|
onChange={(e) => handleChange("aws_region", e.target.value)}
|
||||||
|
placeholder="e.g., us-east-1"
|
||||||
|
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,32 +1,84 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useDocker } from "../../hooks/useDocker";
|
import { useDocker } from "../../hooks/useDocker";
|
||||||
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
import type { ImageSource } from "../../lib/types";
|
||||||
|
|
||||||
|
const REGISTRY_IMAGE = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest";
|
||||||
|
|
||||||
|
const IMAGE_SOURCE_OPTIONS: { value: ImageSource; label: string; description: string }[] = [
|
||||||
|
{ value: "registry", label: "Registry", description: "Pull from container registry" },
|
||||||
|
{ value: "local_build", label: "Local Build", description: "Build from embedded Dockerfile" },
|
||||||
|
{ value: "custom", label: "Custom", description: "Specify a custom image" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function DockerSettings() {
|
export default function DockerSettings() {
|
||||||
const { dockerAvailable, imageExists, checkDocker, checkImage, buildImage } =
|
const { dockerAvailable, imageExists, checkDocker, checkImage, buildImage, pullImage } =
|
||||||
useDocker();
|
useDocker();
|
||||||
const [building, setBuilding] = useState(false);
|
const { appSettings, saveSettings } = useSettings();
|
||||||
const [buildLog, setBuildLog] = useState<string[]>([]);
|
const [working, setWorking] = useState(false);
|
||||||
|
const [log, setLog] = useState<string[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [customInput, setCustomInput] = useState(appSettings?.custom_image_name ?? "");
|
||||||
|
|
||||||
const handleBuild = async () => {
|
const imageSource = appSettings?.image_source ?? "registry";
|
||||||
setBuilding(true);
|
|
||||||
setBuildLog([]);
|
const resolvedImageName = (() => {
|
||||||
|
switch (imageSource) {
|
||||||
|
case "registry": return REGISTRY_IMAGE;
|
||||||
|
case "local_build": return "triple-c:latest";
|
||||||
|
case "custom": return customInput || REGISTRY_IMAGE;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const handleSourceChange = async (source: ImageSource) => {
|
||||||
|
if (!appSettings) return;
|
||||||
|
await saveSettings({ ...appSettings, image_source: source });
|
||||||
|
// Re-check image existence after changing source
|
||||||
|
setTimeout(() => checkImage(), 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomChange = async (value: string) => {
|
||||||
|
setCustomInput(value);
|
||||||
|
if (!appSettings) return;
|
||||||
|
await saveSettings({ ...appSettings, custom_image_name: value || null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePull = async () => {
|
||||||
|
setWorking(true);
|
||||||
|
setLog([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await buildImage((msg) => {
|
await pullImage(resolvedImageName, (msg) => {
|
||||||
setBuildLog((prev) => [...prev, msg]);
|
setLog((prev) => [...prev, msg]);
|
||||||
});
|
});
|
||||||
|
await checkImage();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
} finally {
|
} finally {
|
||||||
setBuilding(false);
|
setWorking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBuild = async () => {
|
||||||
|
setWorking(true);
|
||||||
|
setLog([]);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await buildImage((msg) => {
|
||||||
|
setLog((prev) => [...prev, msg]);
|
||||||
|
});
|
||||||
|
await checkImage();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setWorking(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">Docker</label>
|
<label className="block text-sm font-medium mb-2">Docker</label>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[var(--text-secondary)]">Docker Status</span>
|
<span className="text-[var(--text-secondary)]">Docker Status</span>
|
||||||
<span className={dockerAvailable ? "text-[var(--success)]" : "text-[var(--error)]"}>
|
<span className={dockerAvailable ? "text-[var(--success)]" : "text-[var(--error)]"}>
|
||||||
@@ -34,32 +86,88 @@ export default function DockerSettings() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Image Source Selector */}
|
||||||
|
<div>
|
||||||
|
<span className="text-[var(--text-secondary)] text-xs block mb-1.5">Image Source</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{IMAGE_SOURCE_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => handleSourceChange(opt.value)}
|
||||||
|
className={`flex-1 px-2 py-1.5 text-xs rounded border transition-colors ${
|
||||||
|
imageSource === opt.value
|
||||||
|
? "bg-[var(--accent)] text-white border-[var(--accent)]"
|
||||||
|
: "bg-[var(--bg-tertiary)] border-[var(--border-color)] hover:bg-[var(--border-color)]"
|
||||||
|
}`}
|
||||||
|
title={opt.description}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom image input */}
|
||||||
|
{imageSource === "custom" && (
|
||||||
|
<div>
|
||||||
|
<span className="text-[var(--text-secondary)] text-xs block mb-1">Custom Image</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customInput}
|
||||||
|
onChange={(e) => handleCustomChange(e.target.value)}
|
||||||
|
placeholder="e.g., myregistry.com/image:tag"
|
||||||
|
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resolved image display */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[var(--text-secondary)]">Image</span>
|
<span className="text-[var(--text-secondary)]">Image</span>
|
||||||
<span className={imageExists ? "text-[var(--success)]" : "text-[var(--text-secondary)]"}>
|
<span className="text-xs text-[var(--text-secondary)] truncate max-w-[200px]" title={resolvedImageName}>
|
||||||
{imageExists === null ? "Checking..." : imageExists ? "Built" : "Not Built"}
|
{resolvedImageName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[var(--text-secondary)]">Status</span>
|
||||||
|
<span className={imageExists ? "text-[var(--success)]" : "text-[var(--text-secondary)]"}>
|
||||||
|
{imageExists === null ? "Checking..." : imageExists ? "Ready" : "Not Found"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={async () => { await checkDocker(); await checkImage(); }}
|
onClick={async () => { await checkDocker(); await checkImage(); }}
|
||||||
className="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
className="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||||
>
|
>
|
||||||
Refresh Status
|
Refresh
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleBuild}
|
|
||||||
disabled={building || !dockerAvailable}
|
|
||||||
className="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{building ? "Building..." : imageExists ? "Rebuild Image" : "Build Image"}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{imageSource === "local_build" ? (
|
||||||
|
<button
|
||||||
|
onClick={handleBuild}
|
||||||
|
disabled={working || !dockerAvailable}
|
||||||
|
className="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{working ? "Building..." : imageExists ? "Rebuild Image" : "Build Image"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handlePull}
|
||||||
|
disabled={working || !dockerAvailable}
|
||||||
|
className="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{working ? "Pulling..." : imageExists ? "Re-pull Image" : "Pull Image"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{buildLog.length > 0 && (
|
{/* Log output */}
|
||||||
|
{log.length > 0 && (
|
||||||
<div className="max-h-40 overflow-y-auto bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2 text-xs font-mono text-[var(--text-secondary)]">
|
<div className="max-h-40 overflow-y-auto bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2 text-xs font-mono text-[var(--text-secondary)]">
|
||||||
{buildLog.map((line, i) => (
|
{log.map((line, i) => (
|
||||||
<div key={i}>{line}</div>
|
<div key={i}>{line}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import ApiKeyInput from "./ApiKeyInput";
|
import ApiKeyInput from "./ApiKeyInput";
|
||||||
import DockerSettings from "./DockerSettings";
|
import DockerSettings from "./DockerSettings";
|
||||||
|
import AwsSettings from "./AwsSettings";
|
||||||
|
|
||||||
export default function SettingsPanel() {
|
export default function SettingsPanel() {
|
||||||
return (
|
return (
|
||||||
@@ -9,6 +10,7 @@ export default function SettingsPanel() {
|
|||||||
</h2>
|
</h2>
|
||||||
<ApiKeyInput />
|
<ApiKeyInput />
|
||||||
<DockerSettings />
|
<DockerSettings />
|
||||||
|
<AwsSettings />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,11 +51,30 @@ export function useDocker() {
|
|||||||
[setImageExists],
|
[setImageExists],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pullImage = useCallback(
|
||||||
|
async (imageName: string, onProgress?: (msg: string) => void) => {
|
||||||
|
const unlisten = onProgress
|
||||||
|
? await listen<string>("image-pull-progress", (event) => {
|
||||||
|
onProgress(event.payload);
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await commands.pullImage(imageName);
|
||||||
|
setImageExists(true);
|
||||||
|
} finally {
|
||||||
|
unlisten?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setImageExists],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dockerAvailable,
|
dockerAvailable,
|
||||||
imageExists,
|
imageExists,
|
||||||
checkDocker,
|
checkDocker,
|
||||||
checkImage,
|
checkImage,
|
||||||
buildImage,
|
buildImage,
|
||||||
|
pullImage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useAppState } from "../store/appState";
|
import { useAppState } from "../store/appState";
|
||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
import type { AppSettings } from "../lib/types";
|
||||||
|
|
||||||
export function useSettings() {
|
export function useSettings() {
|
||||||
const { hasKey, setHasKey } = useAppState();
|
const { hasKey, setHasKey, appSettings, setAppSettings } = useAppState();
|
||||||
|
|
||||||
const checkApiKey = useCallback(async () => {
|
const checkApiKey = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -29,10 +30,33 @@ export function useSettings() {
|
|||||||
setHasKey(false);
|
setHasKey(false);
|
||||||
}, [setHasKey]);
|
}, [setHasKey]);
|
||||||
|
|
||||||
|
const loadSettings = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const settings = await commands.getSettings();
|
||||||
|
setAppSettings(settings);
|
||||||
|
return settings;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load settings:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [setAppSettings]);
|
||||||
|
|
||||||
|
const saveSettings = useCallback(
|
||||||
|
async (settings: AppSettings) => {
|
||||||
|
const updated = await commands.updateSettings(settings);
|
||||||
|
setAppSettings(updated);
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
[setAppSettings],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasKey,
|
hasKey,
|
||||||
checkApiKey,
|
checkApiKey,
|
||||||
saveApiKey,
|
saveApiKey,
|
||||||
removeApiKey,
|
removeApiKey,
|
||||||
|
appSettings,
|
||||||
|
loadSettings,
|
||||||
|
saveSettings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export const APP_NAME = "Triple-C";
|
export const APP_NAME = "Triple-C";
|
||||||
export const IMAGE_NAME = "triple-c:latest";
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { Project, ContainerInfo, SiblingContainer } from "./types";
|
import type { Project, ContainerInfo, SiblingContainer, AppSettings } from "./types";
|
||||||
|
|
||||||
// Docker
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -30,6 +30,15 @@ export const setApiKey = (key: string) =>
|
|||||||
invoke<void>("set_api_key", { key });
|
invoke<void>("set_api_key", { key });
|
||||||
export const hasApiKey = () => invoke<boolean>("has_api_key");
|
export const hasApiKey = () => invoke<boolean>("has_api_key");
|
||||||
export const deleteApiKey = () => invoke<void>("delete_api_key");
|
export const deleteApiKey = () => invoke<void>("delete_api_key");
|
||||||
|
export const getSettings = () => invoke<AppSettings>("get_settings");
|
||||||
|
export const updateSettings = (settings: AppSettings) =>
|
||||||
|
invoke<AppSettings>("update_settings", { settings });
|
||||||
|
export const pullImage = (imageName: string) =>
|
||||||
|
invoke<void>("pull_image", { imageName });
|
||||||
|
export const detectAwsConfig = () =>
|
||||||
|
invoke<string | null>("detect_aws_config");
|
||||||
|
export const listAwsProfiles = () =>
|
||||||
|
invoke<string[]>("list_aws_profiles");
|
||||||
|
|
||||||
// Terminal
|
// Terminal
|
||||||
export const openTerminalSession = (projectId: string, sessionId: string) =>
|
export const openTerminalSession = (projectId: string, sessionId: string) =>
|
||||||
|
|||||||
@@ -58,3 +58,21 @@ export interface TerminalSession {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImageSource = "registry" | "local_build" | "custom";
|
||||||
|
|
||||||
|
export interface GlobalAwsSettings {
|
||||||
|
aws_config_path: string | null;
|
||||||
|
aws_profile: string | null;
|
||||||
|
aws_region: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
default_ssh_key_path: string | null;
|
||||||
|
default_git_user_name: string | null;
|
||||||
|
default_git_user_email: string | null;
|
||||||
|
docker_socket_path: string | null;
|
||||||
|
image_source: ImageSource;
|
||||||
|
custom_image_name: string | null;
|
||||||
|
global_aws: GlobalAwsSettings;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { Project, TerminalSession } from "../lib/types";
|
import type { Project, TerminalSession, AppSettings } from "../lib/types";
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
// Projects
|
// Projects
|
||||||
@@ -26,6 +26,10 @@ interface AppState {
|
|||||||
setImageExists: (exists: boolean | null) => void;
|
setImageExists: (exists: boolean | null) => void;
|
||||||
hasKey: boolean | null;
|
hasKey: boolean | null;
|
||||||
setHasKey: (has: boolean | null) => void;
|
setHasKey: (has: boolean | null) => void;
|
||||||
|
|
||||||
|
// App settings
|
||||||
|
appSettings: AppSettings | null;
|
||||||
|
setAppSettings: (settings: AppSettings) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAppState = create<AppState>((set) => ({
|
export const useAppState = create<AppState>((set) => ({
|
||||||
@@ -77,4 +81,8 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
setImageExists: (exists) => set({ imageExists: exists }),
|
setImageExists: (exists) => set({ imageExists: exists }),
|
||||||
hasKey: null,
|
hasKey: null,
|
||||||
setHasKey: (has) => set({ hasKey: has }),
|
setHasKey: (has) => set({ hasKey: has }),
|
||||||
|
|
||||||
|
// App settings
|
||||||
|
appSettings: null,
|
||||||
|
setAppSettings: (settings) => set({ appSettings: settings }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libssl-dev \
|
libssl-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Remove default ubuntu user to free UID 1000 for host-user remapping
|
||||||
|
RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu 2>/dev/null || userdel ubuntu; fi \
|
||||||
|
&& if getent group ubuntu >/dev/null 2>&1; then groupdel ubuntu 2>/dev/null || true; fi
|
||||||
|
|
||||||
# Set UTF-8 locale
|
# Set UTF-8 locale
|
||||||
RUN locale-gen en_US.UTF-8
|
RUN locale-gen en_US.UTF-8
|
||||||
ENV LANG=en_US.UTF-8
|
ENV LANG=en_US.UTF-8
|
||||||
@@ -65,7 +69,7 @@ RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2
|
|||||||
&& unzip awscliv2.zip && ./aws/install && rm -rf awscliv2.zip aws
|
&& unzip awscliv2.zip && ./aws/install && rm -rf awscliv2.zip aws
|
||||||
|
|
||||||
# ── Non-root user with passwordless sudo ─────────────────────────────────────
|
# ── Non-root user with passwordless sudo ─────────────────────────────────────
|
||||||
RUN useradd -m -s /bin/bash claude \
|
RUN useradd -m -s /bin/bash -u 1000 claude \
|
||||||
&& echo "claude ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/claude \
|
&& echo "claude ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/claude \
|
||||||
&& chmod 0440 /etc/sudoers.d/claude
|
&& chmod 0440 /etc/sudoers.d/claude
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,49 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
# NOTE: set -e is intentionally omitted. A failing usermod/groupmod must not
|
||||||
|
# kill the entire entrypoint — SSH setup, git config, and the final exec
|
||||||
|
# must still run so the container is usable even if remapping fails.
|
||||||
|
|
||||||
# ── UID/GID remapping ──────────────────────────────────────────────────────
|
# ── UID/GID remapping ──────────────────────────────────────────────────────
|
||||||
# Match the container's claude user to the host user's UID/GID so that
|
# Match the container's claude user to the host user's UID/GID so that
|
||||||
# bind-mounted files (project dir, docker socket) have correct ownership.
|
# bind-mounted files (project dir, docker socket) have correct ownership.
|
||||||
if [ -n "$HOST_UID" ] && [ "$HOST_UID" != "$(id -u claude)" ]; then
|
remap_uid_gid() {
|
||||||
usermod -u "$HOST_UID" claude
|
local target_uid="${HOST_UID}"
|
||||||
fi
|
local target_gid="${HOST_GID}"
|
||||||
if [ -n "$HOST_GID" ] && [ "$HOST_GID" != "$(id -g claude)" ]; then
|
local current_uid
|
||||||
groupmod -g "$HOST_GID" claude
|
local current_gid
|
||||||
fi
|
current_uid=$(id -u claude 2>/dev/null) || { echo "entrypoint: claude user not found"; return 1; }
|
||||||
|
current_gid=$(id -g claude 2>/dev/null) || { echo "entrypoint: claude group not found"; return 1; }
|
||||||
|
|
||||||
|
# ── GID remapping ──
|
||||||
|
if [ -n "$target_gid" ] && [ "$target_gid" != "$current_gid" ]; then
|
||||||
|
# If another group already holds the target GID, move it out of the way
|
||||||
|
local blocking_group
|
||||||
|
blocking_group=$(getent group "$target_gid" 2>/dev/null | cut -d: -f1)
|
||||||
|
if [ -n "$blocking_group" ] && [ "$blocking_group" != "claude" ]; then
|
||||||
|
echo "entrypoint: moving group '$blocking_group' from GID $target_gid to 65533"
|
||||||
|
groupmod -g 65533 "$blocking_group" || echo "entrypoint: warning — failed to relocate group '$blocking_group'"
|
||||||
|
fi
|
||||||
|
groupmod -g "$target_gid" claude \
|
||||||
|
&& echo "entrypoint: claude GID -> $target_gid" \
|
||||||
|
|| echo "entrypoint: warning — groupmod -g $target_gid claude failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── UID remapping ──
|
||||||
|
if [ -n "$target_uid" ] && [ "$target_uid" != "$current_uid" ]; then
|
||||||
|
# If another user already holds the target UID, move it out of the way
|
||||||
|
local blocking_user
|
||||||
|
blocking_user=$(getent passwd "$target_uid" 2>/dev/null | cut -d: -f1)
|
||||||
|
if [ -n "$blocking_user" ] && [ "$blocking_user" != "claude" ]; then
|
||||||
|
echo "entrypoint: moving user '$blocking_user' from UID $target_uid to 65533"
|
||||||
|
usermod -u 65533 "$blocking_user" || echo "entrypoint: warning — failed to relocate user '$blocking_user'"
|
||||||
|
fi
|
||||||
|
usermod -u "$target_uid" claude \
|
||||||
|
&& echo "entrypoint: claude UID -> $target_uid" \
|
||||||
|
|| echo "entrypoint: warning — usermod -u $target_uid claude failed"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
remap_uid_gid
|
||||||
|
|
||||||
# Fix ownership of home directory after UID/GID change
|
# Fix ownership of home directory after UID/GID change
|
||||||
chown -R claude:claude /home/claude
|
chown -R claude:claude /home/claude
|
||||||
|
|||||||
Reference in New Issue
Block a user