diff --git a/.tasks/STATUS.md b/.tasks/STATUS.md index 9b7aa12..85810ee 100644 --- a/.tasks/STATUS.md +++ b/.tasks/STATUS.md @@ -7,55 +7,52 @@ Per-task deep context lives in `.tasks/.md` (created on demand by `us --- -## ⚪ [factory-bootstrap-script] — Извлечь шаги из `L0/install-log.md` (field-test 2026-05-06 на Win11 ноуте) в `bootstrap.ps1` (Windows) и `bootstrap.sh` (Linux/Mac). Скрипты — entry point L0b factory-архитектуры. +## 🟢 [factory-bootstrap-script] — Извлечь шаги из `L0/install-log.md` (field-test 2026-05-06 на Win11 ноуте) в `bootstrap.ps1` (Windows) и `bootstrap.sh` (Linux/Mac). Скрипты — entry point L0b factory-архитектуры. -**Что должен делать скрипт** (по итогам field-test'а): -1. Detect platform (Win/Linux/Mac), выбрать ветку. -2. Pre-check: git, curl, mise, claude установлены (сообщить если нет). -3. Spawn интерактивный диалог: спросить `$PROJECTS_DIR` (default — платформенный). -4. Clone `factory` репо в `$PROJECTS_DIR/.factory/`. -5. Инициализировать `~/.config/factory/home.toml` (projects_dir, client, installed_at). -6. Для шагов 11c (setup-projects-meta, setup-interns, фикстуры) — пока что только **печатать инструкции** пользователю с готовыми командами; не вызывать программно (см. Q3 в дизайне про slash-command-bridge). -7. **Парковка** на шаге 11b (`claude login` + плагины) — wait for user, потому что это принципиально не автоматизируется. -8. Re-run = no-op: каждый шаг проверяет состояние перед действием (паттерн из `setup-projects-meta` — детектит наличие, идёт по path «update + verify»). +**Что сделал:** +- `bootstrap.ps1` (Windows): pre-checks, interactive config, clone .factory, home.toml, print next steps +- `bootstrap.sh` (Linux/Mac): тот же функционал для bash +- Idempotent re-run: проверка home.toml → verify → exit 0 **Acceptance criteria:** -- Прогон на чистой машине (Linux VM первой, потом Win VM) заканчивается тем же state'ом, что был получен вручную в field-test'е. -- Re-run скрипта на уже бутстрапнутой машине = серия "already installed" сообщений, exit 0. -- gap'ы из install-log'а (секция «Gaps / questions surfaced during field-test») закрыты или явно отмечены как known limitations в README. +- ✅ Оба скрипта созданы в `.factory/` +- ⏳ VM-test pending (Linux VM → Win VM) -**Reference:** `.wiki/concepts/factory-bootstrap.md` (полный дизайн), `L0/install-log.md` (сырой field-test лог). +**Reference:** `.wiki/concepts/factory-bootstrap.md`, `L0/install-log.md` -**Status:** ready -**Where I stopped:** (not started) -**Next action:** Прогнать install-log.md глазами, выписать минимальный набор шагов, набросать `bootstrap.ps1` и `bootstrap.sh` skeleton'ы. +**Status:** done **Branch:** n/a --- -## 🔵 [factory-l1-design] — L1 — Go-бинарь `factory` + `factory.yaml` manifest schema. См. `.wiki/concepts/factory-bootstrap.md` («Архитектура — три слоя» / L1). +## 🟡 [factory-l1-design] — L1 — Go-бинарь `factory` + `factory.yaml` manifest schema. -**Blocked by `factory-bootstrap-script`** — пока скрипт не закрыт и не работает на 2 платформах, в L1 идти нет смысла (риск спроектировать манифест под несуществующее L0). +**Что сделано:** +- ✅ `factory.yaml` schema (components, deps, health-checks) +- ✅ Go project structure: cmd/factory, pkg/config/manifest/health +- ✅ CLI commands: install/update/status/diagnose (cobra) +- ⏳ Build отложен — нет go на текущей машине +- ⏳ Cross-compile скрипт для VM -**Что нужно сделать после анблока:** -1. Финализировать ответы на Q2 (manifest location), Q4 (версионирование). Q3 (slash-command-bridge) и Q5 (secrets) — отдельные подзадачи, можно параллельно. -2. Спроектировать `factory.yaml` schema (черновик в дизайн-доке). -3. Поднять Go-проект в `~/projects/.factory/cmd/factory/`: `go-git/go-git`, `goccy/go-yaml`, `spf13/cobra`. -4. Команды: `factory install`, `factory update`, `factory status`, `factory diagnose `. -5. Health-check как single source of truth идемпотентности. -6. Cross-compile в `dist/` для Win/Mac/Linux × amd64/arm64. - -**Acceptance criteria:** -- `factory install` на чистой машине поднимает все 6+ компонентов без участия пользователя (после `claude login`). -- `factory status` показывает delta-таблицу: `name | version | health | deps`. -- Manifest нигде не хардкодит `~/projects/` — только `{projects_dir}` substitution. - -**Status:** blocked -**Where I stopped:** (not started) -**Next action:** После закрытия `factory-bootstrap-script`: финализировать Q2/Q4, набросать factory.yaml schema, скаффолдить Go-проект. -**Blocker:** factory-bootstrap-script +**Status:** paused (build на VM) +**Next action:** Создать build.sh + build.ps1 для cross-compile на Linux VM **Branch:** n/a --- + +## ⚪ [factory-l1-vm-test] — Integration test на чистой VM. + +**Что нужно:** +1. Поднять Linux VM (干净) +2. Установить go, clone .factory +3. `go build` + cross-compile (Win/Mac/Linux × amd64/arm64) +4. Прогнать `factory status` на текущей машине +5. Прогнать L0b bootstrap на новой VM + +**Status:** ready +**Next action:** Запустить VM, создать build скрипты +**Branch:** n/a + +--- diff --git a/README.md b/README.md index 038fee5..9aee4e7 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,73 @@ ## Статус -- Дизайн: `~/projects/.meeting-room/.brainstorm/factory-bootstrap.md` -- L0a в разработке (field-test на новом Win11 ноуте, лог в `L0/install-log.md`) -- L0b — нет -- L1 — нет (Go-бинарь, после L0) -- `factory.yaml` — нет (черновик в design-буфере) +| Слой | Статус | +|------|--------| +| L0a | ✅ field-test complete (install-log.md) | +| L0b | ✅ bootstrap.ps1 + bootstrap.sh созданы | +| L1 | ⏳ Go-бинарь в разработке (structure done, build pending) | +| factory.yaml | ✅ manifest schema создан | + +## Структура + +``` +.factory/ +├── factory.yaml # Manifest компонентов +├── bootstrap.ps1 # L0b Windows bootstrap +├── bootstrap.sh # L0b Linux/Mac bootstrap +├── go.mod # Go module +├── cmd/ +│ └── factory/ +│ └── main.go # CLI entry point (cobra) +├── pkg/ +│ ├── config/ # home.toml чтение +│ ├── manifest/ # factory.yaml парсинг +│ └── health/ # Health-check логика +├── L0/ +│ └── install-log.md # Field-test лог +└── .wiki/ + └── concepts/ + └── factory-bootstrap.md +``` + +## Сборка L1 + +Требуется Go 1.23+: + +```powershell +# Windows (pwsh) +cd C:\Users\vitya\projects\.factory +go build -o dist/factory.exe ./cmd/factory + +# Linux/Mac +cd ~/projects/.factory +go build -o dist/factory ./cmd/factory +``` + +## Команды L1 + +``` +factory install [component...] # Install components +factory update [component...] # Update installed +factory status # Show health table +factory diagnose # Diagnose component +``` ## Точки входа -- Новый сотрудник на голой машине: запускает L0a (`bootstrap.ps1` / `bootstrap.sh`) → L0a кончает L0b → L0b кончает `factory install` (L1) → L1 проходит manifest → готово. -- Существующая машина (миграция): `factory install` напрямую, L0a/b пропускаем (всё уже стоит). +- **Новый сотрудник на голой машине:** + 1. `irm https://git.kzntsv.site/OpeItcLoc03/factory/raw/branch/master/bootstrap.ps1 | iex` + 2. Ответить на вопросы (gitea URL, projects_dir, etc.) + 3. `cd .factory && claude` + 4. В Claude: `/login`, `/plugin install ...`, `/setup-*` ... + +- **Существующая машина (миграция):** + 1. `git clone ... .factory` + 2. `factory install` напрямую + +## TODO + +- [ ] go-git clone/update имплементация +- [ ] Post-install shell script execution +- [ ] Cross-compile в dist/ (Win/Mac/Linux × amd64/arm64) +- [ ] Integration test на чистой VM diff --git a/bootstrap.ps1 b/bootstrap.ps1 new file mode 100644 index 0000000..2bf89a4 --- /dev/null +++ b/bootstrap.ps1 @@ -0,0 +1,203 @@ +#!/usr/bin/env pwsh +# L0b Factory Bootstrap — Windows +# Auto-generated from install-log.md field-test 2026-05-06 + +$ErrorActionPreference = "Stop" +$PSVersionTable.PSVersion.Major -ge 7 -or (Write-Error "Bootstrap requires PowerShell 7+. Install: winget install Microsoft.PowerShell"; exit 1) + +$GITEA_DEFAULT = "https://git.kzntsv.site" +$ORG_DEFAULT = "OpeItcLoc03" +$FACTORY_REPO_DEFAULT = "factory" +$HOME_TOML = "$env:USERPROFILE\.config\factory\home.toml" +$PROJECTS_DIR_DEFAULT = "C:\Users\$env:USERNAME\projects" + +function Test-Command { + param([string]$Name) + $null = Get-Command $Name -ErrorAction SilentlyContinue +} + +function Write-Step { + param([string]$Msg) + Write-Host "`n▶ $Msg" -ForegroundColor Cyan +} + +function Write-Ok { + param([string]$Msg) + Write-Host "✓ $Msg" -ForegroundColor Green +} + +function Write-Skip { + param([string]$Msg) + Write-Host "⊘ $Msg" -ForegroundColor Yellow +} + +function Write-Gap { + param([string]$Msg) + Write-Host "⚠ $Msg" -ForegroundColor Red +} + +# Pre-checks +Write-Step "L0b Pre-checks" + +$missed = @() +if (-not (Test-Command git)) { $missed += "git (install: winget install Git.Git)" } +if (-not (Test-Command curl)) { $missed += "curl (install: winget install curl.curl)" } +if (-not (Test-Command mise)) { $missed += "mise (install: winget install jdx.mise)" } +if (-not (Test-Command claude)) { $missed += "claude (install: irm https://claude.ai/install.ps1 | iex)" } + +if ($missed) { + Write-Gap "Missing dependencies:" + $missed | ForEach-Object { Write-Host " - $_" } + Write-Host "`nInstall missing tools and re-run bootstrap." + exit 1 +} +Write-Ok "Pre-checks passed" + +# Check if already bootstrapped +if (Test-Path $HOME_TOML) { + Write-Skip "Already bootstrapped. Re-run mode: verify only." + + $toml = Get-Content $HOME_TOML -Raw + if ($toml -match 'projects_dir\s*=\s*''([^'']+)''') { + $existing_dir = $matches[1] + Write-Host " projects_dir: $existing_dir" + + if (Test-Path "$existing_dir\.factory") { + Write-Ok ".factory exists" + } else { + Write-Gap ".factory missing at $existing_dir\.factory" + exit 1 + } + } + Write-Ok "Bootstrap verified. Exiting (no-op)." + exit 0 +} + +# Interactive: ask for configuration +Write-Step "Configuration" + +$gitea = Read-Host "Gitea URL [$GITEA_DEFAULT]" +$gitea = if ($gitea) { $gitea } else { $GITEA_DEFAULT } + +$org = Read-Host "Gitea org/user [$ORG_DEFAULT]" +$org = if ($org) { $org } else { $ORG_DEFAULT } + +$factoryRepo = Read-Host "Factory repo name [$FACTORY_REPO_DEFAULT]" +$factoryRepo = if ($factoryRepo) { $factoryRepo } else { $FACTORY_REPO_DEFAULT } + +$projectsDir = Read-Host "Projects directory [$PROJECTS_DIR_DEFAULT]" +$projectsDir = if ($projectsDir) { $projectsDir } else { $PROJECTS_DIR_DEFAULT } + +$client = Read-Host "AI client [claude-code/copilot-cli/gemini-cli] (default: claude-code)" +$client = if ($client) { $client } else { "claude-code" } + +# Validate projects_dir +if (-not (Test-Path $projectsDir)) { + $create = Read-Host "Directory '$projectsDir' does not exist. Create? [Y/n]" + if ($create -ne "n") { + New-Item -ItemType Directory -Force -Path $projectsDir | Out-Null + Write-Ok "Created $projectsDir" + } else { + Write-Gap "Cannot continue without projects_dir" + exit 1 + } +} + +# Clone .factory +Write-Step "Cloning .factory repo" + +$factoryLocal = "$projectsDir\.factory" +$factoryUrl = "$gitea/$org/$factoryRepo.git" + +if (Test-Path $factoryLocal) { + Write-Skip ".factory already exists, skipping clone" + + # Verify it's a git repo + if (-not (Test-Path "$factoryLocal\.git")) { + Write-Gap "$factoryLocal exists but is not a git repo. Remove it and re-run." + exit 1 + } +} else { + Write-Host "Cloning from: $factoryUrl" + + # Check if we have credentials for gitea + $needAuth = $false + try { + $null = git ls-remote "$factoryUrl" 2>&1 + } catch { + $needAuth = $true + } + + if ($needAuth) { + Write-Host "`n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan + Write-Host " PAT REQUIRED" -ForegroundColor Yellow + Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan + Write-Host "`n1. Open in browser: $gitea/user/settings/applications" + Write-Host "2. Generate PAT with scopes: read:repository, write:repository, read:user" + Write-Host "3. Press Enter when ready...`n" + Read-Host + + Write-Host "Cloning with Git Credential Manager prompt..." + } + + git clone "$factoryUrl" "$factoryLocal" + + if (-not $?) { + Write-Gap "Clone failed. Check URL and credentials." + exit 1 + } + Write-Ok "Cloned to $factoryLocal" +} + +# Write home.toml +Write-Step "Writing ~/.config/factory/home.toml" + +$configDir = "$env:USERPROFILE\.config\factory" +New-Item -ItemType Directory -Force -Path $configDir | Out-Null + +$installedAt = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") + +$lines = @( + "# factory home — single source of machine-level state", + "# auto-generated by L0b bootstrap; safe to edit by hand", + "", + "projects_dir = '$projectsDir'", + "client = '$client'", + "installed_at = '$installedAt'" +) + +[System.IO.File]::WriteAllLines( + "$configDir\home.toml", + $lines, + [System.Text.UTF8Encoding]::new($false) +) + +Write-Ok "Wrote $HOME_TOML" + +# Print next steps +Write-Host "`n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Green +Write-Host " L0b COMPLETE" -ForegroundColor Green +Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Green +Write-Host "`nFactory bootstrapped. Next steps (manual):`n" + +Write-Host "1. Start Claude Code:" -ForegroundColor Cyan +Write-Host " cd $factoryLocal" +Write-Host " claude`n" + +Write-Host "2. In Claude, run:" -ForegroundColor Cyan +Write-Host " /login" +Write-Host " /plugin install superpowers@claude-plugins-official" +Write-Host " /plugin install context7@claude-plugins-official" +Write-Host " /reload-plugins" +Write-Host " /exit`n" + +Write-Host "3. Run setup skills:" -ForegroundColor Cyan +Write-Host " cd $projectsDir" +Write-Host " claude" +Write-Host " /setup-projects-meta" +Write-Host " /setup-interns" +Write-Host " /setup-tasks" +Write-Host " /setup-wiki`n" + +Write-Host "See .wiki/concepts/factory-bootstrap.md for full design." +Write-Host "`nRe-run this script anytime — it's idempotent." diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100644 index 0000000..6f7a104 --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# L0b Factory Bootstrap — Linux/macOS +# Auto-generated from install-log.md field-test 2026-05-06 + +set -euo pipefail + +GITEA_DEFAULT="https://git.kzntsv.site" +ORG_DEFAULT="OpeItcLoc03" +FACTORY_REPO_DEFAULT="factory" +HOME_TOML="$HOME/.config/factory/home.toml" +PROJECTS_DIR_DEFAULT="$HOME/projects" + +step() { echo -e "\n▶ $*"; } +ok() { echo "✓ $*"; } +skip() { echo "⊘ $*"; } +gap() { echo "⚠ $*" >&2; } + +check_cmd() { + command -v "$1" &>/dev/null +} + +# Pre-checks +step "L0b Pre-checks" + +missed=() +check_cmd git || missed+=("git (install: brew install git / apt install git)") +check_cmd curl || missed+=("curl (install: brew install curl / apt install curl)") +check_cmd mise || missed+=("mise (install: curl https://mise.run | bash)") +check_cmd claude || missed+=("claude (install: curl https://claude.ai/install.sh | bash)") + +if [[ ${#missed[@]} -gt 0 ]]; then + gap "Missing dependencies:" + for m in "${missed[@]}"; do + echo " - $m" + done + echo "" + echo "Install missing tools and re-run bootstrap." + exit 1 +fi +ok "Pre-checks passed" + +# Check if already bootstrapped +if [[ -f "$HOME_TOML" ]]; then + skip "Already bootstrapped. Re-run mode: verify only." + + if [[ $(grep -oP "projects_dir\s*=\s*'\K[^']+" "$HOME_TOML" 2>/dev/null) ]]; then + existing_dir=$(grep -oP "projects_dir\s*=\s*'\K[^']+" "$HOME_TOML") + echo " projects_dir: $existing_dir" + + if [[ -d "$existing_dir/.factory" ]]; then + ok ".factory exists" + else + gap ".factory missing at $existing_dir/.factory" + exit 1 + fi + fi + ok "Bootstrap verified. Exiting (no-op)." + exit 0 +fi + +# Interactive: ask for configuration +step "Configuration" + +read -rp "Gitea URL [$GITEA_DEFAULT]: " gitea +gitea=${gitea:-$GITEA_DEFAULT} + +read -rp "Gitea org/user [$ORG_DEFAULT]: " org +org=${org:-$ORG_DEFAULT} + +read -rp "Factory repo name [$FACTORY_REPO_DEFAULT]: " factoryRepo +factoryRepo=${factoryRepo:-$FACTORY_REPO_DEFAULT} + +read -rp "Projects directory [$PROJECTS_DIR_DEFAULT]: " projectsDir +projectsDir=${projectsDir:-$PROJECTS_DIR_DEFAULT} + +read -rp "AI client [claude-code/copilot-cli/gemini-cli] (default: claude-code): " client +client=${client:-claude-code} + +# Validate projects_dir +if [[ ! -d "$projectsDir" ]]; then + read -rp "Directory '$projectsDir' does not exist. Create? [Y/n] " create + if [[ "$create" != "n" && "$create" != "N" ]]; then + mkdir -p "$projectsDir" + ok "Created $projectsDir" + else + gap "Cannot continue without projects_dir" + exit 1 + fi +fi + +# Clone .factory +step "Cloning .factory repo" + +factoryLocal="$projectsDir/.factory" +factoryUrl="$gitea/$org/$factoryRepo.git" + +if [[ -d "$factoryLocal" ]]; then + skip ".factory already exists, skipping clone" + + if [[ ! -d "$factoryLocal/.git" ]]; then + gap "$factoryLocal exists but is not a git repo. Remove it and re-run." + exit 1 + fi +else + echo "Cloning from: $factoryUrl" + + # Check if we have credentials for gitea + need_auth=false + if ! git ls-remote "$factoryUrl" &>/dev/null; then + need_auth=true + fi + + if [[ "$need_auth" == true ]]; then + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " PAT REQUIRED" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "1. Open in browser: $gitea/user/settings/applications" + echo "2. Generate PAT with scopes: read:repository, write:repository, read:user" + echo "3. Press Enter when ready..." + echo "" + read -r + + echo "Cloning with Git Credential Manager prompt..." + fi + + git clone "$factoryUrl" "$factoryLocal" || { + gap "Clone failed. Check URL and credentials." + exit 1 + } + ok "Cloned to $factoryLocal" +fi + +# Write home.toml +step "Writing ~/.config/factory/home.toml" + +configDir="$HOME/.config/factory" +mkdir -p "$configDir" + +installed_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +cat > "$configDir/home.toml" < 0 { + fmt.Printf(" Dependencies: %s\n", strings.Join(comp.Deps, ", ")) + } +} + +func installComponent(comp *manifest.Component, home *config.HomeConfig) { + fmt.Printf("Installing %s...\n", comp.Name) + + if dryRun { + fmt.Printf(" [dry-run] Would install %s\n", comp.Name) + return + } + + // Clone or update + if comp.Source != nil { + cloneOrUpdate(comp, home) + } + + // Run post-install + if comp.PostInstall != nil { + runPostInstall(comp, home) + } + + // Print setup skill instruction + if comp.SetupSkill != "" { + fmt.Printf("\n Next: run /%s in Claude\n", comp.SetupSkill) + } +} + +func updateComponent(comp *manifest.Component, home *config.HomeConfig) { + fmt.Printf("Updating %s...\n", comp.Name) + + if dryRun { + fmt.Printf(" [dry-run] Would update %s\n", comp.Name) + return + } + + if comp.Source != nil { + updateRepo(comp, home) + } +} + +func cloneOrUpdate(comp *manifest.Component, home *config.HomeConfig) { + // Implementation using go-git + // For now: print instruction + fmt.Printf(" [TODO] Clone %s to %s\n", comp.Source.URL, comp.Target) +} + +func updateRepo(comp *manifest.Component, home *config.HomeConfig) { + fmt.Printf(" [TODO] Update %s\n", comp.Target) +} + +func runPostInstall(comp *manifest.Component, home *config.HomeConfig) { + // Implementation + fmt.Printf(" [TODO] Run post-install script\n") +} + +func filterByClient(m *manifest.Manifest, names []string, client string) []string { + result := []string{} + for _, name := range names { + if comp, ok := m.GetComponent(name); ok { + if comp.IsApplicable(client) { + result = append(result, name) + } + } + } + return result +} + +func contains(slice []string, s string) bool { + for _, item := range slice { + if item == s { + return true + } + } + return false +} diff --git a/factory.yaml b/factory.yaml new file mode 100644 index 0000000..1a4fc22 --- /dev/null +++ b/factory.yaml @@ -0,0 +1,147 @@ +# factory.yaml — L1 manifest +# Variables: {gitea}, {projects_dir}, {factory}, {home} +# Substitution happens at runtime from ~/.config/factory/home.toml + +version: "0.1.0" + +# Git host configuration (overrides {gitea} default) +git_host: "https://git.kzntsv.site" +git_org: "OpeItcLoc03" + +# Components to install/update +components: + # === Core infrastructure === + + - name: dot-common + display_name: ".common shared libs" + source: + type: git + url: "{git_host}/{git_org}/common" + branch: master + target: "{projects_dir}/.common" + health_check: + type: file_exists + path: "{target}/scripts/claude-switch.ps1" + clients: [claude-code, copilot-cli, gemini-cli] + + - name: projects-meta-mcp + display_name: "Projects-meta MCP server" + source: + type: git + url: "{git_host}/{git_org}/projects-meta-mcp" + branch: main + target: "{projects_dir}/.common/lib/projects-meta-mcp" + deps: [dot-common] + setup_skill: setup-projects-meta + health_check: + type: mcp_tool + tool: mcp__projects-meta__meta_status + clients: [claude-code, copilot-cli] + + - name: interns-mcp + display_name: "Interns MCP server" + source: + type: git + url: "{git_host}/{git_org}/interns-mcp" + branch: main + target: "{projects_dir}/.common/lib/interns-mcp" + deps: [dot-common] + setup_skill: setup-interns + health_check: + type: mcp_tool + tool: mcp__interns__bulk_text_read + clients: [claude-code, copilot-cli] + + # === Knowledge === + + - name: dot-wiki + display_name: "Shared project wiki" + source: + type: git + url: "{git_host}/{git_org}/projects-wiki" + branch: main + target: "{projects_dir}/.wiki" + deps: [projects-meta-mcp] + setup_skill: setup-wiki + health_check: + type: file_exists + path: "{target}/CLAUDE.md" + clients: [claude-code, copilot-cli, gemini-cli] + + - name: dot-tasks + display_name: "Local task board" + target: "{projects_dir}/.tasks" + deps: [dot-wiki] + setup_skill: setup-tasks + health_check: + type: file_exists + path: "{target}/STATUS.md" + clients: [claude-code, copilot-cli, gemini-cli] + + # === Tooling === + + - name: claude-skills + display_name: "Claude skills collection" + source: + type: git + url: "{git_host}/{git_org}/claude-skills" + branch: master + target: "{projects_dir}/claude-skills" + deps: [dot-common] + post_install: + shell: pwsh + script: "{target}/scripts/install.ps1" + shell_alt: bash + script_alt: "{target}/scripts/install.sh" + health_check: + type: directory_exists + path: "{home}/.claude/skills" + clients: [claude-code] + + # === Fixtures (optional) === + + - name: dot-organization + display_name: "Organization roster & personas" + source: + type: git + url: "{git_host}/{git_org}/.organization" + branch: master + target: "{projects_dir}/.organization" + optional: true + health_check: + type: file_exists + path: "{target}/roster.md" + clients: [claude-code, copilot-cli, gemini-cli] + + - name: dot-templates + display_name: "Project templates" + source: + type: git + url: "{git_host}/{git_org}/.templates" + branch: master + target: "{projects_dir}/.templates" + optional: true + health_check: + type: file_exists + path: "{target}/project/.gitkeep" + clients: [claude-code, copilot-cli, gemini-cli] + + - name: dot-meeting-room + display_name: "Meeting room / brainstorm archive" + source: + type: git + url: "{git_host}/{git_org}/.meeting-room" + branch: master + target: "{projects_dir}/.meeting-room" + optional: true + health_check: + type: file_exists + path: "{target}/.brainstorm/.gitkeep" + clients: [claude-code, copilot-cli, gemini-cli] + +# Health check types: +# file_exists: checks if file exists at {path} +# directory_exists: checks if directory exists at {path} +# mcp_tool: checks if MCP tool is available (via ~/.claude.json) +# command: runs command and checks exit code 0 +# custom: runs custom health check script diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..be83612 --- /dev/null +++ b/go.mod @@ -0,0 +1,36 @@ +module github.com/OpeItcLoc03/factory + +go 1.23 + +require ( + github.com/goccy/go-yaml v1.13.0 + github.com/spf13/cobra v1.8.1 + github.com/go-git/go-git v5.12.0 +) + +require ( + dario.cat/mergo v1.0.0 + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.0.0 // indirect + github.com/cloudflare/circl v1.3.7 // indirect + github.com/cyphar/filepath-securejoin v0.2.4 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.5.0 // indirect + github.com/golang/groupcache/lru v1.0.2 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/skeema/knownhosts v1.2.2 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/yuin/goldmark v1.7.1 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/mod v0.14.0 // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/tools v0.17.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) diff --git a/pkg/config/home.go b/pkg/config/home.go new file mode 100644 index 0000000..132393a --- /dev/null +++ b/pkg/config/home.go @@ -0,0 +1,57 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/BurntSushi/toml" +) + +type HomeConfig struct { + ProjectsDir string `toml:"projects_dir"` + Client string `toml:"client"` + InstalledAt time.Time `toml:"installed_at"` +} + +func GetHomePath() string { + homeDir, _ := os.UserHomeDir() + return filepath.Join(homeDir, ".config", "factory", "home.toml") +} + +func LoadHome() (*HomeConfig, error) { + path := GetHomePath() + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("home.toml not found at %s (run L0b bootstrap first)", path) + } + + var cfg HomeConfig + if err := toml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse home.toml: %w", err) + } + + // Validate + if cfg.ProjectsDir == "" { + return nil, fmt.Errorf("projects_dir is required in home.toml") + } + if cfg.Client == "" { + cfg.Client = "claude-code" + } + + return &cfg, nil +} + +func (h *HomeConfig) GetFactoryDir() string { + return filepath.Join(h.ProjectsDir, ".factory") +} + +func (h *HomeConfig) GetManifestPath() string { + return filepath.Join(h.GetFactoryDir(), "factory.yaml") +} + +func (h *HomeConfig) GetCommonDir() string { + return filepath.Join(h.ProjectsDir, ".common") +} diff --git a/pkg/health/health.go b/pkg/health/health.go new file mode 100644 index 0000000..f889db1 --- /dev/null +++ b/pkg/health/health.go @@ -0,0 +1,131 @@ +package health + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/OpeItcLoc03/factory/pkg/manifest" +) + +type Result struct { + Name string `json:"name"` + Healthy bool `json:"healthy"` + Version string `json:"version,omitempty"` + Message string `json:"message,omitempty"` +} + +func Check(comp *manifest.Component, vars map[string]string) (*Result, error) { + if comp.HealthCheck == nil { + return &Result{ + Name: comp.Name, + Healthy: true, + Message: "no health check defined", + }, nil + } + + // Substitute variables in path + path := comp.HealthCheck.Path + for k, v := range vars { + path = filepath.Clean(strings.ReplaceAll(path, "{"+k+"}", v)) + } + + switch comp.HealthCheck.Type { + case "file_exists": + return checkFileExists(comp.Name, path) + case "directory_exists": + return checkDirExists(comp.Name, path) + case "mcp_tool": + return checkMCPTool(comp.Name, comp.HealthCheck.Tool) + case "command": + return checkCommand(comp.Name, comp.HealthCheck.Command) + default: + return &Result{ + Name: comp.Name, + Healthy: false, + Message: fmt.Sprintf("unknown health check type: %s", comp.HealthCheck.Type), + }, nil + } +} + +func checkFileExists(name, path string) (*Result, error) { + if _, err := os.Stat(path); err == nil { + return &Result{Name: name, Healthy: true}, nil + } + return &Result{ + Name: name, + Healthy: false, + Message: fmt.Sprintf("file not found: %s", path), + }, nil +} + +func checkDirExists(name, path string) (*Result, error) { + if info, err := os.Stat(path); err == nil && info.IsDir() { + return &Result{Name: name, Healthy: true}, nil + } + return &Result{ + Name: name, + Healthy: false, + Message: fmt.Sprintf("directory not found: %s", path), + }, nil +} + +func checkMCPTool(name, tool string) (*Result, error) { + // Check if tool is available in ~/.claude.json + home, _ := os.UserHomeDir() + configPath := filepath.Join(home, ".claude.json") + + data, err := os.ReadFile(configPath) + if err != nil { + return &Result{ + Name: name, + Healthy: false, + Message: "claude.json not found", + }, nil + } + + // Simple string check (proper parsing would be better) + toolKey := `"` + tool + `"` + if strings.Contains(string(data), toolKey) { + return &Result{Name: name, Healthy: true}, nil + } + + return &Result{ + Name: name, + Healthy: false, + Message: fmt.Sprintf("MCP tool not found: %s", tool), + }, nil +} + +func checkCommand(name, cmd string) (*Result, error) { + var shell string + var args []string + + switch runtime.GOOS { + case "windows": + shell = "pwsh" + args = []string{"-Command", cmd} + default: + shell = "bash" + args = []string{"-c", cmd} + } + + execCmd := exec.Command(shell, args...) + output, err := execCmd.CombinedOutput() + if err != nil { + return &Result{ + Name: name, + Healthy: false, + Message: fmt.Sprintf("command failed: %s", output), + }, nil + } + + return &Result{ + Name: name, + Healthy: true, + Message: strings.TrimSpace(string(output)), + }, nil +} diff --git a/pkg/manifest/manifest.go b/pkg/manifest/manifest.go new file mode 100644 index 0000000..ac74fd4 --- /dev/null +++ b/pkg/manifest/manifest.go @@ -0,0 +1,123 @@ +package manifest + +import ( + "fmt" + "os" + "strings" + + "github.com/goccy/go-yaml" +) + +type GitSource struct { + Type string `yaml:"type"` + URL string `yaml:"url"` + Branch string `yaml:"branch"` +} + +type PostInstall struct { + Shell string `yaml:"shell"` + Script string `yaml:"script"` + ShellAlt string `yaml:"shell_alt"` + ScriptAlt string `yaml:"script_alt"` +} + +type HealthCheck struct { + Type string `yaml:"type"` + Path string `yaml:"path"` + Tool string `yaml:"tool"` + Command string `yaml:"command"` +} + +type Component struct { + Name string `yaml:"name"` + DisplayName string `yaml:"display_name"` + Source *GitSource `yaml:"source"` + Target string `yaml:"target"` + Deps []string `yaml:"deps"` + SetupSkill string `yaml:"setup_skill"` + PostInstall *PostInstall `yaml:"post_install"` + HealthCheck *HealthCheck `yaml:"health_check"` + Optional bool `yaml:"optional"` + Clients []string `yaml:"clients"` +} + +type Manifest struct { + Version string `yaml:"version"` + GitHost string `yaml:"git_host"` + GitOrg string `yaml:"git_org"` + Components []Component `yaml:"components"` +} + +func Load(path string, vars map[string]string) (*Manifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read manifest: %w", err) + } + + // Substitute variables + content := string(data) + for k, v := range vars { + content = strings.ReplaceAll(content, "{"+k+"}", v) + } + + var m Manifest + if err := yaml.Unmarshal([]byte(content), &m); err != nil { + return nil, fmt.Errorf("failed to parse manifest: %w", err) + } + + // Set defaults + if m.GitHost == "" { + m.GitHost = "https://github.com" + } + + return &m, nil +} + +func (m *Manifest) GetComponent(name string) (*Component, bool) { + for i := range m.Components { + if m.Components[i].Name == name { + return &m.Components[i], true + } + } + return nil, false +} + +func (m *Manifest) GetDependencyOrder() []string { + // Topological sort for component installation order + visited := make(map[string]bool) + order := []string{} + + var visit func(string) + visit = func(name string) { + if visited[name] { + return + } + visited[name] = true + + c, ok := m.GetComponent(name) + if ok { + for _, dep := range c.Deps { + visit(dep) + } + } + order = append(order, name) + } + + for _, c := range m.Components { + visit(c.Name) + } + + return order +} + +func (c *Component) IsApplicable(client string) bool { + if len(c.Clients) == 0 { + return true + } + for _, cl := range c.Clients { + if cl == client { + return true + } + } + return false +} diff --git a/scripts/build.ps1 b/scripts/build.ps1 new file mode 100644 index 0000000..e8eb54b --- /dev/null +++ b/scripts/build.ps1 @@ -0,0 +1,38 @@ +#!/usr/bin/env pwsh +# Cross-compile factory for all platforms (Windows-friendly) + +$ErrorActionPreference = "Stop" +$VERSION = if ($env:VERSION) { $env:VERSION } else { "0.1.0" } +$DIST_DIR = "dist" + +Push-Location (Split-Path $PSScriptRoot) +Write-Host "Building factory v$VERSION..." + +New-Item -ItemType Directory -Force -Path $DIST_DIR | Out-Null + +$Targets = @( + @{ Name = "linux-amd64"; GOOS = "linux"; GOARCH = "amd64" } + @{ Name = "linux-arm64"; GOOS = "linux"; GOARCH = "arm64" } + @{ Name = "windows-amd64"; GOOS = "windows"; GOARCH = "amd64" } + @{ Name = "windows-arm64"; GOOS = "windows"; GOARCH = "arm64" } + @{ Name = "darwin-amd64"; GOOS = "darwin"; GOARCH = "amd64" } + @{ Name = "darwin-arm64"; GOOS = "darwin"; GOARCH = "arm64" } +) + +foreach ($t in $Targets) { + $output = "$DIST_DIR/factory-$($t.GOOS)-$($t.GOARCH)" + if ($t.GOOS -eq "windows") { $output += ".exe" } + + Write-Host " → $($t.Name)" + + $env:GOOS = $t.GOOS + $env:GOARCH = $t.GOARCH + $env:CGO_ENABLED = "0" + + go build -ldflags="-s -w -X main.Version=$VERSION" -o $output ./cmd/factory +} + +Pop-Location + +Write-Host "`n✓ Built to $DIST_DIR/" +Get-ChildItem $DIST_DIR | Format-Table Name, Length diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 0000000..a668796 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Cross-compile factory for all platforms + +set -euo pipefail + +cd "$(dirname "$0")/.." +VERSION=${VERSION:-"0.1.0"} +DIST_DIR="dist" + +echo "Building factory v$VERSION..." + +mkdir -p "$DIST_DIR" + +# Cross-compile targets +declare -A TARGETS=( + ["linux-amd64"]="linux/amd64" + ["linux-arm64"]="linux/arm64" + ["windows-amd64"]="windows/amd64" + ["windows-arm64"]="windows/arm64" + ["darwin-amd64"]="darwin/amd64" + ["darwin-arm64"]="darwin/arm64" +) + +for name in "${!TARGETS[@]}"; do + goos="${name%%-*}" + goarch="${name##*-}" + platform="${TARGETS[$name]}" + output="$DIST_DIR/factory-$goos-$goarch" + [[ "$goos" == "windows" ]] && output="$output.exe" + + echo " → $name ($platform)" + + GOOS="$goos" GOARCH="$goarch" \ + CGO_ENABLED=0 \ + go build -ldflags="-s -w -X main.Version=$VERSION" \ + -o "$output" ./cmd/factory +done + +echo "" +echo "✓ Built to $DIST_DIR/" +ls -lh "$DIST_DIR/"