Skip to content

Commit a67f4fd

Browse files
committed
Support devcontainer_cleanup and devcontainer_list
1 parent de83171 commit a67f4fd

File tree

4 files changed

+188
-16
lines changed

4 files changed

+188
-16
lines changed

README.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,31 @@ Initializes and starts a devcontainer environment in the specified workspace fol
103103
Text content with the command execution result
104104

105105

106-
### `devcontainer_list (TODO)`
106+
### `devcontainer_cleanup`
107+
108+
Runs docker command to cleanup all devcontainer environments.
109+
110+
- #### Input Parameters
111+
112+
N/A
113+
114+
- #### Returns
115+
116+
Text content with Docker process ID removed
117+
118+
119+
### `devcontainer_list`
120+
121+
Runs docker command to list all devcontainer environments.
122+
123+
- #### Input Parameters
124+
125+
N/A
126+
127+
- #### Returns
128+
129+
Text content with the current devcontainer Docker process status
107130

108-
### `devcontainer_cleanup (TODO)`
109131

110132
## 🤝 Contributing
111133

src/devcontainer.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,23 @@ const NULL_DEVICE = "/dev/null";
99
type CommandResult = Promise<string>;
1010

1111
const WS_FOLDER_DESC = "Path to the workspace folder (string)";
12-
const STDIO_FILE_PATH = `Path for output logs (string), default is ${NULL_DEVICE}`;
13-
const COMMAND = "Command to execute (array of string)";
12+
const STDIO_FILE_PATH_DESC = `Path for output logs (string), default is ${NULL_DEVICE}`;
13+
const COMMAND_DESC = "Command to execute (array of string)";
1414

1515
export const DevUpSchema = z.object({
1616
workspaceFolder: z.string().describe(WS_FOLDER_DESC),
17-
stdioFilePath: z.string().describe(STDIO_FILE_PATH).optional(),
17+
stdioFilePath: z.string().describe(STDIO_FILE_PATH_DESC).optional(),
1818
});
1919

2020
export const DevRunSchema = z.object({
2121
workspaceFolder: z.string().describe(WS_FOLDER_DESC),
22-
stdioFilePath: z.string().describe(STDIO_FILE_PATH).optional(),
22+
stdioFilePath: z.string().describe(STDIO_FILE_PATH_DESC).optional(),
2323
});
2424

2525
export const DevExecSchema = z.object({
2626
workspaceFolder: z.string().describe(WS_FOLDER_DESC),
27-
stdioFilePath: z.string().describe(STDIO_FILE_PATH).optional(),
28-
command: z.array(z.string()).min(1).describe(COMMAND),
27+
stdioFilePath: z.string().describe(STDIO_FILE_PATH_DESC).optional(),
28+
command: z.array(z.string()).min(1).describe(COMMAND_DESC),
2929
});
3030

3131
type DevUpArgs = z.infer<typeof DevUpSchema>;

src/docker.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { spawn, SpawnOptions, exec } from "child_process";
2+
import fs from "fs";
3+
import { z } from "zod";
4+
import { promisify } from 'util';
5+
6+
const execAsync = promisify(exec);
7+
8+
const NULL_DEVICE = "/dev/null";
9+
10+
const COMMAND = "docker";
11+
12+
type CommandResult = Promise<string>;
13+
14+
const PS_PATTERN = "\"{psID: {{.ID}}, psName: {{.Names}}, workspaceFolder: {{.Label \"devcontainer.local_folder\"}}, container: {{.Label \"dev.containers.id\"}}}\""
15+
16+
export const DevCleanupSchema = z.object({});
17+
18+
export const DevListSchema = z.object({});
19+
20+
type DevCleanupArgs = z.infer<typeof DevCleanupSchema>;
21+
type DevListArgs = z.infer<typeof DevListSchema>;
22+
23+
function createOutputStream(
24+
stdioFilePath: string = NULL_DEVICE
25+
): fs.WriteStream {
26+
try {
27+
return fs.createWriteStream(stdioFilePath, { flags: "w" });
28+
} catch (error) {
29+
throw new Error(
30+
`Failed to create output stream: ${(error as Error).message}`
31+
);
32+
}
33+
}
34+
35+
async function runCommand(
36+
args: string[],
37+
stdoutStream: fs.WriteStream
38+
): CommandResult {
39+
return new Promise((resolve, reject) => {
40+
const child = spawn(COMMAND, [...args], {
41+
stdio: ["ignore", "pipe", "pipe"],
42+
} as SpawnOptions);
43+
44+
const stdoutData: string[] = [];
45+
const stderrData: string[] = [];
46+
47+
child.on("error", (error) => {
48+
cleanup(error);
49+
reject(new Error(`Process spawn failed: ${error.message}`));
50+
});
51+
52+
// Pipe stdout to the stream as before, but also collect it
53+
child.stdout?.on("data", (data) => {
54+
stdoutData.push(data.toString());
55+
stdoutStream.write(data);
56+
});
57+
58+
// Collect stderr data instead of piping to process.stderr
59+
child.stderr?.on("data", (data) => {
60+
stderrData.push(data.toString().trim());
61+
});
62+
63+
const cleanup = (error?: Error) => {
64+
child.stdout?.removeAllListeners();
65+
child.stderr?.removeAllListeners();
66+
if (!stdoutStream.writableEnded) {
67+
stdoutStream.end();
68+
}
69+
if (error) {
70+
stdoutStream.destroy();
71+
}
72+
};
73+
74+
child.on("close", (code, signal) => {
75+
cleanup();
76+
if (code === 0) {
77+
resolve(
78+
`success with code ${code}\n-------\n${stdoutData.join("\n\n")}`
79+
);
80+
} else {
81+
const reason = signal
82+
? `terminated by signal ${signal}`
83+
: `exited with code ${code}`;
84+
85+
// Combine the error message with the collected stderr output
86+
const errorMessage = `Command failed: ${COMMAND} ${args.join(
87+
" "
88+
)} (${reason})\n-------\n${stderrData.join("\n\n")}`;
89+
reject(new Error(errorMessage));
90+
}
91+
});
92+
});
93+
}
94+
95+
export async function devCleanup(options: DevCleanupArgs): CommandResult {
96+
void options;
97+
const stream = createOutputStream(NULL_DEVICE);
98+
99+
let ids: string[];
100+
101+
try {
102+
const { stdout } = await execAsync("docker ps -aq -f label=dev.containers.id")
103+
const raw = stdout.toString().trim();
104+
ids = raw ? raw.split("\n") : [];
105+
} catch (error) {
106+
return Promise.reject(`Cannot list all docker ps: ${(error as Error).message}`);
107+
}
108+
109+
if (ids.length === 0) {
110+
return Promise.resolve(
111+
"No 'docker ps' results found; all devcontainers have already been cleaned up."
112+
);
113+
}
114+
115+
return runCommand(["rm", "-f", ...ids], stream);
116+
}
117+
118+
export async function devList(options: DevListArgs): CommandResult {
119+
void options;
120+
const stream = createOutputStream(NULL_DEVICE);
121+
122+
return runCommand(
123+
["ps", "-a", "--filter", "label=dev.containers.id", "--format", PS_PATTERN],
124+
stream
125+
);
126+
}

src/server.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import { devUp, devRunUserCommands, devExec } from "./devcontainer.js";
1010

1111
import { DevUpSchema, DevRunSchema, DevExecSchema } from "./devcontainer.js";
1212

13+
import { devCleanup, devList } from "./docker.js";
14+
import { DevCleanupSchema, DevListSchema } from "./docker.js";
15+
1316
type ToolInput = Tool["inputSchema"];
1417
type ToolName = keyof typeof ToolMap;
1518
type ToolArgs = CallToolRequest["params"]["arguments"];
@@ -42,6 +45,24 @@ const ToolMap = {
4245
},
4346
label: "Devcontainer Exec",
4447
},
48+
devcontainer_cleanup: {
49+
description:
50+
"Runs docker command to cleanup all devcontainer environments.",
51+
schema: DevCleanupSchema,
52+
execute: async (args: ToolArgs) => {
53+
return devCleanup(DevCleanupSchema.parse(args));
54+
},
55+
label: "Devcontainer Cleanup",
56+
},
57+
devcontainer_list: {
58+
description:
59+
"Runs docker command to list all devcontainer environments.",
60+
schema: DevListSchema,
61+
execute: async (args: ToolArgs) => {
62+
return devList(DevListSchema.parse(args));
63+
},
64+
label: "Devcontainer List",
65+
},
4566
};
4667

4768
export const createServer = () => {
@@ -86,14 +107,17 @@ export const createServer = () => {
86107
switch (name) {
87108
case "devcontainer_up":
88109
case "devcontainer_run_user_commands":
89-
case "devcontainer_exec": {
90-
const result = await Tool.execute(args);
91-
return {
92-
content: [
93-
{ type: "text", text: `${Tool.label} result: ${result}` },
94-
],
95-
};
96-
}
110+
case "devcontainer_exec":
111+
case "devcontainer_cleanup":
112+
case "devcontainer_list":
113+
{
114+
const result = await Tool.execute(args);
115+
return {
116+
content: [
117+
{ type: "text", text: `${Tool.label} result: ${result}` },
118+
],
119+
};
120+
}
97121
default:
98122
return {
99123
error: {

0 commit comments

Comments
 (0)