#!/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()