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 - name: Build dist/ Directory
id: package 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. # This will fail the workflow if the PR wasn't created by Dependabot.
- name: Compare Directories - name: Compare Directories

85
dist/index.js generated vendored
View File

@ -31403,12 +31403,16 @@ var DockerComposeService = class {
} }
} }
async logs({ services, ...optionsInputs }) { async logs({ services, ...optionsInputs }) {
const options = this.getCommonOptions(optionsInputs); const commandArgs = this.getDockerComposeCommandArgs("logs", {
const { executablePath, executableArgs } = this.getDockerComposeCommandExecution("logs", services, options); dockerFlags: optionsInputs.dockerFlags,
composeFlags: optionsInputs.composeFlags,
composeFiles: optionsInputs.composeFiles,
commandArgs: services
});
return new Promise((resolve2) => { return new Promise((resolve2) => {
let settled = false; let settled = false;
const childProcess = spawn2(executablePath, executableArgs, { const childProcess = spawn2("docker", commandArgs, {
cwd: options.cwd cwd: optionsInputs.cwd
}); });
childProcess.on("error", (error2) => { childProcess.on("error", (error2) => {
if (settled) { if (settled) {
@ -31429,10 +31433,10 @@ var DockerComposeService = class {
return; return;
} }
childProcess.stdout.on("data", (chunk) => { childProcess.stdout.on("data", (chunk) => {
options.callback?.(Buffer.from(chunk), "stdout"); optionsInputs.serviceLogger(chunk.toString());
}); });
childProcess.stderr.on("data", (chunk) => { childProcess.stderr.on("data", (chunk) => {
options.callback?.(Buffer.from(chunk), "stderr"); optionsInputs.serviceLogger(chunk.toString());
}); });
childProcess.on("close", (exitCode, signal) => { childProcess.on("close", (exitCode, signal) => {
if (settled) { 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 * Formats docker-compose errors into proper Error objects with readable messages
*/ */
formatDockerComposeError(error2) { 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) { if (error2 instanceof Error) {
return error2.message; return error2;
} }
if (this.isDockerComposeResult(error2)) { if (this.isDockerComposeResult(error2)) {
const parts = []; const parts = [];
@ -31531,12 +31510,12 @@ var DockerComposeService = class {
parts.push("\nStandard output:"); parts.push("\nStandard output:");
parts.push(error2.out.trim()); parts.push(error2.out.trim());
} }
return parts.join("\n"); return new Error(parts.join("\n"));
} }
if (typeof error2 === "string") { 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 * 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 }) { async logs({ services, ...optionsInputs }) {
const options = this.getCommonOptions(optionsInputs); const commandArgs = this.getDockerComposeCommandArgs("logs", {
const { executablePath, executableArgs } = this.getDockerComposeCommandExecution("logs", services, options); dockerFlags: optionsInputs.dockerFlags,
composeFlags: optionsInputs.composeFlags,
composeFiles: optionsInputs.composeFiles,
commandArgs: services
});
return new Promise((resolve) => { return new Promise((resolve) => {
let settled = false; let settled = false;
const childProcess = spawn(executablePath, executableArgs, { const childProcess = spawn("docker", commandArgs, {
cwd: options.cwd cwd: optionsInputs.cwd
}); });
childProcess.on("error", (error2) => { childProcess.on("error", (error2) => {
if (settled) { if (settled) {
@ -27166,10 +27170,10 @@ var DockerComposeService = class {
return; return;
} }
childProcess.stdout.on("data", (chunk) => { childProcess.stdout.on("data", (chunk) => {
options.callback?.(Buffer.from(chunk), "stdout"); optionsInputs.serviceLogger(chunk.toString());
}); });
childProcess.stderr.on("data", (chunk) => { childProcess.stderr.on("data", (chunk) => {
options.callback?.(Buffer.from(chunk), "stderr"); optionsInputs.serviceLogger(chunk.toString());
}); });
childProcess.on("close", (exitCode, signal) => { childProcess.on("close", (exitCode, signal) => {
if (settled) { 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 * Formats docker-compose errors into proper Error objects with readable messages
*/ */
formatDockerComposeError(error2) { 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) { if (error2 instanceof Error) {
return error2.message; return error2;
} }
if (this.isDockerComposeResult(error2)) { if (this.isDockerComposeResult(error2)) {
const parts = []; const parts = [];
@ -27268,12 +27247,12 @@ var DockerComposeService = class {
parts.push("\nStandard output:"); parts.push("\nStandard output:");
parts.push(error2.out.trim()); parts.push(error2.out.trim());
} }
return parts.join("\n"); return new Error(parts.join("\n"));
} }
if (typeof error2 === "string") { 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 * 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": { "node_modules/nanoid": {
"version": "3.3.18", "version": "3.3.17",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {

View File

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

View File

@ -59,58 +59,56 @@ export class DockerComposeService {
error: string; error: string;
output: string; output: string;
}> { }> {
const options = this.getCommonOptions(optionsInputs); const commandArgs = this.getDockerComposeCommandArgs("logs", {
const { executablePath, executableArgs } = dockerFlags: optionsInputs.dockerFlags,
this.getDockerComposeCommandExecution("logs", services, options); composeFlags: optionsInputs.composeFlags,
composeFiles: optionsInputs.composeFiles,
commandArgs: services,
});
return new Promise((resolve) => { return new Promise((resolve) => {
let settled = false; let settled = false;
const childProcess = spawn(executablePath, executableArgs, { const childProcess = spawn("docker", commandArgs, {
cwd: options.cwd, cwd: optionsInputs.cwd,
}); });
childProcess.on("error", (error) => { childProcess.on("error", (error) => {
if (settled) { if (settled) {
return; return;
} }
settled = true; settled = true;
resolve({ resolve({
error: `Unable to collect docker compose logs: ${error.message}`, error: `Unable to collect docker compose logs: ${error.message}`,
output: "", output: "",
}); });
}); });
if (!childProcess.stdout || !childProcess.stderr) { if (!childProcess.stdout || !childProcess.stderr) {
settled = true; settled = true;
resolve({ resolve({
error: error: "Unable to collect docker compose logs: stdout/stderr unavailable",
"Unable to collect docker compose logs: stdout/stderr unavailable",
output: "", output: "",
}); });
return; return;
} }
childProcess.stdout.on("data", (chunk: Buffer | string) => { childProcess.stdout.on("data", (chunk: Buffer | string) => {
options.callback?.(Buffer.from(chunk), "stdout"); optionsInputs.serviceLogger(chunk.toString());
}); });
childProcess.stderr.on("data", (chunk: Buffer | string) => { childProcess.stderr.on("data", (chunk: Buffer | string) => {
options.callback?.(Buffer.from(chunk), "stderr"); optionsInputs.serviceLogger(chunk.toString());
}); });
childProcess.on("close", (exitCode, signal) => { childProcess.on("close", (exitCode, signal) => {
if (settled) { if (settled) {
return; return;
} }
settled = true; settled = true;
resolve({ resolve({
error: signal error:
? `Docker Compose logs command failed with signal ${signal}` signal
: exitCode !== null && exitCode !== 0 ? `Docker Compose logs command failed with signal ${signal}`
? `Docker Compose logs command failed with exit code ${exitCode}` : exitCode !== null && exitCode !== 0
: "", ? `Docker Compose logs command failed with exit code ${exitCode}`
: "",
output: "", 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 * Formats docker-compose errors into proper Error objects with readable messages
*/ */
private formatDockerComposeError(error: unknown): Error { private formatDockerComposeError(error: unknown): Error {
return new Error(this.getDockerComposeErrorMessage(error)); // If it's already an Error, return it
}
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 (error instanceof Error) { if (error instanceof Error) {
return error.message; return error;
} }
// Handle docker-compose result objects
if (this.isDockerComposeResult(error)) { if (this.isDockerComposeResult(error)) {
const parts: string[] = []; const parts: string[] = [];
// Add exit code information
if (error.exitCode !== null) { if (error.exitCode !== null) {
parts.push( parts.push(
`Docker Compose command failed with exit code ${error.exitCode}`, `Docker Compose command failed with exit code ${error.exitCode}`,
@ -219,25 +183,30 @@ export class DockerComposeService {
parts.push("Docker Compose command failed"); parts.push("Docker Compose command failed");
} }
// Add error stream output if available
if (error.err?.trim()) { if (error.err?.trim()) {
parts.push("\nError output:"); parts.push("\nError output:");
parts.push(error.err.trim()); parts.push(error.err.trim());
} }
// Add standard output if available and different from error output
if (error.out?.trim() && error.out !== error.err) { if (error.out?.trim() && error.out !== error.err) {
parts.push("\nStandard output:"); parts.push("\nStandard output:");
parts.push(error.out.trim()); parts.push(error.out.trim());
} }
return parts.join("\n"); return new Error(parts.join("\n"));
} }
// Handle string errors
if (typeof error === "string") { 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 * Type guard to check if an object is a docker-compose result
*/ */