diff --git a/.env.example b/.env.example index 0f206da..1da06d7 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,13 @@ COOLIFY_TOKEN=REPLACE_WITH_COOLIFY_TOKEN # Generate at https://github.com/settings/tokens (classic 'repo' scope, or # fine-grained with Contents:Read+Write and Metadata:Read). GITHUB_TOKEN=REPLACE_WITH_GITHUB_PAT + +# Used by gitea_skill (Invoke-GiteaApi.ps1, New-GiteaRepo.ps1, Sync-GiteaRemote.ps1). +# Self-hosted Gitea behind the Cloudflare tunnel. Generate a token at: +# /user/settings/applications +# Scopes needed: read:repository, write:repository, read:user (and write:admin +# only if you manage other users). git push uses a one-shot http.extraHeader +# injected by Sync-GiteaRemote.ps1 — the token is NOT persisted to .git/config. +GITEA_URL=https://gitea-hjwh0svsoo9p5w5kj2j6b1bd.urieljareth.org +GITEA_USER=urieljareth +GITEA_TOKEN=REPLACE_WITH_GITEA_TOKEN diff --git a/CLAUDE.md b/CLAUDE.md index e8534d1..de91041 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,12 @@ Invoke-ProxmoxApi -Path "/version" # Cloudflare API (requires CLOUDFLARE_API_TOKEN) — full tunnel/DNS control .\scripts\Invoke-CloudflareApi.ps1 -Path "/user/tokens/verify" + +# Gitea API (requires GITEA_URL + GITEA_TOKEN) — self-hosted git hosting +.\gitea_skill\scripts\Test-GiteaConnection.ps1 +.\gitea_skill\scripts\Get-GiteaRepo.ps1 -List +.\gitea_skill\scripts\Invoke-GiteaApi.ps1 -Path "/user" +.\gitea_skill\scripts\Sync-GiteaRemote.ps1 -AppPath .\my-app -CreateIfMissing -Force ``` For the Coolify API reference: don't read the whole tree. Search @@ -118,4 +124,5 @@ root cause of "database connection" failures here — check with - [coolify_skill/SKILL.md](coolify_skill/SKILL.md) + [coolify_skill/TOOLS.md](coolify_skill/TOOLS.md) — Coolify agent operating skill and API reference index. - [docs/AGENTS-coolify-apps.md](docs/AGENTS-coolify-apps.md) — compatibility guide for agents **developing** an app to deploy on this Coolify instance (networking, ports, domains/TLS, env, volumes, healthchecks) + pre-deploy checklist. Source this when building a new app, not when operating an existing one. - [deploy_skill/SKILL.md](deploy_skill/SKILL.md) + [deploy_skill/TOOLS.md](deploy_skill/TOOLS.md) — deploy pipeline skill (scaffold → push → Coolify API → verify → rollback). +- [gitea_skill/SKILL.md](gitea_skill/SKILL.md) + [gitea_skill/TOOLS.md](gitea_skill/TOOLS.md) — self-hosted Gitea skill: list/create/mirror repos, wire a `gitea` remote, push headlessly via one-shot `http.extraHeader` (token never persisted). Distinct from deploy_skill (which targets Coolify); this manages the git hosting layer. - `PROXMOX/`, `proxmox-agent/`, `proxmox-skill/` are **legacy pointer folders** — they only redirect to the live docs above. Don't add content there. diff --git a/README.md b/README.md index 4fbbb4e..7fd0623 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ Verificado el 2026-05-30 desde esta maquina: punta (scaffold Dockerfile/compose → checklist → repo GitHub → push → alta via API → deploy → verificacion → rollback). Necesita `GITHUB_TOKEN` y `COOLIFY_TOKEN` en `.env.local.ps1`. +- `gitea_skill/`: skill para operar la instancia Gitea self-hosted (crear/listar/ + buscar repos, migrar desde GitHub, wire de un remoto `gitea` y push headless + con token inyectado por invocacion — sin persistirlo en `.git/config`). + Necesita `GITEA_URL`, `GITEA_USER`, `GITEA_TOKEN` en `.env.local.ps1`. - `docs/proxmox-inventory.md`: inventario verificado y notas de arquitectura. - `docs/runbooks/`: procedimientos concretos para conexion, diagnostico y Coolify. - `docs/runbooks/nextcloud.md`: recuperacion y fix HTTPS para Nextcloud. diff --git a/gitea_skill/SKILL.md b/gitea_skill/SKILL.md new file mode 100644 index 0000000..12abf30 --- /dev/null +++ b/gitea_skill/SKILL.md @@ -0,0 +1,125 @@ +--- +name: gitea-agent +description: Operate the local self-hosted Gitea instance (gitea-...urieljareth.org) for this Proxmox and Coolify Manager project. Use when Codex needs to create/list/search/mirror Gitea repositories, push a local project to Gitea headlessly (token in extraHeader, no wincred), inspect branches/releases/hooks, or wire a `gitea` remote alongside a GitHub `origin`. Distinct from coolify-deploy (which ships to Coolify) — this skill only manages the git hosting layer on Gitea. +--- + +# Gitea Agent + +Use this project-local skill for Gitea work in this repository. The Gitea +instance lives behind the same Cloudflare tunnel as Coolify and is reachable +only via its public URL. + +## Startup routine + +1. Load private values: `. .\.env.local.ps1` (provides `GITEA_URL`, `GITEA_USER`, + `GITEA_TOKEN`). +2. Run the smoke test: `.\gitea_skill\scripts\Test-GiteaConnection.ps1`. +3. Only after it passes, perform the requested operation. + +## Local context (verified 2026-07-19) + +- Gitea URL: `https://gitea-hjwh0svsoo9p5w5kj2j6b1bd.urieljareth.org` +- Gitea version: `1.26.2` +- Authenticated user: `urieljareth` (admin) +- Auth header: `Authorization: token ` (canonical Gitea form; + `Bearer` also works on 1.16+). +- The Gitea instance runs as a Coolify container inside LXC `102` on the + Proxmox host `192.168.0.200`. Container-level work goes through + `pct exec 102 -- docker ...` (see `coolify_skill`); this skill speaks only + to the public REST API. +- Secrets live only in `.env.local.ps1` (gitignored). Never write tokens into + Markdown, scripts, or `.git/config`. + +## When to use + +- "Push this project to Gitea" / "crea un repo en Gitea y sube esto" +- "List my Gitea repos" / "¿qué repos tengo en Gitea?" +- "Mirror this GitHub repo to Gitea" (`/repos/migrate`) +- "Set up a `gitea` remote alongside GitHub `origin`" +- "Create a release / tag on Gitea" + +## When NOT to use + +- Deploying an app to run on the server → use `deploy_skill/` (Coolify). +- Operating an already-deployed container → use `coolify_skill/`. +- Proxmox host / LXC maintenance → use `agent/`. + +## Safety policy + +- **Read-only by default**: prefer `Get-GiteaRepo.ps1` and GET endpoints before + any write. +- **Confirm before mutating**: repo creation, visibility changes, branch + protection, deletes, releases, and any `git push` require explicit + confirmation unless `-Force` is supplied. +- **Never write secrets into the repo.** The token is injected per-invocation + via `http.extraHeader` for pushes; it is NOT baked into remote URLs and NOT + stored in wincred by these scripts. +- **No force-push to main** unless the operator explicitly asks. +- **State the rollback path** before destructive actions (delete repo, force + push): once a Gitea repo is deleted the data is gone (no Coolify-side + recycle bin). + +## Tooling + +See [`TOOLS.md`](TOOLS.md) for the full command reference. Core scripts: + +- `scripts/Invoke-GiteaApi.ps1` — low-level REST wrapper (curl-based, BOM-safe). +- `scripts/Test-GiteaConnection.ps1` — smoke test (version + auth + repos). +- `scripts/Get-GiteaRepo.ps1` — list / search / get a repo. +- `scripts/New-GiteaRepo.ps1` — idempotent repo creation. +- `scripts/Sync-GiteaRemote.ps1` — wire a `gitea` remote + push headlessly. + +## API reference + +See [`references/api-index.md`](references/api-index.md) for the verified +endpoint cheatsheet. Full Swagger at `$GITEA_URL/api/swagger`. + +## Common workflows + +### Push an existing local repo to Gitea (one shot) + +```powershell +. .\.env.local.ps1 +.\gitea_skill\scripts\Sync-GiteaRemote.ps1 ` + -AppPath .\my-app ` + -Name my-app ` + -CreateIfMissing -Force +``` + +This creates `urieljareth/my-app` if missing, adds a `gitea` remote (so it +coexists with a GitHub `origin`), and pushes the current branch with a +one-shot auth header (token not persisted). + +### Mirror a GitHub repo into Gitea + +```powershell +. .\.env.local.ps1 +$body = @{ + clone_addr = "https://github.com/urieljarethbusiness-cpu/my-app.git" + repo_owner = "urieljareth" + repo_name = "my-app" + service = "github" + mirror = $true + private = $false + wiki = $false + issues = $false + pull_requests = $false + releases = $true +} | ConvertTo-Json -Compress +.\gitea_skill\scripts\Invoke-GiteaApi.ps1 -Method POST -Path "/repos/migrate" -BodyJson $body +``` + +### Add a Gitea webhook so Coolify auto-pulls on push + +```powershell +$body = @{ + type = "gitea" + active = $true + events = @("push") + config = @{ url = "https://coolify.urieljareth.org/webhooks/source.manual_webhook_secret_gitea"; content_type = "json" } +} | ConvertTo-Json -Depth 5 -Compress +.\gitea_skill\scripts\Invoke-GiteaApi.ps1 -Method POST ` + -Path "/repos/urieljareth/my-app/hooks" -BodyJson $body +``` +(Replace the `manual_webhook_secret_*` segment with the real webhook URL from +the Coolify app's "Webhooks" section.) diff --git a/gitea_skill/TOOLS.md b/gitea_skill/TOOLS.md new file mode 100644 index 0000000..1f55166 --- /dev/null +++ b/gitea_skill/TOOLS.md @@ -0,0 +1,125 @@ +# Gitea — Tooling + +All commands assume PowerShell from the project root. Load env first: + +```powershell +. .\.env.local.ps1 +``` + +Required env vars (in `.env.local.ps1`, gitignored): + +- `GITEA_URL` — base URL, e.g. `https://gitea-hjwh0svsoo9p5w5kj2j6b1bd.urieljareth.org`. +- `GITEA_USER` — auth user (informational; `GITEA_TOKEN` encodes the identity). +- `GITEA_TOKEN` — app token with `read:repository`, `write:repository`, + `read:user` scopes (generate at `$GITEA_URL/user/settings/applications`). + +## Smoke test + +```powershell +.\gitea_skill\scripts\Test-GiteaConnection.ps1 +``` + +Calls `/version`, `/user`, `/settings/api`, `/repos/search`. Exits 1 on any +FAIL — gate operational work on this. + +## Read-only + +```powershell +# Who am I? +.\gitea_skill\scripts\Invoke-GiteaApi.ps1 -Path "/user" + +# List visible repos +.\gitea_skill\scripts\Get-GiteaRepo.ps1 -List + +# Get one repo (owner defaults to auth user) +.\gitea_skill\scripts\Get-GiteaRepo.ps1 -Name Proxmox-Coolify-Manager +.\gitea_skill\scripts\Get-GiteaRepo.ps1 -Owner urieljareth -Name MP-Manager + +# Search +.\gitea_skill\scripts\Get-GiteaRepo.ps1 -Search manager + +# Branches + tags +.\gitea_skill\scripts\Invoke-GiteaApi.ps1 -Path "/repos/urieljareth/MP-Manager/branches" +.\gitea_skill\scripts\Invoke-GiteaApi.ps1 -Path "/repos/urieljareth/MP-Manager/tags" +``` + +## Create a repo (idempotent) + +```powershell +.\gitea_skill\scripts\New-GiteaRepo.ps1 -Name my-app -Description "demo" [-Private] [-NoAutoInit] +``` + +If the repo already exists, prints its URL and returns the existing object +(no failure). `-NoAutoInit` skips the Gitea-side README so you can push an +existing local history without conflicts. + +## Push a local repo to Gitea (headless, token NOT persisted) + +```powershell +.\gitea_skill\scripts\Sync-GiteaRemote.ps1 ` + -AppPath .\my-app ` + -Name my-app ` + -RemoteName gitea ` + -CreateIfMissing [-Private] [-Force] +``` + +What it does: + +1. Ensures `urieljareth/` exists (creates if `-CreateIfMissing`). +2. Adds (or updates) the `gitea` remote with the **clean** HTTPS URL — **no + token in `.git/config`**. +3. Pushes the current branch with a **one-shot** `http.extraHeader=Authorization: + token ` and a blanked `credential.helper`. The token never reaches disk. + +`-RemoteName gitea` (default) coexists with a GitHub `origin`. To replace an +existing `origin` that already points at Gitea (as in this manager repo), +pass `-RemoteName origin`. + +### If you prefer wincred for interactive pushes (one-time) + +```powershell +cmdkey /generic:"git:$env:GITEA_URL" /user:"$env:GITEA_USER" /pass:"$env:GITEA_TOKEN" +``` + +After that, plain `git push` works. The skill scripts do NOT require this — +they work headlessly out of the box. + +## Low-level API + +```powershell +# GET +.\gitea_skill\scripts\Invoke-GiteaApi.ps1 -Path "/repos/urieljareth/MP-Manager/releases" + +# POST with body +$body = @{ name = "v1.0.0"; tag_name = "v1.0.0" } | ConvertTo-Json -Compress +.\gitea_skill\scripts\Invoke-GiteaApi.ps1 -Method POST ` + -Path "/repos/urieljareth/MP-Manager/releases" -BodyJson $body + +# Raw (status + headers + body string) +.\gitea_skill\scripts\Invoke-GiteaApi.ps1 -Path "/user" -Raw +``` + +`Invoke-GiteaApi.ps1` uses `curl.exe --data-binary @` (UTF-8 no BOM) +so Gitea's JSON parser never trips on a BOM — same pattern as +`Invoke-GitHubApi.ps1`. + +## Commands that always require confirmation + +- Creating a repo (`New-GiteaRepo.ps1` warns if it already exists). +- Any `git push` (`Sync-GiteaRemote.ps1` asks unless `-Force`). +- Any Gitea `POST`/`PATCH`/`DELETE` (repo, release, hook, branch protection). +- Mirror/migrate operations. +- Deleting a repo (no Coolify-side recycle bin — data is gone). + +`-Force` skips the interactive prompts. Use only after manually reviewing the plan. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| `GITEA_TOKEN not set` | `.env.local.ps1` not loaded | `. .\.env.local.ps1` | +| `401`/`403` on writes | Token lacks `write:repository` | Regenerate token with the right scope at `$GITEA_URL/user/settings/applications` | +| `invalid character '\u00ef'` | Body sent with a BOM | Already handled by `Invoke-GiteaApi.ps1` (uses `[IO.File]::WriteAllText` no-BOM); do not switch to `Set-Content -Encoding utf8` | +| `git push` prompts for credentials | The one-shot header was overridden by a stored helper | The script blanks `credential.helper` for the push; if you configured wincred manually, that's fine too. If a different helper intercepts, run from a clean shell. | +| `404` creating repo under an org | Org does not exist or token lacks access | Create the org first at `$GITEA_URL/org/create` | +| Cloudflare 5xx on Gitea URL | Tunnel routing or the Gitea container is down | `coolify_skill/scripts/Get-CoolifyDockerStatus.ps1 -Filter gitea`; tunnel is dashboard-managed, see `docs/runbooks/cloudflare-tunnel.md` | diff --git a/gitea_skill/references/api-index.md b/gitea_skill/references/api-index.md new file mode 100644 index 0000000..9ceb2e0 --- /dev/null +++ b/gitea_skill/references/api-index.md @@ -0,0 +1,66 @@ +# Gitea API quick reference (v1.26.x) + +Base URL: `$GITEA_URL/api/v1` (default `https://gitea-hjwh0svsoo9p5w5kj2j6b1bd.urieljareth.org/api/v1`). +Auth header: `Authorization: token ` (also accepts `Bearer` on 1.16+). +Docs (Swagger): `$GITEA_URL/api/swagger` (browsable in a browser). + +## Verified 2026-07-19 on this instance + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/version` | GET | Server version (anonymous). Returns `{"version":"1.26.2"}`. | +| `/user` | GET | Authenticated user. Proves token. | +| `/settings/api` | GET | Pagination/limits config. | +| `/repos/search?limit=50` | GET | All repos visible to the token. | +| `/repos/{owner}/{name}` | GET | Single repo (404 if missing or no access). | +| `/user/repos` | POST | Create repo under auth user. | +| `/orgs/{org}/repos` | POST | Create repo under an org. | +| `/repos/{owner}/{name}` | PATCH | Update description, visibility, default branch. | +| `/repos/{owner}/{name}` | DELETE | Delete repo. | +| `/repos/{owner}/{name}/branches` | GET | List branches. | +| `/repos/{owner}/{name}/branch protections` | POST | Branch protection rules. | +| `/repos/{owner}/{name}/releases` | GET/POST | List / create releases. | +| `/repos/{owner}/{name}/tags` | GET | List tags. | +| `/repos/{owner}/{name}/hooks` | GET/POST | Webhooks (e.g. Coolify pull). | +| `/repos/{owner}/{name}/mirror-sync` | POST | Sync a mirror repo. | +| `/repos/migrate` | POST | Migrate/mirror from GitHub/GitLab/etc. | +| `/users/{username}/tokens` | POST | Create a new app token (needs username+password or admin). | +| `/orgs` | GET | List orgs. | +| `/admin/users` | GET | List users (admin only). | + +## Token scopes (Gitea 1.20+ fine-grained) + +Tokens carry scopes. The current skill token (`GITEA_TOKEN` in `.env.local.ps1`) +needs at minimum: `read:repository`, `write:repository`, `read:user`. Create at +`$GITEA_URL/user/settings/applications` (or via API `/users/{user}/tokens` with +basic auth). Verify scopes with: + +```powershell +.\gitea_skill\scripts\Test-GiteaConnection.ps1 +``` + +## Auth header convention + +Gitea's canonical header is `Authorization: token ` (note: literal `token`, +not `Bearer`). This differs from GitHub/Coolify. `Invoke-GiteaApi.ps1` uses the +canonical form. `Bearer` also works on 1.16+ but stick to `token` for max +compat. + +## Git push with token (headless, no wincred) + +The token is NOT persisted into `.git/config` by `Sync-GiteaRemote.ps1`. It is +sent via a one-shot `http.extraHeader`: + +```powershell +git -c "http.extraHeader=Authorization: token $env:GITEA_TOKEN" ` + -c "credential.helper=" ` + push -u gitea main +``` + +If you prefer wincred (so plain `git push` works afterwards), seed it once: + +```powershell +cmdkey /generic:"git:$env:GITEA_URL" /user:"$env:GITEA_USER" /pass:"$env:GITEA_TOKEN" +``` + +But note: the skill scripts do not require this. They work headlessly. diff --git a/gitea_skill/scripts/Get-GiteaRepo.ps1 b/gitea_skill/scripts/Get-GiteaRepo.ps1 new file mode 100644 index 0000000..132065a --- /dev/null +++ b/gitea_skill/scripts/Get-GiteaRepo.ps1 @@ -0,0 +1,81 @@ +<# +.SYNOPSIS + Get or search a Gitea repository. + +.DESCRIPTION + Three modes: + -Name -> GET /repos/{owner}/{name} (default owner = auth user) + -Search -> GET /repos/search?q= + -List -> GET /repos/search (all visible to the token) + +.PARAMETER Owner + Owner (user or org). Defaults to the authenticated user. + +.PARAMETER Name + Exact repository name to look up. + +.PARAMETER Search + Free-text search term. + +.PARAMETER List + Switch: list all visible repos. + +.EXAMPLE + .\Get-GiteaRepo.ps1 -Name Proxmox-Coolify-Manager + .\Get-GiteaRepo.ps1 -Search manager + .\Get-GiteaRepo.ps1 -List +#> +param( + [string]$Owner, + [Parameter(ParameterSetName = "ByName")] + [string]$Name, + [Parameter(ParameterSetName = "BySearch")] + [string]$Search, + [Parameter(ParameterSetName = "ListAll")] + [switch]$List +) + +$ErrorActionPreference = "Stop" +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$api = Join-Path $here "Invoke-GiteaApi.ps1" + +function Resolve-GiteaOwner { + param([string]$OwnerArg) + if ($OwnerArg) { return $OwnerArg } + $me = & $api -Path "/user" + return $me.login +} + +switch ($PSCmdlet.ParameterSetName) { + "ByName" { + if (-not $Name) { throw "Provide -Name." } + $o = Resolve-GiteaOwner -OwnerArg $Owner + try { + $r = & $api -Path "/repos/$o/$Name" + [PSCustomObject]@{ + full_name = $r.full_name + private = $r.private + default_branch = $r.default_branch + clone_url = $r.clone_url + html_url = $r.html_url + ssh_url = $r.ssh_url + updated_at = $r.updated_at + } | Format-List + } catch { + if ($_.Exception.Message -match "404") { + Write-Host "Repository not found: $o/$Name" -ForegroundColor Yellow + return + } + throw + } + } + "BySearch" { + $term = [uri]::EscapeDataString($Search) + $r = & $api -Path "/repos/search?q=$term&limit=50" + $r.data | Select-Object full_name, private, default_branch, updated_at | Format-Table -AutoSize + } + "ListAll" { + $r = & $api -Path "/repos/search?limit=50" + $r.data | Select-Object full_name, private, default_branch, updated_at | Format-Table -AutoSize + } +} diff --git a/gitea_skill/scripts/Invoke-GiteaApi.ps1 b/gitea_skill/scripts/Invoke-GiteaApi.ps1 new file mode 100644 index 0000000..bc08601 --- /dev/null +++ b/gitea_skill/scripts/Invoke-GiteaApi.ps1 @@ -0,0 +1,98 @@ +<# +.SYNOPSIS + Low-level Gitea REST API wrapper (curl-based, BOM-safe). + +.DESCRIPTION + Mirrors Invoke-GitHubApi.ps1's design: uses curl.exe so JSON bodies are + written via --data-binary @file (no PowerShell Set-Content BOM, which + Gitea rejects with "invalid character '\u00ef' looking for beginning of + value"). Reads GITEA_URL + GITEA_TOKEN from the environment. + +.PARAMETER Method + HTTP verb. Default GET. + +.PARAMETER Path + Path under /api/v1 (with or without leading slash). Query params allowed. + +.PARAMETER BodyJson + JSON body for write verbs. Validated as JSON before sending. + +.PARAMETER Raw + Return a pscustomobject with Status, Headers, Body instead of parsed JSON. + +.EXAMPLE + .\Invoke-GiteaApi.ps1 -Path "/version" + .\Invoke-GiteaApi.ps1 -Path "/user" + .\Invoke-GiteaApi.ps1 -Method POST -Path "/user/repos" -BodyJson $json +#> +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:GITEA_TOKEN)) { + throw "Set GITEA_TOKEN before calling the Gitea API. Store it in .env.local.ps1 (gitignored)." +} +if (-not (Get-Command curl.exe -ErrorAction SilentlyContinue)) { + throw "curl.exe not found in PATH." +} + +$base = if ($env:GITEA_URL) { $env:GITEA_URL.TrimEnd("/") } else { "https://gitea-hjwh0svsoo9p5w5kj2j6b1bd.urieljareth.org" } +$base = "$base/api/v1" +$cleanPath = $Path.TrimStart("/") +$uri = "$base/$cleanPath" + +# Gitea accepts "token " (canonical) and "Bearer " (since 1.16+). +$curlArgs = @( + "-sS", "-i", + "-X", $Method.ToUpper(), + "-H", "Authorization: token $env:GITEA_TOKEN", + "-H", "Accept: application/json", + "-H", "User-Agent: gitea-skill" +) +$tmpBody = $null +if ($PSBoundParameters.ContainsKey("BodyJson")) { + try { $null = $BodyJson | ConvertFrom-Json } catch { throw "BodyJson is not valid JSON: $($_.Exception.Message)" } + $tmpBody = [System.IO.Path]::GetTempFileName() + [System.IO.File]::WriteAllText($tmpBody, $BodyJson, (New-Object System.Text.UTF8Encoding($false))) + $curlArgs += @("--data-binary", "@$tmpBody", "-H", "Content-Type: application/json") +} + +try { + $output = & curl.exe @curlArgs $uri + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { throw "Gitea 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 500) { $snippet = $snippet.Substring(0, 500) + "..." } + throw "Gitea API HTTP error: $statusLine`nURI: $uri`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 + } +} diff --git a/gitea_skill/scripts/New-GiteaRepo.ps1 b/gitea_skill/scripts/New-GiteaRepo.ps1 new file mode 100644 index 0000000..13000e6 --- /dev/null +++ b/gitea_skill/scripts/New-GiteaRepo.ps1 @@ -0,0 +1,99 @@ +<# +.SYNOPSIS + Create a Gitea repository (idempotent). + +.DESCRIPTION + Mirrors deploy_skill/scripts/New-GitHubRepo.ps1. If the repo already exists, + prints its URL and returns the existing object instead of failing. + +.PARAMETER Name + Repository name (required). + +.PARAMETER Description + Short description. + +.PARAMETER Private + Switch. If set, creates a private repo. Default public. + +.PARAMETER Owner + Optional. User (default) or org to create under. + +.PARAMETER NoAutoInit + Skip the Gitea-side README init (use when you will push an existing local + repo with its own history). + +.EXAMPLE + .\New-GiteaRepo.ps1 -Name my-app -Description "demo" -Private + .\New-GiteaRepo.ps1 -Name my-app -NoAutoInit +#> +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^[A-Za-z0-9._-]+$')] + [string]$Name, + + [string]$Description = "", + + [switch]$Private, + + [string]$Owner, + + [switch]$NoAutoInit +) + +$ErrorActionPreference = "Stop" +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$api = Join-Path $here "Invoke-GiteaApi.ps1" + +$me = & $api -Path "/user" +$effectiveOwner = if ($Owner) { $Owner } else { $me.login } +Write-Host "Authenticated Gitea user: $($me.login)" -ForegroundColor Cyan + +$existing = $null +try { + $existing = & $api -Path "/repos/$effectiveOwner/$Name" +} catch { + if ($_.Exception.Message -notmatch "404") { throw } + $existing = $null +} +if ($existing) { + Write-Host "Repository already exists: $($existing.html_url)" -ForegroundColor Yellow + [PSCustomObject]@{ + full_name = $existing.full_name + html_url = $existing.html_url + clone_url = $existing.clone_url + ssh_url = $existing.ssh_url + private = $existing.private + default_branch = $existing.default_branch + } | Format-List + return $existing +} + +$body = [ordered]@{ + name = $Name + description = $Description + private = [bool]$Private + auto_init = (-not $NoAutoInit) + has_issues = $true + has_wiki = $false + default_branch = "main" +} | ConvertTo-Json -Compress + +$endpoint = if ($Owner -and $Owner -ne $me.login) { + "/orgs/$Owner/repos" +} else { + "/user/repos" +} + +Write-Host "Creating repository at: $endpoint" -ForegroundColor Cyan +$created = & $api -Method POST -Path $endpoint -BodyJson $body + +[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 diff --git a/gitea_skill/scripts/Sync-GiteaRemote.ps1 b/gitea_skill/scripts/Sync-GiteaRemote.ps1 new file mode 100644 index 0000000..2eaa9da --- /dev/null +++ b/gitea_skill/scripts/Sync-GiteaRemote.ps1 @@ -0,0 +1,140 @@ +<# +.SYNOPSIS + Wire a local git repo to Gitea and push the current branch headlessly. + +.DESCRIPTION + 1. Ensures the target Gitea repo exists (creates it if -CreateIfMissing). + 2. Adds (or updates) a git remote pointing at Gitea. Default remote name + is 'gitea' so it sits alongside a GitHub 'origin' without conflict. + 3. Pushes the current branch with a one-shot Authorization header so the + Gitea token is NOT persisted into .git/config or wincred. + + The token is injected via `git -c http.extraHeader=...` for the single + push invocation only. credential.helper is blanked for that push so the + helper does not prompt and does not cache the header. + +.PARAMETER AppPath + Path to the local git working copy. Defaults to current directory. + +.PARAMETER Name + Repository name on Gitea. Defaults to the basename of AppPath. + +.PARAMETER Owner + Owner (user or org). Defaults to the authenticated Gitea user. + +.PARAMETER RemoteName + Remote name to add/update. Default 'gitea'. + +.PARAMETER Branch + Branch to push. Defaults to the current HEAD of AppPath. + +.PARAMETER CreateIfMissing + Create the Gitea repo if it does not exist. If omitted and the repo is + missing, the script aborts with a clear message. + +.PARAMETER Private + Only used when -CreateIfMissing creates a new repo. + +.PARAMETER Force + Skip the interactive confirmation before pushing. + +.EXAMPLE + .\Sync-GiteaRemote.ps1 -AppPath .\my-app -CreateIfMissing + .\Sync-GiteaRemote.ps1 -Name Proxmox-Coolify-Manager -RemoteName origin -Force +#> +param( + [string]$AppPath = (Get-Location).Path, + [string]$Name, + [string]$Owner, + [string]$RemoteName = "gitea", + [string]$Branch, + [switch]$CreateIfMissing, + [switch]$Private, + [switch]$Force +) + +$ErrorActionPreference = "Stop" +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$api = Join-Path $here "Invoke-GiteaApi.ps1" +$newRepo = Join-Path $here "New-GiteaRepo.ps1" + +if ([string]::IsNullOrWhiteSpace($env:GITEA_TOKEN)) { + throw "Set GITEA_TOKEN (and GITEA_URL) before running this. Load .\.env.local.ps1." +} + +$repoPath = (Resolve-Path -LiteralPath $AppPath).ProviderPath +if (-not (Test-Path -LiteralPath (Join-Path $repoPath ".git"))) { + throw "Not a git repository: $repoPath" +} + +$me = & $api -Path "/user" +$effectiveOwner = if ($Owner) { $Owner } else { $me.login } +$effectiveName = if ($Name) { $Name } else { (Split-Path -Leaf $repoPath) } + +if (-not $Branch) { + $Branch = (& git -C $repoPath rev-parse --abbrev-ref HEAD).Trim() +} +if ([string]::IsNullOrWhiteSpace($Branch) -or $Branch -eq "HEAD") { + throw "Could not determine current branch in $repoPath. Pass -Branch explicitly." +} + +Write-Host "[*] repo=$effectiveOwner/$effectiveName branch=$Branch remote=$RemoteName" -ForegroundColor Cyan + +$repo = $null +try { + $repo = & $api -Path "/repos/$effectiveOwner/$effectiveName" +} catch { + if ($_.Exception.Message -notmatch "404") { throw } + $repo = $null +} +if (-not $repo) { + if (-not $CreateIfMissing) { + throw "Gitea repo $effectiveOwner/$effectiveName does not exist. Re-run with -CreateIfMissing, or create it first with New-GiteaRepo.ps1." + } + Write-Host "[*] Creating missing repo $effectiveOwner/$effectiveName ..." -ForegroundColor Cyan + & $newRepo -Name $effectiveName -Owner $effectiveOwner -Private:$Private -NoAutoInit | Out-Null + $repo = & $api -Path "/repos/$effectiveOwner/$effectiveName" +} + +$giteaHost = if ($env:GITEA_URL) { $env:GITEA_URL.TrimEnd("/") } else { "https://gitea-hjwh0svsoo9p5w5kj2j6b1bd.urieljareth.org" } +$remoteUrl = "$giteaHost/$effectiveOwner/$effectiveName.git" + +$existingRemote = $null +try { + $existingRemote = & git -C $repoPath remote get-url $RemoteName 2>$null +} catch { $existingRemote = $null } + +if ($existingRemote) { + if ($existingRemote.Trim() -ne $remoteUrl) { + Write-Host "[*] Updating remote '$RemoteName' URL -> $remoteUrl" -ForegroundColor Yellow + & git -C $repoPath remote set-url $RemoteName $remoteUrl + } else { + Write-Host "[*] Remote '$RemoteName' already -> $remoteUrl" -ForegroundColor DarkGray + } +} else { + Write-Host "[*] Adding remote '$RemoteName' -> $remoteUrl" -ForegroundColor Cyan + & git -C $repoPath remote add $RemoteName $remoteUrl +} + +if (-not $Force) { + $answer = Read-Host "Push '$Branch' to $RemoteName ($effectiveOwner/$effectiveName)? [y/N]" + if ($answer -notmatch '^[yY]') { + Write-Host "Aborted (no push)." -ForegroundColor Yellow + return + } +} + +Write-Host "[*] Pushing $Branch -> $RemoteName ..." -ForegroundColor Cyan +$env:GIT_TERMINAL_PROMPT = "0" +& git -C $repoPath ` + -c "http.extraHeader=Authorization: token $env:GITEA_TOKEN" ` + -c "credential.helper=" ` + push -u $RemoteName $Branch +$pushExit = $LASTEXITCODE +if ($pushExit -ne 0) { + throw "git push failed (exit $pushExit). Check the token scopes (needs 'write:repository')." +} + +Write-Host "" +Write-Host "[OK] Pushed to $($repo.html_url)" -ForegroundColor Green +Write-Host " remote '$RemoteName' -> $remoteUrl" diff --git a/gitea_skill/scripts/Test-GiteaConnection.ps1 b/gitea_skill/scripts/Test-GiteaConnection.ps1 new file mode 100644 index 0000000..4ca798f --- /dev/null +++ b/gitea_skill/scripts/Test-GiteaConnection.ps1 @@ -0,0 +1,68 @@ +<# +.SYNOPSIS + Gitea smoke test: config + API auth + token scopes. + +.DESCRIPTION + Calls /version, /user (auth), /settings/api, and lists the authenticated + user's repos. Exits 1 on any FAIL so it composes into CI / agent startup. + +.EXAMPLE + .\Test-GiteaConnection.ps1 +#> +$ErrorActionPreference = "Stop" + +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$api = Join-Path $here "Invoke-GiteaApi.ps1" + +if ([string]::IsNullOrWhiteSpace($env:GITEA_TOKEN)) { + Write-Host "[FAIL] GITEA_TOKEN not set. Load .\.env.local.ps1 first." -ForegroundColor Red + exit 1 +} +if ([string]::IsNullOrWhiteSpace($env:GITEA_URL)) { + Write-Host "[WARN] GITEA_URL not set; using default https://gitea-hjwh0svsoo9p5w5kj2j6b1bd.urieljareth.org" -ForegroundColor Yellow +} + +$failures = 0 + +# 1) Version (anonymous, but proves reachability) +try { + $v = & $api -Path "/version" + Write-Host "[PASS] /version -> Gitea $($v.version)" -ForegroundColor Green +} catch { + Write-Host "[FAIL] /version: $($_.Exception.Message)" -ForegroundColor Red + $failures++ +} + +# 2) Authenticated user (proves token validity) +try { + $u = & $api -Path "/user" + Write-Host "[PASS] /user -> login='$($u.login)' is_admin=$($u.is_admin)" -ForegroundColor Green +} catch { + Write-Host "[FAIL] /user (token invalid or expired): $($_.Exception.Message)" -ForegroundColor Red + $failures++ +} + +# 3) Token scopes (Gitea 1.20+: /users/me/tokens only lists tokens created via API; for app tokens, /user suffices) +try { + $settings = & $api -Path "/settings/api" + Write-Host "[PASS] /settings/api -> max_response_items=$($settings.max_response_items)" -ForegroundColor Green +} catch { + Write-Host "[WARN] /settings/api unavailable on this version: $($_.Exception.Message)" -ForegroundColor Yellow +} + +# 4) List repos (proves read scope on /repos/search) +try { + $repos = & $api -Path "/repos/search?limit=50" + $count = @($repos.data).Count + Write-Host "[PASS] /repos/search -> $count repos visible" -ForegroundColor Green +} catch { + Write-Host "[FAIL] /repos/search: $($_.Exception.Message)" -ForegroundColor Red + $failures++ +} + +Write-Host "" +if ($failures -gt 0) { + Write-Host "[RESULT] $failures FAIL(s). Gitea skill NOT ready." -ForegroundColor Red + exit 1 +} +Write-Host "[RESULT] OK. Gitea skill ready." -ForegroundColor Green