commit 042071438c6037ba87fcd6e341ff46d0c344860d
parent e357e9361dee0c77ae3ee7f149c5252f31f8195f
Author: Pablo Murad <pablo@pablomurad.com>
Date: Tue, 19 May 2026 20:42:03 -0300
Melhorias
Diffstat:
20 files changed, 2727 insertions(+), 4 deletions(-)
diff --git a/docs/05-tools-and-system-experience.md b/docs/05-tools-and-system-experience.md
@@ -7,9 +7,9 @@
**Função:** orquestrar no servidor Debian:
1. Pacotes APT listados em `tools/manifests/apt_packages.txt` (alias `chat` → metapacote `weechat`). O manifesto inclui **`weechat-curses`** explicitamente porque `tools.py` usa `apt-get install --no-install-recommends`: sem isso, o metapacote `weechat` pode satisfazer-se **só** com `weechat-headless` e o comando `chat` deixa de encontrar cliente interactivo (`weechat` / `weechat-curses` no PATH).
-2. Cópia de `tools/bin/` para `/usr/local/bin` (`runv-help`, `runv-links`, `runv-status`, `chat`, …).
+2. Cópia de `tools/bin/` para `/usr/local/bin` (`runv-help`, `runv-links`, `runv-status`, `chat`, `runv-profile`, `runv-finger`, `runv-who`, `runv-bulletin`, …) e de `tools/lib/runv_community.py` para `/usr/local/share/runv/lib/`.
3. MOTD dinâmico: `tools/motd/60-runv` → `/etc/update-motd.d/60-runv`.
-4. Modelos para novas contas: `tools/skel/` → `/etc/skel/`.
+4. Modelos para novas contas: `tools/skel/` → `/etc/skel/` (inclui `.plan`, `.project`, `.runv/profile.json`).
5. Drop-in SSH para utilizadores jailed: `tools/sshd/90-runv-jailed.conf` → `/etc/ssh/sshd_config.d/`.
6. Sudo administrativo para `pmurad-admin`: `tools/sudoers/90-runv-pmurad-admin` → `/etc/sudoers.d/`.
7. Reconciliação do jail SSH em membros existentes via `scripts/admin/perm1.py`.
diff --git a/docs/08-email.md b/docs/08-email.md
@@ -25,6 +25,80 @@
- **Não** é MTA completo (não recebe correio para caixas locais de membros como produto deste repositório).
+## Aliases de email para membros
+
+O email transacional da runv.club (Mailgun, `/etc/runv-email.json`) continua separado deste fluxo.
+
+Nesta etapa **não** há mailbox local nem caixa `@runv.club` no servidor. Um membro pode pedir um alias fixo:
+
+`username@runv.club` → email externo de destino
+
+O alias **não** é activado automaticamente: o membro pede no terminal, um admin aprova, e o registo fica em JSON local. Criar o encaminhamento real no provedor de email (Mailgun, DNS, etc.) continua a ser passo manual ou integração futura.
+
+### Membro
+
+```bash
+runv-email-alias request usuario@example.org
+runv-email-alias status
+runv-email-alias cancel
+```
+
+O alias é sempre `username@runv.club` (username Unix do utilizador que corre o comando). Não é possível escolher outro nome de alias.
+
+### Admin
+
+```bash
+sudo runv-admin-email-alias pending
+sudo runv-admin-email-alias list
+sudo runv-admin-email-alias approve pablo
+sudo runv-admin-email-alias reject pablo --reason "email destino inválido"
+```
+
+### Setup inicial no servidor
+
+```bash
+sudo python3 scripts/admin/setup_email_aliases.py
+sudo python3 scripts/admin/setup_email_aliases.py --add-existing-users
+```
+
+Depois instalar os comandos com `sudo python3 tools/tools.py` (ver [05-tools-and-system-experience.md](05-tools-and-system-experience.md)).
+
+### Ficheiros e permissões
+
+| Caminho | Função |
+|---------|--------|
+| `/var/lib/runv/email-aliases.json` | Aliases aprovados (activos) |
+| `/var/lib/runv/email-aliases.lock` | Lock para escrita segura |
+| `/var/lib/runv/email-alias-queue/` | Pedidos pendentes |
+| `.../approved/`, `.../rejected/`, `.../cancelled/` | Histórico de pedidos |
+
+Permissões sugeridas após o setup:
+
+| Caminho | Modo | Dono:grupo |
+|---------|------|------------|
+| `/var/lib/runv/email-aliases.json` | 640 | root:runv-members |
+| `/var/lib/runv/email-aliases.lock` | 660 | root:runv-members |
+| `/var/lib/runv/email-alias-queue/` | 2770 | root:runv-members |
+
+Variáveis de ambiente para testes locais: `RUNV_EMAIL_ALIASES_PATH`, `RUNV_EMAIL_ALIASES_LOCK_PATH`, `RUNV_EMAIL_ALIAS_QUEUE_DIR`, `RUNV_EMAIL_ALIAS_DOMAIN`.
+
+Mais detalhe dos comandos: [17-community-commands.md](17-community-commands.md).
+
+### O que isto não faz
+
+- Não cria mailbox.
+- Não recebe email.
+- Não envia email (além do stack transacional já existente).
+- Não configura DNS.
+- Não configura SPF/DKIM/DMARC.
+- Não configura Postfix/Dovecot.
+- Não configura Mailgun para aliases de membros.
+- Não activa encaminhamento real automaticamente.
+
+### Próximo passo futuro
+
+Um script como `runv-email-provider-sync` poderá ler `email-aliases.json` e aplicar aliases no provedor real.
+
## Testes
- Existem testes em `email/tests/` (ex.: `test_mailgun_client.py`). Ver [14-smoke-tests-and-validation.md](14-smoke-tests-and-validation.md).
diff --git a/docs/14-smoke-tests-and-validation.md b/docs/14-smoke-tests-and-validation.md
@@ -38,6 +38,33 @@ Vários scripts importam `fcntl` ou `grp` — **não executáveis** em Windows t
Em **Debian:** correr os `--help` acima e guardar a saída para operadores. Confirmar que `site/genlanding.py --help` lista **`--sync-public-only`**.
+## Aliases de email para membros (Linux)
+
+Revisão estática (qualquer OS):
+
+```bash
+python3 -m compileall -q tools scripts/admin/setup_email_aliases.py scripts/admin/smoke_test_email_aliases.py
+python3 tools/bin/runv-email-alias --help
+python3 tools/bin/runv-admin-email-alias --help
+```
+
+Smoke test integrado (VPS ou WSL; usa diretório temporário por defeito):
+
+```bash
+cd REPO
+sudo python3 scripts/admin/smoke_test_email_aliases.py --user MEMBRO_TESTE
+```
+
+Setup + instalação em produção (antes do smoke com paths reais):
+
+```bash
+sudo python3 scripts/admin/setup_email_aliases.py --verbose
+sudo python3 scripts/admin/setup_email_aliases.py --add-existing-users
+cd tools && sudo python3 tools.py --verbose
+```
+
+Ver também [08-email.md](08-email.md) e [17-community-commands.md](17-community-commands.md). Registo de revisão: [review-email-aliases-signoff.md](review-email-aliases-signoff.md).
+
## O que **não** existe no repo (facto)
- **Sem** workflows `.github/workflows` na raiz do projecto runv (verificado por ausência de `.github/` no clone típico).
diff --git a/docs/17-community-commands.md b/docs/17-community-commands.md
@@ -0,0 +1,296 @@
+# Comandos comunitários
+
+[← Índice](README.md)
+
+## Visão geral
+
+Estes comandos dão mais vida pubnix ao servidor runv.club:
+
+- **perfil local** (`runv-profile`) — ficheiros em `~/.runv/profile.json`, `~/.plan` e `~/.project`;
+- **finger moderno** (`runv-finger`) — ver o perfil público de outro membro;
+- **listagem de membros** (`runv-who`) — quem está na comunidade e sinais de actividade;
+- **mural comunitário** (`runv-bulletin`) — mensagens curtas partilhadas no terminal.
+
+São instalados por [`tools/tools.py`](../tools/tools.py) em `/usr/local/bin` (junto com `runv-help`, `chat`, etc.). A biblioteca partilhada fica em `/usr/local/share/runv/lib/runv_community.py`.
+
+Não expõem email, chave pública nem fingerprint de `/var/lib/runv/users.json`.
+
+## `runv-profile`
+
+### O que faz
+
+Gerencia o perfil local público:
+
+- `~/.runv/profile.json`
+- `~/.plan`
+- `~/.project`
+
+### Exemplos
+
+```bash
+runv-profile init
+runv-profile show
+runv-profile path
+```
+
+### Arquivos criados
+
+| Caminho | Descrição |
+|---------|-----------|
+| `~/.runv/profile.json` | Nome, bio, local, links, interesses |
+| `~/.plan` | Plano actual (texto livre) |
+| `~/.project` | Projecto actual (texto livre) |
+
+### Permissões esperadas
+
+| Caminho | Modo |
+|---------|------|
+| `~/.runv` | `755` |
+| `~/.runv/profile.json` | `644` |
+| `~/.plan` | `644` |
+| `~/.project` | `644` |
+
+### Observações
+
+- `init` **não sobrescreve** ficheiros existentes.
+- Não guarde dados sensíveis no perfil (são legíveis por outros membros via `runv-finger`).
+- Contas antigas sem estes ficheiros podem correr `runv-profile init` uma vez.
+
+---
+
+## `runv-finger`
+
+### O que faz
+
+Mostra o perfil público de outro membro (estilo `finger`).
+
+### Exemplo
+
+```bash
+runv-finger pablo
+```
+
+### Dados exibidos
+
+- `~/.runv/profile.json` (campos públicos)
+- `~/.plan`
+- `~/.project`
+- existência e última actualização de `~/public_html/index.html` (como `Home: /~USER/`)
+
+### Segurança
+
+- Não mostra email.
+- Não mostra chave pública.
+- Não mostra fingerprint.
+- Cada ficheiro é lido com limite de **16 KiB**.
+
+---
+
+## `runv-who`
+
+### O que faz
+
+Lista membros da runv.club com indícios de actividade (homepage, `.plan`, `.project`).
+
+### Exemplos
+
+```bash
+runv-who
+runv-who --active
+runv-who --limit 20
+runv-who --json
+```
+
+### Fontes de dados
+
+**Preferencial:** `/var/lib/runv/users.json` (apenas usernames; formatos suportados: lista de objectos com `username`, objecto com chaves = usernames, ou `{ "users": [ ... ] }`).
+
+**Fallback:** directórios em `/home/` cujo nome passa na regex de username.
+
+Se `users.json` existir mas for inválido, aparece um aviso e usa-se `/home`.
+
+### Campos exibidos
+
+| Campo | Significado |
+|-------|-------------|
+| `username` | Nome Unix |
+| `homepage` | Sempre `/~USER/` |
+| `has_homepage` | Existe `~/public_html/index.html` |
+| `homepage_mtime` | ISO UTC da última modificação da homepage, ou `null` |
+| `has_plan` | `.plan` existe e não está vazio |
+| `has_project` | `.project` existe e não está vazio |
+
+### Ordenação
+
+1. Membros com homepage, por data da homepage (mais recente primeiro).
+2. Membros sem homepage, por ordem alfabética.
+
+Com `--active`, só entram quem tem homepage **ou** `.plan` **ou** `.project`.
+
+### JSON
+
+`--json` imprime um array JSON só com os campos acima — útil para integração futura com site, Garden, Gotchi ou outros scripts.
+
+---
+
+## `runv-bulletin`
+
+### O que faz
+
+Mural comunitário simples em terminal (uma linha JSON por post).
+
+### Exemplos
+
+```bash
+runv-bulletin
+runv-bulletin list
+runv-bulletin post "Hoje configurei meu gopher"
+runv-bulletin --limit 10
+runv-bulletin --json
+```
+
+Sem subcomando, equivale a `list`.
+
+### Arquivos usados
+
+| Caminho | Função |
+|---------|--------|
+| `/var/lib/runv/bulletin/posts.ndjson` | Posts (NDJSON) |
+| `/var/lib/runv/bulletin/posts.lock` | Lock `flock` em escritas |
+
+Testes locais:
+
+```bash
+export RUNV_BULLETIN_PATH=/tmp/runv-bulletin/posts.ndjson
+```
+
+### Formato
+
+Uma linha JSON por post, por exemplo:
+
+```json
+{"id":"20260519T120000Z-pablo-a1b2c3","username":"pablo","created_at":"2026-05-19T12:00:00Z","body":"Hoje configurei meu gopher"}
+```
+
+O username em `post` vem sempre do utilizador Unix actual (`getpwuid`); não se aceita username por argumento.
+
+### Permissões
+
+O directório global precisa permitir escrita pelos membros. Sugestão operacional (ajuste o grupo se o vosso não for `runv`):
+
+```bash
+sudo mkdir -p /var/lib/runv/bulletin
+sudo touch /var/lib/runv/bulletin/posts.ndjson
+sudo touch /var/lib/runv/bulletin/posts.lock
+sudo chgrp -R runv /var/lib/runv/bulletin
+sudo chmod 2775 /var/lib/runv/bulletin
+sudo chmod 664 /var/lib/runv/bulletin/posts.ndjson /var/lib/runv/bulletin/posts.lock
+```
+
+Sem permissão de escrita, `post` mostra mensagem clara — **não** há fallback para `/tmp`.
+
+---
+
+## Instalação
+
+No servidor (clone em `REPO`):
+
+```bash
+cd REPO/tools
+sudo python3 tools.py --dry-run --verbose
+sudo python3 tools.py
+```
+
+Verificar:
+
+```bash
+which runv-profile runv-finger runv-who runv-bulletin runv-email-alias runv-admin-email-alias
+ls -l /usr/local/share/runv/lib/runv_community.py /usr/local/share/runv/lib/runv_email_aliases.py
+```
+
+Novas contas recebem modelos em `/etc/skel` (`.plan`, `.project`, `.runv/profile.json`) após `tools.py`.
+
+---
+
+## Testes manuais rápidos
+
+```bash
+runv-profile init
+runv-profile show
+runv-finger "$USER"
+runv-who
+runv-who --json
+
+mkdir -p /tmp/runv-bulletin-test
+export RUNV_BULLETIN_PATH=/tmp/runv-bulletin-test/posts.ndjson
+runv-bulletin post "primeiro teste do mural"
+runv-bulletin
+runv-bulletin --json
+```
+
+Sintaxe Python (repo):
+
+```bash
+cd REPO
+python3 -m compileall -q tools
+```
+
+---
+
+## `runv-email-alias`
+
+### O que faz
+
+Permite pedir um alias de email fixo `username@runv.club` que, após aprovação admin, deve encaminhar para um email externo.
+
+- Não cria mailbox no servidor.
+- Não activa o encaminhamento automaticamente.
+- O membro só indica o email de destino; o nome do alias segue o username Unix.
+
+### Exemplos
+
+```bash
+runv-email-alias request usuario@example.org
+runv-email-alias status
+runv-email-alias cancel
+```
+
+Política, ficheiros em `/var/lib/runv/` e setup: [08-email.md](08-email.md).
+
+---
+
+## `runv-admin-email-alias`
+
+### O que faz
+
+Comando **root** para listar pedidos, aprovar ou rejeitar aliases, e actualizar `email-aliases.json` localmente. Não chama Mailgun nem configura DNS.
+
+### Exemplos
+
+```bash
+sudo runv-admin-email-alias pending
+sudo runv-admin-email-alias list
+sudo runv-admin-email-alias approve pablo
+sudo runv-admin-email-alias reject pablo --reason "email destino inválido"
+```
+
+Setup inicial da fila e permissões:
+
+```bash
+sudo python3 scripts/admin/setup_email_aliases.py
+sudo python3 scripts/admin/setup_email_aliases.py --add-existing-users
+```
+
+---
+
+## Próximos passos futuros
+
+Possíveis evoluções (fora do âmbito actual):
+
+- `runv-admin bulletin hide/delete` (moderação);
+- feed público do mural no site;
+- integração com MOTD;
+- integração com Garden / Gotchi;
+- backfill admin para membros existentes.
+
+Próximo: [15-glossary-and-reference.md](15-glossary-and-reference.md).
diff --git a/docs/README.md b/docs/README.md
@@ -20,7 +20,8 @@
14. [Resolução de problemas](13-troubleshooting.md)
15. [Smoke tests](14-smoke-tests-and-validation.md)
16. [Reparar usuários](16-repair-users.md)
-17. [Glossário e referência](15-glossary-and-reference.md)
+17. [Comandos comunitários](17-community-commands.md)
+18. [Glossário e referência](15-glossary-and-reference.md)
## Mapa rápido
@@ -33,7 +34,9 @@
| Pedidos SSH `entre` | [09-terminal-entre.md](09-terminal-entre.md) |
| Criar conta membro | [10-user-provisioning-and-admin-ops.md](10-user-provisioning-and-admin-ops.md) |
| Reparar home incompleta / `Index of /~USER` | [16-repair-users.md](16-repair-users.md) |
+| Usar comandos sociais pubnix (`runv-who`, `runv-finger`, mural) | [17-community-commands.md](17-community-commands.md) |
| Email Mailgun / legado | [08-email.md](08-email.md) |
+| Solicitar alias `usuario@runv.club` | [08-email.md](08-email.md), [17-community-commands.md](17-community-commands.md) |
## Diagramas (Mermaid)
diff --git a/docs/review-email-aliases-signoff.md b/docs/review-email-aliases-signoff.md
@@ -0,0 +1,93 @@
+# Revisão: aliases de email (Parte 2)
+
+[← Índice](README.md)
+
+Registo da revisão de código e validação. Atualizar a secção **VPS** após correr o smoke test no servidor.
+
+| Campo | Valor |
+|-------|--------|
+| Data | 2026-05-19 |
+| Commit | `e357e93` (ajustar após `git rev-parse HEAD` no deploy) |
+| Revisor | Automatizado + checklist manual |
+
+## Fase 1 — Estática (Windows / clone)
+
+| Item | Resultado | Notas |
+|------|-----------|--------|
+| 1.1 Sem Mailgun/Postfix/DNS no código `tools/` | **PASS** | Grep sem matches |
+| 1.1 Alias fixo `username@domínio` | **PASS** | `alias_address()` |
+| 1.1 Usernames reservados | **PASS** | `ALIAS_RESERVED_USERNAMES` completo |
+| 1.1 Validação destino | **PASS** | Script Python com 6 casos |
+| 1.1 `O_EXCL` em pedidos | **PASS** | `create_pending_request()` |
+| 1.1 Arquivo sem apagar | **PASS** | `archive_request()` |
+| 1.1 Admin root | **PASS** | `require_root()` após `parse_args` |
+| 1.1 Lock + escrita atómica | **PASS** | `approve_pending()` |
+| 1.1 Setup idempotente | **PASS** | `setup_email_aliases.py` |
+| 1.2 Sem leitura `users.json` nos bins membro | **PASS** | Só setup `--add-existing-users` |
+| 1.3 `compileall` | **PASS** | |
+| 1.3 `--help` (3 entrypoints) | **PASS** | |
+| 1.3 `setup --dry-run` | **PASS** | |
+
+### Correcções aplicadas na revisão
+
+- Re-validação de `destination` em `approve_pending()` antes de gravar alias activo.
+- Rejeição de `--reason` vazio em `runv-admin-email-alias reject`.
+- Script [`scripts/admin/smoke_test_email_aliases.py`](../scripts/admin/smoke_test_email_aliases.py) para VPS/WSL.
+- Secção em [14-smoke-tests-and-validation.md](14-smoke-tests-and-validation.md).
+
+## Fase 2 — VPS Linux
+
+### 2.3–2.5 Lógica (WSL, modo direct, temp dir)
+
+| Secção | Resultado | Notas |
+|--------|-----------|--------|
+| 2.3 E2E request → approve → list | **PASS** | `smoke_test_email_aliases.py --user pablo` em WSL |
+| 2.4 Validações / cancel / reject | **PASS** | mesmo script |
+| 2.5 Alteração destino | **PASS** | `created_at` preservado |
+
+### 2.2 / bins instalados (executar na VPS)
+
+```bash
+cd /caminho/para/runv-server
+git pull
+COMMIT=$(git rev-parse --short HEAD)
+echo "Testando commit $COMMIT"
+
+sudo python3 scripts/admin/setup_email_aliases.py --verbose
+sudo python3 scripts/admin/setup_email_aliases.py --add-existing-users
+cd tools && sudo python3 tools.py --verbose
+
+sudo python3 scripts/admin/smoke_test_email_aliases.py --user SEU_MEMBRO_TESTE
+```
+
+| Secção | Resultado | Data / notas |
+|--------|-----------|----------------|
+| 2.2 Setup + instalação | _pendente VPS_ | `which`, `ls -la /var/lib/runv/email-*` |
+| 2.2 Smoke subprocess (bins reais) | _pendente VPS_ | sem `--direct`; exige `sudo` |
+| 2.6 Regressão Parte 1 | _pendente VPS_ | `runv-profile`, `runv-who`, etc. |
+| 2.7 Docs vs saída real | **PASS** | revisão estática 08 + 17 |
+
+### Permissões esperadas (2.2)
+
+```text
+/var/lib/runv/email-aliases.json 640 root:runv-members
+/var/lib/runv/email-aliases.lock 660 root:runv-members
+/var/lib/runv/email-alias-queue/ 2770 root:runv-members
+```
+
+## Critérios finais
+
+- [x] Fase 1 sem bloqueantes
+- [x] Fase 2.3–2.5 lógica validada (WSL + smoke script)
+- [ ] Fase 2.2 + smoke **subprocess** na VPS Debian (operador)
+- [x] Nenhum código envia email ou configura Mailgun/DNS para aliases
+- [x] Documentação 08 + 17 alinhada com implementação
+- [ ] Operador confirmou passo manual pós-`approve` no provedor real
+
+## Comando único recomendado na VPS
+
+```bash
+sudo python3 scripts/admin/smoke_test_email_aliases.py --user MEMBRO
+```
+
+Saída esperada final: `Smoke test aliases de email: PASS`
diff --git a/scripts/admin/setup_email_aliases.py b/scripts/admin/setup_email_aliases.py
@@ -0,0 +1,246 @@
+#!/usr/bin/env python3
+"""
+Prepara diretórios, permissões e grupo para pedidos de alias de email runv.club.
+
+Não configura Postfix, Dovecot, Mailgun nem DNS.
+"""
+
+from __future__ import annotations
+
+import argparse
+import logging
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+_SCRIPT_DIR = Path(__file__).resolve().parent
+_REPO_TOOLS_LIB = _SCRIPT_DIR.parent.parent / "tools" / "lib"
+if str(_REPO_TOOLS_LIB) not in sys.path:
+ sys.path.insert(0, str(_REPO_TOOLS_LIB))
+
+import runv_community as rc # noqa: E402
+
+DEFAULT_GROUP = "runv-members"
+VAR_LIB_RUNV = Path("/var/lib/runv")
+ALIASES_JSON = VAR_LIB_RUNV / "email-aliases.json"
+ALIASES_LOCK = VAR_LIB_RUNV / "email-aliases.lock"
+QUEUE_DIR = VAR_LIB_RUNV / "email-alias-queue"
+USERS_JSON = VAR_LIB_RUNV / "users.json"
+
+
+def _run(cmd: list[str], *, dry_run: bool, log: logging.Logger) -> subprocess.CompletedProcess[str]:
+ log.info("exec: %s", " ".join(cmd))
+ if dry_run:
+ return subprocess.CompletedProcess(cmd, 0, "", "")
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=120)
+
+
+def require_root(dry_run: bool) -> None:
+ if dry_run:
+ return
+ geteuid = getattr(os, "geteuid", None)
+ if geteuid is None or geteuid() != 0:
+ print("este script precisa ser executado como root (ou use --dry-run).", file=sys.stderr)
+ raise SystemExit(1)
+
+
+def group_exists(name: str) -> bool:
+ try:
+ import grp
+ except ModuleNotFoundError:
+ return False
+ try:
+ grp.getgrnam(name)
+ return True
+ except KeyError:
+ return False
+
+
+def ensure_group(name: str, *, dry_run: bool, log: logging.Logger) -> None:
+ if dry_run:
+ print(f"[dry-run] groupadd {name} (se não existir)")
+ return
+ if group_exists(name):
+ log.info("grupo %s já existe", name)
+ return
+ r = _run(["groupadd", name], dry_run=dry_run, log=log)
+ if dry_run:
+ print(f"[dry-run] groupadd {name}")
+ return
+ if r.returncode != 0:
+ err = (r.stderr or r.stdout or "").strip()
+ print(f"groupadd {name} falhou: {err}", file=sys.stderr)
+ raise SystemExit(1)
+ log.info("grupo %s criado", name)
+
+
+def ensure_dir(path: Path, mode: int, *, dry_run: bool, log: logging.Logger) -> None:
+ if dry_run:
+ print(f"[dry-run] mkdir {path} mode {oct(mode)}")
+ return
+ path.mkdir(parents=True, exist_ok=True)
+ os.chmod(path, mode)
+ log.info("directório %s (%s)", path, oct(mode))
+
+
+def ensure_file(path: Path, default_content: str, mode: int, *, dry_run: bool, log: logging.Logger) -> None:
+ if dry_run:
+ action = "criar" if not path.is_file() else "manter"
+ print(f"[dry-run] {action} {path}")
+ return
+ path.parent.mkdir(parents=True, exist_ok=True)
+ if not path.is_file():
+ path.write_text(default_content, encoding="utf-8")
+ log.info("ficheiro criado: %s", path)
+ else:
+ raw = path.read_text(encoding="utf-8").strip()
+ if not raw:
+ path.write_text(default_content, encoding="utf-8")
+ log.info("ficheiro inicializado: %s", path)
+ else:
+ log.info("ficheiro existente preservado: %s", path)
+ os.chmod(path, mode)
+
+
+def chown_path(path: Path, user: str, group: str, *, dry_run: bool, log: logging.Logger) -> None:
+ if dry_run:
+ print(f"[dry-run] chown {user}:{group} {path}")
+ return
+ import grp
+ import pwd
+
+ try:
+ uid = pwd.getpwnam(user).pw_uid
+ gid = grp.getgrnam(group).gr_gid
+ os.chown(path, uid, gid)
+ log.info("chown %s:%s %s", user, group, path)
+ except (KeyError, OSError) as e:
+ log.warning("não foi possível chown %s: %s", path, e)
+
+
+def apply_permissions(group: str, *, dry_run: bool, log: logging.Logger) -> None:
+ ensure_dir(VAR_LIB_RUNV, 0o755, dry_run=dry_run, log=log)
+ if not dry_run:
+ try:
+ os.chown(VAR_LIB_RUNV, 0, 0)
+ except OSError:
+ pass
+
+ ensure_file(ALIASES_JSON, "{}\n", 0o640, dry_run=dry_run, log=log)
+ ensure_file(ALIASES_LOCK, "", 0o660, dry_run=dry_run, log=log)
+
+ for sub in ("", "approved", "rejected", "cancelled"):
+ d = QUEUE_DIR if not sub else QUEUE_DIR / sub
+ ensure_dir(d, 0o2770, dry_run=dry_run, log=log)
+
+ if dry_run:
+ print(f"[dry-run] chown root:{group} em aliases e fila")
+ return
+
+ chown_path(ALIASES_JSON, "root", group, dry_run=False, log=log)
+ chown_path(ALIASES_LOCK, "root", group, dry_run=False, log=log)
+ for sub in ("", "approved", "rejected", "cancelled"):
+ d = QUEUE_DIR if not sub else QUEUE_DIR / sub
+ chown_path(d, "root", group, dry_run=False, log=log)
+
+
+def add_existing_users(group: str, *, dry_run: bool, log: logging.Logger) -> None:
+ if not USERS_JSON.is_file():
+ print(f"aviso: {USERS_JSON} não encontrado; --add-existing-users ignorado.")
+ return
+ names, warning = rc.load_member_usernames(USERS_JSON, rc.DEFAULT_HOME_ROOT)
+ if warning:
+ print(warning)
+ if not names:
+ print("aviso: nenhum username encontrado em users.json.")
+ return
+ import pwd
+
+ for username in names:
+ if dry_run:
+ print(f"[dry-run] usermod -aG {group} {username}")
+ continue
+ try:
+ pwd.getpwnam(username)
+ except KeyError:
+ print(f"aviso: utilizador Unix {username!r} não existe; ignorado.")
+ continue
+ r = _run(["usermod", "-aG", group, username], dry_run=False, log=log)
+ if r.returncode != 0:
+ err = (r.stderr or r.stdout or "").strip()
+ print(f"aviso: usermod -aG {group} {username}: {err}")
+ else:
+ log.info("utilizador %s adicionado ao grupo %s", username, group)
+
+
+def print_final_instructions(repo_root: Path) -> None:
+ tools_dir = repo_root / "tools"
+ print()
+ print("Setup de aliases de email concluído.\n")
+ print("Para instalar comandos:")
+ print(f" cd {tools_dir}")
+ print(" sudo python3 tools.py\n")
+ print("Para testar como utilizador:")
+ print(" runv-email-alias request seu-email@exemplo.com")
+ print(" runv-email-alias status\n")
+ print("Para admin:")
+ print(" sudo runv-admin-email-alias pending")
+ print(" sudo runv-admin-email-alias approve USER\n")
+ print(
+ "Se o servidor não usar o grupo runv-members, pode escolher outro com --group NOME."
+ )
+
+
+def build_parser() -> argparse.ArgumentParser:
+ p = argparse.ArgumentParser(
+ description="Prepara /var/lib/runv para pedidos de alias de email (sem MTA).",
+ )
+ p.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="mostrar acções sem alterar o sistema",
+ )
+ p.add_argument(
+ "--group",
+ default=DEFAULT_GROUP,
+ metavar="NOME",
+ help=f"grupo Unix com acesso à fila (padrão: {DEFAULT_GROUP})",
+ )
+ p.add_argument(
+ "--add-existing-users",
+ action="store_true",
+ help="adicionar usernames de /var/lib/runv/users.json ao grupo",
+ )
+ p.add_argument("-v", "--verbose", action="store_true", help="mais detalhe no log")
+ return p
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = build_parser().parse_args(argv)
+ logging.basicConfig(
+ level=logging.DEBUG if args.verbose else logging.INFO,
+ format="%(levelname)s: %(message)s",
+ )
+ log = logging.getLogger("setup_email_aliases")
+ require_root(bool(args.dry_run))
+ group = args.group.strip()
+ if not group:
+ print("nome de grupo inválido.", file=sys.stderr)
+ return 1
+
+ ensure_group(group, dry_run=bool(args.dry_run), log=log)
+ apply_permissions(group, dry_run=bool(args.dry_run), log=log)
+ if args.add_existing_users:
+ add_existing_users(group, dry_run=bool(args.dry_run), log=log)
+
+ repo_root = _SCRIPT_DIR.parent.parent
+ if not args.dry_run:
+ print_final_instructions(repo_root)
+ else:
+ print("\n[dry-run] nenhuma alteração aplicada.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/admin/smoke_test_email_aliases.py b/scripts/admin/smoke_test_email_aliases.py
@@ -0,0 +1,343 @@
+#!/usr/bin/env python3
+"""
+Smoke test dos aliases de email runv.club (Linux).
+
+Por defeito usa diretório temporário. Modo --direct chama a biblioteca in-process
+(útil em dev/WSL sem sudo). Na VPS use sudo sem --direct para testar os bins.
+
+ sudo python3 scripts/admin/smoke_test_email_aliases.py --user MEMBRO
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+import tempfile
+from collections.abc import Callable
+from contextlib import contextmanager
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+BIN_EMAIL = REPO_ROOT / "tools" / "bin" / "runv-email-alias"
+BIN_ADMIN = REPO_ROOT / "tools" / "bin" / "runv-admin-email-alias"
+
+
+def fail(msg: str) -> None:
+ print(f"FAIL: {msg}", file=sys.stderr)
+ raise SystemExit(1)
+
+
+def ok(msg: str) -> None:
+ print(f"OK: {msg}")
+
+
+@contextmanager
+def push_env(updates: dict[str, str]):
+ old: dict[str, str | None] = {}
+ for key, val in updates.items():
+ old[key] = os.environ.get(key)
+ os.environ[key] = val
+ try:
+ yield
+ finally:
+ for key, prev in old.items():
+ if prev is None:
+ os.environ.pop(key, None)
+ else:
+ os.environ[key] = prev
+
+
+def run_cmd(
+ cmd: list[str],
+ *,
+ env: dict[str, str],
+ as_root: bool = False,
+ as_user: str | None = None,
+ check: bool = True,
+) -> subprocess.CompletedProcess[str]:
+ full = list(cmd)
+ if as_user:
+ full = ["sudo", "-n", "-u", as_user, "-E"] + full
+ elif as_root and os.geteuid() != 0:
+ full = ["sudo", "-n", "-E"] + full
+ proc = subprocess.run(
+ full,
+ env=env,
+ capture_output=True,
+ text=True,
+ cwd=str(REPO_ROOT),
+ timeout=60,
+ )
+ if check and proc.returncode != 0:
+ fail(
+ f"{' '.join(full)}\nstdout={proc.stdout!r}\nstderr={proc.stderr!r}"
+ )
+ return proc
+
+
+def expect_exit(fn: Callable[[], object]) -> bool:
+ try:
+ fn()
+ except SystemExit:
+ return True
+ return False
+
+
+def main() -> int:
+ if sys.platform == "win32":
+ print(
+ "Este smoke test requer Linux (pwd/grp/fcntl). "
+ "Execute na VPS runv ou em WSL.",
+ file=sys.stderr,
+ )
+ return 2
+
+ p = argparse.ArgumentParser(description="Smoke test aliases de email")
+ p.add_argument(
+ "--production",
+ action="store_true",
+ help="usar paths em /var/lib/runv (cuidado: altera estado real)",
+ )
+ p.add_argument(
+ "--direct",
+ action="store_true",
+ help="chamar runv_email_aliases in-process (temp dir; sem testar bins admin root)",
+ )
+ p.add_argument(
+ "--user",
+ default="",
+ help="username Unix para pedidos",
+ )
+ args = p.parse_args()
+ if args.production and args.direct:
+ fail("--production e --direct são incompatíveis")
+
+ username = args.user.strip() or os.environ.get("SUDO_USER", "").strip()
+ if not username:
+ username = os.environ.get("USER", "").strip()
+ if not username:
+ fail("defina --user ou execute com USER/SUDO_USER definido")
+ if os.geteuid() == 0 and not args.user and not os.environ.get("SUDO_USER"):
+ fail("como root directo, use --user MEMBRO (não reservado)")
+
+ sys.path.insert(0, str(REPO_ROOT / "tools" / "lib"))
+ import runv_email_aliases as ea # noqa: E402
+
+ try:
+ ea.validate_alias_username(username)
+ except SystemExit:
+ fail(f"username {username!r} inválido ou reservado para alias")
+
+ if args.production:
+ queue = Path("/var/lib/runv/email-alias-queue")
+ aliases = Path("/var/lib/runv/email-aliases.json")
+ lock = Path("/var/lib/runv/email-aliases.lock")
+ tmp_ctx = None
+ else:
+ tmp_ctx = tempfile.TemporaryDirectory(prefix="runv-email-smoke-")
+ base = Path(tmp_ctx.name)
+ queue = base / "queue"
+ aliases = base / "email-aliases.json"
+ lock = base / "email-aliases.lock"
+ queue.mkdir(parents=True, exist_ok=True)
+ for sub in ("approved", "rejected", "cancelled"):
+ (queue / sub).mkdir(parents=True, exist_ok=True)
+ aliases.write_text("{}\n", encoding="utf-8")
+ lock.touch()
+
+ env = {
+ "RUNV_EMAIL_ALIAS_QUEUE_DIR": str(queue),
+ "RUNV_EMAIL_ALIASES_PATH": str(aliases),
+ "RUNV_EMAIL_ALIASES_LOCK_PATH": str(lock),
+ }
+
+ use_direct = bool(args.direct) or (tmp_ctx is not None and os.geteuid() != 0)
+ if use_direct and not args.direct:
+ print(
+ "aviso: sem root; a usar modo direct (biblioteca). "
+ "Na VPS: sudo python3 scripts/admin/smoke_test_email_aliases.py --user MEMBRO",
+ file=sys.stderr,
+ )
+
+ with push_env(env):
+ if use_direct:
+ _run_direct(username, queue, aliases)
+ else:
+ _run_subprocess(env, username, queue, aliases)
+
+ if tmp_ctx is not None:
+ tmp_ctx.cleanup()
+
+ print("\nSmoke test aliases de email: PASS")
+ return 0
+
+
+def _run_direct(username: str, queue: Path, aliases: Path) -> None:
+ import runv_email_aliases as ea
+
+ for dest in ("foo", "x@runv.club"):
+ if not expect_exit(lambda d=dest: ea.validate_destination_email(d)):
+ fail(f"validate {dest!r} deveria falhar")
+ ok("validações de destino inválido rejeitadas")
+
+ dest_ok = "smoke-alias-test@example.org"
+ ea.create_pending_request(username, dest_ok)
+ ok("request criado")
+
+ if ea.find_pending_for_user(username) is None:
+ fail("pending não encontrado")
+ if not expect_exit(lambda: ea.create_pending_request(username, dest_ok)):
+ fail("segundo request deveria falhar")
+ ok("duplo pending bloqueado")
+
+ entry = ea.approve_pending(username, "smoke-test")
+ ok("approve")
+
+ rows = ea.list_active_aliases()
+ if not any(r[2] == dest_ok for r in rows):
+ fail(f"list_active sem {dest_ok!r}")
+ ok("list")
+
+ if ea.get_active_alias(username) is None:
+ fail("alias activo ausente após approve")
+ ok("status active")
+
+ data = json.loads(aliases.read_text(encoding="utf-8"))
+ created_at = data[username].get("created_at")
+ if not any((queue / "approved").glob("*.json")):
+ fail("nenhum pedido em approved/")
+ ok("pedido arquivado em approved/")
+
+ dest2 = "smoke-alias-test2@example.org"
+ ea.create_pending_request(username, dest2)
+ ea.approve_pending(username, "smoke-test")
+ data2 = json.loads(aliases.read_text(encoding="utf-8"))
+ if data2[username].get("destination") != dest2:
+ fail("destino não actualizado")
+ if data2[username].get("created_at") != created_at:
+ fail("created_at não preservado")
+ ok("alteração de destino com created_at preservado")
+
+ dest3 = "smoke-cancel@example.org"
+ ea.create_pending_request(username, dest3)
+ if ea.cancel_latest_pending(username) is None:
+ fail("cancel falhou")
+ ok("cancel")
+
+ dest4 = "smoke-reject@example.org"
+ ea.create_pending_request(username, dest4)
+ ea.reject_pending(username, "smoke-test", "smoke test")
+ if not any((queue / "rejected").glob("*.json")):
+ fail("reject não arquivou")
+ ok("reject")
+
+ if not expect_exit(lambda: ea.approve_pending("entre", "smoke-test")):
+ fail("approve entre deveria falhar")
+ ok("username reservado rejeitado no approve")
+
+
+def _run_subprocess(
+ env: dict[str, str],
+ username: str,
+ queue: Path,
+ aliases: Path,
+) -> None:
+ py = sys.executable
+ email_bin = str(BIN_EMAIL) if BIN_EMAIL.is_file() else "runv-email-alias"
+ admin_bin = str(BIN_ADMIN) if BIN_ADMIN.is_file() else "runv-admin-email-alias"
+ member_user = username if os.geteuid() == 0 else None
+
+ for dest in ("foo", "x@runv.club"):
+ proc = run_cmd(
+ [py, email_bin, "request", dest],
+ env=env,
+ as_user=member_user,
+ check=False,
+ )
+ if proc.returncode == 0:
+ fail(f"request {dest!r} deveria falhar")
+ ok("validações de destino inválido rejeitadas")
+
+ dest_ok = "smoke-alias-test@example.org"
+ run_cmd([py, email_bin, "request", dest_ok], env=env, as_user=member_user)
+ ok("request criado")
+
+ proc = run_cmd([py, email_bin, "status"], env=env, as_user=member_user)
+ if "pending" not in proc.stdout.lower():
+ fail(f"status sem pending: {proc.stdout!r}")
+
+ proc2 = run_cmd(
+ [py, email_bin, "request", dest_ok],
+ env=env,
+ as_user=member_user,
+ check=False,
+ )
+ if proc2.returncode == 0:
+ fail("segundo request deveria falhar")
+ ok("duplo pending bloqueado")
+
+ run_cmd([py, admin_bin, "approve", username], env=env, as_root=True)
+ ok("approve")
+
+ proc = run_cmd([py, admin_bin, "list"], env=env, as_root=True)
+ if dest_ok not in proc.stdout:
+ fail(f"list não mostra destino: {proc.stdout!r}")
+ ok("list")
+
+ proc = run_cmd([py, email_bin, "status"], env=env, as_user=member_user)
+ if "active" not in proc.stdout.lower():
+ fail(f"status sem active: {proc.stdout!r}")
+ ok("status active")
+
+ data = json.loads(aliases.read_text(encoding="utf-8"))
+ entry = data.get(username)
+ if not entry or entry.get("status") != "active":
+ fail(f"email-aliases.json inválido: {data!r}")
+ if not any((queue / "approved").glob("*.json")):
+ fail("nenhum pedido em approved/")
+ ok("pedido arquivado em approved/")
+
+ dest2 = "smoke-alias-test2@example.org"
+ run_cmd([py, email_bin, "request", dest2], env=env, as_user=member_user)
+ run_cmd([py, admin_bin, "approve", username], env=env, as_root=True)
+ data2 = json.loads(aliases.read_text(encoding="utf-8"))
+ if data2[username].get("destination") != dest2:
+ fail("destino não actualizado")
+ if data2[username].get("created_at") != entry.get("created_at"):
+ fail("created_at não preservado")
+ ok("alteração de destino")
+
+ dest3 = "smoke-cancel@example.org"
+ run_cmd([py, email_bin, "request", dest3], env=env, as_user=member_user)
+ run_cmd([py, email_bin, "cancel"], env=env, as_user=member_user)
+ if not any((queue / "cancelled").glob("*.json")):
+ fail("cancel não arquivou")
+ ok("cancel")
+
+ dest4 = "smoke-reject@example.org"
+ run_cmd([py, email_bin, "request", dest4], env=env, as_user=member_user)
+ run_cmd(
+ [py, admin_bin, "reject", username, "--reason", "smoke test"],
+ env=env,
+ as_root=True,
+ )
+ if not any((queue / "rejected").glob("*.json")):
+ fail("reject não arquivou")
+ ok("reject")
+
+ proc = run_cmd(
+ [py, admin_bin, "approve", "entre"],
+ env=env,
+ as_root=True,
+ check=False,
+ )
+ if proc.returncode == 0:
+ fail("approve entre deveria falhar")
+ ok("username reservado rejeitado")
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/bin/runv-admin-email-alias b/tools/bin/runv-admin-email-alias
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+"""Administração de pedidos e aliases de email runv.club."""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+sys.tracebacklimit = 0
+
+
+def _bootstrap() -> None:
+ installed = Path("/usr/local/share/runv/lib")
+ candidates = [installed]
+ script = Path(__file__).resolve()
+ if script.parent.name == "bin":
+ candidates.insert(0, script.parent.parent / "lib")
+ for c in candidates:
+ if (c / "runv_email_aliases.py").is_file() and str(c) not in sys.path:
+ sys.path.insert(0, str(c))
+ return
+
+
+_bootstrap()
+import runv_email_aliases as ea # noqa: E402
+
+
+def cmd_pending() -> int:
+ pending = ea.iter_pending_requests()
+ if not pending:
+ print("Nenhum pedido pendente.")
+ return 0
+ print("Pedidos pendentes de alias\n")
+ for req in pending:
+ user = req.get("username", "?")
+ print(user)
+ print(f" alias: {req.get('alias', '?')}")
+ print(f" destino: {req.get('destination', '?')}")
+ print(f" criado: {req.get('created_at', '?')}")
+ print()
+ return 0
+
+
+def cmd_list() -> int:
+ rows = ea.list_active_aliases()
+ if not rows:
+ print("Nenhum alias ativo.")
+ return 0
+ print("Aliases ativos\n")
+ for _user, alias, dest in rows:
+ print(f"{alias} -> {dest}")
+ return 0
+
+
+def cmd_approve(username: str) -> int:
+ user = ea.validate_alias_username(username)
+ operator = ea.admin_operator()
+ entry = ea.approve_pending(user, operator)
+ print("Alias aprovado localmente.\n")
+ print("Alias:")
+ print(f" {entry.get('alias')}\n")
+ print("Destino:")
+ print(f" {entry.get('destination')}\n")
+ print("Próximo passo manual:")
+ print(
+ " criar/atualizar o encaminhamento real no provedor de email:\n"
+ f" {entry.get('alias')} -> {entry.get('destination')}"
+ )
+ return 0
+
+
+def cmd_reject(username: str, reason: str) -> int:
+ if not reason.strip():
+ ea.rc.friendly_exit("motivo da rejeição obrigatório (--reason).")
+ user = ea.validate_alias_username(username)
+ operator = ea.admin_operator()
+ ea.reject_pending(user, operator, reason.strip())
+ print("Pedido rejeitado.\n")
+ print("Usuário:")
+ print(f" {user}\n")
+ print("Motivo:")
+ print(f" {reason}")
+ return 0
+
+
+def build_parser() -> argparse.ArgumentParser:
+ p = argparse.ArgumentParser(
+ prog="runv-admin-email-alias",
+ description="Aprovar ou rejeitar pedidos de alias de email (requer root).",
+ )
+ sub = p.add_subparsers(dest="command", required=True)
+ sub.add_parser("pending", help="listar pedidos pendentes")
+ sub.add_parser("list", help="listar aliases activos")
+ appr = sub.add_parser("approve", help="aprovar pedido pendente mais recente")
+ appr.add_argument("user", metavar="USER", help="nome de utilizador Unix")
+ rej = sub.add_parser("reject", help="rejeitar pedido pendente")
+ rej.add_argument("user", metavar="USER", help="nome de utilizador Unix")
+ rej.add_argument(
+ "--reason",
+ required=True,
+ help='motivo da rejeição (obrigatório)',
+ )
+ return p
+
+
+def main(argv: list[str] | None = None) -> int:
+ try:
+ args = build_parser().parse_args(argv)
+ ea.require_root()
+ if args.command == "pending":
+ return cmd_pending()
+ if args.command == "list":
+ return cmd_list()
+ if args.command == "approve":
+ return cmd_approve(args.user)
+ if args.command == "reject":
+ return cmd_reject(args.user, args.reason)
+ return 1
+ except KeyboardInterrupt:
+ print("\nInterrompido.", file=sys.stderr)
+ return 130
+ except SystemExit:
+ raise
+ except Exception as e:
+ ea.rc.friendly_exit(f"erro: {e}")
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/bin/runv-bulletin b/tools/bin/runv-bulletin
@@ -0,0 +1,205 @@
+#!/usr/bin/env python3
+"""Mural comunitário simples da runv.club."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import secrets
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+sys.tracebacklimit = 0
+
+
+def _bootstrap() -> None:
+ installed = Path("/usr/local/share/runv/lib")
+ candidates = [installed]
+ script = Path(__file__).resolve()
+ if script.parent.name == "bin":
+ candidates.insert(0, script.parent.parent / "lib")
+ for c in candidates:
+ if (c / "runv_community.py").is_file() and str(c) not in sys.path:
+ sys.path.insert(0, str(c))
+ return
+
+
+_bootstrap()
+import runv_community as rc # noqa: E402
+
+DEFAULT_BULLETIN_PATH = Path("/var/lib/runv/bulletin/posts.ndjson")
+MAX_BODY_LEN = 500
+DEFAULT_LIST_LIMIT = 20
+
+
+def bulletin_paths() -> tuple[Path, Path]:
+ raw = os.environ.get("RUNV_BULLETIN_PATH", "").strip()
+ ndjson = Path(raw) if raw else DEFAULT_BULLETIN_PATH
+ lock = ndjson.parent / "posts.lock"
+ return ndjson, lock
+
+
+def current_username() -> str:
+ import pwd
+
+ try:
+ name = pwd.getpwuid(os.getuid()).pw_name
+ except (KeyError, OSError) as e:
+ rc.friendly_exit(f"não foi possível determinar o utilizador: {e}")
+ return rc.validate_username(name)
+
+
+def new_post_id(username: str) -> str:
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
+ suffix = secrets.token_hex(3)
+ return f"{ts}-{username}-{suffix}"
+
+
+def append_post(body: str) -> dict[str, Any]:
+ username = current_username()
+ clean = body.strip()
+ if not clean:
+ rc.friendly_exit("mensagem obrigatória.")
+ if "\x00" in clean:
+ rc.friendly_exit("mensagem inválida (caracter NUL).")
+ if len(clean) > MAX_BODY_LEN:
+ rc.friendly_exit(f"mensagem demasiado longa (máximo {MAX_BODY_LEN} caracteres).")
+
+ post = {
+ "id": new_post_id(username),
+ "username": username,
+ "created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "body": clean,
+ }
+ line = json.dumps(post, ensure_ascii=False) + "\n"
+
+ ndjson_path, lock_path = bulletin_paths()
+ try:
+ ndjson_path.parent.mkdir(parents=True, exist_ok=True)
+ except OSError as e:
+ rc.friendly_exit(f"não foi possível criar o diretório do mural: {e}")
+
+ try:
+ import fcntl
+
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
+ with open(lock_path, "a+", encoding="utf-8") as lock_f:
+ fcntl.flock(lock_f.fileno(), fcntl.LOCK_EX)
+ try:
+ with open(ndjson_path, "a", encoding="utf-8") as out:
+ out.write(line)
+ finally:
+ fcntl.flock(lock_f.fileno(), fcntl.LOCK_UN)
+ except PermissionError:
+ rc.friendly_exit(
+ f"sem permissão para escrever em {ndjson_path}\n"
+ "peça ao admin para criar o diretório com permissões de grupo para membros"
+ )
+ except OSError as e:
+ rc.friendly_exit(f"erro ao escrever no mural: {e}")
+
+ return post
+
+
+def read_posts(limit: int) -> list[dict[str, Any]]:
+ ndjson_path, _lock_path = bulletin_paths()
+ if not ndjson_path.is_file():
+ return []
+ try:
+ raw = ndjson_path.read_text(encoding="utf-8")
+ except OSError:
+ return []
+ posts: list[dict[str, Any]] = []
+ for line in raw.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ item = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if isinstance(item, dict) and isinstance(item.get("id"), str):
+ posts.append(item)
+ return posts[-limit:]
+
+
+def format_post_line(post: dict[str, Any]) -> str:
+ created = post.get("created_at", "")
+ display_time = created
+ try:
+ if isinstance(created, str) and created:
+ dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
+ display_time = dt.astimezone().strftime("%Y-%m-%d %H:%M")
+ except ValueError:
+ pass
+ user = post.get("username", "?")
+ body = post.get("body", "")
+ return f"[{display_time}] {user}\n {body}"
+
+
+def cmd_list(limit: int, as_json: bool) -> int:
+ posts = read_posts(limit)
+ if as_json:
+ print(json.dumps(posts, ensure_ascii=False, indent=2))
+ return 0
+ if not posts:
+ print("mural vazio ainda.")
+ return 0
+ print("Mural runv.club\n")
+ for post in posts:
+ print()
+ print(format_post_line(post))
+ print()
+ return 0
+
+
+def build_parser() -> argparse.ArgumentParser:
+ p = argparse.ArgumentParser(
+ prog="runv-bulletin",
+ description="Mural comunitário simples em terminal.",
+ )
+ p.add_argument("--json", action="store_true", help="saída em JSON (list)")
+ p.add_argument(
+ "--limit",
+ type=int,
+ default=None,
+ metavar="N",
+ help=f"últimos N posts (padrão {DEFAULT_LIST_LIMIT} em list)",
+ )
+ sub = p.add_subparsers(dest="command")
+ sub.add_parser("list", help="listar posts recentes")
+ post_p = sub.add_parser("post", help="publicar mensagem")
+ post_p.add_argument("message", help="texto do post")
+ return p
+
+
+def main(argv: list[str] | None = None) -> int:
+ try:
+ args = build_parser().parse_args(argv)
+ if args.command is None:
+ args.command = "list"
+
+ limit = args.limit if args.limit is not None else DEFAULT_LIST_LIMIT
+ if limit < 1:
+ rc.friendly_exit("--limit deve ser um inteiro positivo.")
+
+ if args.command == "post":
+ append_post(args.message)
+ print("post publicado.")
+ return 0
+
+ return cmd_list(limit, bool(args.json))
+ except KeyboardInterrupt:
+ print("\nInterrompido.", file=sys.stderr)
+ return 130
+ except SystemExit:
+ raise
+ except Exception as e:
+ rc.friendly_exit(f"erro: {e}")
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/bin/runv-email-alias b/tools/bin/runv-email-alias
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+"""Pedidos de alias de email runv.club para membros (username@runv.club)."""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+sys.tracebacklimit = 0
+
+
+def _bootstrap() -> None:
+ installed = Path("/usr/local/share/runv/lib")
+ candidates = [installed]
+ script = Path(__file__).resolve()
+ if script.parent.name == "bin":
+ candidates.insert(0, script.parent.parent / "lib")
+ for c in candidates:
+ if (c / "runv_email_aliases.py").is_file() and str(c) not in sys.path:
+ sys.path.insert(0, str(c))
+ return
+
+
+_bootstrap()
+import runv_email_aliases as ea # noqa: E402
+
+
+def cmd_request(destination: str) -> int:
+ username = ea.current_username()
+ dest = ea.validate_destination_email(destination)
+ active = ea.get_active_alias(username)
+ if active is not None:
+ print(
+ "Aviso: já tem alias activo; este pedido altera o destino após aprovação admin.\n"
+ )
+ payload = ea.create_pending_request(username, dest)
+ print("Pedido de alias criado.\n")
+ print("Alias:")
+ print(f" {payload['alias']}\n")
+ print("Destino:")
+ print(f" {payload['destination']}\n")
+ print("Status:")
+ print(" pending\n")
+ print("Um admin precisa aprovar antes de ativar.")
+ return 0
+
+
+def cmd_status() -> int:
+ username = ea.current_username()
+ active = ea.get_active_alias(username)
+ pending = ea.find_pending_for_user(username)
+
+ if active is None and pending is None:
+ print("Você ainda não tem alias de email.\n")
+ print("Para solicitar:")
+ print(" runv-email-alias request seu-email@exemplo.com")
+ return 0
+
+ if active is None and pending is not None:
+ print("Alias:")
+ print(f" {pending.get('alias', ea.alias_address(username))}\n")
+ print("Destino solicitado:")
+ print(f" {pending.get('destination', '?')}\n")
+ print("Status:")
+ print(" pending")
+ return 0
+
+ if active is not None and pending is None:
+ print("Alias:")
+ print(f" {active.get('alias', ea.alias_address(username))}\n")
+ print("Destino:")
+ print(f" {active.get('destination', '?')}\n")
+ print("Status:")
+ print(" active")
+ return 0
+
+ print("Alias ativo:")
+ print(f" {active.get('alias')} -> {active.get('destination')}\n")
+ print("Alteração pendente:")
+ print(f" {pending.get('alias')} -> {pending.get('destination')}\n")
+ print("Status:")
+ print(" pending")
+ return 0
+
+
+def cmd_cancel() -> int:
+ username = ea.current_username()
+ cancelled = ea.cancel_latest_pending(username)
+ if cancelled is None:
+ print("nenhum pedido pendente para cancelar.")
+ return 1
+ print("Pedido cancelado.")
+ return 0
+
+
+def build_parser() -> argparse.ArgumentParser:
+ p = argparse.ArgumentParser(
+ prog="runv-email-alias",
+ description="Solicitar alias username@runv.club que encaminha para o seu email externo.",
+ )
+ sub = p.add_subparsers(dest="command", required=True)
+ req = sub.add_parser("request", help="pedir alias (só informa o email de destino)")
+ req.add_argument("destination", help="email externo de destino")
+ sub.add_parser("status", help="ver alias activo e pedidos pendentes")
+ sub.add_parser("cancel", help="cancelar pedido pendente mais recente")
+ return p
+
+
+def main(argv: list[str] | None = None) -> int:
+ try:
+ args = build_parser().parse_args(argv)
+ if args.command == "request":
+ return cmd_request(args.destination)
+ if args.command == "status":
+ return cmd_status()
+ if args.command == "cancel":
+ return cmd_cancel()
+ return 1
+ except KeyboardInterrupt:
+ print("\nInterrompido.", file=sys.stderr)
+ return 130
+ except SystemExit:
+ raise
+ except Exception as e:
+ ea.rc.friendly_exit(f"erro: {e}")
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/bin/runv-finger b/tools/bin/runv-finger
@@ -0,0 +1,118 @@
+#!/usr/bin/env python3
+"""Mostra o perfil público de um membro runv.club (estilo finger)."""
+
+from __future__ import annotations
+
+import argparse
+import sys
+
+sys.tracebacklimit = 0
+
+
+def _bootstrap() -> None:
+ from pathlib import Path
+
+ installed = Path("/usr/local/share/runv/lib")
+ candidates = [installed]
+ script = Path(__file__).resolve()
+ if script.parent.name == "bin":
+ candidates.insert(0, script.parent.parent / "lib")
+ for c in candidates:
+ if (c / "runv_community.py").is_file() and str(c) not in sys.path:
+ sys.path.insert(0, str(c))
+ return
+
+
+_bootstrap()
+import runv_community as rc # noqa: E402
+
+
+def cmd_finger(username: str) -> int:
+ user = rc.validate_username(username)
+ paths = rc.home_paths(user)
+ if not paths["home"].is_dir():
+ rc.friendly_exit(f"usuário não encontrado: {user}")
+
+ print(f"{user} @ runv.club\n")
+
+ profile_text = rc.read_text_limited(paths["profile"])
+ profile, warn = rc.parse_profile_json(profile_text)
+ if warn:
+ print(warn)
+
+ def field(label: str, value: str | None) -> None:
+ print(f"{label}: {value if value and value.strip() else 'não informado'}")
+
+ if profile:
+ field("Nome", profile.get("display_name", ""))
+ field("Bio", profile.get("bio", ""))
+ field("Local", profile.get("location", ""))
+ else:
+ field("Nome", None)
+ field("Bio", None)
+ field("Local", None)
+
+ if paths["public_index"].is_file():
+ print(f"Home: /~{user}/")
+ mtime = rc.format_mtime_local(paths["public_index"])
+ if mtime:
+ print(f"Home atualizada: {mtime}")
+ else:
+ print("Home: não informado")
+
+ project_text = rc.read_text_limited(paths["project"])
+ plan_text = rc.read_text_limited(paths["plan"])
+
+ print("\nProjeto:")
+ if project_text and project_text.strip():
+ for line in project_text.strip().splitlines():
+ print(f" {line}")
+ else:
+ print(" não informado")
+
+ print("\n.plan:")
+ if plan_text and plan_text.strip():
+ for line in plan_text.strip().splitlines():
+ print(f" {line}")
+ else:
+ print(" não informado")
+
+ if profile:
+ links = profile.get("links") or []
+ print("\nLinks:")
+ if links:
+ for link in links:
+ print(f" {link}")
+ else:
+ print(" não informado")
+ interests = profile.get("interests") or []
+ print("\nInteresses:")
+ if interests:
+ for item in interests:
+ print(f" {item}")
+ else:
+ print(" não informado")
+
+ return 0
+
+
+def main(argv: list[str] | None = None) -> int:
+ try:
+ p = argparse.ArgumentParser(
+ prog="runv-finger",
+ description="Mostra o perfil público de outro membro.",
+ )
+ p.add_argument("username", help="nome de utilizador Unix")
+ args = p.parse_args(argv)
+ return cmd_finger(args.username)
+ except KeyboardInterrupt:
+ print("\nInterrompido.", file=sys.stderr)
+ return 130
+ except SystemExit:
+ raise
+ except Exception as e:
+ rc.friendly_exit(f"erro: {e}")
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/bin/runv-profile b/tools/bin/runv-profile
@@ -0,0 +1,181 @@
+#!/usr/bin/env python3
+"""Gerencia o perfil local público do utilizador (runv.club)."""
+
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+from pathlib import Path
+
+sys.tracebacklimit = 0
+
+
+def _bootstrap() -> None:
+ from pathlib import Path
+
+ installed = Path("/usr/local/share/runv/lib")
+ candidates = [installed]
+ script = Path(__file__).resolve()
+ if script.parent.name == "bin":
+ candidates.insert(0, script.parent.parent / "lib")
+ for c in candidates:
+ if (c / "runv_community.py").is_file() and str(c) not in sys.path:
+ sys.path.insert(0, str(c))
+ return
+
+
+_bootstrap()
+import runv_community as rc # noqa: E402
+
+PLAN_INITIAL = "Ainda estou escrevendo meu .plan.\n"
+PROJECT_INITIAL = "Explorando a runv.club.\n"
+
+
+def local_paths() -> dict[str, Path]:
+ home = Path.home()
+ return {
+ "runv_dir": home / ".runv",
+ "profile": home / ".runv" / "profile.json",
+ "plan": home / ".plan",
+ "project": home / ".project",
+ }
+
+
+def cmd_init() -> int:
+ paths = local_paths()
+ created: list[str] = []
+ existed: list[str] = []
+
+ if not paths["runv_dir"].exists():
+ paths["runv_dir"].mkdir(parents=True, exist_ok=True)
+ os.chmod(paths["runv_dir"], 0o755)
+ created.append(str(paths["runv_dir"]))
+ else:
+ os.chmod(paths["runv_dir"], 0o755)
+ existed.append(str(paths["runv_dir"]))
+
+ targets: list[tuple[Path, str]] = [
+ (paths["profile"], rc.profile_json_default_text()),
+ (paths["plan"], PLAN_INITIAL),
+ (paths["project"], PROJECT_INITIAL),
+ ]
+ for path, content in targets:
+ if path.exists():
+ existed.append(str(path))
+ continue
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ os.chmod(path, 0o644)
+ created.append(str(path))
+
+ print("Perfil runv inicializado.\n")
+ if created:
+ print("criado:")
+ for p in created:
+ print(f" {p}")
+ if existed:
+ print("\njá existia:")
+ for p in existed:
+ print(f" {p}")
+ if not created and not existed:
+ print("(nada a fazer)")
+ return 0
+
+
+def cmd_show() -> int:
+ paths = local_paths()
+ print("Perfil runv\n")
+ print("Arquivo:")
+ print(f" {paths['profile']}\n")
+
+ if not paths["profile"].is_file():
+ print("Perfil ainda não existe.")
+ print("Rode: runv-profile init\n")
+ else:
+ text = rc.read_text_limited(paths["profile"])
+ profile, warn = rc.parse_profile_json(text)
+ if warn:
+ print(warn)
+ if profile:
+ print(f"Nome:\n {profile.get('display_name') or '(vazio)'}\n")
+ print(f"Bio:\n {profile.get('bio') or '(vazio)'}\n")
+ print(f"Local:\n {profile.get('location') or '(vazio)'}\n")
+ links = profile.get("links") or []
+ print("Links:")
+ if links:
+ for link in links:
+ print(f" {link}")
+ else:
+ print(" (vazio)")
+ print()
+ interests = profile.get("interests") or []
+ print("Interesses:")
+ if interests:
+ for item in interests:
+ print(f" {item}")
+ else:
+ print(" (vazio)")
+ print()
+
+ plan_text = rc.read_text_limited(paths["plan"])
+ project_text = rc.read_text_limited(paths["project"])
+ print(".project:")
+ if project_text and project_text.strip():
+ for line in project_text.strip().splitlines():
+ print(f" {line}")
+ else:
+ print(" (vazio)")
+ print()
+ print(".plan:")
+ if plan_text and plan_text.strip():
+ for line in plan_text.strip().splitlines():
+ print(f" {line}")
+ else:
+ print(" (vazio)")
+ return 0
+
+
+def cmd_path() -> int:
+ paths = local_paths()
+ print(f"profile: {paths['profile']}")
+ print(f"plan: {paths['plan']}")
+ print(f"project: {paths['project']}")
+ return 0
+
+
+def build_parser() -> argparse.ArgumentParser:
+ p = argparse.ArgumentParser(
+ prog="runv-profile",
+ description="Gerencia o perfil local público (~/.runv/profile.json, .plan, .project).",
+ )
+ sub = p.add_subparsers(dest="command")
+
+ sub.add_parser("init", help="cria ficheiros de perfil se ainda não existirem")
+ sub.add_parser("show", help="mostra o perfil local")
+ sub.add_parser("path", help="mostra caminhos dos ficheiros de perfil")
+ return p
+
+
+def main(argv: list[str] | None = None) -> int:
+ try:
+ args = build_parser().parse_args(argv)
+ if args.command == "init":
+ return cmd_init()
+ if args.command == "show":
+ return cmd_show()
+ if args.command == "path":
+ return cmd_path()
+ build_parser().print_help()
+ return 1
+ except KeyboardInterrupt:
+ print("\nInterrompido.", file=sys.stderr)
+ return 130
+ except SystemExit:
+ raise
+ except Exception as e:
+ rc.friendly_exit(f"erro: {e}")
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/bin/runv-who b/tools/bin/runv-who
@@ -0,0 +1,133 @@
+#!/usr/bin/env python3
+"""Lista membros da runv.club."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+from typing import Any
+
+sys.tracebacklimit = 0
+
+
+def _bootstrap() -> None:
+ from pathlib import Path
+
+ installed = Path("/usr/local/share/runv/lib")
+ candidates = [installed]
+ script = Path(__file__).resolve()
+ if script.parent.name == "bin":
+ candidates.insert(0, script.parent.parent / "lib")
+ for c in candidates:
+ if (c / "runv_community.py").is_file() and str(c) not in sys.path:
+ sys.path.insert(0, str(c))
+ return
+
+
+_bootstrap()
+import runv_community as rc # noqa: E402
+
+
+def collect_member(username: str) -> dict[str, Any]:
+ paths = rc.home_paths(username)
+ has_homepage = paths["public_index"].is_file()
+ homepage_mtime = rc.homepage_mtime_iso(paths["public_index"]) if has_homepage else None
+ return {
+ "username": username,
+ "homepage": f"/~{username}/",
+ "has_homepage": has_homepage,
+ "homepage_mtime": homepage_mtime,
+ "has_plan": rc.file_has_content(paths["plan"]),
+ "has_project": rc.file_has_content(paths["project"]),
+ }
+
+
+def sort_members(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ with_home = [r for r in rows if r["has_homepage"]]
+ without_home = [r for r in rows if not r["has_homepage"]]
+
+ def mtime_key(r: dict[str, Any]) -> str:
+ return r.get("homepage_mtime") or ""
+
+ with_home.sort(key=lambda r: mtime_key(r), reverse=True)
+ without_home.sort(key=lambda r: r["username"].lower())
+ return with_home + without_home
+
+
+def filter_active(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ return [
+ r
+ for r in rows
+ if r["has_homepage"] or r["has_plan"] or r["has_project"]
+ ]
+
+
+def print_text_table(rows: list[dict[str, Any]]) -> None:
+ print("Membros runv.club\n")
+ if not rows:
+ print("(nenhum membro encontrado)")
+ return
+ for r in rows:
+ user = r["username"]
+ if r["has_homepage"]:
+ hm = rc.format_mtime_local(r.get("homepage_mtime")) or "?"
+ home_col = f"home {hm}"
+ else:
+ home_col = "sem homepage"
+ flags = []
+ if r["has_plan"]:
+ flags.append(".plan")
+ if r["has_project"]:
+ flags.append(".project")
+ flag_col = " ".join(flags) if flags else "-"
+ print(f"{user:<12} {home_col:<22} {flag_col:<14} {r['homepage']}")
+
+
+def main(argv: list[str] | None = None) -> int:
+ try:
+ p = argparse.ArgumentParser(
+ prog="runv-who",
+ description="Lista membros da runv.club.",
+ )
+ p.add_argument("--json", action="store_true", help="saída em JSON")
+ p.add_argument("--limit", type=int, default=None, metavar="N", help="limitar resultados")
+ p.add_argument(
+ "--active",
+ action="store_true",
+ help="só utilizadores com homepage, .plan ou .project",
+ )
+ args = p.parse_args(argv)
+
+ if args.limit is not None and args.limit < 1:
+ rc.friendly_exit("--limit deve ser um inteiro positivo.")
+
+ names, warning = rc.load_member_usernames(rc.DEFAULT_USERS_JSON, rc.DEFAULT_HOME_ROOT)
+ if warning:
+ print(warning, file=sys.stderr)
+
+ rows = [collect_member(u) for u in names]
+ if args.active:
+ rows = filter_active(rows)
+ rows = sort_members(rows)
+ if args.limit is not None:
+ rows = rows[: args.limit]
+
+ if args.json:
+ print(json.dumps(rows, ensure_ascii=False, indent=2))
+ return 0
+
+ print_text_table(rows)
+ return 0
+ except KeyboardInterrupt:
+ print("\nInterrompido.", file=sys.stderr)
+ return 130
+ except SystemExit:
+ raise
+ except Exception as e:
+ rc.friendly_exit(f"erro: {e}")
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/lib/runv_community.py b/tools/lib/runv_community.py
@@ -0,0 +1,205 @@
+#!/usr/bin/env python3
+"""
+Utilitários partilhados pelos comandos comunitários runv.club (stdlib apenas).
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Final
+
+USERNAME_RE: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_-]{1,31}$")
+MAX_READ_BYTES: Final[int] = 16 * 1024
+DEFAULT_USERS_JSON: Final[Path] = Path("/var/lib/runv/users.json")
+DEFAULT_HOME_ROOT: Final[Path] = Path("/home")
+INSTALLED_LIB_DIR: Final[Path] = Path("/usr/local/share/runv/lib")
+
+PROFILE_DEFAULT: Final[dict[str, Any]] = {
+ "display_name": "",
+ "bio": "",
+ "location": "",
+ "links": [],
+ "interests": [],
+}
+
+
+def install_bootstrap() -> None:
+ """Permite importar este módulo a partir de tools/bin/ ou de /usr/local/bin/."""
+ here = Path(__file__).resolve().parent
+ candidates: list[Path] = [INSTALLED_LIB_DIR, here]
+ try:
+ script = Path(sys.argv[0]).resolve()
+ if script.parent.name == "bin":
+ candidates.insert(0, script.parent.parent / "lib")
+ except (IndexError, OSError):
+ pass
+ for candidate in candidates:
+ if (candidate / "runv_community.py").is_file():
+ s = str(candidate)
+ if s not in sys.path:
+ sys.path.insert(0, s)
+ return
+
+
+def friendly_exit(msg: str, code: int = 1) -> None:
+ print(msg, file=sys.stderr)
+ raise SystemExit(code)
+
+
+def validate_username(username: str) -> str:
+ u = username.strip()
+ if not USERNAME_RE.fullmatch(u):
+ friendly_exit(
+ "nome de utilizador inválido: use letras minúsculas, dígitos, _ e -; "
+ "comece com letra; entre 2 e 32 caracteres."
+ )
+ return u
+
+
+def read_text_limited(path: Path, *, max_bytes: int = MAX_READ_BYTES) -> str | None:
+ if not path.is_file():
+ return None
+ try:
+ with path.open("rb") as f:
+ data = f.read(max_bytes + 1)
+ if len(data) > max_bytes:
+ data = data[:max_bytes]
+ return data.decode("utf-8", errors="replace")
+ except OSError:
+ return None
+
+
+def file_has_content(path: Path) -> bool:
+ text = read_text_limited(path)
+ return text is not None and bool(text.strip())
+
+
+def parse_profile_json(text: str | None) -> tuple[dict[str, Any] | None, str | None]:
+ if text is None:
+ return None, None
+ raw = text.strip()
+ if not raw:
+ return None, None
+ try:
+ data = json.loads(raw)
+ except json.JSONDecodeError as e:
+ return None, f"aviso: profile.json inválido ({e.msg})."
+ if not isinstance(data, dict):
+ return None, "aviso: profile.json deve ser um objeto JSON."
+ safe: dict[str, Any] = {}
+ for key in ("display_name", "bio", "location"):
+ val = data.get(key, "")
+ safe[key] = val if isinstance(val, str) else ""
+ links = data.get("links", [])
+ safe["links"] = [x for x in links if isinstance(x, str)] if isinstance(links, list) else []
+ interests = data.get("interests", [])
+ safe["interests"] = (
+ [x for x in interests if isinstance(x, str)] if isinstance(interests, list) else []
+ )
+ return safe, None
+
+
+def home_paths(username: str) -> dict[str, Path]:
+ base = DEFAULT_HOME_ROOT / username
+ return {
+ "home": base,
+ "profile": base / ".runv" / "profile.json",
+ "plan": base / ".plan",
+ "project": base / ".project",
+ "public_index": base / "public_html" / "index.html",
+ }
+
+
+def homepage_mtime_iso(path: Path) -> str | None:
+ try:
+ st = path.stat()
+ ts = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc)
+ return ts.isoformat().replace("+00:00", "Z")
+ except OSError:
+ return None
+
+
+def format_mtime_local(iso_or_path: str | Path | None) -> str | None:
+ if iso_or_path is None:
+ return None
+ if isinstance(iso_or_path, Path):
+ iso = homepage_mtime_iso(iso_or_path)
+ if iso is None:
+ return None
+ else:
+ iso = iso_or_path
+ try:
+ if iso.endswith("Z"):
+ dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
+ else:
+ dt = datetime.fromisoformat(iso)
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ local = dt.astimezone()
+ return local.strftime("%Y-%m-%d %H:%M")
+ except (ValueError, TypeError):
+ return None
+
+
+def _username_from_item(item: Any) -> str | None:
+ if isinstance(item, str) and USERNAME_RE.fullmatch(item):
+ return item
+ if isinstance(item, dict):
+ uname = item.get("username")
+ if isinstance(uname, str) and USERNAME_RE.fullmatch(uname):
+ return uname
+ return None
+
+
+def _usernames_from_parsed(data: Any) -> list[str]:
+ found: list[str] = []
+ seen: set[str] = set()
+ if isinstance(data, list):
+ items = data
+ elif isinstance(data, dict):
+ if isinstance(data.get("users"), list):
+ items = data["users"]
+ else:
+ items = list(data.keys())
+ else:
+ return []
+ for item in items:
+ uname = _username_from_item(item)
+ if uname and uname not in seen:
+ seen.add(uname)
+ found.append(uname)
+ return found
+
+
+def load_member_usernames(
+ users_json_path: Path,
+ home_root: Path,
+) -> tuple[list[str], str | None]:
+ warning: str | None = None
+ if users_json_path.is_file():
+ try:
+ raw = users_json_path.read_text(encoding="utf-8").strip()
+ if raw:
+ parsed = json.loads(raw)
+ names = _usernames_from_parsed(parsed)
+ if names:
+ return sorted(names, key=str.lower), None
+ except (OSError, json.JSONDecodeError):
+ warning = (
+ f"aviso: não foi possível ler {users_json_path}; "
+ "a listar diretórios em /home."
+ )
+ names_home: list[str] = []
+ if home_root.is_dir():
+ for entry in home_root.iterdir():
+ if entry.is_dir() and USERNAME_RE.fullmatch(entry.name):
+ names_home.append(entry.name)
+ return sorted(set(names_home), key=str.lower), warning
+
+
+def profile_json_default_text() -> str:
+ return json.dumps(PROFILE_DEFAULT, ensure_ascii=False, indent=2) + "\n"
diff --git a/tools/lib/runv_email_aliases.py b/tools/lib/runv_email_aliases.py
@@ -0,0 +1,462 @@
+#!/usr/bin/env python3
+"""
+Utilitários para pedidos e aprovação de aliases de email runv.club (stdlib apenas).
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import secrets
+import sys
+import tempfile
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Final, Literal
+
+import runv_community as rc
+
+DEFAULT_ALIASES_PATH: Final[Path] = Path("/var/lib/runv/email-aliases.json")
+DEFAULT_ALIASES_LOCK: Final[Path] = Path("/var/lib/runv/email-aliases.lock")
+DEFAULT_QUEUE_DIR: Final[Path] = Path("/var/lib/runv/email-alias-queue")
+DEFAULT_ALIAS_DOMAIN: Final[str] = "runv.club"
+
+ALIAS_RESERVED_USERNAMES: Final[frozenset[str]] = frozenset(
+ {
+ "root",
+ "admin",
+ "postmaster",
+ "abuse",
+ "security",
+ "support",
+ "contato",
+ "contact",
+ "noreply",
+ "no-reply",
+ "mailer-daemon",
+ "hostmaster",
+ "webmaster",
+ "entre",
+ "join",
+ "welcome",
+ }
+)
+
+DEST_EMAIL_RE: Final[re.Pattern[str]] = re.compile(
+ r"^[^@\s\x00\r\n]+@[^@\s\x00\r\n]+\.[^@\s\x00\r\n]+$"
+)
+
+ArchiveKind = Literal["approved", "rejected", "cancelled"]
+
+
+def iso_utc_now() -> str:
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def alias_domain() -> str:
+ raw = os.environ.get("RUNV_EMAIL_ALIAS_DOMAIN", "").strip().lower()
+ return raw if raw else DEFAULT_ALIAS_DOMAIN
+
+
+def aliases_paths() -> tuple[Path, Path]:
+ aliases = os.environ.get("RUNV_EMAIL_ALIASES_PATH", "").strip()
+ lock = os.environ.get("RUNV_EMAIL_ALIASES_LOCK_PATH", "").strip()
+ return (
+ Path(aliases) if aliases else DEFAULT_ALIASES_PATH,
+ Path(lock) if lock else DEFAULT_ALIASES_LOCK,
+ )
+
+
+def queue_paths() -> dict[str, Path]:
+ raw = os.environ.get("RUNV_EMAIL_ALIAS_QUEUE_DIR", "").strip()
+ base = Path(raw) if raw else DEFAULT_QUEUE_DIR
+ return {
+ "queue": base,
+ "approved": base / "approved",
+ "rejected": base / "rejected",
+ "cancelled": base / "cancelled",
+ }
+
+
+def validate_alias_username(username: str) -> str:
+ u = rc.validate_username(username)
+ if u in ALIAS_RESERVED_USERNAMES:
+ rc.friendly_exit(f"nome de utilizador reservado para alias de email: {u!r}")
+ return u
+
+
+def validate_destination_email(email: str) -> str:
+ addr = email.strip()
+ if not addr:
+ rc.friendly_exit("email de destino obrigatório.")
+ if len(addr) > 254:
+ rc.friendly_exit("email de destino demasiado longo (máximo 254 caracteres).")
+ if "\x00" in addr or "\n" in addr or "\r" in addr or " " in addr:
+ rc.friendly_exit("email de destino inválido.")
+ if addr.count("@") != 1:
+ rc.friendly_exit("email de destino inválido: deve conter exactamente um @.")
+ if not DEST_EMAIL_RE.fullmatch(addr):
+ rc.friendly_exit("email de destino inválido.")
+ local, domain = addr.rsplit("@", 1)
+ if not local or not domain:
+ rc.friendly_exit("email de destino inválido.")
+ if "." not in domain:
+ rc.friendly_exit("email de destino inválido: domínio deve conter pelo menos um ponto.")
+ if domain.lower() == alias_domain().lower():
+ rc.friendly_exit(
+ f"email de destino não pode ser @{alias_domain()} (evita encaminhamento em loop)."
+ )
+ return addr
+
+
+def alias_address(username: str) -> str:
+ return f"{username}@{alias_domain()}"
+
+
+def current_username() -> str:
+ import pwd
+
+ try:
+ name = pwd.getpwuid(os.getuid()).pw_name
+ except (KeyError, OSError) as e:
+ rc.friendly_exit(f"não foi possível determinar o utilizador: {e}")
+ return validate_alias_username(name)
+
+
+def admin_operator() -> str:
+ sudo_user = os.environ.get("SUDO_USER", "").strip()
+ if sudo_user:
+ return sudo_user
+ import pwd
+
+ try:
+ return pwd.getpwuid(os.getuid()).pw_name
+ except (KeyError, OSError):
+ return "root"
+
+
+def require_root() -> None:
+ geteuid = getattr(os, "geteuid", None)
+ if geteuid is None or geteuid() != 0:
+ rc.friendly_exit("este comando precisa ser executado como root.")
+
+
+def new_request_id(username: str) -> str:
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
+ suffix = secrets.token_hex(3)
+ return f"{ts}-{username}-{suffix}"
+
+
+def _read_json_file(path: Path) -> Any | None:
+ if not path.is_file():
+ return None
+ try:
+ raw = path.read_text(encoding="utf-8").strip()
+ if not raw:
+ return None
+ return json.loads(raw)
+ except (OSError, json.JSONDecodeError):
+ return None
+
+
+def load_aliases_unlocked(aliases_path: Path) -> dict[str, dict[str, Any]]:
+ parsed = _read_json_file(aliases_path)
+ if parsed is None:
+ return {}
+ if not isinstance(parsed, dict):
+ rc.friendly_exit(f"formato inválido em {aliases_path}: esperado objecto JSON.")
+ out: dict[str, dict[str, Any]] = {}
+ for key, val in parsed.items():
+ if isinstance(key, str) and isinstance(val, dict):
+ out[key] = val
+ return out
+
+
+def load_aliases() -> dict[str, dict[str, Any]]:
+ aliases_path, _ = aliases_paths()
+ return load_aliases_unlocked(aliases_path)
+
+
+def save_aliases(data: dict[str, dict[str, Any]]) -> None:
+ import fcntl
+
+ aliases_path, lock_path = aliases_paths()
+ aliases_path.parent.mkdir(parents=True, exist_ok=True)
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
+
+ lock_f = open(lock_path, "a+", encoding="utf-8")
+ try:
+ fcntl.flock(lock_f.fileno(), fcntl.LOCK_EX)
+ tmp_fd, tmp_name = tempfile.mkstemp(
+ prefix="email-aliases.",
+ suffix=".tmp",
+ dir=str(aliases_path.parent),
+ )
+ tmp_path = Path(tmp_name)
+ try:
+ with os.fdopen(tmp_fd, "w", encoding="utf-8") as out:
+ json.dump(data, out, indent=2, ensure_ascii=False)
+ out.write("\n")
+ out.flush()
+ os.fsync(out.fileno())
+ os.replace(tmp_path, aliases_path)
+ except Exception:
+ tmp_path.unlink(missing_ok=True)
+ raise
+ finally:
+ fcntl.flock(lock_f.fileno(), fcntl.LOCK_UN)
+ lock_f.close()
+
+
+def get_active_alias(username: str) -> dict[str, Any] | None:
+ entry = load_aliases().get(username)
+ if not entry or entry.get("status") != "active":
+ return None
+ return entry
+
+
+def _queue_request_file(request_id: str) -> Path:
+ return queue_paths()["queue"] / f"{request_id}.json"
+
+
+def _parse_request(path: Path) -> dict[str, Any] | None:
+ data = _read_json_file(path)
+ if not isinstance(data, dict):
+ return None
+ return data
+
+
+def iter_pending_requests() -> list[dict[str, Any]]:
+ qdir = queue_paths()["queue"]
+ if not qdir.is_dir():
+ return []
+ pending: list[dict[str, Any]] = []
+ for path in sorted(qdir.glob("*.json")):
+ if not path.is_file():
+ continue
+ req = _parse_request(path)
+ if req and req.get("status") == "pending":
+ pending.append({**req, "_path": str(path)})
+ pending.sort(key=lambda r: str(r.get("created_at", "")))
+ return pending
+
+
+def find_pending_for_user(username: str) -> dict[str, Any] | None:
+ for req in iter_pending_requests():
+ if req.get("username") == username:
+ return req
+ return None
+
+
+def find_latest_pending_for_user(username: str) -> tuple[dict[str, Any], Path] | None:
+ matches: list[tuple[dict[str, Any], Path]] = []
+ qdir = queue_paths()["queue"]
+ if not qdir.is_dir():
+ return None
+ for path in qdir.glob("*.json"):
+ if not path.is_file():
+ continue
+ req = _parse_request(path)
+ if req and req.get("username") == username and req.get("status") == "pending":
+ matches.append((req, path))
+ if not matches:
+ return None
+
+ def sort_key(item: tuple[dict[str, Any], Path]) -> str:
+ req, path = item
+ created = req.get("created_at")
+ if isinstance(created, str) and created:
+ return created
+ try:
+ return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).strftime(
+ "%Y-%m-%dT%H:%M:%SZ"
+ )
+ except OSError:
+ return ""
+
+ matches.sort(key=sort_key)
+ req, path = matches[-1]
+ return req, path
+
+
+def create_pending_request(username: str, destination: str) -> dict[str, Any]:
+ paths = queue_paths()
+ try:
+ paths["queue"].mkdir(parents=True, exist_ok=True)
+ except OSError as e:
+ rc.friendly_exit(f"não foi possível criar o diretório da fila: {e}")
+
+ if find_pending_for_user(username) is not None:
+ rc.friendly_exit(
+ "Já existe pedido pendente.\n"
+ "Veja o status com:\n"
+ " runv-email-alias status\n\n"
+ "Para cancelar:\n"
+ " runv-email-alias cancel"
+ )
+
+ request_id = new_request_id(username)
+ alias = alias_address(username)
+ payload: dict[str, Any] = {
+ "request_id": request_id,
+ "username": username,
+ "alias": alias,
+ "destination": destination,
+ "status": "pending",
+ "created_at": iso_utc_now(),
+ }
+ dest_path = _queue_request_file(request_id)
+ try:
+ fd = os.open(
+ dest_path,
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL,
+ 0o664,
+ )
+ except FileExistsError:
+ rc.friendly_exit("conflito ao criar pedido; tente novamente.")
+ except PermissionError:
+ rc.friendly_exit(
+ f"sem permissão para criar pedido em {paths['queue']}\n"
+ "peça ao admin para executar:\n"
+ " sudo python3 scripts/admin/setup_email_aliases.py"
+ )
+ except OSError as e:
+ rc.friendly_exit(f"erro ao criar pedido: {e}")
+
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as out:
+ json.dump(payload, out, indent=2, ensure_ascii=False)
+ out.write("\n")
+ except OSError as e:
+ dest_path.unlink(missing_ok=True)
+ rc.friendly_exit(f"erro ao gravar pedido: {e}")
+ return payload
+
+
+def archive_request(
+ path: Path,
+ payload: dict[str, Any],
+ kind: ArchiveKind,
+) -> Path:
+ paths = queue_paths()
+ dest_dir = paths[kind]
+ dest_dir.mkdir(parents=True, exist_ok=True)
+ request_id = payload.get("request_id")
+ if not isinstance(request_id, str) or not request_id:
+ request_id = path.stem
+ dest = dest_dir / f"{request_id}.json"
+ if dest.exists():
+ dest = dest_dir / f"{request_id}-{secrets.token_hex(2)}.json"
+ try:
+ path.replace(dest)
+ except OSError:
+ dest.write_text(
+ json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
+ encoding="utf-8",
+ )
+ path.unlink(missing_ok=True)
+ return dest
+
+
+def cancel_latest_pending(username: str) -> dict[str, Any] | None:
+ found = find_latest_pending_for_user(username)
+ if found is None:
+ return None
+ req, path = found
+ req["status"] = "cancelled"
+ req["cancelled_at"] = iso_utc_now()
+ archive_request(path, req, "cancelled")
+ return req
+
+
+def approve_pending(username: str, operator: str) -> dict[str, Any]:
+ validate_alias_username(username)
+ found = find_latest_pending_for_user(username)
+ if found is None:
+ rc.friendly_exit(f"nenhum pedido pendente para o utilizador {username!r}.")
+ req, path = found
+ destination = req.get("destination")
+ if not isinstance(destination, str):
+ rc.friendly_exit("pedido pendente sem email de destino válido.")
+ destination = validate_destination_email(destination)
+
+ aliases_path, _ = aliases_paths()
+ import fcntl
+
+ aliases_path.parent.mkdir(parents=True, exist_ok=True)
+ lock_path = aliases_paths()[1]
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
+
+ lock_f = open(lock_path, "a+", encoding="utf-8")
+ try:
+ fcntl.flock(lock_f.fileno(), fcntl.LOCK_EX)
+ data = load_aliases_unlocked(aliases_path)
+ now = iso_utc_now()
+ alias = alias_address(username)
+ existing = data.get(username)
+ created_at = now
+ if isinstance(existing, dict) and isinstance(existing.get("created_at"), str):
+ created_at = existing["created_at"]
+ data[username] = {
+ "username": username,
+ "alias": alias,
+ "destination": destination,
+ "status": "active",
+ "created_at": created_at,
+ "updated_at": now,
+ "approved_by": operator,
+ }
+ tmp_fd, tmp_name = tempfile.mkstemp(
+ prefix="email-aliases.",
+ suffix=".tmp",
+ dir=str(aliases_path.parent),
+ )
+ tmp_path = Path(tmp_name)
+ try:
+ with os.fdopen(tmp_fd, "w", encoding="utf-8") as out:
+ json.dump(data, out, indent=2, ensure_ascii=False)
+ out.write("\n")
+ out.flush()
+ os.fsync(out.fileno())
+ os.replace(tmp_path, aliases_path)
+ except Exception:
+ tmp_path.unlink(missing_ok=True)
+ raise
+ finally:
+ fcntl.flock(lock_f.fileno(), fcntl.LOCK_UN)
+ lock_f.close()
+
+ req["status"] = "approved"
+ req["approved_at"] = iso_utc_now()
+ req["approved_by"] = operator
+ archive_request(path, req, "approved")
+ return data[username]
+
+
+def reject_pending(username: str, operator: str, reason: str) -> dict[str, Any]:
+ validate_alias_username(username)
+ found = find_latest_pending_for_user(username)
+ if found is None:
+ rc.friendly_exit(f"nenhum pedido pendente para o utilizador {username!r}.")
+ req, path = found
+ req["status"] = "rejected"
+ req["rejected_at"] = iso_utc_now()
+ req["rejected_by"] = operator
+ req["reason"] = reason
+ archive_request(path, req, "rejected")
+ return req
+
+
+def list_active_aliases() -> list[tuple[str, str, str]]:
+ data = load_aliases()
+ rows: list[tuple[str, str, str]] = []
+ for username in sorted(data.keys(), key=str.lower):
+ entry = data[username]
+ if entry.get("status") != "active":
+ continue
+ alias = entry.get("alias")
+ dest = entry.get("destination")
+ if isinstance(alias, str) and isinstance(dest, str):
+ rows.append((username, alias, dest))
+ return rows
diff --git a/tools/skel/.plan b/tools/skel/.plan
@@ -0,0 +1 @@
+Ainda estou escrevendo meu .plan.
diff --git a/tools/skel/.project b/tools/skel/.project
@@ -0,0 +1 @@
+Explorando a runv.club.
diff --git a/tools/skel/.runv/profile.json b/tools/skel/.runv/profile.json
@@ -0,0 +1,7 @@
+{
+ "display_name": "",
+ "bio": "",
+ "location": "",
+ "links": [],
+ "interests": []
+}
diff --git a/tools/tools.py b/tools/tools.py
@@ -32,11 +32,13 @@ _APT_PACKAGE_ALIASES: dict[str, str] = {
"chat": "weechat",
}
BIN_DIR: Path = TOOL_ROOT / "bin"
+LIB_DIR: Path = TOOL_ROOT / "lib"
MOTD_SRC: Path = TOOL_ROOT / "motd" / "60-runv"
SKEL_DIR: Path = TOOL_ROOT / "skel"
SUDOERS_ADMIN_SRC: Path = TOOL_ROOT / "sudoers" / "90-runv-pmurad-admin"
DEST_BIN_DIR: Path = Path("/usr/local/bin")
+DEST_COMMUNITY_LIB_DIR: Path = Path("/usr/local/share/runv/lib")
DEST_MOTD: Path = Path("/etc/update-motd.d/60-runv")
DEST_SKEL: Path = Path("/etc/skel")
DEST_SSHD_DROPIN: Path = Path("/etc/ssh/sshd_config.d/90-runv-jailed.conf")
@@ -217,7 +219,20 @@ def install_bin_scripts(
) -> None:
if not dry_run:
DEST_BIN_DIR.mkdir(parents=True, exist_ok=True)
- for name in ("runv-help", "runv-links", "runv-status", "runv-games", "runvers", "chat"):
+ for name in (
+ "runv-help",
+ "runv-links",
+ "runv-status",
+ "runv-games",
+ "runvers",
+ "chat",
+ "runv-profile",
+ "runv-finger",
+ "runv-who",
+ "runv-bulletin",
+ "runv-email-alias",
+ "runv-admin-email-alias",
+ ):
copy_one(
BIN_DIR / name,
DEST_BIN_DIR / name,
@@ -229,6 +244,28 @@ def install_bin_scripts(
)
+def install_community_lib(
+ *,
+ force: bool,
+ dry_run: bool,
+ log: logging.Logger,
+ summary: RunSummary,
+) -> None:
+ """Copia bibliotecas partilhadas para /usr/local/share/runv/lib/."""
+ if not dry_run:
+ DEST_COMMUNITY_LIB_DIR.mkdir(parents=True, exist_ok=True)
+ for src in sorted(LIB_DIR.glob("*.py")):
+ copy_one(
+ src,
+ DEST_COMMUNITY_LIB_DIR / src.name,
+ 0o644,
+ force=force,
+ dry_run=dry_run,
+ log=log,
+ summary=summary,
+ )
+
+
def install_motd(
*,
force: bool,
@@ -373,10 +410,38 @@ def install_skel(
skel_files: list[tuple[Path, Path, int]] = [
(SKEL_DIR / ".bash_aliases", DEST_SKEL / ".bash_aliases", 0o644),
+ (SKEL_DIR / ".plan", DEST_SKEL / ".plan", 0o644),
+ (SKEL_DIR / ".project", DEST_SKEL / ".project", 0o644),
]
for src, dst, mode in skel_files:
copy_one(src, dst, mode, force=force, dry_run=dry_run, log=log, summary=summary)
+ runv_skel_dir = DEST_SKEL / ".runv"
+ profile_src = SKEL_DIR / ".runv" / "profile.json"
+ profile_dst = runv_skel_dir / "profile.json"
+ if not profile_src.is_file():
+ summary.errors.append(f"origem inexistente: {profile_src}")
+ log.error("Origem inexistente: %s", profile_src)
+ else:
+ if not dry_run:
+ runv_skel_dir.mkdir(parents=True, exist_ok=True)
+ os.chmod(runv_skel_dir, 0o755)
+ try:
+ os.chown(runv_skel_dir, 0, 0)
+ except OSError as e:
+ log.warning("chown em %s: %s", runv_skel_dir, e)
+ elif not runv_skel_dir.exists():
+ log.info("[dry-run] criaria diretório %s (755)", runv_skel_dir)
+ copy_one(
+ profile_src,
+ profile_dst,
+ 0o644,
+ force=force,
+ dry_run=dry_run,
+ log=log,
+ summary=summary,
+ )
+
pub_dir = DEST_SKEL / "public_html"
index_src = SKEL_DIR / "public_html" / "index.html"
index_dst = pub_dir / "index.html"
@@ -628,6 +693,9 @@ def main(argv: list[str] | None = None) -> int:
log.info("Instalando scripts em %s", DEST_BIN_DIR)
install_bin_scripts(force=args.force, dry_run=args.dry_run, log=log, summary=summary)
+ log.info("Instalando biblioteca comunitária em %s", DEST_COMMUNITY_LIB_DIR)
+ install_community_lib(force=args.force, dry_run=args.dry_run, log=log, summary=summary)
+
log.info("Instalando MOTD em %s", DEST_MOTD)
install_motd(force=args.force, dry_run=args.dry_run, log=log, summary=summary)