diff --git a/apps/admin-x-settings/src/components/settings/membership/member-emails.tsx b/apps/admin-x-settings/src/components/settings/membership/member-emails.tsx index 9d9a67e42b2..ca1430deb59 100644 --- a/apps/admin-x-settings/src/components/settings/membership/member-emails.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/member-emails.tsx @@ -8,6 +8,7 @@ import {checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/ import {useAddAutomatedEmail, useBrowseAutomatedEmails, useEditAutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails'; import {useGlobalData} from '../../providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; +import {useWelcomeEmailSenderDetails} from '../../../hooks/use-welcome-email-sender-details'; import type {AutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails'; // Default welcome email content in Lexical JSON format @@ -32,10 +33,9 @@ const EmailPreview: React.FC<{ onToggle }) => { const {settings} = useGlobalData(); - const [accentColor, icon, siteTitle] = getSettingValues(settings, ['accent_color', 'icon', 'title']); + const [accentColor, icon] = getSettingValues(settings, ['accent_color', 'icon']); const color = accentColor || '#F6414E'; - - const senderName = automatedEmail.sender_name || siteTitle || 'Your Site'; + const {resolvedSenderName} = useWelcomeEmailSenderDetails(automatedEmail); return (
}
-
{senderName}
+
{resolvedSenderName}
{automatedEmail.subject}
diff --git a/apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-modal.tsx b/apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-modal.tsx index 9834b261b34..9b012b48a93 100644 --- a/apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-modal.tsx @@ -4,6 +4,7 @@ import {useCallback, useEffect, useRef, useState} from 'react'; import MemberEmailEditor from './member-email-editor'; import {Button, Hint, Modal, TextField} from '@tryghost/admin-x-design-system'; import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; +import {useWelcomeEmailSenderDetails} from '../../../../hooks/use-welcome-email-sender-details'; import TestEmailDropdown from './test-email-dropdown'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; @@ -51,7 +52,8 @@ const WelcomeEmailModal = NiceModal.create(({emailType = const hasEditorBeenFocused = useRef(false); const handleError = useHandleError(); const {settings} = useGlobalData(); - const [siteTitle, defaultEmailAddress] = getSettingValues(settings, ['title', 'default_email_address']); + const [siteTitle] = getSettingValues(settings, ['title']); + const {resolvedSenderName, resolvedSenderEmail, resolvedReplyToEmail, hasDistinctReplyTo} = useWelcomeEmailSenderDetails(automatedEmail); const {formState, saveState, updateForm, setFormState, handleSave, okProps, errors, validate} = useForm({ initialState: { @@ -135,9 +137,6 @@ const WelcomeEmailModal = NiceModal.create(({emailType = } }, [setFormState, updateForm]); - const senderEmail = automatedEmail?.sender_email || defaultEmailAddress; - const replyToEmail = automatedEmail?.sender_reply_to || defaultEmailAddress; - return ( { @@ -177,15 +176,15 @@ const WelcomeEmailModal = NiceModal.create(({emailType =
From:
- {automatedEmail?.sender_name || siteTitle} - {`<${senderEmail}>`} + {resolvedSenderName} + {`<${resolvedSenderEmail}>`}
- {replyToEmail !== senderEmail && ( + {hasDistinctReplyTo && (
Reply-to:
- {replyToEmail} + {resolvedReplyToEmail}
)} diff --git a/apps/admin-x-settings/src/hooks/use-welcome-email-sender-details.ts b/apps/admin-x-settings/src/hooks/use-welcome-email-sender-details.ts new file mode 100644 index 00000000000..96679011410 --- /dev/null +++ b/apps/admin-x-settings/src/hooks/use-welcome-email-sender-details.ts @@ -0,0 +1,54 @@ +import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; +import {renderReplyToEmail, renderSenderEmail} from '../utils/newsletter-emails'; +import {useBrowseNewsletters} from '@tryghost/admin-x-framework/api/newsletters'; +import {useGlobalData} from '../components/providers/global-data-provider'; +import {useMemo} from 'react'; +import type {AutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails'; + +type AutomatedEmailSenderFields = Pick | null | undefined; + +const trimValue = (value: string | null | undefined) => value?.trim() || ''; + +export const useWelcomeEmailSenderDetails = (automatedEmail: AutomatedEmailSenderFields) => { + const {settings, config} = useGlobalData(); + const [siteTitle, defaultEmailAddress, supportEmailAddress] = getSettingValues(settings, ['title', 'default_email_address', 'support_email_address']); + const {data: newslettersData} = useBrowseNewsletters({ + searchParams: { + filter: 'status:active', + limit: '1' + } + }); + const defaultNewsletter = newslettersData?.newsletters?.[0]; + + return useMemo(() => { + const automatedSenderName = trimValue(automatedEmail?.sender_name); + const automatedSenderEmail = trimValue(automatedEmail?.sender_email); + const automatedSenderReplyTo = trimValue(automatedEmail?.sender_reply_to); + + const defaultNewsletterSenderName = trimValue(defaultNewsletter?.sender_name); + const defaultNewsletterSenderEmail = defaultNewsletter ? trimValue(renderSenderEmail(defaultNewsletter, config, defaultEmailAddress)) : ''; + const defaultNewsletterReplyTo = defaultNewsletter ? trimValue(renderReplyToEmail(defaultNewsletter, config, supportEmailAddress, defaultEmailAddress)) : ''; + + const resolvedSenderName = automatedSenderName || defaultNewsletterSenderName || trimValue(siteTitle) || 'Your Site'; + const resolvedSenderEmail = automatedSenderEmail || defaultNewsletterSenderEmail || trimValue(defaultEmailAddress) || ''; + const resolvedReplyToEmail = automatedSenderReplyTo || defaultNewsletterReplyTo || ''; + const hasDistinctReplyTo = resolvedReplyToEmail !== '' && resolvedReplyToEmail !== resolvedSenderEmail; + + return { + resolvedSenderName, + resolvedSenderEmail, + resolvedReplyToEmail, + defaultNewsletterSenderName, + hasDistinctReplyTo + }; + }, [ + automatedEmail?.sender_email, + automatedEmail?.sender_name, + automatedEmail?.sender_reply_to, + config, + defaultEmailAddress, + defaultNewsletter, + siteTitle, + supportEmailAddress + ]); +}; diff --git a/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts b/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts index 9f8c2e016e1..22ded8e1a80 100644 --- a/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts +++ b/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts @@ -18,6 +18,10 @@ const automatedEmailsFixture = { }] }; +const newslettersRequest = { + browseNewslettersLimit: {method: 'GET', path: '/newsletters/?filter=status%3Aactive&limit=1', response: responseFixtures.newsletters} +}; + test.describe('Member emails settings', async () => { test.describe('Welcome email modal', async () => { test('Escape key closes test email dropdown without closing modal', async ({page}) => { @@ -33,6 +37,7 @@ test.describe('Member emails settings', async () => { await mockApi({page, requests: { ...globalDataRequests, + ...newslettersRequest, browseConfig: {method: 'GET', path: '/config/', response: configResponse}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture} }}); @@ -82,6 +87,7 @@ test.describe('Member emails settings', async () => { await mockApi({page, requests: { ...globalDataRequests, + ...newslettersRequest, browseConfig: {method: 'GET', path: '/config/', response: configResponse}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture} }}); @@ -121,6 +127,7 @@ test.describe('Member emails settings', async () => { await mockApi({page, requests: { ...globalDataRequests, + ...newslettersRequest, browseConfig: {method: 'GET', path: '/config/', response: configResponse}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture} }}); @@ -178,6 +185,7 @@ test.describe('Member emails settings', async () => { await mockApi({page, requests: { ...globalDataRequests, + ...newslettersRequest, browseConfig: {method: 'GET', path: '/config/', response: configResponse}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture} }}); @@ -227,6 +235,150 @@ test.describe('Member emails settings', async () => { document.querySelector('[data-kg-link-input]')?.remove(); }); }); + + test('uses automated email sender fields when populated, even if newsletter differs', async ({page}) => { + const configResponse = { + config: { + ...responseFixtures.config.config, + labs: { + welcomeEmails: true + } + } + }; + + const populatedAutomatedEmailsFixture = { + automated_emails: [{ + ...automatedEmailsFixture.automated_emails[0], + sender_name: 'Automated Sender', + sender_email: 'automated@example.com', + sender_reply_to: 'reply-automated@example.com' + }] + }; + + const defaultNewsletterResponse = { + newsletters: [{ + ...responseFixtures.newsletters.newsletters[0], + sender_name: 'Newsletter Sender', + sender_email: 'newsletter@example.com', + sender_reply_to: 'support' + }], + meta: responseFixtures.newsletters.meta + }; + + await mockApi({page, requests: { + ...globalDataRequests, + browseConfig: {method: 'GET', path: '/config/', response: configResponse}, + browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: populatedAutomatedEmailsFixture}, + browseNewslettersLimit: {method: 'GET', path: '/newsletters/?filter=status%3Aactive&limit=1', response: defaultNewsletterResponse} + }}); + + await page.goto('/#/memberemails'); + await page.waitForLoadState('networkidle'); + + const section = page.getByTestId('memberemails'); + await expect(section).toBeVisible({timeout: 10000}); + await section.getByTestId('free-welcome-email-preview').click(); + + const modal = page.getByTestId('welcome-email-modal'); + await expect(modal).toBeVisible(); + await expect(modal).toContainText('Automated Sender'); + await expect(modal).toContainText('automated@example.com'); + await expect(modal).toContainText('reply-automated@example.com'); + await expect(modal).not.toContainText('newsletter@example.com'); + }); + + test('falls back to default newsletter sender values when automated fields are empty', async ({page}) => { + const configResponse = { + config: { + ...responseFixtures.config.config, + labs: { + welcomeEmails: true + } + } + }; + + const emptyAutomatedSenderFixture = { + automated_emails: [{ + ...automatedEmailsFixture.automated_emails[0], + sender_name: ' ', + sender_email: ' ', + sender_reply_to: ' ' + }] + }; + + const defaultNewsletterResponse = { + newsletters: [{ + ...responseFixtures.newsletters.newsletters[0], + sender_name: 'Newsletter Sender', + sender_email: 'newsletter@example.com', + sender_reply_to: 'support' + }], + meta: responseFixtures.newsletters.meta + }; + + await mockApi({page, requests: { + ...globalDataRequests, + browseConfig: {method: 'GET', path: '/config/', response: configResponse}, + browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: emptyAutomatedSenderFixture}, + browseNewslettersLimit: {method: 'GET', path: '/newsletters/?filter=status%3Aactive&limit=1', response: defaultNewsletterResponse} + }}); + + await page.goto('/#/memberemails'); + await page.waitForLoadState('networkidle'); + + const section = page.getByTestId('memberemails'); + await expect(section).toBeVisible({timeout: 10000}); + await section.getByTestId('free-welcome-email-preview').click(); + + const modal = page.getByTestId('welcome-email-modal'); + await expect(modal).toBeVisible(); + await expect(modal).toContainText('Newsletter Sender'); + await expect(modal).toContainText('newsletter@example.com'); + await expect(modal).toContainText('support@example.com'); + await expect(modal).not.toContainText('default@example.com'); + }); + + test('preview card uses newsletter sender name when automated sender name is empty', async ({page}) => { + const configResponse = { + config: { + ...responseFixtures.config.config, + labs: { + welcomeEmails: true + } + } + }; + + const emptyAutomatedSenderFixture = { + automated_emails: [{ + ...automatedEmailsFixture.automated_emails[0], + sender_name: ' ' + }] + }; + + const defaultNewsletterResponse = { + newsletters: [{ + ...responseFixtures.newsletters.newsletters[0], + sender_name: 'Newsletter Sender' + }], + meta: responseFixtures.newsletters.meta + }; + + await mockApi({page, requests: { + ...globalDataRequests, + browseConfig: {method: 'GET', path: '/config/', response: configResponse}, + browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: emptyAutomatedSenderFixture}, + browseNewslettersLimit: {method: 'GET', path: '/newsletters/?filter=status%3Aactive&limit=1', response: defaultNewsletterResponse} + }}); + + await page.goto('/#/memberemails'); + await page.waitForLoadState('networkidle'); + + const section = page.getByTestId('memberemails'); + await expect(section).toBeVisible({timeout: 10000}); + + const cardSenderName = section.locator('[data-testid="free-welcome-email-preview"] .font-semibold').first(); + await expect(cardSenderName).toHaveText('Newsletter Sender'); + }); }); // NY-842: Tests for editing/viewing welcome emails before activation @@ -249,6 +401,7 @@ test.describe('Member emails settings', async () => { await mockApi({page, requests: { ...globalDataRequests, + ...newslettersRequest, browseConfig: {method: 'GET', path: '/config/', response: configResponse}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: emptyAutomatedEmailsFixture} }}); @@ -305,6 +458,7 @@ test.describe('Member emails settings', async () => { const {lastApiRequests} = await mockApi({page, requests: { ...globalDataRequests, + ...newslettersRequest, browseConfig: {method: 'GET', path: '/config/', response: configResponse}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: emptyAutomatedEmailsFixture}, addAutomatedEmail: {method: 'POST', path: '/automated_emails/', response: createdAutomatedEmailResponse} @@ -361,6 +515,7 @@ test.describe('Member emails settings', async () => { const {lastApiRequests} = await mockApi({page, requests: { ...globalDataRequests, + ...newslettersRequest, browseConfig: {method: 'GET', path: '/config/', response: configResponse}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: existingAutomatedEmailsFixture}, addAutomatedEmail: {method: 'POST', path: '/automated_emails/', response: existingAutomatedEmailsFixture} @@ -416,6 +571,7 @@ test.describe('Member emails settings', async () => { const {lastApiRequests} = await mockApi({page, requests: { ...globalDataRequests, + ...newslettersRequest, browseConfig: {method: 'GET', path: '/config/', response: configResponse}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: emptyAutomatedEmailsFixture}, addAutomatedEmail: {method: 'POST', path: '/automated_emails/', response: createdActiveResponse} @@ -475,6 +631,7 @@ test.describe('Member emails settings', async () => { const {lastApiRequests} = await mockApi({page, requests: { ...globalDataRequests, + ...newslettersRequest, browseConfig: {method: 'GET', path: '/config/', response: configResponse}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: inactiveAutomatedEmailsFixture}, editAutomatedEmail: {method: 'PUT', path: '/automated_emails/free-welcome-email-id/', response: updatedActiveResponse} @@ -534,6 +691,7 @@ test.describe('Member emails settings', async () => { const {lastApiRequests} = await mockApi({page, requests: { ...globalDataRequests, + ...newslettersRequest, browseConfig: {method: 'GET', path: '/config/', response: configResponse}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: activeAutomatedEmailsFixture}, editAutomatedEmail: {method: 'PUT', path: '/automated_emails/free-welcome-email-id/', response: updatedInactiveResponse} diff --git a/apps/portal/package.json b/apps/portal/package.json index 25bd6d06639..93a628ef7ce 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/portal", - "version": "2.64.2", + "version": "2.64.3", "license": "MIT", "repository": "https://github.com/TryGhost/Ghost", "author": "Ghost Foundation", diff --git a/apps/portal/src/app.js b/apps/portal/src/app.js index 92afee6399e..80960853f08 100644 --- a/apps/portal/src/app.js +++ b/apps/portal/src/app.js @@ -744,7 +744,7 @@ export default class App extends React.Component { /**Handle state update for preview url and Portal Link changes */ updateStateForPreviewLinks() { const {site: previewSite, ...restPreviewData} = this.fetchPreviewData(); - const {site: linkSite, ...restLinkData} = this.fetchLinkData(); + const {site: linkSite, ...restLinkData} = this.fetchLinkData(this.state.site, this.state.member); const updatedState = { site: { diff --git a/apps/portal/test/portal-links.test.js b/apps/portal/test/portal-links.test.js index 3e72a64cae5..909756f173e 100644 --- a/apps/portal/test/portal-links.test.js +++ b/apps/portal/test/portal-links.test.js @@ -385,4 +385,79 @@ describe('Portal Data links:', () => { }); }); }); + + describe('hashchange account page access', () => { + test.each([ + {path: 'account', expectedText: /your account/i}, + {path: 'account/plans', expectedText: /choose a plan/i}, + {path: 'account/profile', expectedText: /account settings/i} + ])('#/portal/$path opens account page via hashchange when logged in', async ({path, expectedText}) => { + // Start with no hash — simulates an already-loaded page + window.location.hash = ''; + let { + popupFrame, triggerButtonFrame, ...utils + } = await setup({ + site: FixtureSite.singleTier.basic, + member: FixtureMember.free, + showPopup: false + }); + expect(triggerButtonFrame).toBeInTheDocument(); + + // Navigate via hash change (e.g. clicking ) + window.location.hash = `#/portal/${path}`; + window.dispatchEvent(new HashChangeEvent('hashchange')); + + popupFrame = await utils.findByTitle(/portal-popup/i); + expect(popupFrame).toBeInTheDocument(); + + const pageTitle = within(popupFrame.contentDocument).queryByText(expectedText); + expect(pageTitle).toBeInTheDocument(); + }); + + test.each([ + {path: 'account', label: 'account'}, + {path: 'account/plans', label: 'account/plans'}, + {path: 'account/profile', label: 'account/profile'}, + {path: 'account/newsletters', label: 'account/newsletters'} + ])('#/portal/$label redirects to signin via hashchange when not logged in', async ({path}) => { + // Start with no hash — simulates an already-loaded page + window.location.hash = ''; + let { + ghostApi, popupFrame, triggerButtonFrame, ...utils + } = await setup({ + site: FixtureSite.singleTier.basic, + member: null, + showPopup: false + }); + expect(triggerButtonFrame).toBeInTheDocument(); + + // Now navigate via hash change (e.g. clicking ) + window.location.hash = `#/portal/${path}`; + window.dispatchEvent(new HashChangeEvent('hashchange')); + + popupFrame = await utils.findByTitle(/portal-popup/i); + expect(popupFrame).toBeInTheDocument(); + + // Should show signin page instead of account page + const popupIframeDocument = popupFrame.contentDocument; + const signinTitle = within(popupIframeDocument).queryByText(/sign in/i); + expect(signinTitle).toBeInTheDocument(); + + // Fill in email and submit to verify the redirect URL is passed through + const emailInput = within(popupIframeDocument).getByLabelText(/email/i); + const submitButton = within(popupIframeDocument).getByRole('button', {name: 'Continue'}); + fireEvent.change(emailInput, {target: {value: 'test@example.com'}}); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(ghostApi.member.sendMagicLink).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'test@example.com', + emailType: 'signin', + redirect: `https://portal.localhost#/portal/${path}/` + }) + ); + }); + }); + }); }); diff --git a/e2e/helpers/environment/docker-compose.ts b/e2e/helpers/environment/docker-compose.ts index 5c0011e91f2..5df31b69120 100644 --- a/e2e/helpers/environment/docker-compose.ts +++ b/e2e/helpers/environment/docker-compose.ts @@ -28,12 +28,16 @@ export class DockerCompose { } async up(): Promise { + const command = this.composeCommand('up -d'); + try { logging.info('Starting docker compose services...'); - execSync(`docker compose -f ${this.composeFilePath} -p ${this.projectName} up -d`, {stdio: 'inherit'}); + execSync(command, {encoding: 'utf-8', maxBuffer: 1024 * 1024 * 10}); logging.info('Docker compose services are up'); } catch (error) { + this.logCommandFailure(command, error); logging.error('Failed to start docker compose services:', error); + this.ps(); this.logs(); throw error; } @@ -43,30 +47,30 @@ export class DockerCompose { // Stop and remove all services for the project including volumes down(): void { + const command = this.composeCommand('down -v'); + try { - execSync( - `docker compose -f ${this.composeFilePath} -p ${this.projectName} down -v`, - {stdio: 'inherit'} - ); + execSync(command, {encoding: 'utf-8', maxBuffer: 1024 * 1024 * 10}); } catch (error) { + this.logCommandFailure(command, error); logging.error('Failed to stop docker compose services:', error); throw error; } } execShellInService(service: string, shellCommand: string): string { - const command = `docker compose -f ${this.composeFilePath} -p ${this.projectName} run --rm -T --entrypoint sh ${service} -c "${shellCommand}"`; + const command = this.composeCommand(`run --rm -T --entrypoint sh ${service} -c "${shellCommand}"`); debug('readFileFromService running:', command); - return execSync(command, {encoding: 'utf-8'}).toString(); + return execSync(command, {encoding: 'utf-8'}); } execInService(service: string, command: string[]): string { const cmdArgs = command.map(arg => `"${arg}"`).join(' '); - const cmd = `docker compose -f ${this.composeFilePath} -p ${this.projectName} run --rm -T ${service} ${cmdArgs}`; + const cmd = this.composeCommand(`run --rm -T ${service} ${cmdArgs}`); debug('execInService running:', cmd); - return execSync(cmd, {encoding: 'utf-8'}).toString(); + return execSync(cmd, {encoding: 'utf-8'}); } async getContainerForService(serviceLabel: string): Promise { @@ -154,7 +158,7 @@ export class DockerCompose { logging.error('\n=== Docker compose logs ==='); const logs = execSync( - `docker compose -f ${this.composeFilePath} -p ${this.projectName} logs`, + this.composeCommand('logs'), {encoding: 'utf-8', maxBuffer: 1024 * 1024 * 10} // 10MB buffer for logs ); @@ -165,8 +169,56 @@ export class DockerCompose { } } + private ps(): void { + try { + logging.error('\n=== Docker compose ps -a ==='); + + const ps = execSync(this.composeCommand('ps -a'), { + encoding: 'utf-8', + maxBuffer: 1024 * 1024 * 10 + }); + + logging.error(ps); + logging.error('=== End docker compose ps -a ===\n'); + } catch (psError) { + debug('Could not get docker compose ps -a:', psError); + } + } + + private composeCommand(args: string): string { + return `docker compose -f ${this.composeFilePath} -p ${this.projectName} ${args}`; + } + + private logCommandFailure(command: string, error: unknown): void { + if (!(error instanceof Error)) { + return; + } + + const commandError = error as Error & { + stdout?: Buffer | string; + stderr?: Buffer | string; + }; + + const stdout = commandError.stdout?.toString().trim(); + const stderr = commandError.stderr?.toString().trim(); + + logging.error(`Command failed: ${command}`); + + if (stdout) { + logging.error('\n=== docker compose command stdout ==='); + logging.error(stdout); + logging.error('=== End docker compose command stdout ===\n'); + } + + if (stderr) { + logging.error('\n=== docker compose command stderr ==='); + logging.error(stderr); + logging.error('=== End docker compose command stderr ===\n'); + } + } + private async getContainers(): Promise { - const command = `docker compose -f ${this.composeFilePath} -p ${this.projectName} ps -a --format json`; + const command = this.composeCommand('ps -a --format json'); const output = execSync(command, {encoding: 'utf-8'}).trim(); if (!output) {