Skip to content

Commit 8cc48ca

Browse files
msukkariclaude
andcommitted
fix(setupWizard): detect and remove conflicting container names
The setup-sourcebot wizard now detects pre-existing containers whose name conflicts with Sourcebot's (e.g. a leftover `docker run --name sourcebot`) and offers to remove them before starting, avoiding the "container name is already in use" error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 358d481 commit 8cc48ca

1 file changed

Lines changed: 110 additions & 0 deletions

File tree

packages/setupWizard/src/index.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,83 @@ function parseTopLevelVolumes(composeYaml: string): string[] {
110110
return names;
111111
}
112112

113+
// Parses every `container_name:` entry from a docker-compose.yml. These are the
114+
// fixed names Docker assigns the containers, and a pre-existing container with the
115+
// same name (e.g. from an older `docker run --name sourcebot ...`) makes
116+
// `docker compose up` fail with "The container name ... is already in use".
117+
function parseComposeContainerNames(composeYaml: string): string[] {
118+
const names: string[] = [];
119+
for (const rawLine of composeYaml.split('\n')) {
120+
const line = rawLine.replace(/\r$/, '');
121+
const m = line.match(/^\s+container_name:\s*(.+?)\s*$/);
122+
if (m) {
123+
names.push(m[1].replace(/^["']|["']$/g, '').trim());
124+
}
125+
}
126+
return names;
127+
}
128+
129+
// A pre-existing container that would collide with a declared `container_name`.
130+
type ConflictingContainer = { name: string; id: string; project: string };
131+
132+
// Finds existing containers (running or stopped) whose name matches one of the given
133+
// names. Returns the container id and its compose project label (empty if it isn't
134+
// compose-managed) so callers can ignore containers belonging to the current project.
135+
async function findConflictingContainers(names: string[]): Promise<ConflictingContainer[]> {
136+
if (names.length === 0) {
137+
return [];
138+
}
139+
return new Promise<ConflictingContainer[]>((resolve) => {
140+
const child = spawn(
141+
'docker',
142+
['ps', '-a', '--no-trunc', '--format', '{{.Names}}\t{{.ID}}\t{{.Label "com.docker.compose.project"}}'],
143+
{ stdio: ['ignore', 'pipe', 'ignore'] },
144+
);
145+
let out = '';
146+
child.stdout?.on('data', (chunk: Buffer) => {
147+
out += chunk.toString();
148+
});
149+
child.on('exit', (code) => {
150+
if (code !== 0) {
151+
resolve([]);
152+
return;
153+
}
154+
const wanted = new Set(names);
155+
const conflicts: ConflictingContainer[] = [];
156+
for (const line of out.split('\n')) {
157+
const [name, id, project] = line.split('\t');
158+
if (name && id && wanted.has(name)) {
159+
conflicts.push({ name, id, project: (project ?? '').trim() });
160+
}
161+
}
162+
resolve(conflicts);
163+
});
164+
child.on('error', () => resolve([]));
165+
});
166+
}
167+
168+
// Force-removes the given containers (by id or name). Returns true only if all
169+
// removed cleanly.
170+
async function removeDockerContainers(ids: string[]): Promise<boolean> {
171+
if (ids.length === 0) {
172+
return true;
173+
}
174+
return new Promise<boolean>((resolve) => {
175+
const child = spawn('docker', ['rm', '-f', ...ids], { stdio: ['ignore', 'ignore', 'pipe'] });
176+
let err = '';
177+
child.stderr?.on('data', (chunk: Buffer) => {
178+
err += chunk.toString();
179+
});
180+
child.on('exit', (code) => {
181+
if (code !== 0 && err.trim()) {
182+
console.error(chalk.red('✗ ') + err.trim());
183+
}
184+
resolve(code === 0);
185+
});
186+
child.on('error', () => resolve(false));
187+
});
188+
}
189+
113190
// A published port from a compose `ports:` entry, with the host interface Docker
114191
// would bind to. Container-only, range, and env-interpolated specs are skipped.
115192
type PublishedPort = { host: string; port: number };
@@ -746,6 +823,39 @@ async function main() {
746823
}
747824
}
748825

826+
// A container created outside this compose project but sharing a declared
827+
// `container_name` (e.g. a leftover `docker run --name sourcebot ...` from an older
828+
// install) makes `docker compose up` fail with "The container name ... is already in
829+
// use". The compose cleanup above only removes our own project's containers, so check
830+
// for foreign name collisions here and offer to remove them.
831+
if (downloadedCompose && !leftDeploymentRunning) {
832+
const project = dockerComposeProjectName();
833+
const containerNames = parseComposeContainerNames(readFileSync('docker-compose.yml', 'utf-8'));
834+
const conflicts = (await findConflictingContainers(containerNames))
835+
.filter((c) => c.project !== project);
836+
837+
if (conflicts.length > 0) {
838+
console.log();
839+
console.log(chalk.yellow('⚠ ') + 'The following existing container names conflict with Sourcebot and will prevent it from starting:');
840+
for (const c of conflicts) {
841+
console.log(' ' + chalk.dim('- ') + c.name);
842+
}
843+
const remove = await confirm({
844+
message: `Remove ${conflicts.length === 1 ? 'this container' : 'these containers'} so Sourcebot can start?`,
845+
default: true,
846+
});
847+
if (remove) {
848+
const cs = ora('Removing containers...').start();
849+
const ok = await removeDockerContainers(conflicts.map((c) => c.id));
850+
if (ok) {
851+
cs.succeed(`Removed ${conflicts.length} container${conflicts.length === 1 ? '' : 's'}`);
852+
} else {
853+
cs.fail('Failed to remove one or more containers');
854+
}
855+
}
856+
}
857+
}
858+
749859
// Volume wipe is only safe (and only succeeds) once nothing is using the volumes.
750860
if (downloadedCompose && !leftDeploymentRunning) {
751861
const declaredVolumes = parseTopLevelVolumes(readFileSync('docker-compose.yml', 'utf-8'));

0 commit comments

Comments
 (0)