Compare commits
4 Commits
build-9454
...
build-win-
| Author | SHA1 | Date | |
|---|---|---|---|
| df3d434877 | |||
| 60842befde | |||
| 1a78378ed7 | |||
| 0d4ed86f53 |
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(.:*)"
|
"Bash(.:*)",
|
||||||
|
"Bash(git:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,9 +102,33 @@ pub async fn start_project_container(
|
|||||||
|
|
||||||
// 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? {
|
||||||
// Start existing container
|
// Compare the running container's configuration (mounts, env vars)
|
||||||
|
// against the current project settings. If anything changed (SSH key
|
||||||
|
// path, git config, docker socket, etc.) we recreate the container.
|
||||||
|
// Safe to recreate: the claude config named volume is keyed by
|
||||||
|
// project ID (not container ID) so it persists across recreation.
|
||||||
|
let needs_recreation = docker::container_needs_recreation(&existing_id, &project)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
if needs_recreation {
|
||||||
|
log::info!("Container config changed, recreating container for project {}", project.id);
|
||||||
|
let _ = docker::stop_container(&existing_id).await;
|
||||||
|
docker::remove_container(&existing_id).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?;
|
||||||
|
new_id
|
||||||
|
} else {
|
||||||
|
// Start existing container as-is
|
||||||
docker::start_container(&existing_id).await?;
|
docker::start_container(&existing_id).await?;
|
||||||
existing_id
|
existing_id
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Create new container
|
// Create new container
|
||||||
let new_id = docker::create_container(
|
let new_id = docker::create_container(
|
||||||
|
|||||||
@@ -288,6 +288,84 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
|||||||
.map_err(|e| format!("Failed to remove container: {}", e))
|
.map_err(|e| format!("Failed to remove container: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check whether the existing container's configuration still matches the
|
||||||
|
/// current project settings. Returns `true` when the container must be
|
||||||
|
/// recreated (mounts or env vars differ).
|
||||||
|
pub async fn container_needs_recreation(container_id: &str, project: &Project) -> Result<bool, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
let info = docker
|
||||||
|
.inspect_container(container_id, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to inspect container: {}", e))?;
|
||||||
|
|
||||||
|
let mounts = info
|
||||||
|
.host_config
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|hc| hc.mounts.as_ref());
|
||||||
|
|
||||||
|
// ── Docker socket mount ──────────────────────────────────────────────
|
||||||
|
let has_socket = mounts
|
||||||
|
.map(|m| {
|
||||||
|
m.iter()
|
||||||
|
.any(|mount| mount.target.as_deref() == Some("/var/run/docker.sock"))
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
if has_socket != project.allow_docker_access {
|
||||||
|
log::info!("Docker socket mismatch (container={}, project={})", has_socket, project.allow_docker_access);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSH key path mount ───────────────────────────────────────────────
|
||||||
|
let ssh_mount_source = mounts
|
||||||
|
.and_then(|m| {
|
||||||
|
m.iter()
|
||||||
|
.find(|mount| mount.target.as_deref() == Some("/tmp/.host-ssh"))
|
||||||
|
})
|
||||||
|
.and_then(|mount| mount.source.as_deref());
|
||||||
|
let project_ssh = project.ssh_key_path.as_deref();
|
||||||
|
if ssh_mount_source != project_ssh {
|
||||||
|
log::info!(
|
||||||
|
"SSH key path mismatch (container={:?}, project={:?})",
|
||||||
|
ssh_mount_source,
|
||||||
|
project_ssh
|
||||||
|
);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Git environment variables ────────────────────────────────────────
|
||||||
|
let env_vars = info
|
||||||
|
.config
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|c| c.env.as_ref());
|
||||||
|
|
||||||
|
let get_env = |name: &str| -> Option<String> {
|
||||||
|
env_vars.and_then(|vars| {
|
||||||
|
vars.iter()
|
||||||
|
.find(|v| v.starts_with(&format!("{}=", name)))
|
||||||
|
.map(|v| v[name.len() + 1..].to_string())
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let container_git_name = get_env("GIT_USER_NAME");
|
||||||
|
let container_git_email = get_env("GIT_USER_EMAIL");
|
||||||
|
let container_git_token = get_env("GIT_TOKEN");
|
||||||
|
|
||||||
|
if container_git_name.as_deref() != project.git_user_name.as_deref() {
|
||||||
|
log::info!("GIT_USER_NAME mismatch (container={:?}, project={:?})", container_git_name, project.git_user_name);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
if container_git_email.as_deref() != project.git_user_email.as_deref() {
|
||||||
|
log::info!("GIT_USER_EMAIL mismatch (container={:?}, project={:?})", container_git_email, project.git_user_email);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
if container_git_token.as_deref() != project.git_token.as_deref() {
|
||||||
|
log::info!("GIT_TOKEN mismatch");
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_container_info(project: &Project) -> Result<Option<ContainerInfo>, String> {
|
pub async fn get_container_info(project: &Project) -> Result<Option<ContainerInfo>, String> {
|
||||||
if let Some(ref container_id) = project.container_id {
|
if let Some(ref container_id) = project.container_id {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function Sidebar() {
|
|||||||
const { sidebarView, setSidebarView } = useAppState();
|
const { sidebarView, setSidebarView } = useAppState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-64 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
<div className="flex flex-col h-full w-[25%] min-w-56 max-w-80 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||||
{/* Nav tabs */}
|
{/* Nav tabs */}
|
||||||
<div className="flex border-b border-[var(--border-color)]">
|
<div className="flex border-b border-[var(--border-color)]">
|
||||||
<button
|
<button
|
||||||
@@ -32,7 +32,7 @@ export default function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto p-1">
|
||||||
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
|
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export default function StatusBar() {
|
|||||||
const running = projects.filter((p) => p.status === "running").length;
|
const running = projects.filter((p) => p.status === "running").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center h-6 px-3 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg text-xs text-[var(--text-secondary)]">
|
<div className="flex items-center h-6 px-4 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg text-xs text-[var(--text-secondary)]">
|
||||||
<span>
|
<span>
|
||||||
{projects.length} project{projects.length !== 1 ? "s" : ""}
|
{projects.length} project{projects.length !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ export default function TopBar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||||
<div className="flex-1 overflow-x-auto">
|
<div className="flex-1 overflow-x-auto pl-2">
|
||||||
<TerminalTabs />
|
<TerminalTabs />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 px-3 text-xs text-[var(--text-secondary)]">
|
<div className="flex items-center gap-2 px-4 flex-shrink-0 text-xs text-[var(--text-secondary)]">
|
||||||
<StatusDot ok={dockerAvailable === true} label="Docker" />
|
<StatusDot ok={dockerAvailable === true} label="Docker" />
|
||||||
<StatusDot ok={imageExists === true} label="Image" />
|
<StatusDot ok={imageExists === true} label="Image" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
{isStopped ? (
|
{isStopped ? (
|
||||||
<>
|
<>
|
||||||
<ActionButton onClick={handleStart} disabled={loading} label="Start" />
|
<ActionButton onClick={handleStart} disabled={loading} label="Start" />
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default function ProjectList() {
|
|||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-2">
|
<div className="p-3">
|
||||||
<div className="flex items-center justify-between px-2 py-1 mb-2">
|
<div className="flex items-center justify-between px-2 py-1 mb-2">
|
||||||
<span className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
|
<span className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
|
||||||
Projects
|
Projects
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export default function AwsSettings() {
|
|||||||
<select
|
<select
|
||||||
value={globalAws.aws_profile ?? ""}
|
value={globalAws.aws_profile ?? ""}
|
||||||
onChange={(e) => handleChange("aws_profile", e.target.value)}
|
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)]"
|
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||||
>
|
>
|
||||||
<option value="">None (use default)</option>
|
<option value="">None (use default)</option>
|
||||||
{profiles.map((p) => (
|
{profiles.map((p) => (
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={`w-full h-full ${active ? "" : "hidden"}`}
|
className={`w-full h-full ${active ? "" : "hidden"}`}
|
||||||
style={{ padding: "4px" }}
|
style={{ padding: "8px" }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,9 @@
|
|||||||
--success: #3fb950;
|
--success: #3fb950;
|
||||||
--warning: #d29922;
|
--warning: #d29922;
|
||||||
--error: #f85149;
|
--error: #f85149;
|
||||||
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body, #root {
|
html, body, #root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user