diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..e2388f5 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,55 @@ +# Deploy del monolito MercadoDeVida + +## ¿Basta con copiar `project/`? + +Sí, para levantar el monolito en un host basta con traer la carpeta `project/` completa y ejecutar: + +```bash +cd project +./scripts/monolith.sh prod start +``` + +o, para reemplazar una versión anterior ya corriendo: + +```bash +cd project +./scripts/monolith.sh prod restart +``` + +## Qué hace `monolith.sh prod start/restart` + +1. Limpia procesos antiguos del proyecto y listeners en puertos gestionados. +2. Arranca PostgreSQL y Redis con Docker Compose. +3. Instala dependencias con `npm ci` en backend, admin, TPV, frontend y storefront. +4. Ejecuta migraciones. +5. Compila backend y frontends. +6. Arranca servicios con `nohup` y guarda PID/logs en `project/.runtime/prod/`. +7. Verifica HTTP para cada servicio. + +Servicios por defecto: + +| Servicio | Puerto | +|---|---:| +| Backend API | 3000 | +| Admin | 3001 | +| TPV | 3002 | +| Frontend | 3003 | +| Storefront SEO | 3004 | + +## Requisitos del host + +- Node.js >= 22 +- npm +- Docker + Docker Compose +- Bash, curl, lsof +- Variables/secretos en `project/.env` + +Si no existe `.env`, el script crea uno de desarrollo. En producción real conviene crear `.env` explícito antes de arrancar. + +## Lo que NO gestiona + +- Traefik/Nginx/Authelia o certificados TLS. +- Arranque automático tras reinicio del host. +- Backups de base de datos. + +Para producción estable conviene envolver `./scripts/monolith.sh prod restart` con systemd, supervisor o el orquestador elegido. diff --git a/docs/versioning.md b/docs/versioning.md new file mode 100644 index 0000000..8563b64 --- /dev/null +++ b/docs/versioning.md @@ -0,0 +1,83 @@ +# Versionado semántico y Conventional Commits + +## Fuente de verdad + +La versión del producto vive en: + +```txt +project/VERSION +``` + +Los `package.json` del monolito deben mantenerse sincronizados con ese valor. + +## Conventional Commits obligatorios + +Formato: + +```txt +[optional scope][!]: +``` + +Tipos permitidos: + +- `feat` +- `fix` +- `docs` +- `chore` +- `refactor` +- `perf` +- `test` +- `build` +- `ci` +- `style` +- `revert` + +Ejemplos: + +```txt +feat(orders): add awaiting-payment notifications +fix(monolith): clean stale deployments before start +chore(release): v0.2.0 +``` + +## Herramientas Orquestra + +Validar un mensaje: + +```bash +python3 scripts/validate_conventional_commit.py --message "fix(monolith): clean stale deployments" +``` + +Instalar hook local de Git: + +```bash +./scripts/install_git_hooks.sh +``` + +Bump semver: + +```bash +python3 scripts/version.py bump patch +python3 scripts/version.py bump minor --commit --tag +python3 scripts/version.py bump major --commit --tag +``` + +Ver versión actual: + +```bash +python3 scripts/version.py show +``` + +## Cierre de features + +`scripts/commit_feature.sh` genera commits convencionales automáticamente usando el tipo del ticket: + +- `feature` → `feat(...)` +- `fix` / `bug` → `fix(...)` +- `chore` → `chore(...)` + +`verify.sh` valida: + +- `project/VERSION` como semver. +- `package.json` sincronizados. +- último commit compatible con Conventional Commits. diff --git a/project/VERSION b/project/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/project/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/scripts/commit_feature.sh b/scripts/commit_feature.sh index 8ef6477..76b09cc 100755 --- a/scripts/commit_feature.sh +++ b/scripts/commit_feature.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Commit and optionally push a completed feature +# Commit and optionally push a completed feature using Conventional Commits. # Usage: ./scripts/commit_feature.sh set -euo pipefail @@ -16,7 +16,50 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then exit 0 fi -# Stage all changes (excluding node_modules, dist, .next, etc.) +feature_meta() { + python3 - "$FEATURE_ID" <<'PY' +import json +import re +import sys +from pathlib import Path + +fid = sys.argv[1] +path = Path('backlog/features.json') +commit_type = 'feat' +title = 'completed feature' + +if path.exists(): + data = json.loads(path.read_text(encoding='utf-8')) + for feature in data.get('features', []): + if str(feature.get('id')) == fid: + ftype = str(feature.get('type', 'feature')).lower() + commit_type = { + 'feature': 'feat', + 'fix': 'fix', + 'bug': 'fix', + 'chore': 'chore', + }.get(ftype, 'feat') + title = feature.get('title') or feature.get('description') or title + break + +subject = re.sub(r'\s+', ' ', str(title)).strip() +subject = subject[0].lower() + subject[1:] if subject else 'completed feature' +subject = subject.rstrip('.') +print(commit_type) +print(subject[:120]) +PY +} + +mapfile -t META < <(feature_meta) +COMMIT_TYPE="${META[0]:-feat}" +COMMIT_SUBJECT="${META[1]:-completed feature}" +SCOPE=$(printf '%s' "$FEATURE_ID" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9._/-' '-') +COMMIT_MSG="$COMMIT_TYPE($SCOPE): $COMMIT_SUBJECT" + +# Validate before touching git state. +python3 scripts/validate_conventional_commit.py --message "$COMMIT_MSG" + +# Stage all changes (excluding node_modules, dist, .next, etc. via .gitignore) git add -A # Check if there are changes to commit @@ -25,24 +68,10 @@ if git diff --cached --quiet; then exit 0 fi -# Create commit message -COMMIT_MSG="feat($FEATURE_ID): completed feature" - -# Check if there's a description in the feature -if [ -f "work/current.md" ]; then - # Extract first line of description if available - DESC=$(grep -m1 "^## Description" work/current.md -A1 2>/dev/null | tail -1 | sed 's/^[[:space:]]*//' || true) - if [ -n "$DESC" ]; then - COMMIT_MSG="$COMMIT_MSG - -$DESC" - fi -fi - # Commit git commit -m "$COMMIT_MSG" -echo "[OK] Committed $FEATURE_ID" +echo "[OK] Committed $FEATURE_ID: $COMMIT_MSG" # Check if remote exists and push if git remote get-url origin >/dev/null 2>&1; then diff --git a/scripts/git-hooks/commit-msg b/scripts/git-hooks/commit-msg new file mode 100755 index 0000000..127b7af --- /dev/null +++ b/scripts/git-hooks/commit-msg @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +python3 scripts/validate_conventional_commit.py --file "$1" diff --git a/scripts/install.sh b/scripts/install.sh index 90a028c..d16ffde 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -149,9 +149,10 @@ copy_dir_update "$SOURCE_DIR/harness" "$TARGET_DIR/harness" copy_dir_update "$SOURCE_DIR/platforms/pi" "$TARGET_DIR/platforms/pi" copy_dir_update "$SOURCE_DIR/docs" "$TARGET_DIR/docs" -for file in scripts/verify.sh scripts/agent_status.py scripts/new_ticket.py scripts/pi_orquestra.sh scripts/run_stage.py scripts/commit_feature.sh scripts/close_feature.py scripts/install.sh; do +for file in scripts/verify.sh scripts/agent_status.py scripts/new_ticket.py scripts/pi_orquestra.sh scripts/run_stage.py scripts/commit_feature.sh scripts/close_feature.py scripts/validate_conventional_commit.py scripts/version.py scripts/install_git_hooks.sh scripts/install.sh; do copy_file_update "$SOURCE_DIR/$file" "$TARGET_DIR/$file" done +copy_dir_update "$SOURCE_DIR/scripts/git-hooks" "$TARGET_DIR/scripts/git-hooks" append_gitignore_block "$TARGET_DIR/.gitignore" @@ -176,7 +177,7 @@ if [ -d "$TARGET_DIR/.pi/subagents" ] || [ -f "$TARGET_DIR/.pi/subagents.json" ] warn "Found .pi/subagents or .pi/subagents.json in target. Orquestra does not delete user Pi config automatically; remove Orquestra-owned stale subagent files manually before running verify.sh." fi -chmod +x "$TARGET_DIR/scripts/verify.sh" "$TARGET_DIR/scripts/pi_orquestra.sh" "$TARGET_DIR/scripts/run_stage.py" "$TARGET_DIR/scripts/install.sh" +chmod +x "$TARGET_DIR/scripts/verify.sh" "$TARGET_DIR/scripts/pi_orquestra.sh" "$TARGET_DIR/scripts/run_stage.py" "$TARGET_DIR/scripts/install.sh" "$TARGET_DIR/scripts/validate_conventional_commit.py" "$TARGET_DIR/scripts/version.py" "$TARGET_DIR/scripts/install_git_hooks.sh" "$TARGET_DIR/scripts/git-hooks/commit-msg" cat </dev/null 2>&1 || { + echo '[FAIL] Not inside a git repository' >&2 + exit 1 +} + +chmod +x scripts/git-hooks/commit-msg scripts/validate_conventional_commit.py scripts/version.py +git config core.hooksPath scripts/git-hooks + +echo '[OK] Git hooks installed: core.hooksPath=scripts/git-hooks' +echo '[OK] commit-msg now enforces Conventional Commits' diff --git a/scripts/validate_conventional_commit.py b/scripts/validate_conventional_commit.py new file mode 100755 index 0000000..fd3a1f8 --- /dev/null +++ b/scripts/validate_conventional_commit.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Validate Conventional Commit messages for Orquestra. + +Allowed format: + [optional scope][!]: + +Examples: + feat(orders): add awaiting-payment notifications + fix(DEPLOY-MONOLITH-CLEANUP): clean stale deployments + chore(release)!: v1.0.0 +""" +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +ALLOWED_TYPES = { + 'build', + 'chore', + 'ci', + 'docs', + 'feat', + 'fix', + 'perf', + 'refactor', + 'revert', + 'style', + 'test', +} + +HEADER_RE = re.compile( + r'^(?Pbuild|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)' + r'(?:\((?P[A-Za-z0-9._/-]+)\))?' + r'(?P!)?: (?P\S.{0,119})$' +) + + +def validate(message: str) -> tuple[bool, str | None]: + lines = message.strip('\n').splitlines() + if not lines or not lines[0].strip(): + return False, 'empty commit message' + + header = lines[0].strip() + match = HEADER_RE.match(header) + if not match: + return ( + False, + "first line must be Conventional Commit: " + "[optional scope][!]: ", + ) + + commit_type = match.group('type') + if commit_type not in ALLOWED_TYPES: + return False, f"unsupported type '{commit_type}'" + + description = match.group('description').strip() + if description.endswith('.'): + return False, 'description must not end with a period' + + if any(line.rstrip() != line for line in lines): + return False, 'commit message contains trailing whitespace' + + if len(lines) > 1 and lines[1].strip(): + return False, 'second line must be blank before body/footer' + + return True, None + + +def main() -> int: + parser = argparse.ArgumentParser(description='Validate Conventional Commit message') + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--file', type=Path, help='commit message file') + group.add_argument('--message', help='commit message string') + args = parser.parse_args() + + message = args.message if args.message is not None else args.file.read_text(encoding='utf-8') + ok, error = validate(message) + if ok: + return 0 + + print(f'[FAIL] Invalid commit message: {error}', file=sys.stderr) + print('', file=sys.stderr) + print('Examples:', file=sys.stderr) + print(' feat(orders): add awaiting-payment notifications', file=sys.stderr) + print(' fix(monolith): clean stale deployments before start', file=sys.stderr) + print(' chore(release): v0.2.0', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/verify.sh b/scripts/verify.sh index 8bea150..50f8ac9 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -39,6 +39,11 @@ required=( "scripts/run_stage.py" "scripts/commit_feature.sh" "scripts/close_feature.py" + "scripts/validate_conventional_commit.py" + "scripts/version.py" + "scripts/install_git_hooks.sh" + "scripts/git-hooks/commit-msg" + "project/VERSION" "platforms/pi/README.md" "platforms/pi/extensions/orquestra-status/index.ts" "platforms/pi/extensions/orquestra-web-fetch.ts" @@ -84,6 +89,55 @@ else ok "Sin archivos de producto/código en la raíz" fi +python3 - <<'PY' +import json +import pathlib +import re +import subprocess +import sys + +root = pathlib.Path('.') +version_path = root / 'project' / 'VERSION' +semver = re.compile(r'^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$') +try: + version = version_path.read_text(encoding='utf-8').strip() +except Exception as e: + print(f'[FAIL] project/VERSION no se puede leer: {e}') + sys.exit(1) +if not semver.match(version): + print(f'[FAIL] project/VERSION no es semver válido: {version}') + sys.exit(1) + +for rel in [ + 'project/package.json', + 'project/apps/admin/package.json', + 'project/apps/pos/package.json', + 'project/frontend/package.json', + 'project/storefront/package.json', +]: + path = root / rel + if not path.exists(): + continue + data = json.loads(path.read_text(encoding='utf-8')) + if data.get('version') != version: + print(f"[FAIL] {rel} version {data.get('version')} != project/VERSION {version}") + sys.exit(1) + +try: + msg = subprocess.check_output(['git', 'log', '-1', '--pretty=%B'], text=True).strip() +except Exception: + msg = '' +if msg: + result = subprocess.run(['python3', 'scripts/validate_conventional_commit.py', '--message', msg]) + if result.returncode != 0: + print('[FAIL] Último commit no cumple Conventional Commits') + sys.exit(1) + +print(f'[OK] semver válido y sincronizado: {version}') +print('[OK] último commit cumple Conventional Commits') +PY +if [ $? -ne 0 ]; then EXIT_CODE=1; fi + echo "" echo "── 2) Validando requisitos Pi ─────────────────────────" if command -v pi >/dev/null 2>&1; then diff --git a/scripts/version.py b/scripts/version.py new file mode 100755 index 0000000..342fb25 --- /dev/null +++ b/scripts/version.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Semantic version helper for MercadoDeVida. + +Source of truth: project/VERSION. +Mirrors the version into package.json files that are part of project/. +""" +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +VERSION_FILE = ROOT / 'project' / 'VERSION' +PACKAGE_FILES = [ + ROOT / 'project' / 'package.json', + ROOT / 'project' / 'apps' / 'admin' / 'package.json', + ROOT / 'project' / 'apps' / 'pos' / 'package.json', + ROOT / 'project' / 'frontend' / 'package.json', + ROOT / 'project' / 'storefront' / 'package.json', +] +PACKAGE_LOCK_FILES = [ + ROOT / 'project' / 'package-lock.json', + ROOT / 'project' / 'apps' / 'admin' / 'package-lock.json', + ROOT / 'project' / 'apps' / 'pos' / 'package-lock.json', + ROOT / 'project' / 'frontend' / 'package-lock.json', + ROOT / 'project' / 'storefront' / 'package-lock.json', +] +SEMVER_RE = re.compile(r'^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$') + + +def read_version() -> str: + version = VERSION_FILE.read_text(encoding='utf-8').strip() + if not SEMVER_RE.match(version): + raise SystemExit(f'[FAIL] Invalid semver in {VERSION_FILE}: {version}') + return version + + +def write_version(version: str) -> None: + if not SEMVER_RE.match(version): + raise SystemExit(f'[FAIL] Invalid semver: {version}') + VERSION_FILE.write_text(version + '\n', encoding='utf-8') + for path in PACKAGE_FILES: + if not path.exists(): + continue + data = json.loads(path.read_text(encoding='utf-8')) + data['version'] = version + path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') + for path in PACKAGE_LOCK_FILES: + if not path.exists(): + continue + data = json.loads(path.read_text(encoding='utf-8')) + if 'version' in data: + data['version'] = version + root_package = data.get('packages', {}).get('') + if isinstance(root_package, dict) and 'version' in root_package: + root_package['version'] = version + path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') + + +def bump(version: str, part: str) -> str: + major, minor, patch = [int(p) for p in version.split('-', 1)[0].split('+', 1)[0].split('.')] + if part == 'major': + return f'{major + 1}.0.0' + if part == 'minor': + return f'{major}.{minor + 1}.0' + if part == 'patch': + return f'{major}.{minor}.{patch + 1}' + raise SystemExit(f'[FAIL] Unsupported bump part: {part}') + + +def run(cmd: list[str]) -> None: + subprocess.run(cmd, cwd=ROOT, check=True) + + +def main() -> int: + parser = argparse.ArgumentParser(description='Manage project semantic version') + sub = parser.add_subparsers(dest='command', required=True) + + sub.add_parser('show', help='print current version') + + bump_parser = sub.add_parser('bump', help='bump semantic version') + bump_parser.add_argument('part', choices=['major', 'minor', 'patch']) + bump_parser.add_argument('--commit', action='store_true', help='create conventional release commit') + bump_parser.add_argument('--tag', action='store_true', help='create git tag vX.Y.Z (requires --commit or clean index)') + + set_parser = sub.add_parser('set', help='set exact semantic version') + set_parser.add_argument('version') + + args = parser.parse_args() + current = read_version() + + if args.command == 'show': + print(current) + return 0 + + if args.command == 'set': + write_version(args.version) + print(f'[OK] Version set: {current} -> {args.version}') + return 0 + + next_version = bump(current, args.part) + write_version(next_version) + print(f'[OK] Version bumped: {current} -> {next_version}') + + if args.commit: + msg = f'chore(release): v{next_version}' + run(['python3', 'scripts/validate_conventional_commit.py', '--message', msg]) + run([ + 'git', + 'add', + 'project/VERSION', + *[str(p.relative_to(ROOT)) for p in PACKAGE_FILES if p.exists()], + *[str(p.relative_to(ROOT)) for p in PACKAGE_LOCK_FILES if p.exists()], + ]) + run(['git', 'commit', '-m', msg]) + + if args.tag: + run(['git', 'tag', f'v{next_version}']) + print(f'[OK] Tagged v{next_version}') + + return 0 + + +if __name__ == '__main__': + raise SystemExit(main())