--- status: reviewed author: Claude version: 6 created_at: 2026-05-14 reviewed_by: [haiku, sonnet, opus] --- # Plan v3.5 — Bootstrap-wizard voor nieuwe Product-repo (Scrum4Me feature) ## Context Bij het aanmaken van een nieuw Product in Scrum4Me wil de user direct een GitHub-repo bootstrappen volgens canonical conventies (MD3-theme, ADR-systeem, docs-structuur, tooling). De catalogus van aanvinkbare opties + uitvoer-recepten leeft in de **database** (configureerbaar, audit-bar). Uitvoering gebeurt server-side via een **aparte `bootstrap-service`** — geen Claude-CLI, geen serverless fire-and-forget. **v3.2 verwerkt twee reviews + vijf pre-implementatie correcties + vijf clarificaties**: - **v1 review**: deterministic runtime, status-sync, schema-relaties, secret-boundary, action-validatie, ADR-coverage, route-groups, repo-slug - **v2 web-research review**: één executor-model, PAT-shape, dry-run als feature, owner-picker, action-permissions, catalog-versioning, conditie weg uit MVP, tag-pinning behouden - **Pre-implementatie correcties (v3.1)**: 1. Snake-case DB-tables via `@@map` 2. NOTIFY payload contract: `type: 'claude_job_status'`, `user_id` verplicht 3. `lease_until` veld (bestaand) voor lease-renewal 4. Git push via `isomorphic-git` (in-process credentials) 5. `bootstrap-service` als sibling-directory - **Pre-implementatie clarificaties (v3.2)**: 1. Shared package npm-publicatie: trigger op derde consumer of coordination-pijn 2. Recipe-hash determinisme: hash over `recipe_snapshot` (niet `selected_options`), canonicalized 3. Dry-run file-tree: gefilterd ignore-set + cap 500 entries 4. Template cache: geen cache in MVP; fase-2 disk-cache met TTL-sweep 5. Deployment target: multi-arch Dockerfile, Mac arm64 als primary - **Plan v3.3 verwerkt review-v3.2** (5 P1 + 6 P2): 1. **Claim-identiteit**: `claimed_by_worker_id String?` toegevoegd aan ClaudeJob (niet het bestaande `claimed_by_token_id` misbruiken) 2. **Shared package in Scrum4Me-repo**: `packages/bootstrap-actions/` binnen deze repo (geen secrets, deploybaar); bootstrap-service consumeert via release 3. **GitHub-side-effect checkpoints**: `github_repo_created_at`/`github_repo_id`/`github_repo_full_name`/`push_completed_at` + status `FAILED_NEEDS_CLEANUP` 4. **Stale-recovery kind-filter**: SQL altijd `AND kind='BOOTSTRAP_REPO'` 5. **Atomic enqueue**: pre-generated cuid IDs binnen transaction-array 6. **Cancel-safe terminal sync**: conditional `updateMany` voor success/failure 7. **`last_bootstrap_run_id`** met expliciete Prisma-relation + relation-name (om ambiguïteit met `Product.bootstrap_runs` te voorkomen) + `onDelete: SetNull` 8. **Action-permissions naar action-niveau**: `risk_level`/`requires_role` op `BootstrapAction`; option-level derived van max 9. **ID-strategie**: alle nieuwe modellen gebruiken `@default(cuid())` (consistent met 22 bestaande modellen) 10. **Classic PAT MVP**: scope-detectie via `x-oauth-scopes` werkt alleen voor classic; fine-grained PATs als open punt 11. **`.env.example` + deployment docs** in filelijst - **Plan v3.4 verwerkt review-v3.3** (3 P1 + 4 P2): 1. **Status-sync echt transactioneel**: één `prisma.$transaction(async tx => …)` callback met alle 3 updates + count-checks; lease_until + claimed_by_worker_id terminal op null 2. **Stale-recovery split**: `FAILED_NEEDS_CLEANUP` alleen bij `github_repo_full_name IS NOT NULL OR github_repo_created_at IS NOT NULL`; rest `FAILED` 3. **Geen `@paralleldrive/cuid2` dep**: transaction-callback vorm met door Prisma gegenereerde cuid's 4. `User.github_pat_scopes` krijgt `@default([])` voor migration-safety 5. NOTIFY-payload `status` is **lowercase** (`jobStatusToApi`-output); DB blijft UPPER_SNAKE 6. Stale-recovery komt naar **Sprint 1d** (MVP), niet pas fase-2 7. **Org-owner preflight via Octokit-call**: `RepoOwnerPicker` toont alleen owners waarvoor `octokit.repos.create…`-preflight slaagt; scope alleen is niet genoeg - **Plan v3.5 verwerkt review-v3.4** (4 P2 + 3 P3 — geen P1's; go-signaal na P2-verwerking): 1. **Org-owner preflight expliciet best-effort**: discovery toont owners; collision via `GET /repos/{owner}/{repo}`; finale autorisatie pas bij service-create; 403/422 → duidelijke wizard-fout; org-policy via `members_can_create_repositories` waar beschikbaar; ontbrekende info = "unknown" (niet automatisch verbergen) 2. **`syncRunning` timestamp-contract**: bootstrap_runs.started_at én claude_jobs.started_at in **dezelfde transaction** met **dezelfde `now`-waarde**; unit-test voor PENDING/CLAIMED → RUNNING 3. **`catalog_version` deterministisch**: canonical JSON over categories+options+actions, gesorteerd op display_order/slug/execution_order, alle relevante velden geïncludeerd, sha256 (niet md5) 4. **E2E verification-query** JOIN naar `claude_jobs` voor `lease_until` 5. **Stale-recovery globaal** (geen `claimed_by_worker_id`-filter); `claimed_by_worker_id` alleen voor renewal/observability 6. **Vendor-copy drift CI-check** als concrete sprint-taak in Sprint 1a + verificatie-stap; service logt geladen `ActionSchema`-hash bij startup 7. **`ADD_DEPENDENCY.version` regex**: MVP expliciet "alleen exact/range semver"; fase-2 `npm-package-arg`-parser voor `latest`/prerelease/`workspace:*`/`npm:`-aliases --- ## Architectuur-besluiten | # | Onderwerp | Keuze | |---|---|---| | 1 | Scope | Server doet GitHub-side via **Octokit** (repo-create) + **isomorphic-git** (clone + push) | | 2 | DB-model | Declaratieve recepten + Zod-validatie + action-permissions | | 3 | Wizard | Gemengd radio/checkbox + **dry-run preview-stap** | | 4 | Uitvoer | `ClaudeJobKind.BOOTSTRAP_REPO` voor uniforme UI/status; **aparte `bootstrap-service` claimt** | | 5 | GitHub-auth | Per-user PAT (encrypted); service decrypt per run binnen execution-boundary | | 6 | Schema | Product (`repo_owner`, `repo_slug`, `template_version`, …) + `BootstrapRun` + versioning-velden | | 7 | UX | Twee-staps: Product → wizard (Configure → Preview → Run) | | 8 | Catalog mgmt | Hybride — seed canonical, admin-UI fase-2 met `recipe_hash`-publish | --- ## Executor: nieuwe `bootstrap-service` (sibling-directory) ### Locatie **Sibling-folder**: `~/Development/bootstrap-service/` — naast `Scrum4Me/`, `scrum4me-mcp/`, `scrum4me-docker/`. Eigen `package.json`, `tsconfig.json`, `Dockerfile`, `prisma/schema.prisma` (gesynced via `sync-schema.sh` zoals scrum4me-mcp). **Niet**: - In deze repo (zou worker-secrets in app-build mengen) - Monorepo-package (deze codebase heeft geen monorepo-tooling; zou aparte tooling-investering vereisen) ### Environment (`bootstrap-service/env.ts`) ```ts const Env = z.object({ DATABASE_URL: z.string().url(), DIRECT_URL: z.string().url(), // LISTEN/NOTIFY BOOTSTRAP_ENCRYPTION_KEY: z.string().min(32), // AES-256-GCM key (gedeeld met app) BOOTSTRAP_TEMPLATE_REPO: z.string().default('madhura68/nextjs-baseline'), // GEEN ANTHROPIC_API_KEY, SESSION_SECRET, CRON_SECRET }) ``` ### Claim-protocol (gebruikt bestaand `lease_until` + nieuw `claimed_by_worker_id`) ClaudeJob krijgt een nieuw veld `claimed_by_worker_id String?` (separaat van bestaand `claimed_by_token_id` dat voor ApiToken-claim wordt gebruikt). Worker-ID is een unieke service-instance-identifier (`${hostname}-${pid}-${startTs}`). ```ts // In bootstrap-service/src/claim.ts: const row = await prisma.$queryRaw<{ id: string }[]>` UPDATE claude_jobs SET status = 'CLAIMED', lease_until = NOW() + INTERVAL '60 seconds', claimed_at = NOW(), claimed_by_worker_id = ${WORKER_ID} WHERE id = ( SELECT id FROM claude_jobs WHERE status = 'QUEUED' AND kind = 'BOOTSTRAP_REPO' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1 ) RETURNING id ` ``` **Lease-renewal**: setInterval(30s) doet `UPDATE claude_jobs SET lease_until = NOW() + INTERVAL '60 seconds' WHERE id = $1 AND claimed_by_worker_id = ${WORKER_ID}` (only-mine-guard). **Stale-recovery** — **strikt kind-gefilterd én split op externe side-effects**. Hoort in **Sprint 1d (MVP)**, niet pas fase-2; zonder dit kan een service-crash een job langdurig in `CLAIMED`/`RUNNING` laten hangen. ```sql -- Stap 1: markeer verlopen BOOTSTRAP_REPO jobs als FAILED (DB-side) UPDATE claude_jobs SET status='FAILED', error='lease expired', finished_at=NOW(), lease_until=NULL, claimed_by_worker_id=NULL WHERE status IN ('CLAIMED','RUNNING') AND kind = 'BOOTSTRAP_REPO' AND lease_until < NOW(); -- Stap 2a: runs met external side-effects → FAILED_NEEDS_CLEANUP (orphan repo mogelijk) UPDATE bootstrap_runs SET status='FAILED_NEEDS_CLEANUP', error='lease expired', finished_at=NOW() WHERE status IN ('PENDING','RUNNING') AND claude_job_id IN ( SELECT id FROM claude_jobs WHERE status='FAILED' AND kind='BOOTSTRAP_REPO' AND error='lease expired' ) AND (github_repo_full_name IS NOT NULL OR github_repo_created_at IS NOT NULL); -- Stap 2b: runs zonder external side-effects → FAILED (clean failure) UPDATE bootstrap_runs SET status='FAILED', error='lease expired', finished_at=NOW() WHERE status IN ('PENDING','RUNNING') AND claude_job_id IN ( SELECT id FROM claude_jobs WHERE status='FAILED' AND kind='BOOTSTRAP_REPO' AND error='lease expired' ) AND github_repo_full_name IS NULL AND github_repo_created_at IS NULL; ``` Bestaande Claude-runner cleanup (in `app/api/cron/cleanup-agent-artifacts/route.ts`) blijft ongemoeid — dit is een dedicated SQL-pad voor BOOTSTRAP_REPO. **Wanneer draait dit**: - In MVP via een dedicated cron-route (`app/api/cron/bootstrap-stale-recovery/route.ts`) elke 5 minuten, getrigggerd door Vercel-cron of de bestaande cron-runner. Bearer-secret-protected zoals andere cron-routes. - `bootstrap-service` draait dezelfde SQL bij startup als **globale recovery** voor alle verlopen `BOOTSTRAP_REPO`-leases (van wie dan ook). **Niet** filteren op `claimed_by_worker_id` — een herstartende service heeft een nieuw `worker_id` (hostname + pid + start-timestamp) en zou zijn eigen oude leases anders niet matchen. `claimed_by_worker_id` is dus puur voor lease-renewal-only-mine-guard en observability/log-correlatie; stale-recovery is `kind`- en `lease_until`-gebaseerd. **LISTEN-fallback**: service luistert op `scrum4me_changes` filtered op nieuwe enqueues (`type: 'claude_job_enqueued'`, `kind: 'BOOTSTRAP_REPO'`); poll-interval 30s als safety. ### `scrum4me-docker` skip-filter `scrum4me-docker/bin/run-one-job.ts` claim-query toevoegen: `AND kind <> 'BOOTSTRAP_REPO'`. Twee runners delen de `claude_jobs`-tabel zonder overlap. --- ## Status-sync (transactional + post-commit NOTIFY) ```ts // In bootstrap-service/src/status-sync.ts: import { jobStatusToApi } from '@/lib/job-status' // gespiegeld via shared-package of bootstrap-service-eigen kopie async function syncSuccess(runId: string, jobId: string, productId: string, userId: string, repoUrl: string, templateVersion: string) { // ÉÉN transaction; geen partial commits. const result = await prisma.$transaction(async (tx) => { const runUpdate = await tx.bootstrapRun.updateMany({ where: { id: runId, status: 'RUNNING' }, data: { status: 'SUCCEEDED', finished_at: new Date(), repo_url: repoUrl, push_completed_at: new Date() }, }) if (runUpdate.count === 0) { // Was al CANCELLED of FAILED — abort, geen verdere writes return { committed: false as const } } const jobUpdate = await tx.claudeJob.updateMany({ where: { id: jobId, status: 'RUNNING' }, data: { status: 'DONE', finished_at: new Date(), summary: `Bootstrap completed: ${repoUrl}`, lease_until: null, claimed_by_worker_id: null, }, }) if (jobUpdate.count === 0) { // Race: job is buiten verwachting al terminal — gooi om transaction te rollbacken throw new Error('job-state-mismatch') } await tx.product.update({ where: { id: productId }, data: { repo_url: repoUrl, template_version: templateVersion, last_bootstrap_run_id: runId }, }) return { committed: true as const } }) if (!result.committed) return // niets te notifyen // NA commit: await prisma.$executeRaw` SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ type: 'claude_job_status', job_id: jobId, user_id: userId, kind: 'BOOTSTRAP_REPO', status: jobStatusToApi('DONE'), // 'done' lowercase per bestaand contract bootstrap_run_id: runId, repo_url: repoUrl, })}::text) ` } ``` **Belangrijke eigenschappen**: - **Echt transactioneel**: bootstrap_runs, claude_jobs, en products updates zitten in dezelfde DB-transaction. Faal in een willekeurige stap → volledige rollback. Geen partial-committed-state mogelijk. - **Cancel-safe**: status-filter in `where` zorgt dat een `CANCELLED`-overgang die tussendoor gebeurde niet door late terminal-write wordt overschreven. - **Lease-cleanup terminal**: `lease_until` en `claimed_by_worker_id` worden expliciet `null` gezet bij terminal status; voorkomt dat stale-recovery deze record nog "ziet". - **NOTIFY na commit**: pas na succesvolle commit; rollback betekent geen NOTIFY. - **Status lowercase in payload**: `jobStatusToApi('DONE') → 'done'` matched bestaande SSE-clients; DB blijft UPPER_SNAKE. Voor `syncFailed`: identieke vorm, met aanvullende beslislogica voor terminal-status: ```ts const terminalRunStatus = (run.github_repo_full_name || run.github_repo_created_at) ? 'FAILED_NEEDS_CLEANUP' : 'FAILED' ``` Voor `syncRunning`: **cancel-safe** analoog aan syncSuccess én **timestamp-contract expliciet**. Beide tabellen krijgen `started_at = now` in dezelfde transaction met dezelfde `now`-waarde, zodat downstream metrics, UI-sortering en E2E-queries betrouwbaar zijn. ```ts async function syncRunning(runId: string, jobId: string, userId: string) { const now = new Date() // één waarde, gedeeld tussen run + job const result = await prisma.$transaction(async (tx) => { const runUpdate = await tx.bootstrapRun.updateMany({ where: { id: runId, status: 'PENDING' }, data: { status: 'RUNNING', started_at: now }, }) if (runUpdate.count === 0) return { committed: false as const } // CANCELLED ertussen const jobUpdate = await tx.claudeJob.updateMany({ where: { id: jobId, status: 'CLAIMED' }, data: { status: 'RUNNING', started_at: now }, }) if (jobUpdate.count === 0) throw new Error('job-state-mismatch') // rollback return { committed: true as const } }) if (!result.committed) return await prisma.$executeRaw` SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ type: 'claude_job_status', job_id: jobId, user_id: userId, kind: 'BOOTSTRAP_REPO', status: jobStatusToApi('RUNNING'), // 'running' bootstrap_run_id: runId, })}::text) ` } ``` **Verplichte unit-test** (Sprint 1a): `bootstrap_runs.started_at == claude_jobs.started_at` na `syncRunning`; allebei niet-null; transitie PENDING/CLAIMED → RUNNING. **GitHub side-effect checkpoint writes** (apart van terminal-sync, na elke external mutatie): ```ts // Na Octokit createForAuthenticatedUser: await prisma.bootstrapRun.update({ where: { id: runId }, data: { github_repo_created_at: new Date(), github_repo_id: repo.id, github_repo_full_name: repo.full_name, }, }) // Na isomorphic-git push success: await prisma.bootstrapRun.update({ where: { id: runId }, data: { push_completed_at: new Date() }, }) ``` Dit zorgt dat na een service-crash een stale-recovery weet wat er extern gebeurd is en kan beslissen tussen compensating delete vs. `FAILED_NEEDS_CLEANUP`-flag. **Payload-contract** matched bestaand `JobPayload`-type uit `app/api/realtime/jobs/route.ts`: ```ts type JobPayload = { type: 'claude_job_enqueued' | 'claude_job_status' job_id: string user_id: string // verplicht voor SSE-filter kind?: string // 'BOOTSTRAP_REPO' status: string bootstrap_run_id?: string // nieuw extension-veld voor deze kind task_id?: null idea_id?: null sprint_run_id?: null ... } ``` `bootstrap_run_id` is een **additieve uitbreiding**; bestaande consumers negeren onbekende velden veilig. --- ## Git operaties: `isomorphic-git` (pure-JS) **Waarom isomorphic-git, niet shell `git`**: - Geen subprocess in service-container - PAT als HTTP-header in-process (`onAuth` callback), nooit in URL of shell-argv - Geen credential-helper-state op disk - Werkt op alle platforms zonder git-binary-dependency ```ts import * as git from 'isomorphic-git' import http from 'isomorphic-git/http/node' import fs from 'fs' // Clone template (tag-pinned): await git.clone({ fs, http, dir: tmpdir, url: `https://github.com/${BOOTSTRAP_TEMPLATE_REPO}.git`, ref: templateVersion, // bv. 'v1.0.0' singleBranch: true, depth: 1, }) const sourceSha = await git.resolveRef({ fs, dir: tmpdir, ref: 'HEAD' }) // (recipe-acties muteren tmpdir hier) // Init opnieuw als clean git-history voor target: await fs.promises.rm(`${tmpdir}/.git`, { recursive: true, force: true }) await git.init({ fs, dir: tmpdir, defaultBranch: 'main' }) await git.add({ fs, dir: tmpdir, filepath: '.' }) await git.commit({ fs, dir: tmpdir, message: `Bootstrap: ${selectedOptionsSummary}\n\nFrom template ${BOOTSTRAP_TEMPLATE_REPO}@${templateVersion}`, author: { name: user.github_username ?? 'Scrum4Me', email: 'bootstrap@scrum4me.dev' }, }) // Create remote repo via Octokit: const octokit = new Octokit({ auth: pat }) const { data: repo } = await octokit.repos.createForAuthenticatedUser({ // of createInOrg name: repoSlug, private: true, auto_init: false, }) // Push via isomorphic-git met token in onAuth-callback (nooit in URL): await git.addRemote({ fs, dir: tmpdir, remote: 'origin', url: repo.clone_url }) await git.push({ fs, http, dir: tmpdir, remote: 'origin', ref: 'main', onAuth: () => ({ username: 'x-access-token', password: pat }), // header-only }) ``` `pat` blijft binnen function-scope; `onAuth` returnt closure-scoped value zonder logging. --- ## Domein-model (Prisma) — met `@@map` ### `BootstrapCategory` → table `bootstrap_categories` ```prisma model BootstrapCategory { id String @id @default(cuid()) slug String @unique label String description String? selection_type BootstrapSelectionType // SINGLE | MULTI display_order Int is_required Boolean @default(false) options BootstrapOption[] created_at DateTime @default(now()) updated_at DateTime @updatedAt @@map("bootstrap_categories") } ``` ### `BootstrapOption` → table `bootstrap_options` **Action-permissions verplaatst naar `BootstrapAction`** (review-fix). `BootstrapOption` houdt alleen catalog-eigenschappen; risk/role is per action. Option-level "effective risk" wordt server-side afgeleid (`MAX(action.risk_level) FOR action IN option.actions`) maar niet als kolom opgeslagen — alleen runtime-computed waar nodig. ```prisma model BootstrapOption { id String @id @default(cuid()) category_id String category BootstrapCategory @relation(fields: [category_id], references: [id], onDelete: Cascade) slug String label String description String? is_default Boolean @default(false) display_order Int archived Boolean @default(false) enabled Boolean @default(true) actions BootstrapAction[] @@unique([category_id, slug]) @@index([category_id, display_order]) @@map("bootstrap_options") } ``` ### `BootstrapAction` → table `bootstrap_actions` ```prisma model BootstrapAction { id String @id @default(cuid()) option_id String option BootstrapOption @relation(fields: [option_id], references: [id], onDelete: Cascade) kind BootstrapActionKind params Json // Zod-validated per kind execution_order Int supports_dry_run Boolean @default(true) side_effects SideEffect[] // FILESYSTEM | GITHUB_REPO | GITHUB_SETTINGS | NETWORK risk_level RiskLevel @default(LOW) // verplaatst van Option requires_role RoleRequired @default(ANY) // verplaatst van Option @@index([option_id, execution_order]) @@map("bootstrap_actions") } ``` Action-permissions worden bij start-action gevalideerd: `RUN_BASH_TEMPLATE` action met `requires_role=ADMIN` blokkeert non-admin users in `startBootstrapAction`. Dry-run-validatie idem (Backstage-pattern). **`condition` veld weggelaten uit MVP** — mini-DSL in fase-2. ### `BootstrapRun` → table `bootstrap_runs` **GitHub-side-effect checkpoints toegevoegd** (review-fix) — durable record van externe mutaties zodat crash-recovery weet wat opgeruimd moet worden. ```prisma model BootstrapRun { id String @id @default(cuid()) product_id String product Product @relation(name: "ProductBootstrapRuns", fields: [product_id], references: [id], onDelete: Cascade) user_id String user User @relation(fields: [user_id], references: [id]) claude_job_id String? @unique claude_job ClaudeJob? @relation(name: "BootstrapRunJob", fields: [claude_job_id], references: [id], onDelete: SetNull) status BootstrapRunStatus // PENDING | RUNNING | SUCCEEDED | FAILED | CANCELLED | FAILED_NEEDS_CLEANUP template_version String template_source_sha String? catalog_version String recipe_hash String action_schema_version String repo_owner_snapshot String repo_slug_snapshot String selected_options Json recipe_snapshot Json dry_run_report Json? // GitHub side-effect checkpoints (durable mutaties) github_repo_created_at DateTime? github_repo_id BigInt? // GitHub's numeric repo id — JSON.stringify() gooit TypeError; cast via .toString() of Number() bij API-serialisatie github_repo_full_name String? // 'owner/repo' push_completed_at DateTime? repo_url String? // gevuld na push_completed_at started_at DateTime? finished_at DateTime? error String? @db.VarChar(8192) output_log String? created_at DateTime @default(now()) @@index([product_id, status]) @@index([user_id, created_at]) @@index([status, finished_at]) // voor stale-recovery sweep @@map("bootstrap_runs") } enum BootstrapRunStatus { PENDING RUNNING SUCCEEDED FAILED // recoverable / no orphan side-effects FAILED_NEEDS_CLEANUP // orphan GitHub-repo or partial push; manual or compensating CANCELLED } ``` **Partial unique index** (raw SQL toegevoegd in migration): ```sql CREATE UNIQUE INDEX bootstrap_runs_one_active_per_product ON bootstrap_runs (product_id) WHERE status IN ('PENDING','RUNNING'); ``` ### `Product` uitbreiding (table `products`, `@@map` blijft) ```prisma model Product { // ... bestaande velden ... repo_owner String? repo_slug String? template_version String? last_bootstrap_run_id String? last_bootstrap_run BootstrapRun? @relation(name: "ProductLastBootstrapRun", fields: [last_bootstrap_run_id], references: [id], onDelete: SetNull) bootstrap_runs BootstrapRun[] @relation(name: "ProductBootstrapRuns") // history @@unique([repo_owner, repo_slug]) // ... bestaande @@map("products") ... } ``` Twee expliciete relaties met disjoint relation-names: `ProductLastBootstrapRun` voor de huidige pointer (SetNull bij delete van de run), `ProductBootstrapRuns` voor de history (Cascade bij delete van de product). Prisma vereist named relations bij meerdere relaties tussen dezelfde modellen. ### `User` uitbreiding (table `users`) ```prisma model User { // ... bestaande velden ... github_pat_encrypted String? // prefix 'v1:' github_username String? github_pat_verified_at DateTime? github_pat_scopes String[] @default([]) // default-array voor migration-safety op bestaande users github_pat_expires_at DateTime? github_orgs Json? bootstrap_runs BootstrapRun[] // ... bestaande @@map("users") ... } ``` `@default([])` zorgt dat de migration op een bestaande database met users geen backfill nodig heeft; bestaande rijen krijgen een lege array. ### `ClaudeJob` uitbreiding (table `claude_jobs`) ```prisma model ClaudeJob { // ... bestaande velden, inclusief lease_until + claimed_by_token_id ... claimed_by_worker_id String? // NIEUW: voor bootstrap-service (en toekomstige worker-types) bootstrap_run BootstrapRun? @relation(name: "BootstrapRunJob") // ... bestaande @@map("claude_jobs") ... } ``` `claimed_by_worker_id` blijft naast `claimed_by_token_id` (bestaand): laatste is voor ApiToken-claim door de Claude-CLI runner, eerste is een free-form service-instance-identifier voor `bootstrap-service`. Geen FK; pure log/correlation-veld. `ClaudeJobKind` enum: `BOOTSTRAP_REPO` toegevoegd. ### Env (`lib/env.ts`) - App: `BOOTSTRAP_ENCRYPTION_KEY` (required, min 32), `BOOTSTRAP_TEMPLATE_REPO` (default `madhura68/nextjs-baseline`) --- ## PAT-secret-boundary **`startBootstrapAction` decrypt nooit**: - Bewaart geen plaintext PAT - Geeft alleen `runId` mee aan executie **`bootstrap-service` decrypt per run** binnen execution-scope: ```ts const run = await prisma.bootstrapRun.findUnique({ where:{ id: runId }, include:{ user: true }}) let pat = decryptPat(run.user.github_pat_encrypted, env.BOOTSTRAP_ENCRYPTION_KEY) try { await executeRecipe(run, pat) } finally { pat = ''; /* GC */ } ``` **Test-flow voor PAT** (`saveGitHubPatAction`) — **classic PAT in MVP**: ```ts const octokit = new Octokit({ auth: token }) const { data: me, headers } = await octokit.rest.users.getAuthenticated() const scopes = (headers['x-oauth-scopes'] ?? '').split(',').map(s => s.trim()).filter(Boolean) if (!scopes.includes('repo')) { throw new Error('Classic PAT met scope "repo" vereist. Fine-grained PATs nog niet ondersteund — zie open punten.') } // Encrypt + opslaan + verified_at/scopes ``` **Fine-grained PATs werken anders** — geen `x-oauth-scopes` header, wel `x-accepted-github-permissions` of repository-permission-set via `GET /user/installations`. Voor MVP **alleen classic PAT ondersteund**; settings-UI toont dit expliciet ("Vereist een classic PAT met `repo` scope — fine-grained tokens nog niet ondersteund."). Fine-grained support staat in open punten. --- ## Dry-run / preview (eerste-klas) > **Implementatienoot**: `previewBootstrapAction` doet een git clone + recipe-run (~2-5s). Als dit te zwaar wordt voor een Next.js Server Action (Vercel function geheugendruk bij hoog volume), migreer naar een Route Handler (`POST /api/bootstrap/preview`) met streaming response. In MVP is Server Action acceptabel; monitor Vercel function-metrics. `previewBootstrapAction(productId, selections, repoOwner, repoSlug)`: 1. Auth + demo-check (403) 2. Zod-validate selections + GitHub-name regex 3. Resolve recipe + compute `recipe_hash` + `catalog_version` 4. Spin up tmpdir + clone template (geen cache in MVP — zie Template-cache-sectie) 5. Run alle handlers met `supports_dry_run=true` tegen tmpdir; `RUN_BASH_TEMPLATE` logged als "skipped" 6. Octokit preflight: `octokit.repos.get({ owner, repo })` om collision te detecteren 7. Octokit preflight: `octokit.orgs.list...` om owner-rechten te valideren 8. **File-tree filter en cap** (zie hieronder) 9. Retourneer `DryRunReport`: ```ts { fileTree: string[]; truncated: boolean; actionLog: Array<{ kind, summary, status }>; warnings: string[]; canProceed: boolean; collisions: { owner: string; slug: string } | null } ``` Geen DB-write, geen GitHub-write. Wel telemetry. ### Org-owner preflight — **best-effort discovery** (geen harde create-permission proof) De wizard heeft drie verschillende garanties op verschillende plekken; **alleen de service-side `octokit.repos.create*`-call is de finale autorisatie**. Eerdere checks zijn best-effort hints, niet bewijzen. **Stap 1: Best-effort owner-discovery** (in `RepoOwnerPicker`) ```ts // Voor user-owner: altijd tonen indien PAT geldig await octokit.users.getAuthenticated() // moet slagen voor PAT-validiteit // Voor elke org uit github_orgs cache: best-effort metadata-fetch try { const { data: org } = await octokit.orgs.get({ org: orgLogin }) return { login: orgLogin, member_status: 'visible', members_can_create_private: org.members_can_create_private_repositories ?? null, // null = onbekend members_creation_type: org.members_allowed_repository_creation_type ?? null, } } catch (err) { // 404 / 403 = onbekend, niet automatisch verbergen return { login: orgLogin, member_status: 'unknown' } } ``` De UI toont **alle** zichtbare orgs (plus user) met een hint-badge: "✓ kan repos maken" / "⚠ org-policy onbekend" / "⚠ org-policy blokkeert private repos". Geen owner wordt **automatisch verborgen** op basis van twijfelachtige info — dat zou false negatives (legitieme orgs verborgen) creëren. **Stap 2: Collision-check** vóór wizard-submit ```ts try { await octokit.repos.get({ owner, repo }) // 200 = collision; 404 = vrij return { canProceed: false, collision: { owner, repo }} } catch (err) { if (err.status === 404) return { canProceed: true } return { canProceed: false, reason: 'preflight-failed' } } ``` **Stap 3: Finale autorisatie** = de werkelijke `octokit.repos.createForAuthenticatedUser`/`createInOrg` in `bootstrap-service`. GitHub-antwoord is de waarheid: - `201 Created` → succes, ga door - `403 Forbidden` → vertaal naar wizard-fout "Geen rechten om repo te maken in ``. Mogelijke oorzaken: SSO niet geautoriseerd, org-policy blokkeert, of PAT mist scope. [Wijzig owner] [Wijzig PAT]" - `422 Unprocessable Entity` → typisch naam-conflict (race vs. stap 2); zelfde wizard-fout met "naam al in gebruik" - Andere fouten → generieke "GitHub-fout: " + retry-knop **Documenteer dit expliciet in `RepoOwnerPicker`-tooltip**: "Owners worden best-effort gedetecteerd. Sommige org-policies (SSO, admin-only-repo-create) zijn niet vooraf zichtbaar; de daadwerkelijke create-actie is de finale check." Scope `repo` blijft een **noodzakelijke** voorwaarde (gecheckt bij `saveGitHubPatAction`), maar de UI gebruikt geen scope-alleen heuristiek om owners te verbergen. ### File-tree scope (filter + cap) `fileTree` is **gefilterd** anders wordt het onleesbaar (~200+ files in een Next.js-template). Ignore-patterns (gitignore-stijl): ``` .git/ node_modules/ .next/ dist/ build/ out/ *.log .DS_Store .env* coverage/ ``` **Hard cap** van 500 entries; bij overschrijding `truncated: true` met indicator `[+12 more files omitted]`. MVP: flat `string[]`. Fase-2 kan migreren naar: ```ts type DryRunFileTreeNode = { name: string; type: 'file' | 'dir'; children?: DryRunFileTreeNode[]; size?: number } ``` ### Template-cache lifecycle **MVP: geen cache** — `git clone --depth=1` met `isomorphic-git` is snel (~2-3s op kleine templates); cache-complexity is YAGNI tot het pijn doet. **Fase-2 strategie** (als preview-latency UX-probleem wordt): - Disk-cache in `/var/cache/bootstrap-service///` - Persistent across service-restarts (snelle warm-start) - **Niet** clearen op deploy (deploys frequent, cache-warmup duur) - **Geen invalidation nodig** voor tag-pinned versies (semver-tags zijn immutable; re-tag is mis-use) - TTL-sweep via `last_used.json` mark-file per cached version; cron verwijdert versies >30 dagen ongebruikt --- ## Catalog/recipe versioning ### Hash-input bepaling (determinisme) `recipe_hash` wordt berekend over **`recipe_snapshot`** (de resolved action-list), **niet** over `selected_options`. Reden: identieke `selected_options` met een andere catalog-versie produceert andere acties → andere uitkomst; de hash moet dat onderscheid tonen. `catalog_version` blijft een orthogonaal apart veld. Canonicalization-regels (in `packages/bootstrap-actions/recipe-hash.ts`): ```ts function canonicalize(recipe: RecipeSnapshot): string { const sorted = { actions: recipe.actions .sort((a, b) => a.execution_order - b.execution_order) .map(a => ({ kind: a.kind, execution_order: a.execution_order, params: sortObjectKeysRecursive(a.params), })), } return JSON.stringify(sorted) } export function recipeHash(recipe: RecipeSnapshot): string { return createHash('sha256').update(canonicalize(recipe)).digest('hex') } ``` Geen timestamps, geen UUIDs in de hash-input. Identieke recipe ⇔ identieke hash, garandeert deterministische replay. ### Velden op `BootstrapRun` - `recipe_hash` = sha256(canonicalize(recipe_snapshot)) - `catalog_version` = sha256(canonicalize(catalog_snapshot)) — zelfde discipline als recipe_hash - `action_schema_version` = hardcoded in shared package; bumped bij schema-breaks - `template_source_sha` = git SHA na clone ### Deterministische `catalog_version` berekening Niet `md5(string_agg(...))` — dat is zonder ordering niet deterministisch en mist categories/actions. Gebruik dezelfde canonical-JSON-aanpak als `recipe_hash`: ```ts // In packages/bootstrap-actions/catalog-hash.ts export function catalogVersion(catalog: CatalogSnapshot): string { const sorted = { categories: catalog.categories .sort((a, b) => a.display_order - b.display_order || a.slug.localeCompare(b.slug)) .map(cat => ({ slug: cat.slug, selection_type: cat.selection_type, is_required: cat.is_required, display_order: cat.display_order, options: cat.options .sort((a, b) => a.display_order - b.display_order || a.slug.localeCompare(b.slug)) .map(opt => ({ slug: opt.slug, is_default: opt.is_default, enabled: opt.enabled, archived: opt.archived, display_order: opt.display_order, actions: opt.actions .sort((a, b) => a.execution_order - b.execution_order || a.id.localeCompare(b.id)) .map(act => ({ kind: act.kind, execution_order: act.execution_order, params: sortObjectKeysRecursive(act.params), supports_dry_run: act.supports_dry_run, side_effects: [...act.side_effects].sort(), risk_level: act.risk_level, requires_role: act.requires_role, })), })), })), } return createHash('sha256').update(JSON.stringify(sorted)).digest('hex') } ``` Wijzigingen in **elk** van deze velden (selection_type, required/default, enabled/archived, action kind, params, dry-run support, side effects, risk_level, requires_role) leveren een nieuwe `catalog_version`. Geen md5; sha256 voor consistentie met `recipe_hash`. SQL-loading: `SELECT * FROM bootstrap_categories WHERE archived=false ORDER BY display_order, slug` + nested fetches; transformatie in TypeScript via `catalogVersion()`. ### `.scrum4me/bootstrap.json` in target-repo (na succes) ```json { "template_repo": "...", "template_version": "v1.0.0", "template_source_sha": "...", "catalog_version": "...", "recipe_hash": "...", "action_schema_version": "1.0", "generated_at": "...", "selected_options": {...} } ``` --- ## UI-componenten (`app/(app)/...`) | Component | Locatie | |---|---| | `BootstrapWizardDialog` | `app/(app)/products/[id]/_components/bootstrap-wizard-dialog.tsx` | | `BootstrapWizardStep` | idem (per-categorie) | | `RepoOwnerPicker` | idem | | `BootstrapPreviewPanel` | idem | | `BootstrapStatusPanel` | idem (SSE-status) | | `GitHubPatSettings` | `app/(app)/settings/_components/github-pat-settings.tsx` | | `BootstrapAdminPage` (fase-2) | `app/(app)/admin/bootstrap/page.tsx` | | Product-detail-knop | `app/(app)/products/[id]/page.tsx` | Wizard-flow: **Configure → Preview → Run**. --- ## Server-actions (`actions/bootstrap.ts`) - `previewBootstrapAction` → `DryRunReport` (**Sprint 1c**) - `startBootstrapAction` → `{ runId }`; gebruikt partial unique index voor concurrency (**Sprint 1c**) - `cancelBootstrapAction(runId)` → markeert `ClaudeJob.status='CANCELLED'`; service detecteert per-action (**Fase 2** — stub in MVP, geen UI-knop) - `retryBootstrapAction(failedRunId)` → nieuwe run met zelfde selections (**Fase 2**) - `saveGitHubPatAction(token)` → encrypt + verify + scope-detect + opslaan (**Sprint 1c**) Alle vijf: demo-check (403) + Zod + rate-limit. In MVP worden `cancelBootstrapAction` en `retryBootstrapAction` als gated stub geïmplementeerd (403 of 501) maar nog niet aangeroepen via UI. Atomisch enqueue via **transaction callback** — Prisma genereert beide cuid's intern, geen externe `cuid2`-dependency nodig: ```ts import { jobStatusToApi } from '@/lib/job-status' const { jobId, runId } = await prisma.$transaction(async (tx) => { const job = await tx.claudeJob.create({ data: { kind: 'BOOTSTRAP_REPO', status: 'QUEUED', user_id: userId, product_id: productId, // requested_* allemaal null (deterministic runtime) }, select: { id: true }, }) const run = await tx.bootstrapRun.create({ data: { product_id: productId, user_id: userId, claude_job_id: job.id, status: 'PENDING', template_version, catalog_version, recipe_hash, action_schema_version, repo_owner_snapshot, repo_slug_snapshot, selected_options, recipe_snapshot, }, select: { id: true }, }) return { jobId: job.id, runId: run.id } }) // NA commit (niet IN transaction): await prisma.$executeRaw` SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ type: 'claude_job_enqueued', job_id: jobId, user_id: userId, kind: 'BOOTSTRAP_REPO', status: jobStatusToApi('QUEUED'), // 'queued' lowercase bootstrap_run_id: runId, })}::text) ` return { runId } ``` Concurrency wordt afgedwongen door partial unique index — een tweede gelijktijdige insert mislukt met unique violation. Geen externe ID-library nodig; Prisma's built-in `cuid()` default doet het werk. --- ## Shared action-package (in **Scrum4Me-repo**) **Locatie**: `packages/bootstrap-actions/` **binnen de Scrum4Me-repo** (review-fix). Reden: dit package bevat geen secrets, alleen Zod-schema's en pure-JS file-handlers. Het mag dus mee in de Scrum4Me build — een `file:` link naar een sibling-directory zou de app onbouwbaar maken in Vercel/CI (sibling zit niet in de checkout). **Inhoud**: - `schema.ts` — `ActionSchema` (Zod discriminated union) - `handlers/{copy-file,write-file,append-to-file,replace-string,create-adr-stub,add-dependency,run-bash}.ts` - `allowed-commands.ts` — RUN_BASH_TEMPLATE regex-array - `recipe-hash.ts` — canonicalize + sha256 - `types.ts` — gedeelde DryRunReport, ActionContext, etc. - `package.json` met `"name": "@scrum4me/bootstrap-actions"`, `"version": "0.1.0"` **Consumers**: - Scrum4Me-app: `lib/bootstrap/dry-run.ts` importeert `@scrum4me/bootstrap-actions` via workspace-resolution - `bootstrap-service/src/runner.ts`: idem; consumeert via release (zie hieronder) **Hoe Scrum4Me-app het package gebruikt**: - Als de Scrum4Me-repo één package.json heeft (geen workspaces): bouw als pure TS-bibliotheek in `packages/bootstrap-actions/` en consumeer via path-alias of monorepo-light setup - Als workspaces wel beschikbaar zijn: voeg `"workspaces": ["packages/*"]` toe aan root `package.json` (minimal pnpm/npm workspaces; geen Turborepo nodig) - MVP-pragmatisch: TypeScript path-alias `@scrum4me/bootstrap-actions` → `./packages/bootstrap-actions/src` in `tsconfig.json`, bundlen via Next.js default. **Let op**: ook `vitest.config.ts` krijgt `resolve.alias: { '@scrum4me/bootstrap-actions': './packages/bootstrap-actions/src' }` anders falen unit-tests op dit package. **Hoe bootstrap-service het consumeert**: - **Optie A (MVP)**: `bootstrap-service` doet bij iedere release een **vendor-copy** van het package via een sync-script (`scripts/sync-bootstrap-actions.sh` — vergelijkbaar met `sync-schema.sh`). Sources blijven in Scrum4Me-repo authoritative; service kopieert een snapshot bij CI-build. - **Optie B (later)**: publiceer naar GitHub Packages (`@madhura68/bootstrap-actions`) zodra een derde consumer of coordination-pijn dit rechtvaardigt. **Upgrade-trigger naar B** (geen sprint-automatisme — pas wanneer): 1. Derde consumer verschijnt (CI-pipeline, tweede service, externe contributor) 2. Coordination-pijn: `ActionSchema`-wijziging die app/service apart-deploy verbreekt zonder version-pin 3. Externe gebruiker vraagt het package buiten lokale dev --- ## Action-schema + path-safety ```ts // packages/bootstrap-actions/src/schema.ts const SafeRelPath = z.string().min(1).max(256) .regex(/^[A-Za-z0-9_./-]+$/) .refine(p => !p.startsWith('/'), 'absolute denied') .refine(p => !p.split('/').includes('..'), 'parent traversal denied') .refine(p => !p.split('/').includes('.git'), '.git denied') export const ActionSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('COPY_FILE'), params: z.object({ source: SafeRelPath, dest: SafeRelPath })}), z.object({ kind: z.literal('WRITE_FILE'), params: z.object({ path: SafeRelPath, content: z.string().max(65_536) })}), z.object({ kind: z.literal('APPEND_TO_FILE'), params: z.object({ path: SafeRelPath, content: z.string().max(65_536), marker: z.string().min(1).max(256) })}), z.object({ kind: z.literal('REPLACE_STRING'), params: z.object({ file: SafeRelPath, find: z.string().min(1).max(1024), replace: z.string().max(8192) })}), z.object({ kind: z.literal('CREATE_ADR_STUB'), params: z.object({ number: z.number().int().min(1).max(99), title: z.string().min(1).max(160), template: z.enum(['nygard','madr']) })}), // MVP: alleen exact/range semver (geen 'latest', 'workspace:*', 'npm:'-aliases, geen prerelease-labels). // Fase-2: vervang regex door npm-package-arg + allowlist van toegestane spec-types. z.object({ kind: z.literal('ADD_DEPENDENCY'), params: z.object({ name: z.string().regex(/^[@a-z0-9/_.-]+$/), version: z.string().regex(/^[\^~>=<.\d-]+$/), dev: z.boolean() })}), z.object({ kind: z.literal('RUN_BASH_TEMPLATE'), params: z.object({ command: z.string().refine(c => allowedCommands.some(rx => rx.test(c))) })}), ]) ``` Path-resolution in handlers: `path.resolve(tmpdir, params.dest)` + assert `result.startsWith(tmpdir + path.sep)`. **Run-level caps**: - ≤ 200 acties per recipe - ≤ 256 KiB output_log - 30-min runtime watchdog - ≤ 64 KiB per WRITE/APPEND content - ≤ 50 MB tmpdir total --- ## Enum-uitbreiding — exhaustive scope `ClaudeJobKind.BOOTSTRAP_REPO` ripple: - `components/jobs/job-card.tsx` (`Record`) - `components/jobs/jobs-column.tsx` - `lib/insights/agent-throughput.ts` - SSE initial-payload + filter-set - `lib/job-config.ts` + spiegel-MCP: discriminated union returns `runtime: 'deterministic'` - Tests rond exhaustive switches --- ## Bestanden te wijzigen / aan te maken ### Database (Scrum4Me) - `prisma/schema.prisma` — modellen + enums + `@@map` annotaties + Product/User uitbreidingen - `prisma/migrations/_bootstrap_wizard/migration.sql` — incl. partial unique index (raw SQL) - `prisma/seed.ts` — `seedBootstrapCatalog()` met 6 core + add-ons - `scrum4me-mcp/prisma/schema.prisma` — gesynced via `sync-schema.sh` - `~/Development/bootstrap-service/prisma/schema.prisma` — gesynced idem ### App-side (Scrum4Me) - `lib/env.ts` — `BOOTSTRAP_ENCRYPTION_KEY`, `BOOTSTRAP_TEMPLATE_REPO` - `lib/job-config.ts` (+ MCP-spiegel) — discriminated union, BOOTSTRAP_REPO → deterministic - `lib/crypto/pat.ts` — encrypt only (decrypt leeft in service) - `actions/bootstrap.ts` — preview/start/cancel/retry/savePat - `actions/products.ts` — `repo_owner`, `repo_slug` velden - `lib/bootstrap/dry-run.ts` — gebruikt shared handlers - `lib/bootstrap/recipe.ts` — recipe-resolver van selections naar action-list ### Shared package (in deze repo) - `packages/bootstrap-actions/` — schema, handlers, types, recipe-hash, allowed-commands. Path-alias `@scrum4me/bootstrap-actions` in `tsconfig.json` - `scripts/sync-bootstrap-actions.sh` — sync-script (analoog aan `sync-schema.sh`) dat het package naar `~/Development/bootstrap-service/packages/bootstrap-actions/` vendor-copyt bij service-build ### Jobs-board (Scrum4Me) - `components/jobs/job-card.tsx`, `jobs-column.tsx` - `lib/insights/agent-throughput.ts` - SSE-routes (`app/api/realtime/jobs/route.ts` etc.) ### UI (Scrum4Me) - `app/(app)/products/[id]/page.tsx` + `_components/*` - `app/(app)/settings/_components/github-pat-settings.tsx` - `app/(app)/admin/bootstrap/page.tsx` (fase-2) ### Worker (scrum4me-docker) - `bin/run-one-job.ts` — skip-filter `AND kind <> 'BOOTSTRAP_REPO'` - `docs/manual/05-docker.md` — herbevestig secret-boundary ### Nieuwe sibling-repo (`~/Development/bootstrap-service/`) ``` bootstrap-service/ ├─ package.json # deps: prisma, isomorphic-git, @octokit/rest, zod ├─ tsconfig.json ├─ env.ts # service-env Zod-schema ├─ bin/run.ts # daemon: LISTEN + claim-loop + lease-renewal ├─ src/ │ ├─ claim.ts # tryClaim, releaseClaim, renewLease (lease_until) │ ├─ runner.ts # executeRecipe(run, pat) │ ├─ template-clone.ts # isomorphic-git clone --depth=1 --tag │ ├─ github.ts # Octokit wrapper + isomorphic-git push (onAuth) │ ├─ status-sync.ts # transactionele update + post-commit pg_notify │ ├─ crypto/pat.ts # decrypt │ ├─ telemetry.ts # log-helper met token-scrubbing │ └─ stale-recovery.ts # lease_until < NOW recovery ├─ prisma/schema.prisma # gesynced ├─ packages/bootstrap-actions/ # shared package (zie boven) ├─ Dockerfile # multi-arch, Mac arm64 primary ├─ docker-compose.yml # default target arm64 lokale Mac dev ├─ sync-schema.sh # zelfde patroon als scrum4me-mcp └─ README.md ``` **Deployment target** (volgt Scrum4Me-pattern uit memory: "Docker deploy = Mac default; scrum4me-docker arm64 native; NAS-flow opt-in"): - `Dockerfile` start met `FROM --platform=$BUILDPLATFORM node:24-alpine` - Build met `docker buildx build --platform linux/amd64,linux/arm64 --push` - `docker-compose.yml` default target arm64 voor lokale Mac dev - CI publiceert beide platforms naar GitHub Container Registry (`ghcr.io/madhura68/bootstrap-service`) - Geen platform-specifieke deps — `isomorphic-git`, `@octokit/rest` zijn pure-JS; Prisma client multi-arch native - NAS/Linux-deploy (Beelink server etc.) is opt-in via `--platform=linux/amd64` ### ADR + docs (Scrum4Me) - `docs/adr/0009-bootstrap-wizard.md` (Nygard, accepted) - `docs/architecture/bootstrap-service.md` - `docs/runbooks/bootstrap-wizard.md` — operatorpad incl. PAT-setup, troubleshooting, FAILED_NEEDS_CLEANUP handling - `docs/INDEX.md` regenereren ### Env + deployment docs - `.env.example` (in Scrum4Me-repo) — voeg `BOOTSTRAP_ENCRYPTION_KEY` en `BOOTSTRAP_TEMPLATE_REPO` toe met instructies hoe te genereren (`openssl rand -base64 32`) - `~/Development/bootstrap-service/.env.example` — `DATABASE_URL`, `DIRECT_URL`, `BOOTSTRAP_ENCRYPTION_KEY` (gedeeld met app) - `~/Development/bootstrap-service/README.md` — setup-instructies (clone, sync-schema, npm install, env, npm run dev) - `docs/manual/06-bootstrap-service.md` — deployment-runbook voor productie (Mac arm64 default, Linux opt-in) ### Externe template-repo - Apart traject: `madhura68/nextjs-baseline` v1.0.0 — geen wijzigingen in deze PR --- ## Fasering **Sprint 1a — Contracten** 1. ADR-0009 2. `JobConfig` discriminated union + tests 3. `scrum4me-docker/bin/run-one-job.ts` skip-filter + tests 4. Shared `bootstrap-actions` package scaffold (schema + handler-interfaces) 5. `lib/bootstrap/notify.ts` post-commit pg_notify helper + tests 6. **Schema-hash drift-check** (vendor-copy mitigatie): `scripts/check-bootstrap-actions-hash.sh` berekent `sha256` over `packages/bootstrap-actions/src/**` en vergelijkt met `~/Development/bootstrap-service/packages/bootstrap-actions/.hash`. CI faalt bij mismatch. Service logt bij startup geladen hash + `action_schema_version`. **Sprint 1b — Schema + seed + safety** 6. Prisma-modellen (incl. `@@map`) + migration (incl. partial unique index) 7. Action-handlers in shared package (idempotent, path-safe) 8. Seed met 6 core categorieën + minimale add-ons 9. Jobs-board enum-uitbreidingen **Sprint 1c — PAT + Dry-run** 10. `lib/crypto/pat.ts` (encrypt) + tests 11. `GitHubPatSettings` UI + `saveGitHubPatAction` (scope-detect + verify) 12. `previewBootstrapAction` + `lib/bootstrap/dry-run.ts` 13. Wizard: Configure + Preview steps **Sprint 1d — bootstrap-service + E2E** 14. Sibling-repo `bootstrap-service/` opzetten: package.json, env, sync-schema.sh, sync-bootstrap-actions.sh, Dockerfile 15. Claim-loop + LISTEN + lease-renewal (gebruikt bestaand `lease_until` + nieuw `claimed_by_worker_id`) 16. Execute-flow: isomorphic-git clone + recipe + Octokit createRepo + isomorphic-git push + side-effect checkpoints 17. Status-sync transactional + post-commit NOTIFY (lowercase status) 18. **Stale-recovery cron** `/api/cron/bootstrap-stale-recovery` met split-strategie (`FAILED` vs `FAILED_NEEDS_CLEANUP`) — MVP, niet fase-2 19. **Service-startup logging**: print `action_schema_version`, schema-hash, en geladen catalog-version voor observability/drift-detectie 20. `BootstrapStatusPanel` realtime SSE 21. E2E: nieuw product → wizard → preview → run → SUCCEEDED → repo bestaat 22. **Drift-verificatie-stap**: CI-job die `check-bootstrap-actions-hash.sh` draait na elke bootstrap-service-image-build; release-pipeline faalt bij mismatch met Scrum4Me-bron **Fase 2**: - Add-ons (sentry, web-push, realtime, demo-policy, MD3-theme) - `RUN_BASH_TEMPLATE` met allowlist - `condition` mini-DSL - `retryBootstrapAction` + `cancelBootstrapAction` - Admin-CRUD UI + catalog-publish met dry-run-gate - Update-detection - `FAILED_NEEDS_CLEANUP` admin-UI met handmatige "Cleanup orphan repo"-knop **Fase 3**: - Sandbox-isolatie per run - GitHub App (van per-user PAT naar app-installation) - Multi-tenant cross-org owners - GitHub-webhook back-channel --- ## Verificatie ```bash # 1. Contracten npm test -- lib/job-config npm test -- lib/bootstrap/notify npm test -- packages/bootstrap-actions/schema # 2. Schema (alle tables snake_case) npx prisma migrate dev npm run seed psql "$DATABASE_URL" -c "\dt" # toont bootstrap_categories, bootstrap_options, bootstrap_actions, bootstrap_runs psql "$DATABASE_URL" -c "SELECT COUNT(*) FROM bootstrap_categories" # 7 psql "$DATABASE_URL" -c "SELECT slug FROM bootstrap_categories WHERE is_required = true ORDER BY display_order" # Expect: deploy, auth, database, ui-components, state-management, testing # 3. PAT # - Settings → plak PAT met repo-scope → Test toont "✓ " + scopes psql "$DATABASE_URL" -c "SELECT length(github_pat_encrypted), github_pat_scopes FROM users WHERE id=X" # Expect: lengte > 100; scopes = ['repo'] # 4. Dry-run # - Wizard configure → Preview-stap → DryRunReport file-tree zichtbaar # - Geen DB-row bij bootstrap_runs aangemaakt # - Geen GitHub-write-call (alleen GET /repos/{owner}/{repo}) # 5. End-to-end # Maak product 'ops-dashboard'; wizard alle 6 core; repo_owner=madhura68; preview groen; Run # Service claimt binnen 2s; lease_until wordt verlengd; status SUCCEEDED in <60s psql "$DATABASE_URL" -c " SELECT br.status, br.repo_url, br.recipe_hash, cj.lease_until > NOW() AS lease_active FROM bootstrap_runs br JOIN claude_jobs cj ON cj.id = br.claude_job_id ORDER BY br.started_at DESC NULLS LAST, br.created_at DESC LIMIT 1 " # NB: lease_until staat op claude_jobs, niet bootstrap_runs — JOIN nodig psql "$DATABASE_URL" -c " SELECT status, finished_at FROM claude_jobs WHERE kind = 'BOOTSTRAP_REPO' ORDER BY created_at DESC LIMIT 1 " # Expect: DONE, finished_at gevuld gh api repos/madhura68/ops-dashboard/contents/.scrum4me/bootstrap.json | jq . # Expect: { template_version: 'v1.0.0', recipe_hash: ..., selected_options: {...} } gh api repos/madhura68/ops-dashboard/contents/docs/adr | jq '.[].name' # Expect: 0000…0006-... (alle 6 stubs) # 6. Failure # Invalid PAT → bootstrap → FAILED met "Bad credentials" — geen orphan repo psql "$DATABASE_URL" -c "SELECT status, error FROM bootstrap_runs WHERE status='FAILED' ORDER BY finished_at DESC LIMIT 1" # 7. Concurrency # Twee gelijktijdige starts → één gaat door, andere unique violation psql "$DATABASE_URL" -c "SELECT COUNT(*) FROM bootstrap_runs WHERE product_id=X AND status IN ('PENDING','RUNNING')" # Expect: 1 # 8. Demo-policy # Login als demo → Bootstrap-knop niet zichtbaar; direct API call → 403 # 9. Lease-renewal # Tijdens RUNNING-fase: verifieer dat lease_until > NOW + 30s psql "$DATABASE_URL" -c "SELECT lease_until - NOW() FROM claude_jobs WHERE status='RUNNING'" # 10. Stale-recovery (Sprint 1d — niet fase-2) # Kill bootstrap-service tijdens RUNNING; cron picks up na lease_until < NOW psql "$DATABASE_URL" -c "SELECT status FROM claude_jobs WHERE id=X" # Expect na cleanup: FAILED (geen GitHub side-effects) of FAILED_NEEDS_CLEANUP (repo al aangemaakt) # 11. Project-acceptatie npm run verify && npm run build ``` **Lokaal dev**: ```bash # In Scrum4Me/: npm run dev # In ~/Development/bootstrap-service/ (apart terminal): npm run dev # of `docker compose up bootstrap-service` ``` --- ## Deployment prerequisites (vóór Sprint 1d) - `madhura68/nextjs-baseline` repo **must be public** en tag `v1.0.0` aanwezig (anders falen alle clone-ops) - `BOOTSTRAP_ENCRYPTION_KEY` genereren: `openssl rand -base64 32` — **zelfde waarde** in Scrum4Me-app en bootstrap-service - Prisma migration geapplied + seed gedraaid --- ## Accepted risks (bewuste keuzes) - **PAT in JS function-closure**: `pat = ''` helpt niet bij GC van de originele immutable string — in Node.js geen betere optie zonder `Buffer`-gebruik. Accepted: PAT leeft kort (functie-scope), geen logging, geen serialisatie buiten de closure. - **previewBootstrapAction als Server Action**: kan zware Vercel function zijn bij hoog volume — monitor in productie; migreer naar Route Handler als nodig. - **Vendor-copy sync-drift**: als `ActionSchema` in Scrum4Me-repo wijzigt maar sync-script niet gedraaid is, silent mismatch in bootstrap-service. Mitigatie: CI-check in bootstrap-service die de schema-hash vergelijkt. --- ## Open punten (post-MVP) - **Idempotency** voor APPEND_TO_FILE (marker-based) en RUN_BASH_TEMPLATE - **Key rotation** voor `BOOTSTRAP_ENCRYPTION_KEY` (`v1:` prefix maakt versionering mogelijk) - **PAT-expiry honoring** — UI-prompt als `github_pat_expires_at < now + 7d` - **Fine-grained PAT support** — vereist andere scope-detection-logica (`x-accepted-github-permissions` of `GET /user/installations`); UI moet kunnen kiezen tussen classic en fine-grained - **GitHub App migratie** — fase-3 alternative voor PAT (granular permissions, geen user-token-management) - **Shared package upgrade** — van vendor-copy naar gepubliceerd GitHub Packages-package als derde consumer komt - **`FAILED_NEEDS_CLEANUP` recovery flow** — admin-UI knop voor handmatige `octokit.repos.delete` of "mark as resolved" - **Bootstrap-service unit-tests** — claim.ts, runner.ts, status-sync.ts hebben geen test-strategie beschreven; overweeg integration-tests met een lokale Postgres-instance - **`ADD_DEPENDENCY.version` spec-broadening** — `npm-package-arg`-parser ondersteuning voor `latest`, prerelease (`^1.2.3-beta.1`), `workspace:*`, `npm:`-aliases, git/tarball specs; per-spec-type allowlist --- ## Plan-locatie noot Bij implementatie-go: hernoem naar `docs/plans/M8-bootstrap-wizard.md` voor zichtbaarheid binnen Scrum4Me + MCP-Milestone-koppeling.