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:
co-authored by
Claude Opus 4.8
parent
cd998ce6b0
commit
e7d1c33cd5
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(); }
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user