siesa-agents 2.1.81 → 2.1.83
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sa-init-devops
|
|
3
|
+
description: 'Bootstrap the DevOps workspace and skills in Siesa-Agents in one shot: clones architecture-sa-devops into `_siesa-agents/devops/`, detects conflicts between local and upstream copies of the `sa-*` DevOps skills and the deploy workflows (asks the user via AskUserQuestion which side to keep when there are diffs), then MOVES the chosen versions into `.claude/skills/` (and `claude/skills/`) so `/sa-aplicar`, `/sa-nuevo-servicio`, `/sa-auditar-servicio`, etc. become immediately invocable, MOVES the workflows into `.github/workflows/`, deletes the now-empty `.claude/` and `.github/` directories from the clone, then mirrors the cleaned tree to `siesa-agents/devops/`. Use whenever the user wants to set up the DevOps workspace for the first time, refresh it after upstream changes, mentions needing terraform/k8s/environments files, or when any `sa-*` deploy skill is missing from Claude Code.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Init DevOps — clone the workspace + materialize the DevOps skills
|
|
7
|
+
|
|
8
|
+
**Goal:** In a single invocation, populate Siesa-Agents with everything an engineer needs to run the Finance DevOps skills. After a successful run the layout is:
|
|
9
|
+
|
|
10
|
+
| Location | Contents |
|
|
11
|
+
|---|---|
|
|
12
|
+
| `_siesa-agents/devops/` | Git working tree cloned from `architecture-sa-devops`. **Only** the deployment workspace: `terraform/`, `k8s/`, `environments/`, `scripts/`, `cicd-templates/`, `docs/`, `CLAUDE.md`, `README.md`, `.gitignore`. The `.claude/` and `.github/` directories from upstream are wiped from the clone after their contents are moved to Siesa-Agents level. |
|
|
13
|
+
| `siesa-agents/devops/` | Flat snapshot mirror of the cleaned clone (no `.git/`, no `.claude/`, no `.github/`). The npm-publishable copy. |
|
|
14
|
+
| `.claude/skills/sa-*/SKILL.md` | The 8 DevOps skills moved out of the clone: `sa-aplicar`, `sa-auditar-servicio`, `sa-nueva-transversal`, `sa-nuevo-ambiente`, `sa-nuevo-servicio`, `sa-onboard-db`, `sa-registrar-permisos`, `sa-agent-sre-sentinel`. |
|
|
15
|
+
| `claude/skills/sa-*/SKILL.md` | Same skills, in the npm mirror. |
|
|
16
|
+
| `.github/workflows/*.yml` | The 4 deploy workflow files moved out of the clone (`infra-pipeline-dev/qa/shared.yml`, `reconcile-geographic-projections.yml`). **These do not fire from Siesa-Agents** — gitignored, for visibility only. They fire from `architecture-sa-devops` where they originally live. |
|
|
17
|
+
|
|
18
|
+
**Why this matters:** Without this skill, Siesa-Agents is missing every Finance DevOps capability. The DevOps skills cannot live statically in this repo because they evolve with the deploy workspace (`environments/`, `terraform/`, `k8s/`) — keeping them in sync would require a PR every time Finance bumps the workspace. Instead the upstream `architecture-sa-devops` repo is the single source of truth, and this skill pulls everything down on demand.
|
|
19
|
+
|
|
20
|
+
## When to use this skill
|
|
21
|
+
|
|
22
|
+
Trigger immediately when the user:
|
|
23
|
+
|
|
24
|
+
- Says "init devops", "setup devops", "clone architecture-sa-devops", "trae el workspace de despliegue", or similar
|
|
25
|
+
- Tries to invoke any deploy skill (`/sa-aplicar`, `/sa-nuevo-servicio`, etc.) and Claude Code reports the skill is not available
|
|
26
|
+
- Tries to invoke a deploy skill and it complains about missing paths (`environments/shared.yaml`, `terraform/environments/`, `k8s/overlays/`, etc.)
|
|
27
|
+
- Mentions wanting to refresh / pull latest from `architecture-sa-devops`
|
|
28
|
+
- Has just cloned Siesa-Agents and is preparing to use the deploy skills
|
|
29
|
+
|
|
30
|
+
Do **not** trigger for: editing the deploy skills themselves (those edits must go to `architecture-sa-devops`), modifying terraform/k8s contents (those go to `architecture-sa-devops` too), or anything unrelated to bootstrapping.
|
|
31
|
+
|
|
32
|
+
## Prerequisites
|
|
33
|
+
|
|
34
|
+
- `git` is on the PATH (`git --version` works).
|
|
35
|
+
- The user has network access to GitHub.com.
|
|
36
|
+
- The helper script lives at `_siesa-agents/sa/sa-init-devops/scripts/sa-init-devops.js` (source of truth) and is mirrored to `siesa-agents/sa/sa-init-devops/scripts/sa-init-devops.js` (npm copy). Prefer the source-of-truth path when both exist.
|
|
37
|
+
|
|
38
|
+
If the script is missing or `git` is unavailable, stop and tell the user — do not improvise the work inline. The script handles all the edge cases (existing clone with wrong remote, partial state, conflicting local edits, skill/workflow copy overwrites) that an inline shell sequence would miss.
|
|
39
|
+
|
|
40
|
+
## Workflow
|
|
41
|
+
|
|
42
|
+
Two-phase orchestration. The helper does an initial conflict-detection pass; if any local file differs from upstream, the helper aborts and the model asks the user how to resolve, then re-runs with a policy flag.
|
|
43
|
+
|
|
44
|
+
### Step 1 — Initial run
|
|
45
|
+
|
|
46
|
+
From the project root (or any subdirectory of it — the script walks up to find both `_siesa-agents/` and `siesa-agents/`):
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
node _siesa-agents/sa/sa-init-devops/scripts/sa-init-devops.js
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Default mode does:
|
|
53
|
+
|
|
54
|
+
1. **Clone or update.** `git clone` (if `_siesa-agents/devops/` does not exist) **or** `git checkout HEAD -- .claude .github && git fetch && git checkout main && git pull --ff-only` (if it does). The `git checkout HEAD -- .claude .github` step restores files that the previous run wiped so the pull doesn't fail on "uncommitted deletions".
|
|
55
|
+
2. **Dry-run conflict detection.** For each upstream `.claude/skills/sa-*/SKILL.md` and `.github/workflows/*.yml`, compare to the local copy in Siesa-Agents (`.claude/skills/`, `claude/skills/`, and `.github/workflows/`). The bootstrap skill `sa-init-devops` is always excluded from this comparison.
|
|
56
|
+
3. **If any local file differs from upstream → abort.** Exit 4 with `status: "conflicts"` and lists `skill_conflicts` / `workflow_conflicts`. Nothing is mutated. The model proceeds to Step 2.
|
|
57
|
+
4. **If no conflicts → apply.** Copy the upstream files into Siesa-Agents (creating new files, leaving identical ones alone), then delete `.claude/` and `.github/` from the clone working tree, then mirror the cleaned tree to `siesa-agents/devops/`. Exit 0.
|
|
58
|
+
|
|
59
|
+
### Step 2 — Handle conflicts (only when `status: "conflicts"`)
|
|
60
|
+
|
|
61
|
+
When the JSON output has `status: "conflicts"`, **ask the user which side to keep** before any local file is changed. Use `AskUserQuestion` with the list of conflicting items from `skill_conflicts` and `workflow_conflicts`.
|
|
62
|
+
|
|
63
|
+
For most cases, one question with three options is enough:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
question: "Estos N archivos locales difieren del upstream architecture-sa-devops:
|
|
67
|
+
<list with skill_conflicts + workflow_conflicts>
|
|
68
|
+
¿Cómo resuelvo?"
|
|
69
|
+
options:
|
|
70
|
+
- "Take all from upstream" (overwrite all conflicts with upstream version)
|
|
71
|
+
- "Keep all local versions" (preserve every local edit)
|
|
72
|
+
- "Resolve per file" (only if user wants per-item granularity)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
If the user picks **"Take all from upstream"**, re-run with `--take-upstream`:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
node _siesa-agents/sa/sa-init-devops/scripts/sa-init-devops.js --take-upstream
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
If the user picks **"Keep all local versions"**, re-run with `--keep-local`:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
node _siesa-agents/sa/sa-init-devops/scripts/sa-init-devops.js --keep-local
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
If the user picks **"Resolve per file"**, ask one more question per conflicting item (with `AskUserQuestion`, max 4 items per call — chunk if needed) collecting which ones to keep local, then re-run with `--keep-local=<csv>`:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
node _siesa-agents/sa/sa-init-devops/scripts/sa-init-devops.js --keep-local=sa-aplicar,infra-pipeline-qa.yml
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The named items keep their local version; everything else takes upstream.
|
|
94
|
+
|
|
95
|
+
### Step 3 — Interpret the final JSON status
|
|
96
|
+
|
|
97
|
+
After the (possibly second) successful invocation, read the `status` field:
|
|
98
|
+
|
|
99
|
+
- **`status: "cloned"`** — Fresh clone. Tell the user: "Cloned `architecture-sa-devops`. Created `<N>` skills + `<M>` workflows in Siesa-Agents. The deploy skills (`/sa-aplicar`, `/sa-nuevo-servicio`, etc.) are now available in Claude Code."
|
|
100
|
+
- **`status: "updated"`** — Existing clone pulled new commits. Report `before_sha..after_sha` and the per-category counts (`skills_created`, `skills_overwritten`, `skills_kept_local`, `skills_unchanged`, same for workflows).
|
|
101
|
+
- **`status: "already-cloned"`** — Local clone was already at the latest HEAD; no upstream change. The counts reflect what happened in the apply step (likely all `unchanged` if everything matched).
|
|
102
|
+
- **`status: "conflicts"`** — Should only appear on the FIRST run when conflicts exist; never on a re-run with a policy flag. If you see this twice, surface it as a bug.
|
|
103
|
+
- **`status: "invalid-source"`** — `_siesa-agents/devops/` exists but is not a git working tree. Surface the message verbatim and stop. Do not try to delete the directory automatically.
|
|
104
|
+
- **`status: "wrong-remote"`** — Same directory is a clone of a different repo. Surface the message and stop.
|
|
105
|
+
- **`status: "clone-failed"` / `"fetch-failed"` / `"pull-failed"` / `"checkout-failed"`** — Git operation failed. Surface the underlying git error verbatim. Common causes: no network, branch diverged, local edits blocking ff-pull.
|
|
106
|
+
|
|
107
|
+
### Step 3 — Remind the user about cwd for the deploy skills
|
|
108
|
+
|
|
109
|
+
After a successful run (any of `cloned`, `updated`, `already-cloned`), remind the user once:
|
|
110
|
+
|
|
111
|
+
> Las skills de despliegue (`/sa-aplicar`, `/sa-nuevo-servicio`, etc.) asumen que el cwd está dentro de `_siesa-agents/devops/`. Cambia de directorio (`cd _siesa-agents/devops/`) antes de invocarlas, para que las rutas relativas (`environments/`, `terraform/`, `k8s/`) se resuelvan correctamente.
|
|
112
|
+
|
|
113
|
+
If the user invokes one of those skills from elsewhere, the relative paths in their workflows won't resolve and they'll see confusing "file not found" errors.
|
|
114
|
+
|
|
115
|
+
## Optional flags
|
|
116
|
+
|
|
117
|
+
- `--check` — Read-only. Clones/pulls if needed, then reports current state plus a dry-run conflict list. Useful to preview what a real run would surface.
|
|
118
|
+
- `--mirror-only` — Skip the git + move steps; rebuild `siesa-agents/devops/` from the existing clone. Useful when only the mirror is out of sync.
|
|
119
|
+
- `--take-upstream` — Resolve all conflicts in favor of upstream (overwrite every conflicting local file). Use after the user confirms they want the upstream versions.
|
|
120
|
+
- `--keep-local` — Keep every local file as-is; only create files that are entirely new in upstream. Use when the user wants to preserve all local edits.
|
|
121
|
+
- `--keep-local=<csv>` — Keep these specific items local (comma-separated names matching `skill_conflicts` / `workflow_conflicts` entries); overwrite all other conflicts with upstream. Example: `--keep-local=sa-aplicar,infra-pipeline-qa.yml`.
|
|
122
|
+
|
|
123
|
+
The three policy flags are mutually exclusive; passing more than one returns exit code 1.
|
|
124
|
+
|
|
125
|
+
## Edge cases
|
|
126
|
+
|
|
127
|
+
- **Skill conflicts.** The default run aborts on any conflict (local file differs from upstream) and lists them in `skill_conflicts` / `workflow_conflicts`. The model then asks the user with `AskUserQuestion` and re-runs with `--take-upstream`, `--keep-local`, or `--keep-local=<csv>`. Engineers who want their custom edits to survive automatic refresh should fork `architecture-sa-devops` and update `REMOTE_URL` at the top of the helper script — that keeps their fork as the upstream source of truth.
|
|
128
|
+
- **The bootstrap skill is never overwritten.** `sa-init-devops/` is explicitly excluded from the copy loop in the helper, so re-running the skill cannot trash itself.
|
|
129
|
+
- **The clone has 12 "deleted" files in `git status` after every run.** Expected. The script moves `.claude/skills/sa-*/SKILL.md` and `.github/workflows/*.yml` out of the clone and deletes those directories, but the files are still tracked in upstream HEAD. The next run restores them via `git checkout HEAD -- .claude .github` before pulling, then moves and deletes them again. Engineers should never `git add` or `git commit` those deletions inside `_siesa-agents/devops/` — they are intentional working-tree-only state.
|
|
130
|
+
- **Workflows in Siesa-Agents `.github/workflows/`** — moved for inspection but gitignored (see `.gitignore`), so engineers can read them locally but they never fire on Siesa-Agents PRs. The same workflows fire on PRs / pushes to `architecture-sa-devops`, which is where they live.
|
|
131
|
+
- **`_siesa-agents/devops/` exists from a previous run with local edits to non-managed paths.** The restore step touches only `.claude` and `.github`; edits to `environments/`, `terraform/`, `k8s/`, etc. survive the restore step but `git pull --ff-only` will fail if they conflict with upstream. The script surfaces the git error verbatim. The user should commit, stash, or discard their local changes and re-run.
|
|
132
|
+
- **The directory exists but isn't a clone of `architecture-sa-devops`.** The script reports `invalid-source` or `wrong-remote` and refuses to delete. The user must move it aside manually.
|
|
133
|
+
- **The mirror or local skills/workflows have manual edits.** The script overwrites them on every run — that is by design. If you need to preserve edits, make them in the upstream `architecture-sa-devops` repo, not locally.
|
|
134
|
+
- **No `git` on PATH.** The first `tryGit` call fails; the script surfaces a `clone-failed` or `fetch-failed` status. Tell the user to install git or add it to PATH.
|
|
135
|
+
|
|
136
|
+
## Upstream URL
|
|
137
|
+
|
|
138
|
+
The script currently points at `https://github.com/ssancheze912/architecture-sa-devops.git` as a temporary location while SiesaTeams org-create permissions are being arranged. When the repo is transferred to `https://github.com/SiesaTeams/architecture-sa-devops.git`, update `REMOTE_URL` at the top of the helper script and ship the change.
|
|
139
|
+
|
|
140
|
+
## Reminders
|
|
141
|
+
|
|
142
|
+
- Step 1 is always safe to re-run. Idempotency is built into the script — on `already-cloned` it does nothing destructive other than refresh the mirror and re-copy the skills/workflows from upstream (which is the intended behavior — keeping local in sync with upstream).
|
|
143
|
+
- Never delete `_siesa-agents/devops/` from this skill. The user owns that directory once cloned; they may have in-progress edits.
|
|
144
|
+
- Do not modify the DevOps skills themselves from this skill. If a deploy skill's behavior needs to change, that is a PR to `architecture-sa-devops`, not a local edit (which would be wiped on the next run).
|
|
145
|
+
- The script's JSON output is the contract. If a future field appears (e.g. a new status), surface it to the user rather than guessing what it means.
|
package/package.json
CHANGED
|
@@ -147,3 +147,19 @@ node "{project_root}/_siesa-agents/observability/scripts/sa-emit.js" --event wor
|
|
|
147
147
|
|
|
148
148
|
Before marking a story as complete, verify the Siesa-specific checklist items at:
|
|
149
149
|
`@_siesa-agents/bmm/workflows/4-implementation/dev-story/checklist_ext.md`
|
|
150
|
+
|
|
151
|
+
# MANDATORY RULE: SONARQUBE CODE QUALITY CONSIDERATIONS
|
|
152
|
+
|
|
153
|
+
**TRIGGER:** Every time the dev-story workflow implements or modifies code.
|
|
154
|
+
|
|
155
|
+
**CRITICAL INSTRUCTION:** BEFORE writing or modifying any code, YOU MUST **USE YOUR READ TOOL** to open and read the FULL contents of the SonarQube rules file at:
|
|
156
|
+
`_siesa-agents/resources/sonar/sonar-rules.md`
|
|
157
|
+
|
|
158
|
+
> NOTE ON THE MIRROR COPY: This reference path is ALWAYS `_siesa-agents/resources/sonar/sonar-rules.md`,
|
|
159
|
+
|
|
160
|
+
## 8. Apply Sonar rules throughout implementation
|
|
161
|
+
|
|
162
|
+
1. Assimilate the rules in `sonar-rules.md` (security, reliability, maintainability findings based on real SonarQube results) and keep them in memory during the entire implementation.
|
|
163
|
+
2. You MUST enforce these rules in every piece of code you write or modify — never produce code that introduces a known SonarQube violation (e.g., hardcoded secrets, unhandled exceptions, code smells covered by the file).
|
|
164
|
+
3. BEFORE marking the story as review/complete, validate your changes against the final checklist defined in `sonar-rules.md`.
|
|
165
|
+
4. If the file does not exist for the current project, acknowledge it and proceed normally (Sonar enforcement is best-effort and must never block the workflow).
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# Sonar Rules — Reglas transversales de calidad y seguridad
|
|
2
|
+
|
|
3
|
+
> Reglas obligatorias para cualquier agente de IA (Claude Code, Cursor, Copilot, etc.) que genere o modifique código en cualquier proyecto SIESA.
|
|
4
|
+
> Basado en hallazgos reales de SonarQube y prácticas de seguridad para .NET / C#.
|
|
5
|
+
>
|
|
6
|
+
> **Stack y versiones:** la fuente de verdad es `_siesa-agents/resources/architecture/` (.NET 10, PostgreSQL 18+, EF Core 10, xUnit, etc.). NO uses frameworks, motores de BD ni versiones distintas a los definidos allí.
|
|
7
|
+
>
|
|
8
|
+
> **Antes de generar código, lee este archivo completo.**
|
|
9
|
+
> **Antes de hacer commit, valida el checklist final.**
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 1. Reglas críticas de seguridad (NUNCA violar)
|
|
14
|
+
|
|
15
|
+
### 1.1 Secretos y credenciales
|
|
16
|
+
|
|
17
|
+
**Prohibido absolutamente:**
|
|
18
|
+
- Hardcodear passwords, API keys, connection strings con credenciales, encryption keys, tokens, certificados.
|
|
19
|
+
- Subir `appsettings.json` o cualquier archivo con valores sensibles reales.
|
|
20
|
+
- Dejar credenciales en comentarios, ejemplos o tests.
|
|
21
|
+
|
|
22
|
+
**Cómo hacerlo bien:**
|
|
23
|
+
|
|
24
|
+
```jsonc
|
|
25
|
+
// appsettings.json — SOLO placeholders o valores no sensibles
|
|
26
|
+
{
|
|
27
|
+
"DATABASE_PROVIDER": "postgres",
|
|
28
|
+
"DATABASE_URL": "", // ← se inyecta en runtime
|
|
29
|
+
"ENCRYPTION_KEY": "", // ← se inyecta en runtime
|
|
30
|
+
"API_PORT": 3005
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# Variables de entorno (producción / staging) — PostgreSQL / Npgsql
|
|
36
|
+
export DATABASE_URL="Host=...;Port=5432;Database=...;Username=...;Password=...;"
|
|
37
|
+
export ENCRYPTION_KEY="..."
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# User Secrets (desarrollo local)
|
|
42
|
+
dotnet user-secrets init
|
|
43
|
+
dotnet user-secrets set "DATABASE_URL" "Host=localhost;Port=5432;Database=...;Username=...;Password=...;"
|
|
44
|
+
dotnet user-secrets set "ENCRYPTION_KEY" "..."
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**En producción:** Azure Key Vault, AWS Secrets Manager, o GCP Secret Manager (si el proyecto ya usa GCP, **preferir Secret Manager de GCP**).
|
|
48
|
+
|
|
49
|
+
**Si encuentras un secreto hardcodeado:** no solo lo muevas — **márcalo como comprometido** y avisa al humano para rotarlo. Una vez en Git, asume que está filtrado.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
### 1.2 Regex siempre con timeout
|
|
54
|
+
|
|
55
|
+
Toda expresión regular **debe** especificar `matchTimeout` para prevenir ReDoS (Regular Expression Denial of Service).
|
|
56
|
+
|
|
57
|
+
```csharp
|
|
58
|
+
// ❌ MAL — vulnerable a ReDoS
|
|
59
|
+
private static readonly Regex IdFormatoPattern =
|
|
60
|
+
new(@"^[A-Za-z0-9_]{1,40}$", RegexOptions.Compiled);
|
|
61
|
+
|
|
62
|
+
// ✅ BIEN — con timeout explícito
|
|
63
|
+
private static readonly Regex IdFormatoPattern =
|
|
64
|
+
new(@"^[A-Za-z0-9_]{1,40}$", RegexOptions.Compiled, TimeSpan.FromSeconds(2));
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Aplica a:**
|
|
68
|
+
- `Regex` estáticos `readonly`
|
|
69
|
+
- `Regex` instanciados ad-hoc
|
|
70
|
+
- `Regex.IsMatch()`, `Regex.Match()`, `Regex.Replace()` estáticos → preferir instancias con timeout
|
|
71
|
+
|
|
72
|
+
**Timeout recomendado:** entre `100ms` (validaciones rápidas) y `2s` (parsing complejo). Nunca `Timeout.InfiniteTimeSpan`.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
### 1.3 HTTPS y configuración CORS
|
|
77
|
+
|
|
78
|
+
**HTTP solo en `localhost` o `127.0.0.1` para desarrollo.** Cualquier otro origen → HTTPS.
|
|
79
|
+
|
|
80
|
+
```csharp
|
|
81
|
+
// ❌ MAL — http://app-dev.example.com:3000 es un dominio público con HTTP
|
|
82
|
+
var defaultOrigins = new[]
|
|
83
|
+
{
|
|
84
|
+
"http://localhost:5173", // ok, es localhost
|
|
85
|
+
"http://app-dev.example.com:3000" // ← NO, debe ser https
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// ✅ BIEN
|
|
89
|
+
var defaultOrigins = new[]
|
|
90
|
+
{
|
|
91
|
+
"http://localhost:5173",
|
|
92
|
+
"https://app-dev.example.com:3000"
|
|
93
|
+
};
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**CORS — reglas:**
|
|
97
|
+
- Nunca `AllowAnyOrigin()` en producción.
|
|
98
|
+
- Lista explícita de orígenes, idealmente leída de configuración.
|
|
99
|
+
- No combinar `AllowAnyOrigin()` con `AllowCredentials()` (es invalido y peligroso).
|
|
100
|
+
- `AllowAnyHeader()` y `AllowAnyMethod()` aceptables solo si el conjunto de orígenes está restringido.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
### 1.4 Configuración del archivo `appsettings.json`
|
|
105
|
+
|
|
106
|
+
Solo valores **no sensibles**:
|
|
107
|
+
- ✅ Puertos, hostnames públicos, feature flags, niveles de log, nombres de bucket
|
|
108
|
+
- ❌ Passwords, connection strings con credenciales, encryption keys, JWT secrets, API tokens
|
|
109
|
+
|
|
110
|
+
Si un valor sensible necesita un default visible, usa cadena vacía `""` o un placeholder claro como `"__SET_IN_ENV__"`, **nunca un valor real ni de ejemplo creíble**.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## 2. Reglas para contenedores (Docker)
|
|
115
|
+
|
|
116
|
+
### 2.1 Nunca correr como root
|
|
117
|
+
|
|
118
|
+
```dockerfile
|
|
119
|
+
# ❌ MAL — implícitamente root
|
|
120
|
+
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
|
121
|
+
COPY --from=build /app/publish .
|
|
122
|
+
ENTRYPOINT ["dotnet", "MyApp.API.dll"]
|
|
123
|
+
|
|
124
|
+
# ✅ BIEN — usuario no privilegiado
|
|
125
|
+
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
|
126
|
+
RUN groupadd -r app && useradd -r -g app -u 1001 app
|
|
127
|
+
WORKDIR /app
|
|
128
|
+
COPY --from=build --chown=app:app /app/publish .
|
|
129
|
+
USER app
|
|
130
|
+
ENTRYPOINT ["dotnet", "MyApp.API.dll"]
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### 2.2 Otras reglas Docker
|
|
134
|
+
|
|
135
|
+
- Tags específicos en `FROM`, nunca `:latest`.
|
|
136
|
+
- Multi-stage build para no incluir SDK en la imagen final.
|
|
137
|
+
- `.dockerignore` actualizado (excluir `bin/`, `obj/`, `.git/`, `appsettings.Development.json`, secretos).
|
|
138
|
+
- `COPY` específico, evitar `COPY . .` sin `.dockerignore`.
|
|
139
|
+
- `HEALTHCHECK` definido.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## 3. Reglas de código C# / .NET
|
|
144
|
+
|
|
145
|
+
### 3.1 Manejo de excepciones
|
|
146
|
+
|
|
147
|
+
```csharp
|
|
148
|
+
// ❌ MAL
|
|
149
|
+
try { ... } catch (Exception ex) { _logger.LogError(ex.Message); }
|
|
150
|
+
|
|
151
|
+
// ✅ BIEN — capturar específico, loguear con contexto, re-lanzar o convertir
|
|
152
|
+
try { ... }
|
|
153
|
+
catch (NpgsqlException ex)
|
|
154
|
+
{
|
|
155
|
+
_logger.LogError(ex, "Failed to query {Entity} with id {Id}", entityName, id);
|
|
156
|
+
throw;
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
- Capturar excepciones específicas, no `Exception` genérico (salvo en handlers globales).
|
|
161
|
+
- Loguear con structured logging (`LogError(ex, "msg {Param}", value)`), no concatenar.
|
|
162
|
+
- No tragar excepciones silenciosamente.
|
|
163
|
+
- **No uses excepciones para flujos de negocio esperados** → usa el **Result Pattern** (`Result<T>`) definido en la arquitectura. Las excepciones son solo para errores inesperados / de infraestructura.
|
|
164
|
+
|
|
165
|
+
### 3.2 Async / await
|
|
166
|
+
|
|
167
|
+
- Usar `async`/`await` en cualquier I/O (BD, HTTP, archivos).
|
|
168
|
+
- `ConfigureAwait(false)` en bibliotecas reutilizables (no en endpoints ASP.NET Core).
|
|
169
|
+
- No mezclar `.Result` ni `.Wait()` con `async` (deadlocks).
|
|
170
|
+
- `CancellationToken` propagado desde el endpoint hasta el repositorio.
|
|
171
|
+
|
|
172
|
+
### 3.3 Nullability y disposable
|
|
173
|
+
|
|
174
|
+
- `<Nullable>enable</Nullable>` en el `.csproj`, respetar las anotaciones.
|
|
175
|
+
- `using` / `await using` para todo lo que implementa `IDisposable` / `IAsyncDisposable`.
|
|
176
|
+
- `sealed class` por defecto si no se necesita herencia.
|
|
177
|
+
|
|
178
|
+
### 3.4 Code smells frecuentes
|
|
179
|
+
|
|
180
|
+
- Métodos > 50 líneas → refactorizar.
|
|
181
|
+
- Complejidad ciclomática > 10 → separar.
|
|
182
|
+
- Magic numbers / strings → constantes o enums.
|
|
183
|
+
- TODO / FIXME sin issue asociado → prohibido. Si va, debe incluir referencia (`TODO(JIRA-123): ...`).
|
|
184
|
+
- Código comentado → eliminar, no comentar (para eso está Git).
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## 4. Testing — cobertura ≥ 80% en código nuevo
|
|
189
|
+
|
|
190
|
+
Toda funcionalidad nueva debe incluir tests **en el mismo PR**. No "lo agrego después". **TDD es obligatorio**: escribe los tests antes del código de producción.
|
|
191
|
+
|
|
192
|
+
**Mínimo esperado por tipo:**
|
|
193
|
+
|
|
194
|
+
| Tipo de código | Tests requeridos |
|
|
195
|
+
|---|---|
|
|
196
|
+
| Validators (FluentValidation) | Happy path + cada regla de validación |
|
|
197
|
+
| Endpoints (Minimal API) | 200 OK, 400 validación, 401/403 si aplica, 404, 500 mockeado |
|
|
198
|
+
| Handlers (MediatR / similar) | Happy path + cada rama de negocio + manejo de excepciones |
|
|
199
|
+
| Servicios con dependencias externas | Mock de la dependencia, verificar contrato |
|
|
200
|
+
| Regex / parsers | Casos válidos + casos inválidos + casos borde (vacío, max length) |
|
|
201
|
+
|
|
202
|
+
Frameworks de testing (definidos en la arquitectura corporativa, NO usar otros):
|
|
203
|
+
- **Unit tests:** xUnit + EF Core InMemory.
|
|
204
|
+
- **Integration tests:** xUnit + Testcontainers.PostgreSql (base de datos real en contenedor).
|
|
205
|
+
|
|
206
|
+
Si necesitas introducir un framework adicional, primero valida contra `_siesa-agents/resources/architecture/` y justifícalo.
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## 5. Reglas específicas para agentes de IA
|
|
211
|
+
|
|
212
|
+
### 5.1 Antes de generar código
|
|
213
|
+
|
|
214
|
+
1. Lee este archivo completo.
|
|
215
|
+
2. Revisa las reglas relevantes al tipo de cambio que vas a hacer.
|
|
216
|
+
3. Si vas a tocar configuración, secretos, Regex, Docker o CORS → relee la sección correspondiente.
|
|
217
|
+
|
|
218
|
+
### 5.2 Durante la generación
|
|
219
|
+
|
|
220
|
+
- **Si necesitas un secreto:** genera el código que lo lee de configuración / env y **explícale al humano** dónde debe definirlo. Nunca pongas un valor real ni "de ejemplo".
|
|
221
|
+
- **Si generas Regex:** incluye `TimeSpan.FromSeconds(N)` siempre.
|
|
222
|
+
- **Si generas Dockerfile:** incluye `USER` no-root.
|
|
223
|
+
- **Si generas CORS:** lee orígenes de configuración.
|
|
224
|
+
- **Si generas un endpoint o handler:** genera también su test en el mismo turno.
|
|
225
|
+
|
|
226
|
+
### 5.3 Antes de declarar el cambio terminado
|
|
227
|
+
|
|
228
|
+
Ejecuta este self-check mentalmente:
|
|
229
|
+
|
|
230
|
+
- [ ] ¿Hay algún string que parezca una credencial real?
|
|
231
|
+
- [ ] ¿Todas las `Regex` tienen `TimeSpan` como tercer/cuarto argumento?
|
|
232
|
+
- [ ] ¿Algún `catch (Exception ex)` sin re-lanzar ni contexto?
|
|
233
|
+
- [ ] ¿Tests nuevos para todo el código nuevo?
|
|
234
|
+
- [ ] ¿`appsettings.json` modificado contiene solo valores no sensibles?
|
|
235
|
+
- [ ] ¿Si toqué Dockerfile, sigue corriendo con `USER` no-root?
|
|
236
|
+
- [ ] ¿Si toqué CORS, los orígenes son `https://` excepto `localhost`?
|
|
237
|
+
|
|
238
|
+
Si alguna respuesta es "no" o "no sé", **vuelve atrás y corrige antes de entregar**.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## 6. Checklist final antes de commit
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
[ ] dotnet build sin warnings nuevos
|
|
246
|
+
[ ] dotnet test pasa al 100%
|
|
247
|
+
[ ] Cobertura de líneas nuevas ≥ 80%
|
|
248
|
+
[ ] Sin secretos en el diff (revisar con: git diff --staged | grep -iE 'password|secret|key|token')
|
|
249
|
+
[ ] sonar-scanner local sin issues nuevos de severidad >= Major
|
|
250
|
+
[ ] Pre-commit hooks ejecutados (gitleaks, dotnet format)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## 7. Cuando SonarQube falla un Quality Gate
|
|
256
|
+
|
|
257
|
+
Si una corrida marca este archivo como insuficiente (apareció un patrón nuevo no cubierto aquí):
|
|
258
|
+
|
|
259
|
+
1. **No solo arregles el hallazgo** — agrega la regla a este archivo.
|
|
260
|
+
2. Si es una categoría nueva, abrir sección con ejemplo `❌ MAL` / `✅ BIEN`.
|
|
261
|
+
3. Commit del fix + commit del update de reglas en el mismo PR.
|
|
262
|
+
|
|
263
|
+
Así el siguiente código generado por IA no repetirá el patrón.
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
**Origen:** consolidado a partir de hallazgos reales de análisis de SonarQube en proyectos SIESA (security hotspots, cobertura insuficiente, credenciales hardcodeadas). Mantener vivo según la sección 7.
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sa-init-devops.js — Bootstrap the DevOps workspace and skills in Siesa-Agents.
|
|
3
|
+
//
|
|
4
|
+
// In one invocation the script:
|
|
5
|
+
// 1. Clones (or pulls) architecture-sa-devops into `_siesa-agents/devops/`.
|
|
6
|
+
// 2. Detects conflicts: for each upstream `.claude/skills/sa-*/SKILL.md` and
|
|
7
|
+
// `.github/workflows/*.yml`, compares to the local copy in Siesa-Agents. If any local
|
|
8
|
+
// file differs from upstream and no policy flag was passed, aborts with `status:
|
|
9
|
+
// "conflicts"` and lists the conflicting items so the caller can ask the user how to
|
|
10
|
+
// resolve them.
|
|
11
|
+
// 3. If there are no conflicts, or the caller passed `--take-upstream` / `--keep-local`
|
|
12
|
+
// / `--keep-local=<csv>`, moves the upstream files into Siesa-Agents according to the
|
|
13
|
+
// policy and deletes the `.claude/` and `.github/` directories from the clone.
|
|
14
|
+
// 4. Mirrors the cleaned working tree to `siesa-agents/devops/`.
|
|
15
|
+
//
|
|
16
|
+
// Conflict policy flags (mutually exclusive):
|
|
17
|
+
// --take-upstream Overwrite all conflicting local files with the upstream copy.
|
|
18
|
+
// --keep-local Keep every local file as-is. Only create files that are
|
|
19
|
+
// entirely new in upstream.
|
|
20
|
+
// --keep-local=<csv> Keep these specific files (comma-separated names) local;
|
|
21
|
+
// overwrite the rest. Example:
|
|
22
|
+
// --keep-local=sa-aplicar,infra-pipeline-qa.yml
|
|
23
|
+
//
|
|
24
|
+
// Idempotency: between runs the clone's `.claude/` and `.github/` directories are gone from
|
|
25
|
+
// the working tree but still tracked in upstream HEAD. The script restores those paths
|
|
26
|
+
// (`git checkout HEAD -- .claude .github`) before the pull so it doesn't fail on
|
|
27
|
+
// "uncommitted deletions". The move-and-delete cycle then repeats.
|
|
28
|
+
//
|
|
29
|
+
// Modes:
|
|
30
|
+
// <no args> or --clone Default. Clone or update, detect conflicts, abort if any,
|
|
31
|
+
// otherwise move + delete + mirror.
|
|
32
|
+
// --check Read-only. Report the current state without mutating.
|
|
33
|
+
// --mirror-only Skip the git + move steps; rebuild the mirror from the
|
|
34
|
+
// current clone.
|
|
35
|
+
//
|
|
36
|
+
// Exit codes:
|
|
37
|
+
// 0 Success
|
|
38
|
+
// 1 Argument or usage error
|
|
39
|
+
// 2 No-op (--check on a clean state)
|
|
40
|
+
// 3 Git or filesystem mutation failed
|
|
41
|
+
// 4 Conflicts detected; the caller should ask the user and re-run with a policy flag.
|
|
42
|
+
|
|
43
|
+
'use strict'
|
|
44
|
+
|
|
45
|
+
const fs = require('fs')
|
|
46
|
+
const path = require('path')
|
|
47
|
+
const { spawnSync } = require('child_process')
|
|
48
|
+
|
|
49
|
+
// TODO(siesa-admin): change to https://github.com/SiesaTeams/architecture-sa-devops.git
|
|
50
|
+
// once the repo is transferred to the SiesaTeams organization.
|
|
51
|
+
const REMOTE_URL = 'https://github.com/ssancheze912/architecture-sa-devops.git'
|
|
52
|
+
const REMOTE_NAME = 'origin'
|
|
53
|
+
const DEFAULT_BRANCH = 'main'
|
|
54
|
+
|
|
55
|
+
function parseArgs(argv) {
|
|
56
|
+
const out = { _keepLocalList: null }
|
|
57
|
+
for (const a of argv) {
|
|
58
|
+
if (!a.startsWith('--')) continue
|
|
59
|
+
const eq = a.indexOf('=')
|
|
60
|
+
if (eq === -1) {
|
|
61
|
+
out[a.slice(2)] = true
|
|
62
|
+
} else {
|
|
63
|
+
const key = a.slice(2, eq)
|
|
64
|
+
const val = a.slice(eq + 1)
|
|
65
|
+
out[key] = val
|
|
66
|
+
if (key === 'keep-local') {
|
|
67
|
+
out._keepLocalList = val.split(',').map(s => s.trim()).filter(Boolean)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function emit(obj) {
|
|
75
|
+
process.stdout.write(JSON.stringify(obj, null, 2) + '\n')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function findProjectRoot(startDir) {
|
|
79
|
+
let cur = path.resolve(startDir)
|
|
80
|
+
for (let i = 0; i < 40; i++) {
|
|
81
|
+
if (fs.existsSync(path.join(cur, '_siesa-agents')) && fs.existsSync(path.join(cur, 'siesa-agents'))) {
|
|
82
|
+
return cur
|
|
83
|
+
}
|
|
84
|
+
const parent = path.dirname(cur)
|
|
85
|
+
if (parent === cur) return null
|
|
86
|
+
cur = parent
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function tryGit(args, cwd) {
|
|
92
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8' })
|
|
93
|
+
return {
|
|
94
|
+
ok: r.status === 0,
|
|
95
|
+
code: r.status,
|
|
96
|
+
stdout: (r.stdout || '').trim(),
|
|
97
|
+
stderr: (r.stderr || '').trim(),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isGitWorkingTree(dir) {
|
|
102
|
+
if (!fs.existsSync(path.join(dir, '.git'))) return false
|
|
103
|
+
const r = tryGit(['rev-parse', '--show-toplevel'], dir)
|
|
104
|
+
return r.ok && path.resolve(r.stdout) === path.resolve(dir)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function currentRemoteUrl(dir) {
|
|
108
|
+
const r = tryGit(['remote', 'get-url', REMOTE_NAME], dir)
|
|
109
|
+
return r.ok ? r.stdout : null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function rmDirSafe(dir) {
|
|
113
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function copyTreeWithoutGit(srcDir, destDir) {
|
|
117
|
+
fs.mkdirSync(destDir, { recursive: true })
|
|
118
|
+
const entries = fs.readdirSync(srcDir, { withFileTypes: true })
|
|
119
|
+
for (const ent of entries) {
|
|
120
|
+
if (ent.name === '.git') continue
|
|
121
|
+
const srcPath = path.join(srcDir, ent.name)
|
|
122
|
+
const destPath = path.join(destDir, ent.name)
|
|
123
|
+
if (ent.isDirectory()) {
|
|
124
|
+
copyTreeWithoutGit(srcPath, destPath)
|
|
125
|
+
} else if (ent.isFile()) {
|
|
126
|
+
fs.copyFileSync(srcPath, destPath)
|
|
127
|
+
} else if (ent.isSymbolicLink()) {
|
|
128
|
+
const target = fs.readlinkSync(srcPath)
|
|
129
|
+
try { fs.symlinkSync(target, destPath) } catch (_) { /* Windows may need elevated perms */ }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Compare two files for equality, normalizing CRLF→LF so Windows line-ending quirks don't
|
|
135
|
+
// create spurious conflicts. Returns true if both files have the same logical content.
|
|
136
|
+
function filesEqual(a, b) {
|
|
137
|
+
if (!fs.existsSync(a) || !fs.existsSync(b)) return false
|
|
138
|
+
try {
|
|
139
|
+
const ba = fs.readFileSync(a).toString('utf8').replace(/\r\n/g, '\n')
|
|
140
|
+
const bb = fs.readFileSync(b).toString('utf8').replace(/\r\n/g, '\n')
|
|
141
|
+
return ba === bb
|
|
142
|
+
} catch (_) {
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function refreshMirror(sourceDir, mirrorDir, status) {
|
|
148
|
+
const hadMirror = fs.existsSync(mirrorDir)
|
|
149
|
+
if (hadMirror) rmDirSafe(mirrorDir)
|
|
150
|
+
copyTreeWithoutGit(sourceDir, mirrorDir)
|
|
151
|
+
status.mirror_refreshed = true
|
|
152
|
+
status.mirror_dir = mirrorDir
|
|
153
|
+
status.mirror_existed_before = hadMirror
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Decide what to do with a single upstream item given the policy flags and the local state.
|
|
157
|
+
// Returns one of: 'create' (no local copy yet), 'identical' (already matches upstream),
|
|
158
|
+
// 'conflict' (differs and no policy says how to resolve), 'overwrite' (differs but policy
|
|
159
|
+
// resolves it as upstream-wins), 'keep-local' (differs but policy says keep local).
|
|
160
|
+
function decideAction(name, upstreamPath, localPaths, policy) {
|
|
161
|
+
const anyLocalExists = localPaths.some(p => fs.existsSync(p))
|
|
162
|
+
if (!anyLocalExists) return 'create'
|
|
163
|
+
|
|
164
|
+
// If every local copy matches upstream, nothing to do.
|
|
165
|
+
if (localPaths.every(p => filesEqual(p, upstreamPath))) return 'identical'
|
|
166
|
+
|
|
167
|
+
// At least one local copy differs from upstream — this is a conflict.
|
|
168
|
+
if (policy.keepLocalAll) return 'keep-local'
|
|
169
|
+
if (policy.keepLocalList && policy.keepLocalList.includes(name)) return 'keep-local'
|
|
170
|
+
if (policy.takeUpstream) return 'overwrite'
|
|
171
|
+
return 'conflict'
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// MOVE `<sourceDir>/.claude/skills/sa-*/SKILL.md` into Siesa-Agents `.claude/skills/sa-*/`
|
|
175
|
+
// (and `claude/skills/sa-*/`) according to the policy. Records conflicts in
|
|
176
|
+
// status.skill_conflicts; the caller checks that before any mutation happens.
|
|
177
|
+
function planAndApplySkills(sourceDir, projectRoot, status, policy, dryRun) {
|
|
178
|
+
const srcSkillsDir = path.join(sourceDir, '.claude', 'skills')
|
|
179
|
+
status.skills_created = []
|
|
180
|
+
status.skills_overwritten = []
|
|
181
|
+
status.skills_kept_local = []
|
|
182
|
+
status.skills_unchanged = []
|
|
183
|
+
status.skill_conflicts = []
|
|
184
|
+
|
|
185
|
+
if (!fs.existsSync(srcSkillsDir)) {
|
|
186
|
+
status.skills_source_missing = true
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const dstA = path.join(projectRoot, '.claude', 'skills')
|
|
191
|
+
const dstB = path.join(projectRoot, 'claude', 'skills')
|
|
192
|
+
|
|
193
|
+
for (const entry of fs.readdirSync(srcSkillsDir, { withFileTypes: true })) {
|
|
194
|
+
if (!entry.isDirectory()) continue
|
|
195
|
+
if (!entry.name.startsWith('sa-')) continue
|
|
196
|
+
if (entry.name === 'sa-init-devops') continue // never overwrite the bootstrap itself
|
|
197
|
+
const srcSkill = path.join(srcSkillsDir, entry.name, 'SKILL.md')
|
|
198
|
+
if (!fs.existsSync(srcSkill)) continue
|
|
199
|
+
|
|
200
|
+
const dstSkillA = path.join(dstA, entry.name, 'SKILL.md')
|
|
201
|
+
const dstSkillB = path.join(dstB, entry.name, 'SKILL.md')
|
|
202
|
+
|
|
203
|
+
const action = decideAction(entry.name, srcSkill, [dstSkillA, dstSkillB], policy)
|
|
204
|
+
|
|
205
|
+
if (action === 'conflict') {
|
|
206
|
+
status.skill_conflicts.push(entry.name)
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
if (action === 'identical') {
|
|
210
|
+
status.skills_unchanged.push(entry.name)
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
if (dryRun) continue
|
|
214
|
+
|
|
215
|
+
if (action === 'create' || action === 'overwrite') {
|
|
216
|
+
fs.mkdirSync(path.dirname(dstSkillA), { recursive: true })
|
|
217
|
+
fs.mkdirSync(path.dirname(dstSkillB), { recursive: true })
|
|
218
|
+
fs.copyFileSync(srcSkill, dstSkillA)
|
|
219
|
+
fs.copyFileSync(srcSkill, dstSkillB)
|
|
220
|
+
if (action === 'create') status.skills_created.push(entry.name)
|
|
221
|
+
else status.skills_overwritten.push(entry.name)
|
|
222
|
+
} else if (action === 'keep-local') {
|
|
223
|
+
status.skills_kept_local.push(entry.name)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function planAndApplyWorkflows(sourceDir, projectRoot, status, policy, dryRun) {
|
|
229
|
+
const srcDir = path.join(sourceDir, '.github', 'workflows')
|
|
230
|
+
status.workflows_created = []
|
|
231
|
+
status.workflows_overwritten = []
|
|
232
|
+
status.workflows_kept_local = []
|
|
233
|
+
status.workflows_unchanged = []
|
|
234
|
+
status.workflow_conflicts = []
|
|
235
|
+
|
|
236
|
+
if (!fs.existsSync(srcDir)) {
|
|
237
|
+
status.workflows_source_missing = true
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const dstDir = path.join(projectRoot, '.github', 'workflows')
|
|
242
|
+
|
|
243
|
+
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
|
244
|
+
if (!entry.isFile()) continue
|
|
245
|
+
if (!(entry.name.endsWith('.yml') || entry.name.endsWith('.yaml'))) continue
|
|
246
|
+
|
|
247
|
+
const srcFile = path.join(srcDir, entry.name)
|
|
248
|
+
const dstFile = path.join(dstDir, entry.name)
|
|
249
|
+
|
|
250
|
+
const action = decideAction(entry.name, srcFile, [dstFile], policy)
|
|
251
|
+
|
|
252
|
+
if (action === 'conflict') {
|
|
253
|
+
status.workflow_conflicts.push(entry.name)
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
if (action === 'identical') {
|
|
257
|
+
status.workflows_unchanged.push(entry.name)
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
if (dryRun) continue
|
|
261
|
+
|
|
262
|
+
if (action === 'create' || action === 'overwrite') {
|
|
263
|
+
fs.mkdirSync(dstDir, { recursive: true })
|
|
264
|
+
fs.copyFileSync(srcFile, dstFile)
|
|
265
|
+
if (action === 'create') status.workflows_created.push(entry.name)
|
|
266
|
+
else status.workflows_overwritten.push(entry.name)
|
|
267
|
+
} else if (action === 'keep-local') {
|
|
268
|
+
status.workflows_kept_local.push(entry.name)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// After a successful apply (no conflicts left), wipe the .claude/ and .github/ directories
|
|
274
|
+
// from the clone working tree. These remain tracked in upstream HEAD; restoreClonePaths()
|
|
275
|
+
// brings them back before the next pull.
|
|
276
|
+
function cleanCloneDirs(sourceDir) {
|
|
277
|
+
for (const sub of ['.claude', '.github']) {
|
|
278
|
+
const p = path.join(sourceDir, sub)
|
|
279
|
+
if (fs.existsSync(p)) rmDirSafe(p)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Before pulling, restore `.claude/` and `.github/` paths in the clone working tree.
|
|
284
|
+
// They are tracked in HEAD but get wiped at the end of every run. Without this restore,
|
|
285
|
+
// `git pull --ff-only` would refuse to proceed because the working tree has uncommitted
|
|
286
|
+
// deletions. `git checkout HEAD -- .claude .github` is a no-op if the paths already exist.
|
|
287
|
+
function restoreClonePaths(sourceDir) {
|
|
288
|
+
tryGit(['checkout', 'HEAD', '--', '.claude', '.github'], sourceDir)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function resolveTargets(projectRoot) {
|
|
292
|
+
return {
|
|
293
|
+
sourceDir: path.join(projectRoot, '_siesa-agents', 'devops'),
|
|
294
|
+
mirrorDir: path.join(projectRoot, 'siesa-agents', 'devops'),
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function buildPolicy(args) {
|
|
299
|
+
// Both --keep-local with no value (`--keep-local` alone, no `=`) AND with a value (`--keep-local=...`)
|
|
300
|
+
// need to be distinguished. parseArgs sets out['keep-local'] = true when no `=`, and = '<value>' when there is.
|
|
301
|
+
const keepLocalArg = args['keep-local']
|
|
302
|
+
return {
|
|
303
|
+
takeUpstream: Boolean(args['take-upstream']),
|
|
304
|
+
keepLocalAll: keepLocalArg === true, // bare `--keep-local`
|
|
305
|
+
keepLocalList: Array.isArray(args._keepLocalList) ? args._keepLocalList : null,
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function validatePolicy(policy, status) {
|
|
310
|
+
const setCount = [policy.takeUpstream, policy.keepLocalAll, policy.keepLocalList !== null].filter(Boolean).length
|
|
311
|
+
if (setCount > 1) {
|
|
312
|
+
status.status = 'error'
|
|
313
|
+
status.message = 'Conflicting flags: pass at most one of --take-upstream, --keep-local, --keep-local=<csv>.'
|
|
314
|
+
return false
|
|
315
|
+
}
|
|
316
|
+
return true
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function runCheck(projectRoot) {
|
|
320
|
+
const { sourceDir, mirrorDir } = resolveTargets(projectRoot)
|
|
321
|
+
const state = {
|
|
322
|
+
status: 'unknown',
|
|
323
|
+
project_root: projectRoot,
|
|
324
|
+
source_dir: sourceDir,
|
|
325
|
+
mirror_dir: mirrorDir,
|
|
326
|
+
remote_url: REMOTE_URL,
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!fs.existsSync(sourceDir)) {
|
|
330
|
+
state.status = 'missing'
|
|
331
|
+
state.message = `${sourceDir} does not exist. Run without --check to clone ${REMOTE_URL}.`
|
|
332
|
+
emit(state)
|
|
333
|
+
return 2
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (!isGitWorkingTree(sourceDir)) {
|
|
337
|
+
state.status = 'invalid-source'
|
|
338
|
+
state.message = `${sourceDir} exists but is not a git working tree. Move it aside or delete it manually before re-running this skill.`
|
|
339
|
+
emit(state)
|
|
340
|
+
return 3
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const actualRemote = currentRemoteUrl(sourceDir)
|
|
344
|
+
state.actual_remote_url = actualRemote
|
|
345
|
+
if (actualRemote !== REMOTE_URL) {
|
|
346
|
+
state.status = 'wrong-remote'
|
|
347
|
+
state.message = `${sourceDir} is a git clone but its origin points at ${actualRemote} (expected ${REMOTE_URL}). Move it aside manually before re-running.`
|
|
348
|
+
emit(state)
|
|
349
|
+
return 3
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
state.mirror_exists = fs.existsSync(mirrorDir)
|
|
353
|
+
state.clone_has_claude_dir = fs.existsSync(path.join(sourceDir, '.claude'))
|
|
354
|
+
state.clone_has_github_dir = fs.existsSync(path.join(sourceDir, '.github'))
|
|
355
|
+
|
|
356
|
+
// Dry-run conflict detection so callers can preview before running default mode.
|
|
357
|
+
restoreClonePaths(sourceDir)
|
|
358
|
+
const policy = { takeUpstream: false, keepLocalAll: false, keepLocalList: null }
|
|
359
|
+
planAndApplySkills(sourceDir, projectRoot, state, policy, true)
|
|
360
|
+
planAndApplyWorkflows(sourceDir, projectRoot, state, policy, true)
|
|
361
|
+
|
|
362
|
+
state.status = 'already-cloned'
|
|
363
|
+
const conflictCount = state.skill_conflicts.length + state.workflow_conflicts.length
|
|
364
|
+
state.message = `${sourceDir} is a clean clone of ${REMOTE_URL}. ${conflictCount} conflict(s) would be raised by a default run.`
|
|
365
|
+
emit(state)
|
|
366
|
+
return 0
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function runClone(projectRoot, mirrorOnly, args) {
|
|
370
|
+
const { sourceDir, mirrorDir } = resolveTargets(projectRoot)
|
|
371
|
+
const status = {
|
|
372
|
+
status: 'unknown',
|
|
373
|
+
project_root: projectRoot,
|
|
374
|
+
source_dir: sourceDir,
|
|
375
|
+
mirror_dir: mirrorDir,
|
|
376
|
+
remote_url: REMOTE_URL,
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const policy = buildPolicy(args)
|
|
380
|
+
status.policy = {
|
|
381
|
+
take_upstream: policy.takeUpstream,
|
|
382
|
+
keep_local_all: policy.keepLocalAll,
|
|
383
|
+
keep_local_list: policy.keepLocalList,
|
|
384
|
+
}
|
|
385
|
+
if (!validatePolicy(policy, status)) {
|
|
386
|
+
emit(status)
|
|
387
|
+
return 1
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const sourceExists = fs.existsSync(sourceDir)
|
|
391
|
+
|
|
392
|
+
if (mirrorOnly) {
|
|
393
|
+
if (!sourceExists) {
|
|
394
|
+
status.status = 'error'
|
|
395
|
+
status.message = `--mirror-only requested but ${sourceDir} does not exist yet.`
|
|
396
|
+
emit(status)
|
|
397
|
+
return 3
|
|
398
|
+
}
|
|
399
|
+
refreshMirror(sourceDir, mirrorDir, status)
|
|
400
|
+
status.status = 'mirror-refreshed'
|
|
401
|
+
status.message = `Refreshed ${mirrorDir} from ${sourceDir} (skipped git + move steps).`
|
|
402
|
+
emit(status)
|
|
403
|
+
return 0
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// --- Path 1: source dir does not exist → clone fresh ---
|
|
407
|
+
if (!sourceExists) {
|
|
408
|
+
fs.mkdirSync(path.dirname(sourceDir), { recursive: true })
|
|
409
|
+
const r = tryGit(['clone', '--branch', DEFAULT_BRANCH, REMOTE_URL, sourceDir], path.dirname(sourceDir))
|
|
410
|
+
if (!r.ok) {
|
|
411
|
+
status.status = 'clone-failed'
|
|
412
|
+
status.message = `git clone failed: ${r.stderr || r.stdout || 'unknown error'}`
|
|
413
|
+
emit(status)
|
|
414
|
+
return 3
|
|
415
|
+
}
|
|
416
|
+
return finalizeMoveAndMirror(sourceDir, projectRoot, mirrorDir, status, policy, 'cloned')
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// --- Path 2: source exists, validate it's the right clone ---
|
|
420
|
+
if (!isGitWorkingTree(sourceDir)) {
|
|
421
|
+
status.status = 'invalid-source'
|
|
422
|
+
status.message = `${sourceDir} exists but is not a git working tree. Move it aside or delete it manually before re-running.`
|
|
423
|
+
emit(status)
|
|
424
|
+
return 3
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const actualRemote = currentRemoteUrl(sourceDir)
|
|
428
|
+
status.actual_remote_url = actualRemote
|
|
429
|
+
if (actualRemote !== REMOTE_URL) {
|
|
430
|
+
status.status = 'wrong-remote'
|
|
431
|
+
status.message = `${sourceDir} is a git clone but origin points at ${actualRemote}. Expected ${REMOTE_URL}. Move it aside manually before re-running.`
|
|
432
|
+
emit(status)
|
|
433
|
+
return 3
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// --- Path 3: existing clean clone → restore .claude/.github + fetch + pull --ff-only ---
|
|
437
|
+
restoreClonePaths(sourceDir)
|
|
438
|
+
|
|
439
|
+
const fetchR = tryGit(['fetch', REMOTE_NAME, DEFAULT_BRANCH, '--prune'], sourceDir)
|
|
440
|
+
if (!fetchR.ok) {
|
|
441
|
+
status.status = 'fetch-failed'
|
|
442
|
+
status.message = `git fetch failed: ${fetchR.stderr || fetchR.stdout || 'unknown error'}`
|
|
443
|
+
emit(status)
|
|
444
|
+
return 3
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const beforeR = tryGit(['rev-parse', 'HEAD'], sourceDir)
|
|
448
|
+
const before = beforeR.ok ? beforeR.stdout : null
|
|
449
|
+
|
|
450
|
+
const checkoutR = tryGit(['checkout', DEFAULT_BRANCH], sourceDir)
|
|
451
|
+
if (!checkoutR.ok) {
|
|
452
|
+
status.status = 'checkout-failed'
|
|
453
|
+
status.message = `git checkout ${DEFAULT_BRANCH} failed: ${checkoutR.stderr || checkoutR.stdout}`
|
|
454
|
+
emit(status)
|
|
455
|
+
return 3
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const pullR = tryGit(['pull', '--ff-only', REMOTE_NAME, DEFAULT_BRANCH], sourceDir)
|
|
459
|
+
if (!pullR.ok) {
|
|
460
|
+
status.status = 'pull-failed'
|
|
461
|
+
status.message = `git pull --ff-only failed: ${pullR.stderr || pullR.stdout}. Local edits or diverged branch may be the cause.`
|
|
462
|
+
emit(status)
|
|
463
|
+
return 3
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const afterR = tryGit(['rev-parse', 'HEAD'], sourceDir)
|
|
467
|
+
const after = afterR.ok ? afterR.stdout : null
|
|
468
|
+
const moved = before && after && before !== after
|
|
469
|
+
|
|
470
|
+
status.before_sha = before
|
|
471
|
+
status.after_sha = after
|
|
472
|
+
|
|
473
|
+
return finalizeMoveAndMirror(sourceDir, projectRoot, mirrorDir, status, policy, moved ? 'updated' : 'already-cloned')
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function finalizeMoveAndMirror(sourceDir, projectRoot, mirrorDir, status, policy, terminalStatus) {
|
|
477
|
+
// Phase A: dry-run to compute the action plan and conflicts.
|
|
478
|
+
planAndApplySkills(sourceDir, projectRoot, status, policy, true)
|
|
479
|
+
planAndApplyWorkflows(sourceDir, projectRoot, status, policy, true)
|
|
480
|
+
|
|
481
|
+
const totalConflicts = status.skill_conflicts.length + status.workflow_conflicts.length
|
|
482
|
+
if (totalConflicts > 0) {
|
|
483
|
+
// Abort without mutating. The caller (Claude/SKILL.md) asks the user and re-runs with
|
|
484
|
+
// a policy flag.
|
|
485
|
+
status.status = 'conflicts'
|
|
486
|
+
status.message = `Detected ${status.skill_conflicts.length} skill conflict(s) and ${status.workflow_conflicts.length} workflow conflict(s). Re-run with one of: --take-upstream (overwrite all), --keep-local (keep all local versions), or --keep-local=<csv> (keep specific ones).`
|
|
487
|
+
emit(status)
|
|
488
|
+
return 4
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Phase B: real apply (no conflicts left to resolve).
|
|
492
|
+
planAndApplySkills(sourceDir, projectRoot, status, policy, false)
|
|
493
|
+
planAndApplyWorkflows(sourceDir, projectRoot, status, policy, false)
|
|
494
|
+
cleanCloneDirs(sourceDir)
|
|
495
|
+
refreshMirror(sourceDir, mirrorDir, status)
|
|
496
|
+
|
|
497
|
+
status.status = terminalStatus
|
|
498
|
+
const created = status.skills_created.length + status.workflows_created.length
|
|
499
|
+
const over = status.skills_overwritten.length + status.workflows_overwritten.length
|
|
500
|
+
const kept = status.skills_kept_local.length + status.workflows_kept_local.length
|
|
501
|
+
const unch = status.skills_unchanged.length + status.workflows_unchanged.length
|
|
502
|
+
status.message = `${terminalStatus === 'cloned' ? 'Cloned' : (terminalStatus === 'updated' ? 'Pulled' : 'No upstream change')}. Created ${created}, overwrote ${over}, kept local ${kept}, unchanged ${unch}. Mirror refreshed.`
|
|
503
|
+
emit(status)
|
|
504
|
+
return 0
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const args = parseArgs(process.argv.slice(2))
|
|
508
|
+
const projectRoot = findProjectRoot(process.cwd())
|
|
509
|
+
if (!projectRoot) {
|
|
510
|
+
emit({
|
|
511
|
+
status: 'error',
|
|
512
|
+
message: 'Could not locate the project root. Expected to find a parent directory containing both `_siesa-agents/` and `siesa-agents/`.',
|
|
513
|
+
cwd: process.cwd(),
|
|
514
|
+
})
|
|
515
|
+
process.exit(1)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (args['check']) {
|
|
519
|
+
process.exit(runCheck(projectRoot))
|
|
520
|
+
} else if (args['mirror-only']) {
|
|
521
|
+
process.exit(runClone(projectRoot, true, args))
|
|
522
|
+
} else {
|
|
523
|
+
process.exit(runClone(projectRoot, false, args))
|
|
524
|
+
}
|