@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topogram/cli",
3
- "version": "0.3.41",
3
+ "version": "0.3.42",
4
4
  "description": "Topogram CLI for checking Topogram workspaces and generating app bundles.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
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
+ }
@@ -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) {
@@ -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) {