Compare commits

..

4 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
610019824f
fix: restore post-job compose logs header
Co-authored-by: neilime <314088+neilime@users.noreply.github.com>
2026-08-16 09:50:46 +00:00
copilot-swe-agent[bot]
e7fba772e2
fix: avoid buffering compose logs during cleanup
Co-authored-by: neilime <314088+neilime@users.noreply.github.com>
2026-08-07 09:27:55 +00:00
copilot-swe-agent[bot]
1b4933e684
fix: stream compose logs during post cleanup
Co-authored-by: neilime <314088+neilime@users.noreply.github.com>
2026-08-07 09:20:46 +00:00
copilot-swe-agent[bot]
51872038c3
Initial plan 2026-08-07 09:16:14 +00:00
7 changed files with 152 additions and 225 deletions

View File

@ -1,3 +0,0 @@
[codespell]
# Skip third-party/generated paths and French-locale content files.
skip = .git,*.svg,**/package-lock.json,**/dist/**,**/node_modules/**,**/coverage/**,**/junit.xml

View File

@ -21,7 +21,7 @@ jobs:
- name: Build dist/ Directory
id: package
run: npm run package
run: ${{ steps.setup-node.outputs.run-script-command }} package
# This will fail the workflow if the PR wasn't created by Dependabot.
- name: Compare Directories

85
dist/index.js generated vendored
View File

@ -31403,12 +31403,16 @@ var DockerComposeService = class {
}
}
async logs({ services, ...optionsInputs }) {
const options = this.getCommonOptions(optionsInputs);
const { executablePath, executableArgs } = this.getDockerComposeCommandExecution("logs", services, options);
const commandArgs = this.getDockerComposeCommandArgs("logs", {
dockerFlags: optionsInputs.dockerFlags,
composeFlags: optionsInputs.composeFlags,
composeFiles: optionsInputs.composeFiles,
commandArgs: services
});
return new Promise((resolve2) => {
let settled = false;
const childProcess = spawn2(executablePath, executableArgs, {
cwd: options.cwd
const childProcess = spawn2("docker", commandArgs, {
cwd: optionsInputs.cwd
});
childProcess.on("error", (error2) => {
if (settled) {
@ -31429,10 +31433,10 @@ var DockerComposeService = class {
return;
}
childProcess.stdout.on("data", (chunk) => {
options.callback?.(Buffer.from(chunk), "stdout");
optionsInputs.serviceLogger(chunk.toString());
});
childProcess.stderr.on("data", (chunk) => {
options.callback?.(Buffer.from(chunk), "stderr");
optionsInputs.serviceLogger(chunk.toString());
});
childProcess.on("close", (exitCode, signal) => {
if (settled) {
@ -31464,55 +31468,30 @@ var DockerComposeService = class {
}
};
}
/**
* Builds docker CLI arguments in the order expected by `docker compose`.
*/
getDockerComposeCommandArgs(command, {
dockerFlags,
composeFlags,
composeFiles,
commandArgs
}) {
return [
...dockerFlags,
"compose",
...composeFlags,
...composeFiles.flatMap((composeFile) => ["-f", composeFile]),
command,
...commandArgs
];
}
/**
* Formats docker-compose errors into proper Error objects with readable messages
*/
formatDockerComposeError(error2) {
return new Error(this.getDockerComposeErrorMessage(error2));
}
getDockerComposeCommandExecution(command, commandArgs, options) {
const composeArgs = [
...this.getComposeOptionArgs(options.composeOptions),
...this.getConfigArgs(options.config),
command,
...this.getComposeOptionArgs(options.commandOptions),
...commandArgs
];
if (options.executable?.standalone) {
return {
executablePath: options.executable.executablePath ?? "docker-compose",
executableArgs: composeArgs
};
}
return {
executablePath: options.executable?.executablePath ?? "docker",
executableArgs: [
...this.getComposeOptionArgs(options.executable?.options),
"compose",
...composeArgs
]
};
}
getConfigArgs(config) {
if (typeof config === "undefined") {
return [];
}
if (typeof config === "string") {
return ["-f", config];
}
return config.flatMap((item) => ["-f", item]);
}
getComposeOptionArgs(composeOptions) {
if (!composeOptions) {
return [];
}
return composeOptions.flatMap(
(option) => Array.isArray(option) ? option : [option]
);
}
getDockerComposeErrorMessage(error2) {
if (error2 instanceof Error) {
return error2.message;
return error2;
}
if (this.isDockerComposeResult(error2)) {
const parts = [];
@ -31531,12 +31510,12 @@ var DockerComposeService = class {
parts.push("\nStandard output:");
parts.push(error2.out.trim());
}
return parts.join("\n");
return new Error(parts.join("\n"));
}
if (typeof error2 === "string") {
return error2;
return new Error(error2);
}
return JSON.stringify(error2);
return new Error(JSON.stringify(error2));
}
/**
* Type guard to check if an object is a docker-compose result

85
dist/post.js generated vendored
View File

@ -27140,12 +27140,16 @@ var DockerComposeService = class {
}
}
async logs({ services, ...optionsInputs }) {
const options = this.getCommonOptions(optionsInputs);
const { executablePath, executableArgs } = this.getDockerComposeCommandExecution("logs", services, options);
const commandArgs = this.getDockerComposeCommandArgs("logs", {
dockerFlags: optionsInputs.dockerFlags,
composeFlags: optionsInputs.composeFlags,
composeFiles: optionsInputs.composeFiles,
commandArgs: services
});
return new Promise((resolve) => {
let settled = false;
const childProcess = spawn(executablePath, executableArgs, {
cwd: options.cwd
const childProcess = spawn("docker", commandArgs, {
cwd: optionsInputs.cwd
});
childProcess.on("error", (error2) => {
if (settled) {
@ -27166,10 +27170,10 @@ var DockerComposeService = class {
return;
}
childProcess.stdout.on("data", (chunk) => {
options.callback?.(Buffer.from(chunk), "stdout");
optionsInputs.serviceLogger(chunk.toString());
});
childProcess.stderr.on("data", (chunk) => {
options.callback?.(Buffer.from(chunk), "stderr");
optionsInputs.serviceLogger(chunk.toString());
});
childProcess.on("close", (exitCode, signal) => {
if (settled) {
@ -27201,55 +27205,30 @@ var DockerComposeService = class {
}
};
}
/**
* Builds docker CLI arguments in the order expected by `docker compose`.
*/
getDockerComposeCommandArgs(command, {
dockerFlags,
composeFlags,
composeFiles,
commandArgs
}) {
return [
...dockerFlags,
"compose",
...composeFlags,
...composeFiles.flatMap((composeFile) => ["-f", composeFile]),
command,
...commandArgs
];
}
/**
* Formats docker-compose errors into proper Error objects with readable messages
*/
formatDockerComposeError(error2) {
return new Error(this.getDockerComposeErrorMessage(error2));
}
getDockerComposeCommandExecution(command, commandArgs, options) {
const composeArgs = [
...this.getComposeOptionArgs(options.composeOptions),
...this.getConfigArgs(options.config),
command,
...this.getComposeOptionArgs(options.commandOptions),
...commandArgs
];
if (options.executable?.standalone) {
return {
executablePath: options.executable.executablePath ?? "docker-compose",
executableArgs: composeArgs
};
}
return {
executablePath: options.executable?.executablePath ?? "docker",
executableArgs: [
...this.getComposeOptionArgs(options.executable?.options),
"compose",
...composeArgs
]
};
}
getConfigArgs(config) {
if (typeof config === "undefined") {
return [];
}
if (typeof config === "string") {
return ["-f", config];
}
return config.flatMap((item) => ["-f", item]);
}
getComposeOptionArgs(composeOptions) {
if (!composeOptions) {
return [];
}
return composeOptions.flatMap(
(option) => Array.isArray(option) ? option : [option]
);
}
getDockerComposeErrorMessage(error2) {
if (error2 instanceof Error) {
return error2.message;
return error2;
}
if (this.isDockerComposeResult(error2)) {
const parts = [];
@ -27268,12 +27247,12 @@ var DockerComposeService = class {
parts.push("\nStandard output:");
parts.push(error2.out.trim());
}
return parts.join("\n");
return new Error(parts.join("\n"));
}
if (typeof error2 === "string") {
return error2;
return new Error(error2);
}
return JSON.stringify(error2);
return new Error(JSON.stringify(error2));
}
/**
* Type guard to check if an object is a docker-compose result

6
package-lock.json generated
View File

@ -3145,9 +3145,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"version": "3.3.17",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
"dev": true,
"funding": [
{

View File

@ -1,8 +1,8 @@
import { EventEmitter } from "node:events";
import type {
IDockerComposeOptions,
IDockerComposeResult,
} from "docker-compose";
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock docker-compose before importing the module under test
@ -357,13 +357,13 @@ describe("DockerComposeService", () => {
});
describe("logs", () => {
it("should stream logs with wrapper-compatible command arguments", async () => {
it("should stream logs with correct command arguments", async () => {
const debugMock = vi.fn();
const logsInputs = {
dockerFlags: ["--context", "dev"] as string[],
dockerFlags: [] as string[],
composeFiles: ["docker-compose.yml"],
services: ["helloworld2", "helloworld3"],
composeFlags: ["--profile", "ci"] as string[],
composeFlags: [] as string[],
cwd: "/current/working/dir",
serviceLogger: debugMock,
};
@ -380,31 +380,22 @@ describe("DockerComposeService", () => {
const logsPromise = service.logs(logsInputs);
expect(spawnMock).toHaveBeenCalledWith(
"docker",
[
"--context",
"dev",
"compose",
"--profile",
"ci",
"-f",
"docker-compose.yml",
"logs",
"helloworld2",
"helloworld3",
],
{
cwd: "/current/working/dir",
},
);
expect(spawnMock).toHaveBeenCalledWith("docker", [
"compose",
"-f",
"docker-compose.yml",
"logs",
"helloworld2",
"helloworld3",
], {
cwd: "/current/working/dir",
});
stdout.emit("data", Buffer.from("logs"));
stderr.emit("data", Buffer.from("error logs"));
childProcess.emit("close", 0);
await expect(logsPromise).resolves.toEqual({ error: "", output: "" });
expect(debugMock).toHaveBeenNthCalledWith(1, "logs");
expect(debugMock).toHaveBeenNthCalledWith(2, "error logs");
});
@ -429,6 +420,19 @@ describe("DockerComposeService", () => {
const logsPromise = service.logs(logsInputs);
expect(spawnMock).toHaveBeenCalledWith("docker", [
"--context",
"dev",
"compose",
"--profile",
"ci",
"-f",
"docker-compose.yml",
"logs",
], {
cwd: "/current/working/dir",
});
childProcess.emit("close", 1);
await expect(logsPromise).resolves.toEqual({
@ -512,8 +516,7 @@ describe("DockerComposeService", () => {
spawnMock.mockReturnValue(childProcess);
await expect(service.logs(logsInputs)).resolves.toEqual({
error:
"Unable to collect docker compose logs: stdout/stderr unavailable",
error: "Unable to collect docker compose logs: stdout/stderr unavailable",
output: "",
});
});

View File

@ -59,58 +59,56 @@ export class DockerComposeService {
error: string;
output: string;
}> {
const options = this.getCommonOptions(optionsInputs);
const { executablePath, executableArgs } =
this.getDockerComposeCommandExecution("logs", services, options);
const commandArgs = this.getDockerComposeCommandArgs("logs", {
dockerFlags: optionsInputs.dockerFlags,
composeFlags: optionsInputs.composeFlags,
composeFiles: optionsInputs.composeFiles,
commandArgs: services,
});
return new Promise((resolve) => {
let settled = false;
const childProcess = spawn(executablePath, executableArgs, {
cwd: options.cwd,
const childProcess = spawn("docker", commandArgs, {
cwd: optionsInputs.cwd,
});
childProcess.on("error", (error) => {
if (settled) {
return;
}
settled = true;
resolve({
error: `Unable to collect docker compose logs: ${error.message}`,
output: "",
});
});
if (!childProcess.stdout || !childProcess.stderr) {
settled = true;
resolve({
error:
"Unable to collect docker compose logs: stdout/stderr unavailable",
error: "Unable to collect docker compose logs: stdout/stderr unavailable",
output: "",
});
return;
}
childProcess.stdout.on("data", (chunk: Buffer | string) => {
options.callback?.(Buffer.from(chunk), "stdout");
optionsInputs.serviceLogger(chunk.toString());
});
childProcess.stderr.on("data", (chunk: Buffer | string) => {
options.callback?.(Buffer.from(chunk), "stderr");
optionsInputs.serviceLogger(chunk.toString());
});
childProcess.on("close", (exitCode, signal) => {
if (settled) {
return;
}
settled = true;
resolve({
error: signal
? `Docker Compose logs command failed with signal ${signal}`
: exitCode !== null && exitCode !== 0
? `Docker Compose logs command failed with exit code ${exitCode}`
: "",
error:
signal
? `Docker Compose logs command failed with signal ${signal}`
: exitCode !== null && exitCode !== 0
? `Docker Compose logs command failed with exit code ${exitCode}`
: "",
output: "",
});
});
@ -136,81 +134,47 @@ export class DockerComposeService {
};
}
/**
* Builds docker CLI arguments in the order expected by `docker compose`.
*/
private getDockerComposeCommandArgs(
command: "logs",
{
dockerFlags,
composeFlags,
composeFiles,
commandArgs,
}: {
dockerFlags: string[];
composeFlags: string[];
composeFiles: string[];
commandArgs: string[];
},
): string[] {
return [
...dockerFlags,
"compose",
...composeFlags,
...composeFiles.flatMap((composeFile) => ["-f", composeFile]),
command,
...commandArgs,
];
}
/**
* Formats docker-compose errors into proper Error objects with readable messages
*/
private formatDockerComposeError(error: unknown): Error {
return new Error(this.getDockerComposeErrorMessage(error));
}
private getDockerComposeCommandExecution(
command: string,
commandArgs: string[],
options: IDockerComposeOptions,
): {
executablePath: string;
executableArgs: string[];
} {
const composeArgs = [
...this.getComposeOptionArgs(options.composeOptions),
...this.getConfigArgs(options.config),
command,
...this.getComposeOptionArgs(options.commandOptions),
...commandArgs,
];
if (options.executable?.standalone) {
return {
executablePath: options.executable.executablePath ?? "docker-compose",
executableArgs: composeArgs,
};
}
return {
executablePath: options.executable?.executablePath ?? "docker",
executableArgs: [
...this.getComposeOptionArgs(options.executable?.options),
"compose",
...composeArgs,
],
};
}
private getConfigArgs(config: IDockerComposeOptions["config"]): string[] {
if (typeof config === "undefined") {
return [];
}
if (typeof config === "string") {
return ["-f", config];
}
return config.flatMap((item) => ["-f", item]);
}
private getComposeOptionArgs(
composeOptions:
| IDockerComposeOptions["composeOptions"]
| IDockerComposeOptions["commandOptions"]
| NonNullable<IDockerComposeOptions["executable"]>["options"],
): string[] {
if (!composeOptions) {
return [];
}
return composeOptions.flatMap((option) =>
Array.isArray(option) ? option : [option],
);
}
private getDockerComposeErrorMessage(error: unknown): string {
// If it's already an Error, return it
if (error instanceof Error) {
return error.message;
return error;
}
// Handle docker-compose result objects
if (this.isDockerComposeResult(error)) {
const parts: string[] = [];
// Add exit code information
if (error.exitCode !== null) {
parts.push(
`Docker Compose command failed with exit code ${error.exitCode}`,
@ -219,25 +183,30 @@ export class DockerComposeService {
parts.push("Docker Compose command failed");
}
// Add error stream output if available
if (error.err?.trim()) {
parts.push("\nError output:");
parts.push(error.err.trim());
}
// Add standard output if available and different from error output
if (error.out?.trim() && error.out !== error.err) {
parts.push("\nStandard output:");
parts.push(error.out.trim());
}
return parts.join("\n");
return new Error(parts.join("\n"));
}
// Handle string errors
if (typeof error === "string") {
return error;
return new Error(error);
}
return JSON.stringify(error);
// Fallback for unknown error types
return new Error(JSON.stringify(error));
}
/**
* Type guard to check if an object is a docker-compose result
*/