@topogram/cli 0.3.40 → 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 +458 -8
- package/src/generator-policy.d.ts +12 -0
- package/src/generator-policy.js +344 -0
- package/src/new-project.js +22 -6
- package/src/project-config.js +5 -0
- package/src/template-trust.js +66 -18
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
getTemplateTrustStatus,
|
|
33
33
|
implementationRequiresTrust,
|
|
34
34
|
TEMPLATE_TRUST_FILE,
|
|
35
|
+
templateTrustRecoveryGuidance,
|
|
35
36
|
validateProjectImplementationTrust,
|
|
36
37
|
writeTemplateTrustRecord
|
|
37
38
|
} from "./template-trust.js";
|
|
@@ -43,6 +44,17 @@ import {
|
|
|
43
44
|
loadPackageGeneratorManifest,
|
|
44
45
|
packageGeneratorInstallCommand
|
|
45
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";
|
|
46
58
|
import {
|
|
47
59
|
buildAuthHintsQueryPayload,
|
|
48
60
|
buildAuthReviewPacketPayload,
|
|
@@ -206,6 +218,10 @@ function printUsage(options = {}) {
|
|
|
206
218
|
console.log(" or: topogram generator list [--json]");
|
|
207
219
|
console.log(" or: topogram generator show <id-or-package> [--json]");
|
|
208
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]");
|
|
209
225
|
console.log(" or: topogram new <path> [--template hello-web|todo|./local-template|@scope/template]");
|
|
210
226
|
console.log(" or: topogram new --list-templates [--json] [--catalog <path-or-source>]");
|
|
211
227
|
console.log("");
|
|
@@ -227,6 +243,7 @@ function printUsage(options = {}) {
|
|
|
227
243
|
console.log(" topogram generator list");
|
|
228
244
|
console.log(" topogram generator show @topogram/generator-react-web");
|
|
229
245
|
console.log(" topogram generator check ./generator-package");
|
|
246
|
+
console.log(" topogram generator policy check");
|
|
230
247
|
console.log(" topogram generate");
|
|
231
248
|
console.log(" topogram import ./existing-app --out ./imported-topogram");
|
|
232
249
|
console.log(" topogram import diff ./imported-topogram");
|
|
@@ -568,6 +585,10 @@ function printGeneratorHelp() {
|
|
|
568
585
|
console.log("Usage: topogram generator list [--json]");
|
|
569
586
|
console.log(" or: topogram generator show <id-or-package> [--json]");
|
|
570
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]");
|
|
571
592
|
console.log("");
|
|
572
593
|
console.log("Inspects generator manifests and checks generator pack conformance.");
|
|
573
594
|
console.log("");
|
|
@@ -576,6 +597,7 @@ function printGeneratorHelp() {
|
|
|
576
597
|
console.log(" - show accepts an installed package name or a bundled fallback generator id.");
|
|
577
598
|
console.log(" - check validates a local generator package path or an already installed package.");
|
|
578
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.`);
|
|
579
601
|
console.log("");
|
|
580
602
|
console.log("Examples:");
|
|
581
603
|
console.log(" topogram generator list");
|
|
@@ -584,6 +606,9 @@ function printGeneratorHelp() {
|
|
|
584
606
|
console.log(" topogram generator show @scope/topogram-generator-web --json");
|
|
585
607
|
console.log(" topogram generator check ./generator-package");
|
|
586
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");
|
|
587
612
|
}
|
|
588
613
|
|
|
589
614
|
function printTemplateHelp() {
|
|
@@ -788,7 +813,7 @@ function printImportHelp() {
|
|
|
788
813
|
function printCheckHelp() {
|
|
789
814
|
console.log("Usage: topogram check [path] [--json]");
|
|
790
815
|
console.log("");
|
|
791
|
-
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.");
|
|
792
817
|
console.log("");
|
|
793
818
|
console.log("Defaults: path is ./topogram.");
|
|
794
819
|
console.log("");
|
|
@@ -1523,6 +1548,341 @@ function printGeneratorShow(payload) {
|
|
|
1523
1548
|
console.log(stableStringify(payload.exampleTopologyBinding));
|
|
1524
1549
|
}
|
|
1525
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
|
+
|
|
1526
1886
|
function combineProjectValidationResults(...results) {
|
|
1527
1887
|
const errors = [];
|
|
1528
1888
|
for (const result of results) {
|
|
@@ -3339,6 +3699,7 @@ function printNewProjectResult(result, cwd) {
|
|
|
3339
3699
|
}
|
|
3340
3700
|
console.log(`Executable implementation: ${template.includesExecutableImplementation ? "yes" : "no"}`);
|
|
3341
3701
|
console.log("Policy: topogram.template-policy.json");
|
|
3702
|
+
console.log(`Generator policy: ${GENERATOR_POLICY_FILE}`);
|
|
3342
3703
|
console.log("Template files: .topogram-template-files.json");
|
|
3343
3704
|
if (template.includesExecutableImplementation) {
|
|
3344
3705
|
console.log("Trust: .topogram-template-trust.json");
|
|
@@ -3354,6 +3715,7 @@ function printNewProjectResult(result, cwd) {
|
|
|
3354
3715
|
console.log(" npm run source:status");
|
|
3355
3716
|
console.log(" npm run template:explain");
|
|
3356
3717
|
console.log(" npm run check");
|
|
3718
|
+
console.log(" npm run generator:policy:check");
|
|
3357
3719
|
if (template.includesExecutableImplementation) {
|
|
3358
3720
|
console.log(" npm run template:policy:explain");
|
|
3359
3721
|
console.log(" npm run trust:status");
|
|
@@ -5951,6 +6313,15 @@ function diagnosticForTemplateCreateFailure(message, templateSpec, step) {
|
|
|
5951
6313
|
step
|
|
5952
6314
|
});
|
|
5953
6315
|
}
|
|
6316
|
+
if (message.includes("unsupported symlink")) {
|
|
6317
|
+
return templateCheckDiagnostic({
|
|
6318
|
+
code: "template_symlink_unsupported",
|
|
6319
|
+
message,
|
|
6320
|
+
path: path.isAbsolute(templateSpec) ? templateSpec : null,
|
|
6321
|
+
suggestedFix: "Replace template symlinks with real files or directories, then rerun `topogram new` or `topogram template check`.",
|
|
6322
|
+
step
|
|
6323
|
+
});
|
|
6324
|
+
}
|
|
5954
6325
|
return templateCheckDiagnostic({
|
|
5955
6326
|
code: "template_create_failed",
|
|
5956
6327
|
message,
|
|
@@ -5968,13 +6339,15 @@ function diagnosticForTemplateCreateFailure(message, templateSpec, step) {
|
|
|
5968
6339
|
*/
|
|
5969
6340
|
function diagnosticForStarterCheckFailure(error, step, configPath) {
|
|
5970
6341
|
const locFile = typeof error?.loc?.file === "string" ? error.loc.file : null;
|
|
5971
|
-
const isTrust = error.message.includes(TEMPLATE_TRUST_FILE)
|
|
6342
|
+
const isTrust = error.message.includes(TEMPLATE_TRUST_FILE) ||
|
|
6343
|
+
error.message.includes("unsupported symlink") ||
|
|
6344
|
+
error.message.includes("must be under implementation/");
|
|
5972
6345
|
return templateCheckDiagnostic({
|
|
5973
6346
|
code: isTrust ? "template_trust_invalid" : "starter_check_failed",
|
|
5974
6347
|
message: error.message,
|
|
5975
6348
|
path: locFile || configPath,
|
|
5976
6349
|
suggestedFix: isTrust
|
|
5977
|
-
?
|
|
6350
|
+
? templateTrustRecoveryGuidance(error.message)
|
|
5978
6351
|
: "Fix the generated Topogram source or topogram.project.json so topogram check passes.",
|
|
5979
6352
|
step
|
|
5980
6353
|
});
|
|
@@ -6176,7 +6549,7 @@ function buildTemplateCheckPayload(templateSpec) {
|
|
|
6176
6549
|
code: "template_trust_invalid",
|
|
6177
6550
|
message: issue,
|
|
6178
6551
|
path: trustStatus.trustPath,
|
|
6179
|
-
suggestedFix:
|
|
6552
|
+
suggestedFix: templateTrustRecoveryGuidance(issue),
|
|
6180
6553
|
step: "executable-implementation-trust"
|
|
6181
6554
|
}));
|
|
6182
6555
|
steps.push(templateCheckStep("executable-implementation-trust", trustStatus.ok, {
|
|
@@ -6844,6 +7217,14 @@ if (args[0] === "version" || args[0] === "--version") {
|
|
|
6844
7217
|
commandArgs = { generatorShow: true, inputPath: args[2] };
|
|
6845
7218
|
} else if (args[0] === "generator" && args[1] === "check") {
|
|
6846
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) };
|
|
6847
7228
|
} else if (args[0] === "generator") {
|
|
6848
7229
|
printGeneratorHelp();
|
|
6849
7230
|
process.exit(args[1] ? 1 : 0);
|
|
@@ -7040,6 +7421,10 @@ const shouldComponentBehavior = Boolean(commandArgs?.componentBehavior);
|
|
|
7040
7421
|
const shouldGeneratorList = Boolean(commandArgs?.generatorList);
|
|
7041
7422
|
const shouldGeneratorShow = Boolean(commandArgs?.generatorShow);
|
|
7042
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);
|
|
7043
7428
|
const shouldTrustTemplate = Boolean(commandArgs?.trustTemplate);
|
|
7044
7429
|
const shouldTrustStatus = Boolean(commandArgs?.trustStatus);
|
|
7045
7430
|
const shouldTrustDiff = Boolean(commandArgs?.trustDiff);
|
|
@@ -7173,7 +7558,7 @@ const outIndex = args.indexOf("--out");
|
|
|
7173
7558
|
const outPath = outIndex >= 0 ? args[outIndex + 1] : null;
|
|
7174
7559
|
const effectiveOutDir = outDir || outPath || commandArgs?.defaultOutDir || null;
|
|
7175
7560
|
|
|
7176
|
-
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) {
|
|
7177
7562
|
console.error("Missing required <path>.");
|
|
7178
7563
|
printUsage();
|
|
7179
7564
|
process.exit(1);
|
|
@@ -7227,7 +7612,7 @@ if (shouldQueryShow && !commandArgs?.queryShowName) {
|
|
|
7227
7612
|
process.exit(1);
|
|
7228
7613
|
}
|
|
7229
7614
|
|
|
7230
|
-
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) {
|
|
7231
7616
|
inputPath = normalizeTopogramPath(inputPath);
|
|
7232
7617
|
}
|
|
7233
7618
|
|
|
@@ -7354,6 +7739,59 @@ try {
|
|
|
7354
7739
|
process.exit(payload.ok ? 0 : 1);
|
|
7355
7740
|
}
|
|
7356
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
|
+
|
|
7357
7795
|
if (shouldCatalogList) {
|
|
7358
7796
|
const payload = buildCatalogListPayload(catalogSource || inputPath || null);
|
|
7359
7797
|
if (emitJson) {
|
|
@@ -7853,7 +8291,10 @@ try {
|
|
|
7853
8291
|
console.log(`Removed: ${filePath}`);
|
|
7854
8292
|
}
|
|
7855
8293
|
if (!status.ok) {
|
|
7856
|
-
|
|
8294
|
+
const guidance = templateTrustRecoveryGuidance(status.issues);
|
|
8295
|
+
if (guidance) {
|
|
8296
|
+
console.log(guidance);
|
|
8297
|
+
}
|
|
7857
8298
|
}
|
|
7858
8299
|
}
|
|
7859
8300
|
process.exit(status.ok ? 0 : 1);
|
|
@@ -7882,6 +8323,12 @@ try {
|
|
|
7882
8323
|
for (const issue of diff.status.issues) {
|
|
7883
8324
|
console.log(`Issue: ${issue}`);
|
|
7884
8325
|
}
|
|
8326
|
+
if (!diff.ok) {
|
|
8327
|
+
const guidance = templateTrustRecoveryGuidance(diff.status.issues);
|
|
8328
|
+
if (guidance) {
|
|
8329
|
+
console.log(guidance);
|
|
8330
|
+
}
|
|
8331
|
+
}
|
|
7885
8332
|
} else {
|
|
7886
8333
|
console.log(diff.ok ? "Template trust diff: no implementation changes." : "Template trust diff: review required");
|
|
7887
8334
|
for (const file of diff.files) {
|
|
@@ -7906,7 +8353,10 @@ try {
|
|
|
7906
8353
|
}
|
|
7907
8354
|
if (!diff.ok) {
|
|
7908
8355
|
console.log("");
|
|
7909
|
-
|
|
8356
|
+
const guidance = templateTrustRecoveryGuidance(diff.status.issues);
|
|
8357
|
+
if (guidance) {
|
|
8358
|
+
console.log(guidance);
|
|
8359
|
+
}
|
|
7910
8360
|
}
|
|
7911
8361
|
}
|
|
7912
8362
|
process.exit(diff.ok ? 0 : 1);
|
|
@@ -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";
|
|
@@ -32,6 +33,15 @@ const SURFACE_ORDER = new Map([
|
|
|
32
33
|
["native", 40]
|
|
33
34
|
]);
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} templateId
|
|
38
|
+
* @param {string} relativePath
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
function unsupportedTemplateSymlinkMessage(templateId, relativePath) {
|
|
42
|
+
return `Template '${templateId}' contains unsupported symlink '${relativePath}'. Template packs must copy real files because Topogram records hashes for copied topogram/ and implementation/ content; symlinks can point outside the trusted template root. Replace the symlink with a real file or directory before running topogram new or topogram template check.`;
|
|
43
|
+
}
|
|
44
|
+
|
|
35
45
|
/**
|
|
36
46
|
* @typedef {Object} CreateNewProjectOptions
|
|
37
47
|
* @property {string} targetPath
|
|
@@ -342,7 +352,7 @@ function assertTemplateTreeHasNoSymlinks(root, currentDir, label, templateId) {
|
|
|
342
352
|
const rootStat = fs.lstatSync(currentDir);
|
|
343
353
|
const relativeRoot = path.relative(root, currentDir).replace(/\\/g, "/") || label;
|
|
344
354
|
if (rootStat.isSymbolicLink()) {
|
|
345
|
-
throw new Error(
|
|
355
|
+
throw new Error(unsupportedTemplateSymlinkMessage(templateId, relativeRoot));
|
|
346
356
|
}
|
|
347
357
|
if (!rootStat.isDirectory()) {
|
|
348
358
|
return;
|
|
@@ -351,7 +361,7 @@ function assertTemplateTreeHasNoSymlinks(root, currentDir, label, templateId) {
|
|
|
351
361
|
const entryPath = path.join(currentDir, entry.name);
|
|
352
362
|
const relativePath = path.relative(root, entryPath).replace(/\\/g, "/");
|
|
353
363
|
if (entry.isSymbolicLink()) {
|
|
354
|
-
throw new Error(
|
|
364
|
+
throw new Error(unsupportedTemplateSymlinkMessage(templateId, relativePath));
|
|
355
365
|
}
|
|
356
366
|
if (entry.isDirectory()) {
|
|
357
367
|
assertTemplateTreeHasNoSymlinks(root, entryPath, label, templateId);
|
|
@@ -368,10 +378,10 @@ function validateTemplateRoot(templateRoot) {
|
|
|
368
378
|
const topogramRoot = path.join(templateRoot, "topogram");
|
|
369
379
|
const projectConfigPath = path.join(templateRoot, "topogram.project.json");
|
|
370
380
|
if (fs.existsSync(topogramRoot) && fs.lstatSync(topogramRoot).isSymbolicLink()) {
|
|
371
|
-
throw new Error(
|
|
381
|
+
throw new Error(unsupportedTemplateSymlinkMessage(manifest.id, "topogram"));
|
|
372
382
|
}
|
|
373
383
|
if (fs.existsSync(projectConfigPath) && fs.lstatSync(projectConfigPath).isSymbolicLink()) {
|
|
374
|
-
throw new Error(
|
|
384
|
+
throw new Error(unsupportedTemplateSymlinkMessage(manifest.id, "topogram.project.json"));
|
|
375
385
|
}
|
|
376
386
|
if (!fs.existsSync(topogramRoot) || !fs.statSync(topogramRoot).isDirectory()) {
|
|
377
387
|
throw new Error(`Template '${manifest.id}' is missing topogram/.`);
|
|
@@ -383,7 +393,7 @@ function validateTemplateRoot(templateRoot) {
|
|
|
383
393
|
if (manifest.includesExecutableImplementation) {
|
|
384
394
|
const implementationRoot = path.join(templateRoot, "implementation");
|
|
385
395
|
if (fs.existsSync(implementationRoot) && fs.lstatSync(implementationRoot).isSymbolicLink()) {
|
|
386
|
-
throw new Error(
|
|
396
|
+
throw new Error(unsupportedTemplateSymlinkMessage(manifest.id, "implementation"));
|
|
387
397
|
}
|
|
388
398
|
if (!fs.existsSync(implementationRoot) || !fs.statSync(implementationRoot).isDirectory()) {
|
|
389
399
|
throw new Error(
|
|
@@ -1169,7 +1179,7 @@ function collectFiles(root, currentDir, files) {
|
|
|
1169
1179
|
}
|
|
1170
1180
|
const entryPath = path.join(currentDir, entry.name);
|
|
1171
1181
|
if (entry.isSymbolicLink()) {
|
|
1172
|
-
throw new Error(`Template-owned files cannot include symlink '${path.relative(root, entryPath).replace(/\\/g, "/")}'.`);
|
|
1182
|
+
throw new Error(`Template-owned files cannot include symlink '${path.relative(root, entryPath).replace(/\\/g, "/")}'. Template-owned files must be real files so Topogram can hash the exact content being trusted. Replace the symlink with a real file, then run topogram trust status, topogram trust diff, and topogram trust template after review.`);
|
|
1173
1183
|
}
|
|
1174
1184
|
if (entry.isDirectory()) {
|
|
1175
1185
|
collectFiles(root, entryPath, files);
|
|
@@ -1965,6 +1975,8 @@ function writeProjectPackage(projectRoot, engineRoot, template) {
|
|
|
1965
1975
|
"template:detach:dry-run": "topogram template detach --dry-run",
|
|
1966
1976
|
"template:policy:check": "topogram template policy check",
|
|
1967
1977
|
"template:policy:explain": "topogram template policy explain",
|
|
1978
|
+
"generator:policy:check": "topogram generator policy check",
|
|
1979
|
+
"generator:policy:explain": "topogram generator policy explain",
|
|
1968
1980
|
"template:update:status": "topogram template update --status",
|
|
1969
1981
|
"template:update:recommend": "topogram template update --recommend",
|
|
1970
1982
|
"template:update:plan": "topogram template update --plan",
|
|
@@ -2040,6 +2052,8 @@ Useful inspection:
|
|
|
2040
2052
|
npm run template:detach:dry-run
|
|
2041
2053
|
npm run template:policy:check
|
|
2042
2054
|
npm run template:policy:explain
|
|
2055
|
+
npm run generator:policy:check
|
|
2056
|
+
npm run generator:policy:explain
|
|
2043
2057
|
npm run template:update:status
|
|
2044
2058
|
npm run template:update:recommend
|
|
2045
2059
|
npm run template:update:plan
|
|
@@ -2070,6 +2084,7 @@ function writeProjectReadme(projectRoot, projectConfig) {
|
|
|
2070
2084
|
"npm run template:explain",
|
|
2071
2085
|
"npm run check",
|
|
2072
2086
|
"npm run template:policy:check",
|
|
2087
|
+
"npm run generator:policy:check",
|
|
2073
2088
|
...(template.includesExecutableImplementation ? [
|
|
2074
2089
|
"npm run template:policy:explain",
|
|
2075
2090
|
"npm run trust:status"
|
|
@@ -2145,6 +2160,7 @@ export function createNewProject({
|
|
|
2145
2160
|
writeProjectReadme(projectRoot, projectConfig);
|
|
2146
2161
|
writeTemplateFilesManifest(projectRoot, projectConfig);
|
|
2147
2162
|
writeTemplatePolicy(projectRoot, defaultTemplatePolicyForTemplate(template));
|
|
2163
|
+
writeGeneratorPolicy(projectRoot, defaultGeneratorPolicy());
|
|
2148
2164
|
|
|
2149
2165
|
const warnings = [];
|
|
2150
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) {
|
package/src/template-trust.js
CHANGED
|
@@ -19,6 +19,7 @@ export const TEMPLATE_TRUST_POLICY = "topogram-template-executable-implementatio
|
|
|
19
19
|
|
|
20
20
|
const IGNORED_IMPLEMENTATION_ENTRIES = new Set([".DS_Store", "node_modules", ".tmp"]);
|
|
21
21
|
const MAX_TEXT_DIFF_BYTES = 256 * 1024;
|
|
22
|
+
const TRUST_REVIEW_COMMANDS = "`topogram trust status`, `topogram trust diff`, and `topogram trust template`";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* @param {string} parent
|
|
@@ -46,6 +47,45 @@ function normalizeRelativePath(value) {
|
|
|
46
47
|
return value.replace(/\\/g, "/");
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} relativePath
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function unsupportedImplementationSymlinkMessage(relativePath) {
|
|
55
|
+
return `Template implementation contains unsupported symlink '${relativePath}'. Implementation trust hashes real files under implementation/; symlinks can point outside the trusted root. Replace symlinks with real files under implementation/, then run ${TRUST_REVIEW_COMMANDS} after review.`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} modulePath
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
function implementationOutsideRootMessage(modulePath) {
|
|
63
|
+
return `Template implementation module '${modulePath}' must be under implementation/ for template-attached projects. Keep executable template code inside implementation/ so the trust record covers what topogram generate may load. Move the module back under implementation/, then run ${TRUST_REVIEW_COMMANDS} after review.`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {string|string[]} issueOrIssues
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
export function templateTrustRecoveryGuidance(issueOrIssues) {
|
|
71
|
+
const issues = Array.isArray(issueOrIssues) ? issueOrIssues : [issueOrIssues];
|
|
72
|
+
const text = issues.join("\n");
|
|
73
|
+
if (issues.length > 0 && issues.every((issue) =>
|
|
74
|
+
issue.includes("topogram trust status") &&
|
|
75
|
+
issue.includes("topogram trust diff") &&
|
|
76
|
+
issue.includes("topogram trust template")
|
|
77
|
+
)) {
|
|
78
|
+
return "";
|
|
79
|
+
}
|
|
80
|
+
if (text.includes("unsupported symlink")) {
|
|
81
|
+
return `Replace symlinks with real files under implementation/, then run ${TRUST_REVIEW_COMMANDS} after review.`;
|
|
82
|
+
}
|
|
83
|
+
if (text.includes("must be under implementation/")) {
|
|
84
|
+
return `Keep executable template code under implementation/ so it can be hashed and trusted; move the module back under implementation/, then run ${TRUST_REVIEW_COMMANDS} after review.`;
|
|
85
|
+
}
|
|
86
|
+
return `Run \`topogram trust status\` and \`topogram trust diff\` to review implementation changes; after review, run \`topogram trust template\` to trust the current files.`;
|
|
87
|
+
}
|
|
88
|
+
|
|
49
89
|
/**
|
|
50
90
|
* @param {string} value
|
|
51
91
|
* @returns {string}
|
|
@@ -187,7 +227,7 @@ function collectImplementationFiles(implementationRoot, currentDir, files) {
|
|
|
187
227
|
const entryPath = path.join(currentDir, entry.name);
|
|
188
228
|
const relativePath = normalizeRelativePath(path.relative(implementationRoot, entryPath));
|
|
189
229
|
if (entry.isSymbolicLink()) {
|
|
190
|
-
throw new Error(
|
|
230
|
+
throw new Error(unsupportedImplementationSymlinkMessage(relativePath));
|
|
191
231
|
}
|
|
192
232
|
if (entry.isDirectory()) {
|
|
193
233
|
collectImplementationFiles(implementationRoot, entryPath, files);
|
|
@@ -343,7 +383,7 @@ export function writeTemplateTrustRecord(configDir, projectConfig) {
|
|
|
343
383
|
};
|
|
344
384
|
if (!implementationModuleIsUnderRoot(implementationInfo)) {
|
|
345
385
|
const implementation = implementationTrustFingerprint(implementationConfig);
|
|
346
|
-
throw new Error(
|
|
386
|
+
throw new Error(implementationOutsideRootMessage(implementation.module));
|
|
347
387
|
}
|
|
348
388
|
const implementation = implementationTrustFingerprint(implementationConfig);
|
|
349
389
|
const record = buildTrustRecord(configDir, projectConfig, implementation);
|
|
@@ -362,8 +402,9 @@ export function assertTrustedImplementation(implementationInfo, projectConfig =
|
|
|
362
402
|
return;
|
|
363
403
|
}
|
|
364
404
|
const firstIssue = status.issues[0] || "implementation trust is invalid";
|
|
405
|
+
const guidance = templateTrustRecoveryGuidance(firstIssue);
|
|
365
406
|
throw new Error(
|
|
366
|
-
`${firstIssue}.
|
|
407
|
+
guidance ? `${firstIssue}. ${guidance}` : firstIssue
|
|
367
408
|
);
|
|
368
409
|
}
|
|
369
410
|
|
|
@@ -451,9 +492,7 @@ export function getTemplateTrustStatus(implementationInfo, projectConfig = null)
|
|
|
451
492
|
const contentStatus = { trustedDigest: null, currentDigest: null, added: [], removed: [], changed: [] };
|
|
452
493
|
|
|
453
494
|
if (templateAttached && !moduleInsideImplementation) {
|
|
454
|
-
issues.push(
|
|
455
|
-
`Template implementation module '${fingerprint.module}' must be under implementation/ for template-attached projects`
|
|
456
|
-
);
|
|
495
|
+
issues.push(implementationOutsideRootMessage(fingerprint.module));
|
|
457
496
|
}
|
|
458
497
|
|
|
459
498
|
if (!trustRecord) {
|
|
@@ -490,17 +529,21 @@ export function getTemplateTrustStatus(implementationInfo, projectConfig = null)
|
|
|
490
529
|
} else if (trustRecord.content.algorithm !== "sha256") {
|
|
491
530
|
issues.push(`${TEMPLATE_TRUST_FILE} uses unsupported content hash algorithm '${trustRecord.content.algorithm}'`);
|
|
492
531
|
} else {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
532
|
+
try {
|
|
533
|
+
const currentContent = hashImplementationContent(implementationInfo.configDir);
|
|
534
|
+
contentStatus.trustedDigest = trustRecord.content.digest;
|
|
535
|
+
contentStatus.currentDigest = currentContent.digest;
|
|
536
|
+
const trustedByPath = new Map((trustRecord.content.files || []).map((file) => [file.path, file]));
|
|
537
|
+
const currentByPath = new Map(currentContent.files.map((file) => [file.path, file]));
|
|
538
|
+
const diff = diffContentFiles(trustedByPath, currentByPath);
|
|
539
|
+
contentStatus.added = diff.added;
|
|
540
|
+
contentStatus.removed = diff.removed;
|
|
541
|
+
contentStatus.changed = diff.changed;
|
|
542
|
+
if (trustRecord.content.digest !== currentContent.digest) {
|
|
543
|
+
issues.push(`${TEMPLATE_TRUST_FILE} implementation content changed since it was last trusted`);
|
|
544
|
+
}
|
|
545
|
+
} catch (error) {
|
|
546
|
+
issues.push(error instanceof Error ? error.message : String(error));
|
|
504
547
|
}
|
|
505
548
|
}
|
|
506
549
|
}
|
|
@@ -538,7 +581,12 @@ export function getTemplateTrustDiff(implementationInfo, projectConfig = null) {
|
|
|
538
581
|
if (!status.requiresTrust || !status.trustRecord?.content) {
|
|
539
582
|
return { ok: status.ok, requiresTrust: status.requiresTrust, status, files: [] };
|
|
540
583
|
}
|
|
541
|
-
|
|
584
|
+
let currentContent;
|
|
585
|
+
try {
|
|
586
|
+
currentContent = hashImplementationContent(implementationInfo.configDir);
|
|
587
|
+
} catch (_error) {
|
|
588
|
+
return { ok: false, requiresTrust: status.requiresTrust, status, files: [] };
|
|
589
|
+
}
|
|
542
590
|
const trustedByPath = new Map((status.trustRecord.content.files || []).map((file) => [file.path, file]));
|
|
543
591
|
const currentByPath = new Map(currentContent.files.map((file) => [file.path, file]));
|
|
544
592
|
/** @type {Array<{ path: string, kind: "added"|"removed"|"changed", trusted: { path: string, sha256: string|null, size: number|null }|null, current: { path: string, sha256: string|null, size: number|null, binary: boolean, diffOmitted: boolean }|null, binary: boolean, diffOmitted: boolean, unifiedDiff: string|null }>} */
|