#!/usr/bin/env node /** * cli:derive-permission-floor — entry point. * * Deterministic companion of /ba-create-rbac for the default PERMISSION FLOOR * mirror block. Runs after /ba-create-rbac (and re-runs after any menu * change / /ba-reconcile-menu) — the ACTUAL floor is seeded by * scaffold-core-seed from the same nav tree, so a drift here only breaks the * human review mirror, never the seed. * * Invocation: * npx --prefer-offline tsx skills/business-analyse/create-rbac/cli/derive-permission-floor/index.ts \ * --spec '{"baRoot":".smartstack/ba","app":"CRM","module":"PIPELINE","mode":"derive"}' \ * [--workdir ] * * - mode=derive → REWRITES the machine-owned block of the module's rbac.md * between `` (whole-block splice, idempotent, * human rows + the ba:rbac-derived-lookups block never touched). * - mode=check → READ-ONLY drift report against the current block, consumed * by /ba-audit-rbac RBAC-009. Drift is DATA — exit 0; only spec/parse/IO * errors fail. */ import { parseArgs } from 'node:util' import { readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { executeEnvelope, failExecute, generateEnvelope, printEnvelope, } from '../../../../lib/output.js' import { validateSpec } from './validate.js' import { derivePermissionFloor, fsFloorSources } from './derive.js' import { extractFloorBlock, extractFloorRowLines, renderFloorBlock, renderFloorRowLine, spliceFloorBlock, } from './block.js' import type { DerivePermissionFloorReport } from './types.js' const COMMAND = 'derive-permission-floor' function main(): void { const { values } = parseArgs({ options: { spec: { type: 'string' }, workdir: { type: 'string' }, }, strict: true, }) if (!values.spec) { printEnvelope(failExecute(COMMAND, ['--spec is required'])) process.exit(1) } let raw: unknown try { raw = JSON.parse(values.spec) } catch { printEnvelope(failExecute(COMMAND, ['Invalid JSON in --spec'])) process.exit(1) } const validation = validateSpec(raw, values.workdir) if (!validation.valid || !validation.spec || !validation.resolvedBaRoot) { printEnvelope(failExecute(COMMAND, validation.errors)) process.exit(1) } const spec = validation.spec const baRoot = validation.resolvedBaRoot const rbacPath = join(baRoot, spec.app, spec.module, 'rbac.md') const rbacContent = readFileSync(rbacPath, 'utf8') const core = derivePermissionFloor(spec.app, spec.module, fsFloorSources(baRoot)) const block = renderFloorBlock(core.rows, core.totals.floorPaths) if (spec.mode === 'derive') { const next = spliceFloorBlock(rbacContent, block) const blockWritten = next !== null if (next !== null) { writeFileSync(rbacPath, next, 'utf8') } printEnvelope( generateEnvelope(COMMAND, { data: { ...core.totals, blockWritten, report: { mode: 'derive', ...core } }, filesModified: blockWritten ? [rbacPath] : [], warnings: core.warnings, nextSteps: [ 'Le socle est seedé par scaffold-core-seed depuis le MÊME arbre nav — ce bloc est un miroir de relecture, pas une entrée de la Phase 0.', 'La matrice humaine porte les GRANTS et les actions hors socle (approve, export, `.read.all`, …).', 'Relancer ce CLI après toute modification du menu, et après /ba-reconcile-menu.', '`/ba-audit-rbac` (RBAC-009) vérifie la fraîcheur du bloc via `"mode":"check"`.', ], }), ) process.exit(0) } // --- mode=check (read-only) --- const current = extractFloorBlock(rbacContent) const freshLines = core.rows.map(renderFloorRowLine) const fileLines = current ? extractFloorRowLines(current) : [] const freshSet = new Set(freshLines) const fileSet = new Set(fileLines) const staleRows = fileLines.filter((l) => !freshSet.has(l)) const missingRows = freshLines.filter((l) => !fileSet.has(l)) const drift = { missingBlock: current === null, staleRows, missingRows, upToDate: current !== null && staleRows.length === 0 && missingRows.length === 0, } const report: DerivePermissionFloorReport = { mode: 'check', ...core, drift } printEnvelope( executeEnvelope(COMMAND, { success: true, data: { ...core.totals, ...drift, staleRows: staleRows.length, missingRows: missingRows.length }, report, warnings: core.warnings, nextSteps: drift.upToDate ? ['Bloc socle à jour — rien à faire.'] : [ 'Drift détecté — le menu a bougé après l\'écriture du miroir. Relancer en `"mode":"derive"` (jamais à la main).', 'Le drift est une DONNÉE (exit 0) — le verdict appartient à /ba-audit-rbac (RBAC-009).', ], }), ) process.exit(0) } main()