Wizard CLI — passo a passo por perfil de repo
O wizard do runner detecta o perfil do seu repositório e gera o .deploy.yml proporcional ao que ele encontra. Este guia mostra cada perfil em ação, com input do repo, prompts respondidos e resultado final.
Tutoriais validados via matriz CI/CD
tests/cicd-matrix/(72 cells, 0 fail). Toda saída abaixo é real — copiada de execuções da matriz contra os branchesdevborlot/ccs-deploy-test/dist-*.
Perfis cobertos
| ID | Perfil | Quando | Esperado |
|---|---|---|---|
| A | Repo já tem .deploy.yml |
App pronta pra deploy | Wizard refuse, runner usa o yml direto |
| B | Só Dockerfile (com EXPOSE) |
App nova com Docker | Generator emite yml completo zero-touch |
| C | Só docker-compose.yml |
Migrando de docker-compose | Generator converte; warning de port mapping |
| D | composer.json + Dockerfile + .env.example (Laravel) |
Apps PHP/Laravel | Generator detecta secrets + production profile |
| E | Só README.md (bare) |
Repo sem nada | Wizard falha com instrução |
| F | Image-only (registry) | Imagem pronta de registry | runner wizard --answers com type: image |
| G | Dockerfile SEM EXPOSE |
Dockerfile minimalista | Wizard avisa; usuário override port: |
| H | Image + IP-restricted (127.0.0.1:8081:80) |
Serviço interno (VPC/loopback) | .deploy.yml com ports: + bind validado |
| I | .env.example com valores raw em chaves de secret |
Setup desleixado | Wizard auto-classifica em secrets: |
Perfil A — repo já tem `.deploy.yml`
Conteúdo do repo
ccs-deploy-test/dist/
├── .deploy.yml ← já existe
├── Caddyfile
├── index.html
└── package.jsonComando
runner add --repo devborlot/ccs-deploy-test \
--branch dist \
--ckey "minha-ckey-32-chars-secure-random" \
--instance productionO que acontece
[generator] Profile A: já tem .deploy.yml — usando direto, não gera nada.
[ADD] ccs-deploy-test/sys registered at /opt/runner/apps/ccs-deploy-testSe você rodar o wizard standalone:
runner wizard --path /tmp/seu-repo
# ↓
Error: manifest_already_exists: "/tmp/seu-repo/.deploy.yml" já contém .deploy.yml.
O wizard não sobrescreve manifestos existentes (perfil A — usa direto).
Para regenerar do zero: remova o arquivo manualmente e rode novamente.Quando usar
- App estável que já passou pelo wizard antes
- Multi-team:
.deploy.ymlé a fonte de verdade no repo
Perfil B — só Dockerfile
Conteúdo do repo
meu-app/
├── Dockerfile ← FROM nginx:1, EXPOSE 8080
├── index.html
└── .env.example ← LOG_LEVEL=info, DB_HOST=mysqlComando (zero-touch)
runner add --repo user/meu-app \
--branch main \
--ckey "..." \
--instance productionO que o generator faz
[generator] Profile B detected. Generating .deploy.yml from inputs: Dockerfile, .env.example
[generator] Source: repo tem Dockerfile + docker-compose.yml. Build do Dockerfile
foi usado como source-of-truth (compose só pra env vars).
Para usar a imagem do compose em vez de buildar, edite type: image + image:
[--branch] propagated to instance 'production': Some("main") -> main`.deploy.yml` gerado
project: meu-app
system: api
build:
context: .
dockerfile: Dockerfile
port: 8080
networks:
- public
environment:
DB_HOST: "mysql"
LOG_LEVEL: "info"
healthcheck:
mode: http # ← W2: nginx detectado → HTTP
path: /
port: 8080
interval: 30s
timeout: 10s
retries: 3
version:
source: git-commit # ← W3 default: deploys auto-versionados via SHA
instances:
production:
domain: meu-app.example.com
source:
type: branch
branch: main # ← W2: --branch propagado
keep_versions: 3Próximos passos
runner deploy meu-app # build + run + healthcheckPerfil C — só docker-compose
Conteúdo do repo
meu-app/
└── docker-compose.yml
services:
web:
image: nginx:1
ports: ["8080:8080"]
environment: { LOG_LEVEL: info }
postgres:
image: postgres:16Comando
runner add --repo user/meu-app --branch main --ckey "..." --instance productionO que acontece
[generator] Profile D: docker-compose só detected. Generating .deploy.yml from inputs: docker-compose.yml
[generator] Profile D: usando service 'web' do compose. Confira .deploy.yml gerado antes do deploy.
[generator] Port: usando porta 8080 do mapping no compose.
ATENÇÃO: este é mapping host:container — se o container internamente
escuta em outra porta (nginx :80, php-fpm :9000), ajuste port: no .deploy.yml.`.deploy.yml` gerado
project: meu-app
system: api
image: nginx:1
port: 8080
networks: [public]
environment:
LOG_LEVEL: "info"
healthcheck:
mode: http # ← nginx → HTTP
path: /
port: 8080
...
instances:
production:
domain: meu-app.example.com
source: { type: branch, branch: main }
keep_versions: 3Ação necessária do operador
O warning W1 te avisou: nginx:1 na verdade escuta na porta 80 internamente, não 8080. Edite manualmente:
sed -i 's/port: 8080/port: 80/' /opt/runner/apps/meu-app/.deploy.yml
sed -i 's/port: 8080$/port: 80/' /opt/runner/apps/meu-app/.deploy.yml # healthcheckOu refaça o runner add com o port correto via answers (perfil F abaixo).
Por que não auto-detectar? Análise estática não consegue ver dentro do container. W6 (smoke-probe rodando o container brevemente) está no backlog.
Perfil D — Laravel (composer + Dockerfile + .env.example)
Conteúdo do repo
meu-laravel/
├── composer.json ← laravel/framework
├── Dockerfile ← FROM php:8.2-fpm, EXPOSE 9000
└── .env.example ← APP_KEY=, DB_PASSWORD=, JWT_SECRET=, MAIL_*, AWS_*, ...Comando recomendado (com answers para preset Laravel)
cat > /tmp/laravel.json <<'EOF'
{
"system": "sys",
"type": "docker-build",
"skip_env": ["AWS_*", "MAIL_*", "MEMCACHED_*"],
"secret_keys": ["APP_KEY", "DB_PASSWORD", "JWT_SECRET", "REDIS_PASSWORD"]
}
EOF
runner wizard --path /caminho/meu-laravel --answers /tmp/laravel.json --ckey "..."O que o wizard faz
- Detecta Laravel via
composer.json→system: sys,type: docker-build - Lê
.env.example(31 vars típicas) - Aplica production profile:
APP_DEBUG=true → false,APP_ENV=local → production,LOG_LEVEL=debug → info - Prune
AWS_*/MAIL_*/MEMCACHED_*placeholders vazios - Auto-classifica
APP_KEY,DB_PASSWORD,JWT_SECRET,REDIS_PASSWORDemsecrets: - Encripta os secrets em
.runner/secrets.enccom a--ckey - W2 detectou php-fpm →
mode: tcp tcp: 9000
`.deploy.yml` gerado
project: meu-laravel
system: sys
type: docker-build
build: { context: ., dockerfile: Dockerfile }
port: 9000
healthcheck:
mode: tcp # ← W2: php-fpm → TCP (FastCGI, não HTTP)
tcp: 9000
interval: 30s
...
instances:
production:
domain: meu-laravel.example.com
source: { type: branch, branch: main }
keep_versions: 3
environment:
APP_DEBUG: false
APP_ENV: production
APP_NAME: Laravel
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_DATABASE: laravel
LOG_LEVEL: info # ← Production profile: era debug
...
secrets:
APP_KEY: "{{::APP_KEY}}" # ← prompts interativos no deploy
DB_PASSWORD: "{{::DB_PASSWORD}}"
JWT_SECRET: "{{::JWT_SECRET}}"
REDIS_PASSWORD: "{{::REDIS_PASSWORD}}"Perfil E — repo bare (só README)
Conteúdo do repo
meu-projeto/
└── README.mdComando
runner add --repo user/meu-projeto --branch main --ckey "..."O que acontece
Error: no_deploy_profile: repo sem perfil de deploy detectado.
Adicione um Dockerfile ou .deploy.yml no repo.
Documentação: https://docs.runner.ccs.systems/getting-startedEm modo TTY, o runner oferece executar o
runner wizardinterativamente para você criar.deploy.ymlna hora.
Ação
Crie um
Dockerfilemínimo no repo:FROM nginx:1 EXPOSE 80 COPY index.html /usr/share/nginx/html/Re-rode
runner add— agora cai no perfil B/C.
Perfil F — image-only (deploy de imagem do registry)
Quando usar
- App distribuída como imagem pronta (Hub Docker, GHCR, ECR)
- Sem código no repo, só configuração de deploy
Setup
Crie um repo com só .deploy.yml:
project: minha-imagem
system: sys
type: image
image: ghcr.io/myorg/myapp:v1.2.3
port: 8080
healthcheck:
mode: http
path: /health # app precisa implementar este endpoint; nginx default seria '/'
interval: 30s
version:
source: git-commit
instances:
production:
domain: myapp.com.br
source:
type: image
image: ghcr.io/myorg/myapp:v1.2.3
keep_versions: 2Comando
runner add --repo user/minha-imagem-config --branch main --ckey "..." --instance production
runner deploy minha-imagemResultado
Runner pulla ghcr.io/myorg/myapp:v1.2.3 do registry e roda — sem docker build.
Perfil G — Dockerfile sem EXPOSE
Conteúdo do repo
FROM nginx:1
COPY index.html /usr/share/nginx/html/
# (sem EXPOSE)Comando
runner add --repo user/meu-app --branch main --ckey "..." --instance productionO que acontece
[generator] Profile C: Dockerfile só detected.
[generator] Profile C: Dockerfile sem env detectado. Adicione environment: ...
[generator] Port: Dockerfile sem EXPOSE; assumindo 8080.
Se o container escuta em outra porta, ajuste port: no .deploy.yml
antes do deploy (ex: nginx default 80, php-fpm 9000).Ação obrigatória
O warning W1 te diz exatamente o que precisa. Para nginx que escuta em 80:
sed -i 's/port: 8080/port: 80/' /opt/runner/apps/meu-app/.deploy.yml
runner deploy meu-appPróxima versão (W6 backlog): smoke-probe vai detectar a porta real automaticamente, eliminando esse passo manual.
Perfil H — image + porta restrita ao IP interno
Quando usar
- Serviço interno que só pode ser acessado da VPC
- Banco/cache exposto só pra apps no mesmo host (loopback)
- Bind explícito a
127.0.0.1para que Traefik (no host) seja a única ponte pública
Setup
Em vez de wizard, crie .deploy.yml direto:
project: meu-cache
system: sys
type: image
image: redis:7
port: 6379
ports:
- "127.0.0.1:6379:6379" # ← bind apenas ao loopback do host
healthcheck:
mode: tcp
tcp: 6379
version: { source: git-commit }
instances:
production:
domain: cache-internal.local # ← não expõe via Traefik público
source: { type: image, image: redis:7 }
keep_versions: 1Ou via answers:
{
"type": "image",
"image": "redis:7",
"port": 6379,
"instance_domain": "cache-internal.local",
"healthcheck_mode": "tcp"
}Validação
docker inspect <container> --format '{{json .HostConfig.PortBindings}}'
# → {"6379/tcp":[{"HostIp":"127.0.0.1","HostPort":"6379"}]}Apenas processos no host conseguem acessar; o mundo externo não.
Perfil I — `.env.example` com secrets em valores raw
Conteúdo do repo (anti-pattern comum)
# .env.example
APP_NAME=meuapp
LOG_LEVEL=info
DB_PASSWORD=actual-real-secret-leak # ← MAL!
JWT_SECRET=ey...token-pretendendo-ser-real # ← MAL!
API_KEY=ak_live_NotReallyAStripeKey # ← MAL!Comando
runner add --repo user/meu-app --branch main --ckey "..."O que o wizard faz (auto-detection)
looks_like_secret() casa por padrão de nome (*PASSWORD*, *SECRET*, *KEY*, *TOKEN*...). Esses 3 vars saem automaticamente do bloco environment: e vão pra secrets::
environment:
APP_NAME: "meuapp" # ← não-sensível
LOG_LEVEL: "info" # ← não-sensível
secrets:
DB_PASSWORD: "{{::DB_PASSWORD}}" # ← prompt no deploy
JWT_SECRET: "{{::JWT_SECRET}}" # ← prompt no deploy
API_KEY: "{{::API_KEY}}" # ← prompt no deployE os valores literais ficam em .runner/secrets.enc (encriptados com a --ckey).
Validação
grep -E "DB_PASSWORD|JWT_SECRET|API_KEY" /opt/runner/apps/meu-app/.deploy.yml
# → DB_PASSWORD: "{{::DB_PASSWORD}}"
# → JWT_SECRET: "{{::JWT_SECRET}}"
# → API_KEY: "{{::API_KEY}}"
# (NÃO aparece no environment:)
ls /opt/runner/apps/meu-app/.runner/
# → secrets.enc (encriptado, commitable)Se você tinha valores reais no
.env.examplee ele estava commitado no git, rotacione esses secrets agora. O wizard não te salva de leak histórico — só evita NOVOS leaks.
Resumo: qual perfil cair?
Repo tem .deploy.yml? → A (usa direto)
Repo tem Dockerfile com EXPOSE + .env? → B (zero-touch)
Repo tem Dockerfile com EXPOSE mas sem .env? → C (gera + warning env)
Repo tem Dockerfile SEM EXPOSE? → G (gera + warning port — você ajusta)
Repo tem só docker-compose? → C/D (gera + warning port)
Repo tem composer/package/requirements + Dockerfile? → B com type-detection (Laravel/React/Flask)
Repo é bare? → E (falha clara, instrutiva)
Quer rodar imagem do registry sem código no repo? → F (.deploy.yml manual ou answers)
Quer bind a 127.0.0.1? → H (ports: ["127.0.0.1:X:Y"])
.env.example tem valores reais em DB_PASSWORD/etc? → I (auto-classifica em secrets:)Validar tudo
A matriz CI/CD do runner roda os 9 cenários acima end-to-end (add → deploy → healthcheck → status → edit → fetch → unregister) automaticamente:
cd runner-client/tests/cicd-matrix
./matrix.sh
# → 90 cells, 73 pass, 0 fail, 17 n/a (~115s) — v2.19.0+Use isso depois de qualquer mudança no wizard/generator pra garantir que nada regrediu.
v2.19.0 — Port allocation autônomo
A partir do v2.19.0 o runner aloca porta livre automaticamente quando configurado, eliminando a única intervenção manual restante no fluxo runner add.
Setup (uma vez por server)
# Interactive — runner init pergunta sobre VPC + strategy
runner init
# → "Esse server fica em uma VPC interna? [s/N]"
# → "IP detectado: 10.108.0.5 (interface eth1). Confirma? [Y/n]"
# → "Strategy [random/sequential/explicit] (default: random)"Resultado em /opt/runner/config.yml:
network:
vpc_ip: 10.108.0.5 # opcional; sem isso bind cai pra 127.0.0.1
publish_strategy: random # primeira deploy sorteia; depois sticky
port_range: [8000, 8999]Comportamento
Primeira deploy:
[deploy] [NETWORK] Allocated 10.108.0.5:8473 (strategy=Random, source=FreshAllocation)
[deploy] Container healthy at http://10.108.0.5:8473Operador atualiza Traefik externo apontando pra 10.108.0.5:8473. Done.
Re-deploys (commit novo, runner fetch --deploy, runner deploy):
[deploy] [NETWORK] Allocated 10.108.0.5:8473 (strategy=Random, source=State)
^^^^^^^^^^^^^^
porta sticky reusadaTraefik continua válido. Zero ação humana.
Quando a porta muda:
| Comando | Comportamento |
|---|---|
runner deploy X (normal) |
mantém |
runner fetch X --deploy (cron) |
mantém |
runner reset X --hard |
libera state → próximo deploy realoca |
runner unregister X |
libera porta de volta ao pool |
runner ports realloc X |
força nova alocação (operador atualiza Traefik) |
runner deploy X --port 9000 |
override permanente (sobrescreve state) |
Inspecionar portas
# Listar portas alocadas em todas apps
runner ports list
# APP VPC_IP PORT STRATEGY ALLOCATED_AT
# middleware-241 10.108.0.5 8473 random 2026-05-06T12:30:00Z
# sweepapex 10.108.0.5 8501 random 2026-05-06T14:15:00Z
# JSON pra automação
runner ports list --json | jq
# Status de uma app específica (consumido pelo CCS)
runner status middleware-241 --json | jq .runtime.network
# {
# "vpc_ip": "10.108.0.5",
# "published_port": 8473,
# "endpoint": "http://10.108.0.5:8473",
# "allocation": {
# "strategy": "random",
# "allocated_at": "2026-05-06T12:30:00Z",
# "sticky": true,
# "source": "state"
# }
# }Sem `network.vpc_ip`
Comportamento bind 127.0.0.1 automático — útil quando Traefik está no mesmo host (loopback alcança):
# config.yml — sem vpc_ip
network:
publish_strategy: random
port_range: [8000, 8999][deploy] [NETWORK] Allocated 127.0.0.1:8473 (strategy=Random, source=FreshAllocation)Pre-flight de secrets vazios
Outra adição do v2.19.0: o runner aborta o deploy se .deploy.yml::secrets: declara um secret cujo valor está vazio em .runner/secrets.enc. Antes (v2.18.x) o deploy "passava" silenciosamente e o app subia quebrado em runtime — o feedback do middleware-241 mostrou APP_KEY vazio, healthcheck /up retornando 200 (Laravel native, bypassa encryption), e / retornando 500.
Agora:
runner deploy middleware-241
# Error: deploy_blocked: secrets declared in .deploy.yml have empty values: ["APP_KEY"]
# Bypass: rerun with --insecure (NOT recommended).
# Or fill the values:
# runner env set <app> --key APP_KEY --secret --value <value>Resolução:
runner env set middleware-241 --key APP_KEY --secret --value "base64:$(openssl rand -base64 32)"
runner deploy middleware-241 # agora passaBypass: runner deploy middleware-241 --insecure (não recomendado — só use se conscientemente quer um deploy degradado pra debug).
Backwards compat
Apps registradas em v2.18.x continuam rodando exatamente como antes:
- Sem bloco
network:em config.yml → strategy default =explicit→ lêports:do.deploy.yml(comportamento idêntico ao v2.18.x) - Apps com
ports: ["VPC_IP:porta:porta"]no.deploy.ymlcontinuam funcionando sem precisar migrar
Pra migrar uma app existente pro modo random:
# 1. Adicionar bloco network: em /opt/runner/config.yml
# 2. Liberar a porta antiga + remover ports: do .deploy.yml
runner ports release middleware-241
# 3. Próximo deploy aloca porta nova random
runner deploy middleware-241 --force
# 4. Atualizar Traefik externo pra apontar na porta novav2.20.0 — Multi-app polish
5 features que fecham fricções identificadas em deploys multi-service complexos (LiveKit + agent Python + frontend Vite):
`build.args:` — Vite/Next/Angular zero-touch
Antes de v2.20: VITE_* impossível injetar via runner — precisava buildar local + commitar dist/.
Agora:
build:
context: frontend
dockerfile: Dockerfile
args:
VITE_SUPABASE_URL: "https://test.supabase.co"
VITE_LIVEKIT_WS_URL: "wss://livekit.example.com"
BUILD_ENV: productionCada entry vira --build-arg KEY=VAL no docker build. App responsabilidade: declarar ARG VITE_* no Dockerfile e mover pra ENV antes do RUN npm run build.
Limitação MVP v2.20.0: valores são literais. Interpolation
{{::Var}}(igualenvironment:) é follow-up v2.21.
Auto-create docker network
Antes: precisava rodar docker network create meet manualmente antes do primeiro deploy.
Agora:
networks:
- meetRunner cria a network antes do docker run se não existir. Idempotente. Skipa builtins (bridge/host/none).
`traefik.mode: external` — bypass do dynamic file
Útil em multi-server (Traefik em outro host) — evita orphan local config.
# /opt/runner/config.yml
traefik:
mode: external # none | local (default) | external| Mode | Comportamento |
|---|---|
local (default) |
Escreve em <traefik_dynamic_dir> (v2.19.x) |
external |
Skip write — operador trata roteamento externo |
none |
Mesmo bypass — apps internas sem HTTP público |
v2.20 = só bypass. Snippet output, write_to, etc. são roadmap.
`runner status --json` schema split
CCS dashboard agora consome 3 blocos lógicos separados:
runner status meet-livekit --json | jq .manifest # repo, branch, ckey_fingerprint, ...
runner status meet-livekit --json | jq .config # type, port, ports, networks, hosts, ...
runner status meet-livekit --json | jq .runtime # status, current_version, network endpointAditivo — campos legados top-level (project, system, instances, state) preservados pra v2.19.x consumers. Marker _deprecated_top_level: true sinaliza CCS pra migrar pro caminho novo.
`hosts:` map (DNS aliases)
Resolve host.docker.internal em Linux Docker (que não tem por default). Ou qualquer alias custom:
hosts:
host.docker.internal: host-gateway
internal-api: 10.0.0.5
livekit-pub: 10.108.0.5Cada entry vira --add-host KEY:VAL no docker run. Map (não array) — last-wins em duplicatas.
Migration
Apps existentes opt-in — sem precisar unregister ou --force. Adiciona o campo no .deploy.yml e roda runner deploy --force.
# Frontend Vite ganhando build_args
sed -i '/dockerfile:/a\ args:\n VITE_SUPABASE_URL: "https://..."' .deploy.yml
git push origin dist-frontend && runner fetch meet-frontend --deploy
# Agent Linux Docker ganhando hosts
echo -e "hosts:\n host.docker.internal: host-gateway" >> .deploy.yml
git push origin dist-agent && runner fetch meet-agent --deployv2.21.0 — Multi-app follow-ups
5 features completando MVPs/slots do v2.20.0 + 1 UX safety net + 1 doc fix.
`{{::Var}}` em `build.args:` (interpolation real)
v2.20.0 passava literais. v2.21.0 interpola de .env/.secrets:
build:
args:
VITE_SUPABASE_URL: "{{::SUPABASE_URL}}"
VITE_LIVEKIT_WS_URL: "{{::LIVEKIT_WS_URL?wss://default.com}}"
BUILD_ENV: production # literal continua funcionandoOrdem: .secrets primeiro (mais específico), .env fallback. Sem TTY no build → não pede prompt; se var não resolve, erro instrutivo:
Error: failed to resolve build_arg `VITE_SUPABASE_URL`
Caused by: unresolved build_arg template `{{::SUPABASE_URL}}`: not found in .env or .secrets.
Set the value first: runner env set <app> --key SUPABASE_URL --value <V>`depends_on:` cross-app
Resolve o caso multi-app: meu-agent precisa esperar meu-livekit healthy:
instances:
production:
depends_on:
- app: meu-livekit
condition: healthy # healthy | started
timeout: 300s
- app: meu-redis
instance: production
condition: startedRunner polla docker inspect por cada dep até atingir condition OR timeout. Bypass: --insecure ignora.
`traefik.external` snippet output
v2.20 só tinha o bypass. v2.21 emite snippet pronto pra colar no Traefik externo:
# /opt/runner/config.yml
traefik:
mode: external
external:
write_snippet_to: "/opt/traefik-snippets/{app}.yml" # opcional
print_snippet: true # defaultOutput do deploy:
✅ DEPLOY CONCLUÍDO
Endpoint: http://10.0.0.5:8473
📋 SNIPPET TRAEFIK (cole no <traefik-server>:/etc/traefik/dynamic/<file>.yml):
http:
routers:
meu-frontend-production:
rule: "Host(`app.example.com`)"
entryPoints: ["websecure"]
tls: { certResolver: myresolver }
service: meu-frontend-production
services:
meu-frontend-production:
loadBalancer:
servers:
- url: "http://127.0.0.1:8473"
ℹ️ Snippet salvo em /opt/traefik-snippets/meu-frontend.yml`runner add` detecta repo duplicado
runner add --repo usuario/meu-app --branch main --ckey "..." --instance production
# Error: duplicate_repo: Repo usuario/meu-app já está registrado
# como `meu-app-livekit` (branch: deploy/livekit, status: registered).
# Opções:
# --force-duplicate permite criar SEGUNDA app pro mesmo repo
# runner unregister meu-app-livekit --force remove a anteriorWorkaround quando intencional:
runner add --repo X --branch Y --ckey ... --instance production --force-duplicate`generate-config` template completo
runner generate-config agora mostra TODOS os campos válidos: build.args, hosts, depends_on, secrets block, etc. Use como referência completa do schema.
Migration
Mudança de comportamento ÚNICA: runner add em repo duplicado falha. Apps existentes não-duplicadas: zero impacto. Pra apps com duplicates pré-v2.21:
# Opção A: limpar duplicates (recomendado)
runner unregister <app-duplicada> --force
# Opção B: aceitar duplicates (raro)
runner add ... --force-duplicate