siesa-agents 2.1.80 → 2.1.82
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.
- package/claude/skills/sa-init-devops/SKILL.md +145 -0
- package/claude/skills/sa-init-observability/SKILL.md +10 -8
- package/package.json +1 -1
- package/siesa-agents/observability/scripts/sa-emit.js +25 -4
- package/siesa-agents/observability/scripts/sa-init-project.js +51 -7
- package/siesa-agents/sa/sa-init-devops/scripts/sa-init-devops.js +524 -0
|
@@ -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.
|
|
@@ -73,7 +73,7 @@ Parse the JSON object printed to stdout. The `status` field selects the next bra
|
|
|
73
73
|
|
|
74
74
|
Proceed straight to Step 2 with `proposed_slug` and `recommended_dir`. Do **not** ask the user to confirm — the user has already accepted the auto-detection policy by invoking this skill.
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
Single-repo projects also return `status: "ready"` (with `convention_match: false`). The proposed slug is the repo name lowercased and sanitized to kebab-case (e.g. `Siesa-Agents` → `siesa-agents`) and `recommended_dir` is the git root itself. Proceed to Step 2 the same way — no separate branch.
|
|
77
77
|
|
|
78
78
|
**`status: "already-configured"`** — A `.siesa-project` exists above the current git root. Stop and report `existing_siesa_project.path` and `existing_siesa_project.value` to the user. Do not overwrite implicitly. If the user replies that they want it changed, run `--write` with `--force`, the new slug, and the same directory.
|
|
79
79
|
|
|
@@ -95,7 +95,9 @@ Parse the JSON response:
|
|
|
95
95
|
- **`status: "exists"`** — A file is already there with a different value (the detect step missed it because the user pointed at a non-default dir, or a race). Show `current_value` vs `requested_slug` and ask the user whether to overwrite. If yes, re-run with `--force`.
|
|
96
96
|
- **`status: "error"`** — Surface the error message verbatim and stop.
|
|
97
97
|
|
|
98
|
-
For the `
|
|
98
|
+
For the `already-configured` and `no-remote` branches where the user explicitly opts into writing, gather slug + directory inline and run the same `--write` command.
|
|
99
|
+
|
|
100
|
+
The file body written by `--write` is always `project_id=<slug>\n` (key=value form). `sa-emit.js` accepts both this format and a legacy bare-slug line for backward compatibility, so existing `.siesa-project` files keep working.
|
|
99
101
|
|
|
100
102
|
### Step 3 — Verify (optional)
|
|
101
103
|
|
|
@@ -129,7 +131,7 @@ business-<module>-<role>-<rest>
|
|
|
129
131
|
|
|
130
132
|
The logical slug collapses `<role>` away: `business-pos-backend-pdv` → `pos-pdv`.
|
|
131
133
|
|
|
132
|
-
For repo names that do not contain `docs`/`backend`/`frontend`, the script returns `
|
|
134
|
+
For repo names that do not contain `docs`/`backend`/`frontend`, the script still returns `status: "ready"` but with `convention_match: false`. The slug is derived from the repo name (lowercased, sanitized to kebab-case) and the file is written at the git root of that single repo.
|
|
133
135
|
|
|
134
136
|
## Examples
|
|
135
137
|
|
|
@@ -157,11 +159,11 @@ Model: <runs sa-init-env.js — status "created">
|
|
|
157
159
|
|
|
158
160
|
```
|
|
159
161
|
User: Set up .siesa-project for Siesa-Agents.
|
|
160
|
-
Model: <runs
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
162
|
+
Model: <runs sa-init-env.js — silent>
|
|
163
|
+
<runs --detect from Siesa-Agents/>
|
|
164
|
+
<reads proposed_slug="siesa-agents", recommended_dir=C:\…\Siesa-Agents, convention_match=false>
|
|
165
|
+
<runs --write --slug siesa-agents --dir C:\…\Siesa-Agents>
|
|
166
|
+
Wrote C:\…\Siesa-Agents\.siesa-project (project_id=siesa-agents). sa-emit.js will now report project_id="siesa-agents" instead of "SiesaTeams/Siesa-Agents".
|
|
165
167
|
```
|
|
166
168
|
|
|
167
169
|
**Example 3 — Already configured**
|
package/package.json
CHANGED
|
@@ -134,17 +134,38 @@ const stateDir = path.join(os.homedir(), '.claude', 'observability')
|
|
|
134
134
|
const epicIdMatch = story.match(/^(\d+)-/)
|
|
135
135
|
const epicId = epicIdMatch ? epicIdMatch[1] : 'unknown'
|
|
136
136
|
|
|
137
|
+
function parseSiesaProject(content) {
|
|
138
|
+
// Preferred format: a single `project_id=<slug>` line. For backward compatibility a bare
|
|
139
|
+
// slug on its own line is also accepted. `#` introduces a comment. Returns the resolved
|
|
140
|
+
// slug, or null if nothing usable was found.
|
|
141
|
+
if (!content) return null
|
|
142
|
+
let firstBare = null
|
|
143
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
144
|
+
const line = raw.trim()
|
|
145
|
+
if (!line || line.startsWith('#')) continue
|
|
146
|
+
const eq = line.indexOf('=')
|
|
147
|
+
if (eq !== -1) {
|
|
148
|
+
const key = line.slice(0, eq).trim()
|
|
149
|
+
const val = line.slice(eq + 1).trim()
|
|
150
|
+
if (key === 'project_id' && val) return val
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
if (firstBare === null) firstBare = line
|
|
154
|
+
}
|
|
155
|
+
return firstBare
|
|
156
|
+
}
|
|
157
|
+
|
|
137
158
|
function findSiesaProject(startDir) {
|
|
138
|
-
// Walk up from startDir looking for a `.siesa-project` file. Returns
|
|
139
|
-
//
|
|
159
|
+
// Walk up from startDir looking for a `.siesa-project` file. Returns the project_id slug
|
|
160
|
+
// it declares, or null. Lets a parent dir override the git-remote-derived project_id —
|
|
140
161
|
// useful when several sub-repos (docs/backend/frontend) belong to the same logical project.
|
|
141
162
|
let cur = path.resolve(startDir)
|
|
142
163
|
for (let i = 0; i < 40; i++) {
|
|
143
164
|
const candidate = path.join(cur, '.siesa-project')
|
|
144
165
|
if (fs.existsSync(candidate)) {
|
|
145
166
|
try {
|
|
146
|
-
const
|
|
147
|
-
if (
|
|
167
|
+
const slug = parseSiesaProject(fs.readFileSync(candidate, 'utf8'))
|
|
168
|
+
if (slug) return slug
|
|
148
169
|
} catch (_) {}
|
|
149
170
|
}
|
|
150
171
|
const parent = path.dirname(cur)
|
|
@@ -63,6 +63,27 @@ function tryExec(cmd, opts) {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// Parse a `.siesa-project` file body. Preferred format is `project_id=<slug>` (one key=value
|
|
67
|
+
// line, optional `#` comments). For backward compatibility a bare slug on its own line is
|
|
68
|
+
// also accepted. Returns the resolved slug, or null if nothing usable was found.
|
|
69
|
+
function parseSiesaProject(content) {
|
|
70
|
+
if (!content) return null
|
|
71
|
+
let firstBare = null
|
|
72
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
73
|
+
const line = raw.trim()
|
|
74
|
+
if (!line || line.startsWith('#')) continue
|
|
75
|
+
const eq = line.indexOf('=')
|
|
76
|
+
if (eq !== -1) {
|
|
77
|
+
const key = line.slice(0, eq).trim()
|
|
78
|
+
const val = line.slice(eq + 1).trim()
|
|
79
|
+
if (key === 'project_id' && val) return val
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
if (firstBare === null) firstBare = line
|
|
83
|
+
}
|
|
84
|
+
return firstBare
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
// Walk up from `startDir` looking for an existing `.siesa-project`. Returns
|
|
67
88
|
// { path, value } of the first match found, or null.
|
|
68
89
|
function findExistingSiesaProject(startDir) {
|
|
@@ -71,8 +92,8 @@ function findExistingSiesaProject(startDir) {
|
|
|
71
92
|
const candidate = path.join(cur, '.siesa-project')
|
|
72
93
|
if (fs.existsSync(candidate)) {
|
|
73
94
|
try {
|
|
74
|
-
const
|
|
75
|
-
return { path: candidate, value
|
|
95
|
+
const value = parseSiesaProject(fs.readFileSync(candidate, 'utf8'))
|
|
96
|
+
return { path: candidate, value }
|
|
76
97
|
} catch (_) {
|
|
77
98
|
return { path: candidate, value: null }
|
|
78
99
|
}
|
|
@@ -186,15 +207,38 @@ function runDetect() {
|
|
|
186
207
|
const derived = deriveLogicalSlug(repoName)
|
|
187
208
|
|
|
188
209
|
if (!derived.matched) {
|
|
210
|
+
// Single-repo project: still write a `.siesa-project` so the project_id is explicit
|
|
211
|
+
// and self-documented. The slug is derived from the repo name, sanitized to kebab-case,
|
|
212
|
+
// and the file lands at the git root of the current project.
|
|
213
|
+
const singleRepoSlug = (repoName || '')
|
|
214
|
+
.toLowerCase()
|
|
215
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
216
|
+
.replace(/^-+|-+$/g, '')
|
|
217
|
+
if (!singleRepoSlug || !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(singleRepoSlug)) {
|
|
218
|
+
emit({
|
|
219
|
+
status: 'no-remote',
|
|
220
|
+
message: 'Could not derive a valid kebab-case slug from the repo name. Pass --slug explicitly to write a .siesa-project.',
|
|
221
|
+
git_root: gitRoot,
|
|
222
|
+
remote_url: remoteUrl,
|
|
223
|
+
remote_repo: repoName,
|
|
224
|
+
})
|
|
225
|
+
process.exit(2)
|
|
226
|
+
}
|
|
189
227
|
emit({
|
|
190
|
-
status: '
|
|
191
|
-
message: '
|
|
228
|
+
status: 'ready',
|
|
229
|
+
message: 'Single-repo project detected. The .siesa-project file will be written at the git root with a slug derived from the repo name.',
|
|
192
230
|
git_root: gitRoot,
|
|
193
231
|
remote_url: remoteUrl,
|
|
194
232
|
remote_repo: repoName,
|
|
233
|
+
convention_match: false,
|
|
234
|
+
proposed_slug: singleRepoSlug,
|
|
235
|
+
convention_breakdown: null,
|
|
236
|
+
parent_candidates: [{ path: gitRoot, label: 'git root', levelsUp: 0 }],
|
|
237
|
+
recommended_dir: gitRoot,
|
|
195
238
|
fallback_project_id: remoteParsed ? `${remoteParsed.org}/${remoteParsed.repo}` : null,
|
|
239
|
+
next_step_example: `node sa-init-project.js --write --slug "${singleRepoSlug}" --dir "${gitRoot}"`,
|
|
196
240
|
})
|
|
197
|
-
process.exit(
|
|
241
|
+
process.exit(0)
|
|
198
242
|
}
|
|
199
243
|
|
|
200
244
|
const candidates = listParentCandidates(gitRoot)
|
|
@@ -256,7 +300,7 @@ function runWrite(args) {
|
|
|
256
300
|
const target = path.join(resolvedDir, '.siesa-project')
|
|
257
301
|
if (fs.existsSync(target) && !force) {
|
|
258
302
|
let current = null
|
|
259
|
-
try { current = fs.readFileSync(target, 'utf8')
|
|
303
|
+
try { current = parseSiesaProject(fs.readFileSync(target, 'utf8')) } catch (_) {}
|
|
260
304
|
emit({
|
|
261
305
|
status: 'exists',
|
|
262
306
|
message: '.siesa-project already exists at this location. Pass --force to overwrite.',
|
|
@@ -268,7 +312,7 @@ function runWrite(args) {
|
|
|
268
312
|
}
|
|
269
313
|
|
|
270
314
|
try {
|
|
271
|
-
fs.writeFileSync(target, normalizedSlug
|
|
315
|
+
fs.writeFileSync(target, `project_id=${normalizedSlug}\n`, { encoding: 'utf8' })
|
|
272
316
|
} catch (err) {
|
|
273
317
|
emit({ status: 'error', message: `Failed to write ${target}: ${err.message}` })
|
|
274
318
|
process.exit(3)
|
|
@@ -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
|
+
}
|