#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const manifestRoot = path.join(repoRoot, "docs", "feature-manifests"); const classifications = new Set(["core", "plugin-candidate", "remove-candidate", "mixed"]); const ownerships = new Set(["tenant-owned", "admin-managed", "hybrid", "system-only"]); export function parseArgs(argv) { const args = {}; for (let index = 0; index < argv.length; index += 1) { const token = argv[index]; if (!token.startsWith("--")) { throw new Error(`Unexpected argument: ${token}`); } const key = token.slice(2); if (key === "force") { args.force = true; continue; } const value = argv[index + 1]; if (!value || value.startsWith("--")) { throw new Error(`Missing value for --${key}`); } args[key] = value; index += 1; } return args; } export function createFeatureManifest({ id, title, classification = "plugin-candidate", ownership = "hybrid", owner = "boilerplate", description = "Describe the feature contract before implementation.", lastReviewed = process.env.TOOLCTRL_MANIFEST_DATE ?? new Date().toISOString().slice(0, 10), }) { if (!/^[a-z0-9][a-z0-9-]*$/.test(id ?? "")) { throw new Error("--id must match ^[a-z0-9][a-z0-9-]*$"); } if (!title?.trim()) { throw new Error("--title is required"); } if (!classifications.has(classification)) { throw new Error(`--classification must be one of ${Array.from(classifications).join(", ")}`); } if (!ownerships.has(ownership)) { throw new Error(`--ownership must be one of ${Array.from(ownerships).join(", ")}`); } const pruningAction = classification === "core" ? "keep" : classification === "remove-candidate" ? "remove" : classification === "plugin-candidate" ? "extract-plugin" : "review"; return { schemaVersion: "1.0", id, title, classification, status: "proposed", owner, description, rationale: ["Document why this belongs in the boilerplate, a plugin, or the removal backlog before code changes land."], codeAreas: ["Add concrete source paths before implementation, for example apps/web/app/routes/... or packages/..."], dependencies: [], consumers: [], integration: { ownership, surfaces: { adminRoutes: [], tenantRoutes: [], publicRoutes: [], apiRoutes: [], }, configuration: { admin: [], tenant: [], environment: [], secrets: [], }, workers: { required: false, queues: [], jobs: [], notes: [], }, externalProviders: [], permissions: ["app.feature.view"], auditEvents: ["feature.viewed"], billingImpacts: ["None declared yet; confirm plan gates, metering, and provider costs before implementation."], uat: ["Verify positive access, denied access, tenant isolation, i18n output, and host-aware routing for this feature."], }, boundaries: { mustKeep: [], mayMoveToPlugin: [], mustNotTouch: [], }, pruning: { phase: "planning", action: pruningAction, notes: ["Generated scaffold. Replace placeholders before treating this manifest as implementation-ready."], }, validation: { source: [`docs/feature-manifests/${id}.json`], lastReviewed, }, }; } export function writeFeatureManifest(args, root = repoRoot) { const manifest = createFeatureManifest(args); const target = path.join(root, "docs", "feature-manifests", `${manifest.id}.json`); if (fs.existsSync(target) && !args.force) { throw new Error(`${path.relative(root, target)} already exists. Pass --force to overwrite.`); } fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, `${JSON.stringify(manifest, null, 2)}\n`); return { manifest, target }; } function printUsage() { console.error("Usage: node scripts/create-feature-manifest.mjs --id my-feature --title \"My Feature\" [--classification plugin-candidate] [--ownership hybrid] [--owner boilerplate] [--description text] [--force]"); } const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); if (isCli) { try { const result = writeFeatureManifest(parseArgs(process.argv.slice(2))); console.log(`Created ${path.relative(repoRoot, result.target)}`); } catch (error) { printUsage(); console.error(`FAIL ${error.message}`); process.exit(1); } }