@topogram/cli 0.3.41 → 0.3.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli.js +429 -3
- package/src/generator-policy.d.ts +12 -0
- package/src/generator-policy.js +344 -0
- package/src/new-project.js +7 -0
- package/src/project-config.js +5 -0
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -44,6 +44,17 @@ import {
|
|
|
44
44
|
loadPackageGeneratorManifest,
|
|
45
45
|
packageGeneratorInstallCommand
|
|
46
46
|
} from "./generator/registry.js";
|
|
47
|
+
import {
|
|
48
|
+
defaultGeneratorPolicy,
|
|
49
|
+
GENERATOR_POLICY_FILE,
|
|
50
|
+
generatorPackageAllowed,
|
|
51
|
+
generatorPolicyDiagnosticsForBindings,
|
|
52
|
+
loadGeneratorPolicy,
|
|
53
|
+
packageBackedGeneratorBindings,
|
|
54
|
+
packageScopeFromName,
|
|
55
|
+
parseGeneratorPolicyPin,
|
|
56
|
+
writeGeneratorPolicy
|
|
57
|
+
} from "./generator-policy.js";
|
|
47
58
|
import {
|
|
48
59
|
buildAuthHintsQueryPayload,
|
|
49
60
|
buildAuthReviewPacketPayload,
|
|
@@ -207,6 +218,10 @@ function printUsage(options = {}) {
|
|
|
207
218
|
console.log(" or: topogram generator list [--json]");
|
|
208
219
|
console.log(" or: topogram generator show <id-or-package> [--json]");
|
|
209
220
|
console.log(" or: topogram generator check <path-or-package> [--json]");
|
|
221
|
+
console.log(" or: topogram generator policy init [path] [--json]");
|
|
222
|
+
console.log(" or: topogram generator policy check [path] [--json]");
|
|
223
|
+
console.log(" or: topogram generator policy explain [path] [--json]");
|
|
224
|
+
console.log(" or: topogram generator policy pin [package@version] [path] [--json]");
|
|
210
225
|
console.log(" or: topogram new <path> [--template hello-web|todo|./local-template|@scope/template]");
|
|
211
226
|
console.log(" or: topogram new --list-templates [--json] [--catalog <path-or-source>]");
|
|
212
227
|
console.log("");
|
|
@@ -228,6 +243,7 @@ function printUsage(options = {}) {
|
|
|
228
243
|
console.log(" topogram generator list");
|
|
229
244
|
console.log(" topogram generator show @topogram/generator-react-web");
|
|
230
245
|
console.log(" topogram generator check ./generator-package");
|
|
246
|
+
console.log(" topogram generator policy check");
|
|
231
247
|
console.log(" topogram generate");
|
|
232
248
|
console.log(" topogram import ./existing-app --out ./imported-topogram");
|
|
233
249
|
console.log(" topogram import diff ./imported-topogram");
|
|
@@ -569,6 +585,10 @@ function printGeneratorHelp() {
|
|
|
569
585
|
console.log("Usage: topogram generator list [--json]");
|
|
570
586
|
console.log(" or: topogram generator show <id-or-package> [--json]");
|
|
571
587
|
console.log(" or: topogram generator check <path-or-package> [--json]");
|
|
588
|
+
console.log(" or: topogram generator policy init [path] [--json]");
|
|
589
|
+
console.log(" or: topogram generator policy check [path] [--json]");
|
|
590
|
+
console.log(" or: topogram generator policy explain [path] [--json]");
|
|
591
|
+
console.log(" or: topogram generator policy pin [package@version] [path] [--json]");
|
|
572
592
|
console.log("");
|
|
573
593
|
console.log("Inspects generator manifests and checks generator pack conformance.");
|
|
574
594
|
console.log("");
|
|
@@ -577,6 +597,7 @@ function printGeneratorHelp() {
|
|
|
577
597
|
console.log(" - show accepts an installed package name or a bundled fallback generator id.");
|
|
578
598
|
console.log(" - check validates a local generator package path or an already installed package.");
|
|
579
599
|
console.log(" - Topogram does not install generator packages during show or check.");
|
|
600
|
+
console.log(` - package-backed project generators are governed by ${GENERATOR_POLICY_FILE}; bundled topogram/* generators are allowed.`);
|
|
580
601
|
console.log("");
|
|
581
602
|
console.log("Examples:");
|
|
582
603
|
console.log(" topogram generator list");
|
|
@@ -585,6 +606,9 @@ function printGeneratorHelp() {
|
|
|
585
606
|
console.log(" topogram generator show @scope/topogram-generator-web --json");
|
|
586
607
|
console.log(" topogram generator check ./generator-package");
|
|
587
608
|
console.log(" topogram generator check @scope/topogram-generator-web --json");
|
|
609
|
+
console.log(" topogram generator policy init");
|
|
610
|
+
console.log(" topogram generator policy check --json");
|
|
611
|
+
console.log(" topogram generator policy pin @topogram/generator-react-web@1");
|
|
588
612
|
}
|
|
589
613
|
|
|
590
614
|
function printTemplateHelp() {
|
|
@@ -789,7 +813,7 @@ function printImportHelp() {
|
|
|
789
813
|
function printCheckHelp() {
|
|
790
814
|
console.log("Usage: topogram check [path] [--json]");
|
|
791
815
|
console.log("");
|
|
792
|
-
console.log("Validates Topogram files, project configuration, topology, generator compatibility, output ownership, and template policy.");
|
|
816
|
+
console.log("Validates Topogram files, project configuration, topology, generator compatibility, generator policy, output ownership, and template policy.");
|
|
793
817
|
console.log("");
|
|
794
818
|
console.log("Defaults: path is ./topogram.");
|
|
795
819
|
console.log("");
|
|
@@ -1524,6 +1548,341 @@ function printGeneratorShow(payload) {
|
|
|
1524
1548
|
console.log(stableStringify(payload.exampleTopologyBinding));
|
|
1525
1549
|
}
|
|
1526
1550
|
|
|
1551
|
+
/**
|
|
1552
|
+
* @param {string} name
|
|
1553
|
+
* @param {boolean} ok
|
|
1554
|
+
* @param {string} actual
|
|
1555
|
+
* @param {string} expected
|
|
1556
|
+
* @param {string} message
|
|
1557
|
+
* @param {string|null} fix
|
|
1558
|
+
* @returns {{ name: string, ok: boolean, actual: string, expected: string, message: string, fix: string|null }}
|
|
1559
|
+
*/
|
|
1560
|
+
function generatorPolicyRule(name, ok, actual, expected, message, fix = null) {
|
|
1561
|
+
return { name, ok, actual, expected, message, fix };
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/**
|
|
1565
|
+
* @param {string} name
|
|
1566
|
+
* @returns {string}
|
|
1567
|
+
*/
|
|
1568
|
+
function generatorPolicyRuleLabel(name) {
|
|
1569
|
+
return ({
|
|
1570
|
+
"policy-file": "Policy file",
|
|
1571
|
+
"allowed-package": "Allowed package",
|
|
1572
|
+
"pinned-version": "Pinned version"
|
|
1573
|
+
})[name] || name;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/**
|
|
1577
|
+
* @param {import("./generator-policy.js").GeneratorPolicyInfo} policyInfo
|
|
1578
|
+
* @returns {import("./generator-policy.js").GeneratorPolicy}
|
|
1579
|
+
*/
|
|
1580
|
+
function effectiveGeneratorPolicy(policyInfo) {
|
|
1581
|
+
return policyInfo.policy || {
|
|
1582
|
+
version: "0.1",
|
|
1583
|
+
allowedPackageScopes: ["@topogram"],
|
|
1584
|
+
allowedPackages: [],
|
|
1585
|
+
pinnedVersions: {}
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* @param {string} projectPath
|
|
1591
|
+
* @returns {{ ok: boolean, path: string, exists: boolean, policy: any, defaulted: boolean, bindings: ReturnType<typeof packageBackedGeneratorBindings>, diagnostics: any[], errors: string[] }}
|
|
1592
|
+
*/
|
|
1593
|
+
function buildGeneratorPolicyCheckPayload(projectPath) {
|
|
1594
|
+
const projectConfigInfo = loadProjectConfig(projectPath);
|
|
1595
|
+
if (!projectConfigInfo) {
|
|
1596
|
+
const diagnostic = {
|
|
1597
|
+
code: "generator_policy_project_missing",
|
|
1598
|
+
severity: "error",
|
|
1599
|
+
message: "Cannot check generator policy without topogram.project.json.",
|
|
1600
|
+
path: path.resolve(projectPath),
|
|
1601
|
+
suggestedFix: "Run this command in a Topogram project.",
|
|
1602
|
+
step: "generator-policy"
|
|
1603
|
+
};
|
|
1604
|
+
return {
|
|
1605
|
+
ok: false,
|
|
1606
|
+
path: path.join(path.resolve(projectPath), GENERATOR_POLICY_FILE),
|
|
1607
|
+
exists: false,
|
|
1608
|
+
policy: null,
|
|
1609
|
+
defaulted: false,
|
|
1610
|
+
bindings: [],
|
|
1611
|
+
diagnostics: [diagnostic],
|
|
1612
|
+
errors: [diagnostic.message]
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
const policyInfo = loadGeneratorPolicy(projectConfigInfo.configDir);
|
|
1616
|
+
const bindings = packageBackedGeneratorBindings(projectConfigInfo.config);
|
|
1617
|
+
const diagnostics = [];
|
|
1618
|
+
if (!policyInfo.exists) {
|
|
1619
|
+
diagnostics.push({
|
|
1620
|
+
code: "generator_policy_missing",
|
|
1621
|
+
severity: "warning",
|
|
1622
|
+
message: `No ${GENERATOR_POLICY_FILE} found. Default generator policy allows @topogram/* package-backed generators and blocks other package scopes.`,
|
|
1623
|
+
path: policyInfo.path,
|
|
1624
|
+
suggestedFix: "Run `topogram generator policy init` to write an explicit project generator policy after review.",
|
|
1625
|
+
step: "generator-policy"
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
diagnostics.push(...generatorPolicyDiagnosticsForBindings(policyInfo, bindings, "generator-policy"));
|
|
1629
|
+
const errors = diagnostics.filter((diagnostic) => diagnostic.severity === "error").map((diagnostic) => diagnostic.message);
|
|
1630
|
+
return {
|
|
1631
|
+
ok: errors.length === 0,
|
|
1632
|
+
path: policyInfo.path,
|
|
1633
|
+
exists: policyInfo.exists,
|
|
1634
|
+
policy: policyInfo.policy || effectiveGeneratorPolicy(policyInfo),
|
|
1635
|
+
defaulted: !policyInfo.exists,
|
|
1636
|
+
bindings,
|
|
1637
|
+
diagnostics,
|
|
1638
|
+
errors
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* @param {string} projectPath
|
|
1644
|
+
* @returns {ReturnType<typeof buildGeneratorPolicyCheckPayload> & { rules: Array<{ name: string, ok: boolean, actual: string, expected: string, message: string, fix: string|null }> }}
|
|
1645
|
+
*/
|
|
1646
|
+
function buildGeneratorPolicyExplainPayload(projectPath) {
|
|
1647
|
+
const check = buildGeneratorPolicyCheckPayload(projectPath);
|
|
1648
|
+
const policy = check.policy || effectiveGeneratorPolicy({ path: check.path, exists: false, policy: null, diagnostics: [] });
|
|
1649
|
+
const rules = [];
|
|
1650
|
+
rules.push(generatorPolicyRule(
|
|
1651
|
+
"policy-file",
|
|
1652
|
+
check.exists,
|
|
1653
|
+
check.exists ? "present" : "missing",
|
|
1654
|
+
"present",
|
|
1655
|
+
check.exists
|
|
1656
|
+
? "Project has a generator policy file."
|
|
1657
|
+
: "Project is using the default generator policy.",
|
|
1658
|
+
check.exists ? null : "Run `topogram generator policy init` after review."
|
|
1659
|
+
));
|
|
1660
|
+
for (const binding of check.bindings) {
|
|
1661
|
+
const scope = packageScopeFromName(binding.packageName);
|
|
1662
|
+
rules.push(generatorPolicyRule(
|
|
1663
|
+
"allowed-package",
|
|
1664
|
+
generatorPackageAllowed(policy, binding.packageName),
|
|
1665
|
+
`${binding.packageName}${scope ? ` (${scope})` : ""}`,
|
|
1666
|
+
[
|
|
1667
|
+
`scopes=${policy.allowedPackageScopes.join(", ") || "(none)"}`,
|
|
1668
|
+
`packages=${policy.allowedPackages.join(", ") || "(none)"}`
|
|
1669
|
+
].join("; "),
|
|
1670
|
+
`Component '${binding.componentId}' package-backed generator must be from an allowed package or scope.`,
|
|
1671
|
+
`Run \`topogram generator policy pin ${binding.packageName}@${binding.version}\` after reviewing the generator package.`
|
|
1672
|
+
));
|
|
1673
|
+
const pinnedVersion = policy.pinnedVersions[binding.packageName] || policy.pinnedVersions[binding.generatorId] || null;
|
|
1674
|
+
rules.push(generatorPolicyRule(
|
|
1675
|
+
"pinned-version",
|
|
1676
|
+
!pinnedVersion || pinnedVersion === binding.version,
|
|
1677
|
+
binding.version,
|
|
1678
|
+
pinnedVersion || "(unpinned)",
|
|
1679
|
+
`Component '${binding.componentId}' generator version must match its policy pin when one exists.`,
|
|
1680
|
+
`Run \`topogram generator policy pin ${binding.packageName}@${binding.version}\` after review.`
|
|
1681
|
+
));
|
|
1682
|
+
}
|
|
1683
|
+
return {
|
|
1684
|
+
...check,
|
|
1685
|
+
rules
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/**
|
|
1690
|
+
* @param {ReturnType<typeof buildGeneratorPolicyCheckPayload>} payload
|
|
1691
|
+
* @returns {void}
|
|
1692
|
+
*/
|
|
1693
|
+
function printGeneratorPolicyCheckPayload(payload) {
|
|
1694
|
+
console.log(payload.ok ? "Generator policy check passed" : "Generator policy check failed");
|
|
1695
|
+
console.log(`Policy: ${payload.path}`);
|
|
1696
|
+
console.log(`Exists: ${payload.exists ? "yes" : "no"}`);
|
|
1697
|
+
console.log(`Defaulted: ${payload.defaulted ? "yes" : "no"}`);
|
|
1698
|
+
console.log(`Package-backed generators: ${payload.bindings.length}`);
|
|
1699
|
+
for (const binding of payload.bindings) {
|
|
1700
|
+
console.log(`- ${binding.componentId}: ${binding.generatorId}@${binding.version} via ${binding.packageName}`);
|
|
1701
|
+
}
|
|
1702
|
+
for (const diagnostic of payload.diagnostics) {
|
|
1703
|
+
console.log(`[${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.message}`);
|
|
1704
|
+
if (diagnostic.path) {
|
|
1705
|
+
console.log(` path: ${diagnostic.path}`);
|
|
1706
|
+
}
|
|
1707
|
+
if (diagnostic.suggestedFix) {
|
|
1708
|
+
console.log(` fix: ${diagnostic.suggestedFix}`);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
/**
|
|
1714
|
+
* @param {ReturnType<typeof buildGeneratorPolicyExplainPayload>} payload
|
|
1715
|
+
* @returns {void}
|
|
1716
|
+
*/
|
|
1717
|
+
function printGeneratorPolicyExplainPayload(payload) {
|
|
1718
|
+
console.log(payload.ok ? "Generator policy: allowed" : "Generator policy: denied");
|
|
1719
|
+
console.log(payload.ok
|
|
1720
|
+
? "Decision: package-backed generators are allowed by this project's generator policy."
|
|
1721
|
+
: "Decision: one or more package-backed generators are blocked by this project's generator policy.");
|
|
1722
|
+
console.log(`Policy file: ${payload.path}`);
|
|
1723
|
+
console.log(`Policy file exists: ${payload.exists ? "yes" : "no"}`);
|
|
1724
|
+
console.log(`Default policy active: ${payload.defaulted ? "yes" : "no"}`);
|
|
1725
|
+
if (payload.bindings.length > 0) {
|
|
1726
|
+
console.log("");
|
|
1727
|
+
console.log("Package-backed generators:");
|
|
1728
|
+
for (const binding of payload.bindings) {
|
|
1729
|
+
console.log(`- ${binding.componentId}: ${binding.generatorId}@${binding.version} via ${binding.packageName}`);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
if (payload.rules.length > 0) {
|
|
1733
|
+
console.log("");
|
|
1734
|
+
console.log("Policy checks:");
|
|
1735
|
+
}
|
|
1736
|
+
for (const rule of payload.rules) {
|
|
1737
|
+
console.log(`${rule.ok ? "PASS" : "FAIL"} ${generatorPolicyRuleLabel(rule.name)}: ${rule.message}`);
|
|
1738
|
+
console.log(` actual: ${rule.actual}`);
|
|
1739
|
+
console.log(` expected: ${rule.expected}`);
|
|
1740
|
+
if (!rule.ok && rule.fix) {
|
|
1741
|
+
console.log(` fix: ${rule.fix}`);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
for (const diagnostic of payload.diagnostics) {
|
|
1745
|
+
const label = diagnostic.severity === "warning" ? "Warning" : "Error";
|
|
1746
|
+
console.log(`${label}: ${diagnostic.code}: ${diagnostic.message}`);
|
|
1747
|
+
if (diagnostic.suggestedFix) {
|
|
1748
|
+
console.log(` fix: ${diagnostic.suggestedFix}`);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
/**
|
|
1754
|
+
* @param {string} projectPath
|
|
1755
|
+
* @param {string|null|undefined} spec
|
|
1756
|
+
* @returns {{ ok: boolean, path: string, policy: any, pinned: Array<{ packageName: string, version: string }>, diagnostics: any[], errors: string[] }}
|
|
1757
|
+
*/
|
|
1758
|
+
function buildGeneratorPolicyPinPayload(projectPath, spec) {
|
|
1759
|
+
const projectConfigInfo = loadProjectConfig(projectPath);
|
|
1760
|
+
if (!projectConfigInfo) {
|
|
1761
|
+
const diagnostic = {
|
|
1762
|
+
code: "generator_policy_project_missing",
|
|
1763
|
+
severity: "error",
|
|
1764
|
+
message: "Cannot pin generator policy without topogram.project.json.",
|
|
1765
|
+
path: path.resolve(projectPath),
|
|
1766
|
+
suggestedFix: "Run this command in a Topogram project.",
|
|
1767
|
+
step: "generator-policy"
|
|
1768
|
+
};
|
|
1769
|
+
return {
|
|
1770
|
+
ok: false,
|
|
1771
|
+
path: path.join(path.resolve(projectPath), GENERATOR_POLICY_FILE),
|
|
1772
|
+
policy: null,
|
|
1773
|
+
pinned: [],
|
|
1774
|
+
diagnostics: [diagnostic],
|
|
1775
|
+
errors: [diagnostic.message]
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
const policyInfo = loadGeneratorPolicy(projectConfigInfo.configDir);
|
|
1779
|
+
if (policyInfo.diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
|
|
1780
|
+
const errors = policyInfo.diagnostics.filter((diagnostic) => diagnostic.severity === "error").map((diagnostic) => diagnostic.message);
|
|
1781
|
+
return {
|
|
1782
|
+
ok: false,
|
|
1783
|
+
path: policyInfo.path,
|
|
1784
|
+
policy: policyInfo.policy,
|
|
1785
|
+
pinned: [],
|
|
1786
|
+
diagnostics: policyInfo.diagnostics,
|
|
1787
|
+
errors
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
let pins = [];
|
|
1791
|
+
try {
|
|
1792
|
+
pins = spec
|
|
1793
|
+
? [parseGeneratorPolicyPin(spec)]
|
|
1794
|
+
: packageBackedGeneratorBindings(projectConfigInfo.config).map((binding) => ({
|
|
1795
|
+
packageName: binding.packageName,
|
|
1796
|
+
version: binding.version
|
|
1797
|
+
}));
|
|
1798
|
+
} catch (error) {
|
|
1799
|
+
const diagnostic = {
|
|
1800
|
+
code: "generator_policy_pin_invalid",
|
|
1801
|
+
severity: "error",
|
|
1802
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1803
|
+
path: policyInfo.path,
|
|
1804
|
+
suggestedFix: "Pass a pin such as @topogram/generator-react-web@1.",
|
|
1805
|
+
step: "generator-policy"
|
|
1806
|
+
};
|
|
1807
|
+
return {
|
|
1808
|
+
ok: false,
|
|
1809
|
+
path: policyInfo.path,
|
|
1810
|
+
policy: policyInfo.policy,
|
|
1811
|
+
pinned: [],
|
|
1812
|
+
diagnostics: [diagnostic],
|
|
1813
|
+
errors: [diagnostic.message]
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
if (pins.length === 0) {
|
|
1817
|
+
const diagnostic = {
|
|
1818
|
+
code: "generator_policy_pin_no_generators",
|
|
1819
|
+
severity: "error",
|
|
1820
|
+
message: "No package-backed topology generator bindings are available to pin.",
|
|
1821
|
+
path: projectConfigInfo.configPath,
|
|
1822
|
+
suggestedFix: "Pass an explicit pin such as @topogram/generator-react-web@1, or use bundled generators.",
|
|
1823
|
+
step: "generator-policy"
|
|
1824
|
+
};
|
|
1825
|
+
return {
|
|
1826
|
+
ok: false,
|
|
1827
|
+
path: policyInfo.path,
|
|
1828
|
+
policy: policyInfo.policy,
|
|
1829
|
+
pinned: [],
|
|
1830
|
+
diagnostics: [diagnostic],
|
|
1831
|
+
errors: [diagnostic.message]
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
const policy = policyInfo.policy || defaultGeneratorPolicy();
|
|
1835
|
+
const allowedPackages = [...policy.allowedPackages];
|
|
1836
|
+
const allowedPackageScopes = [...policy.allowedPackageScopes];
|
|
1837
|
+
const pinnedVersions = { ...policy.pinnedVersions };
|
|
1838
|
+
for (const pin of pins) {
|
|
1839
|
+
if (!allowedPackages.includes(pin.packageName)) {
|
|
1840
|
+
allowedPackages.push(pin.packageName);
|
|
1841
|
+
}
|
|
1842
|
+
const scope = packageScopeFromName(pin.packageName);
|
|
1843
|
+
if (scope && !allowedPackageScopes.includes(scope)) {
|
|
1844
|
+
allowedPackageScopes.push(scope);
|
|
1845
|
+
}
|
|
1846
|
+
pinnedVersions[pin.packageName] = pin.version;
|
|
1847
|
+
}
|
|
1848
|
+
const nextPolicy = {
|
|
1849
|
+
...policy,
|
|
1850
|
+
allowedPackageScopes,
|
|
1851
|
+
allowedPackages,
|
|
1852
|
+
pinnedVersions
|
|
1853
|
+
};
|
|
1854
|
+
writeGeneratorPolicy(projectConfigInfo.configDir, nextPolicy);
|
|
1855
|
+
return {
|
|
1856
|
+
ok: true,
|
|
1857
|
+
path: path.join(projectConfigInfo.configDir, GENERATOR_POLICY_FILE),
|
|
1858
|
+
policy: nextPolicy,
|
|
1859
|
+
pinned: pins,
|
|
1860
|
+
diagnostics: [],
|
|
1861
|
+
errors: []
|
|
1862
|
+
};
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
/**
|
|
1866
|
+
* @param {{ ok: boolean, path: string, pinned: Array<{ packageName: string, version: string }>, diagnostics: any[] }} payload
|
|
1867
|
+
* @returns {void}
|
|
1868
|
+
*/
|
|
1869
|
+
function printGeneratorPolicyPinPayload(payload) {
|
|
1870
|
+
console.log(payload.ok ? "Generator policy pin updated" : "Generator policy pin failed");
|
|
1871
|
+
console.log(`Policy: ${payload.path}`);
|
|
1872
|
+
for (const pin of payload.pinned) {
|
|
1873
|
+
console.log(`Pinned: ${pin.packageName}@${pin.version}`);
|
|
1874
|
+
}
|
|
1875
|
+
for (const diagnostic of payload.diagnostics) {
|
|
1876
|
+
console.log(`[${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.message}`);
|
|
1877
|
+
if (diagnostic.path) {
|
|
1878
|
+
console.log(` path: ${diagnostic.path}`);
|
|
1879
|
+
}
|
|
1880
|
+
if (diagnostic.suggestedFix) {
|
|
1881
|
+
console.log(` fix: ${diagnostic.suggestedFix}`);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1527
1886
|
function combineProjectValidationResults(...results) {
|
|
1528
1887
|
const errors = [];
|
|
1529
1888
|
for (const result of results) {
|
|
@@ -3340,6 +3699,7 @@ function printNewProjectResult(result, cwd) {
|
|
|
3340
3699
|
}
|
|
3341
3700
|
console.log(`Executable implementation: ${template.includesExecutableImplementation ? "yes" : "no"}`);
|
|
3342
3701
|
console.log("Policy: topogram.template-policy.json");
|
|
3702
|
+
console.log(`Generator policy: ${GENERATOR_POLICY_FILE}`);
|
|
3343
3703
|
console.log("Template files: .topogram-template-files.json");
|
|
3344
3704
|
if (template.includesExecutableImplementation) {
|
|
3345
3705
|
console.log("Trust: .topogram-template-trust.json");
|
|
@@ -3355,6 +3715,7 @@ function printNewProjectResult(result, cwd) {
|
|
|
3355
3715
|
console.log(" npm run source:status");
|
|
3356
3716
|
console.log(" npm run template:explain");
|
|
3357
3717
|
console.log(" npm run check");
|
|
3718
|
+
console.log(" npm run generator:policy:check");
|
|
3358
3719
|
if (template.includesExecutableImplementation) {
|
|
3359
3720
|
console.log(" npm run template:policy:explain");
|
|
3360
3721
|
console.log(" npm run trust:status");
|
|
@@ -6856,6 +7217,14 @@ if (args[0] === "version" || args[0] === "--version") {
|
|
|
6856
7217
|
commandArgs = { generatorShow: true, inputPath: args[2] };
|
|
6857
7218
|
} else if (args[0] === "generator" && args[1] === "check") {
|
|
6858
7219
|
commandArgs = { generatorCheck: true, inputPath: args[2] };
|
|
7220
|
+
} else if (args[0] === "generator" && args[1] === "policy" && args[2] === "init") {
|
|
7221
|
+
commandArgs = { generatorPolicyInit: true, inputPath: commandPath(3) };
|
|
7222
|
+
} else if (args[0] === "generator" && args[1] === "policy" && args[2] === "check") {
|
|
7223
|
+
commandArgs = { generatorPolicyCheck: true, inputPath: commandPath(3) };
|
|
7224
|
+
} else if (args[0] === "generator" && args[1] === "policy" && args[2] === "explain") {
|
|
7225
|
+
commandArgs = { generatorPolicyExplain: true, inputPath: commandPath(3) };
|
|
7226
|
+
} else if (args[0] === "generator" && args[1] === "policy" && args[2] === "pin") {
|
|
7227
|
+
commandArgs = { generatorPolicyPin: true, generatorPolicyPinSpec: args[3] && !args[3].startsWith("-") ? args[3] : null, inputPath: commandPath(4) };
|
|
6859
7228
|
} else if (args[0] === "generator") {
|
|
6860
7229
|
printGeneratorHelp();
|
|
6861
7230
|
process.exit(args[1] ? 1 : 0);
|
|
@@ -7052,6 +7421,10 @@ const shouldComponentBehavior = Boolean(commandArgs?.componentBehavior);
|
|
|
7052
7421
|
const shouldGeneratorList = Boolean(commandArgs?.generatorList);
|
|
7053
7422
|
const shouldGeneratorShow = Boolean(commandArgs?.generatorShow);
|
|
7054
7423
|
const shouldGeneratorCheck = Boolean(commandArgs?.generatorCheck);
|
|
7424
|
+
const shouldGeneratorPolicyInit = Boolean(commandArgs?.generatorPolicyInit);
|
|
7425
|
+
const shouldGeneratorPolicyCheck = Boolean(commandArgs?.generatorPolicyCheck);
|
|
7426
|
+
const shouldGeneratorPolicyExplain = Boolean(commandArgs?.generatorPolicyExplain);
|
|
7427
|
+
const shouldGeneratorPolicyPin = Boolean(commandArgs?.generatorPolicyPin);
|
|
7055
7428
|
const shouldTrustTemplate = Boolean(commandArgs?.trustTemplate);
|
|
7056
7429
|
const shouldTrustStatus = Boolean(commandArgs?.trustStatus);
|
|
7057
7430
|
const shouldTrustDiff = Boolean(commandArgs?.trustDiff);
|
|
@@ -7185,7 +7558,7 @@ const outIndex = args.indexOf("--out");
|
|
|
7185
7558
|
const outPath = outIndex >= 0 ? args[outIndex + 1] : null;
|
|
7186
7559
|
const effectiveOutDir = outDir || outPath || commandArgs?.defaultOutDir || null;
|
|
7187
7560
|
|
|
7188
|
-
if ((shouldCheck || shouldComponentCheck || shouldComponentBehavior || shouldGeneratorCheck || shouldValidate || shouldTrustTemplate || shouldTrustStatus || shouldTrustDiff || shouldSourceStatus || shouldTemplateExplain || shouldTemplateStatus || shouldTemplateDetach || shouldTemplatePolicyInit || shouldTemplatePolicyCheck || shouldTemplatePolicyExplain || shouldTemplatePolicyPin || shouldTemplateCheck || shouldTemplateUpdate || generateTarget === "app-bundle") && !inputPath) {
|
|
7561
|
+
if ((shouldCheck || shouldComponentCheck || shouldComponentBehavior || shouldGeneratorCheck || shouldGeneratorPolicyInit || shouldGeneratorPolicyCheck || shouldGeneratorPolicyExplain || shouldGeneratorPolicyPin || shouldValidate || shouldTrustTemplate || shouldTrustStatus || shouldTrustDiff || shouldSourceStatus || shouldTemplateExplain || shouldTemplateStatus || shouldTemplateDetach || shouldTemplatePolicyInit || shouldTemplatePolicyCheck || shouldTemplatePolicyExplain || shouldTemplatePolicyPin || shouldTemplateCheck || shouldTemplateUpdate || generateTarget === "app-bundle") && !inputPath) {
|
|
7189
7562
|
console.error("Missing required <path>.");
|
|
7190
7563
|
printUsage();
|
|
7191
7564
|
process.exit(1);
|
|
@@ -7239,7 +7612,7 @@ if (shouldQueryShow && !commandArgs?.queryShowName) {
|
|
|
7239
7612
|
process.exit(1);
|
|
7240
7613
|
}
|
|
7241
7614
|
|
|
7242
|
-
if ((shouldCheck || shouldComponentCheck || shouldComponentBehavior || shouldValidate || shouldTrustTemplate || shouldTrustStatus || shouldTrustDiff || shouldTemplateExplain || shouldTemplateStatus || shouldTemplatePolicyInit || shouldTemplatePolicyCheck || shouldTemplatePolicyExplain || shouldTemplatePolicyPin || shouldTemplateUpdate || generateTarget === "app-bundle") && inputPath) {
|
|
7615
|
+
if ((shouldCheck || shouldComponentCheck || shouldComponentBehavior || shouldValidate || shouldGeneratorPolicyInit || shouldGeneratorPolicyCheck || shouldGeneratorPolicyExplain || shouldGeneratorPolicyPin || shouldTrustTemplate || shouldTrustStatus || shouldTrustDiff || shouldTemplateExplain || shouldTemplateStatus || shouldTemplatePolicyInit || shouldTemplatePolicyCheck || shouldTemplatePolicyExplain || shouldTemplatePolicyPin || shouldTemplateUpdate || generateTarget === "app-bundle") && inputPath) {
|
|
7243
7616
|
inputPath = normalizeTopogramPath(inputPath);
|
|
7244
7617
|
}
|
|
7245
7618
|
|
|
@@ -7366,6 +7739,59 @@ try {
|
|
|
7366
7739
|
process.exit(payload.ok ? 0 : 1);
|
|
7367
7740
|
}
|
|
7368
7741
|
|
|
7742
|
+
if (shouldGeneratorPolicyInit) {
|
|
7743
|
+
const projectConfigInfo = loadProjectConfig(inputPath);
|
|
7744
|
+
if (!projectConfigInfo) {
|
|
7745
|
+
throw new Error("Cannot initialize generator policy without topogram.project.json.");
|
|
7746
|
+
}
|
|
7747
|
+
const policy = writeGeneratorPolicy(projectConfigInfo.configDir, defaultGeneratorPolicy());
|
|
7748
|
+
const payload = {
|
|
7749
|
+
ok: true,
|
|
7750
|
+
path: path.join(projectConfigInfo.configDir, GENERATOR_POLICY_FILE),
|
|
7751
|
+
policy,
|
|
7752
|
+
diagnostics: [],
|
|
7753
|
+
errors: []
|
|
7754
|
+
};
|
|
7755
|
+
if (emitJson) {
|
|
7756
|
+
console.log(stableStringify(payload));
|
|
7757
|
+
} else {
|
|
7758
|
+
console.log(`Wrote generator policy: ${payload.path}`);
|
|
7759
|
+
console.log(`Allowed package scopes: ${policy.allowedPackageScopes.join(", ") || "(none)"}`);
|
|
7760
|
+
console.log(`Allowed packages: ${policy.allowedPackages.join(", ") || "(none)"}`);
|
|
7761
|
+
}
|
|
7762
|
+
process.exit(0);
|
|
7763
|
+
}
|
|
7764
|
+
|
|
7765
|
+
if (shouldGeneratorPolicyCheck) {
|
|
7766
|
+
const payload = buildGeneratorPolicyCheckPayload(inputPath);
|
|
7767
|
+
if (emitJson) {
|
|
7768
|
+
console.log(stableStringify(payload));
|
|
7769
|
+
} else {
|
|
7770
|
+
printGeneratorPolicyCheckPayload(payload);
|
|
7771
|
+
}
|
|
7772
|
+
process.exit(payload.ok ? 0 : 1);
|
|
7773
|
+
}
|
|
7774
|
+
|
|
7775
|
+
if (shouldGeneratorPolicyExplain) {
|
|
7776
|
+
const payload = buildGeneratorPolicyExplainPayload(inputPath);
|
|
7777
|
+
if (emitJson) {
|
|
7778
|
+
console.log(stableStringify(payload));
|
|
7779
|
+
} else {
|
|
7780
|
+
printGeneratorPolicyExplainPayload(payload);
|
|
7781
|
+
}
|
|
7782
|
+
process.exit(payload.ok ? 0 : 1);
|
|
7783
|
+
}
|
|
7784
|
+
|
|
7785
|
+
if (shouldGeneratorPolicyPin) {
|
|
7786
|
+
const payload = buildGeneratorPolicyPinPayload(inputPath, commandArgs?.generatorPolicyPinSpec);
|
|
7787
|
+
if (emitJson) {
|
|
7788
|
+
console.log(stableStringify(payload));
|
|
7789
|
+
} else {
|
|
7790
|
+
printGeneratorPolicyPinPayload(payload);
|
|
7791
|
+
}
|
|
7792
|
+
process.exit(payload.ok ? 0 : 1);
|
|
7793
|
+
}
|
|
7794
|
+
|
|
7369
7795
|
if (shouldCatalogList) {
|
|
7370
7796
|
const payload = buildCatalogListPayload(catalogSource || inputPath || null);
|
|
7371
7797
|
if (emitJson) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const GENERATOR_POLICY_FILE: string;
|
|
2
|
+
export function defaultGeneratorPolicy(): any;
|
|
3
|
+
export function validateGeneratorPolicy(value: unknown, policyPath: string): any;
|
|
4
|
+
export function packageScopeFromName(packageName: string): string | null;
|
|
5
|
+
export function generatorPackageAllowed(policy: any, packageName: string): boolean;
|
|
6
|
+
export function packageBackedGeneratorBindings(projectConfig: any): any[];
|
|
7
|
+
export function loadGeneratorPolicy(projectRoot: string): any;
|
|
8
|
+
export function writeGeneratorPolicy(projectRoot: string, policy: any): any;
|
|
9
|
+
export function generatorPolicyDiagnosticsForBindings(policyInfo: any, bindings: any[], step?: string): any[];
|
|
10
|
+
export function generatorPolicyDiagnosticsForProject(projectConfig: any, projectRoot: string, step?: string): any[];
|
|
11
|
+
export function validateProjectGeneratorPolicy(projectConfig: any, options?: { configDir?: string | null; rootDir?: string | null }): { ok: boolean; errors: Array<{ message: string; loc: null }> };
|
|
12
|
+
export function parseGeneratorPolicyPin(spec: string): { packageName: string; version: string };
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { stableStringify } from "./format.js";
|
|
7
|
+
|
|
8
|
+
export const GENERATOR_POLICY_FILE = "topogram.generator-policy.json";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} GeneratorPolicy
|
|
12
|
+
* @property {string} version
|
|
13
|
+
* @property {string[]} allowedPackageScopes
|
|
14
|
+
* @property {string[]} allowedPackages
|
|
15
|
+
* @property {Record<string, string>} pinnedVersions
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} GeneratorPolicyInfo
|
|
20
|
+
* @property {string} path
|
|
21
|
+
* @property {GeneratorPolicy|null} policy
|
|
22
|
+
* @property {boolean} exists
|
|
23
|
+
* @property {GeneratorPolicyDiagnostic[]} diagnostics
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {Object} GeneratorPolicyDiagnostic
|
|
28
|
+
* @property {string} code
|
|
29
|
+
* @property {"error"|"warning"} severity
|
|
30
|
+
* @property {string} message
|
|
31
|
+
* @property {string|null} path
|
|
32
|
+
* @property {string|null} suggestedFix
|
|
33
|
+
* @property {string|null} step
|
|
34
|
+
* @property {string|null} [componentId]
|
|
35
|
+
* @property {string|null} [generatorId]
|
|
36
|
+
* @property {string|null} [packageName]
|
|
37
|
+
* @property {string|null} [version]
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {Object} PackageGeneratorBinding
|
|
42
|
+
* @property {string} componentId
|
|
43
|
+
* @property {string} componentType
|
|
44
|
+
* @property {string} projection
|
|
45
|
+
* @property {string} generatorId
|
|
46
|
+
* @property {string} version
|
|
47
|
+
* @property {string} packageName
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {Record<string, any>} input
|
|
52
|
+
* @returns {GeneratorPolicyDiagnostic}
|
|
53
|
+
*/
|
|
54
|
+
function generatorPolicyDiagnostic(input) {
|
|
55
|
+
return {
|
|
56
|
+
code: String(input.code || "generator_policy_failed"),
|
|
57
|
+
severity: input.severity === "warning" ? "warning" : "error",
|
|
58
|
+
message: String(input.message || "Generator policy check failed."),
|
|
59
|
+
path: typeof input.path === "string" ? input.path : null,
|
|
60
|
+
suggestedFix: typeof input.suggestedFix === "string" ? input.suggestedFix : null,
|
|
61
|
+
step: typeof input.step === "string" ? input.step : null,
|
|
62
|
+
componentId: typeof input.componentId === "string" ? input.componentId : null,
|
|
63
|
+
generatorId: typeof input.generatorId === "string" ? input.generatorId : null,
|
|
64
|
+
packageName: typeof input.packageName === "string" ? input.packageName : null,
|
|
65
|
+
version: typeof input.version === "string" ? input.version : null
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {unknown} value
|
|
71
|
+
* @param {string} fieldName
|
|
72
|
+
* @param {string} policyPath
|
|
73
|
+
* @returns {string[]}
|
|
74
|
+
*/
|
|
75
|
+
function optionalStringArray(value, fieldName, policyPath) {
|
|
76
|
+
if (value == null) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
if (!Array.isArray(value)) {
|
|
80
|
+
throw new Error(`${policyPath} ${fieldName} must be an array of strings.`);
|
|
81
|
+
}
|
|
82
|
+
return value.map((item) => {
|
|
83
|
+
if (typeof item !== "string" || item.length === 0) {
|
|
84
|
+
throw new Error(`${policyPath} ${fieldName} must contain only non-empty strings.`);
|
|
85
|
+
}
|
|
86
|
+
return item;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {unknown} value
|
|
92
|
+
* @param {string} policyPath
|
|
93
|
+
* @returns {Record<string, string>}
|
|
94
|
+
*/
|
|
95
|
+
function optionalStringRecord(value, policyPath) {
|
|
96
|
+
if (value == null) {
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
99
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
100
|
+
throw new Error(`${policyPath} pinnedVersions must be an object of package-or-generator ids to versions.`);
|
|
101
|
+
}
|
|
102
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => {
|
|
103
|
+
if (typeof item !== "string" || item.length === 0) {
|
|
104
|
+
throw new Error(`${policyPath} pinnedVersions['${key}'] must be a non-empty string.`);
|
|
105
|
+
}
|
|
106
|
+
return [key, item];
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @returns {GeneratorPolicy}
|
|
112
|
+
*/
|
|
113
|
+
export function defaultGeneratorPolicy() {
|
|
114
|
+
return {
|
|
115
|
+
version: "0.1",
|
|
116
|
+
allowedPackageScopes: ["@topogram"],
|
|
117
|
+
allowedPackages: [],
|
|
118
|
+
pinnedVersions: {}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {unknown} value
|
|
124
|
+
* @param {string} policyPath
|
|
125
|
+
* @returns {GeneratorPolicy}
|
|
126
|
+
*/
|
|
127
|
+
export function validateGeneratorPolicy(value, policyPath) {
|
|
128
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
129
|
+
throw new Error(`${GENERATOR_POLICY_FILE} must contain a JSON object.`);
|
|
130
|
+
}
|
|
131
|
+
const raw = /** @type {Record<string, unknown>} */ (value);
|
|
132
|
+
const defaults = defaultGeneratorPolicy();
|
|
133
|
+
return {
|
|
134
|
+
version: typeof raw.version === "string" && raw.version ? raw.version : defaults.version,
|
|
135
|
+
allowedPackageScopes: raw.allowedPackageScopes == null
|
|
136
|
+
? defaults.allowedPackageScopes
|
|
137
|
+
: optionalStringArray(raw.allowedPackageScopes, "allowedPackageScopes", policyPath),
|
|
138
|
+
allowedPackages: optionalStringArray(raw.allowedPackages, "allowedPackages", policyPath),
|
|
139
|
+
pinnedVersions: optionalStringRecord(raw.pinnedVersions, policyPath)
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {string} packageName
|
|
145
|
+
* @returns {string|null}
|
|
146
|
+
*/
|
|
147
|
+
export function packageScopeFromName(packageName) {
|
|
148
|
+
return packageName.startsWith("@") ? packageName.split("/")[0] || null : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {string} allowed
|
|
153
|
+
* @param {string|null} scope
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
function packageScopeMatches(allowed, scope) {
|
|
157
|
+
return Boolean(scope && (allowed === scope || allowed === `${scope}/*`));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @param {GeneratorPolicy} policy
|
|
162
|
+
* @param {string} packageName
|
|
163
|
+
* @returns {boolean}
|
|
164
|
+
*/
|
|
165
|
+
export function generatorPackageAllowed(policy, packageName) {
|
|
166
|
+
if (policy.allowedPackages.includes(packageName)) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
const scope = packageScopeFromName(packageName);
|
|
170
|
+
return policy.allowedPackageScopes.some((allowed) => packageScopeMatches(allowed, scope));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @param {Record<string, any>} projectConfig
|
|
175
|
+
* @returns {PackageGeneratorBinding[]}
|
|
176
|
+
*/
|
|
177
|
+
export function packageBackedGeneratorBindings(projectConfig) {
|
|
178
|
+
const components = Array.isArray(projectConfig?.topology?.components)
|
|
179
|
+
? projectConfig.topology.components
|
|
180
|
+
: [];
|
|
181
|
+
return components
|
|
182
|
+
.filter((component) => typeof component?.generator?.package === "string" && component.generator.package.length > 0)
|
|
183
|
+
.map((component) => ({
|
|
184
|
+
componentId: String(component.id || "unknown"),
|
|
185
|
+
componentType: String(component.type || "unknown"),
|
|
186
|
+
projection: String(component.projection || "unknown"),
|
|
187
|
+
generatorId: String(component.generator.id || "unknown"),
|
|
188
|
+
version: String(component.generator.version || "unknown"),
|
|
189
|
+
packageName: String(component.generator.package)
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @param {string} projectRoot
|
|
195
|
+
* @returns {GeneratorPolicyInfo}
|
|
196
|
+
*/
|
|
197
|
+
export function loadGeneratorPolicy(projectRoot) {
|
|
198
|
+
const policyPath = path.join(projectRoot, GENERATOR_POLICY_FILE);
|
|
199
|
+
if (!fs.existsSync(policyPath)) {
|
|
200
|
+
return {
|
|
201
|
+
path: policyPath,
|
|
202
|
+
policy: null,
|
|
203
|
+
exists: false,
|
|
204
|
+
diagnostics: []
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
return {
|
|
209
|
+
path: policyPath,
|
|
210
|
+
policy: validateGeneratorPolicy(JSON.parse(fs.readFileSync(policyPath, "utf8")), policyPath),
|
|
211
|
+
exists: true,
|
|
212
|
+
diagnostics: []
|
|
213
|
+
};
|
|
214
|
+
} catch (error) {
|
|
215
|
+
return {
|
|
216
|
+
path: policyPath,
|
|
217
|
+
policy: null,
|
|
218
|
+
exists: true,
|
|
219
|
+
diagnostics: [generatorPolicyDiagnostic({
|
|
220
|
+
code: "generator_policy_invalid",
|
|
221
|
+
message: error instanceof Error ? error.message : String(error),
|
|
222
|
+
path: policyPath,
|
|
223
|
+
suggestedFix: `Fix ${GENERATOR_POLICY_FILE} or regenerate it with \`topogram generator policy init\`.`,
|
|
224
|
+
step: "generator-policy"
|
|
225
|
+
})]
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @param {string} projectRoot
|
|
232
|
+
* @param {GeneratorPolicy} policy
|
|
233
|
+
* @returns {GeneratorPolicy}
|
|
234
|
+
*/
|
|
235
|
+
export function writeGeneratorPolicy(projectRoot, policy) {
|
|
236
|
+
fs.writeFileSync(path.join(projectRoot, GENERATOR_POLICY_FILE), `${stableStringify(policy)}\n`, "utf8");
|
|
237
|
+
return policy;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* @param {GeneratorPolicyInfo} policyInfo
|
|
242
|
+
* @returns {GeneratorPolicy}
|
|
243
|
+
*/
|
|
244
|
+
function effectivePolicy(policyInfo) {
|
|
245
|
+
return policyInfo.policy || defaultGeneratorPolicy();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* @param {GeneratorPolicyInfo} policyInfo
|
|
250
|
+
* @param {PackageGeneratorBinding[]} bindings
|
|
251
|
+
* @param {string} step
|
|
252
|
+
* @returns {GeneratorPolicyDiagnostic[]}
|
|
253
|
+
*/
|
|
254
|
+
export function generatorPolicyDiagnosticsForBindings(policyInfo, bindings, step = "generator-policy") {
|
|
255
|
+
if (policyInfo.diagnostics.length > 0) {
|
|
256
|
+
return policyInfo.diagnostics;
|
|
257
|
+
}
|
|
258
|
+
const policy = effectivePolicy(policyInfo);
|
|
259
|
+
/** @type {GeneratorPolicyDiagnostic[]} */
|
|
260
|
+
const diagnostics = [];
|
|
261
|
+
for (const binding of bindings) {
|
|
262
|
+
if (!generatorPackageAllowed(policy, binding.packageName)) {
|
|
263
|
+
const scope = packageScopeFromName(binding.packageName);
|
|
264
|
+
const allowedScopes = policy.allowedPackageScopes.join(", ") || "(none)";
|
|
265
|
+
const allowedPackages = policy.allowedPackages.join(", ") || "(none)";
|
|
266
|
+
diagnostics.push(generatorPolicyDiagnostic({
|
|
267
|
+
code: "generator_package_denied",
|
|
268
|
+
message: `Component '${binding.componentId}' generator package '${binding.packageName}' is not allowed by ${GENERATOR_POLICY_FILE}.`,
|
|
269
|
+
path: policyInfo.path,
|
|
270
|
+
suggestedFix: `Review '${binding.packageName}', then run \`topogram generator policy pin ${binding.packageName}@${binding.version}\` or add '${scope || binding.packageName}' to ${GENERATOR_POLICY_FILE}.`,
|
|
271
|
+
step,
|
|
272
|
+
componentId: binding.componentId,
|
|
273
|
+
generatorId: binding.generatorId,
|
|
274
|
+
packageName: binding.packageName,
|
|
275
|
+
version: binding.version
|
|
276
|
+
}));
|
|
277
|
+
diagnostics[diagnostics.length - 1].message += ` Allowed scopes: ${allowedScopes}; allowed packages: ${allowedPackages}.`;
|
|
278
|
+
}
|
|
279
|
+
const pinnedVersion = policy.pinnedVersions[binding.packageName] || policy.pinnedVersions[binding.generatorId] || null;
|
|
280
|
+
if (pinnedVersion && pinnedVersion !== binding.version) {
|
|
281
|
+
diagnostics.push(generatorPolicyDiagnostic({
|
|
282
|
+
code: "generator_version_mismatch",
|
|
283
|
+
message: `Component '${binding.componentId}' generator '${binding.generatorId}' uses version '${binding.version}', but ${GENERATOR_POLICY_FILE} pins '${binding.packageName}' to '${pinnedVersion}'.`,
|
|
284
|
+
path: policyInfo.path,
|
|
285
|
+
suggestedFix: `Use generator version '${pinnedVersion}', or run \`topogram generator policy pin ${binding.packageName}@${binding.version}\` after review.`,
|
|
286
|
+
step,
|
|
287
|
+
componentId: binding.componentId,
|
|
288
|
+
generatorId: binding.generatorId,
|
|
289
|
+
packageName: binding.packageName,
|
|
290
|
+
version: binding.version
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return diagnostics;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* @param {Record<string, any>} projectConfig
|
|
299
|
+
* @param {string} projectRoot
|
|
300
|
+
* @param {string} [step]
|
|
301
|
+
* @returns {GeneratorPolicyDiagnostic[]}
|
|
302
|
+
*/
|
|
303
|
+
export function generatorPolicyDiagnosticsForProject(projectConfig, projectRoot, step = "generator-policy") {
|
|
304
|
+
const bindings = packageBackedGeneratorBindings(projectConfig);
|
|
305
|
+
const policyInfo = loadGeneratorPolicy(projectRoot);
|
|
306
|
+
return generatorPolicyDiagnosticsForBindings(policyInfo, bindings, step);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* @param {Record<string, any>} projectConfig
|
|
311
|
+
* @param {{ configDir?: string|null, rootDir?: string|null }} [options]
|
|
312
|
+
* @returns {{ ok: boolean, errors: Array<{ message: string, loc: null }> }}
|
|
313
|
+
*/
|
|
314
|
+
export function validateProjectGeneratorPolicy(projectConfig, options = {}) {
|
|
315
|
+
const projectRoot = options.configDir || options.rootDir || process.cwd();
|
|
316
|
+
const diagnostics = generatorPolicyDiagnosticsForProject(projectConfig, projectRoot, "project-config");
|
|
317
|
+
const errors = diagnostics
|
|
318
|
+
.filter((diagnostic) => diagnostic.severity === "error")
|
|
319
|
+
.map((diagnostic) => ({
|
|
320
|
+
message: diagnostic.suggestedFix
|
|
321
|
+
? `${diagnostic.message} Suggested fix: ${diagnostic.suggestedFix}`
|
|
322
|
+
: diagnostic.message,
|
|
323
|
+
loc: null
|
|
324
|
+
}));
|
|
325
|
+
return {
|
|
326
|
+
ok: errors.length === 0,
|
|
327
|
+
errors
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* @param {string} spec
|
|
333
|
+
* @returns {{ packageName: string, version: string }}
|
|
334
|
+
*/
|
|
335
|
+
export function parseGeneratorPolicyPin(spec) {
|
|
336
|
+
const separator = spec.lastIndexOf("@");
|
|
337
|
+
if (separator <= 0 || separator === spec.length - 1) {
|
|
338
|
+
throw new Error("Generator policy pin requires a package name and generator version, for example @topogram/generator-react-web@1.");
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
packageName: spec.slice(0, separator),
|
|
342
|
+
version: spec.slice(separator + 1)
|
|
343
|
+
};
|
|
344
|
+
}
|
package/src/new-project.js
CHANGED
|
@@ -6,6 +6,7 @@ import crypto from "node:crypto";
|
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
|
|
9
|
+
import { defaultGeneratorPolicy, writeGeneratorPolicy } from "./generator-policy.js";
|
|
9
10
|
import { writeTemplateTrustRecord } from "./template-trust.js";
|
|
10
11
|
|
|
11
12
|
const CLI_PACKAGE_NAME = "@topogram/cli";
|
|
@@ -1974,6 +1975,8 @@ function writeProjectPackage(projectRoot, engineRoot, template) {
|
|
|
1974
1975
|
"template:detach:dry-run": "topogram template detach --dry-run",
|
|
1975
1976
|
"template:policy:check": "topogram template policy check",
|
|
1976
1977
|
"template:policy:explain": "topogram template policy explain",
|
|
1978
|
+
"generator:policy:check": "topogram generator policy check",
|
|
1979
|
+
"generator:policy:explain": "topogram generator policy explain",
|
|
1977
1980
|
"template:update:status": "topogram template update --status",
|
|
1978
1981
|
"template:update:recommend": "topogram template update --recommend",
|
|
1979
1982
|
"template:update:plan": "topogram template update --plan",
|
|
@@ -2049,6 +2052,8 @@ Useful inspection:
|
|
|
2049
2052
|
npm run template:detach:dry-run
|
|
2050
2053
|
npm run template:policy:check
|
|
2051
2054
|
npm run template:policy:explain
|
|
2055
|
+
npm run generator:policy:check
|
|
2056
|
+
npm run generator:policy:explain
|
|
2052
2057
|
npm run template:update:status
|
|
2053
2058
|
npm run template:update:recommend
|
|
2054
2059
|
npm run template:update:plan
|
|
@@ -2079,6 +2084,7 @@ function writeProjectReadme(projectRoot, projectConfig) {
|
|
|
2079
2084
|
"npm run template:explain",
|
|
2080
2085
|
"npm run check",
|
|
2081
2086
|
"npm run template:policy:check",
|
|
2087
|
+
"npm run generator:policy:check",
|
|
2082
2088
|
...(template.includesExecutableImplementation ? [
|
|
2083
2089
|
"npm run template:policy:explain",
|
|
2084
2090
|
"npm run trust:status"
|
|
@@ -2154,6 +2160,7 @@ export function createNewProject({
|
|
|
2154
2160
|
writeProjectReadme(projectRoot, projectConfig);
|
|
2155
2161
|
writeTemplateFilesManifest(projectRoot, projectConfig);
|
|
2156
2162
|
writeTemplatePolicy(projectRoot, defaultTemplatePolicyForTemplate(template));
|
|
2163
|
+
writeGeneratorPolicy(projectRoot, defaultGeneratorPolicy());
|
|
2157
2164
|
|
|
2158
2165
|
const warnings = [];
|
|
2159
2166
|
if (template.manifest.includesExecutableImplementation) {
|
package/src/project-config.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
resolveGeneratorManifestForBinding,
|
|
9
9
|
validateGeneratorManifest
|
|
10
10
|
} from "./generator/registry.js";
|
|
11
|
+
import { validateProjectGeneratorPolicy } from "./generator-policy.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* @typedef {Object} GeneratorBinding
|
|
@@ -434,6 +435,10 @@ export function validateProjectConfig(config, graph = null, options = {}) {
|
|
|
434
435
|
for (const component of config.topology.components) {
|
|
435
436
|
validateComponentShape(errors, component, seenIds);
|
|
436
437
|
}
|
|
438
|
+
const generatorPolicy = validateProjectGeneratorPolicy(config, options);
|
|
439
|
+
for (const error of generatorPolicy.errors) {
|
|
440
|
+
pushError(errors, error.message, error.loc);
|
|
441
|
+
}
|
|
437
442
|
if (graph) {
|
|
438
443
|
const projections = projectionById(graph);
|
|
439
444
|
for (const component of config.topology.components) {
|