deploy_skill: soporte para Coolify v4.1.2 (API /applications 404) + flujo UI Playwright

Aprendido desplegando cotizador (Next.js+FastAPI+Postgres, Docker Compose,
repo privado) en coolify.urieljareth.org (v4.1.2):

- Documenta que /applications/* devuelve 404 en esta instancia y que configurar
  apps git build-from-source solo es posible por la UI web.
- Nuevos scripts scripts/coolify-ui/: coolify-login.mjs (guarda storageState) y
  Configure-CoolifyComposeApp.mjs (build pack -> Docker Compose, Reload Compose
  File, env vars por Developer view, dominios por servicio).
- references/coolify-4.1.2-notes.md: mapa de API funcional/404, gotchas (BOM
  UTF-8 rompe el parser YAML de Coolify; Reload Compose File obligatorio; pin de
  dominios por servicio o 503; no encolar deploys concurrentes; PowerShell
  [IO.File]::ReadAllText para payloads de llaves).
- env.local.template.ps1: COOLIFY_EMAIL/COOLIFY_PASSWORD para la UI.
- SKILL.md/TOOLS.md: seccion "instance reality" y flujo UI paso a paso.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
urieljareth
2026-07-08 00:42:12 -06:00
co-authored by Claude Opus 4.8
parent cd998ce6b0
commit e7d1c33cd5
22 changed files with 2935 additions and 0 deletions
@@ -0,0 +1,112 @@
<#
.SYNOPSIS
Initialize a project directory for compliant Coolify deployment.
.DESCRIPTION
Copies the right template (Node / Python / multi-container) into the target
project: Dockerfile, docker-compose, .dockerignore, plus a Coolify env
template. Idempotent: never overwrites existing files unless -Force.
.PARAMETER Path
Target project directory. Defaults to current directory.
.PARAMETER Stack
Which template to scaffold. One of: node, python, compose, static.
.PARAMETER AppPort
Listening port to bake into the Dockerfile and compose. Default 8080.
.PARAMETER Force
Overwrite existing Dockerfile / docker-compose / .dockerignore.
.EXAMPLE
.\Initialize-CoolifyProject.ps1 -Path .\my-app -Stack node -AppPort 8080
#>
param(
[string]$Path = ".",
[ValidateSet("node", "python", "compose", "static")]
[string]$Stack = "node",
[int]$AppPort = 8080,
[switch]$Force
)
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$templates = Join-Path $here "..\references\templates"
$target = (Resolve-Path -LiteralPath $Path).Path
function Copy-Template {
param([string]$Source, [string]$Dest)
if (-not (Test-Path -LiteralPath $Source)) { throw "Template not found: $Source" }
if ((Test-Path -LiteralPath $Dest) -and -not $Force) {
Write-Host " skip (exists): $Dest" -ForegroundColor Yellow
return
}
Copy-Item -LiteralPath $Source -Destination $Dest -Force:$Force
Write-Host " wrote: $Dest" -ForegroundColor Green
}
Write-Host "Initializing $target with stack=$Stack port=$AppPort" -ForegroundColor Cyan
switch ($Stack) {
"node" {
Copy-Template (Join-Path $templates "Dockerfile.node") (Join-Path $target "Dockerfile")
}
"python" {
Copy-Template (Join-Path $templates "Dockerfile.python") (Join-Path $target "Dockerfile")
}
"compose" {
Copy-Template (Join-Path $templates "Dockerfile.node") (Join-Path $target "Dockerfile")
Copy-Template (Join-Path $templates "docker-compose.app-db.yml") (Join-Path $target "docker-compose.yml")
}
"static" {
# Static sites are served by nginx in Coolify; no Dockerfile required.
Write-Host " (static stack: no Dockerfile needed; Coolify uses is_static + static_image)" -ForegroundColor DarkGray
}
}
# .dockerignore (idempotent)
$di = Join-Path $target ".dockerignore"
if ((-not (Test-Path -LiteralPath $di)) -or $Force) {
@(
".git",
".gitignore",
".env*",
"node_modules",
"vendor",
"__pycache__",
"*.log",
".vscode",
".idea",
"README.md"
) | Set-Content -LiteralPath $di -Encoding utf8
Write-Host " wrote: $di" -ForegroundColor Green
} else {
Write-Host " skip (exists): $di" -ForegroundColor Yellow
}
# Recommended .gitignore additions (idempotent, append-only if missing)
$gi = Join-Path $target ".gitignore"
$existing = if (Test-Path -LiteralPath $gi) { Get-Content -LiteralPath $gi } else { @() }
$additions = @(".env", ".env.local", ".env.*.local", "*.key", "*.pem") | Where-Object { $_ -notin $existing }
if ($additions) {
if (-not (Test-Path -LiteralPath $gi)) { "" | Set-Content -LiteralPath $gi -Encoding utf8 }
Add-Content -LiteralPath $gi -Value "`n# Added by deploy_skill"
$additions | ForEach-Object { Add-Content -LiteralPath $gi -Value $_ }
Write-Host " updated: $gi (+$($additions.Count) lines)" -ForegroundColor Green
} else {
Write-Host " skip (already covered): $gi" -ForegroundColor Yellow
}
Write-Host "`nNext steps:" -ForegroundColor Cyan
Write-Host " 1. Implement /health in your app (DB-independent, returns 200)."
Write-Host " 2. Put real secrets in a local .env.coolify file (not committed)."
Write-Host " 3. Run Test-PreDeployChecklist.ps1 to validate."
Write-Host " 4. Run New-GitHubRepo.ps1 to create the repo."
Write-Host " 5. git push -u origin main."
Write-Host " 6. Run New-CoolifyApplication.ps1 to register and deploy."
Write-Host " 7. Run Test-PostDeploy.ps1 to verify."
@@ -0,0 +1,112 @@
<#
.SYNOPSIS
Re-deploy a Coolify application from a previous commit SHA.
.DESCRIPTION
Implements the rollback path:
1. Force-push the target commit SHA to a new branch `rollback/<sha>` on
the origin remote (does NOT touch the main branch).
2. PATCH the Coolify application to point at the rollback branch + SHA,
OR update only git_commit_sha if the SHA is reachable from the
currently configured branch.
3. Trigger a fresh deploy.
This script does NOT rewrite public history. To avoid surprises, it always
asks for confirmation before pushing or deploying unless -Force is given.
.PARAMETER AppPath
Local path of the project Git repo. Defaults to current directory.
.PARAMETER CommitSha
Target commit SHA to roll back to (full or short). If omitted, prints the
last 10 commits so you can choose.
.PARAMETER ApplicationUuid
Coolify application UUID to update and redeploy.
.PARAMETER Branch
Branch currently configured in Coolify for this app. Default: 'main'.
.PARAMETER Force
Skip confirmation prompts.
.EXAMPLE
.\Invoke-CoolifyRollback.ps1 -ApplicationUuid og88wk4 -CommitSha a1b2c3d
#>
param(
[string]$AppPath = ".",
[string]$CommitSha,
[Parameter(Mandatory = $true)]
[string]$ApplicationUuid,
[string]$Branch = "main",
[switch]$Force
)
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$coolifyApi = Join-Path $here "..\..\coolify_skill\scripts\Invoke-CoolifyApi.ps1"
if (-not (Test-Path -LiteralPath $coolifyApi)) {
throw "Invoke-CoolifyApi.ps1 not found at: $coolifyApi"
}
$appPath = (Resolve-Path -LiteralPath $AppPath).Path
function Confirm-Step([string]$Action) {
if ($Force) { return $true }
$r = Read-Host "Confirm: $Action`nProceed? (y/N)"
return ($r -match '^(y|yes|s|si|sí)$')
}
Write-Host "Recent commits in $appPath :" -ForegroundColor Cyan
$commits = git -C $appPath log --oneline -10
$commits | ForEach-Object { Write-Host " $_" }
if (-not $CommitSha) {
$CommitSha = Read-Host "`nEnter commit SHA to roll back to"
}
if (-not $CommitSha) { throw "No commit SHA provided." }
$fullSha = git -C $appPath rev-parse $CommitSha 2>$null
if (-not $fullSha) { throw "Could not resolve commit: $CommitSha" }
Write-Host "`nResolved SHA: $fullSha" -ForegroundColor Green
$originUrl = git -C $appPath remote get-url origin 2>$null
if (-not $originUrl) { throw "No 'origin' remote configured in $appPath" }
Write-Host "Origin: $originUrl"
$rollbackBranch = "rollback/$($fullSha.Substring(0,8))"
if (-not (Confirm-Step "Push commit $fullSha to new branch '$rollbackBranch' on origin")) {
throw "Aborted by user."
}
git -C $appPath branch -f $rollbackBranch $fullSha | Out-Null
git -C $appPath push origin $rollbackBranch 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
if ($LASTEXITCODE -ne 0) { throw "git push failed." }
Write-Host "Pushed $rollbackBranch." -ForegroundColor Green
if (-not (Confirm-Step "PATCH /applications/$ApplicationUuid (git_branch=$rollbackBranch, git_commit_sha=$fullSha)")) {
throw "Aborted by user."
}
$body = @{
git_branch = $rollbackBranch
git_commit_sha = $fullSha
} | ConvertTo-Json -Compress
& $coolifyApi -Method PATCH -Path "/applications/$ApplicationUuid" -BodyJson $body -ErrorAction Stop | Out-Null
Write-Host "Application updated." -ForegroundColor Green
if (-not (Confirm-Step "Trigger DEPLOY of $ApplicationUuid at $fullSha")) {
Write-Host "Skipping deploy trigger." -ForegroundColor Yellow
return
}
$deployBody = @{ uuid = $ApplicationUuid } | ConvertTo-Json -Compress
& $coolifyApi -Method POST -Path "/deploy" -BodyJson $deployBody -ErrorAction Stop
Write-Host "`nRollback deploy triggered." -ForegroundColor Green
Write-Host "Note: main branch on GitHub is untouched. To make this the new HEAD of main, do it explicitly:" -ForegroundColor Yellow
Write-Host " git -C `"$appPath`" checkout main && git -C `"$appPath`" reset --hard $fullSha && git push --force-with-lease origin main" -ForegroundColor DarkGray
+62
View File
@@ -0,0 +1,62 @@
param(
[ValidateSet("GET", "POST", "PATCH", "PUT", "DELETE")]
[string]$Method = "GET",
[Parameter(Mandatory = $true)]
[string]$Path,
[string]$BodyJson,
[switch]$Raw
)
$ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) {
throw "Set GITHUB_TOKEN before calling the GitHub API. Generate a PAT at https://github.com/settings/tokens (scope 'repo') and store it in .env.local.ps1."
}
if (-not (Get-Command curl.exe -ErrorAction SilentlyContinue)) {
throw "curl.exe not found in PATH."
}
$base = if ($env:GITHUB_API_URL) { $env:GITHUB_API_URL.TrimEnd("/") } else { "https://api.github.com" }
$cleanPath = $Path.TrimStart("/")
$uri = "$base/$cleanPath"
$tmpBody = $null
$args = @("-sS", "-i", "-X", $Method.ToUpper(), "-H", "Authorization: Bearer $env:GITHUB_TOKEN", "-H", "Accept: application/vnd.github+json", "-H", "X-GitHub-Api-Version: 2022-11-28", "-H", "User-Agent: coolify-deploy-skill")
if ($PSBoundParameters.ContainsKey("BodyJson")) {
try { $null = $BodyJson | ConvertFrom-Json } catch { throw "BodyJson is not valid JSON: $($_.Exception.Message)" }
$tmpBody = [System.IO.Path]::GetTempFileName()
Set-Content -LiteralPath $tmpBody -Value $BodyJson -NoNewline -Encoding utf8
$args += @("--data", "@$tmpBody", "-H", "Content-Type: application/json")
}
try {
$output = & curl.exe @args $uri
$exitCode = $LASTEXITCODE
if ($exitCode -ne 0) { throw "GitHub API request failed with exit code $exitCode." }
$rawStr = ($output | Out-String)
$parts = $rawStr -split "(?:`r`n`r`n|`n`n)", 2
$headersBlock = if ($parts.Count -ge 1) { $parts[0] } else { "" }
$bodyBlock = if ($parts.Count -ge 2) { $parts[1] } else { "{}" }
$statusLine = ($headersBlock -split "`n" | Select-Object -First 1).Trim()
if ($statusLine -notmatch " 20[04-9] ") {
$snippet = $bodyBlock.Trim()
if ($snippet.Length -gt 400) { $snippet = $snippet.Substring(0, 400) + "..." }
throw "GitHub API HTTP error: $statusLine`nBody: $snippet"
}
if ($Raw) {
[pscustomobject]@{ Status = $statusLine; Headers = $headersBlock; Body = $bodyBlock }
} else {
try { $bodyBlock | ConvertFrom-Json }
catch { $bodyBlock }
}
}
finally {
if ($tmpBody -and (Test-Path -LiteralPath $tmpBody)) { Remove-Item -LiteralPath $tmpBody -Force -ErrorAction SilentlyContinue }
}
@@ -0,0 +1,241 @@
<#
.SYNOPSIS
Create or update a Coolify application from a Git repository and deploy it.
.DESCRIPTION
Orchestration wrapper around Invoke-CoolifyApi.ps1 that performs the full
Coolify onboarding flow for an existing Git repository:
1. Resolve or create the target Project (by name).
2. Resolve or create the target Environment (by name within the project).
3. Resolve the local server UUID (first reachable server).
4. Create the application (public repo by default, or via deploy key).
5. Set the FQDN and ports_exposes on the new app.
6. Push env vars (key=value pairs) onto the application.
7. Trigger an initial deploy and return the deployment UUIDs.
Each mutating step asks for explicit confirmation unless -Force is supplied.
The script NEVER writes secrets to stdout: env values are sent to the API
but not echoed.
.PARAMETER RepoUrl
HTTPS clone URL of the Git repository (e.g. https://github.com/user/repo).
.PARAMETER Branch
Branch to deploy. Defaults to 'main'.
.PARAMETER Fqdn
Public URL Coolify will expose, e.g. https://myapp.urieljareth.org.
.PARAMETER ProjectName
Coolify project name. If it does not exist, it will be created. Default: 'default'.
.PARAMETER EnvironmentName
Coolify environment name within the project. Default: 'production'.
.PARAMETER PortsExposes
Internal port the app listens on (Traefik targets this). Default: '3000'.
.PARAMETER BuildPack
Optional Coolify build pack override (nixpacks, dockerfile, dockercompose).
.PARAMETER IsStatic
Switch. Marks the app as a static site (Coolify will use a static_image).
.PARAMETER EnvFile
Optional path to a .env-formatted file. Each KEY=VALUE line is pushed to
Coolify as an environment variable. Lines starting with '#' are ignored.
.PARAMETER ApplicationUuid
Optional. If the app already exists in Coolify, pass its UUID to update it
in place (FQDN, ports, env) and redeploy, instead of creating a new one.
.PARAMETER Force
Skip per-step confirmation prompts. Use only after validating the plan.
.EXAMPLE
$envs = "./.env.coolify"
.\New-CoolifyApplication.ps1 -RepoUrl https://github.com/urieljarethbusiness-cpu/my-app `
-Fqdn https://myapp.urieljareth.org -PortsExposes 8080 -EnvFile $envs -Force
#>
param(
[Parameter(Mandatory = $true, ParameterSetName = "Create")]
[string]$RepoUrl,
[string]$Branch = "main",
[Parameter(Mandatory = $true)]
[string]$Fqdn,
[string]$ProjectName = "default",
[string]$EnvironmentName = "production",
[string]$PortsExposes = "3000",
[string]$BuildPack,
[switch]$IsStatic,
[string]$EnvFile,
[Parameter(ParameterSetName = "Update")]
[string]$ApplicationUuid,
[switch]$Force
)
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$coolifyApi = Join-Path $here "..\..\coolify_skill\scripts\Invoke-CoolifyApi.ps1"
if (-not (Test-Path -LiteralPath $coolifyApi)) {
throw "Invoke-CoolifyApi.ps1 not found at: $coolifyApi"
}
function Confirm-Step {
param([string]$Action)
if ($Force) { return $true }
$reply = Read-Host "Confirm action: $Action`nProceed? (y/N)"
return ($reply -match '^(y|yes|s|si|sí)$')
}
function Get-CoolifyResource {
param([string]$Path)
& $coolifyApi -Path $Path -ErrorAction Stop
}
function Resolve-Project {
param([string]$Name)
$projects = Get-CoolifyResource -Path "/projects"
$match = $projects | Where-Object { $_.name -eq $Name } | Select-Object -First 1
if (-not $match) {
if (-not (Confirm-Step "Create project '$Name' in Coolify")) {
throw "Aborted by user before creating project."
}
$body = @{ name = $Name } | ConvertTo-Json -Compress
$created = & $coolifyApi -Method POST -Path "/projects" -BodyJson $body -ErrorAction Stop
Write-Host "Project created: $($created.uuid)" -ForegroundColor Green
return $created
}
return $match
}
function Resolve-Environment {
param([string]$ProjectUuid, [string]$Name)
$envs = Get-CoolifyResource -Path "/projects/$ProjectUuid/environments"
$match = $envs | Where-Object { $_.name -eq $Name } | Select-Object -First 1
if (-not $match) {
if (-not (Confirm-Step "Create environment '$Name' in project $ProjectUuid")) {
throw "Aborted by user before creating environment."
}
$body = @{ name = $Name } | ConvertTo-Json -Compress
$created = & $coolifyApi -Method POST -Path "/projects/$ProjectUuid/environments" -BodyJson $body -ErrorAction Stop
Write-Host "Environment created: $($created.uuid)" -ForegroundColor Green
return $created
}
return $match
}
function Resolve-ServerUuid {
$servers = Get-CoolifyResource -Path "/servers"
if (-not $servers) { throw "No Coolify servers configured." }
$srv = $servers | Where-Object { $_.settings.is_usable -eq $true } | Select-Object -First 1
if (-not $srv) { $srv = $servers | Select-Object -First 1 }
return $srv.uuid
}
function Resolve-AppUuid {
param([string]$Uuid)
if ($Uuid) {
$app = Get-CoolifyResource -Path "/applications/$Uuid"
return @{ uuid = $app.uuid; created = $false }
}
return $null
}
Write-Host "Resolving Coolify project '$ProjectName'..." -ForegroundColor Cyan
$proj = Resolve-Project -Name $ProjectName
Write-Host "Resolving environment '$EnvironmentName'..." -ForegroundColor Cyan
$env = Resolve-Environment -ProjectUuid $proj.uuid -Name $EnvironmentName
Write-Host "Resolving server..." -ForegroundColor Cyan
$serverUuid = Resolve-ServerUuid
Write-Host "Using server: $serverUuid" -ForegroundColor Cyan
$existing = Resolve-AppUuid -Uuid $ApplicationUuid
if ($existing) {
Write-Host "Updating existing application: $($existing.uuid)" -ForegroundColor Cyan
$appUuid = $existing.uuid
$patch = [ordered]@{
domains = $Fqdn
ports_exposes = $PortsExposes
}
if ($BuildPack) { $patch.build_pack = $BuildPack }
if (-not (Confirm-Step "PATCH /applications/$appUuid (domain=$Fqdn, ports=$PortsExposes)")) {
throw "Aborted by user."
}
& $coolifyApi -Method PATCH -Path "/applications/$appUuid" -BodyJson ($patch | ConvertTo-Json -Compress) -ErrorAction Stop | Out-Null
} else {
$createBody = [ordered]@{
project_uuid = $proj.uuid
environment_name = $env.name
server_uuid = $serverUuid
git_repository = $RepoUrl
git_branch = $Branch
ports_exposes = $PortsExposes
domains = $Fqdn
name = Split-Path -Leaf ($RepoUrl -replace '\.git$', '')
is_static = [bool]$IsStatic
instant_deploy = $false
}
if ($BuildPack) { $createBody.build_pack = $BuildPack }
if (-not (Confirm-Step "POST /applications/public with repo=$RepoUrl, branch=$Branch, fqdn=$Fqdn, ports=$PortsExposes")) {
throw "Aborted by user."
}
$created = & $coolifyApi -Method POST -Path "/applications/public" -BodyJson ($createBody | ConvertTo-Json -Compress) -ErrorAction Stop
$appUuid = $created.uuid
Write-Host "Application created: $appUuid" -ForegroundColor Green
}
if ($EnvFile -and (Test-Path -LiteralPath $EnvFile)) {
if (-not (Confirm-Step "Push env vars from $EnvFile to application $appUuid")) {
Write-Host "Skipping env push." -ForegroundColor Yellow
} else {
Get-Content -LiteralPath $EnvFile | ForEach-Object {
$line = $_.Trim()
if (-not $line -or $line.StartsWith("#")) { return }
$eq = $line.IndexOf("=")
if ($eq -le 0) { return }
$k = $line.Substring(0, $eq).Trim()
$v = $line.Substring($eq + 1).Trim().Trim('"').Trim("'")
$body = [ordered]@{
key = $k
value = $v
is_literal = $true
is_shown_once = $false
} | ConvertTo-Json -Compress
& $coolifyApi -Method POST -Path "/applications/$appUuid/envs" -BodyJson $body -ErrorAction Stop | Out-Null
Write-Host " env: $k set" -ForegroundColor DarkGray
}
Write-Host "Env vars pushed." -ForegroundColor Green
}
}
if (-not (Confirm-Step "Trigger DEPLOY of $appUuid")) {
Write-Host "Skipping deploy trigger. Application is created but not deployed." -ForegroundColor Yellow
return
}
$deployResult = & $coolifyApi -Method POST -Path "/deploy" -BodyJson (@{ uuid = $appUuid } | ConvertTo-Json -Compress) -ErrorAction Stop
Write-Host "Deploy triggered." -ForegroundColor Green
$deployResult
[PSCustomObject]@{
project_uuid = $proj.uuid
environment_name = $env.name
server_uuid = $serverUuid
application_uuid = $appUuid
fqdn = $Fqdn
repo = $RepoUrl
branch = $Branch
} | Format-List
+319
View File
@@ -0,0 +1,319 @@
<#
.SYNOPSIS
Create or update a Coolify SERVICE (Docker Compose multi-container stack)
from a local project's compose file.
.DESCRIPTION
Complements New-CoolifyApplication.ps1 (which only handles single-app
Dockerfile builds via POST /applications/public). This script handles
multi-container stacks via POST /services - the modern replacement for
the deprecated POST /applications/dockercompose.
Workflow:
1. Resolve (or create) the target Coolify Project and Environment.
2. Resolve the local server UUID.
3. Read the docker-compose file from the project (default:
docker-compose.coolify.yml). The compose must already satisfy the
hard rules from docs/AGENTS-coolify-apps.md (no published 80/443,
service-name DB host, healthchecks, named volumes, SERVICE_FQDN_*
placeholders so Coolify can wire routes).
4. POST /services with the raw compose + URL mappings (which service
gets which FQDN).
5. Push env vars from an optional .env file to the service.
6. Trigger an initial deploy (unless -NoDeploy).
Each mutating step asks for confirmation unless -Force is supplied.
.PARAMETER AppPath
Local path of the project that contains the compose file. Required.
.PARAMETER AppName
Service name in Coolify. Required. Becomes the resource name; Coolify
auto-suffixes with a short UUID when it instantiates sub-services.
.PARAMETER ComposeFile
Compose file name (relative to AppPath) or absolute path.
Default: docker-compose.coolify.yml
.PARAMETER Fqdn
Primary public URL, e.g. https://cotizador.urieljareth.org. The FQDN is
mapped to the -PrimaryService in the compose via the `urls` payload.
Required.
.PARAMETER PrimaryService
The compose service name that should receive the -Fqdn. Default: 'web'.
Set this to whatever your compose calls the main app service.
.PARAMETER ExtraUrls
Optional hashtable mapping additional compose service names to FQDNs.
Example: @{ api = 'https://cotizador-api.urieljareth.org' }
Use when your compose exposes more than one public service.
.PARAMETER ProjectName
Coolify project name. Created if missing. Default: 'default'.
.PARAMETER EnvironmentName
Coolify environment name within the project. Default: 'production'.
.PARAMETER EnvFile
Optional .env-formatted file. Each KEY=VALUE line is pushed to Coolify
as a service-level env var. Lines starting with '#' are ignored.
.PARAMETER InstantDeploy
Pass instant_deploy=true to the create call. Coolify starts the stack
immediately after creation. Mutually exclusive with -NoDeploy.
.PARAMETER NoDeploy
Do not trigger a deploy. Service is created with env vars but not started.
Use this when you want to review in the UI before the first start.
.PARAMETER ServiceUuid
Optional. If a service already exists in Coolify, pass its UUID to PATCH
it (compose_raw, urls) in place instead of creating a new one.
.PARAMETER Force
Skip per-step confirmation prompts.
.EXAMPLE
.\New-CoolifyService.ps1 `
-AppPath .\cotizador `
-AppName cotizador `
-Fqdn https://cotizador.urieljareth.org `
-PrimaryService web `
-EnvFile .\cotizador\.env.coolify `
-InstantDeploy
#>
param(
[Parameter(Mandatory = $true)]
[string]$AppPath,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Za-z0-9._-]+$')]
[string]$AppName,
[string]$ComposeFile = "docker-compose.coolify.yml",
[Parameter(Mandatory = $true)]
[string]$Fqdn,
[string]$PrimaryService = "web",
[hashtable]$ExtraUrls,
[string]$ProjectName = "default",
[string]$EnvironmentName = "production",
[string]$EnvFile,
[switch]$InstantDeploy,
[switch]$NoDeploy,
[string]$ServiceUuid,
[switch]$Force
)
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$coolifyApi = Join-Path $here "..\..\coolify_skill\scripts\Invoke-CoolifyApi.ps1"
if (-not (Test-Path -LiteralPath $coolifyApi)) {
throw "Invoke-CoolifyApi.ps1 not found at: $coolifyApi"
}
function Confirm-Step {
param([string]$Action)
if ($Force) { return $true }
$reply = Read-Host "Confirm action: $Action`nProceed? (y/N)"
return ($reply -match '^(y|yes|s|si|sí)$')
}
function Get-CoolifyResource {
param([string]$Path)
& $coolifyApi -Path $Path -Raw -ErrorAction Stop
}
function Resolve-Project {
param([string]$Name)
$projects = Get-CoolifyResource -Path "/projects"
$match = $projects | Where-Object { $_.name -eq $Name } | Select-Object -First 1
if (-not $match) {
if (-not (Confirm-Step "Create project '$Name' in Coolify")) {
throw "Aborted by user before creating project."
}
$body = @{ name = $Name } | ConvertTo-Json -Compress
$created = & $coolifyApi -Method POST -Path "/projects" -BodyJson $body -Raw -ErrorAction Stop
Write-Host "Project created: $($created.uuid)" -ForegroundColor Green
return $created
}
return $match
}
function Resolve-Environment {
param([string]$ProjectUuid, [string]$Name)
$envs = Get-CoolifyResource -Path "/projects/$ProjectUuid/environments"
$match = $envs | Where-Object { $_.name -eq $Name } | Select-Object -First 1
if (-not $match) {
if (-not (Confirm-Step "Create environment '$Name' in project $ProjectUuid")) {
throw "Aborted by user before creating environment."
}
$body = @{ name = $Name } | ConvertTo-Json -Compress
$created = & $coolifyApi -Method POST -Path "/projects/$ProjectUuid/environments" -BodyJson $body -Raw -ErrorAction Stop
Write-Host "Environment created: $($created.uuid)" -ForegroundColor Green
return $created
}
return $match
}
function Resolve-ServerUuid {
$servers = Get-CoolifyResource -Path "/servers"
if (-not $servers) { throw "No Coolify servers configured." }
$srv = $servers | Where-Object { $_.settings.is_usable -eq $true } | Select-Object -First 1
if (-not $srv) { $srv = $servers | Select-Object -First 1 }
return $srv.uuid
}
# ─── Resolve inputs ──────────────────────────────────────────────────────────
$appPath = (Resolve-Path -LiteralPath $AppPath).Path
# Compose file: try as-is (absolute), else relative to AppPath
$composePath = if ([System.IO.Path]::IsPathRooted($ComposeFile)) {
$ComposeFile
} else {
Join-Path $appPath $ComposeFile
}
if (-not (Test-Path -LiteralPath $composePath)) {
throw "Compose file not found: $composePath (resolved from -ComposeFile '$ComposeFile' against AppPath '$appPath')"
}
$composeRaw = Get-Content -LiteralPath $composePath -Raw -ErrorAction Stop
Write-Host "Loaded compose: $composePath ($($composeRaw.Length) bytes)" -ForegroundColor Cyan
# URLs array: primary + extras
$urlsList = @(
[pscustomobject]@{ name = $PrimaryService; url = $Fqdn }
)
if ($ExtraUrls -and $ExtraUrls.Count -gt 0) {
foreach ($k in $ExtraUrls.Keys) {
$urlsList += [pscustomobject]@{ name = $k; url = $ExtraUrls[$k] }
}
}
Write-Host "Resolving Coolify project '$ProjectName'..." -ForegroundColor Cyan
$proj = Resolve-Project -Name $ProjectName
Write-Host "Resolving environment '$EnvironmentName'..." -ForegroundColor Cyan
$env = Resolve-Environment -ProjectUuid $proj.uuid -Name $EnvironmentName
Write-Host "Resolving server..." -ForegroundColor Cyan
$serverUuid = Resolve-ServerUuid
Write-Host "Using server: $serverUuid" -ForegroundColor Cyan
# ─── Create or update the service ────────────────────────────────────────────
if ($ServiceUuid) {
Write-Host "Updating existing service: $ServiceUuid" -ForegroundColor Cyan
$patch = [ordered]@{
name = $AppName
docker_compose_raw = $composeRaw
urls = $urlsList
project_uuid = $proj.uuid
environment_name = $env.name
server_uuid = $serverUuid
}
if (-not (Confirm-Step "PATCH /services/$ServiceUuid (compose=$($composePath), urls=$($urlsList.Count), fqdn=$Fqdn)")) {
throw "Aborted by user."
}
$result = & $coolifyApi -Method PATCH -Path "/services/$ServiceUuid" -BodyJson ($patch | ConvertTo-Json -Depth 6) -Raw -ErrorAction Stop
$svcUuid = $ServiceUuid
} else {
$createBody = [ordered]@{
type = "one-click-service" # custom service; Coolify accepts any non-empty string here
name = $AppName
project_uuid = $proj.uuid
environment_name = $env.name
server_uuid = $serverUuid
docker_compose_raw = $composeRaw
urls = $urlsList
instant_deploy = [bool]$InstantDeploy
}
if (-not (Confirm-Step "POST /services with AppName=$AppName, compose=$($composePath), fqdn=$Fqdn -> $PrimaryService, instant_deploy=$InstantDeploy")) {
throw "Aborted by user."
}
$result = & $coolifyApi -Method POST -Path "/services" -BodyJson ($createBody | ConvertTo-Json -Depth 6) -Raw -ErrorAction Stop
$svcUuid = $result.uuid
Write-Host "Service created: $svcUuid" -ForegroundColor Green
}
# Show domains assigned (Coolify echoes the resolved FQDNs)
if ($result.domains) {
Write-Host "Domains assigned:" -ForegroundColor Cyan
$result.domains | ForEach-Object { Write-Host " $_" }
}
# ─── Push env vars ───────────────────────────────────────────────────────────
if ($EnvFile -and (Test-Path -LiteralPath $EnvFile)) {
if (-not (Confirm-Step "Push env vars from $EnvFile to service $svcUuid")) {
Write-Host "Skipping env push." -ForegroundColor Yellow
} else {
$pushed = 0
Get-Content -LiteralPath $EnvFile | ForEach-Object {
$line = $_.Trim()
if (-not $line -or $line.StartsWith("#")) { return }
$eq = $line.IndexOf("=")
if ($eq -le 0) { return }
$k = $line.Substring(0, $eq).Trim()
$v = $line.Substring($eq + 1).Trim().Trim('"').Trim("'")
$body = [ordered]@{
key = $k
value = $v
is_literal = $true
is_shown_once = $false
} | ConvertTo-Json -Compress
try {
& $coolifyApi -Method POST -Path "/services/$svcUuid/envs" -BodyJson $body -ErrorAction Stop | Out-Null
Write-Host " env: $k set" -ForegroundColor DarkGray
$pushed++
} catch {
Write-Host " env: $k FAILED -> $($_.Exception.Message)" -ForegroundColor Red
}
}
Write-Host "Env vars pushed: $pushed" -ForegroundColor Green
}
} elseif ($EnvFile) {
Write-Host "EnvFile specified but not found: $EnvFile" -ForegroundColor Yellow
}
# ─── Deploy trigger (if not instant) ─────────────────────────────────────────
if ($NoDeploy) {
Write-Host "Service created with env vars. -NoDeploy set: skipping deploy trigger." -ForegroundColor Yellow
} elseif ($InstantDeploy -and -not $ServiceUuid) {
Write-Host "instant_deploy=true: Coolify is already starting the stack." -ForegroundColor Green
} else {
if (-not (Confirm-Step "Trigger DEPLOY of service $svcUuid")) {
Write-Host "Skipping deploy trigger. Service is created but not started." -ForegroundColor Yellow
} else {
# Coolify's "deploy" endpoint for services is POST /services/{uuid}/start (or the unified /deploy with type).
# /services/{uuid}/start is the documented happy path; it queues the stack start.
try {
& $coolifyApi -Method POST -Path "/services/$svcUuid/start" -ErrorAction Stop | Out-Null
Write-Host "Deploy triggered (POST /services/$svcUuid/start)." -ForegroundColor Green
} catch {
Write-Host " POST /services/$svcUuid/start failed: $($_.Exception.Message)" -ForegroundColor Yellow
Write-Host " Try the Coolify UI 'Deploy' button or POST /services/$svcUuid/start manually." -ForegroundColor Yellow
}
}
}
[PSCustomObject]@{
project_uuid = $proj.uuid
environment_name = $env.name
server_uuid = $serverUuid
service_uuid = $svcUuid
name = $AppName
primary_fqdn = $Fqdn
primary_service = $PrimaryService
compose_source = $composePath
} | Format-List
+85
View File
@@ -0,0 +1,85 @@
<#
.SYNOPSIS
Create a new GitHub repository for a project that will be deployed to Coolify.
.DESCRIPTION
Calls the GitHub REST API with GITHUB_TOKEN to create a new repository under
the authenticated user. After creation, prints the clone URL (HTTPS) and the
SSH push URL. Idempotent in the sense that if the repo already exists it
aborts with a clear message instead of failing.
.PARAMETER Name
Repository name (required). GitHub naming rules: lowercase, digits, '-' '_' '.'.
.PARAMETER Description
Short description shown on GitHub.
.PARAMETER Private
Switch. If set, creates a private repository. Default is public (Coolify
public-app deploy does not need a deploy key).
.PARAMETER Owner
Optional. Organization or user to create the repo under. Defaults to the
authenticated user (the owner of GITHUB_TOKEN).
.EXAMPLE
.\New-GitHubRepo.ps1 -Name "my-cool-app" -Description "Demo" -Private
#>
param(
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Za-z0-9._-]+$')]
[string]$Name,
[string]$Description = "",
[switch]$Private,
[string]$Owner
)
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$ghApi = Join-Path $here "Invoke-GitHubApi.ps1"
$me = & $ghApi -Path "/user" -ErrorAction Stop
$effectiveOwner = if ($Owner) { $Owner } else { $me.login }
Write-Host "Authenticated GitHub user: $($me.login)" -ForegroundColor Cyan
$exists = $null
try {
$exists = & $ghApi -Path "/repos/$effectiveOwner/$Name" -ErrorAction Stop
} catch {
$exists = $null
}
if ($exists) {
Write-Host "Repository already exists: https://github.com/$effectiveOwner/$Name" -ForegroundColor Yellow
Write-Host "clone_url: $($exists.clone_url)"
return $exists
}
$body = [ordered]@{
name = $Name
description = $Description
private = [bool]$Private
auto_init = $true
has_issues = $true
has_wiki = $false
} | ConvertTo-Json -Compress
$endpoint = if ($Owner) { "/orgs/$Owner/repos" } else { "/user/repos" }
Write-Host "Creating repository at: $endpoint" -ForegroundColor Cyan
$created = & $ghApi -Method POST -Path $endpoint -BodyJson $body -ErrorAction Stop
[PSCustomObject]@{
full_name = $created.full_name
html_url = $created.html_url
clone_url = $created.clone_url
ssh_url = $created.ssh_url
private = $created.private
default_branch = $created.default_branch
} | Format-List
$created
@@ -0,0 +1,156 @@
<#
.SYNOPSIS
End-to-end deployment pipeline for a project going to this Coolify instance.
.DESCRIPTION
Orchestrates the full flow:
1. Initialize-CoolifyProject (optional, if -Init)
2. Test-PreDeployChecklist (gate; aborts on FAIL)
3. New-GitHubRepo (creates the repo, idempotent)
4. git init/add/commit/push (local repo -> origin)
5. New-CoolifyApplication (project + env + app + envs + deploy)
6. Test-PostDeploy (verification)
All mutating steps ask for explicit confirmation unless -Force is given.
.PARAMETER AppPath
Local path of the project. Defaults to current directory.
.PARAMETER AppName
Name used for the GitHub repo and the Coolify app. Required.
.PARAMETER Fqdn
https://<name>.urieljareth.org. Required.
.PARAMETER Stack
Template to scaffold if -Init: node, python, compose, static.
.PARAMETER AppPort
Internal listening port. Default 8080.
.PARAMETER EnvFile
Optional .env file with KEY=VALUE lines to push into Coolify.
.PARAMETER Branch
Branch to push and deploy. Default: main.
.PARAMETER Init
Scaffolde Dockerfile/compose before pushing (skip if already present).
.PARAMETER Force
Skip per-step confirmation. Use with care.
.EXAMPLE
.\Publish-ProjectToCoolify.ps1 -AppPath .\my-app -AppName my-app `
-Fqdn https://my-app.urieljareth.org -Stack node -AppPort 8080
#>
param(
[string]$AppPath = ".",
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Za-z0-9._-]+$')]
[string]$AppName,
[Parameter(Mandatory = $true)]
[string]$Fqdn,
[ValidateSet("node", "python", "compose", "static")]
[string]$Stack = "node",
[int]$AppPort = 8080,
[string]$EnvFile,
[string]$Branch = "main",
[switch]$Init,
[switch]$Force
)
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
function Call-Step([string]$Label, [scriptblock]$Block) {
Write-Host "`n>>> $Label`n" -ForegroundColor Magenta
& $Block
if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { throw "Step failed: $Label (exit $LASTEXITCODE)" }
}
function Confirm-Step([string]$Action) {
if ($Force) { return $true }
$r = Read-Host "Confirm: $Action`nProceed? (y/N)"
return ($r -match '^(y|yes|s|si|sí)$')
}
$appPath = (Resolve-Path -LiteralPath $AppPath).Path
if ($Init) {
Call-Step "1. Scaffold Dockerfile/compose (Initialize-CoolifyProject)" {
& (Join-Path $here "Initialize-CoolifyProject.ps1") -Path $appPath -Stack $Stack -AppPort $AppPort
}
}
Call-Step "2. Pre-deploy checklist (Test-PreDeployChecklist)" {
& (Join-Path $here "Test-PreDeployChecklist.ps1") -Path $appPath -ExpectedPort $AppPort -Strict
}
Call-Step "3. Create GitHub repo (New-GitHubRepo)" {
& (Join-Path $here "New-GitHubRepo.ps1") -Name $AppName -Description "Deployed via coolify-deploy skill"
}
$origin = "https://github.com/urieljarethbusiness-cpu/$AppName.git"
Write-Host "`nOrigin: $origin" -ForegroundColor Cyan
Push-Location -LiteralPath $appPath
try {
if (-not (Test-Path -LiteralPath (Join-Path $appPath ".git"))) {
if (-not (Confirm-Step "git init + add remote origin")) { throw "Aborted." }
git init -b $Branch
git remote add origin $origin
} else {
$existing = git remote get-url origin 2>$null
if ($existing -ne $origin) {
if (-not (Confirm-Step "git remote set-url origin -> $origin")) { throw "Aborted." }
git remote set-url origin $origin
}
}
Call-Step "4. git add / commit / push" {
git add -A
$pending = (git status --porcelain).Count
if ($pending -gt 0) {
if (-not (Confirm-Step "Commit $pending change(s) and push to origin/$Branch")) { throw "Aborted." }
git commit -m "Initial deployment via coolify-deploy skill"
} else {
Write-Host " nothing to commit (clean tree)" -ForegroundColor DarkGray
}
git push -u origin $Branch 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
}
} finally {
Pop-Location
}
Call-Step "5. Create Coolify application + deploy (New-CoolifyApplication)" {
$args = @(
"-RepoUrl", $origin,
"-Branch", $Branch,
"-Fqdn", $Fqdn,
"-PortsExposes", "$AppPort"
)
if ($EnvFile) { $args += @("-EnvFile", $EnvFile) }
if ($Force) { $args += "-Force" }
& (Join-Path $here "New-CoolifyApplication.ps1") @args
}
Call-Step "6. Post-deploy inspection (Test-PostDeploy)" {
& (Join-Path $here "Test-PostDeploy.ps1") -Fqdn $Fqdn -ContainerName "$AppName-main"
}
Call-Step "7. Operational verification: HTTP 200 + Playwright (Test-ServiceOnline)" {
& (Join-Path $here "Test-ServiceOnline.ps1") -Fqdn $Fqdn -Retries 8
}
Write-Host "`n=== Pipeline complete ===" -ForegroundColor Green
Write-Host "App: $Fqdn" -ForegroundColor Cyan
Write-Host "Repo: $origin" -ForegroundColor Cyan
+75
View File
@@ -0,0 +1,75 @@
<#
.SYNOPSIS
Verify a Coolify application after a deploy.
.DESCRIPTION
Implements section 8 of docs/AGENTS-coolify-apps.md: runs the public
endpoint check, the sibling DNS check from inside the app container, the
container status snapshot, and the proxy logs for routing/cert errors.
Requires the existing repo scripts (Invoke-ProxmoxSsh.ps1,
Get-CoolifyDockerStatus.ps1, Invoke-CoolifyApi.ps1). No new dependencies.
.PARAMETER Fqdn
Full public URL, e.g. https://myapp.urieljareth.org.
.PARAMETER ContainerName
App container name as seen by `docker ps` inside LXC 102 (used for the
sibling DNS check and the proxy log filter).
.PARAMETER SiblingService
Optional Docker service name to verify with `getent hosts` from inside the
app container. Example: 'postgres' or 'app-db'.
.PARAMETER ApplicationUuid
Optional Coolify application UUID. If provided, also fetches the app status
via API and lists recent deployments.
.EXAMPLE
.\Test-PostDeploy.ps1 -Fqdn https://myapp.urieljareth.org `
-ContainerName my-app-main -SiblingService postgres
#>
param(
[Parameter(Mandatory = $true)]
[string]$Fqdn,
[Parameter(Mandatory = $true)]
[string]$ContainerName,
[string]$SiblingService,
[string]$ApplicationUuid
)
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = (Resolve-Path (Join-Path $here "..\..")).Path
$ssh = Join-Path $repoRoot "scripts\Invoke-ProxmoxSsh.ps1"
$dockerStatus = Join-Path $repoRoot "coolify_skill\scripts\Get-CoolifyDockerStatus.ps1"
$coolifyApi = Join-Path $repoRoot "coolify_skill\scripts\Invoke-CoolifyApi.ps1"
function Get-SectionTitle($t) { Write-Host "`n=== $t ===" -ForegroundColor Cyan }
Get-SectionTitle "1. Public endpoint HTTP check ($Fqdn)"
& curl.exe -sSI $Fqdn --max-time 15 | Select-Object -First 8
Get-SectionTitle "2. Container status in LXC 102"
& $dockerStatus -Filter $ContainerName
if ($SiblingService) {
Get-SectionTitle "3. Sibling DNS from inside '$ContainerName' -> '$SiblingService'"
& $ssh -Command "pct exec 102 -- docker exec $ContainerName getent hosts $SiblingService" 2>&1
}
Get-SectionTitle "4. coolify-proxy logs (filter: error | acme | certificate | $ContainerName)"
$cmd = "pct exec 102 -- docker logs coolify-proxy --tail 80 2>&1 | grep -iE 'error|certificate|acme|$ContainerName' | tail -30"
& $ssh -Command $cmd 2>&1
if ($ApplicationUuid) {
Get-SectionTitle "5. Coolify API: app status + recent deployments"
& $coolifyApi -Path "/applications/$ApplicationUuid" 2>&1 | Select-String -Pattern '"status"|"fqdn"|"name"|"ports_exposes"'
& $coolifyApi -Path "/applications/$ApplicationUuid/deployments" 2>&1 | Select-Object -First 40
}
Write-Host "`nVerification complete." -ForegroundColor Green
@@ -0,0 +1,177 @@
<#
.SYNOPSIS
Validate a project directory against the Coolify hard rules before pushing.
.DESCRIPTION
Implements the pre-deploy checklist from docs/AGENTS-coolify-apps.md as an
automated gate. Run this BEFORE pushing to GitHub and BEFORE creating the
Coolify application. Reports each rule as PASS / WARN / FAIL with the
concrete fix for any failure.
The script does NOT modify files. It only inspects.
.PARAMETER Path
Project directory to validate. Defaults to the current directory.
.PARAMETER ExpectedPort
Port the app is expected to listen on. Used to cross-check against
ports_exposes if a compose file declares it, and against Dockerfile EXPOSE.
.PARAMETER Strict
Treat WARN as failures (exit code 1). Default: only FAIL fails.
.EXAMPLE
.\Test-PreDeployChecklist.ps1 -Path .\my-app -ExpectedPort 8080
#>
param(
[string]$Path = ".",
[string]$ExpectedPort,
[switch]$Strict
)
$ErrorActionPreference = "Stop"
$root = (Resolve-Path -LiteralPath $Path).Path
$results = New-Object System.Collections.Generic.List[object]
function Add-Result {
param([string]$Id, [string]$Status, [string]$Message, [string]$Fix = "")
$results.Add([PSCustomObject]@{
Id = $Id
Status = $Status
Message = $Message
Fix = $Fix
})
}
$composeFiles = @("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml") |
ForEach-Object { Join-Path $root $_ } |
Where-Object { Test-Path -LiteralPath $_ }
$dockerfile = @("Dockerfile", "Dockerfile.dev") |
ForEach-Object { Join-Path $root $_ } |
Where-Object { Test-Path -LiteralPath $_ } |
Select-Object -First 1
# Rule 2.1 - DB host is service name, not localhost
foreach ($cf in $composeFiles) {
$txt = Get-Content -LiteralPath $cf -Raw
if ($txt -match 'localhost|127\.0\.0\.1') {
$svc = ($txt -split "`n" | Select-String -Pattern 'localhost|127\.0\.0\.1' | Select-Object -First 1)
Add-Result "2.1-sibling-dns" "WARN" "$cf references localhost/127.0.0.1; if it is a DB/cache host for a sibling service, use the Docker service name." "Replace localhost with the sibling service name (see AGENTS-coolify-apps.md 2.1)."
}
}
if ($results | Where-Object Id -eq '2.1-sibling-dns') {
# already added
} else {
Add-Result "2.1-sibling-dns" "PASS" "No localhost/127.0.0.1 reference detected in compose files."
}
# Rule 2.2 - coolify external network if app references shared services
foreach ($cf in $composeFiles) {
$txt = Get-Content -LiteralPath $cf -Raw
if (($txt -match 'postgres|redis|mysql|mariadb|mongodb') -and ($txt -notmatch '(?ms)networks:.*?coolify:.*?external:\s*true')) {
Add-Result "2.2-coolify-network" "WARN" "$cf references a DB/cache but does not declare the external 'coolify' network." "If the app must reach shared Coolify services, add the external coolify network (see AGENTS-coolify-apps.md 2.2)."
}
}
if (-not ($results | Where-Object Id -eq '2.2-coolify-network')) {
Add-Result "2.2-coolify-network" "PASS" "No external coolify network requirement detected, or it is correctly declared."
}
# Rule 2.3 - no published 80/443
$badPorts = $false
foreach ($cf in $composeFiles) {
$txt = Get-Content -LiteralPath $cf -Raw
if ($txt -match '(?m)^\s*-\s*"?80:80"?|^\s*-\s*"?443:443"?|"80:80"|"443:443"|ports_exposes.*\b80\b.*\b443\b') {
Add-Result "2.3-no-80-443-publish" "FAIL" "$cf publishes host ports 80 or 443 (owned by coolify-proxy)." "Remove the host port mapping; let Traefik route (AGENTS-coolify-apps.md 2.3)."
$badPorts = $true
}
}
if (-not $badPorts) {
Add-Result "2.3-no-80-443-publish" "PASS" "No host 80/443 publishing found."
}
# Rule 2.4 - FQDN hint (informational)
Add-Result "2.4-fqdn-set" "INFO" "Remember to set FQDN to https://<name>.urieljareth.org BEFORE first deploy (not via Coolify's auto UUID subdomain)." "Set -Fqdn when calling New-CoolifyApplication.ps1."
# Rule 2.5 - no obvious secrets in tree
$secretPatterns = @(
'AKIA[0-9A-Z]{16}',
'ghp_[A-Za-z0-9]{36}',
'github_pat_[A-Za-z0-9_]{82}',
'-----BEGIN (RSA |EC |OPENSSH |)PRIVATE KEY-----',
'postgres(ql)?://[^:\s]+:[^@\s]+@'
)
$found = $false
Get-ChildItem -LiteralPath $root -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -notmatch '\\(\.git|node_modules|vendor|dist|build)\\' } |
ForEach-Object {
try {
$content = Get-Content -LiteralPath $_.FullName -Raw -ErrorAction Stop
foreach ($pat in $secretPatterns) {
if ($content -match $pat) {
Add-Result "2.5-no-secrets-in-repo" "FAIL" "Potential secret matching pattern '$pat' found in $($_.FullName)." "Remove the secret and inject it as env via Coolify (AGENTS-coolify-apps.md 2.5)."
$found = $true
}
}
} catch {}
}
if (-not $found) {
Add-Result "2.5-no-secrets-in-repo" "PASS" "No obvious hardcoded secrets detected."
}
# Rule 4 - healthcheck present
$hcFound = $false
foreach ($cf in $composeFiles) {
$txt = Get-Content -LiteralPath $cf -Raw
if ($txt -match 'healthcheck:') { $hcFound = $true }
}
if ($dockerfile) {
$txt = Get-Content -LiteralPath $dockerfile -Raw
if ($txt -match 'HEALTHCHECK') { $hcFound = $true }
}
if ($hcFound) {
Add-Result "4-healthcheck" "PASS" "Healthcheck detected."
} else {
Add-Result "4-healthcheck" "WARN" "No healthcheck found in compose or Dockerfile." "Add a healthcheck on a DB-independent endpoint (AGENTS-coolify-apps.md section 4)."
}
# Rule 5 - named volumes for persistence
foreach ($cf in $composeFiles) {
$txt = Get-Content -LiteralPath $cf -Raw
if ($txt -match 'volumes:' -and $txt -notmatch '(?ms)^volumes:\s*\n\s+\S+:') {
Add-Result "5-named-volumes" "WARN" "$cf has volumes but no top-level named volumes section." "Declare named volumes to survive redeploys (AGENTS-coolify-apps.md section 5)."
}
}
if (-not ($results | Where-Object Id -eq '5-named-volumes')) {
Add-Result "5-named-volumes" "PASS" "Volume declarations look consistent."
}
# Cross-check ExpectedPort vs Dockerfile EXPOSE
if ($ExpectedPort -and $dockerfile) {
$txt = Get-Content -LiteralPath $dockerfile -Raw
if ($txt -match 'EXPOSE\s+(\d+)') {
$exposed = $matches[1]
if ($exposed -ne $ExpectedPort) {
Add-Result "ports-match" "WARN" "Dockerfile EXPOSE=$exposed but ExpectedPort=$ExpectedPort." "Make ports_exposes match the real listening port."
} else {
Add-Result "ports-match" "PASS" "Dockerfile EXPOSE matches ExpectedPort ($ExpectedPort)."
}
}
}
$results | Format-Table -AutoSize
$failed = $results | Where-Object Status -eq 'FAIL'
$warned = $results | Where-Object Status -eq 'WARN'
if ($failed) {
Write-Host "`nFAIL: $($failed.Count) hard rule(s) violated. Fix before pushing." -ForegroundColor Red
exit 1
}
if ($warned) {
Write-Host "`nWARN: $($warned.Count) soft warning(s). Review before deploy." -ForegroundColor Yellow
if ($Strict) { exit 1 }
}
Write-Host "`nChecklist complete." -ForegroundColor Green
+238
View File
@@ -0,0 +1,238 @@
<#
.SYNOPSIS
Operational verification: confirm a deployed service actually serves its
page with HTTP 200 and no client-side crashes.
.DESCRIPTION
The final "is it live and working?" gate after a Coolify deploy. Runs two
layers of checks and FAILS (non-zero exit) if either is unhealthy:
Layer 1 - HTTP layer (curl.exe)
HEAD or GET against the FQDN, follow redirects, expect 2xx.
Cheap, no browser. Catches Traefik 502, wrong ports_exposes, TLS
failure, missing FQDN route.
Layer 2 - Browser layer (Playwright via Node, verify-online.mjs)
Real Chromium navigation. Catches client-side JS crashes
(pageerror), failed main-frame requests, runtime exceptions, and
verifies the page actually rendered (not a blank/error page).
Saves a PNG screenshot for evidence.
This script complements Test-PostDeploy.ps1 (which inspects Coolify state,
container status, proxy logs, sibling DNS). Run BOTH:
- Test-PostDeploy.ps1 -> "did Coolify ship it correctly?"
- Test-ServiceOnline.ps1 -> "does the user actually get a working page?"
.PARAMETER Fqdn
Full public URL, e.g. https://cotizador.urieljareth.org. Required.
.PARAMETER Path
Path appended to the FQDN. Default: "/".
Use a non-authenticated landing route (e.g. /, /login, /health) - Playwright
will follow redirects, so / often works even when the app forces login.
.PARAMETER ExpectedStatusCode
HTTP status code expected from Layer 1. Default: 200. Use 302 if the
landing path legitimately redirects (e.g. / -> /login).
.PARAMETER ExpectTitle
Optional regex the document.title must match. Example: "Cotizador|Login".
.PARAMETER Screenshot
Optional PNG path for the Playwright screenshot. Defaults to
"$env:TEMP\coolify-<host>-<timestamp>.png".
.PARAMETER SkipBrowser
Skip Layer 2 (Playwright). Use only on headless/CI environments without
a display, or when Chromium is unavailable.
.PARAMETER TimeoutMs
Per-attempt timeout (curl and Playwright). Default: 30000.
.PARAMETER Retries
Number of times to retry the FULL check with a 10s gap. Coolify + Traefik
DNS challenge + warm-up can take a minute or two on first deploy.
Default: 6 (so up to ~1 minute of retries).
.EXAMPLE
.\Test-ServiceOnline.ps1 -Fqdn https://cotizador.urieljareth.org `
-ExpectTitle "Cotizador|Login" -Retries 6
.EXAMPLE
.\Test-ServiceOnline.ps1 -Fqdn https://myapp.urieljareth.org -SkipBrowser
#>
param(
[Parameter(Mandatory = $true)]
[string]$Fqdn,
[string]$Path = "/",
[int]$ExpectedStatusCode = 200,
[string]$ExpectTitle,
[string]$Screenshot,
[switch]$SkipBrowser,
[int]$TimeoutMs = 30000,
[int]$Retries = 6
)
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = (Resolve-Path (Join-Path $here "..\..")).Path
$verifyNode = Join-Path $here "verify-online.mjs"
$nodeModulesRoot = Join-Path $repoRoot "node_modules"
function Write-Section($t) { Write-Host "`n=== $t ===" -ForegroundColor Cyan }
function Get-FullUrl {
param([string]$Base, [string]$P)
$b = $Base.TrimEnd("/")
if ($P -eq "/") { return $b + "/" }
if (-not $P.StartsWith("/")) { $P = "/" + $P }
return $b + $P
}
function Test-HttpLayer {
param([string]$Url, [int]$Expected, [int]$TimeoutMs)
Write-Section "Layer 1 - HTTP (curl) -> $Url"
$tmp = [System.IO.Path]::GetTempFileName()
try {
# -L follow redirects, -o NUL discard body, -w write status to tmp, --max-time total budget.
$codeField = "%{http_code}"
& curl.exe -sS -L -k -o NUL `
-w $codeField `
--max-time ([math]::Floor($TimeoutMs / 1000)) `
$Url 2>$null > $tmp
$raw = (Get-Content -LiteralPath $tmp -Raw -ErrorAction SilentlyContinue)
$status = -1
if ([int]::TryParse(($raw -replace '\s',''), [ref]$status)) {
Write-Host " HTTP status: $status" -ForegroundColor $(if ($status -eq $Expected -or ($status -ge 200 -and $status -lt 400)) { 'Green' } else { 'Red' })
return $status
}
Write-Host " Could not parse HTTP status. Raw: '$raw'" -ForegroundColor Red
return -1
} finally {
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
}
}
function Test-BrowserLayer {
param(
[string]$Url,
[string]$TitleRegex,
[string]$ScreenshotPath,
[int]$TimeoutMs
)
Write-Section "Layer 2 - Browser (Playwright) -> $Url"
if (-not (Test-Path -LiteralPath $nodeModulesRoot)) {
Write-Host " node_modules not found at $nodeModulesRoot - run 'npm install' in the manager repo." -ForegroundColor Yellow
Write-Host " Skipping browser layer (treat as inconclusive)." -ForegroundColor Yellow
return $true # Do not fail the whole pipeline if browser deps are missing.
}
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
Write-Host " node not in PATH. Skipping browser layer." -ForegroundColor Yellow
return $true
}
$nodeArgs = @($verifyNode, $Url, "--timeout", "$TimeoutMs")
if ($TitleRegex) { $nodeArgs += @("--expect-title", $TitleRegex) }
if ($ScreenshotPath) { $nodeArgs += @("--screenshot", $ScreenshotPath) }
# Run from the manager repo root so `require('playwright')` resolves.
Push-Location -LiteralPath $repoRoot
$jsonOut = & node @nodeArgs 2>&1
$exitCode = $LASTEXITCODE
Pop-Location
# The last non-empty line that parses as JSON is the result from verify-online.mjs.
$parsed = $null
foreach ($line in ($jsonOut -split "`n")) {
$t = $line.Trim()
if (-not $t) { continue }
try { $parsed = $t | ConvertFrom-Json } catch { }
}
if ($null -eq $parsed) {
Write-Host " No JSON result from Node script (exit=$exitCode)." -ForegroundColor Red
Write-Host " Raw output: $jsonOut" -ForegroundColor DarkGray
return $false
}
Write-Host " ok : $($parsed.ok)" -ForegroundColor $(if ($parsed.ok) { 'Green' } else { 'Red' })
Write-Host " httpStatus : $($parsed.httpStatus)"
Write-Host " title : $($parsed.title)"
Write-Host " elapsedMs : $($parsed.elapsedMs)"
if ($parsed.pageErrors -and $parsed.pageErrors.Count -gt 0) {
Write-Host " pageErrors : $($parsed.pageErrors.Count)" -ForegroundColor Yellow
$parsed.pageErrors | Select-Object -First 3 | ForEach-Object { Write-Host " - $_" -ForegroundColor DarkYellow }
}
if ($parsed.requestFailures -and $parsed.requestFailures.Count -gt 0) {
Write-Host " reqFailures : $($parsed.requestFailures.Count)" -ForegroundColor Yellow
$parsed.requestFailures | Select-Object -First 3 | ForEach-Object { Write-Host " - $($_.method) $($_.url) [$($_.errorText)]" -ForegroundColor DarkYellow }
}
if ($parsed.consoleErrors -and $parsed.consoleErrors.Count -gt 0) {
Write-Host " consoleErrors: $($parsed.consoleErrors.Count)" -ForegroundColor Yellow
}
if ($parsed.error) {
Write-Host " error : $($parsed.error)" -ForegroundColor Red
}
if ($parsed.screenshot) {
Write-Host " screenshot : $($parsed.screenshot)" -ForegroundColor DarkGray
}
return [bool]$parsed.ok
}
$url = Get-FullUrl -Base $Fqdn -P $Path
if (-not $Screenshot) {
$host_ = ([System.Uri]$Fqdn).Host
$ts = (Get-Date -Format "yyyyMMdd-HHmmss")
$Screenshot = Join-Path $env:TEMP "coolify-$host_-$ts.png"
}
$attempt = 0
$lastResult = $false
while ($attempt -lt $Retries) {
$attempt++
Write-Host "`n>>> Attempt $attempt/$Retries" -ForegroundColor Magenta
$httpStatus = Test-HttpLayer -Url $url -Expected $ExpectedStatusCode -TimeoutMs $TimeoutMs
$httpOk = ($httpStatus -ge 200 -and $httpStatus -lt 400)
if (-not $httpOk) {
Write-Host " HTTP layer not ready (status=$httpStatus)." -ForegroundColor Yellow
}
$browserOk = $true
if ($httpOk -and -not $SkipBrowser) {
$browserOk = Test-BrowserLayer -Url $url -TitleRegex $ExpectTitle -ScreenshotPath $Screenshot -TimeoutMs $TimeoutMs
} elseif ($SkipBrowser) {
Write-Host " Browser layer skipped (-SkipBrowser)." -ForegroundColor DarkGray
}
if ($httpOk -and $browserOk) {
$lastResult = $true
break
}
if ($attempt -lt $Retries) {
Write-Host " Waiting 10s before retry..." -ForegroundColor DarkGray
Start-Sleep -Seconds 10
}
}
Write-Section "Verdict"
if ($lastResult) {
Write-Host " SERVICE ONLINE: $url returned healthy HTTP and a clean page render." -ForegroundColor Green
Write-Host " Screenshot: $Screenshot" -ForegroundColor DarkGray
exit 0
} else {
Write-Host " SERVICE NOT VERIFIED ONLINE after $attempt attempt(s)." -ForegroundColor Red
Write-Host " Check: Coolify deploy logs, ports_exposes, FQDN route, TLS DNS challenge, app boot." -ForegroundColor Red
exit 1
}
@@ -0,0 +1,94 @@
// 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.<svc>.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=<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;i<n;i++){ const x=b.nth(i); if(await x.isVisible().catch(()=>false)){ 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(); }
@@ -0,0 +1,40 @@
// coolify-login.mjs — log into the Coolify web UI and save a Playwright storageState.
// Needed because this Coolify instance (v4.1.2) does NOT expose the /applications/*
// REST API (all 404), so application config must be driven through the UI.
//
// Usage (from the manager repo root, env loaded via .env.local.ps1):
// node deploy_skill/scripts/coolify-ui/coolify-login.mjs <out-state.json> [shot.png]
// Env:
// CF_EMAIL / CF_PASS credentials (map from $env:COOLIFY_EMAIL / $env:COOLIFY_PASSWORD)
// CF_BASE base URL, default https://coolify.urieljareth.org
// CF_PW_BASE path to a package.json whose node_modules has playwright
// (default: this manager repo)
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 BASE = (process.env.CF_BASE || 'https://coolify.urieljareth.org').replace(/\/$/, '');
const EMAIL = process.env.CF_EMAIL, PASS = process.env.CF_PASS;
const OUT = process.argv[2] || 'coolify_state.json';
const SHOT = process.argv[3] || 'coolify_login.png';
if (!EMAIL || !PASS) { console.error('Missing CF_EMAIL/CF_PASS'); process.exit(2); }
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: { width: 1600, height: 1000 }, ignoreHTTPSErrors: true });
const page = await ctx.newPage();
try {
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForTimeout(1500);
for (const s of ['input[type="email"]','input[name="email"]','#email']) { const el = await page.$(s); if (el) { await el.fill(EMAIL); break; } }
for (const s of ['input[type="password"]','input[name="password"]','#password']) { const el = await page.$(s); if (el) { await el.fill(PASS); break; } }
const btn = await page.$('button[type="submit"]') || await page.$('button:has-text("Login")');
if (btn) await btn.click(); else await page.keyboard.press('Enter');
await page.waitForTimeout(4000);
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(()=>{});
const ok = !/\/login/.test(page.url());
console.log('LOGIN_OK:', ok, 'url:', page.url());
await page.screenshot({ path: SHOT }).catch(()=>{});
if (ok) { await ctx.storageState({ path: OUT }); console.log('storageState ->', OUT); process.exitCode = 0; }
else { console.log('login failed'); process.exitCode = 1; }
} catch (e) { console.error('ERR', e.message); process.exitCode = 1; }
finally { await browser.close(); }
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env node
// verify-online.mjs — Browser-level operational verification for a deployed service.
// Used by Test-ServiceOnline.ps1. Exits 0 on success, non-zero on failure.
//
// Usage:
// node verify-online.mjs <url> [--screenshot <path>] [--timeout <ms>] [--expect-title <regex>]
//
// Checks performed:
// 1. Page navigates to status < 400 (HTTP layer via Playwright)
// 2. No uncaught `pageerror` events (JS crashes)
// 3. No failed main-frame network requests (DNS, 5xx on document, etc.)
// 4. Optional: document.title matches a regex
// 5. Saves a screenshot (PNG) when --screenshot is provided
//
// Output: a single JSON line on stdout with the result; human logs go to stderr.
import { chromium } from "playwright";
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: verify-online.mjs <url> [--screenshot <path>] [--timeout <ms>] [--expect-title <regex>] [--no-headless]");
process.exit(2);
}
const url = args[0];
const opts = {};
for (let i = 1; i < args.length; i++) {
const a = args[i];
if (a === "--screenshot") opts.screenshot = args[++i];
else if (a === "--timeout") opts.timeout = parseInt(args[++i], 10);
else if (a === "--expect-title") opts.expectTitle = args[++i];
else if (a === "--no-headless") opts.headless = false;
}
const timeout = opts.timeout || 30000;
const headless = opts.headless !== false;
const result = {
url,
ok: false,
httpStatus: null,
title: null,
pageErrors: [],
requestFailures: [],
consoleErrors: [],
screenshot: null,
elapsedMs: null,
};
const startedAt = Date.now();
let browser;
try {
browser = await chromium.launch({ headless });
const context = await browser.newContext({
ignoreHTTPSErrors: true,
viewport: { width: 1280, height: 720 },
});
const page = await context.newPage();
page.on("pageerror", (err) => {
result.pageErrors.push(err.message);
});
page.on("requestfailed", (req) => {
const u = req.url();
// Ignore third-party analytics/CDN noise; surface only main-frame document/sub-resource
// failures relevant to the app itself.
const failure = req.failure();
result.requestFailures.push({
url: u,
method: req.method(),
errorText: failure ? failure.errorText : "unknown",
resourceType: req.resourceType(),
});
});
page.on("console", (msg) => {
if (msg.type() === "error") {
const txt = msg.text();
// Suppress noisy third-party errors that don't affect operability.
if (!/favicon|googletagmanager|google-analytics|doubleclick|sentry/i.test(txt)) {
result.consoleErrors.push(txt);
}
}
});
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout });
if (!response) {
throw new Error("Navigation returned no response (blocked or timeout).");
}
result.httpStatus = response.status();
result.title = await page.title();
if (opts.expectTitle) {
const re = new RegExp(opts.expectTitle);
if (!re.test(result.title || "")) {
throw new Error(`Title "${result.title}" does not match /${opts.expectTitle}/`);
}
}
if (result.httpStatus >= 400) {
throw new Error(`HTTP ${result.httpStatus} from ${url}`);
}
if (result.pageErrors.length > 0) {
throw new Error(`Page threw ${result.pageErrors.length} uncaught error(s): ${result.pageErrors.slice(0, 3).join(" | ")}`);
}
// Give SPA a moment to settle, then capture screenshot.
await page.waitForTimeout(1500);
if (opts.screenshot) {
try {
await page.screenshot({ path: opts.screenshot, fullPage: false });
result.screenshot = opts.screenshot;
} catch (e) {
// Non-fatal: screenshot is best-effort.
console.error(`[warn] screenshot failed: ${e.message}`);
}
}
result.ok = true;
} catch (err) {
result.error = err.message;
result.ok = false;
} finally {
if (browser) await browser.close().catch(() => {});
result.elapsedMs = Date.now() - startedAt;
console.log(JSON.stringify(result));
}
process.exit(result.ok ? 0 : 1);