// Configure-CoolifyComposeApp.mjs // Drive the Coolify web UI (v4.1.2, Livewire) to configure an EXISTING git-based // application as a Docker Compose build-pack app. Needed because this instance's // REST API does NOT expose /applications/* (all 404) — see references/coolify-4.1.2-notes.md. // // It performs, idempotently and each guarded by a flag, the steps that the API can't: // 1. Build Pack -> Docker Compose (+ optional compose location) // 2. Reload Compose File (MANDATORY once, else deploy crashes Yaml::parse(null)) // 3. Env vars (Developer view bulk -> "Save All Environment Variables") // 4. Per-service domains (parsedServiceDomains..domain) so Traefik routes // the real FQDN instead of a random *.urieljareth.org subdomain. // // Trigger the actual deploy separately via the API: GET /deploy?uuid= // (that endpoint works even though app CRUD is 404). Then verify with Test-ServiceOnline.ps1. // // Env / args: // CF_STATE Playwright storageState from coolify-login.mjs (required) // CF_APP_URL .../project/{p}/environment/{e}/application/{a} (required) // CF_PW_BASE package.json whose node_modules has playwright (default: manager repo) // CF_BUILDPACK=dockercompose set the build pack (optional) // CF_COMPOSE_LOCATION=/docker-compose.coolify.yml (optional) // CF_RELOAD=1 click "Reload Compose File" (do this after buildpack switch / repo change) // CF_ENVBULK="K=V\nK2=V2" bulk env vars to Save All (optional) // CF_DOMAINS='{"web":"https://x.org","api":"https://y.org"}' per-service domains (optional) // CF_SHOT=path.png screenshot (optional) import { createRequire } from 'module'; const require = createRequire(process.env.CF_PW_BASE || 'H:/MegaSync/Proyectos/Proxmox & Coolify Manager/package.json'); const { chromium } = require('playwright'); const STATE = process.env.CF_STATE, URL = process.env.CF_APP_URL; if (!STATE || !URL) { console.error('Need CF_STATE and CF_APP_URL'); process.exit(2); } const SHOT = process.env.CF_SHOT || 'cf_configure.png'; const browser = await chromium.launch({ headless: true }); const ctx = await browser.newContext({ viewport: { width: 1600, height: 1400 }, ignoreHTTPSErrors: true, storageState: STATE }); const page = await ctx.newPage(); const dismiss = async () => { for (const t of ['Accept and Close','Acknowledge & Disable This Popup','Maybe next time']) { const b = page.getByRole('button',{name:t,exact:false}); if (await b.count().catch(()=>0)) { await b.first().click().catch(()=>{}); await page.waitForTimeout(400);} } }; const clickSave = async (label='Save') => { const b = page.getByRole('button',{name:label,exact:(label==='Save')}); const n = await b.count().catch(()=>0); for (let i=0;ifalse)){ await x.scrollIntoViewIfNeeded().catch(()=>{}); await x.click().catch(()=>{}); return true;} } return false; }; const go = async () => { await page.goto(URL,{waitUntil:'domcontentloaded',timeout:45000}); await page.waitForTimeout(3500); await dismiss(); }; try { // 1. Build Pack if (process.env.CF_BUILDPACK) { await go(); const bp = page.locator('select:has(option[value="dockercompose"])').first(); await bp.waitFor({ state:'attached', timeout:15000 }); if (await bp.inputValue().catch(()=>'') !== process.env.CF_BUILDPACK) { await bp.selectOption(process.env.CF_BUILDPACK); await page.waitForTimeout(1500); await clickSave(); await page.waitForTimeout(2500); console.log('buildPack ->', process.env.CF_BUILDPACK); } else console.log('buildPack already', process.env.CF_BUILDPACK); } // (optional) compose location if (process.env.CF_COMPOSE_LOCATION) { await go(); const inputs = await page.locator('input[type="text"]').all(); for (const inp of inputs) { const v = await inp.inputValue().catch(()=>''); const ph = await inp.getAttribute('placeholder').catch(()=>''); if (/docker-compose\.ya?ml/.test(v)||/docker-compose\.ya?ml/.test(ph||'')) { await inp.fill(process.env.CF_COMPOSE_LOCATION); await inp.blur().catch(()=>{}); break; } } await clickSave(); await page.waitForTimeout(2000); console.log('composeLocation ->', process.env.CF_COMPOSE_LOCATION); } // 2. Reload Compose File (persists parsed docker_compose; avoids Yaml::parse(null)) if (process.env.CF_RELOAD) { await go(); const rc = page.getByRole('button',{name:'Reload Compose File',exact:false}); if (await rc.count().catch(()=>0)) { await rc.first().click(); await page.waitForTimeout(4000); await clickSave(); await page.waitForTimeout(2500); console.log('reloaded compose file'); } else console.log('Reload Compose File button not found'); } // 3. Env vars (Developer view bulk) if (process.env.CF_ENVBULK) { await page.goto(URL.replace(/\/?$/, '') + '/environment-variables', { waitUntil:'domcontentloaded', timeout:45000 }); await page.waitForTimeout(3000); await dismiss(); const dev = page.getByRole('button',{name:'Developer view',exact:false}); if (await dev.count().catch(()=>0)) { await dev.first().click(); await page.waitForTimeout(1200); } const tas = await page.locator('textarea').all(); let best=null, area=-1; for (const ta of tas){ if(!(await ta.isVisible().catch(()=>false)))continue; const bb=await ta.boundingBox().catch(()=>null); const a=bb?bb.width*bb.height:0; if(a>area){area=a;best=ta;} } if (best){ await best.click(); await best.fill(process.env.CF_ENVBULK); await best.blur().catch(()=>{}); await page.waitForTimeout(500); await clickSave('Save All Environment Variables'); await page.waitForTimeout(2500); console.log('env vars saved'); } else console.log('bulk textarea not found'); } // 4. Per-service domains if (process.env.CF_DOMAINS) { const map = JSON.parse(process.env.CF_DOMAINS); await go(); for (const [svc, dom] of Object.entries(map)) { const sel = `input[wire\\:model="parsedServiceDomains.${svc}.domain"]`; const loc = page.locator(sel).first(); if (await loc.count().catch(()=>0)) { await loc.fill(dom); await loc.blur().catch(()=>{}); await page.waitForTimeout(400); console.log(`domain ${svc} -> ${dom}`); } else console.log(`domain input for '${svc}' not found`); } await clickSave(); await page.waitForTimeout(2500); } await page.screenshot({ path: SHOT, fullPage: true }).catch(()=>{}); console.log('DONE'); } catch (e) { console.error('ERR', e.message); await page.screenshot({ path: SHOT }).catch(()=>{}); process.exitCode = 1; } finally { await browser.close(); }