139 lines
4.5 KiB
JavaScript
139 lines
4.5 KiB
JavaScript
#!/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);
|
|
}
|
|
}
|