Scrum4Me/docs/plans/M8-bootstrap-wizard.md
Janpeter Visser d84cdf664f
feat(PBI-67): IDEA_REVIEW_PLAN — iterative multi-model plan review (#199)
* feat(ideas): upload-plan knop — short-circuit van Make-Plan AI-flow

Voegt een 'Upload plan' knop toe in idea-row-actions (verschijnt in zowel
list als idea-detail). Klik → file picker → kies .md → server-side parse +
opslaan; idea-status springt naar PLAN_READY. Vandaaruit de bestaande
'Maak PBI' knop voor materialize.

Server (uploadPlanMdAction):
- Toegestaan vanuit DRAFT, GRILLED, PLAN_FAILED, PLAN_READY
- DRAFT → skip-grill: status gaat direct naar PLAN_READY
- PLAN_READY overschrijft het bestaande plan (consistent met
  updatePlanMdAction, geen confirmation)
- Geblokkeerd in GRILLING/PLANNING (job loopt), PLANNED (al gematerialiseerd)
- Parse-failure → 422 + details (NIET opslaan, zodat een onparseerbaar plan
  nooit in de DB belandt)
- Empty / >100k chars → 422
- Schrijft IdeaLog NOTE met from_status + length
- Rate-limit + demo-guard + ownership-check via loadOwnedIdea (zelfde
  patroon als updatePlanMdAction)

UI (idea-row-actions.tsx):
- Hidden <input type=file accept=".md,.markdown,text/markdown,text/plain">
- FileReader → text → action
- Toast bij success + router.refresh()
- Blocked-tooltip in andere statussen

Tests: 10 nieuwe in __tests__/actions/ideas-crud.test.ts dekkend voor:
happy paths (DRAFT/GRILLED/PLAN_READY-overwrite/PLAN_FAILED), blocks
(PLANNED/GRILLING), validation (empty/oversized/parse-fail), 404.
Full suite groen: 849/849.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add reviews for Bootstrap-wizard plans v3.2 to v3.4

- Review v3.2: Addressed executor model, fire-and-forget issues, and PAT handling.
- Review v3.3: Improved transaction handling, stale recovery, and ID generation.
- Review v3.4: Finalized GitHub permissions, catalog versioning, and E2E verification queries.
- Updated recommendations for each version to enhance implementation readiness.

* docs(plans): M8 bootstrap-wizard upload-variant v1.4 — backtick-paden

Upload-variant van het volledige technische plan (docs/plans/M8-bootstrap-wizard.md),
bedoeld voor de "Upload plan"-functie. Genereert 1 PBI + 4 Stories + 22 Tasks
via materializeIdeaPlanAction.

v1.4-aanpassingen tov eerdere generatie-iteratie:
- Alle bestandspaden in implementation_plan in backticks (path-extractor matchen)
- Expliciete "Bestanden:" blok per task vóór de stappen
- Alle tasks op verify_required: ALIGNED_OR_PARTIAL (was deels ALIGNED — te strict
  voor ADR-stubs en multi-file edits)

Fixt forward-only: T-963 cancelled_by_self door DIVERGENT verifier-verdict.
Re-upload van dit bestand produceert tasks die door verify_task_against_plan
als ALIGNED of PARTIAL geclassificeerd kunnen worden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PBI-67: Add review-plan support to Idea model and job config

- Add plan_review_log and reviewed_at fields to Idea model
- Add REVIEWING_PLAN, PLAN_REVIEW_FAILED, PLAN_REVIEWED to IdeaStatus enum
- Add IDEA_REVIEW_PLAN to ClaudeJobKind enum
- Add IDEA_REVIEW_PLAN config to job-config.ts with model=opus, thinking_budget=6000
- Create migration record for schema changes (applied via db push)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* PBI-67 Phase 2: Add update-idea-plan-reviewed MCP tool

- Create src/tools/update-idea-plan-reviewed.ts: saves review-log and transitions idea status to PLAN_REVIEWED
- Add PLAN_REVIEW_RESULT to IdeaLogType enum (both repos)
- Register tool in src/index.ts
- Update Prisma schemas (both repos): add plan_review_log and reviewed_at fields to Idea model
- Add REVIEWING_PLAN, PLAN_REVIEW_FAILED, PLAN_REVIEWED to IdeaStatus enum (MCP schema)
- Add IDEA_REVIEW_PLAN to ClaudeJobKind enum (MCP schema)
- Tool includes transaction safety and convergence metrics logging

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* feat(PBI-67): IDEA_REVIEW_PLAN Phases 3-6 — server actions, UI components, prompt & tests

- Phase 3: startReviewPlanJobAction, cancelIdeaJobAction, status transitions
  (REVIEWING_PLAN / PLAN_REVIEWED / PLAN_REVIEW_FAILED), status colors,
  job-card/jobs-column filters, idea-list status tabs
- Phase 4: review-plan-job.md prompt (multi-model orchestration with codex
  injection + active plan revision via update_idea_plan_md after each round),
  runbook, 13 unit tests
- Phase 5: ReviewLogViewer component (rounds, convergence, approval, issues),
  idea-detail integration, proper ReviewLog TypeScript types exported from component
- Phase 6.1: wait-for-job discriminator wired (IDEA_REVIEW_PLAN), plan-revision
  step made mandatory in prompt (was previously optional/missing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:35:02 +02:00

55 KiB

status author version created_at reviewed_by
reviewed Claude 6 2026-05-14
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)

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}).

// 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-recoverystrikt 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.

-- 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)

// 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:

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.

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):

// 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:

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
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

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.

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

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.

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):

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)

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)

model User {
  // ... bestaande velden ...
  github_pat_encrypted    String?              // prefix 'v1:<base64>'
  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)

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:

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:

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:
    { 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)

// 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

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 <owner>. 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:

type DryRunFileTreeNode = { name: string; type: 'file' | 'dir'; children?: DryRunFileTreeNode[]; size?: number }

Template-cache lifecycle

MVP: geen cachegit 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/<template_repo_slug>/<template_version>/
  • 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):

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:

// 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)

{ "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)

  • previewBootstrapActionDryRunReport (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:

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.tsActionSchema (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

// 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<ClaudeJobKind, …>)
  • 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/<ts>_bootstrap_wizard/migration.sql — incl. partial unique index (raw SQL)
  • prisma/seed.tsseedBootstrapCatalog() 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.tsBOOTSTRAP_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.tsrepo_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.exampleDATABASE_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

# 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 "✓ <username>" + 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:

# 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 32zelfde 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-broadeningnpm-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.