@haus-tech/haus-workflow 1.5.3 → 1.5.5
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/CHANGELOG.md +22 -0
- package/dist/cli.js +1111 -761
- package/library/catalog/manifest.json +361 -138
- package/library/catalog/validation-rules.json +1 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -60,11 +60,11 @@ var init_exec = __esm({
|
|
|
60
60
|
|
|
61
61
|
// src/cli.ts
|
|
62
62
|
import { readFileSync as readFileSync5 } from "node:fs";
|
|
63
|
-
import
|
|
63
|
+
import path72 from "node:path";
|
|
64
64
|
import { Command } from "commander";
|
|
65
65
|
|
|
66
66
|
// src/commands/apply.ts
|
|
67
|
-
import
|
|
67
|
+
import path24 from "node:path";
|
|
68
68
|
import checkbox from "@inquirer/checkbox";
|
|
69
69
|
|
|
70
70
|
// src/catalog/load-catalog.ts
|
|
@@ -185,6 +185,14 @@ async function listFiles(root, patterns) {
|
|
|
185
185
|
function hashText(value) {
|
|
186
186
|
return `sha256-${crypto.createHash("sha256").update(value).digest("hex")}`;
|
|
187
187
|
}
|
|
188
|
+
function resolveContainedPath(root, rel) {
|
|
189
|
+
const resolvedRoot = path.resolve(root);
|
|
190
|
+
const resolved = path.resolve(root, rel);
|
|
191
|
+
if (resolved === resolvedRoot) return null;
|
|
192
|
+
const prefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
|
|
193
|
+
if (!resolved.startsWith(prefix)) return null;
|
|
194
|
+
return resolved;
|
|
195
|
+
}
|
|
188
196
|
async function mapWithConcurrency(items, fn, concurrency = 24) {
|
|
189
197
|
const size = Number.isFinite(concurrency) ? Math.max(1, Math.floor(concurrency)) : 24;
|
|
190
198
|
const results = new Array(items.length);
|
|
@@ -929,6 +937,7 @@ var validation_rules_default = {
|
|
|
929
937
|
"haus",
|
|
930
938
|
"security",
|
|
931
939
|
"quality",
|
|
940
|
+
"a11y",
|
|
932
941
|
"frontend",
|
|
933
942
|
"backend",
|
|
934
943
|
"testing",
|
|
@@ -1552,7 +1561,7 @@ function summarizeGitignoredHausDirs(paths) {
|
|
|
1552
1561
|
}
|
|
1553
1562
|
|
|
1554
1563
|
// src/claude/write-claude-files.ts
|
|
1555
|
-
import
|
|
1564
|
+
import path20 from "node:path";
|
|
1556
1565
|
import fs18 from "fs-extra";
|
|
1557
1566
|
|
|
1558
1567
|
// src/catalog/former-ids.ts
|
|
@@ -1608,8 +1617,199 @@ function findOrphanedLockEntries(lock, recommendedIds) {
|
|
|
1608
1617
|
);
|
|
1609
1618
|
}
|
|
1610
1619
|
|
|
1611
|
-
// src/
|
|
1620
|
+
// src/scanner/detection.ts
|
|
1612
1621
|
import path10 from "node:path";
|
|
1622
|
+
|
|
1623
|
+
// src/security/sensitive-paths.ts
|
|
1624
|
+
var DENY_PATHS = [
|
|
1625
|
+
"*.pem",
|
|
1626
|
+
"*.key",
|
|
1627
|
+
"*.p12",
|
|
1628
|
+
"*.pfx",
|
|
1629
|
+
"id_rsa",
|
|
1630
|
+
"id_ed25519",
|
|
1631
|
+
"customer-data",
|
|
1632
|
+
"secrets",
|
|
1633
|
+
"certs"
|
|
1634
|
+
];
|
|
1635
|
+
var DENY_DIRS = /* @__PURE__ */ new Set(["customer-data", "secrets", "certs"]);
|
|
1636
|
+
var DENY_PATH_REGEXES = [
|
|
1637
|
+
/(^|\/)[^/]+\.pem$/i,
|
|
1638
|
+
/(^|\/)[^/]+\.key$/i,
|
|
1639
|
+
/(^|\/)[^/]+\.p12$/i,
|
|
1640
|
+
/(^|\/)[^/]+\.pfx$/i,
|
|
1641
|
+
/(^|\/)id_rsa$/i,
|
|
1642
|
+
/(^|\/)id_ed25519$/i,
|
|
1643
|
+
/(^|\/)customer-data(\/|$)/i,
|
|
1644
|
+
/(^|\/)secrets(\/|$)/i,
|
|
1645
|
+
/(^|\/)certs(\/|$)/i
|
|
1646
|
+
];
|
|
1647
|
+
function normalizePathForMatch(candidate) {
|
|
1648
|
+
return candidate.replace(/\\/g, "/").replace(/\/+/g, "/").trim();
|
|
1649
|
+
}
|
|
1650
|
+
var ASK_PATHS = [
|
|
1651
|
+
{ pattern: ".env", tools: ["Edit", "Write"] },
|
|
1652
|
+
{ pattern: ".env.*", tools: ["Edit", "Write"] },
|
|
1653
|
+
{ pattern: "storage/logs/**", tools: ["Edit", "Write"] },
|
|
1654
|
+
{ pattern: "exports/**", tools: ["Edit", "Write"] },
|
|
1655
|
+
{ pattern: "*.dump", tools: ["Read", "Edit", "Write"] },
|
|
1656
|
+
{ pattern: "*.backup", tools: ["Read", "Edit", "Write"] },
|
|
1657
|
+
{ pattern: "*.bak", tools: ["Read", "Edit", "Write"] },
|
|
1658
|
+
{ pattern: "wp-content/uploads/**", tools: ["Read", "Edit", "Write"] },
|
|
1659
|
+
{ pattern: "uploads/**", tools: ["Read", "Edit", "Write"] }
|
|
1660
|
+
];
|
|
1661
|
+
var SENSITIVE_PATH_REGEXES = [
|
|
1662
|
+
/^\.env(\.|$)/,
|
|
1663
|
+
/(^|\/)\.env(\.|$)/,
|
|
1664
|
+
/\.pem$/,
|
|
1665
|
+
/\.key$/,
|
|
1666
|
+
/\.p12$/,
|
|
1667
|
+
/\.pfx$/,
|
|
1668
|
+
/\.sql$/,
|
|
1669
|
+
/\.dump$/,
|
|
1670
|
+
/customer-data/,
|
|
1671
|
+
/exports/,
|
|
1672
|
+
/certs/,
|
|
1673
|
+
/secrets/,
|
|
1674
|
+
/(^|\/)storage\/logs(\/|$)/,
|
|
1675
|
+
/(^|\/)wp-content\/uploads(\/|$)/,
|
|
1676
|
+
/(^|\/)uploads(\/|$)/
|
|
1677
|
+
];
|
|
1678
|
+
var SENSITIVE_ITEM_KEYWORDS = [
|
|
1679
|
+
".env",
|
|
1680
|
+
"secrets",
|
|
1681
|
+
"certs",
|
|
1682
|
+
"customer-data",
|
|
1683
|
+
"exports",
|
|
1684
|
+
".pem",
|
|
1685
|
+
".key"
|
|
1686
|
+
];
|
|
1687
|
+
|
|
1688
|
+
// src/utils/audit-checks.ts
|
|
1689
|
+
function isRecord(v) {
|
|
1690
|
+
return typeof v === "object" && v !== null;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// src/scanner/detection.ts
|
|
1694
|
+
var UNSUPPORTED_MARKERS = {
|
|
1695
|
+
"requirements.txt": "python",
|
|
1696
|
+
"pyproject.toml": "python",
|
|
1697
|
+
"go.mod": "go",
|
|
1698
|
+
"Cargo.toml": "rust",
|
|
1699
|
+
"pom.xml": "java",
|
|
1700
|
+
"build.gradle": "java",
|
|
1701
|
+
"build.gradle.kts": "java",
|
|
1702
|
+
Gemfile: "ruby"
|
|
1703
|
+
};
|
|
1704
|
+
var WEAK_STACK_SIGNALS = /* @__PURE__ */ new Set(["missing-prettier", "missing-eslint"]);
|
|
1705
|
+
function computeDetectionStatus(roles, stacks, unsupportedSignals) {
|
|
1706
|
+
const hasRealStack = Object.entries(stacks).some(
|
|
1707
|
+
([bucket, names]) => bucket !== "packageManagers" && names.some((n) => !WEAK_STACK_SIGNALS.has(n))
|
|
1708
|
+
);
|
|
1709
|
+
const hasRealSignal = roles.length > 0 || hasRealStack;
|
|
1710
|
+
if (!hasRealSignal) return "unknown";
|
|
1711
|
+
return unsupportedSignals.length > 0 ? "partial" : "supported";
|
|
1712
|
+
}
|
|
1713
|
+
function describeUnknownDetection(unsupportedSignals, isLinkedWorktree) {
|
|
1714
|
+
const markers = unsupportedSignals.join(", ");
|
|
1715
|
+
const base = markers ? `Stack not recognised \u2014 detected ${markers}, which haus does not support.` : "Stack not recognised \u2014 no supported framework detected.";
|
|
1716
|
+
const worktreeNote = isLinkedWorktree ? "You're running from inside a git worktree \u2014 sibling repos may not be on disk here, so this could be a false zero-signal reading rather than truly no stack." : "";
|
|
1717
|
+
return [base, worktreeNote, "Only stack-agnostic workflow and security guidance is applied."].filter(Boolean).join(" ");
|
|
1718
|
+
}
|
|
1719
|
+
function blocked(rel) {
|
|
1720
|
+
return SENSITIVE_PATH_REGEXES.some((x) => x.test(rel));
|
|
1721
|
+
}
|
|
1722
|
+
function dependencyVersionMap(pkg, composer) {
|
|
1723
|
+
const versions = /* @__PURE__ */ new Map();
|
|
1724
|
+
const pushObj = (obj) => {
|
|
1725
|
+
if (!isRecord(obj)) return;
|
|
1726
|
+
for (const key of Object.keys(obj)) {
|
|
1727
|
+
if (versions.has(key)) continue;
|
|
1728
|
+
const value = obj[key];
|
|
1729
|
+
versions.set(key, typeof value === "string" ? value : "");
|
|
1730
|
+
}
|
|
1731
|
+
};
|
|
1732
|
+
pushObj(pkg?.dependencies);
|
|
1733
|
+
pushObj(pkg?.devDependencies);
|
|
1734
|
+
pushObj(composer?.require);
|
|
1735
|
+
pushObj(composer?.["require-dev"]);
|
|
1736
|
+
return versions;
|
|
1737
|
+
}
|
|
1738
|
+
function finalizeRoles(registryRoles, deps, files) {
|
|
1739
|
+
const roles = new Set(registryRoles);
|
|
1740
|
+
const hasWpConfig = files.some((f) => f.endsWith("wp-config.php"));
|
|
1741
|
+
const hasBedrockLayout = files.some((f) => f.includes("web/app")) || deps.includes("roots/wordpress");
|
|
1742
|
+
if (hasWpConfig && hasBedrockLayout) {
|
|
1743
|
+
roles.add("wordpress-bedrock-site");
|
|
1744
|
+
roles.add("wordpress-site");
|
|
1745
|
+
} else if (hasWpConfig) {
|
|
1746
|
+
roles.add("wordpress-vanilla-site");
|
|
1747
|
+
roles.add("wordpress-site");
|
|
1748
|
+
} else if (deps.includes("roots/wordpress")) {
|
|
1749
|
+
roles.add("wordpress-bedrock-site");
|
|
1750
|
+
roles.add("wordpress-site");
|
|
1751
|
+
}
|
|
1752
|
+
return [...roles].sort();
|
|
1753
|
+
}
|
|
1754
|
+
function collectUnsupportedSignals(files) {
|
|
1755
|
+
return [
|
|
1756
|
+
...new Set(
|
|
1757
|
+
files.map((f) => UNSUPPORTED_MARKERS[path10.basename(f)]).filter((s) => Boolean(s))
|
|
1758
|
+
)
|
|
1759
|
+
].sort();
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// src/recommender/policies.ts
|
|
1763
|
+
var UNSUPPORTED = FORBIDDEN_TAGS;
|
|
1764
|
+
function matchRequiresAny(clauses, ctx) {
|
|
1765
|
+
for (const clause of clauses) {
|
|
1766
|
+
if ("stack" in clause) {
|
|
1767
|
+
if (ctx.stackSet.has(clause.stack.toLowerCase())) {
|
|
1768
|
+
return { matched: true, signal: `stack:${clause.stack}` };
|
|
1769
|
+
}
|
|
1770
|
+
} else if ("dependency" in clause) {
|
|
1771
|
+
if (ctx.depSet.has(clause.dependency.toLowerCase())) {
|
|
1772
|
+
return { matched: true, signal: `dependency:${clause.dependency}` };
|
|
1773
|
+
}
|
|
1774
|
+
} else if ("packageNamePattern" in clause) {
|
|
1775
|
+
const pattern = clause.packageNamePattern.toLowerCase();
|
|
1776
|
+
const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern;
|
|
1777
|
+
for (const dep2 of ctx.depSet) {
|
|
1778
|
+
if (pattern.endsWith("*") ? dep2.startsWith(prefix) : dep2 === pattern) {
|
|
1779
|
+
return {
|
|
1780
|
+
matched: true,
|
|
1781
|
+
signal: `packageNamePattern:${clause.packageNamePattern}`
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
} else if ("role" in clause) {
|
|
1786
|
+
if (ctx.roleSet.has(clause.role.toLowerCase())) {
|
|
1787
|
+
return { matched: true, signal: `role:${clause.role}` };
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
return { matched: false };
|
|
1792
|
+
}
|
|
1793
|
+
function describeRequiresAny(clauses) {
|
|
1794
|
+
return clauses.map((c) => {
|
|
1795
|
+
if ("stack" in c) return `stack=${c.stack}`;
|
|
1796
|
+
if ("dependency" in c) return `dependency=${c.dependency}`;
|
|
1797
|
+
if ("packageNamePattern" in c) return `packageNamePattern=${c.packageNamePattern}`;
|
|
1798
|
+
if ("role" in c) return `role=${c.role}`;
|
|
1799
|
+
return "unknown";
|
|
1800
|
+
}).join(" | ");
|
|
1801
|
+
}
|
|
1802
|
+
function mergeRecommendationWarnings(context, isLinkedWorktree = false) {
|
|
1803
|
+
const markers = context.unsupportedSignals?.join(", ");
|
|
1804
|
+
const statusLines = context.detectionStatus === "unknown" ? [describeUnknownDetection(context.unsupportedSignals ?? [], isLinkedWorktree)] : context.detectionStatus === "partial" && markers ? [
|
|
1805
|
+
`Partially supported \u2014 found unsupported ${markers} alongside recognised stacks; guidance covers the supported parts only.`
|
|
1806
|
+
] : [];
|
|
1807
|
+
const riskLines = (context.securityRisks?.length ?? 0) > 0 ? [`Scan reported security signals: ${context.securityRisks.join("; ")}`] : [];
|
|
1808
|
+
return [.../* @__PURE__ */ new Set([...statusLines, ...context.warnings, ...riskLines])];
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
// src/update/hash-installed.ts
|
|
1812
|
+
import path11 from "node:path";
|
|
1613
1813
|
import fg2 from "fast-glob";
|
|
1614
1814
|
import fs7 from "fs-extra";
|
|
1615
1815
|
|
|
@@ -1666,7 +1866,7 @@ async function hashInstalledPaths(root, relPaths, opts = {}) {
|
|
|
1666
1866
|
const normalized = [...new Set(relPaths.map((p) => p.replace(/\\/g, "/")))].sort();
|
|
1667
1867
|
const fileDigests = [];
|
|
1668
1868
|
for (const rel of normalized) {
|
|
1669
|
-
const abs =
|
|
1869
|
+
const abs = path11.join(root, rel);
|
|
1670
1870
|
if (!await fs7.pathExists(abs)) continue;
|
|
1671
1871
|
const stat = followSymlinks ? await fs7.stat(abs) : await fs7.lstat(abs);
|
|
1672
1872
|
if (!followSymlinks && stat.isSymbolicLink()) continue;
|
|
@@ -1683,9 +1883,9 @@ async function hashInstalledPaths(root, relPaths, opts = {}) {
|
|
|
1683
1883
|
followSymbolicLinks: followSymlinks
|
|
1684
1884
|
});
|
|
1685
1885
|
for (const sub of inner.sort()) {
|
|
1686
|
-
const absFile =
|
|
1886
|
+
const absFile = path11.join(abs, sub);
|
|
1687
1887
|
if (!followSymlinks && (await fs7.lstat(absFile)).isSymbolicLink()) continue;
|
|
1688
|
-
const relFile =
|
|
1888
|
+
const relFile = path11.join(rel, sub).replace(/\\/g, "/");
|
|
1689
1889
|
const body = await fs7.readFile(absFile);
|
|
1690
1890
|
fileDigests.push({ rel: relFile, digest: digestFileContent(body) });
|
|
1691
1891
|
}
|
|
@@ -1757,18 +1957,18 @@ function buildAllowRules() {
|
|
|
1757
1957
|
}
|
|
1758
1958
|
|
|
1759
1959
|
// src/install/settings-merge.ts
|
|
1760
|
-
import
|
|
1960
|
+
import path13 from "node:path";
|
|
1761
1961
|
import fs9 from "fs-extra";
|
|
1762
1962
|
|
|
1763
1963
|
// src/install/manifest.ts
|
|
1764
1964
|
import os3 from "node:os";
|
|
1765
|
-
import
|
|
1965
|
+
import path12 from "node:path";
|
|
1766
1966
|
var MANIFEST_SCHEMA = "haus-install-manifest/1";
|
|
1767
1967
|
function globalClaudeDir() {
|
|
1768
|
-
return
|
|
1968
|
+
return path12.join(os3.homedir(), ".claude");
|
|
1769
1969
|
}
|
|
1770
1970
|
function hausManifestPath() {
|
|
1771
|
-
return
|
|
1971
|
+
return path12.join(globalClaudeDir(), "haus", "install-manifest.json");
|
|
1772
1972
|
}
|
|
1773
1973
|
async function readManifest() {
|
|
1774
1974
|
return readJson(hausManifestPath());
|
|
@@ -1788,7 +1988,7 @@ function buildManifest(source, files, hooks) {
|
|
|
1788
1988
|
|
|
1789
1989
|
// src/install/settings-merge.ts
|
|
1790
1990
|
function settingsJsonPath() {
|
|
1791
|
-
return
|
|
1991
|
+
return path13.join(globalClaudeDir(), "settings.json");
|
|
1792
1992
|
}
|
|
1793
1993
|
async function readSettings() {
|
|
1794
1994
|
const filePath = settingsJsonPath();
|
|
@@ -2017,71 +2217,6 @@ var ASK_COMMANDS = [
|
|
|
2017
2217
|
"php artisan migrate --force"
|
|
2018
2218
|
];
|
|
2019
2219
|
|
|
2020
|
-
// src/security/sensitive-paths.ts
|
|
2021
|
-
var DENY_PATHS = [
|
|
2022
|
-
"*.pem",
|
|
2023
|
-
"*.key",
|
|
2024
|
-
"*.p12",
|
|
2025
|
-
"*.pfx",
|
|
2026
|
-
"id_rsa",
|
|
2027
|
-
"id_ed25519",
|
|
2028
|
-
"customer-data",
|
|
2029
|
-
"secrets",
|
|
2030
|
-
"certs"
|
|
2031
|
-
];
|
|
2032
|
-
var DENY_DIRS = /* @__PURE__ */ new Set(["customer-data", "secrets", "certs"]);
|
|
2033
|
-
var DENY_PATH_REGEXES = [
|
|
2034
|
-
/(^|\/)[^/]+\.pem$/i,
|
|
2035
|
-
/(^|\/)[^/]+\.key$/i,
|
|
2036
|
-
/(^|\/)[^/]+\.p12$/i,
|
|
2037
|
-
/(^|\/)[^/]+\.pfx$/i,
|
|
2038
|
-
/(^|\/)id_rsa$/i,
|
|
2039
|
-
/(^|\/)id_ed25519$/i,
|
|
2040
|
-
/(^|\/)customer-data(\/|$)/i,
|
|
2041
|
-
/(^|\/)secrets(\/|$)/i,
|
|
2042
|
-
/(^|\/)certs(\/|$)/i
|
|
2043
|
-
];
|
|
2044
|
-
function normalizePathForMatch(candidate) {
|
|
2045
|
-
return candidate.replace(/\\/g, "/").replace(/\/+/g, "/").trim();
|
|
2046
|
-
}
|
|
2047
|
-
var ASK_PATHS = [
|
|
2048
|
-
{ pattern: ".env", tools: ["Edit", "Write"] },
|
|
2049
|
-
{ pattern: ".env.*", tools: ["Edit", "Write"] },
|
|
2050
|
-
{ pattern: "storage/logs/**", tools: ["Edit", "Write"] },
|
|
2051
|
-
{ pattern: "exports/**", tools: ["Edit", "Write"] },
|
|
2052
|
-
{ pattern: "*.dump", tools: ["Read", "Edit", "Write"] },
|
|
2053
|
-
{ pattern: "*.backup", tools: ["Read", "Edit", "Write"] },
|
|
2054
|
-
{ pattern: "*.bak", tools: ["Read", "Edit", "Write"] },
|
|
2055
|
-
{ pattern: "wp-content/uploads/**", tools: ["Read", "Edit", "Write"] },
|
|
2056
|
-
{ pattern: "uploads/**", tools: ["Read", "Edit", "Write"] }
|
|
2057
|
-
];
|
|
2058
|
-
var SENSITIVE_PATH_REGEXES = [
|
|
2059
|
-
/^\.env(\.|$)/,
|
|
2060
|
-
/(^|\/)\.env(\.|$)/,
|
|
2061
|
-
/\.pem$/,
|
|
2062
|
-
/\.key$/,
|
|
2063
|
-
/\.p12$/,
|
|
2064
|
-
/\.pfx$/,
|
|
2065
|
-
/\.sql$/,
|
|
2066
|
-
/\.dump$/,
|
|
2067
|
-
/customer-data/,
|
|
2068
|
-
/exports/,
|
|
2069
|
-
/certs/,
|
|
2070
|
-
/secrets/,
|
|
2071
|
-
/(^|\/)storage\/logs(\/|$)/,
|
|
2072
|
-
/(^|\/)wp-content\/uploads(\/|$)/,
|
|
2073
|
-
/(^|\/)uploads(\/|$)/
|
|
2074
|
-
];
|
|
2075
|
-
var SENSITIVE_ITEM_KEYWORDS = [
|
|
2076
|
-
".env",
|
|
2077
|
-
"secrets",
|
|
2078
|
-
"certs",
|
|
2079
|
-
"customer-data",
|
|
2080
|
-
"exports",
|
|
2081
|
-
".pem",
|
|
2082
|
-
".key"
|
|
2083
|
-
];
|
|
2084
|
-
|
|
2085
2220
|
// src/security/ask-rules.ts
|
|
2086
2221
|
function buildAskRules() {
|
|
2087
2222
|
const rules = [];
|
|
@@ -2103,8 +2238,8 @@ function buildDenyRules() {
|
|
|
2103
2238
|
for (const command of DENY_COMMANDS) {
|
|
2104
2239
|
rules.push(`Bash(${command}:*)`);
|
|
2105
2240
|
}
|
|
2106
|
-
for (const
|
|
2107
|
-
const pattern = DENY_DIRS.has(
|
|
2241
|
+
for (const path73 of DENY_PATHS) {
|
|
2242
|
+
const pattern = DENY_DIRS.has(path73) ? `${path73}/**` : path73;
|
|
2108
2243
|
for (const tool of FILE_TOOLS) {
|
|
2109
2244
|
rules.push(`${tool}(${pattern})`);
|
|
2110
2245
|
}
|
|
@@ -2183,24 +2318,28 @@ async function applyProjectSettingsMerge(root) {
|
|
|
2183
2318
|
}
|
|
2184
2319
|
|
|
2185
2320
|
// src/claude/superpowers-install.ts
|
|
2186
|
-
import
|
|
2321
|
+
import path14 from "node:path";
|
|
2187
2322
|
import fg3 from "fast-glob";
|
|
2188
2323
|
import fs10 from "fs-extra";
|
|
2189
2324
|
var SUPERPOWERS_ORIGIN_SOURCE_ID = "superpowers-pcvelz";
|
|
2190
2325
|
var PATH_REWRITES = [
|
|
2191
2326
|
["skills/shared/", ".claude/skills/shared/"]
|
|
2192
2327
|
];
|
|
2328
|
+
var PREFIX_REWRITES = ["superpowers-extended-cc:"];
|
|
2193
2329
|
function rewriteSuperpowersMarkdown(text) {
|
|
2194
2330
|
let out = text;
|
|
2195
2331
|
for (const [from, to] of PATH_REWRITES) {
|
|
2196
2332
|
out = out.split(from).join(to);
|
|
2197
2333
|
}
|
|
2334
|
+
for (const prefix of PREFIX_REWRITES) {
|
|
2335
|
+
out = out.split(prefix).join("");
|
|
2336
|
+
}
|
|
2198
2337
|
return out;
|
|
2199
2338
|
}
|
|
2200
2339
|
async function rewriteMarkdownTree(dir) {
|
|
2201
2340
|
const files = await fg3("**/*.md", { cwd: dir, onlyFiles: true, dot: true });
|
|
2202
2341
|
for (const rel of files) {
|
|
2203
|
-
const abs =
|
|
2342
|
+
const abs = path14.join(dir, rel);
|
|
2204
2343
|
const text = await fs10.readFile(abs, "utf8");
|
|
2205
2344
|
const rewritten = rewriteSuperpowersMarkdown(text);
|
|
2206
2345
|
if (rewritten !== text) {
|
|
@@ -2208,10 +2347,24 @@ async function rewriteMarkdownTree(dir) {
|
|
|
2208
2347
|
}
|
|
2209
2348
|
}
|
|
2210
2349
|
}
|
|
2350
|
+
async function extraDestFiles(sourcePath, destination) {
|
|
2351
|
+
const [sourceFiles, destFiles] = await Promise.all([
|
|
2352
|
+
fg3("**/*", { cwd: sourcePath, onlyFiles: true, dot: true }),
|
|
2353
|
+
fg3("**/*", { cwd: destination, onlyFiles: true, dot: true })
|
|
2354
|
+
]);
|
|
2355
|
+
const sourceSet = new Set(sourceFiles);
|
|
2356
|
+
return destFiles.filter((f) => !sourceSet.has(f));
|
|
2357
|
+
}
|
|
2211
2358
|
async function installCatalogSkill(sourcePath, destination, opts) {
|
|
2212
2359
|
if (opts.dryRun) return;
|
|
2213
|
-
await fs10.ensureDir(
|
|
2360
|
+
await fs10.ensureDir(path14.dirname(destination));
|
|
2214
2361
|
if (await fs10.pathExists(destination)) {
|
|
2362
|
+
const extra = await extraDestFiles(sourcePath, destination);
|
|
2363
|
+
if (extra.length > 0) {
|
|
2364
|
+
warn(
|
|
2365
|
+
`${destination}: removing ${extra.length} untracked file(s) not present in the catalog source before reinstall: ${extra.join(", ")}`
|
|
2366
|
+
);
|
|
2367
|
+
}
|
|
2215
2368
|
await fs10.remove(destination);
|
|
2216
2369
|
}
|
|
2217
2370
|
await fs10.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
|
|
@@ -2220,17 +2373,23 @@ async function installCatalogSkill(sourcePath, destination, opts) {
|
|
|
2220
2373
|
}
|
|
2221
2374
|
}
|
|
2222
2375
|
async function installSuperpowersShared(contentRoot, projectRoot, dryRun) {
|
|
2223
|
-
const source =
|
|
2376
|
+
const source = path14.join(contentRoot, SUPERPOWERS_SHARED_CATALOG_REL);
|
|
2224
2377
|
if (!await fs10.pathExists(source)) return null;
|
|
2225
2378
|
const destination = claudePath(projectRoot, "skills", "shared");
|
|
2226
|
-
if (dryRun) return
|
|
2227
|
-
await fs10.ensureDir(
|
|
2379
|
+
if (dryRun) return path14.relative(projectRoot, destination);
|
|
2380
|
+
await fs10.ensureDir(path14.dirname(destination));
|
|
2228
2381
|
if (await fs10.pathExists(destination)) {
|
|
2382
|
+
const extra = await extraDestFiles(source, destination);
|
|
2383
|
+
if (extra.length > 0) {
|
|
2384
|
+
warn(
|
|
2385
|
+
`${destination}: removing ${extra.length} untracked file(s) not present in the catalog source before reinstall: ${extra.join(", ")}`
|
|
2386
|
+
);
|
|
2387
|
+
}
|
|
2229
2388
|
await fs10.remove(destination);
|
|
2230
2389
|
}
|
|
2231
2390
|
await fs10.copy(source, destination, { overwrite: true, errorOnExist: false });
|
|
2232
2391
|
await rewriteMarkdownTree(destination);
|
|
2233
|
-
return
|
|
2392
|
+
return path14.relative(projectRoot, destination);
|
|
2234
2393
|
}
|
|
2235
2394
|
|
|
2236
2395
|
// src/claude/verify-hooks-contract.ts
|
|
@@ -2345,7 +2504,7 @@ async function verifyProjectSettingsHooksContract(root) {
|
|
|
2345
2504
|
}
|
|
2346
2505
|
|
|
2347
2506
|
// src/claude/write-decisions-seed.ts
|
|
2348
|
-
import
|
|
2507
|
+
import path15 from "node:path";
|
|
2349
2508
|
import fs12 from "fs-extra";
|
|
2350
2509
|
|
|
2351
2510
|
// library/catalog/decisions-triggers.json
|
|
@@ -2389,8 +2548,8 @@ Why we chose X \u2014 short, dated, write-once decision log for humans and agent
|
|
|
2389
2548
|
| --- | ----- | -------------- | ------ |
|
|
2390
2549
|
`;
|
|
2391
2550
|
async function writeDecisionsSeed(root, dryRun) {
|
|
2392
|
-
const decisionsDir =
|
|
2393
|
-
const readmePath =
|
|
2551
|
+
const decisionsDir = path15.join(root, DECISIONS_TRIGGERS.decisionsDir);
|
|
2552
|
+
const readmePath = path15.join(decisionsDir, "README.md");
|
|
2394
2553
|
if (await fs12.pathExists(readmePath)) return null;
|
|
2395
2554
|
if (!dryRun) await fs12.ensureDir(decisionsDir);
|
|
2396
2555
|
await writeManagedText(root, readmePath, SEED_README, dryRun);
|
|
@@ -2398,7 +2557,7 @@ async function writeDecisionsSeed(root, dryRun) {
|
|
|
2398
2557
|
}
|
|
2399
2558
|
|
|
2400
2559
|
// src/claude/write-prettierignore.ts
|
|
2401
|
-
import
|
|
2560
|
+
import path16 from "node:path";
|
|
2402
2561
|
import fs13 from "fs-extra";
|
|
2403
2562
|
var PRETTIERIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
|
|
2404
2563
|
var PRETTIERIGNORE_END = "# HAUS:END haus-managed";
|
|
@@ -2467,7 +2626,7 @@ ${block}
|
|
|
2467
2626
|
`;
|
|
2468
2627
|
}
|
|
2469
2628
|
async function writePrettierIgnore(root, dryRun) {
|
|
2470
|
-
const filePath =
|
|
2629
|
+
const filePath = path16.join(root, ".prettierignore");
|
|
2471
2630
|
const prev = await fs13.pathExists(filePath) ? await fs13.readFile(filePath, "utf8") : "";
|
|
2472
2631
|
const next = injectPrettierIgnoreBlock(prev, buildPrettierIgnoreBlock());
|
|
2473
2632
|
await writeManagedText(root, filePath, next, dryRun);
|
|
@@ -2475,10 +2634,13 @@ async function writePrettierIgnore(root, dryRun) {
|
|
|
2475
2634
|
}
|
|
2476
2635
|
|
|
2477
2636
|
// src/claude/write-root-claude-md.ts
|
|
2478
|
-
import
|
|
2637
|
+
import path17 from "node:path";
|
|
2479
2638
|
import fs14 from "fs-extra";
|
|
2480
2639
|
var BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-imports v=1 -->";
|
|
2481
2640
|
var BLOCK_END = "<!-- HAUS:END haus-imports -->";
|
|
2641
|
+
var WORKSPACE_BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-workspace-imports v=1 -->";
|
|
2642
|
+
var WORKSPACE_BLOCK_END = "<!-- HAUS:END haus-workspace-imports -->";
|
|
2643
|
+
var DEFAULT_SENTINELS = { begin: BLOCK_BEGIN, end: BLOCK_END };
|
|
2482
2644
|
var IMPORT_CONTENT = `@.haus-workflow/WORKFLOW.md
|
|
2483
2645
|
@.haus-workflow/workflow-config.md
|
|
2484
2646
|
@docs/decisions/README.md`;
|
|
@@ -2498,15 +2660,15 @@ function findLineMarker2(content2, marker, from = 0) {
|
|
|
2498
2660
|
}
|
|
2499
2661
|
return null;
|
|
2500
2662
|
}
|
|
2501
|
-
function findImportBlockRange(content2) {
|
|
2502
|
-
const begin = findLineMarker2(content2,
|
|
2663
|
+
function findImportBlockRange(content2, sentinels = DEFAULT_SENTINELS) {
|
|
2664
|
+
const begin = findLineMarker2(content2, sentinels.begin);
|
|
2503
2665
|
if (!begin) return null;
|
|
2504
|
-
const end = findLineMarker2(content2,
|
|
2666
|
+
const end = findLineMarker2(content2, sentinels.end, begin.end);
|
|
2505
2667
|
if (!end) return null;
|
|
2506
2668
|
return { beginStart: begin.start, beginEnd: begin.end, endStart: end.start, endEnd: end.end };
|
|
2507
2669
|
}
|
|
2508
|
-
function stripHausBlock(existing) {
|
|
2509
|
-
const range = findImportBlockRange(existing);
|
|
2670
|
+
function stripHausBlock(existing, sentinels = DEFAULT_SENTINELS) {
|
|
2671
|
+
const range = findImportBlockRange(existing, sentinels);
|
|
2510
2672
|
if (range) {
|
|
2511
2673
|
const before = existing.slice(0, range.beginStart);
|
|
2512
2674
|
const after = existing.slice(range.endEnd);
|
|
@@ -2514,7 +2676,7 @@ function stripHausBlock(existing) {
|
|
|
2514
2676
|
return merged.length > 0 ? `${merged}
|
|
2515
2677
|
` : "";
|
|
2516
2678
|
}
|
|
2517
|
-
const loneBegin = findLineMarker2(existing,
|
|
2679
|
+
const loneBegin = findLineMarker2(existing, sentinels.begin);
|
|
2518
2680
|
if (loneBegin) {
|
|
2519
2681
|
const before = existing.slice(0, loneBegin.start).trimEnd();
|
|
2520
2682
|
return before.length > 0 ? `${before}
|
|
@@ -2522,14 +2684,14 @@ function stripHausBlock(existing) {
|
|
|
2522
2684
|
}
|
|
2523
2685
|
return existing;
|
|
2524
2686
|
}
|
|
2525
|
-
function injectHausBlock(existing, block) {
|
|
2526
|
-
const range = findImportBlockRange(existing);
|
|
2687
|
+
function injectHausBlock(existing, block, sentinels = DEFAULT_SENTINELS) {
|
|
2688
|
+
const range = findImportBlockRange(existing, sentinels);
|
|
2527
2689
|
if (range) {
|
|
2528
2690
|
const before = existing.slice(0, range.beginStart);
|
|
2529
2691
|
const after = existing.slice(range.endEnd);
|
|
2530
2692
|
return `${before}${block}${after}`;
|
|
2531
2693
|
}
|
|
2532
|
-
const loneBegin = findLineMarker2(existing,
|
|
2694
|
+
const loneBegin = findLineMarker2(existing, sentinels.begin);
|
|
2533
2695
|
if (loneBegin) {
|
|
2534
2696
|
const before = existing.slice(0, loneBegin.start).trimEnd();
|
|
2535
2697
|
if (before.length === 0) return `${block}
|
|
@@ -2550,7 +2712,7 @@ ${block}
|
|
|
2550
2712
|
`;
|
|
2551
2713
|
}
|
|
2552
2714
|
async function writeRootClaudeMd(root, dryRun) {
|
|
2553
|
-
const filePath =
|
|
2715
|
+
const filePath = path17.join(root, "CLAUDE.md");
|
|
2554
2716
|
const block = buildImportBlock();
|
|
2555
2717
|
const prev = await fs14.pathExists(filePath) ? await fs14.readFile(filePath, "utf8") : "";
|
|
2556
2718
|
const next = injectHausBlock(prev, block);
|
|
@@ -2559,11 +2721,11 @@ async function writeRootClaudeMd(root, dryRun) {
|
|
|
2559
2721
|
}
|
|
2560
2722
|
|
|
2561
2723
|
// src/claude/write-workflow-config.ts
|
|
2562
|
-
import
|
|
2724
|
+
import path19 from "node:path";
|
|
2563
2725
|
import fs16 from "fs-extra";
|
|
2564
2726
|
|
|
2565
2727
|
// src/claude/derive-workflow-config.ts
|
|
2566
|
-
import
|
|
2728
|
+
import path18 from "node:path";
|
|
2567
2729
|
import fs15 from "fs-extra";
|
|
2568
2730
|
function binCmd(pm, bin, args) {
|
|
2569
2731
|
const tail = args ? ` ${args}` : "";
|
|
@@ -2573,7 +2735,7 @@ function binCmd(pm, bin, args) {
|
|
|
2573
2735
|
}
|
|
2574
2736
|
async function deriveWorkflowConfig(root, ctx) {
|
|
2575
2737
|
const pm = ctx.packageManager === "unknown" ? "npm" : ctx.packageManager;
|
|
2576
|
-
const pkg = await readJson(
|
|
2738
|
+
const pkg = await readJson(path18.join(root, "package.json"));
|
|
2577
2739
|
const scripts = pkg?.scripts ?? {};
|
|
2578
2740
|
const deps = new Set(ctx.dependencies);
|
|
2579
2741
|
const stacks = Object.values(ctx.detectedStacks ?? {}).flat();
|
|
@@ -2583,7 +2745,7 @@ async function deriveWorkflowConfig(root, ctx) {
|
|
|
2583
2745
|
return null;
|
|
2584
2746
|
};
|
|
2585
2747
|
const hasDep = (name) => deps.has(name);
|
|
2586
|
-
const exists = (rel) => fs15.pathExistsSync(
|
|
2748
|
+
const exists = (rel) => fs15.pathExistsSync(path18.join(root, rel));
|
|
2587
2749
|
const hasPlaywright = hasDep("@playwright/test") || stacks.includes("playwright");
|
|
2588
2750
|
const hasCypress = hasDep("cypress");
|
|
2589
2751
|
const preCommitTool = exists("lefthook.yml") || exists("lefthook.yaml") ? "lefthook" : exists(".husky") || hasDep("husky") || (scripts.prepare ?? "").includes("husky") ? "husky" : exists(".pre-commit-config.yaml") ? "pre-commit (Python framework)" : null;
|
|
@@ -2658,7 +2820,7 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
|
|
|
2658
2820
|
const ctx = await readJson(hausPath(root, "context-map.json")) ?? {
|
|
2659
2821
|
...FALLBACK_CONTEXT,
|
|
2660
2822
|
root,
|
|
2661
|
-
repoName:
|
|
2823
|
+
repoName: path19.basename(root)
|
|
2662
2824
|
};
|
|
2663
2825
|
const values = await deriveWorkflowConfig(root, ctx);
|
|
2664
2826
|
if (exists) {
|
|
@@ -2703,7 +2865,8 @@ function checkManagedTamper(body, storedHash, templateContent) {
|
|
|
2703
2865
|
// src/claude/write-workflow.ts
|
|
2704
2866
|
var STABLE_ID = "template.workflow";
|
|
2705
2867
|
function makeWorkflowHeader(pkgVersion, contentHash) {
|
|
2706
|
-
|
|
2868
|
+
const hashAttr = contentHash === void 0 ? "" : ` hash=${contentHash}`;
|
|
2869
|
+
return `<!-- HAUS-MANAGED id=${STABLE_ID} v=${SCHEMA_VERSION} source=@haus-tech/haus-workflow@${pkgVersion}${hashAttr} -->`;
|
|
2707
2870
|
}
|
|
2708
2871
|
async function writeWorkflow(root, pkgVersion, dryRun, force = false) {
|
|
2709
2872
|
const templateContent = await readWorkflowTemplate({ dryRun });
|
|
@@ -2744,12 +2907,12 @@ ${templateContent}`;
|
|
|
2744
2907
|
return null;
|
|
2745
2908
|
}
|
|
2746
2909
|
if (verdict.status === "legacy-diverged" && !force) {
|
|
2747
|
-
const migratedHeader = makeWorkflowHeader(pkgVersion
|
|
2910
|
+
const migratedHeader = makeWorkflowHeader(pkgVersion);
|
|
2748
2911
|
const migrated = `${migratedHeader}
|
|
2749
2912
|
${existingContent}`;
|
|
2750
2913
|
if (hasTextChanged(existing, migrated)) {
|
|
2751
2914
|
if (dryRun) {
|
|
2752
|
-
log(`${printable}: migrating legacy
|
|
2915
|
+
log(`${printable}: migrating legacy header (staying hash-less; body preserved)`);
|
|
2753
2916
|
} else {
|
|
2754
2917
|
await writeText(destPath, migrated);
|
|
2755
2918
|
}
|
|
@@ -2796,7 +2959,7 @@ async function listFilesRecursive2(dir) {
|
|
|
2796
2959
|
const walk = async (current, relPrefix) => {
|
|
2797
2960
|
const names = await fs18.readdir(current);
|
|
2798
2961
|
for (const name of names) {
|
|
2799
|
-
const abs =
|
|
2962
|
+
const abs = path20.join(current, name);
|
|
2800
2963
|
const rel = relPrefix ? `${relPrefix}/${name}` : name;
|
|
2801
2964
|
const stat = await fs18.lstat(abs);
|
|
2802
2965
|
if (stat.isSymbolicLink()) {
|
|
@@ -2840,8 +3003,8 @@ async function logSkillDryRunDiff(root, sourcePath, destination, itemId, say) {
|
|
|
2840
3003
|
for (const rel of sourceFiles) {
|
|
2841
3004
|
await logFileDryRunDiff(
|
|
2842
3005
|
root,
|
|
2843
|
-
|
|
2844
|
-
|
|
3006
|
+
path20.join(sourcePath, rel),
|
|
3007
|
+
path20.join(destination, rel),
|
|
2845
3008
|
itemId,
|
|
2846
3009
|
say
|
|
2847
3010
|
);
|
|
@@ -2849,7 +3012,7 @@ async function logSkillDryRunDiff(root, sourcePath, destination, itemId, say) {
|
|
|
2849
3012
|
const sourceFileSet = new Set(sourceFiles);
|
|
2850
3013
|
for (const rel of destFiles) {
|
|
2851
3014
|
if (!sourceFileSet.has(rel)) {
|
|
2852
|
-
say(`${displayPath(root,
|
|
3015
|
+
say(`${displayPath(root, path20.join(destination, rel))}: would remove (${itemId})`);
|
|
2853
3016
|
}
|
|
2854
3017
|
}
|
|
2855
3018
|
}
|
|
@@ -2869,7 +3032,9 @@ async function removeLegacyManagedStub(root, relPathSegments, stub, dryRun, say)
|
|
|
2869
3032
|
async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
2870
3033
|
const say = opts.quiet ? () => {
|
|
2871
3034
|
} : log;
|
|
2872
|
-
const
|
|
3035
|
+
const recResult = await readJsonDetailed(hausPath(root, "recommendation.json"));
|
|
3036
|
+
const recommendationUnusable = recResult.status !== "ok";
|
|
3037
|
+
const rec = recResult.status === "ok" ? recResult.value : {
|
|
2873
3038
|
recommended: [],
|
|
2874
3039
|
skipped: [],
|
|
2875
3040
|
warnings: [],
|
|
@@ -2879,7 +3044,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2879
3044
|
estimatedTokenReductionPct: 0
|
|
2880
3045
|
};
|
|
2881
3046
|
const pkgRoot = packageRoot();
|
|
2882
|
-
const hausVersion2 = (await readJson(
|
|
3047
|
+
const hausVersion2 = (await readJson(path20.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
|
|
2883
3048
|
const coreFiles = [claudePath(root, "settings.json"), claudePath(root, "rules", "haus.md")];
|
|
2884
3049
|
const rootClaudeMdPath = await writeRootClaudeMd(root, dryRun);
|
|
2885
3050
|
const decisionsSeedPath = await writeDecisionsSeed(root, dryRun);
|
|
@@ -2969,6 +3134,9 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2969
3134
|
const { items: manifestItems, contentRoot } = await loadCatalogContext(root);
|
|
2970
3135
|
const manifestById = new Map(manifestItems.map((item) => [item.id, item]));
|
|
2971
3136
|
const prevLock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
|
|
3137
|
+
const prevLockById = new Map(
|
|
3138
|
+
prevLock.filter((entry) => Boolean(entry.id)).map((entry) => [entry.id, entry])
|
|
3139
|
+
);
|
|
2972
3140
|
const allMigrations = findFormerIdMigrations(
|
|
2973
3141
|
prevLock.filter((entry) => Boolean(entry.id)),
|
|
2974
3142
|
manifestItems
|
|
@@ -3029,6 +3197,17 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
3029
3197
|
warn(`Skipping ${item.id}: reviewStatus is deprecated`);
|
|
3030
3198
|
continue;
|
|
3031
3199
|
}
|
|
3200
|
+
const manifestItemTags = (manifestItem.tags ?? []).map((t) => t.toLowerCase());
|
|
3201
|
+
if (UNSUPPORTED.some((x) => manifestItemTags.includes(x))) {
|
|
3202
|
+
warn(`Skipping ${item.id}: unsupported stack policy`);
|
|
3203
|
+
continue;
|
|
3204
|
+
}
|
|
3205
|
+
const sensitiveInId = SENSITIVE_ITEM_KEYWORDS.some((x) => item.id.toLowerCase().includes(x));
|
|
3206
|
+
const sensitiveInTags = SENSITIVE_ITEM_KEYWORDS.some((x) => manifestItemTags.includes(x));
|
|
3207
|
+
if (sensitiveInId || sensitiveInTags) {
|
|
3208
|
+
warn(`Skipping ${item.id}: sensitive content policy block`);
|
|
3209
|
+
continue;
|
|
3210
|
+
}
|
|
3032
3211
|
if (manifestItem.source === "curated") {
|
|
3033
3212
|
if (manifestItem.reviewStatus !== "approved") {
|
|
3034
3213
|
curatedReviewStatusSkips++;
|
|
@@ -3053,7 +3232,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
3053
3232
|
);
|
|
3054
3233
|
continue;
|
|
3055
3234
|
}
|
|
3056
|
-
const destination = claudePath(root, target,
|
|
3235
|
+
const destination = claudePath(root, target, path20.basename(sourcePath));
|
|
3057
3236
|
if (await fs18.pathExists(sourcePath)) {
|
|
3058
3237
|
if (dryRun) {
|
|
3059
3238
|
if (item.type === "skill") {
|
|
@@ -3061,13 +3240,23 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
3061
3240
|
} else {
|
|
3062
3241
|
await logFileDryRunDiff(root, sourcePath, destination, item.id, say);
|
|
3063
3242
|
}
|
|
3243
|
+
} else if (await skipLocallyEditedItem(
|
|
3244
|
+
root,
|
|
3245
|
+
item.id,
|
|
3246
|
+
prevLockById.get(item.id),
|
|
3247
|
+
opts.force,
|
|
3248
|
+
files,
|
|
3249
|
+
installedPathsByItem,
|
|
3250
|
+
installedIds
|
|
3251
|
+
)) {
|
|
3252
|
+
continue;
|
|
3064
3253
|
} else if (item.type === "skill") {
|
|
3065
3254
|
const skillFiles = (await fs18.readdir(sourcePath, { recursive: true })).filter(
|
|
3066
3255
|
(f) => typeof f === "string" && f.endsWith(".md")
|
|
3067
3256
|
);
|
|
3068
3257
|
let skillValid = true;
|
|
3069
3258
|
for (const relFile of skillFiles) {
|
|
3070
|
-
const mdPath =
|
|
3259
|
+
const mdPath = path20.join(sourcePath, relFile);
|
|
3071
3260
|
const mdContent = await fs18.readFile(mdPath, "utf8");
|
|
3072
3261
|
const mdValidation = validateCatalogItem(
|
|
3073
3262
|
{
|
|
@@ -3106,17 +3295,17 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
3106
3295
|
warn(`Skipping ${item.id}: pre-copy validation failed \u2014 ${validation.reason}`);
|
|
3107
3296
|
continue;
|
|
3108
3297
|
}
|
|
3109
|
-
await fs18.ensureDir(
|
|
3298
|
+
await fs18.ensureDir(path20.dirname(destination));
|
|
3110
3299
|
await fs18.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
|
|
3111
3300
|
}
|
|
3112
3301
|
files.push(destination);
|
|
3113
|
-
const relPaths = [
|
|
3302
|
+
const relPaths = [path20.relative(root, destination)];
|
|
3114
3303
|
if (!superpowersSharedInstalled && manifestItem.originSourceId === SUPERPOWERS_ORIGIN_SOURCE_ID && item.type === "skill") {
|
|
3115
3304
|
const sharedRel = await installSuperpowersShared(contentRoot, root, dryRun);
|
|
3116
3305
|
if (sharedRel) {
|
|
3117
3306
|
superpowersSharedInstalled = true;
|
|
3118
3307
|
relPaths.push(sharedRel);
|
|
3119
|
-
files.push(
|
|
3308
|
+
files.push(path20.join(root, sharedRel));
|
|
3120
3309
|
}
|
|
3121
3310
|
}
|
|
3122
3311
|
const current = installedPathsByItem.get(item.id) ?? [];
|
|
@@ -3134,18 +3323,47 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
3134
3323
|
);
|
|
3135
3324
|
}
|
|
3136
3325
|
await cleanupStaleCatalogItems(root, cleanupManifestById, dryRun, opts.quiet);
|
|
3326
|
+
const refuseDestructiveLockChange = recommendationUnusable && prevLock.length > 0 && !opts.force;
|
|
3137
3327
|
if (opts.prune) {
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3328
|
+
if (refuseDestructiveLockChange) {
|
|
3329
|
+
warn(
|
|
3330
|
+
"recommendation.json is missing or unreadable \u2014 refusing --prune (every installed item would look orphaned). Run `haus recommend` first, or pass --force to override."
|
|
3331
|
+
);
|
|
3332
|
+
} else {
|
|
3333
|
+
const notOrphaned = new Set(rec.recommended.map((r) => r.id));
|
|
3334
|
+
for (const migration of allMigrations) notOrphaned.add(migration.oldId);
|
|
3335
|
+
for (const entry of prevLock) {
|
|
3336
|
+
if (!entry.id) continue;
|
|
3337
|
+
const manifestItem = manifestById.get(entry.id);
|
|
3338
|
+
if (!manifestItem || manifestItem.reviewStatus === "deprecated") notOrphaned.add(entry.id);
|
|
3339
|
+
}
|
|
3340
|
+
await pruneOrphanedCatalogItems(root, prevLock, notOrphaned, dryRun, opts.quiet);
|
|
3144
3341
|
}
|
|
3145
|
-
await pruneOrphanedCatalogItems(root, prevLock, notOrphaned, dryRun, opts.quiet);
|
|
3146
3342
|
}
|
|
3147
|
-
if (dryRun) return [...new Set(files)];
|
|
3148
3343
|
const installedItems = catalogItems.filter((r) => installedIds.has(r.id));
|
|
3344
|
+
if (dryRun) {
|
|
3345
|
+
if (refuseDestructiveLockChange) {
|
|
3346
|
+
say(
|
|
3347
|
+
"[dry-run] recommendation.json is missing or unreadable \u2014 haus.lock.json would be left unchanged. Run `haus recommend` first, or pass --force."
|
|
3348
|
+
);
|
|
3349
|
+
return [...new Set(files)];
|
|
3350
|
+
}
|
|
3351
|
+
const prevIds = new Set(
|
|
3352
|
+
prevLock.map((e) => e.id ? migrationByOldId.get(e.id) ?? e.id : void 0).filter((id) => Boolean(id))
|
|
3353
|
+
);
|
|
3354
|
+
const nextIds = new Set(installedItems.map((r) => r.id));
|
|
3355
|
+
const added = [...nextIds].filter((id) => !prevIds.has(id)).sort();
|
|
3356
|
+
const removed = [...prevIds].filter((id) => !nextIds.has(id)).sort();
|
|
3357
|
+
for (const id of added) say(`[dry-run] haus.lock.json would add: ${id}`);
|
|
3358
|
+
for (const id of removed) say(`[dry-run] haus.lock.json would remove: ${id}`);
|
|
3359
|
+
return [...new Set(files)];
|
|
3360
|
+
}
|
|
3361
|
+
if (refuseDestructiveLockChange) {
|
|
3362
|
+
warn(
|
|
3363
|
+
"recommendation.json is missing or unreadable \u2014 leaving haus.lock.json unchanged rather than writing an empty lock. Run `haus recommend` first, or pass --force to overwrite anyway."
|
|
3364
|
+
);
|
|
3365
|
+
return [...new Set(files)];
|
|
3366
|
+
}
|
|
3149
3367
|
const prevRefById = new Map(
|
|
3150
3368
|
prevLock.filter((e) => e.id && e.catalogRef).map((e) => [migrationByOldId.get(e.id) ?? e.id, e.catalogRef])
|
|
3151
3369
|
);
|
|
@@ -3179,6 +3397,26 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
3179
3397
|
await writeManagedJson(root, hausPath(root, "haus.lock.json"), lock, false);
|
|
3180
3398
|
return [...new Set(files)];
|
|
3181
3399
|
}
|
|
3400
|
+
async function skipLocallyEditedItem(root, itemId, prevEntry, force, files, installedPathsByItem, installedIds) {
|
|
3401
|
+
if (!prevEntry || prevEntry.hash === void 0 || force) return false;
|
|
3402
|
+
const existing = await resolveExistingTrackedPaths(root, itemId, prevEntry.paths ?? [], warn);
|
|
3403
|
+
if (existing.length === 0) return false;
|
|
3404
|
+
const currentHash = await hashInstalledPaths(
|
|
3405
|
+
root,
|
|
3406
|
+
existing.map((e) => e.rel)
|
|
3407
|
+
);
|
|
3408
|
+
if (currentHash === prevEntry.hash) return false;
|
|
3409
|
+
warn(
|
|
3410
|
+
`User edited ${itemId} (skipping reinstall): ${existing.map((e) => e.rel).join(", ")} \u2014 use --force to overwrite`
|
|
3411
|
+
);
|
|
3412
|
+
files.push(...existing.map((e) => e.abs));
|
|
3413
|
+
installedPathsByItem.set(
|
|
3414
|
+
itemId,
|
|
3415
|
+
existing.map((e) => e.rel)
|
|
3416
|
+
);
|
|
3417
|
+
installedIds.add(itemId);
|
|
3418
|
+
return true;
|
|
3419
|
+
}
|
|
3182
3420
|
async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, quiet) {
|
|
3183
3421
|
const say = quiet ? () => {
|
|
3184
3422
|
} : log;
|
|
@@ -3186,26 +3424,25 @@ async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, q
|
|
|
3186
3424
|
const entry = prevLock.find((candidate) => candidate.id === migration.oldId);
|
|
3187
3425
|
if (!entry) continue;
|
|
3188
3426
|
const relPaths = entry.paths ?? [];
|
|
3189
|
-
const existing =
|
|
3190
|
-
for (const rel of relPaths) {
|
|
3191
|
-
if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
|
|
3192
|
-
}
|
|
3427
|
+
const existing = await resolveExistingTrackedPaths(root, migration.oldId, relPaths, warn);
|
|
3193
3428
|
if (existing.length === 0) continue;
|
|
3194
3429
|
if (entry.hash === void 0) {
|
|
3195
3430
|
warn(
|
|
3196
|
-
`Former catalog item ${migration.oldId} has no lock hash \u2014 leaving old paths in place: ${existing.join(", ")}`
|
|
3431
|
+
`Former catalog item ${migration.oldId} has no lock hash \u2014 leaving old paths in place: ${existing.map((e) => e.rel).join(", ")}`
|
|
3197
3432
|
);
|
|
3198
3433
|
continue;
|
|
3199
3434
|
}
|
|
3200
|
-
const currentHash = await hashInstalledPaths(
|
|
3435
|
+
const currentHash = await hashInstalledPaths(
|
|
3436
|
+
root,
|
|
3437
|
+
existing.map((e) => e.rel)
|
|
3438
|
+
);
|
|
3201
3439
|
if (currentHash !== entry.hash) {
|
|
3202
3440
|
warn(
|
|
3203
|
-
`Former catalog item ${migration.oldId} was modified locally \u2014 leaving old paths in place: ${existing.join(", ")}`
|
|
3441
|
+
`Former catalog item ${migration.oldId} was modified locally \u2014 leaving old paths in place: ${existing.map((e) => e.rel).join(", ")}`
|
|
3204
3442
|
);
|
|
3205
3443
|
continue;
|
|
3206
3444
|
}
|
|
3207
|
-
for (const
|
|
3208
|
-
const abs = path19.join(root, rel);
|
|
3445
|
+
for (const { abs } of existing) {
|
|
3209
3446
|
if (dryRun) {
|
|
3210
3447
|
say(
|
|
3211
3448
|
`[dry-run] would remove renamed ${displayPath(root, abs)} (${migration.oldId} \u2192 ${migration.newId})`
|
|
@@ -3213,9 +3450,21 @@ async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, q
|
|
|
3213
3450
|
continue;
|
|
3214
3451
|
}
|
|
3215
3452
|
await fs18.remove(abs);
|
|
3216
|
-
await pruneEmptyDir(
|
|
3453
|
+
await pruneEmptyDir(path20.dirname(abs));
|
|
3454
|
+
}
|
|
3455
|
+
}
|
|
3456
|
+
}
|
|
3457
|
+
async function resolveExistingTrackedPaths(root, itemId, relPaths, warnFn) {
|
|
3458
|
+
const existing = [];
|
|
3459
|
+
for (const rel of relPaths) {
|
|
3460
|
+
const abs = resolveContainedPath(root, rel);
|
|
3461
|
+
if (abs === null) {
|
|
3462
|
+
warnFn(`Refusing to touch ${itemId}'s recorded path outside the project root: ${rel}`);
|
|
3463
|
+
continue;
|
|
3217
3464
|
}
|
|
3465
|
+
if (await fs18.pathExists(abs)) existing.push({ rel, abs });
|
|
3218
3466
|
}
|
|
3467
|
+
return existing;
|
|
3219
3468
|
}
|
|
3220
3469
|
async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
|
|
3221
3470
|
const say = quiet ? () => {
|
|
@@ -3230,33 +3479,32 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
|
|
|
3230
3479
|
if (!removedFromManifest && !deprecated) continue;
|
|
3231
3480
|
const relPaths = entry.paths ?? [];
|
|
3232
3481
|
if (relPaths.length === 0) continue;
|
|
3233
|
-
const existing =
|
|
3234
|
-
for (const rel of relPaths) {
|
|
3235
|
-
if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
|
|
3236
|
-
}
|
|
3482
|
+
const existing = await resolveExistingTrackedPaths(root, entry.id, relPaths, warn);
|
|
3237
3483
|
if (existing.length === 0) continue;
|
|
3238
3484
|
const pruneReason = deprecated ? "deprecated" : "stale";
|
|
3239
3485
|
if (entry.hash === void 0) {
|
|
3240
3486
|
warn(
|
|
3241
|
-
`${deprecated ? "Deprecated" : "Stale"} catalog item ${entry.id} has no lock hash \u2014 leaving in place: ${existing.join(", ")}`
|
|
3487
|
+
`${deprecated ? "Deprecated" : "Stale"} catalog item ${entry.id} has no lock hash \u2014 leaving in place: ${existing.map((e) => e.rel).join(", ")}`
|
|
3242
3488
|
);
|
|
3243
3489
|
continue;
|
|
3244
3490
|
}
|
|
3245
|
-
const currentHash = await hashInstalledPaths(
|
|
3491
|
+
const currentHash = await hashInstalledPaths(
|
|
3492
|
+
root,
|
|
3493
|
+
existing.map((e) => e.rel)
|
|
3494
|
+
);
|
|
3246
3495
|
if (currentHash !== entry.hash) {
|
|
3247
3496
|
warn(
|
|
3248
|
-
`${deprecated ? "Deprecated" : "Stale"} catalog item ${entry.id} was modified locally \u2014 leaving in place: ${existing.join(", ")}`
|
|
3497
|
+
`${deprecated ? "Deprecated" : "Stale"} catalog item ${entry.id} was modified locally \u2014 leaving in place: ${existing.map((e) => e.rel).join(", ")}`
|
|
3249
3498
|
);
|
|
3250
3499
|
continue;
|
|
3251
3500
|
}
|
|
3252
|
-
for (const
|
|
3253
|
-
const abs = path19.join(root, rel);
|
|
3501
|
+
for (const { abs } of existing) {
|
|
3254
3502
|
if (dryRun) {
|
|
3255
3503
|
say(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
|
|
3256
3504
|
continue;
|
|
3257
3505
|
}
|
|
3258
3506
|
await fs18.remove(abs);
|
|
3259
|
-
await pruneEmptyDir(
|
|
3507
|
+
await pruneEmptyDir(path20.dirname(abs));
|
|
3260
3508
|
say(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
|
|
3261
3509
|
}
|
|
3262
3510
|
}
|
|
@@ -3267,12 +3515,12 @@ async function backupBeforePrune(root, absPaths, say) {
|
|
|
3267
3515
|
const backupRoot = hausPath(root, "backups", `prune-${stamp}`);
|
|
3268
3516
|
for (const abs of absPaths) {
|
|
3269
3517
|
if (!await fs18.pathExists(abs)) continue;
|
|
3270
|
-
const rel =
|
|
3271
|
-
const backupPath =
|
|
3272
|
-
await fs18.ensureDir(
|
|
3518
|
+
const rel = path20.relative(root, abs);
|
|
3519
|
+
const backupPath = path20.join(backupRoot, rel);
|
|
3520
|
+
await fs18.ensureDir(path20.dirname(backupPath));
|
|
3273
3521
|
await fs18.copy(abs, backupPath);
|
|
3274
3522
|
}
|
|
3275
|
-
say(`Backed up ${absPaths.length} path(s) to ${
|
|
3523
|
+
say(`Backed up ${absPaths.length} path(s) to ${path20.relative(root, backupRoot)} before pruning.`);
|
|
3276
3524
|
}
|
|
3277
3525
|
async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun, quiet) {
|
|
3278
3526
|
const say = quiet ? () => {
|
|
@@ -3284,21 +3532,21 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
|
|
|
3284
3532
|
if (!entry.id) continue;
|
|
3285
3533
|
const relPaths = entry.paths ?? [];
|
|
3286
3534
|
if (relPaths.length === 0) continue;
|
|
3287
|
-
const existing =
|
|
3288
|
-
for (const rel of relPaths) {
|
|
3289
|
-
if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
|
|
3290
|
-
}
|
|
3535
|
+
const existing = await resolveExistingTrackedPaths(root, entry.id, relPaths, warn);
|
|
3291
3536
|
if (existing.length === 0) continue;
|
|
3292
3537
|
if (entry.hash === void 0) {
|
|
3293
3538
|
warn(
|
|
3294
|
-
`Orphaned catalog item ${entry.id} has no lock hash \u2014 leaving in place: ${existing.join(", ")}`
|
|
3539
|
+
`Orphaned catalog item ${entry.id} has no lock hash \u2014 leaving in place: ${existing.map((e) => e.rel).join(", ")}`
|
|
3295
3540
|
);
|
|
3296
3541
|
continue;
|
|
3297
3542
|
}
|
|
3298
|
-
const currentHash = await hashInstalledPaths(
|
|
3543
|
+
const currentHash = await hashInstalledPaths(
|
|
3544
|
+
root,
|
|
3545
|
+
existing.map((e) => e.rel)
|
|
3546
|
+
);
|
|
3299
3547
|
if (currentHash !== entry.hash) {
|
|
3300
3548
|
warn(
|
|
3301
|
-
`Orphaned catalog item ${entry.id} was modified locally \u2014 leaving in place: ${existing.join(", ")}`
|
|
3549
|
+
`Orphaned catalog item ${entry.id} was modified locally \u2014 leaving in place: ${existing.map((e) => e.rel).join(", ")}`
|
|
3302
3550
|
);
|
|
3303
3551
|
continue;
|
|
3304
3552
|
}
|
|
@@ -3307,21 +3555,18 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
|
|
|
3307
3555
|
if (toRemove.length === 0) return;
|
|
3308
3556
|
if (dryRun) {
|
|
3309
3557
|
for (const { id, existing } of toRemove) {
|
|
3310
|
-
for (const
|
|
3311
|
-
say(`[dry-run] would prune orphaned ${displayPath(root,
|
|
3558
|
+
for (const { abs } of existing) {
|
|
3559
|
+
say(`[dry-run] would prune orphaned ${displayPath(root, abs)} (${id})`);
|
|
3312
3560
|
}
|
|
3313
3561
|
}
|
|
3314
3562
|
return;
|
|
3315
3563
|
}
|
|
3316
|
-
const allAbsPaths = toRemove.flatMap(
|
|
3317
|
-
({ existing }) => existing.map((rel) => path19.join(root, rel))
|
|
3318
|
-
);
|
|
3564
|
+
const allAbsPaths = toRemove.flatMap(({ existing }) => existing.map(({ abs }) => abs));
|
|
3319
3565
|
await backupBeforePrune(root, allAbsPaths, say);
|
|
3320
3566
|
for (const { id, existing } of toRemove) {
|
|
3321
|
-
for (const
|
|
3322
|
-
const abs = path19.join(root, rel);
|
|
3567
|
+
for (const { abs } of existing) {
|
|
3323
3568
|
await fs18.remove(abs);
|
|
3324
|
-
await pruneEmptyDir(
|
|
3569
|
+
await pruneEmptyDir(path20.dirname(abs));
|
|
3325
3570
|
say(`Pruned orphaned ${displayPath(root, abs)} (${id})`);
|
|
3326
3571
|
}
|
|
3327
3572
|
}
|
|
@@ -3329,7 +3574,7 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
|
|
|
3329
3574
|
|
|
3330
3575
|
// src/claude/write-gitignore.ts
|
|
3331
3576
|
init_exec();
|
|
3332
|
-
import
|
|
3577
|
+
import path21 from "node:path";
|
|
3333
3578
|
import fs19 from "fs-extra";
|
|
3334
3579
|
var GITIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
|
|
3335
3580
|
var GITIGNORE_END = "# HAUS:END haus-managed";
|
|
@@ -3444,14 +3689,14 @@ function injectLinkContextGitignoreBlock(existing) {
|
|
|
3444
3689
|
);
|
|
3445
3690
|
}
|
|
3446
3691
|
async function writeGitignore(root, dryRun) {
|
|
3447
|
-
const filePath =
|
|
3692
|
+
const filePath = path21.join(root, ".gitignore");
|
|
3448
3693
|
const prev = await fs19.pathExists(filePath) ? await fs19.readFile(filePath, "utf8") : "";
|
|
3449
3694
|
const next = injectGitignoreBlock(prev);
|
|
3450
3695
|
await writeManagedText(root, filePath, next, dryRun);
|
|
3451
3696
|
return filePath;
|
|
3452
3697
|
}
|
|
3453
3698
|
async function writeLinkContextGitignore(root, dryRun) {
|
|
3454
|
-
const filePath =
|
|
3699
|
+
const filePath = path21.join(root, ".gitignore");
|
|
3455
3700
|
const prev = await fs19.pathExists(filePath) ? await fs19.readFile(filePath, "utf8") : "";
|
|
3456
3701
|
const next = injectLinkContextGitignoreBlock(prev);
|
|
3457
3702
|
await writeManagedText(root, filePath, next, dryRun);
|
|
@@ -3493,15 +3738,15 @@ async function untrackMachineLocalArtifacts(root, dryRun, notify) {
|
|
|
3493
3738
|
}
|
|
3494
3739
|
|
|
3495
3740
|
// src/refs/fetch-refs.ts
|
|
3496
|
-
import
|
|
3741
|
+
import path23 from "node:path";
|
|
3497
3742
|
import fs21 from "fs-extra";
|
|
3498
3743
|
|
|
3499
3744
|
// src/refs/cache-meta.ts
|
|
3500
|
-
import
|
|
3745
|
+
import path22 from "node:path";
|
|
3501
3746
|
import fs20 from "fs-extra";
|
|
3502
3747
|
var META_FILENAME = "cache-meta.json";
|
|
3503
3748
|
async function readCacheMeta(cacheDir) {
|
|
3504
|
-
const metaPath =
|
|
3749
|
+
const metaPath = path22.join(cacheDir, META_FILENAME);
|
|
3505
3750
|
try {
|
|
3506
3751
|
const raw = await fs20.readFile(metaPath, "utf8");
|
|
3507
3752
|
return JSON.parse(raw);
|
|
@@ -3511,7 +3756,7 @@ async function readCacheMeta(cacheDir) {
|
|
|
3511
3756
|
}
|
|
3512
3757
|
async function writeCacheMeta(cacheDir, meta) {
|
|
3513
3758
|
await fs20.ensureDir(cacheDir);
|
|
3514
|
-
const metaPath =
|
|
3759
|
+
const metaPath = path22.join(cacheDir, META_FILENAME);
|
|
3515
3760
|
await fs20.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
|
|
3516
3761
|
}
|
|
3517
3762
|
|
|
@@ -3532,8 +3777,8 @@ function isLlmsTxtUrl(ref) {
|
|
|
3532
3777
|
async function fetchSingleRef(url, cacheDir, meta) {
|
|
3533
3778
|
const existing = meta[url];
|
|
3534
3779
|
const headers = {};
|
|
3535
|
-
const safeFile = existing?.file && existing.file ===
|
|
3536
|
-
const cachedFileExists = safeFile && await fs21.pathExists(
|
|
3780
|
+
const safeFile = existing?.file && existing.file === path23.basename(existing.file);
|
|
3781
|
+
const cachedFileExists = safeFile && await fs21.pathExists(path23.join(cacheDir, existing.file));
|
|
3537
3782
|
if (cachedFileExists) {
|
|
3538
3783
|
if (existing?.etag) headers["If-None-Match"] = existing.etag;
|
|
3539
3784
|
if (existing?.lastModified) headers["If-Modified-Since"] = existing.lastModified;
|
|
@@ -3545,7 +3790,7 @@ async function fetchSingleRef(url, cacheDir, meta) {
|
|
|
3545
3790
|
const text = await res.text();
|
|
3546
3791
|
const file = `${urlToSlug(url)}.md`;
|
|
3547
3792
|
await fs21.ensureDir(cacheDir);
|
|
3548
|
-
await fs21.writeFile(
|
|
3793
|
+
await fs21.writeFile(path23.join(cacheDir, file), text, "utf8");
|
|
3549
3794
|
meta[url] = {
|
|
3550
3795
|
url,
|
|
3551
3796
|
etag: res.headers.get("etag") ?? void 0,
|
|
@@ -3568,8 +3813,8 @@ async function pruneOrphanedRefs(cacheDir, keepUrls) {
|
|
|
3568
3813
|
if (keepUrls.has(url)) continue;
|
|
3569
3814
|
delete meta[url];
|
|
3570
3815
|
removed++;
|
|
3571
|
-
const safeFile = typeof entry.file === "string" && entry.file ===
|
|
3572
|
-
if (safeFile) await fs21.remove(
|
|
3816
|
+
const safeFile = typeof entry.file === "string" && entry.file === path23.basename(entry.file);
|
|
3817
|
+
if (safeFile) await fs21.remove(path23.join(cacheDir, entry.file)).catch(() => {
|
|
3573
3818
|
});
|
|
3574
3819
|
}
|
|
3575
3820
|
if (removed > 0) await writeCacheMeta(cacheDir, meta);
|
|
@@ -3591,10 +3836,10 @@ async function fetchRefsForItems(items, cacheDir) {
|
|
|
3591
3836
|
const outcome = await fetchSingleRef(url, cacheDir, meta);
|
|
3592
3837
|
if (outcome.result === "fetched") {
|
|
3593
3838
|
summary.fetched++;
|
|
3594
|
-
summary.cachedFiles[url] =
|
|
3839
|
+
summary.cachedFiles[url] = path23.join(cacheDir, outcome.file);
|
|
3595
3840
|
} else if (outcome.result === "unchanged") {
|
|
3596
3841
|
summary.unchanged++;
|
|
3597
|
-
if (meta[url]?.file) summary.cachedFiles[url] =
|
|
3842
|
+
if (meta[url]?.file) summary.cachedFiles[url] = path23.join(cacheDir, meta[url].file);
|
|
3598
3843
|
} else {
|
|
3599
3844
|
summary.failed++;
|
|
3600
3845
|
summary.failedUrls.push(url);
|
|
@@ -3617,7 +3862,7 @@ function parseIdList(value) {
|
|
|
3617
3862
|
// src/commands/apply.ts
|
|
3618
3863
|
var STALE_CACHE_THRESHOLD_DAYS = 7;
|
|
3619
3864
|
async function cacheHasItems() {
|
|
3620
|
-
const data = await readJson(
|
|
3865
|
+
const data = await readJson(path24.join(getCacheDir(), "manifest.json"));
|
|
3621
3866
|
return Array.isArray(data?.items) && data.items.length > 0;
|
|
3622
3867
|
}
|
|
3623
3868
|
function installableRecommendedItems(recommended) {
|
|
@@ -3768,7 +4013,7 @@ async function runApply(options) {
|
|
|
3768
4013
|
}
|
|
3769
4014
|
|
|
3770
4015
|
// src/commands/backups.ts
|
|
3771
|
-
import
|
|
4016
|
+
import path25 from "node:path";
|
|
3772
4017
|
import fs22 from "fs-extra";
|
|
3773
4018
|
|
|
3774
4019
|
// src/utils/prompts.ts
|
|
@@ -3805,7 +4050,7 @@ async function listBackups(root) {
|
|
|
3805
4050
|
for (const name of names) {
|
|
3806
4051
|
const kind = classify(name);
|
|
3807
4052
|
if (!kind) continue;
|
|
3808
|
-
const absPath =
|
|
4053
|
+
const absPath = path25.join(dir, name);
|
|
3809
4054
|
const stat = await fs22.lstat(absPath);
|
|
3810
4055
|
if (stat.isSymbolicLink()) continue;
|
|
3811
4056
|
const expectDirectory = kind !== "lock";
|
|
@@ -3835,10 +4080,10 @@ async function runList(root) {
|
|
|
3835
4080
|
}
|
|
3836
4081
|
}
|
|
3837
4082
|
async function isUnsafeWriteTarget(root, target) {
|
|
3838
|
-
const segments =
|
|
4083
|
+
const segments = path25.relative(root, target).split(path25.sep).filter(Boolean);
|
|
3839
4084
|
let current = root;
|
|
3840
4085
|
for (let i = 0; i < segments.length; i++) {
|
|
3841
|
-
current =
|
|
4086
|
+
current = path25.join(current, segments[i]);
|
|
3842
4087
|
const isLastSegment = i === segments.length - 1;
|
|
3843
4088
|
try {
|
|
3844
4089
|
const stat = await fs22.lstat(current);
|
|
@@ -3902,7 +4147,7 @@ async function collectFilesRecursive(dir) {
|
|
|
3902
4147
|
const skippedOther = [];
|
|
3903
4148
|
const names = await fs22.readdir(dir);
|
|
3904
4149
|
for (const name of names) {
|
|
3905
|
-
const abs =
|
|
4150
|
+
const abs = path25.join(dir, name);
|
|
3906
4151
|
const stat = await fs22.lstat(abs);
|
|
3907
4152
|
if (stat.isSymbolicLink()) {
|
|
3908
4153
|
skippedLinks.push(abs);
|
|
@@ -3925,12 +4170,12 @@ async function restoreDirBackup(root, entry, yes) {
|
|
|
3925
4170
|
const { files, skippedLinks, skippedOther } = await collectFilesRecursive(entry.absPath);
|
|
3926
4171
|
if (skippedLinks.length > 0) {
|
|
3927
4172
|
warn(
|
|
3928
|
-
`Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) =>
|
|
4173
|
+
`Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) => path25.relative(entry.absPath, abs)).join(", ")}`
|
|
3929
4174
|
);
|
|
3930
4175
|
}
|
|
3931
4176
|
if (skippedOther.length > 0) {
|
|
3932
4177
|
warn(
|
|
3933
|
-
`Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) =>
|
|
4178
|
+
`Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) => path25.relative(entry.absPath, abs)).join(", ")}`
|
|
3934
4179
|
);
|
|
3935
4180
|
}
|
|
3936
4181
|
if (files.length === 0) {
|
|
@@ -3940,8 +4185,8 @@ async function restoreDirBackup(root, entry, yes) {
|
|
|
3940
4185
|
const safeFiles = [];
|
|
3941
4186
|
const unsafeTargets = [];
|
|
3942
4187
|
for (const abs of files) {
|
|
3943
|
-
const rel =
|
|
3944
|
-
const target =
|
|
4188
|
+
const rel = path25.relative(entry.absPath, abs);
|
|
4189
|
+
const target = path25.join(root, rel);
|
|
3945
4190
|
if (await isUnsafeWriteTarget(root, target)) unsafeTargets.push(rel);
|
|
3946
4191
|
else safeFiles.push(abs);
|
|
3947
4192
|
}
|
|
@@ -3958,14 +4203,14 @@ async function restoreDirBackup(root, entry, yes) {
|
|
|
3958
4203
|
const vanished = [];
|
|
3959
4204
|
const toRestore = [];
|
|
3960
4205
|
for (const abs of safeFiles) {
|
|
3961
|
-
const rel =
|
|
4206
|
+
const rel = path25.relative(entry.absPath, abs);
|
|
3962
4207
|
const backupFileStat = await lstatIfRegularFile(abs);
|
|
3963
4208
|
if (!backupFileStat) {
|
|
3964
4209
|
vanished.push(rel);
|
|
3965
4210
|
continue;
|
|
3966
4211
|
}
|
|
3967
4212
|
toRestore.push(abs);
|
|
3968
|
-
const target =
|
|
4213
|
+
const target = path25.join(root, rel);
|
|
3969
4214
|
if (await fs22.pathExists(target)) {
|
|
3970
4215
|
const targetStat = await fs22.stat(target);
|
|
3971
4216
|
if (targetStat.mtimeMs > backupFileStat.mtimeMs) {
|
|
@@ -4006,13 +4251,13 @@ async function restoreDirBackup(root, entry, yes) {
|
|
|
4006
4251
|
let restoredCount = 0;
|
|
4007
4252
|
const skippedAtWrite = [];
|
|
4008
4253
|
for (const abs of toRestore) {
|
|
4009
|
-
const rel =
|
|
4010
|
-
const target =
|
|
4254
|
+
const rel = path25.relative(entry.absPath, abs);
|
|
4255
|
+
const target = path25.join(root, rel);
|
|
4011
4256
|
if (!await lstatIfRegularFile(abs) || await isUnsafeWriteTarget(root, target)) {
|
|
4012
4257
|
skippedAtWrite.push(rel);
|
|
4013
4258
|
continue;
|
|
4014
4259
|
}
|
|
4015
|
-
await fs22.ensureDir(
|
|
4260
|
+
await fs22.ensureDir(path25.dirname(target));
|
|
4016
4261
|
await fs22.copy(abs, target, { overwrite: true });
|
|
4017
4262
|
restoredCount++;
|
|
4018
4263
|
}
|
|
@@ -4151,16 +4396,53 @@ async function runCatalogAudit() {
|
|
|
4151
4396
|
}
|
|
4152
4397
|
|
|
4153
4398
|
// src/commands/doctor.ts
|
|
4154
|
-
import
|
|
4155
|
-
import
|
|
4399
|
+
import path39 from "node:path";
|
|
4400
|
+
import fs30 from "fs-extra";
|
|
4401
|
+
|
|
4402
|
+
// src/claude/skill-reference-check.ts
|
|
4403
|
+
import path26 from "node:path";
|
|
4404
|
+
import fg4 from "fast-glob";
|
|
4405
|
+
import fs23 from "fs-extra";
|
|
4406
|
+
var REFERENCE_PATTERN = /\b([a-z][a-z0-9]*(?:-[a-z0-9]+)+):([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\b(?!:\/\/)/g;
|
|
4407
|
+
function stripFencedCodeBlocks(text) {
|
|
4408
|
+
return text.replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, " "));
|
|
4409
|
+
}
|
|
4410
|
+
async function findUnresolvedSkillReferences(root) {
|
|
4411
|
+
const skillsRoot = claudePath(root, "skills");
|
|
4412
|
+
if (!await fs23.pathExists(skillsRoot)) return [];
|
|
4413
|
+
const skillDirs = (await fs23.readdir(skillsRoot, { withFileTypes: true })).filter((entry) => entry.isDirectory() && entry.name !== "shared").map((entry) => entry.name);
|
|
4414
|
+
const installedSkillNames = new Set(skillDirs);
|
|
4415
|
+
const files = await fg4("*/**/*.md", { cwd: skillsRoot, onlyFiles: true, dot: true });
|
|
4416
|
+
const issues = [];
|
|
4417
|
+
for (const rel of files) {
|
|
4418
|
+
if (rel.startsWith("shared/")) continue;
|
|
4419
|
+
const abs = path26.join(skillsRoot, rel);
|
|
4420
|
+
const raw = await fs23.readFile(abs, "utf8");
|
|
4421
|
+
const lines = stripFencedCodeBlocks(raw).split("\n");
|
|
4422
|
+
lines.forEach((line2, idx) => {
|
|
4423
|
+
for (const match of line2.matchAll(REFERENCE_PATTERN)) {
|
|
4424
|
+
const [reference, , bareName] = match;
|
|
4425
|
+
if (!reference || !bareName) continue;
|
|
4426
|
+
issues.push({
|
|
4427
|
+
file: path26.join(".claude/skills", rel),
|
|
4428
|
+
line: idx + 1,
|
|
4429
|
+
reference,
|
|
4430
|
+
bareName,
|
|
4431
|
+
bareInstalled: installedSkillNames.has(bareName)
|
|
4432
|
+
});
|
|
4433
|
+
}
|
|
4434
|
+
});
|
|
4435
|
+
}
|
|
4436
|
+
return issues;
|
|
4437
|
+
}
|
|
4156
4438
|
|
|
4157
4439
|
// src/decisions/doctor.ts
|
|
4158
|
-
import
|
|
4159
|
-
import
|
|
4440
|
+
import path31 from "node:path";
|
|
4441
|
+
import fs27 from "fs-extra";
|
|
4160
4442
|
|
|
4161
4443
|
// src/decisions/check.ts
|
|
4162
|
-
import
|
|
4163
|
-
import
|
|
4444
|
+
import path30 from "node:path";
|
|
4445
|
+
import fs26 from "fs-extra";
|
|
4164
4446
|
|
|
4165
4447
|
// src/decisions/dep-bump.ts
|
|
4166
4448
|
var DEP_SECTIONS = [
|
|
@@ -4169,15 +4451,15 @@ var DEP_SECTIONS = [
|
|
|
4169
4451
|
"peerDependencies",
|
|
4170
4452
|
"optionalDependencies"
|
|
4171
4453
|
];
|
|
4172
|
-
function
|
|
4454
|
+
function isRecord2(value) {
|
|
4173
4455
|
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
4174
4456
|
}
|
|
4175
4457
|
function extractDepMap(pkg) {
|
|
4176
4458
|
const map = /* @__PURE__ */ new Map();
|
|
4177
|
-
if (!
|
|
4459
|
+
if (!isRecord2(pkg)) return map;
|
|
4178
4460
|
for (const section of DEP_SECTIONS) {
|
|
4179
4461
|
const block = pkg[section];
|
|
4180
|
-
if (!
|
|
4462
|
+
if (!isRecord2(block)) continue;
|
|
4181
4463
|
for (const [name, ver] of Object.entries(block)) {
|
|
4182
4464
|
if (typeof ver === "string") map.set(`${section}:${name}`, ver);
|
|
4183
4465
|
}
|
|
@@ -4203,7 +4485,7 @@ function isDependencyBumpOnly(beforeText, afterText) {
|
|
|
4203
4485
|
} catch {
|
|
4204
4486
|
return false;
|
|
4205
4487
|
}
|
|
4206
|
-
if (!
|
|
4488
|
+
if (!isRecord2(before) || !isRecord2(after)) return false;
|
|
4207
4489
|
const beforeDeps = extractDepMap(before);
|
|
4208
4490
|
const afterDeps = extractDepMap(after);
|
|
4209
4491
|
if (beforeDeps.size === 0 && afterDeps.size === 0) return false;
|
|
@@ -4219,7 +4501,7 @@ function isDependencyBumpOnly(beforeText, afterText) {
|
|
|
4219
4501
|
|
|
4220
4502
|
// src/decisions/diff.ts
|
|
4221
4503
|
init_exec();
|
|
4222
|
-
import
|
|
4504
|
+
import path27 from "node:path";
|
|
4223
4505
|
function isPackageJsonPath(file) {
|
|
4224
4506
|
return file === "package.json" || file.endsWith("/package.json");
|
|
4225
4507
|
}
|
|
@@ -4246,6 +4528,11 @@ function parseDiffRange(range) {
|
|
|
4246
4528
|
}
|
|
4247
4529
|
return null;
|
|
4248
4530
|
}
|
|
4531
|
+
function toAncestryLogRange(range) {
|
|
4532
|
+
const parsed = parseDiffRange(range);
|
|
4533
|
+
if (!parsed) return `${range}..HEAD`;
|
|
4534
|
+
return `${parsed.left}..${parsed.right}`;
|
|
4535
|
+
}
|
|
4249
4536
|
async function getCurrentBranch(root) {
|
|
4250
4537
|
const result = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root });
|
|
4251
4538
|
if (result.exitCode !== 0) return void 0;
|
|
@@ -4275,7 +4562,7 @@ async function resolvePackageJsonPair(root, file, opts) {
|
|
|
4275
4562
|
return { before: before2, after: after2 };
|
|
4276
4563
|
}
|
|
4277
4564
|
const before = await readBlob(root, `${range}:${file}`) ?? empty;
|
|
4278
|
-
const after = await readText(
|
|
4565
|
+
const after = await readText(path27.join(root, file)) ?? empty;
|
|
4279
4566
|
return { before, after };
|
|
4280
4567
|
}
|
|
4281
4568
|
async function collectDiffStats(root, opts) {
|
|
@@ -4346,22 +4633,22 @@ function firstMatchingRegex(filePath, patterns) {
|
|
|
4346
4633
|
}
|
|
4347
4634
|
|
|
4348
4635
|
// src/decisions/paths.ts
|
|
4349
|
-
import
|
|
4350
|
-
import
|
|
4636
|
+
import path28 from "node:path";
|
|
4637
|
+
import fs24 from "fs-extra";
|
|
4351
4638
|
async function resolveDecisionsDir(root) {
|
|
4352
|
-
const primary =
|
|
4353
|
-
if (await
|
|
4354
|
-
const legacy =
|
|
4355
|
-
if (await
|
|
4639
|
+
const primary = path28.join(root, DECISIONS_TRIGGERS.decisionsDir);
|
|
4640
|
+
if (await fs24.pathExists(primary)) return primary;
|
|
4641
|
+
const legacy = path28.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
|
|
4642
|
+
if (await fs24.pathExists(legacy)) return legacy;
|
|
4356
4643
|
return primary;
|
|
4357
4644
|
}
|
|
4358
4645
|
function relativeDecisionPath(decisionsDir, root) {
|
|
4359
|
-
return
|
|
4646
|
+
return path28.relative(root, decisionsDir).replace(/\\/g, "/");
|
|
4360
4647
|
}
|
|
4361
4648
|
|
|
4362
4649
|
// src/decisions/validate.ts
|
|
4363
|
-
import
|
|
4364
|
-
import
|
|
4650
|
+
import path29 from "node:path";
|
|
4651
|
+
import fs25 from "fs-extra";
|
|
4365
4652
|
function validateDecisionContent(content2) {
|
|
4366
4653
|
const errors = [];
|
|
4367
4654
|
for (const section of DECISIONS_TRIGGERS.requiredSections) {
|
|
@@ -4390,14 +4677,14 @@ function decisionNumberFromFile(name) {
|
|
|
4390
4677
|
return match?.[1] ?? null;
|
|
4391
4678
|
}
|
|
4392
4679
|
async function validateIndexSync(decisionsDir, decisionFiles) {
|
|
4393
|
-
const readmePath =
|
|
4394
|
-
if (!await
|
|
4680
|
+
const readmePath = path29.join(decisionsDir, "README.md");
|
|
4681
|
+
if (!await fs25.pathExists(readmePath)) {
|
|
4395
4682
|
return ["docs/decisions/README.md is missing"];
|
|
4396
4683
|
}
|
|
4397
4684
|
const readme = await readText(readmePath) ?? "";
|
|
4398
4685
|
const errors = [];
|
|
4399
4686
|
for (const file of decisionFiles) {
|
|
4400
|
-
const num = decisionNumberFromFile(
|
|
4687
|
+
const num = decisionNumberFromFile(path29.basename(file));
|
|
4401
4688
|
if (num && !indexReferencesNumber(readme, num)) {
|
|
4402
4689
|
errors.push(`README.md missing index row for ADR-${num}`);
|
|
4403
4690
|
}
|
|
@@ -4492,22 +4779,22 @@ async function checkSatisfaction(input2) {
|
|
|
4492
4779
|
const errors = [];
|
|
4493
4780
|
const decisionsPrefix = `${input2.decisionsRel}/`;
|
|
4494
4781
|
const newDecisionFiles = input2.changedFiles.filter(
|
|
4495
|
-
(f) => f.startsWith(decisionsPrefix) && isDecisionFileName(
|
|
4782
|
+
(f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path30.basename(f))
|
|
4496
4783
|
);
|
|
4497
4784
|
if (newDecisionFiles.length === 0) {
|
|
4498
4785
|
errors.push(`no new or updated decision under ${input2.decisionsRel}/`);
|
|
4499
4786
|
return errors;
|
|
4500
4787
|
}
|
|
4501
|
-
const decisionsDir =
|
|
4788
|
+
const decisionsDir = path30.join(input2.root, input2.decisionsRel);
|
|
4502
4789
|
for (const rel of newDecisionFiles) {
|
|
4503
|
-
const abs =
|
|
4790
|
+
const abs = path30.join(input2.root, rel);
|
|
4504
4791
|
const content2 = await readText(abs) ?? "";
|
|
4505
4792
|
const valid = validateDecisionContent(content2);
|
|
4506
4793
|
if (!valid.ok) errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
|
|
4507
4794
|
}
|
|
4508
4795
|
const indexErrors = await validateIndexSync(
|
|
4509
4796
|
decisionsDir,
|
|
4510
|
-
newDecisionFiles.map((f) =>
|
|
4797
|
+
newDecisionFiles.map((f) => path30.basename(f))
|
|
4511
4798
|
);
|
|
4512
4799
|
errors.push(...indexErrors);
|
|
4513
4800
|
return errors;
|
|
@@ -4563,8 +4850,8 @@ ${opts.commitMessages ?? ""}`;
|
|
|
4563
4850
|
changedFiles: stats.files
|
|
4564
4851
|
});
|
|
4565
4852
|
if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
|
|
4566
|
-
const legacyExists = await
|
|
4567
|
-
if (legacyExists && !await
|
|
4853
|
+
const legacyExists = await fs26.pathExists(path30.join(root, legacyRel));
|
|
4854
|
+
if (legacyExists && !await fs26.pathExists(decisionsDir)) {
|
|
4568
4855
|
satisfactionErrors = await checkSatisfaction({
|
|
4569
4856
|
root,
|
|
4570
4857
|
decisionsRel: legacyRel,
|
|
@@ -4586,18 +4873,18 @@ ${opts.commitMessages ?? ""}`;
|
|
|
4586
4873
|
return { triggered: true, satisfied: false, reasons, securityTriggered };
|
|
4587
4874
|
}
|
|
4588
4875
|
async function listDecisionNumbers(decisionsDir) {
|
|
4589
|
-
if (!await
|
|
4590
|
-
const entries = await
|
|
4876
|
+
if (!await fs26.pathExists(decisionsDir)) return [];
|
|
4877
|
+
const entries = await fs26.readdir(decisionsDir);
|
|
4591
4878
|
return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
|
|
4592
4879
|
}
|
|
4593
4880
|
|
|
4594
4881
|
// src/decisions/doctor.ts
|
|
4595
4882
|
async function auditDecisionsLayout(root) {
|
|
4596
4883
|
const findings = [];
|
|
4597
|
-
const primary =
|
|
4598
|
-
const legacy =
|
|
4599
|
-
const hasPrimary = await
|
|
4600
|
-
const hasLegacy = await
|
|
4884
|
+
const primary = path31.join(root, DECISIONS_TRIGGERS.decisionsDir);
|
|
4885
|
+
const legacy = path31.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
|
|
4886
|
+
const hasPrimary = await fs27.pathExists(primary);
|
|
4887
|
+
const hasLegacy = await fs27.pathExists(legacy);
|
|
4601
4888
|
if (!hasPrimary && hasLegacy) {
|
|
4602
4889
|
findings.push({
|
|
4603
4890
|
level: "advisory",
|
|
@@ -4610,8 +4897,8 @@ async function auditDecisionsLayout(root) {
|
|
|
4610
4897
|
if (!hasPrimary) return findings;
|
|
4611
4898
|
const decisionsDir = await resolveDecisionsDir(root);
|
|
4612
4899
|
const rel = relativeDecisionPath(decisionsDir, root);
|
|
4613
|
-
const readmePath =
|
|
4614
|
-
if (!await
|
|
4900
|
+
const readmePath = path31.join(decisionsDir, "README.md");
|
|
4901
|
+
if (!await fs27.pathExists(readmePath)) {
|
|
4615
4902
|
findings.push({
|
|
4616
4903
|
level: "advisory",
|
|
4617
4904
|
line: `- DECISIONS: missing ${rel}/README.md index`,
|
|
@@ -4621,7 +4908,7 @@ async function auditDecisionsLayout(root) {
|
|
|
4621
4908
|
return findings;
|
|
4622
4909
|
}
|
|
4623
4910
|
const readme = await readText(readmePath);
|
|
4624
|
-
const entries = (await
|
|
4911
|
+
const entries = (await fs27.readdir(decisionsDir)).filter(
|
|
4625
4912
|
(f) => f.endsWith(".md") && f !== "README.md"
|
|
4626
4913
|
);
|
|
4627
4914
|
for (const file of entries) {
|
|
@@ -4635,7 +4922,7 @@ async function auditDecisionsLayout(root) {
|
|
|
4635
4922
|
});
|
|
4636
4923
|
}
|
|
4637
4924
|
}
|
|
4638
|
-
const claude = await readText(
|
|
4925
|
+
const claude = await readText(path31.join(root, "CLAUDE.md"));
|
|
4639
4926
|
if (claude && !claude.includes(`@${rel}/README.md`)) {
|
|
4640
4927
|
findings.push({
|
|
4641
4928
|
level: "advisory",
|
|
@@ -4666,11 +4953,11 @@ async function auditDecisionsLayout(root) {
|
|
|
4666
4953
|
}
|
|
4667
4954
|
|
|
4668
4955
|
// src/scanner/read-context.ts
|
|
4669
|
-
import
|
|
4670
|
-
import
|
|
4956
|
+
import path38 from "node:path";
|
|
4957
|
+
import fs29 from "fs-extra";
|
|
4671
4958
|
|
|
4672
4959
|
// src/commands/workspace/config.ts
|
|
4673
|
-
import
|
|
4960
|
+
import path32 from "node:path";
|
|
4674
4961
|
import YAML from "yaml";
|
|
4675
4962
|
var WORKSPACE_FILE = "haus.workspace.yaml";
|
|
4676
4963
|
function parseWorkspaceConfig(text) {
|
|
@@ -4693,11 +4980,11 @@ function parseWorkspaceConfig(text) {
|
|
|
4693
4980
|
};
|
|
4694
4981
|
}
|
|
4695
4982
|
async function readWorkspaceConfig(workspaceRoot) {
|
|
4696
|
-
return parseWorkspaceConfig(await readText(
|
|
4983
|
+
return parseWorkspaceConfig(await readText(path32.join(workspaceRoot, WORKSPACE_FILE)));
|
|
4697
4984
|
}
|
|
4698
4985
|
|
|
4699
4986
|
// src/workspace/members.ts
|
|
4700
|
-
import
|
|
4987
|
+
import path33 from "node:path";
|
|
4701
4988
|
var REPOS_MANIFEST_FILE = "repos.manifest.json";
|
|
4702
4989
|
var REPOS_LOCAL_FILE = "repos.local.json";
|
|
4703
4990
|
var MemberConfigError = class extends Error {
|
|
@@ -4710,14 +4997,14 @@ var MemberConfigError = class extends Error {
|
|
|
4710
4997
|
};
|
|
4711
4998
|
function resolveAbsPath(workspaceRoot, folder, overrides) {
|
|
4712
4999
|
const override = overrides[folder];
|
|
4713
|
-
if (override) return
|
|
4714
|
-
return
|
|
5000
|
+
if (override) return path33.resolve(workspaceRoot, override);
|
|
5001
|
+
return path33.resolve(workspaceRoot, folder);
|
|
4715
5002
|
}
|
|
4716
5003
|
function isSelfMember(workspaceRoot, member) {
|
|
4717
|
-
return
|
|
5004
|
+
return path33.resolve(member.absPath) === path33.resolve(workspaceRoot);
|
|
4718
5005
|
}
|
|
4719
5006
|
async function readPathOverrides(workspaceRoot) {
|
|
4720
|
-
const filePath =
|
|
5007
|
+
const filePath = path33.join(workspaceRoot, REPOS_LOCAL_FILE);
|
|
4721
5008
|
const result = await readJsonDetailed(filePath);
|
|
4722
5009
|
if (result.status === "missing") return {};
|
|
4723
5010
|
if (result.status === "invalid") throw new MemberConfigError(filePath, "invalid JSON");
|
|
@@ -4733,7 +5020,7 @@ async function readPathOverrides(workspaceRoot) {
|
|
|
4733
5020
|
return overrides;
|
|
4734
5021
|
}
|
|
4735
5022
|
async function readFromWorkspaceYaml(workspaceRoot, overrides) {
|
|
4736
|
-
const filePath =
|
|
5023
|
+
const filePath = path33.join(workspaceRoot, WORKSPACE_FILE);
|
|
4737
5024
|
const text = await readText(filePath);
|
|
4738
5025
|
if (text === void 0) return void 0;
|
|
4739
5026
|
const config = parseWorkspaceConfig(text);
|
|
@@ -4746,7 +5033,7 @@ async function readFromWorkspaceYaml(workspaceRoot, overrides) {
|
|
|
4746
5033
|
}));
|
|
4747
5034
|
}
|
|
4748
5035
|
async function readFromManifest(workspaceRoot, overrides) {
|
|
4749
|
-
const filePath =
|
|
5036
|
+
const filePath = path33.join(workspaceRoot, REPOS_MANIFEST_FILE);
|
|
4750
5037
|
const result = await readJsonDetailed(filePath);
|
|
4751
5038
|
if (result.status === "missing") return void 0;
|
|
4752
5039
|
if (result.status === "invalid") throw new MemberConfigError(filePath, "invalid JSON");
|
|
@@ -4788,16 +5075,11 @@ async function readMembers(rootInfo) {
|
|
|
4788
5075
|
}
|
|
4789
5076
|
|
|
4790
5077
|
// src/scanner/scan-project.ts
|
|
4791
|
-
import
|
|
4792
|
-
|
|
4793
|
-
// src/utils/audit-checks.ts
|
|
4794
|
-
function isRecord2(v) {
|
|
4795
|
-
return typeof v === "object" && v !== null;
|
|
4796
|
-
}
|
|
5078
|
+
import path37 from "node:path";
|
|
4797
5079
|
|
|
4798
5080
|
// src/utils/git-root.ts
|
|
4799
5081
|
init_exec();
|
|
4800
|
-
import
|
|
5082
|
+
import path34 from "node:path";
|
|
4801
5083
|
function fallback(start) {
|
|
4802
5084
|
return {
|
|
4803
5085
|
cwd: start,
|
|
@@ -4813,11 +5095,11 @@ function fallback(start) {
|
|
|
4813
5095
|
async function resolveAbsoluteGitPath(flag, cwd, repoRoot) {
|
|
4814
5096
|
const withFormat = await runGit(["rev-parse", "--path-format=absolute", flag], { cwd });
|
|
4815
5097
|
const raw = withFormat.exitCode === 0 ? withFormat.stdout.trim() : "";
|
|
4816
|
-
if (raw &&
|
|
5098
|
+
if (raw && path34.isAbsolute(raw)) return path34.resolve(raw);
|
|
4817
5099
|
const plain = await runGit(["rev-parse", flag], { cwd });
|
|
4818
5100
|
const plainRaw = plain.exitCode === 0 ? plain.stdout.trim() : "";
|
|
4819
5101
|
if (!plainRaw) return "";
|
|
4820
|
-
return
|
|
5102
|
+
return path34.isAbsolute(plainRaw) ? path34.resolve(plainRaw) : path34.resolve(repoRoot, plainRaw);
|
|
4821
5103
|
}
|
|
4822
5104
|
async function resolveRoots(start) {
|
|
4823
5105
|
const cwd = start ?? process.cwd();
|
|
@@ -4826,15 +5108,15 @@ async function resolveRoots(start) {
|
|
|
4826
5108
|
if (toplevel.exitCode !== 0) return fallback(cwd);
|
|
4827
5109
|
const toplevelRaw = toplevel.stdout.trim();
|
|
4828
5110
|
if (!toplevelRaw) return fallback(cwd);
|
|
4829
|
-
const repoRoot =
|
|
5111
|
+
const repoRoot = path34.resolve(toplevelRaw);
|
|
4830
5112
|
const [gitDir, gitCommonDir] = await Promise.all([
|
|
4831
5113
|
resolveAbsoluteGitPath("--git-dir", cwd, repoRoot),
|
|
4832
5114
|
resolveAbsoluteGitPath("--git-common-dir", cwd, repoRoot)
|
|
4833
5115
|
]);
|
|
4834
5116
|
if (!gitDir || !gitCommonDir) return fallback(cwd);
|
|
4835
5117
|
const isLinkedWorktree = gitDir !== gitCommonDir;
|
|
4836
|
-
const mainRoot = isLinkedWorktree &&
|
|
4837
|
-
const worktreeName = isLinkedWorktree ?
|
|
5118
|
+
const mainRoot = isLinkedWorktree && path34.basename(gitCommonDir) === ".git" ? path34.dirname(gitCommonDir) : repoRoot;
|
|
5119
|
+
const worktreeName = isLinkedWorktree ? path34.basename(gitDir) : null;
|
|
4838
5120
|
return {
|
|
4839
5121
|
cwd,
|
|
4840
5122
|
repoRoot,
|
|
@@ -4879,8 +5161,8 @@ function resolveMajorVersion(range) {
|
|
|
4879
5161
|
}
|
|
4880
5162
|
|
|
4881
5163
|
// src/scanner/detect-package-manager.ts
|
|
4882
|
-
import
|
|
4883
|
-
import
|
|
5164
|
+
import path35 from "node:path";
|
|
5165
|
+
import fs28 from "fs-extra";
|
|
4884
5166
|
function detectPackageManager(root, packageManagerField) {
|
|
4885
5167
|
const field = String(packageManagerField ?? "").trim();
|
|
4886
5168
|
if (field.startsWith("yarn@")) {
|
|
@@ -4898,9 +5180,9 @@ function detectPackageManager(root, packageManagerField) {
|
|
|
4898
5180
|
if (satisfiesVersion(version, ">=9")) return "npm";
|
|
4899
5181
|
return "unknown";
|
|
4900
5182
|
}
|
|
4901
|
-
if (
|
|
4902
|
-
if (
|
|
4903
|
-
if (
|
|
5183
|
+
if (fs28.existsSync(path35.join(root, "yarn.lock"))) return "yarn";
|
|
5184
|
+
if (fs28.existsSync(path35.join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
5185
|
+
if (fs28.existsSync(path35.join(root, "package-lock.json"))) return "npm";
|
|
4904
5186
|
return "unknown";
|
|
4905
5187
|
}
|
|
4906
5188
|
|
|
@@ -5017,6 +5299,7 @@ var STACK_RULES = [
|
|
|
5017
5299
|
stack: ["frontend", "shadcn"],
|
|
5018
5300
|
all: [fileEndsWith("components.json"), dep("class-variance-authority")]
|
|
5019
5301
|
},
|
|
5302
|
+
{ stack: ["frontend", "radix"], any: [depPrefix("@radix-ui/")] },
|
|
5020
5303
|
{ stack: ["tooling", "typescript5"], any: [depMajor("typescript", 5)] },
|
|
5021
5304
|
{ stack: ["backend", "sanity"], any: [dep("sanity"), dep("next-sanity"), dep("@sanity/client")] },
|
|
5022
5305
|
{ stack: ["backend", "strapi"], any: [dep("@strapi/strapi"), depPrefix("@strapi/")] },
|
|
@@ -5111,90 +5394,20 @@ function runDetection(ctx, rules = STACK_RULES) {
|
|
|
5111
5394
|
for (const rule of ROLE_RULES) {
|
|
5112
5395
|
if (rule.role && matchRule(rule, ctx) && !roles.includes(rule.role)) roles.push(rule.role);
|
|
5113
5396
|
}
|
|
5114
|
-
const stacks = {};
|
|
5115
|
-
for (const bucket of STACK_BUCKETS) stacks[bucket] = [];
|
|
5116
|
-
for (const rule of rules) {
|
|
5117
|
-
if (!rule.stack || !matchRule(rule, ctx)) continue;
|
|
5118
|
-
const [bucket, name] = rule.stack;
|
|
5119
|
-
stacks[bucket] ??= [];
|
|
5120
|
-
if (!stacks[bucket].includes(name)) stacks[bucket].push(name);
|
|
5121
|
-
}
|
|
5122
|
-
return { roles, stacks };
|
|
5123
|
-
}
|
|
5124
|
-
|
|
5125
|
-
// src/scanner/detection.ts
|
|
5126
|
-
import path34 from "node:path";
|
|
5127
|
-
var UNSUPPORTED_MARKERS = {
|
|
5128
|
-
"requirements.txt": "python",
|
|
5129
|
-
"pyproject.toml": "python",
|
|
5130
|
-
"go.mod": "go",
|
|
5131
|
-
"Cargo.toml": "rust",
|
|
5132
|
-
"pom.xml": "java",
|
|
5133
|
-
"build.gradle": "java",
|
|
5134
|
-
"build.gradle.kts": "java",
|
|
5135
|
-
Gemfile: "ruby"
|
|
5136
|
-
};
|
|
5137
|
-
var WEAK_STACK_SIGNALS = /* @__PURE__ */ new Set(["missing-prettier", "missing-eslint"]);
|
|
5138
|
-
function computeDetectionStatus(roles, stacks, unsupportedSignals) {
|
|
5139
|
-
const hasRealStack = Object.entries(stacks).some(
|
|
5140
|
-
([bucket, names]) => bucket !== "packageManagers" && names.some((n) => !WEAK_STACK_SIGNALS.has(n))
|
|
5141
|
-
);
|
|
5142
|
-
const hasRealSignal = roles.length > 0 || hasRealStack;
|
|
5143
|
-
if (!hasRealSignal) return "unknown";
|
|
5144
|
-
return unsupportedSignals.length > 0 ? "partial" : "supported";
|
|
5145
|
-
}
|
|
5146
|
-
function describeUnknownDetection(unsupportedSignals, isLinkedWorktree) {
|
|
5147
|
-
const markers = unsupportedSignals.join(", ");
|
|
5148
|
-
const base = markers ? `Stack not recognised \u2014 detected ${markers}, which haus does not support.` : "Stack not recognised \u2014 no supported framework detected.";
|
|
5149
|
-
const worktreeNote = isLinkedWorktree ? "You're running from inside a git worktree \u2014 sibling repos may not be on disk here, so this could be a false zero-signal reading rather than truly no stack." : "";
|
|
5150
|
-
return [base, worktreeNote, "Only stack-agnostic workflow and security guidance is applied."].filter(Boolean).join(" ");
|
|
5151
|
-
}
|
|
5152
|
-
function blocked(rel) {
|
|
5153
|
-
return SENSITIVE_PATH_REGEXES.some((x) => x.test(rel));
|
|
5154
|
-
}
|
|
5155
|
-
function dependencyVersionMap(pkg, composer) {
|
|
5156
|
-
const versions = /* @__PURE__ */ new Map();
|
|
5157
|
-
const pushObj = (obj) => {
|
|
5158
|
-
if (!isRecord2(obj)) return;
|
|
5159
|
-
for (const key of Object.keys(obj)) {
|
|
5160
|
-
if (versions.has(key)) continue;
|
|
5161
|
-
const value = obj[key];
|
|
5162
|
-
versions.set(key, typeof value === "string" ? value : "");
|
|
5163
|
-
}
|
|
5164
|
-
};
|
|
5165
|
-
pushObj(pkg?.dependencies);
|
|
5166
|
-
pushObj(pkg?.devDependencies);
|
|
5167
|
-
pushObj(composer?.require);
|
|
5168
|
-
pushObj(composer?.["require-dev"]);
|
|
5169
|
-
return versions;
|
|
5170
|
-
}
|
|
5171
|
-
function finalizeRoles(registryRoles, deps, files) {
|
|
5172
|
-
const roles = new Set(registryRoles);
|
|
5173
|
-
const hasWpConfig = files.some((f) => f.endsWith("wp-config.php"));
|
|
5174
|
-
const hasBedrockLayout = files.some((f) => f.includes("web/app")) || deps.includes("roots/wordpress");
|
|
5175
|
-
if (hasWpConfig && hasBedrockLayout) {
|
|
5176
|
-
roles.add("wordpress-bedrock-site");
|
|
5177
|
-
roles.add("wordpress-site");
|
|
5178
|
-
} else if (hasWpConfig) {
|
|
5179
|
-
roles.add("wordpress-vanilla-site");
|
|
5180
|
-
roles.add("wordpress-site");
|
|
5181
|
-
} else if (deps.includes("roots/wordpress")) {
|
|
5182
|
-
roles.add("wordpress-bedrock-site");
|
|
5183
|
-
roles.add("wordpress-site");
|
|
5184
|
-
}
|
|
5185
|
-
return [...roles].sort();
|
|
5186
|
-
}
|
|
5187
|
-
function collectUnsupportedSignals(files) {
|
|
5188
|
-
return [
|
|
5189
|
-
...new Set(
|
|
5190
|
-
files.map((f) => UNSUPPORTED_MARKERS[path34.basename(f)]).filter((s) => Boolean(s))
|
|
5191
|
-
)
|
|
5192
|
-
].sort();
|
|
5397
|
+
const stacks = {};
|
|
5398
|
+
for (const bucket of STACK_BUCKETS) stacks[bucket] = [];
|
|
5399
|
+
for (const rule of rules) {
|
|
5400
|
+
if (!rule.stack || !matchRule(rule, ctx)) continue;
|
|
5401
|
+
const [bucket, name] = rule.stack;
|
|
5402
|
+
stacks[bucket] ??= [];
|
|
5403
|
+
if (!stacks[bucket].includes(name)) stacks[bucket].push(name);
|
|
5404
|
+
}
|
|
5405
|
+
return { roles, stacks };
|
|
5193
5406
|
}
|
|
5194
5407
|
|
|
5195
5408
|
// src/scanner/render.ts
|
|
5196
5409
|
import { readFile } from "node:fs/promises";
|
|
5197
|
-
import
|
|
5410
|
+
import path36 from "node:path";
|
|
5198
5411
|
async function buildContentBlob(root, files) {
|
|
5199
5412
|
const candidates = files.filter(
|
|
5200
5413
|
(f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
|
|
@@ -5202,7 +5415,7 @@ async function buildContentBlob(root, files) {
|
|
|
5202
5415
|
const slice = candidates.slice(0, 300);
|
|
5203
5416
|
const parts = await mapWithConcurrency(slice, async (rel) => {
|
|
5204
5417
|
try {
|
|
5205
|
-
return await readFile(
|
|
5418
|
+
return await readFile(path36.join(root, rel), "utf8");
|
|
5206
5419
|
} catch {
|
|
5207
5420
|
return "";
|
|
5208
5421
|
}
|
|
@@ -5294,8 +5507,8 @@ var SAFE_FILES = [
|
|
|
5294
5507
|
"Gemfile"
|
|
5295
5508
|
];
|
|
5296
5509
|
async function scanProject(root) {
|
|
5297
|
-
const pkg = await readJson(
|
|
5298
|
-
const composer = await readJson(
|
|
5510
|
+
const pkg = await readJson(path37.join(root, "package.json"));
|
|
5511
|
+
const composer = await readJson(path37.join(root, "composer.json"));
|
|
5299
5512
|
const files = await listFiles(root, SAFE_FILES);
|
|
5300
5513
|
const safeFiles = files.filter((f) => !blocked(f));
|
|
5301
5514
|
const depVersions = dependencyVersionMap(pkg, composer);
|
|
@@ -5315,7 +5528,7 @@ async function scanProject(root) {
|
|
|
5315
5528
|
const warnings = [];
|
|
5316
5529
|
const securityRisks = [];
|
|
5317
5530
|
const crossRepoHints = [];
|
|
5318
|
-
const nodeEngine =
|
|
5531
|
+
const nodeEngine = isRecord(pkg?.engines) ? String(pkg.engines.node ?? "") : "";
|
|
5319
5532
|
if (nodeEngine && !satisfiesVersion(process.version, nodeEngine)) {
|
|
5320
5533
|
warnings.push(`Current Node ${process.version} does not satisfy package engine ${nodeEngine}`);
|
|
5321
5534
|
}
|
|
@@ -5325,7 +5538,7 @@ async function scanProject(root) {
|
|
|
5325
5538
|
crossRepoHints.push("Monorepo orchestration detected");
|
|
5326
5539
|
const unsupportedSignals = collectUnsupportedSignals(safeFiles);
|
|
5327
5540
|
const detectionStatus = computeDetectionStatus(roles, stacks, unsupportedSignals);
|
|
5328
|
-
const repoNameFallback = pkg?.name ? String(pkg.name) :
|
|
5541
|
+
const repoNameFallback = pkg?.name ? String(pkg.name) : path37.basename((await resolveRoots(root)).mainRoot);
|
|
5329
5542
|
const context = {
|
|
5330
5543
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5331
5544
|
repoName: repoNameFallback,
|
|
@@ -5347,14 +5560,15 @@ async function scanProject(root) {
|
|
|
5347
5560
|
// src/scanner/read-context.ts
|
|
5348
5561
|
async function isCacheFresh(root, cachePath) {
|
|
5349
5562
|
const markerPaths = [
|
|
5350
|
-
|
|
5351
|
-
|
|
5352
|
-
|
|
5563
|
+
path38.join(root, "package.json"),
|
|
5564
|
+
path38.join(root, "composer.json"),
|
|
5565
|
+
path38.join(root, WORKSPACE_FILE),
|
|
5566
|
+
path38.join(root, REPOS_MANIFEST_FILE)
|
|
5353
5567
|
];
|
|
5354
|
-
const cacheStat = await
|
|
5568
|
+
const cacheStat = await fs29.stat(cachePath);
|
|
5355
5569
|
for (const markerPath of markerPaths) {
|
|
5356
|
-
if (!await
|
|
5357
|
-
const markerStat = await
|
|
5570
|
+
if (!await fs29.pathExists(markerPath)) continue;
|
|
5571
|
+
const markerStat = await fs29.stat(markerPath);
|
|
5358
5572
|
if (markerStat.mtimeMs > cacheStat.mtimeMs) return false;
|
|
5359
5573
|
}
|
|
5360
5574
|
return true;
|
|
@@ -5455,7 +5669,7 @@ async function runDoctor(options) {
|
|
|
5455
5669
|
} else {
|
|
5456
5670
|
ok(`- HOOKS OK: ${hooks.message}`);
|
|
5457
5671
|
}
|
|
5458
|
-
const rootClaudeMdPath =
|
|
5672
|
+
const rootClaudeMdPath = path39.join(root, "CLAUDE.md");
|
|
5459
5673
|
const rootClaudeMdContent = await readText(rootClaudeMdPath);
|
|
5460
5674
|
if (!rootClaudeMdContent) {
|
|
5461
5675
|
flag(
|
|
@@ -5479,13 +5693,31 @@ async function runDoctor(options) {
|
|
|
5479
5693
|
"haus apply --write"
|
|
5480
5694
|
);
|
|
5481
5695
|
} else {
|
|
5482
|
-
ok("- CLAUDE.md: import block present");
|
|
5483
5696
|
const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
|
|
5484
|
-
const
|
|
5485
|
-
|
|
5486
|
-
|
|
5697
|
+
const importLinePattern = /^@(\S+)\s*$/gm;
|
|
5698
|
+
const actualTargets = [...block.matchAll(importLinePattern)].map((m) => m[1]);
|
|
5699
|
+
const expectedTargets = [...IMPORT_CONTENT.matchAll(importLinePattern)].map((m) => m[1]);
|
|
5700
|
+
const missingTargets = expectedTargets.filter((t) => !actualTargets.includes(t));
|
|
5701
|
+
const foreignTargets = actualTargets.filter((t) => !expectedTargets.includes(t));
|
|
5702
|
+
if (missingTargets.length > 0 || foreignTargets.length > 0) {
|
|
5703
|
+
const parts = [];
|
|
5704
|
+
if (missingTargets.length > 0) parts.push(`missing @${missingTargets.join(", @")}`);
|
|
5705
|
+
if (foreignTargets.length > 0) parts.push(`unexpected @${foreignTargets.join(", @")}`);
|
|
5706
|
+
flag(
|
|
5707
|
+
`- CLAUDE.md: haus import block content does not match expected imports (${parts.join("; ")}) (run \`haus apply --write\` to repair)`,
|
|
5708
|
+
"The haus import block in CLAUDE.md has been altered and no longer imports the expected files",
|
|
5709
|
+
"haus apply --write"
|
|
5710
|
+
);
|
|
5711
|
+
} else {
|
|
5712
|
+
ok("- CLAUDE.md: import block present");
|
|
5713
|
+
}
|
|
5714
|
+
for (const target of actualTargets) {
|
|
5715
|
+
const resolved = path39.resolve(root, target);
|
|
5716
|
+
const relFromRoot = path39.relative(root, resolved);
|
|
5717
|
+
const escapesRoot = relFromRoot === ".." || relFromRoot.startsWith(`..${path39.sep}`) || path39.isAbsolute(relFromRoot);
|
|
5718
|
+
if (escapesRoot || !await fs30.pathExists(resolved)) {
|
|
5487
5719
|
flag(
|
|
5488
|
-
`- CLAUDE.md import:
|
|
5720
|
+
`- CLAUDE.md import: @${target} does not resolve (run \`haus apply --write\`)`,
|
|
5489
5721
|
`A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
|
|
5490
5722
|
"haus apply --write"
|
|
5491
5723
|
);
|
|
@@ -5493,8 +5725,15 @@ async function runDoctor(options) {
|
|
|
5493
5725
|
}
|
|
5494
5726
|
}
|
|
5495
5727
|
}
|
|
5728
|
+
if (rootClaudeMdContent && rootClaudeMdContent.includes(WORKSPACE_BLOCK_BEGIN) && !rootClaudeMdContent.includes(BLOCK_BEGIN)) {
|
|
5729
|
+
flag(
|
|
5730
|
+
"- CLAUDE.md: haus-workspace-imports block present but the per-repo haus-imports block is missing (run `haus apply --write`)",
|
|
5731
|
+
"The workspace CLAUDE.md has the workspace import block but not the per-repo one alongside it",
|
|
5732
|
+
"haus apply --write"
|
|
5733
|
+
);
|
|
5734
|
+
}
|
|
5496
5735
|
const workflowPath = hausPath(root, "WORKFLOW.md");
|
|
5497
|
-
const workflowExists = await
|
|
5736
|
+
const workflowExists = await fs30.pathExists(workflowPath);
|
|
5498
5737
|
if (!workflowExists) {
|
|
5499
5738
|
flag(
|
|
5500
5739
|
"- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
|
|
@@ -5509,15 +5748,15 @@ async function runDoctor(options) {
|
|
|
5509
5748
|
} else {
|
|
5510
5749
|
const storedHashMatch = firstLine.match(/hash=(sha256-[a-f0-9]+)/);
|
|
5511
5750
|
const bodyContent = workflowContent.slice(firstLine.length + 1);
|
|
5512
|
-
const cachePath =
|
|
5513
|
-
const bundledPath =
|
|
5751
|
+
const cachePath = path39.join(getCacheDir(), "templates/agentic-workflow-standard.md");
|
|
5752
|
+
const bundledPath = path39.join(
|
|
5514
5753
|
packageRoot(),
|
|
5515
5754
|
"library",
|
|
5516
5755
|
"global",
|
|
5517
5756
|
"templates",
|
|
5518
5757
|
"agentic-workflow-standard.md"
|
|
5519
5758
|
);
|
|
5520
|
-
const templatePath = await
|
|
5759
|
+
const templatePath = await fs30.pathExists(cachePath) ? cachePath : bundledPath;
|
|
5521
5760
|
const templateContent = await readText(templatePath);
|
|
5522
5761
|
const verdict = checkManagedTamper(bodyContent, storedHashMatch?.[1], templateContent ?? null);
|
|
5523
5762
|
if (verdict.status === "tampered" || verdict.status === "legacy-diverged") {
|
|
@@ -5538,7 +5777,7 @@ async function runDoctor(options) {
|
|
|
5538
5777
|
}
|
|
5539
5778
|
}
|
|
5540
5779
|
const workflowConfigPath = hausPath(root, "workflow-config.md");
|
|
5541
|
-
const workflowConfigExists = await
|
|
5780
|
+
const workflowConfigExists = await fs30.pathExists(workflowConfigPath);
|
|
5542
5781
|
if (!workflowConfigExists) {
|
|
5543
5782
|
flag(
|
|
5544
5783
|
"- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
|
|
@@ -5546,7 +5785,7 @@ async function runDoctor(options) {
|
|
|
5546
5785
|
"haus apply --write"
|
|
5547
5786
|
);
|
|
5548
5787
|
} else {
|
|
5549
|
-
const cfg = await
|
|
5788
|
+
const cfg = await fs30.readFile(workflowConfigPath, "utf8");
|
|
5550
5789
|
const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
|
|
5551
5790
|
if (unfilled > 0) {
|
|
5552
5791
|
suggest(
|
|
@@ -5559,7 +5798,7 @@ async function runDoctor(options) {
|
|
|
5559
5798
|
}
|
|
5560
5799
|
}
|
|
5561
5800
|
if (workflowExists) {
|
|
5562
|
-
const prettierIgnore = await readText(
|
|
5801
|
+
const prettierIgnore = await readText(path39.join(root, ".prettierignore")) ?? "";
|
|
5563
5802
|
const lines = prettierIgnore.split("\n");
|
|
5564
5803
|
const missing = IGNORED_PATHS.filter((p) => !prettierIgnoreProtects(lines, p));
|
|
5565
5804
|
if (missing.length > 0) {
|
|
@@ -5599,6 +5838,29 @@ async function runDoctor(options) {
|
|
|
5599
5838
|
if (finding.level === "ok") ok(finding.line);
|
|
5600
5839
|
else suggest(finding.line, finding.sentence, finding.fix);
|
|
5601
5840
|
}
|
|
5841
|
+
const skillRefIssues = await findUnresolvedSkillReferences(root);
|
|
5842
|
+
if (skillRefIssues.length > 0) {
|
|
5843
|
+
const prefixBug = skillRefIssues.filter((i) => i.bareInstalled);
|
|
5844
|
+
const missingDep = skillRefIssues.filter((i) => !i.bareInstalled);
|
|
5845
|
+
const describe = (i) => `${i.file}:${i.line} "${i.reference}"`;
|
|
5846
|
+
if (prefixBug.length > 0) {
|
|
5847
|
+
flag(
|
|
5848
|
+
`- SKILL REFS: ${prefixBug.length} reference(s) use an unresolvable plugin-namespace prefix, though the target skill IS installed (${prefixBug.slice(0, 3).map(describe).join("; ")}${prefixBug.length > 3 ? "; \u2026" : ""})`,
|
|
5849
|
+
`${prefixBug.length} skill reference(s) use a prefix that can't resolve, even though the target skill is installed`,
|
|
5850
|
+
"haus apply --write (re-vendors the skill body with the prefix stripped)"
|
|
5851
|
+
);
|
|
5852
|
+
}
|
|
5853
|
+
if (missingDep.length > 0) {
|
|
5854
|
+
const missingNames = [...new Set(missingDep.map((i) => i.bareName))];
|
|
5855
|
+
flag(
|
|
5856
|
+
`- SKILL REFS: ${missingDep.length} reference(s) point at a skill that is not installed (${missingNames.join(", ")}) (${missingDep.slice(0, 3).map(describe).join("; ")}${missingDep.length > 3 ? "; \u2026" : ""})`,
|
|
5857
|
+
`${missingDep.length} skill reference(s) point at a sub-skill (${missingNames.join(", ")}) that isn't installed`,
|
|
5858
|
+
"find its catalog id and run `haus recommend --include <id>` then `haus apply --write`, or remove the reference"
|
|
5859
|
+
);
|
|
5860
|
+
}
|
|
5861
|
+
} else {
|
|
5862
|
+
ok("- SKILL REFS: OK (no unresolved cross-skill references)");
|
|
5863
|
+
}
|
|
5602
5864
|
const cacheAgeMs = await getCacheManifestAge();
|
|
5603
5865
|
if (cacheAgeMs === null) {
|
|
5604
5866
|
suggest(
|
|
@@ -5619,20 +5881,28 @@ async function runDoctor(options) {
|
|
|
5619
5881
|
}
|
|
5620
5882
|
}
|
|
5621
5883
|
if (recommendation) {
|
|
5622
|
-
|
|
5623
|
-
const recommendedIds = new Set(
|
|
5624
|
-
recommendation.recommended.map((r) => r.id).filter((id) => typeof id === "string")
|
|
5625
|
-
);
|
|
5626
|
-
const orphaned = findOrphanedLockEntries(lock ?? [], recommendedIds).map((entry) => entry.id);
|
|
5627
|
-
if (orphaned.length > 0) {
|
|
5884
|
+
if (!Array.isArray(recommendation.recommended)) {
|
|
5628
5885
|
suggest(
|
|
5629
|
-
`- CATALOG ITEMS:
|
|
5630
|
-
|
|
5631
|
-
"
|
|
5886
|
+
`- CATALOG ITEMS: recommendation.json's "recommended" field is not an array \u2014 skipping orphaned-item check`,
|
|
5887
|
+
'recommendation.json looks malformed (its "recommended" field is not an array)',
|
|
5888
|
+
"run `haus recommend` to regenerate it"
|
|
5889
|
+
);
|
|
5890
|
+
} else {
|
|
5891
|
+
const lock = await readJson(hausPath(root, "haus.lock.json"));
|
|
5892
|
+
const recommendedIds = new Set(
|
|
5893
|
+
recommendation.recommended.map((r) => r.id).filter((id) => typeof id === "string")
|
|
5632
5894
|
);
|
|
5895
|
+
const orphaned = findOrphanedLockEntries(lock ?? [], recommendedIds).map((entry) => entry.id);
|
|
5896
|
+
if (orphaned.length > 0) {
|
|
5897
|
+
suggest(
|
|
5898
|
+
`- CATALOG ITEMS: ${orphaned.length} installed item(s) no longer in the current recommendation (${orphaned.join(", ")})`,
|
|
5899
|
+
`${orphaned.length} installed item(s) are no longer recommended for this project`,
|
|
5900
|
+
"review whether they are still needed; haus apply --write leaves them in place until removed from the catalog"
|
|
5901
|
+
);
|
|
5902
|
+
}
|
|
5633
5903
|
}
|
|
5634
5904
|
}
|
|
5635
|
-
const pkgJson = await readJson(
|
|
5905
|
+
const pkgJson = await readJson(path39.join(packageRoot(), "package.json"));
|
|
5636
5906
|
const currentVersion = pkgJson?.version ?? "0.0.0";
|
|
5637
5907
|
const npmStatus = await fetchNpmVersionStatus(currentVersion);
|
|
5638
5908
|
if (npmStatus.updateAvailable && npmStatus.latest !== null) {
|
|
@@ -5665,12 +5935,12 @@ async function runDoctor(options) {
|
|
|
5665
5935
|
}
|
|
5666
5936
|
|
|
5667
5937
|
// src/commands/update.ts
|
|
5668
|
-
import
|
|
5938
|
+
import path42 from "node:path";
|
|
5669
5939
|
|
|
5670
5940
|
// src/claude/refresh-project.ts
|
|
5671
|
-
import
|
|
5941
|
+
import fs31 from "fs-extra";
|
|
5672
5942
|
async function isHausProject(root) {
|
|
5673
|
-
if (await
|
|
5943
|
+
if (await fs31.pathExists(hausPath(root, "recommendation.json"))) return true;
|
|
5674
5944
|
const result = await readJsonDetailed(claudePath(root, "settings.json"));
|
|
5675
5945
|
if (result.status === "ok") return result.value._haus != null;
|
|
5676
5946
|
return false;
|
|
@@ -5682,8 +5952,8 @@ async function refreshProjectApply(root) {
|
|
|
5682
5952
|
|
|
5683
5953
|
// src/install/apply.ts
|
|
5684
5954
|
import crypto2 from "node:crypto";
|
|
5685
|
-
import
|
|
5686
|
-
import
|
|
5955
|
+
import path40 from "node:path";
|
|
5956
|
+
import fs32 from "fs-extra";
|
|
5687
5957
|
|
|
5688
5958
|
// src/install/header.ts
|
|
5689
5959
|
var MD_PREFIX = "<!-- HAUS-MANAGED";
|
|
@@ -5767,59 +6037,66 @@ var SCHEMA_VERSION2 = "1";
|
|
|
5767
6037
|
function hashContent(content2) {
|
|
5768
6038
|
return `sha256-${crypto2.createHash("sha256").update(normaliseLF(content2)).digest("hex")}`;
|
|
5769
6039
|
}
|
|
6040
|
+
function destFileMissing(destPath) {
|
|
6041
|
+
try {
|
|
6042
|
+
return !fs32.statSync(destPath).isFile();
|
|
6043
|
+
} catch {
|
|
6044
|
+
return true;
|
|
6045
|
+
}
|
|
6046
|
+
}
|
|
5770
6047
|
function sourceVersion() {
|
|
5771
6048
|
try {
|
|
5772
|
-
const pkgPath =
|
|
5773
|
-
const pkg = JSON.parse(
|
|
6049
|
+
const pkgPath = path40.join(packageRoot(), "package.json");
|
|
6050
|
+
const pkg = JSON.parse(fs32.readFileSync(pkgPath, "utf8"));
|
|
5774
6051
|
return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
|
|
5775
6052
|
} catch {
|
|
5776
6053
|
return "haus@0.0.0";
|
|
5777
6054
|
}
|
|
5778
6055
|
}
|
|
5779
6056
|
function globalSrcDir() {
|
|
5780
|
-
return
|
|
6057
|
+
return path40.join(packageRoot(), "library", "global");
|
|
5781
6058
|
}
|
|
5782
6059
|
function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
|
|
5783
|
-
const skillDir =
|
|
6060
|
+
const skillDir = path40.join(skillsDir, skillName);
|
|
5784
6061
|
const entries = [];
|
|
5785
|
-
for (const rel of
|
|
6062
|
+
for (const rel of fs32.readdirSync(skillDir, { recursive: true })) {
|
|
5786
6063
|
if (rel === "SKILL.md") continue;
|
|
5787
|
-
const abs =
|
|
5788
|
-
if (!
|
|
5789
|
-
const posixRel = rel.split(
|
|
6064
|
+
const abs = path40.join(skillDir, rel);
|
|
6065
|
+
if (!fs32.statSync(abs).isFile()) continue;
|
|
6066
|
+
const posixRel = rel.split(path40.sep).join("/");
|
|
5790
6067
|
entries.push({
|
|
5791
6068
|
stableId: `skill.${skillName}.${posixRel.replace(/\//g, ".")}`,
|
|
5792
|
-
srcRelPath:
|
|
5793
|
-
destPath:
|
|
6069
|
+
srcRelPath: path40.join("library", "global", "skills", skillName, rel),
|
|
6070
|
+
destPath: path40.join(claudeDir, "skills", skillName, rel)
|
|
5794
6071
|
});
|
|
5795
6072
|
}
|
|
5796
6073
|
return entries;
|
|
5797
6074
|
}
|
|
5798
6075
|
function collectSourceFiles(srcDir, claudeDir) {
|
|
5799
6076
|
const entries = [];
|
|
5800
|
-
const skillsDir =
|
|
5801
|
-
if (
|
|
5802
|
-
for (const skillName of
|
|
5803
|
-
const skillFile =
|
|
5804
|
-
if (
|
|
6077
|
+
const skillsDir = path40.join(srcDir, "skills");
|
|
6078
|
+
if (fs32.pathExistsSync(skillsDir)) {
|
|
6079
|
+
for (const skillName of fs32.readdirSync(skillsDir)) {
|
|
6080
|
+
const skillFile = path40.join(skillsDir, skillName, "SKILL.md");
|
|
6081
|
+
if (fs32.pathExistsSync(skillFile)) {
|
|
5805
6082
|
entries.push({
|
|
5806
6083
|
stableId: `skill.${skillName}`,
|
|
5807
|
-
srcRelPath:
|
|
5808
|
-
destPath:
|
|
6084
|
+
srcRelPath: path40.join("library", "global", "skills", skillName, "SKILL.md"),
|
|
6085
|
+
destPath: path40.join(claudeDir, "skills", skillName, "SKILL.md")
|
|
5809
6086
|
});
|
|
5810
6087
|
entries.push(...collectSkillAuxFiles(skillsDir, claudeDir, skillName));
|
|
5811
6088
|
}
|
|
5812
6089
|
}
|
|
5813
6090
|
}
|
|
5814
|
-
const commandsDir =
|
|
5815
|
-
if (
|
|
5816
|
-
for (const fileName of
|
|
6091
|
+
const commandsDir = path40.join(srcDir, "commands");
|
|
6092
|
+
if (fs32.pathExistsSync(commandsDir)) {
|
|
6093
|
+
for (const fileName of fs32.readdirSync(commandsDir)) {
|
|
5817
6094
|
if (!fileName.endsWith(".md")) continue;
|
|
5818
6095
|
const commandName = fileName.slice(0, -".md".length);
|
|
5819
6096
|
entries.push({
|
|
5820
6097
|
stableId: `command.${commandName}`,
|
|
5821
|
-
srcRelPath:
|
|
5822
|
-
destPath:
|
|
6098
|
+
srcRelPath: path40.join("library", "global", "commands", fileName),
|
|
6099
|
+
destPath: path40.join(claudeDir, "commands", fileName)
|
|
5823
6100
|
});
|
|
5824
6101
|
}
|
|
5825
6102
|
}
|
|
@@ -5845,7 +6122,7 @@ async function applyInstall(options = {}) {
|
|
|
5845
6122
|
};
|
|
5846
6123
|
const manifestFiles = [];
|
|
5847
6124
|
for (const entry of sourceFiles) {
|
|
5848
|
-
const srcPath =
|
|
6125
|
+
const srcPath = path40.join(packageRoot(), entry.srcRelPath);
|
|
5849
6126
|
const rawContent = await readText(srcPath);
|
|
5850
6127
|
if (rawContent === void 0) {
|
|
5851
6128
|
warn(`Source file not found: ${entry.srcRelPath}`);
|
|
@@ -5859,13 +6136,14 @@ async function applyInstall(options = {}) {
|
|
|
5859
6136
|
const newHash = hashContent(stamped);
|
|
5860
6137
|
const existing = manifestByDest.get(entry.destPath);
|
|
5861
6138
|
if (check) {
|
|
5862
|
-
|
|
6139
|
+
const destMissing = destFileMissing(entry.destPath);
|
|
6140
|
+
if (destMissing || existing && existing.hash !== newHash) {
|
|
5863
6141
|
result.drift = true;
|
|
5864
6142
|
result.skipped.push(entry.destPath);
|
|
5865
6143
|
}
|
|
5866
6144
|
continue;
|
|
5867
6145
|
}
|
|
5868
|
-
const destExists =
|
|
6146
|
+
const destExists = fs32.pathExistsSync(entry.destPath);
|
|
5869
6147
|
if (destExists) {
|
|
5870
6148
|
const currentContent = await readText(entry.destPath);
|
|
5871
6149
|
if (currentContent !== void 0) {
|
|
@@ -5905,7 +6183,7 @@ async function applyInstall(options = {}) {
|
|
|
5905
6183
|
}
|
|
5906
6184
|
let mergedSettings;
|
|
5907
6185
|
if (!check) {
|
|
5908
|
-
const fragmentPath =
|
|
6186
|
+
const fragmentPath = path40.join(srcDir, "settings-fragments", "hooks.json");
|
|
5909
6187
|
const fragments = await loadHooksFragment(fragmentPath);
|
|
5910
6188
|
const settings = await readSettings();
|
|
5911
6189
|
const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
|
|
@@ -5919,13 +6197,13 @@ async function applyInstall(options = {}) {
|
|
|
5919
6197
|
const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
|
|
5920
6198
|
for (const entry of existingManifest.files) {
|
|
5921
6199
|
if (currentDestPaths.has(entry.destPath)) continue;
|
|
5922
|
-
if (!
|
|
6200
|
+
if (!fs32.pathExistsSync(entry.destPath)) continue;
|
|
5923
6201
|
const content2 = await readText(entry.destPath);
|
|
5924
6202
|
if (!content2) continue;
|
|
5925
6203
|
const hasHeader = parseMarkdownHeader(content2) !== void 0;
|
|
5926
6204
|
const currentHash = hashContent(content2);
|
|
5927
6205
|
if (hasHeader && currentHash === entry.hash) {
|
|
5928
|
-
if (!dryRun) await
|
|
6206
|
+
if (!dryRun) await fs32.remove(entry.destPath);
|
|
5929
6207
|
result.deleted.push(entry.destPath);
|
|
5930
6208
|
} else {
|
|
5931
6209
|
warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
|
|
@@ -5994,7 +6272,7 @@ function summarizeLockDiff(before, after) {
|
|
|
5994
6272
|
|
|
5995
6273
|
// src/update/lockfile.ts
|
|
5996
6274
|
import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
|
|
5997
|
-
import
|
|
6275
|
+
import path41 from "node:path";
|
|
5998
6276
|
async function readLockSummary(root) {
|
|
5999
6277
|
const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
|
|
6000
6278
|
const catalogRef = lock.find((item) => item.catalogRef)?.catalogRef ?? null;
|
|
@@ -6018,19 +6296,21 @@ async function checkLock(root) {
|
|
|
6018
6296
|
const ok = lock.length > 0 && hasValidVersions && drift.length === 0;
|
|
6019
6297
|
return { ok, count: lock.length, catalogRef, drift, driftCount: drift.length };
|
|
6020
6298
|
}
|
|
6021
|
-
async function
|
|
6022
|
-
const lockPath = hausPath(root, "haus.lock.json");
|
|
6023
|
-
let before = "[]";
|
|
6299
|
+
async function readLockRaw(root) {
|
|
6024
6300
|
try {
|
|
6025
|
-
|
|
6301
|
+
return await readFile2(hausPath(root, "haus.lock.json"), "utf8");
|
|
6026
6302
|
} catch {
|
|
6027
|
-
|
|
6303
|
+
return "[]";
|
|
6028
6304
|
}
|
|
6305
|
+
}
|
|
6306
|
+
async function applyLock(root) {
|
|
6307
|
+
const lockPath = hausPath(root, "haus.lock.json");
|
|
6308
|
+
const before = await readLockRaw(root);
|
|
6029
6309
|
const lock = await readJson(lockPath) ?? [];
|
|
6030
6310
|
try {
|
|
6031
6311
|
const backupDir = hausPath(root, "backups");
|
|
6032
6312
|
await mkdir(backupDir, { recursive: true });
|
|
6033
|
-
await copyFile(lockPath,
|
|
6313
|
+
await copyFile(lockPath, path41.join(backupDir, `haus.lock.${Date.now()}.json`));
|
|
6034
6314
|
} catch {
|
|
6035
6315
|
}
|
|
6036
6316
|
const enriched = await Promise.all(
|
|
@@ -6052,7 +6332,7 @@ function diffLock(before, after) {
|
|
|
6052
6332
|
}
|
|
6053
6333
|
async function hasLocalOverrides(root) {
|
|
6054
6334
|
try {
|
|
6055
|
-
await readFile2(
|
|
6335
|
+
await readFile2(path41.join(root, ".claude", "settings.json"), "utf8");
|
|
6056
6336
|
return true;
|
|
6057
6337
|
} catch {
|
|
6058
6338
|
return false;
|
|
@@ -6068,7 +6348,7 @@ async function runUpdate(options) {
|
|
|
6068
6348
|
return;
|
|
6069
6349
|
}
|
|
6070
6350
|
if (options.check) {
|
|
6071
|
-
const pkgJson2 = await readJson(
|
|
6351
|
+
const pkgJson2 = await readJson(path42.join(packageRoot(), "package.json"));
|
|
6072
6352
|
const currentVersion2 = pkgJson2?.version ?? "0.0.0";
|
|
6073
6353
|
const [status, npmVersion, latestCatalogTag, globalInstallDrift, catalogItems, lockItems] = await Promise.all([
|
|
6074
6354
|
options.fast ? fastLockStatus(root) : checkLock(root),
|
|
@@ -6100,10 +6380,12 @@ async function runUpdate(options) {
|
|
|
6100
6380
|
)
|
|
6101
6381
|
);
|
|
6102
6382
|
const hasHashDrift = !options.fast && status.count > 0 && "ok" in status && !status.ok;
|
|
6103
|
-
if (hasHashDrift || formerIdMigrations.length > 0
|
|
6383
|
+
if (hasHashDrift || formerIdMigrations.length > 0 || globalInstallDrift === true) {
|
|
6384
|
+
process.exitCode = 1;
|
|
6385
|
+
}
|
|
6104
6386
|
return;
|
|
6105
6387
|
}
|
|
6106
|
-
const pkgJson = await readJson(
|
|
6388
|
+
const pkgJson = await readJson(path42.join(packageRoot(), "package.json"));
|
|
6107
6389
|
const currentVersion = pkgJson?.version ?? "0.0.0";
|
|
6108
6390
|
const npmStatus = await fetchNpmVersionStatus(currentVersion);
|
|
6109
6391
|
if (npmStatus.updateAvailable && npmStatus.latest !== null) {
|
|
@@ -6115,9 +6397,7 @@ async function runUpdate(options) {
|
|
|
6115
6397
|
if (await hasLocalOverrides(root)) {
|
|
6116
6398
|
log("Existing .claude/settings.json \u2014 haus rules will be merged, not replaced.");
|
|
6117
6399
|
}
|
|
6118
|
-
const
|
|
6119
|
-
log(diffLock(before, after));
|
|
6120
|
-
log(summarizeLockDiff(before, after));
|
|
6400
|
+
const lockBefore = await readLockRaw(root);
|
|
6121
6401
|
log("Syncing remote catalog...");
|
|
6122
6402
|
const sync = await syncRemoteCatalog();
|
|
6123
6403
|
if (sync.newItems.length > 0) {
|
|
@@ -6139,13 +6419,16 @@ async function runUpdate(options) {
|
|
|
6139
6419
|
}
|
|
6140
6420
|
await refreshGlobalInstall();
|
|
6141
6421
|
await refreshProjectFiles(root);
|
|
6422
|
+
const { after } = await applyLock(root);
|
|
6423
|
+
log(diffLock(lockBefore, after));
|
|
6424
|
+
log(summarizeLockDiff(lockBefore, after));
|
|
6142
6425
|
log("Update applied with backup in .haus-workflow/backups/. Run haus doctor.");
|
|
6143
6426
|
}
|
|
6144
6427
|
async function runFromHookCheck(root) {
|
|
6145
6428
|
try {
|
|
6146
6429
|
const summary = await readLockSummary(root);
|
|
6147
6430
|
if (summary.count === 0) return;
|
|
6148
|
-
const pkgJson = await readJson(
|
|
6431
|
+
const pkgJson = await readJson(path42.join(packageRoot(), "package.json"));
|
|
6149
6432
|
const currentVersion = pkgJson?.version ?? "0.0.0";
|
|
6150
6433
|
const [npmVersion, latestCatalogTag] = await Promise.all([
|
|
6151
6434
|
fetchNpmVersionStatus(currentVersion),
|
|
@@ -6295,7 +6578,8 @@ async function runCiGate(options = {}) {
|
|
|
6295
6578
|
// src/commands/clone.ts
|
|
6296
6579
|
init_exec();
|
|
6297
6580
|
import { existsSync as existsSync2 } from "node:fs";
|
|
6298
|
-
import
|
|
6581
|
+
import { readdir } from "node:fs/promises";
|
|
6582
|
+
import path43 from "node:path";
|
|
6299
6583
|
var GIT_LOCATION_VARS = [
|
|
6300
6584
|
"GIT_DIR",
|
|
6301
6585
|
"GIT_WORK_TREE",
|
|
@@ -6324,6 +6608,16 @@ function normalizeGitUrl(url) {
|
|
|
6324
6608
|
s = s.replace(/\.git$/i, "");
|
|
6325
6609
|
return s.toLowerCase();
|
|
6326
6610
|
}
|
|
6611
|
+
async function isEmptyDirectory(dir) {
|
|
6612
|
+
try {
|
|
6613
|
+
const entries = await readdir(dir);
|
|
6614
|
+
return entries.length === 0;
|
|
6615
|
+
} catch (err) {
|
|
6616
|
+
const code = err.code;
|
|
6617
|
+
if (code === "ENOTDIR" || code === "ENOENT") return false;
|
|
6618
|
+
throw err;
|
|
6619
|
+
}
|
|
6620
|
+
}
|
|
6327
6621
|
async function checkExistingTarget(target, url) {
|
|
6328
6622
|
const isRepo = await runGit(["-C", target, "rev-parse", "--is-inside-work-tree"], {
|
|
6329
6623
|
env: cloneEnv(),
|
|
@@ -6345,11 +6639,11 @@ async function runClone(url, opts = {}) {
|
|
|
6345
6639
|
process.exitCode = 1;
|
|
6346
6640
|
return;
|
|
6347
6641
|
}
|
|
6348
|
-
const target =
|
|
6349
|
-
if (existsSync2(target)) {
|
|
6642
|
+
const target = path43.resolve(opts.dir?.trim() || repoNameFromUrl(url));
|
|
6643
|
+
if (existsSync2(target) && !await isEmptyDirectory(target)) {
|
|
6350
6644
|
const check = await checkExistingTarget(target, url);
|
|
6351
6645
|
if (check.kind === "matches") {
|
|
6352
|
-
log(`\u2022 ${
|
|
6646
|
+
log(`\u2022 ${path43.basename(target)} already cloned here, matches ${url} \u2014 skipped`);
|
|
6353
6647
|
return;
|
|
6354
6648
|
}
|
|
6355
6649
|
const message = check.kind === "not-a-repo" ? `${target} already exists and is not a git repository \u2014 refusing to clone ${url} there. Remove or rename it, or pass a different [dir].` : `${target} already exists as a different repository (origin: ${check.origin ?? "(no origin remote)"}) \u2014 refusing to clone ${url} there. Remove or rename it, or pass a different [dir].`;
|
|
@@ -6389,7 +6683,7 @@ function readHookPayload() {
|
|
|
6389
6683
|
if (!raw) return { payload: {}, malformed: false };
|
|
6390
6684
|
try {
|
|
6391
6685
|
const parsed = JSON.parse(raw);
|
|
6392
|
-
return { payload:
|
|
6686
|
+
return { payload: isRecord(parsed) ? parsed : {}, malformed: false };
|
|
6393
6687
|
} catch {
|
|
6394
6688
|
return { payload: {}, malformed: true };
|
|
6395
6689
|
}
|
|
@@ -6428,7 +6722,7 @@ function readPayload(options) {
|
|
|
6428
6722
|
if (options.stdinPayload != null) {
|
|
6429
6723
|
try {
|
|
6430
6724
|
const parsed = JSON.parse(options.stdinPayload);
|
|
6431
|
-
return { payload:
|
|
6725
|
+
return { payload: isRecord(parsed) ? parsed : {}, malformed: false };
|
|
6432
6726
|
} catch {
|
|
6433
6727
|
return { payload: {}, malformed: true };
|
|
6434
6728
|
}
|
|
@@ -6441,15 +6735,14 @@ async function runDecisionsGuard(options = {}) {
|
|
|
6441
6735
|
emitPreToolUseDeny("Malformed hook payload");
|
|
6442
6736
|
return;
|
|
6443
6737
|
}
|
|
6444
|
-
const toolInput =
|
|
6738
|
+
const toolInput = isRecord(payload.tool_input) ? payload.tool_input : {};
|
|
6445
6739
|
const command = String(toolInput.command ?? "");
|
|
6446
6740
|
if (!PR_CREATE_PATTERN.test(command)) return;
|
|
6447
6741
|
const root = process.cwd();
|
|
6448
6742
|
const baseRef = await resolveBaseRef(root);
|
|
6449
6743
|
if (!baseRef) return;
|
|
6450
6744
|
const range = `${baseRef}...HEAD`;
|
|
6451
|
-
const
|
|
6452
|
-
const commitMessages = (await runGit(["log", "--format=%B", logRange], { cwd: root })).stdout;
|
|
6745
|
+
const commitMessages = (await runGit(["log", "--format=%B", toAncestryLogRange(range)], { cwd: root })).stdout;
|
|
6453
6746
|
const result = await runDecisionsCheck(root, { range, commitMessages });
|
|
6454
6747
|
if (result.triggered && !result.satisfied) {
|
|
6455
6748
|
emitPreToolUseDeny(
|
|
@@ -6459,7 +6752,7 @@ async function runDecisionsGuard(options = {}) {
|
|
|
6459
6752
|
}
|
|
6460
6753
|
|
|
6461
6754
|
// src/commands/decisions.ts
|
|
6462
|
-
import
|
|
6755
|
+
import path44 from "node:path";
|
|
6463
6756
|
|
|
6464
6757
|
// src/decisions/next-number.ts
|
|
6465
6758
|
async function nextDecisionNumber(root) {
|
|
@@ -6534,7 +6827,7 @@ async function runDecisions(sub, arg, options = {}) {
|
|
|
6534
6827
|
const root = process.cwd();
|
|
6535
6828
|
if (sub === "check") {
|
|
6536
6829
|
const commitMessages = options.range != null ? (await Promise.resolve().then(() => (init_exec(), exec_exports)).then(
|
|
6537
|
-
(m) => m.runGit(["log", "--format=%B", options.range], { cwd: root })
|
|
6830
|
+
(m) => m.runGit(["log", "--format=%B", toAncestryLogRange(options.range)], { cwd: root })
|
|
6538
6831
|
)).stdout : "";
|
|
6539
6832
|
const result = await runDecisionsCheck(root, {
|
|
6540
6833
|
staged: options.staged,
|
|
@@ -6569,7 +6862,7 @@ async function runDecisions(sub, arg, options = {}) {
|
|
|
6569
6862
|
return;
|
|
6570
6863
|
}
|
|
6571
6864
|
if (sub === "validate") {
|
|
6572
|
-
const target = arg ?
|
|
6865
|
+
const target = arg ? path44.resolve(root, arg) : void 0;
|
|
6573
6866
|
if (!target) {
|
|
6574
6867
|
error("usage: haus decisions validate <path-to-decision.md>");
|
|
6575
6868
|
process.exitCode = 1;
|
|
@@ -6821,7 +7114,7 @@ async function runGuard(kind, _options) {
|
|
|
6821
7114
|
emitPreToolUseDeny("Malformed hook payload");
|
|
6822
7115
|
return;
|
|
6823
7116
|
}
|
|
6824
|
-
const toolInput =
|
|
7117
|
+
const toolInput = isRecord(payload.tool_input) ? payload.tool_input : {};
|
|
6825
7118
|
if (kind === "file-access") {
|
|
6826
7119
|
const candidate = String(toolInput.path ?? toolInput.file_path ?? "");
|
|
6827
7120
|
const reason2 = guardFileAccess(candidate);
|
|
@@ -6838,8 +7131,8 @@ async function runGuard(kind, _options) {
|
|
|
6838
7131
|
}
|
|
6839
7132
|
|
|
6840
7133
|
// src/commands/init.ts
|
|
6841
|
-
import
|
|
6842
|
-
import
|
|
7134
|
+
import path46 from "node:path";
|
|
7135
|
+
import fs33 from "fs-extra";
|
|
6843
7136
|
|
|
6844
7137
|
// src/recommender/git-signal.ts
|
|
6845
7138
|
init_exec();
|
|
@@ -6862,55 +7155,6 @@ async function readChangedFiles(root) {
|
|
|
6862
7155
|
return [.../* @__PURE__ */ new Set([...unstaged, ...staged, ...untracked])].sort();
|
|
6863
7156
|
}
|
|
6864
7157
|
|
|
6865
|
-
// src/recommender/policies.ts
|
|
6866
|
-
var UNSUPPORTED = FORBIDDEN_TAGS;
|
|
6867
|
-
function matchRequiresAny(clauses, ctx) {
|
|
6868
|
-
for (const clause of clauses) {
|
|
6869
|
-
if ("stack" in clause) {
|
|
6870
|
-
if (ctx.stackSet.has(clause.stack.toLowerCase())) {
|
|
6871
|
-
return { matched: true, signal: `stack:${clause.stack}` };
|
|
6872
|
-
}
|
|
6873
|
-
} else if ("dependency" in clause) {
|
|
6874
|
-
if (ctx.depSet.has(clause.dependency.toLowerCase())) {
|
|
6875
|
-
return { matched: true, signal: `dependency:${clause.dependency}` };
|
|
6876
|
-
}
|
|
6877
|
-
} else if ("packageNamePattern" in clause) {
|
|
6878
|
-
const pattern = clause.packageNamePattern.toLowerCase();
|
|
6879
|
-
const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern;
|
|
6880
|
-
for (const dep2 of ctx.depSet) {
|
|
6881
|
-
if (pattern.endsWith("*") ? dep2.startsWith(prefix) : dep2 === pattern) {
|
|
6882
|
-
return {
|
|
6883
|
-
matched: true,
|
|
6884
|
-
signal: `packageNamePattern:${clause.packageNamePattern}`
|
|
6885
|
-
};
|
|
6886
|
-
}
|
|
6887
|
-
}
|
|
6888
|
-
} else if ("role" in clause) {
|
|
6889
|
-
if (ctx.roleSet.has(clause.role.toLowerCase())) {
|
|
6890
|
-
return { matched: true, signal: `role:${clause.role}` };
|
|
6891
|
-
}
|
|
6892
|
-
}
|
|
6893
|
-
}
|
|
6894
|
-
return { matched: false };
|
|
6895
|
-
}
|
|
6896
|
-
function describeRequiresAny(clauses) {
|
|
6897
|
-
return clauses.map((c) => {
|
|
6898
|
-
if ("stack" in c) return `stack=${c.stack}`;
|
|
6899
|
-
if ("dependency" in c) return `dependency=${c.dependency}`;
|
|
6900
|
-
if ("packageNamePattern" in c) return `packageNamePattern=${c.packageNamePattern}`;
|
|
6901
|
-
if ("role" in c) return `role=${c.role}`;
|
|
6902
|
-
return "unknown";
|
|
6903
|
-
}).join(" | ");
|
|
6904
|
-
}
|
|
6905
|
-
function mergeRecommendationWarnings(context, isLinkedWorktree = false) {
|
|
6906
|
-
const markers = context.unsupportedSignals?.join(", ");
|
|
6907
|
-
const statusLines = context.detectionStatus === "unknown" ? [describeUnknownDetection(context.unsupportedSignals ?? [], isLinkedWorktree)] : context.detectionStatus === "partial" && markers ? [
|
|
6908
|
-
`Partially supported \u2014 found unsupported ${markers} alongside recognised stacks; guidance covers the supported parts only.`
|
|
6909
|
-
] : [];
|
|
6910
|
-
const riskLines = (context.securityRisks?.length ?? 0) > 0 ? [`Scan reported security signals: ${context.securityRisks.join("; ")}`] : [];
|
|
6911
|
-
return [.../* @__PURE__ */ new Set([...statusLines, ...context.warnings, ...riskLines])];
|
|
6912
|
-
}
|
|
6913
|
-
|
|
6914
7158
|
// src/recommender/recommend.ts
|
|
6915
7159
|
async function recommend(root, context, opts = {}) {
|
|
6916
7160
|
const items = await loadCatalog(root);
|
|
@@ -7227,6 +7471,7 @@ function applyCoInstallSuppression(recommended, skipped) {
|
|
|
7227
7471
|
}
|
|
7228
7472
|
var HARD_SKIP_CODES = /* @__PURE__ */ new Set([
|
|
7229
7473
|
"former-id",
|
|
7474
|
+
"invalid-source",
|
|
7230
7475
|
"unsupported-policy",
|
|
7231
7476
|
"deprecated",
|
|
7232
7477
|
"curated-not-approved",
|
|
@@ -7249,8 +7494,10 @@ function applyManualIncludes(include, items, recommended, skipped) {
|
|
|
7249
7494
|
if (recommendedIds.has(id)) continue;
|
|
7250
7495
|
const skipIdx = skipped.findIndex((s) => s.id === id);
|
|
7251
7496
|
const skipCode = skipIdx >= 0 ? skipped[skipIdx].skipReasons[0]?.code : void 0;
|
|
7252
|
-
|
|
7253
|
-
|
|
7497
|
+
const failingGateNames = skipIdx >= 0 ? (skipped[skipIdx].gates ?? []).filter((g) => !g.passed).map((g) => g.name) : [];
|
|
7498
|
+
const hardBlock = failingGateNames.find((name) => HARD_SKIP_CODES.has(name));
|
|
7499
|
+
if (hardBlock) {
|
|
7500
|
+
warnings.push(`--include: "${id}" cannot be force-installed (blocked by ${hardBlock})`);
|
|
7254
7501
|
continue;
|
|
7255
7502
|
}
|
|
7256
7503
|
if (skipCode === "requires-any-unsatisfied") {
|
|
@@ -7306,8 +7553,8 @@ function buildOptInEligible(items, skipped) {
|
|
|
7306
7553
|
}
|
|
7307
7554
|
|
|
7308
7555
|
// src/commands/workspace/discover.ts
|
|
7309
|
-
import
|
|
7310
|
-
import
|
|
7556
|
+
import path45 from "node:path";
|
|
7557
|
+
import fg5 from "fast-glob";
|
|
7311
7558
|
import YAML2 from "yaml";
|
|
7312
7559
|
var DEFAULT_MAX_DEPTH = 3;
|
|
7313
7560
|
var GIT_MARKER = ["**/.git"];
|
|
@@ -7341,16 +7588,16 @@ async function findRepoRoots(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
|
|
|
7341
7588
|
suppressErrors: true
|
|
7342
7589
|
};
|
|
7343
7590
|
const [gitMatches, manifestMatches] = await Promise.all([
|
|
7344
|
-
|
|
7345
|
-
|
|
7591
|
+
fg5(GIT_MARKER, { ...commonOpts, ignore: IGNORE }),
|
|
7592
|
+
fg5(MANIFEST_MARKERS, { ...commonOpts, ignore: [...IGNORE, "**/.git/**"] })
|
|
7346
7593
|
]);
|
|
7347
7594
|
const gitDirs = /* @__PURE__ */ new Set();
|
|
7348
7595
|
const manifestDirs = /* @__PURE__ */ new Set();
|
|
7349
7596
|
for (const match of gitMatches) {
|
|
7350
|
-
gitDirs.add(
|
|
7597
|
+
gitDirs.add(path45.posix.dirname(match));
|
|
7351
7598
|
}
|
|
7352
7599
|
for (const match of manifestMatches) {
|
|
7353
|
-
manifestDirs.add(
|
|
7600
|
+
manifestDirs.add(path45.posix.dirname(match));
|
|
7354
7601
|
}
|
|
7355
7602
|
const repoRoots = [...gitDirs];
|
|
7356
7603
|
const manifestSorted = [...manifestDirs].sort(
|
|
@@ -7367,9 +7614,9 @@ async function findRepoRoots(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
|
|
|
7367
7614
|
async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
|
|
7368
7615
|
const repoRoots = (await findRepoRoots(workspaceRoot, maxDepth)).filter((dir) => dir !== ".");
|
|
7369
7616
|
return mapWithConcurrency(repoRoots, async (relDir) => {
|
|
7370
|
-
const absDir =
|
|
7371
|
-
const pkg = await readJson(
|
|
7372
|
-
const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name :
|
|
7617
|
+
const absDir = path45.resolve(workspaceRoot, relDir);
|
|
7618
|
+
const pkg = await readJson(path45.join(absDir, "package.json"));
|
|
7619
|
+
const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path45.basename(absDir);
|
|
7373
7620
|
let role = "auto";
|
|
7374
7621
|
try {
|
|
7375
7622
|
const scan = await scanProject(absDir);
|
|
@@ -7412,7 +7659,7 @@ function renderWorkspaceYaml(config) {
|
|
|
7412
7659
|
});
|
|
7413
7660
|
}
|
|
7414
7661
|
async function runDiscover(workspaceRoot, opts = {}) {
|
|
7415
|
-
const yamlPath =
|
|
7662
|
+
const yamlPath = path45.join(workspaceRoot, "haus.workspace.yaml");
|
|
7416
7663
|
const existingText = await readText(yamlPath);
|
|
7417
7664
|
const existing = parseWorkspaceConfig(existingText);
|
|
7418
7665
|
if (existingText && !existing) {
|
|
@@ -7459,6 +7706,11 @@ async function hasMultipleSiblingRepos(root) {
|
|
|
7459
7706
|
}
|
|
7460
7707
|
|
|
7461
7708
|
// src/claude/setup-core.ts
|
|
7709
|
+
async function readPriorManualIncludes(root) {
|
|
7710
|
+
const prior = await readJson(hausPath(root, "recommendation.json"));
|
|
7711
|
+
if (!prior?.recommended) return [];
|
|
7712
|
+
return prior.recommended.filter((entry) => entry.selectionMode === "manual").map((entry) => entry.id);
|
|
7713
|
+
}
|
|
7462
7714
|
async function runSetupCore(root, opts) {
|
|
7463
7715
|
const { json, apply, dryRun, quiet, confirm: confirm2, force } = opts;
|
|
7464
7716
|
const say = quiet ? () => {
|
|
@@ -7473,7 +7725,8 @@ async function runSetupCore(root, opts) {
|
|
|
7473
7725
|
}
|
|
7474
7726
|
const siblingWarning = await hasMultipleSiblingRepos(root) ? [SIBLING_REPO_WARNING] : [];
|
|
7475
7727
|
const context = await readContextOrScan(root);
|
|
7476
|
-
const
|
|
7728
|
+
const priorManualIncludes = await readPriorManualIncludes(root);
|
|
7729
|
+
const recommendation = await recommend(root, context, { include: priorManualIncludes });
|
|
7477
7730
|
if (recommendation.recommended.length === 0 && !force) {
|
|
7478
7731
|
const warningLines2 = [
|
|
7479
7732
|
.../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? [], ...siblingWarning])
|
|
@@ -7584,8 +7837,8 @@ async function runSetupProject(options) {
|
|
|
7584
7837
|
// src/commands/init.ts
|
|
7585
7838
|
async function runInit(options) {
|
|
7586
7839
|
const root = process.cwd();
|
|
7587
|
-
const hausDir =
|
|
7588
|
-
const alreadyInit = await
|
|
7840
|
+
const hausDir = path46.join(root, ".haus-workflow");
|
|
7841
|
+
const alreadyInit = await fs33.pathExists(hausDir);
|
|
7589
7842
|
if (alreadyInit) {
|
|
7590
7843
|
log("Haus AI already initialized in this project.");
|
|
7591
7844
|
log("Run `haus setup-project` to reconfigure.");
|
|
@@ -7720,8 +7973,8 @@ async function runRefresh() {
|
|
|
7720
7973
|
}
|
|
7721
7974
|
|
|
7722
7975
|
// src/install/scaffold.ts
|
|
7723
|
-
import
|
|
7724
|
-
import
|
|
7976
|
+
import path47 from "node:path";
|
|
7977
|
+
import fs34 from "fs-extra";
|
|
7725
7978
|
async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
7726
7979
|
const scaffolded = [];
|
|
7727
7980
|
const skipped = [];
|
|
@@ -7734,27 +7987,27 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
7734
7987
|
skipped.push(name);
|
|
7735
7988
|
if (outcome === "skipped-exists") skippedExisting.push(name);
|
|
7736
7989
|
};
|
|
7737
|
-
const realRoot = await
|
|
7990
|
+
const realRoot = await fs34.realpath(catalogRoot).catch(() => null);
|
|
7738
7991
|
for (const item of items) {
|
|
7739
7992
|
if (item.type !== "config") continue;
|
|
7740
|
-
const sourcePath =
|
|
7741
|
-
const rel =
|
|
7742
|
-
const escapes = rel === "" ||
|
|
7993
|
+
const sourcePath = path47.resolve(catalogRoot, item.path);
|
|
7994
|
+
const rel = path47.relative(catalogRoot, sourcePath);
|
|
7995
|
+
const escapes = rel === "" || path47.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
|
|
7743
7996
|
if (escapes) {
|
|
7744
7997
|
warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
|
|
7745
7998
|
continue;
|
|
7746
7999
|
}
|
|
7747
|
-
const realSource = await
|
|
8000
|
+
const realSource = await fs34.realpath(sourcePath).catch(() => null);
|
|
7748
8001
|
if (!realRoot || !realSource) {
|
|
7749
8002
|
warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
|
|
7750
8003
|
continue;
|
|
7751
8004
|
}
|
|
7752
|
-
const realRel =
|
|
7753
|
-
if (realRel !== "" && (
|
|
8005
|
+
const realRel = path47.relative(realRoot, realSource);
|
|
8006
|
+
if (realRel !== "" && (path47.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
|
|
7754
8007
|
warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
|
|
7755
8008
|
continue;
|
|
7756
8009
|
}
|
|
7757
|
-
const stat = await
|
|
8010
|
+
const stat = await fs34.lstat(sourcePath).catch(() => null);
|
|
7758
8011
|
if (!stat) {
|
|
7759
8012
|
warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
|
|
7760
8013
|
continue;
|
|
@@ -7764,14 +8017,14 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
7764
8017
|
continue;
|
|
7765
8018
|
}
|
|
7766
8019
|
if (stat.isFile()) {
|
|
7767
|
-
const filename =
|
|
7768
|
-
const dest =
|
|
8020
|
+
const filename = path47.basename(sourcePath);
|
|
8021
|
+
const dest = path47.join(projectRoot, filename);
|
|
7769
8022
|
record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
|
|
7770
8023
|
} else if (stat.isDirectory()) {
|
|
7771
|
-
const entries = await
|
|
8024
|
+
const entries = await fs34.readdir(sourcePath);
|
|
7772
8025
|
for (const entry of entries) {
|
|
7773
|
-
const src =
|
|
7774
|
-
const dest =
|
|
8026
|
+
const src = path47.join(sourcePath, entry);
|
|
8027
|
+
const dest = path47.join(projectRoot, entry);
|
|
7775
8028
|
record(entry, await scaffoldFile(src, dest, item.id, projectRoot, opts));
|
|
7776
8029
|
}
|
|
7777
8030
|
}
|
|
@@ -7779,42 +8032,42 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
7779
8032
|
return { scaffolded, skipped, skippedExisting };
|
|
7780
8033
|
}
|
|
7781
8034
|
async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
|
|
7782
|
-
const destDisplay =
|
|
7783
|
-
const srcStat = await
|
|
8035
|
+
const destDisplay = path47.relative(projectRoot, dest) || path47.basename(dest);
|
|
8036
|
+
const srcStat = await fs34.lstat(src).catch(() => null);
|
|
7784
8037
|
if (!srcStat) {
|
|
7785
|
-
warn(`Skipping ${
|
|
8038
|
+
warn(`Skipping ${path47.basename(src)}: source not found`);
|
|
7786
8039
|
return "skipped-refused";
|
|
7787
8040
|
}
|
|
7788
8041
|
if (srcStat.isSymbolicLink()) {
|
|
7789
|
-
warn(`Skipping ${
|
|
8042
|
+
warn(`Skipping ${path47.basename(src)}: source is a symlink`);
|
|
7790
8043
|
return "skipped-refused";
|
|
7791
8044
|
}
|
|
7792
|
-
const exists = await
|
|
8045
|
+
const exists = await fs34.pathExists(dest);
|
|
7793
8046
|
if (exists && !opts.force) {
|
|
7794
8047
|
warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
|
|
7795
8048
|
return "skipped-exists";
|
|
7796
8049
|
}
|
|
7797
8050
|
if (opts.dryRun) {
|
|
7798
|
-
log(`[dry-run] would ${exists ? "overwrite" : "create"} ${
|
|
8051
|
+
log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path47.basename(dest)} (${itemId})`);
|
|
7799
8052
|
return "scaffolded";
|
|
7800
8053
|
}
|
|
7801
8054
|
if (exists && opts.force) {
|
|
7802
|
-
await
|
|
8055
|
+
await fs34.remove(dest);
|
|
7803
8056
|
}
|
|
7804
|
-
await
|
|
7805
|
-
await
|
|
8057
|
+
await fs34.ensureDir(path47.dirname(dest));
|
|
8058
|
+
await fs34.copy(src, dest, {
|
|
7806
8059
|
overwrite: true,
|
|
7807
8060
|
dereference: false,
|
|
7808
8061
|
// A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
|
|
7809
8062
|
filter: (s) => {
|
|
7810
8063
|
try {
|
|
7811
|
-
return !
|
|
8064
|
+
return !fs34.lstatSync(s).isSymbolicLink();
|
|
7812
8065
|
} catch {
|
|
7813
8066
|
return false;
|
|
7814
8067
|
}
|
|
7815
8068
|
}
|
|
7816
8069
|
});
|
|
7817
|
-
log(`${exists ? "Overwrote" : "Created"} ${
|
|
8070
|
+
log(`${exists ? "Overwrote" : "Created"} ${path47.basename(dest)} (${itemId})`);
|
|
7818
8071
|
return "scaffolded";
|
|
7819
8072
|
}
|
|
7820
8073
|
|
|
@@ -7879,8 +8132,8 @@ async function runScan(options) {
|
|
|
7879
8132
|
}
|
|
7880
8133
|
|
|
7881
8134
|
// src/commands/undo.ts
|
|
7882
|
-
import
|
|
7883
|
-
import
|
|
8135
|
+
import path48 from "node:path";
|
|
8136
|
+
import fs35 from "fs-extra";
|
|
7884
8137
|
|
|
7885
8138
|
// src/claude/managed-paths.ts
|
|
7886
8139
|
var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md"];
|
|
@@ -7895,7 +8148,7 @@ function coreManagedAbsolutePaths(root) {
|
|
|
7895
8148
|
async function collectCoreManagedPaths(root) {
|
|
7896
8149
|
const existing = [];
|
|
7897
8150
|
for (const abs of coreManagedAbsolutePaths(root)) {
|
|
7898
|
-
if (await
|
|
8151
|
+
if (await fs35.pathExists(abs)) existing.push(abs);
|
|
7899
8152
|
}
|
|
7900
8153
|
return existing;
|
|
7901
8154
|
}
|
|
@@ -7907,18 +8160,18 @@ async function collectLockTrackedPaths(root) {
|
|
|
7907
8160
|
const relPaths = row.paths ?? [];
|
|
7908
8161
|
const existingRel = [];
|
|
7909
8162
|
for (const rel of relPaths) {
|
|
7910
|
-
if (await
|
|
8163
|
+
if (await fs35.pathExists(path48.resolve(root, rel))) existingRel.push(rel);
|
|
7911
8164
|
}
|
|
7912
8165
|
if (existingRel.length === 0) continue;
|
|
7913
8166
|
if (row.hash === void 0) {
|
|
7914
|
-
removable.push(...existingRel.map((rel) =>
|
|
8167
|
+
removable.push(...existingRel.map((rel) => path48.resolve(root, rel)));
|
|
7915
8168
|
continue;
|
|
7916
8169
|
}
|
|
7917
8170
|
const currentHash = await hashInstalledPaths(root, existingRel);
|
|
7918
8171
|
if (currentHash === row.hash) {
|
|
7919
|
-
removable.push(...existingRel.map((rel) =>
|
|
8172
|
+
removable.push(...existingRel.map((rel) => path48.resolve(root, rel)));
|
|
7920
8173
|
} else {
|
|
7921
|
-
preserved.push(...existingRel.map((rel) =>
|
|
8174
|
+
preserved.push(...existingRel.map((rel) => path48.resolve(root, rel)));
|
|
7922
8175
|
log(`Preserving locally-modified ${row.id ?? "(unknown item)"}: ${existingRel.join(", ")}`);
|
|
7923
8176
|
}
|
|
7924
8177
|
}
|
|
@@ -7926,51 +8179,51 @@ async function collectLockTrackedPaths(root) {
|
|
|
7926
8179
|
}
|
|
7927
8180
|
async function settingsHasHausContent(root) {
|
|
7928
8181
|
const settingsPath = claudePath(root, "settings.json");
|
|
7929
|
-
if (!await
|
|
8182
|
+
if (!await fs35.pathExists(settingsPath)) return false;
|
|
7930
8183
|
const settings = await readProjectSettings(root);
|
|
7931
8184
|
return settings._haus != null;
|
|
7932
8185
|
}
|
|
7933
8186
|
async function claudeMdHasHausBlock(root) {
|
|
7934
|
-
const filePath =
|
|
7935
|
-
if (!await
|
|
7936
|
-
const text = await
|
|
8187
|
+
const filePath = path48.join(root, "CLAUDE.md");
|
|
8188
|
+
if (!await fs35.pathExists(filePath)) return false;
|
|
8189
|
+
const text = await fs35.readFile(filePath, "utf8");
|
|
7937
8190
|
return text.includes(BLOCK_BEGIN);
|
|
7938
8191
|
}
|
|
7939
8192
|
async function stripProjectSettings(root) {
|
|
7940
8193
|
const settingsPath = claudePath(root, "settings.json");
|
|
7941
|
-
if (!await
|
|
8194
|
+
if (!await fs35.pathExists(settingsPath)) return false;
|
|
7942
8195
|
let settings = await readProjectSettings(root);
|
|
7943
8196
|
settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
|
|
7944
8197
|
const hasContent = Object.keys(settings).length > 0;
|
|
7945
8198
|
if (hasContent) {
|
|
7946
8199
|
await writeProjectSettings(root, settings);
|
|
7947
|
-
log(`Stripped haus rules from ${
|
|
8200
|
+
log(`Stripped haus rules from ${path48.relative(root, settingsPath)} (user settings preserved).`);
|
|
7948
8201
|
return true;
|
|
7949
8202
|
}
|
|
7950
|
-
await
|
|
7951
|
-
log(`Removed ${
|
|
8203
|
+
await fs35.remove(settingsPath);
|
|
8204
|
+
log(`Removed ${path48.relative(root, settingsPath)} (no user-owned settings remained).`);
|
|
7952
8205
|
return true;
|
|
7953
8206
|
}
|
|
7954
8207
|
async function stripRootClaudeMd(root) {
|
|
7955
|
-
const filePath =
|
|
7956
|
-
if (!await
|
|
7957
|
-
const prev = await
|
|
8208
|
+
const filePath = path48.join(root, "CLAUDE.md");
|
|
8209
|
+
if (!await fs35.pathExists(filePath)) return false;
|
|
8210
|
+
const prev = await fs35.readFile(filePath, "utf8");
|
|
7958
8211
|
if (!prev.includes(BLOCK_BEGIN)) return false;
|
|
7959
8212
|
const next = stripHausBlock(prev);
|
|
7960
8213
|
if (next.length === 0) {
|
|
7961
|
-
await
|
|
8214
|
+
await fs35.remove(filePath);
|
|
7962
8215
|
log("Removed CLAUDE.md (only contained haus import block).");
|
|
7963
8216
|
} else {
|
|
7964
|
-
await
|
|
8217
|
+
await fs35.writeFile(filePath, next, "utf8");
|
|
7965
8218
|
log("Removed haus import block from CLAUDE.md (user content preserved).");
|
|
7966
8219
|
}
|
|
7967
8220
|
return true;
|
|
7968
8221
|
}
|
|
7969
8222
|
async function pruneDirIfEmpty(dir) {
|
|
7970
|
-
if (!await
|
|
8223
|
+
if (!await fs35.pathExists(dir)) return;
|
|
7971
8224
|
try {
|
|
7972
|
-
const entries = await
|
|
7973
|
-
if (entries.length === 0) await
|
|
8225
|
+
const entries = await fs35.readdir(dir);
|
|
8226
|
+
if (entries.length === 0) await fs35.remove(dir);
|
|
7974
8227
|
} catch {
|
|
7975
8228
|
}
|
|
7976
8229
|
}
|
|
@@ -7979,13 +8232,13 @@ async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
|
|
|
7979
8232
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
7980
8233
|
const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
|
|
7981
8234
|
for (const abs of managedAbsPaths) {
|
|
7982
|
-
if (!await
|
|
7983
|
-
const rel =
|
|
7984
|
-
const backupPath =
|
|
7985
|
-
await
|
|
7986
|
-
await
|
|
8235
|
+
if (!await fs35.pathExists(abs)) continue;
|
|
8236
|
+
const rel = path48.relative(root, abs);
|
|
8237
|
+
const backupPath = path48.join(backupRoot, rel);
|
|
8238
|
+
await fs35.ensureDir(path48.dirname(backupPath));
|
|
8239
|
+
await fs35.copy(abs, backupPath);
|
|
7987
8240
|
}
|
|
7988
|
-
log(`Backed up ${managedAbsPaths.length} managed file(s) to ${
|
|
8241
|
+
log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path48.relative(root, backupRoot)}.`);
|
|
7989
8242
|
}
|
|
7990
8243
|
async function runUndo(options) {
|
|
7991
8244
|
const root = options.root ?? process.cwd();
|
|
@@ -7998,7 +8251,7 @@ async function runUndo(options) {
|
|
|
7998
8251
|
log("Nothing to remove: no haus-managed files found in this directory.");
|
|
7999
8252
|
return;
|
|
8000
8253
|
}
|
|
8001
|
-
const relTargets = managed.map((p) =>
|
|
8254
|
+
const relTargets = managed.map((p) => path48.relative(root, p));
|
|
8002
8255
|
const summaryParts = [...relTargets];
|
|
8003
8256
|
if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
|
|
8004
8257
|
if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
|
|
@@ -8020,9 +8273,9 @@ User-owned .claude/ files will be preserved.`
|
|
|
8020
8273
|
}
|
|
8021
8274
|
await backupManagedFilesBeforeUndo(root, managed);
|
|
8022
8275
|
for (const abs of managed) {
|
|
8023
|
-
if (!await
|
|
8024
|
-
await
|
|
8025
|
-
log(`Removed ${
|
|
8276
|
+
if (!await fs35.pathExists(abs)) continue;
|
|
8277
|
+
await fs35.remove(abs);
|
|
8278
|
+
log(`Removed ${path48.relative(root, abs)}`);
|
|
8026
8279
|
}
|
|
8027
8280
|
if (stripSettings) await stripProjectSettings(root);
|
|
8028
8281
|
if (stripClaudeMd) await stripRootClaudeMd(root);
|
|
@@ -8033,8 +8286,8 @@ User-owned .claude/ files will be preserved.`
|
|
|
8033
8286
|
|
|
8034
8287
|
// src/install/uninstall.ts
|
|
8035
8288
|
import crypto3 from "node:crypto";
|
|
8036
|
-
import
|
|
8037
|
-
import
|
|
8289
|
+
import path49 from "node:path";
|
|
8290
|
+
import fs36 from "fs-extra";
|
|
8038
8291
|
async function runUninstall(options = {}) {
|
|
8039
8292
|
const { force = false } = options;
|
|
8040
8293
|
const manifest = await readManifest();
|
|
@@ -8044,7 +8297,7 @@ async function runUninstall(options = {}) {
|
|
|
8044
8297
|
return result;
|
|
8045
8298
|
}
|
|
8046
8299
|
for (const entry of manifest.files) {
|
|
8047
|
-
const exists =
|
|
8300
|
+
const exists = fs36.pathExistsSync(entry.destPath);
|
|
8048
8301
|
if (!exists) continue;
|
|
8049
8302
|
const content2 = await readText(entry.destPath);
|
|
8050
8303
|
if (content2 === void 0) continue;
|
|
@@ -8062,23 +8315,23 @@ async function runUninstall(options = {}) {
|
|
|
8062
8315
|
result.skipped.push(entry.destPath);
|
|
8063
8316
|
continue;
|
|
8064
8317
|
}
|
|
8065
|
-
await
|
|
8066
|
-
await pruneEmptyDir(
|
|
8318
|
+
await fs36.remove(entry.destPath);
|
|
8319
|
+
await pruneEmptyDir(path49.dirname(entry.destPath));
|
|
8067
8320
|
result.deleted.push(entry.destPath);
|
|
8068
8321
|
}
|
|
8069
8322
|
const settings = await readSettings();
|
|
8070
8323
|
const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
|
|
8071
8324
|
await writeSettings(stripped);
|
|
8072
8325
|
result.hooksStripped = true;
|
|
8073
|
-
const hausDir =
|
|
8326
|
+
const hausDir = path49.join(globalClaudeDir(), "haus");
|
|
8074
8327
|
const manifestPath2 = hausManifestPath();
|
|
8075
|
-
if (
|
|
8076
|
-
await
|
|
8328
|
+
if (fs36.pathExistsSync(manifestPath2)) {
|
|
8329
|
+
await fs36.remove(manifestPath2);
|
|
8077
8330
|
}
|
|
8078
|
-
if (
|
|
8331
|
+
if (fs36.pathExistsSync(hausDir)) {
|
|
8079
8332
|
try {
|
|
8080
|
-
const remaining = await
|
|
8081
|
-
if (remaining.length === 0) await
|
|
8333
|
+
const remaining = await fs36.readdir(hausDir);
|
|
8334
|
+
if (remaining.length === 0) await fs36.remove(hausDir);
|
|
8082
8335
|
} catch {
|
|
8083
8336
|
}
|
|
8084
8337
|
}
|
|
@@ -8101,6 +8354,19 @@ function printUninstallResult(result) {
|
|
|
8101
8354
|
// src/commands/uninstall.ts
|
|
8102
8355
|
async function runUninstallCommand(options) {
|
|
8103
8356
|
try {
|
|
8357
|
+
if (!options.yes) {
|
|
8358
|
+
const manifest = await readManifest();
|
|
8359
|
+
if (manifest) {
|
|
8360
|
+
const fileCount = manifest.files.length;
|
|
8361
|
+
const ok = await confirm(
|
|
8362
|
+
fileCount > 0 ? `Remove ${fileCount} haus-managed file(s) from ~/.claude/ and strip haus hook entries from settings.json?` : "Strip haus hook entries from ~/.claude/settings.json and remove the install manifest?"
|
|
8363
|
+
);
|
|
8364
|
+
if (!ok) {
|
|
8365
|
+
log("Cancelled.");
|
|
8366
|
+
return;
|
|
8367
|
+
}
|
|
8368
|
+
}
|
|
8369
|
+
}
|
|
8104
8370
|
const result = await runUninstall({ force: options.force });
|
|
8105
8371
|
printUninstallResult(result);
|
|
8106
8372
|
log("haus uninstall complete");
|
|
@@ -8111,11 +8377,11 @@ async function runUninstallCommand(options) {
|
|
|
8111
8377
|
}
|
|
8112
8378
|
|
|
8113
8379
|
// src/commands/validate-catalog.ts
|
|
8114
|
-
import
|
|
8380
|
+
import path51 from "node:path";
|
|
8115
8381
|
|
|
8116
8382
|
// src/catalog/validate-core.ts
|
|
8117
|
-
import
|
|
8118
|
-
import
|
|
8383
|
+
import fs37 from "node:fs";
|
|
8384
|
+
import path50 from "node:path";
|
|
8119
8385
|
var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
8120
8386
|
var ITEM_ID_PATTERN = /^haus\./;
|
|
8121
8387
|
function auditForbiddenStacks(items) {
|
|
@@ -8257,14 +8523,14 @@ function checkRequiredFrontmatter(text, label) {
|
|
|
8257
8523
|
}
|
|
8258
8524
|
function readForAudit(absPath, label, failures) {
|
|
8259
8525
|
try {
|
|
8260
|
-
return
|
|
8526
|
+
return fs37.readFileSync(absPath, "utf8");
|
|
8261
8527
|
} catch (err) {
|
|
8262
8528
|
failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
|
|
8263
8529
|
return null;
|
|
8264
8530
|
}
|
|
8265
8531
|
}
|
|
8266
8532
|
function auditTemplateContent(manifestDir, absPath, itemId) {
|
|
8267
|
-
const rel =
|
|
8533
|
+
const rel = path50.relative(manifestDir, absPath);
|
|
8268
8534
|
const failures = [];
|
|
8269
8535
|
const text = readForAudit(absPath, `${itemId}: ${rel}`, failures);
|
|
8270
8536
|
if (text === null) return failures;
|
|
@@ -8286,47 +8552,47 @@ function auditShippedFiles(manifestDir, items) {
|
|
|
8286
8552
|
const failures = [];
|
|
8287
8553
|
for (const item of items) {
|
|
8288
8554
|
if (!item.path || !isSafeCatalogPath(item.path)) continue;
|
|
8289
|
-
const absPath =
|
|
8555
|
+
const absPath = path50.join(manifestDir, item.path);
|
|
8290
8556
|
if (item.type === "skill") {
|
|
8291
|
-
const skillMd =
|
|
8292
|
-
if (!
|
|
8293
|
-
failures.push(`${item.id}: missing ${
|
|
8557
|
+
const skillMd = path50.join(absPath, "SKILL.md");
|
|
8558
|
+
if (!fs37.existsSync(skillMd)) {
|
|
8559
|
+
failures.push(`${item.id}: missing ${path50.relative(manifestDir, skillMd)}`);
|
|
8294
8560
|
continue;
|
|
8295
8561
|
}
|
|
8296
8562
|
const text = readForAudit(skillMd, `${item.id}: SKILL.md`, failures);
|
|
8297
8563
|
if (text === null) continue;
|
|
8298
8564
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
|
|
8299
8565
|
failures.push(
|
|
8300
|
-
...auditForbiddenTagsInText(text, `${item.id}: ${
|
|
8566
|
+
...auditForbiddenTagsInText(text, `${item.id}: ${path50.relative(manifestDir, skillMd)}`)
|
|
8301
8567
|
);
|
|
8302
8568
|
} else if (item.type === "agent") {
|
|
8303
|
-
if (!
|
|
8569
|
+
if (!fs37.existsSync(absPath)) {
|
|
8304
8570
|
failures.push(`${item.id}: missing agent file ${item.path}`);
|
|
8305
8571
|
continue;
|
|
8306
8572
|
}
|
|
8307
|
-
const rel =
|
|
8573
|
+
const rel = path50.relative(manifestDir, absPath);
|
|
8308
8574
|
const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
|
|
8309
8575
|
if (text === null) continue;
|
|
8310
8576
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
|
|
8311
8577
|
failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
|
|
8312
8578
|
} else if (item.type === "template") {
|
|
8313
|
-
if (!
|
|
8579
|
+
if (!fs37.existsSync(absPath)) {
|
|
8314
8580
|
failures.push(`${item.id}: missing template file ${item.path}`);
|
|
8315
8581
|
continue;
|
|
8316
8582
|
}
|
|
8317
8583
|
failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
|
|
8318
8584
|
} else if (item.type === "command") {
|
|
8319
|
-
if (!
|
|
8585
|
+
if (!fs37.existsSync(absPath)) {
|
|
8320
8586
|
failures.push(`${item.id}: missing command file ${item.path}`);
|
|
8321
8587
|
continue;
|
|
8322
8588
|
}
|
|
8323
|
-
const rel =
|
|
8589
|
+
const rel = path50.relative(manifestDir, absPath);
|
|
8324
8590
|
const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
|
|
8325
8591
|
if (text === null) continue;
|
|
8326
8592
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
|
|
8327
8593
|
failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
|
|
8328
8594
|
} else if (item.type === "config") {
|
|
8329
|
-
if (!
|
|
8595
|
+
if (!fs37.existsSync(absPath)) {
|
|
8330
8596
|
failures.push(`${item.id}: missing config path ${item.path}`);
|
|
8331
8597
|
}
|
|
8332
8598
|
}
|
|
@@ -8343,7 +8609,7 @@ var CONTENT_ROOTS = ["skills", "agents", "templates", "commands", "configs"];
|
|
|
8343
8609
|
function walkAllFiles(dir, fn, failures) {
|
|
8344
8610
|
let entries;
|
|
8345
8611
|
try {
|
|
8346
|
-
entries =
|
|
8612
|
+
entries = fs37.readdirSync(dir, { withFileTypes: true });
|
|
8347
8613
|
} catch (err) {
|
|
8348
8614
|
failures.push(
|
|
8349
8615
|
`${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
|
|
@@ -8352,7 +8618,7 @@ function walkAllFiles(dir, fn, failures) {
|
|
|
8352
8618
|
}
|
|
8353
8619
|
for (const entry of entries) {
|
|
8354
8620
|
if (entry.name === ".DS_Store") continue;
|
|
8355
|
-
const full =
|
|
8621
|
+
const full = path50.join(dir, entry.name);
|
|
8356
8622
|
if (entry.isDirectory()) walkAllFiles(full, fn, failures);
|
|
8357
8623
|
else fn(full);
|
|
8358
8624
|
}
|
|
@@ -8370,12 +8636,12 @@ function auditDiskOrphans(manifestDir, items) {
|
|
|
8370
8636
|
const failures = [];
|
|
8371
8637
|
const claimedPaths = items.filter((item) => item.path && isSafeCatalogPath(item.path)).map((item) => item.path.replace(/\\/g, "/"));
|
|
8372
8638
|
for (const dir of CONTENT_ROOTS) {
|
|
8373
|
-
const abs =
|
|
8374
|
-
if (!
|
|
8639
|
+
const abs = path50.join(manifestDir, dir);
|
|
8640
|
+
if (!fs37.existsSync(abs)) continue;
|
|
8375
8641
|
walkAllFiles(
|
|
8376
8642
|
abs,
|
|
8377
8643
|
(file) => {
|
|
8378
|
-
const relPath =
|
|
8644
|
+
const relPath = path50.relative(manifestDir, file).replace(/\\/g, "/");
|
|
8379
8645
|
if (!isPathClaimed(relPath, claimedPaths)) {
|
|
8380
8646
|
failures.push(`orphaned on disk, claimed by no manifest item: ${relPath}`);
|
|
8381
8647
|
}
|
|
@@ -8388,7 +8654,7 @@ function auditDiskOrphans(manifestDir, items) {
|
|
|
8388
8654
|
function walkMd(dir, fn, failures) {
|
|
8389
8655
|
let entries;
|
|
8390
8656
|
try {
|
|
8391
|
-
entries =
|
|
8657
|
+
entries = fs37.readdirSync(dir, { withFileTypes: true });
|
|
8392
8658
|
} catch (err) {
|
|
8393
8659
|
failures.push(
|
|
8394
8660
|
`${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
|
|
@@ -8396,7 +8662,7 @@ function walkMd(dir, fn, failures) {
|
|
|
8396
8662
|
return;
|
|
8397
8663
|
}
|
|
8398
8664
|
for (const entry of entries) {
|
|
8399
|
-
const full =
|
|
8665
|
+
const full = path50.join(dir, entry.name);
|
|
8400
8666
|
if (entry.isDirectory()) walkMd(full, fn, failures);
|
|
8401
8667
|
else if (entry.name.endsWith(".md")) fn(full);
|
|
8402
8668
|
}
|
|
@@ -8405,13 +8671,13 @@ function auditMarkdownContent(manifestDir, items) {
|
|
|
8405
8671
|
const failures = [];
|
|
8406
8672
|
const pathSourceMap = buildItemPathSourceMap(items);
|
|
8407
8673
|
for (const dir of ["skills", "agents", "templates", "commands"]) {
|
|
8408
|
-
const abs =
|
|
8409
|
-
if (!
|
|
8674
|
+
const abs = path50.join(manifestDir, dir);
|
|
8675
|
+
if (!fs37.existsSync(abs)) continue;
|
|
8410
8676
|
const dirType = DIR_ITEM_TYPE[dir] ?? "";
|
|
8411
8677
|
walkMd(
|
|
8412
8678
|
abs,
|
|
8413
8679
|
(file) => {
|
|
8414
|
-
const rel =
|
|
8680
|
+
const rel = path50.relative(manifestDir, file);
|
|
8415
8681
|
const text = readForAudit(file, rel, failures);
|
|
8416
8682
|
if (text === null) return;
|
|
8417
8683
|
const norm = rel.replace(/\\/g, "/");
|
|
@@ -8456,8 +8722,8 @@ async function runValidateCatalog(manifestPath2) {
|
|
|
8456
8722
|
process.exitCode = 1;
|
|
8457
8723
|
return;
|
|
8458
8724
|
}
|
|
8459
|
-
const abs =
|
|
8460
|
-
const manifestDir =
|
|
8725
|
+
const abs = path51.resolve(process.cwd(), manifestPath2);
|
|
8726
|
+
const manifestDir = path51.dirname(abs);
|
|
8461
8727
|
const data = await readJson(abs);
|
|
8462
8728
|
if (!data?.items) {
|
|
8463
8729
|
error(`Could not read catalog manifest at ${abs}`);
|
|
@@ -8474,8 +8740,8 @@ async function runValidateCatalog(manifestPath2) {
|
|
|
8474
8740
|
}
|
|
8475
8741
|
|
|
8476
8742
|
// src/workspace/worktree/add.ts
|
|
8477
|
-
import
|
|
8478
|
-
import
|
|
8743
|
+
import path58 from "node:path";
|
|
8744
|
+
import fs41 from "fs-extra";
|
|
8479
8745
|
|
|
8480
8746
|
// src/workspace/worktree/git-worktree.ts
|
|
8481
8747
|
init_exec();
|
|
@@ -8594,13 +8860,13 @@ async function hasUnpushedWork(cwd) {
|
|
|
8594
8860
|
}
|
|
8595
8861
|
|
|
8596
8862
|
// src/workspace/worktree/hydrate.ts
|
|
8597
|
-
import
|
|
8598
|
-
import
|
|
8863
|
+
import path54 from "node:path";
|
|
8864
|
+
import fs39 from "fs-extra";
|
|
8599
8865
|
|
|
8600
8866
|
// src/workspace/worktree/cow-copy.ts
|
|
8601
8867
|
init_exec();
|
|
8602
|
-
import
|
|
8603
|
-
import
|
|
8868
|
+
import path52 from "node:path";
|
|
8869
|
+
import fs38 from "fs-extra";
|
|
8604
8870
|
async function getMacFilesystemType(dir) {
|
|
8605
8871
|
const df = await runCommand("df", ["-P", dir]);
|
|
8606
8872
|
if (df.exitCode !== 0) return void 0;
|
|
@@ -8631,14 +8897,14 @@ async function detectCowStrategy(dir) {
|
|
|
8631
8897
|
return "unknown-platform";
|
|
8632
8898
|
}
|
|
8633
8899
|
async function cowCopyDir(src, dest) {
|
|
8634
|
-
const srcExists = await
|
|
8900
|
+
const srcExists = await fs38.pathExists(src);
|
|
8635
8901
|
if (!srcExists)
|
|
8636
8902
|
return { strategy: "unsupported", attempted: false, ok: false, error: "source missing" };
|
|
8637
|
-
const strategy = await detectCowStrategy(
|
|
8903
|
+
const strategy = await detectCowStrategy(path52.dirname(dest));
|
|
8638
8904
|
if (strategy === "unsupported" || strategy === "unknown-platform") {
|
|
8639
8905
|
return { strategy, attempted: false, ok: false };
|
|
8640
8906
|
}
|
|
8641
|
-
await
|
|
8907
|
+
await fs38.ensureDir(path52.dirname(dest));
|
|
8642
8908
|
const args = strategy === "darwin-clonefile" ? ["-c", "-R", src, dest] : ["-a", "--reflink=always", src, dest];
|
|
8643
8909
|
const result = await runCommand("cp", args);
|
|
8644
8910
|
return {
|
|
@@ -8652,8 +8918,8 @@ async function cowCopyDir(src, dest) {
|
|
|
8652
8918
|
// src/workspace/worktree/install.ts
|
|
8653
8919
|
init_exec();
|
|
8654
8920
|
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
|
|
8655
|
-
import
|
|
8656
|
-
import
|
|
8921
|
+
import path53 from "node:path";
|
|
8922
|
+
import fg6 from "fast-glob";
|
|
8657
8923
|
function detectInstallPlan(signals) {
|
|
8658
8924
|
if (signals.hasYarnLock) {
|
|
8659
8925
|
return signals.hasPackageManagerField ? { manager: "yarn", command: "corepack", args: ["yarn", "install"] } : { manager: "yarn", command: "yarn", args: ["install"] };
|
|
@@ -8673,11 +8939,11 @@ function detectInstallPlan(signals) {
|
|
|
8673
8939
|
return { manager: "none" };
|
|
8674
8940
|
}
|
|
8675
8941
|
async function computeLockfileSignals(dir) {
|
|
8676
|
-
const hasYarnLock = existsSync3(
|
|
8677
|
-
const hasPnpmLock = existsSync3(
|
|
8678
|
-
const hasPackageLock = existsSync3(
|
|
8679
|
-
const hasComposerLock = existsSync3(
|
|
8680
|
-
const dotnetFiles = await
|
|
8942
|
+
const hasYarnLock = existsSync3(path53.join(dir, "yarn.lock"));
|
|
8943
|
+
const hasPnpmLock = existsSync3(path53.join(dir, "pnpm-lock.yaml"));
|
|
8944
|
+
const hasPackageLock = existsSync3(path53.join(dir, "package-lock.json"));
|
|
8945
|
+
const hasComposerLock = existsSync3(path53.join(dir, "composer.lock"));
|
|
8946
|
+
const dotnetFiles = await fg6(["**/*.csproj", "**/*.sln"], {
|
|
8681
8947
|
cwd: dir,
|
|
8682
8948
|
onlyFiles: true,
|
|
8683
8949
|
suppressErrors: true,
|
|
@@ -8690,7 +8956,7 @@ async function computeLockfileSignals(dir) {
|
|
|
8690
8956
|
});
|
|
8691
8957
|
const hasDotnetProject = dotnetFiles.length > 0;
|
|
8692
8958
|
let hasPackageManagerField = false;
|
|
8693
|
-
const pkgPath =
|
|
8959
|
+
const pkgPath = path53.join(dir, "package.json");
|
|
8694
8960
|
if (existsSync3(pkgPath)) {
|
|
8695
8961
|
try {
|
|
8696
8962
|
const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
|
|
@@ -8731,13 +8997,13 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
|
|
|
8731
8997
|
const start = Date.now();
|
|
8732
8998
|
const targets = [];
|
|
8733
8999
|
for (const target of HYDRATION_TARGETS) {
|
|
8734
|
-
const src =
|
|
8735
|
-
const dest =
|
|
8736
|
-
if (!await
|
|
8737
|
-
const destExists = await
|
|
9000
|
+
const src = path54.join(mainMemberPath, target);
|
|
9001
|
+
const dest = path54.join(worktreeMemberPath, target);
|
|
9002
|
+
if (!await fs39.pathExists(src)) continue;
|
|
9003
|
+
const destExists = await fs39.pathExists(dest);
|
|
8738
9004
|
if (!opts.force && destExists) continue;
|
|
8739
9005
|
if (opts.force && destExists) {
|
|
8740
|
-
await
|
|
9006
|
+
await fs39.remove(dest);
|
|
8741
9007
|
}
|
|
8742
9008
|
const copy = await cowCopyDir(src, dest);
|
|
8743
9009
|
targets.push({ target, copy });
|
|
@@ -8750,11 +9016,11 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
|
|
|
8750
9016
|
|
|
8751
9017
|
// src/workspace/worktree/local-files.ts
|
|
8752
9018
|
init_exec();
|
|
8753
|
-
import
|
|
8754
|
-
import
|
|
9019
|
+
import path55 from "node:path";
|
|
9020
|
+
import fs40 from "fs-extra";
|
|
8755
9021
|
var MACHINE_LOCAL_FILES = [
|
|
8756
9022
|
"docker-compose.local.yml",
|
|
8757
|
-
|
|
9023
|
+
path55.join(".claude", "settings.local.json")
|
|
8758
9024
|
];
|
|
8759
9025
|
async function isTracked(repoRoot, relFile) {
|
|
8760
9026
|
const gitPath = relFile.replace(/\\/g, "/");
|
|
@@ -8764,12 +9030,12 @@ async function isTracked(repoRoot, relFile) {
|
|
|
8764
9030
|
async function copyMachineLocalFiles(mainMemberPath, worktreeMemberPath) {
|
|
8765
9031
|
const copied = [];
|
|
8766
9032
|
for (const rel of MACHINE_LOCAL_FILES) {
|
|
8767
|
-
const src =
|
|
8768
|
-
if (!await
|
|
9033
|
+
const src = path55.join(mainMemberPath, rel);
|
|
9034
|
+
if (!await fs40.pathExists(src)) continue;
|
|
8769
9035
|
if (await isTracked(mainMemberPath, rel)) continue;
|
|
8770
|
-
const dest =
|
|
8771
|
-
await
|
|
8772
|
-
await
|
|
9036
|
+
const dest = path55.join(worktreeMemberPath, rel);
|
|
9037
|
+
await fs40.ensureDir(path55.dirname(dest));
|
|
9038
|
+
await fs40.copy(src, dest, { overwrite: true });
|
|
8773
9039
|
copied.push(rel);
|
|
8774
9040
|
}
|
|
8775
9041
|
return copied;
|
|
@@ -8777,15 +9043,15 @@ async function copyMachineLocalFiles(mainMemberPath, worktreeMemberPath) {
|
|
|
8777
9043
|
|
|
8778
9044
|
// src/workspace/worktree/root.ts
|
|
8779
9045
|
import { existsSync as existsSync4 } from "node:fs";
|
|
8780
|
-
import
|
|
9046
|
+
import path56 from "node:path";
|
|
8781
9047
|
async function resolveWorkspaceForWorktree(start) {
|
|
8782
9048
|
const rootInfo = await resolveRoots(start);
|
|
8783
9049
|
if (!rootInfo.isGitRepo) {
|
|
8784
9050
|
return { ok: false, reason: "Not inside a git repository." };
|
|
8785
9051
|
}
|
|
8786
9052
|
const workspaceRoot = rootInfo.mainRoot;
|
|
8787
|
-
const hasYaml = existsSync4(
|
|
8788
|
-
const hasManifest = existsSync4(
|
|
9053
|
+
const hasYaml = existsSync4(path56.join(workspaceRoot, WORKSPACE_FILE));
|
|
9054
|
+
const hasManifest = existsSync4(path56.join(workspaceRoot, REPOS_MANIFEST_FILE));
|
|
8789
9055
|
if (!hasYaml && !hasManifest) {
|
|
8790
9056
|
return {
|
|
8791
9057
|
ok: false,
|
|
@@ -8810,19 +9076,31 @@ function selectMembers(members, only) {
|
|
|
8810
9076
|
}
|
|
8811
9077
|
|
|
8812
9078
|
// src/workspace/worktree/state.ts
|
|
8813
|
-
import
|
|
9079
|
+
import path57 from "node:path";
|
|
8814
9080
|
var WORKTREE_STATE_FILE = ".haus-worktree.json";
|
|
8815
9081
|
function worktreesDir(mainRoot) {
|
|
8816
|
-
return
|
|
9082
|
+
return path57.join(mainRoot, ".claude", "worktrees");
|
|
9083
|
+
}
|
|
9084
|
+
function isValidWorktreeSlug(slug) {
|
|
9085
|
+
if (slug === "" || slug === "." || slug === "..") return false;
|
|
9086
|
+
if (slug.includes("/") || slug.includes("\\")) return false;
|
|
9087
|
+
return /^[A-Za-z0-9._-]+$/.test(slug);
|
|
8817
9088
|
}
|
|
8818
9089
|
function worktreePath(mainRoot, slug) {
|
|
8819
|
-
|
|
9090
|
+
if (!isValidWorktreeSlug(slug)) {
|
|
9091
|
+
throw new Error(
|
|
9092
|
+
`Invalid worktree slug ${JSON.stringify(slug)} \u2014 slugs may only contain letters, digits, ".", "_", "-" (no path separators, and not "." or "..").`
|
|
9093
|
+
);
|
|
9094
|
+
}
|
|
9095
|
+
return path57.join(worktreesDir(mainRoot), slug);
|
|
8820
9096
|
}
|
|
8821
9097
|
async function readWorktreeState(worktreeRoot) {
|
|
8822
|
-
|
|
9098
|
+
const state = await readJson(path57.join(worktreeRoot, WORKTREE_STATE_FILE));
|
|
9099
|
+
if (state == null || typeof state !== "object" || !Array.isArray(state.members)) return void 0;
|
|
9100
|
+
return state;
|
|
8823
9101
|
}
|
|
8824
9102
|
async function writeWorktreeState(worktreeRoot, state) {
|
|
8825
|
-
await writeJson(
|
|
9103
|
+
await writeJson(path57.join(worktreeRoot, WORKTREE_STATE_FILE), state);
|
|
8826
9104
|
}
|
|
8827
9105
|
|
|
8828
9106
|
// src/workspace/worktree/add.ts
|
|
@@ -8837,7 +9115,7 @@ async function runAdd(opts) {
|
|
|
8837
9115
|
}
|
|
8838
9116
|
const branch = opts.branch ?? opts.slug;
|
|
8839
9117
|
const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
|
|
8840
|
-
if (await
|
|
9118
|
+
if (await fs41.pathExists(wsWorktreePath)) {
|
|
8841
9119
|
return {
|
|
8842
9120
|
ok: false,
|
|
8843
9121
|
error: `Workspace worktree already exists at ${wsWorktreePath} \u2014 run \`haus workspace worktree hydrate --materialize\` from inside it to check out any missing members (e.g. a worktree created by plain \`git worktree add\`), or \`remove\` first to start over.`
|
|
@@ -8856,7 +9134,7 @@ async function runAdd(opts) {
|
|
|
8856
9134
|
const memberResults = [];
|
|
8857
9135
|
for (const member of selected) {
|
|
8858
9136
|
const start = Date.now();
|
|
8859
|
-
const memberWorktreePath =
|
|
9137
|
+
const memberWorktreePath = path58.join(wsWorktreePath, member.folder);
|
|
8860
9138
|
const outcome = await addWorktree(member.absPath, memberWorktreePath, branch, {
|
|
8861
9139
|
preferDefaultBranchFrom: true
|
|
8862
9140
|
});
|
|
@@ -8886,7 +9164,7 @@ async function runAdd(opts) {
|
|
|
8886
9164
|
await mapWithConcurrency(okResults, async (result) => {
|
|
8887
9165
|
const member = selected.find((m) => m.id === result.member);
|
|
8888
9166
|
if (!member) return;
|
|
8889
|
-
const memberWorktreePath =
|
|
9167
|
+
const memberWorktreePath = path58.join(wsWorktreePath, member.folder);
|
|
8890
9168
|
const hydrationStart = Date.now();
|
|
8891
9169
|
if (opts.hydrate !== false) {
|
|
8892
9170
|
result.hydration = await hydrateMember(member.absPath, memberWorktreePath);
|
|
@@ -8953,10 +9231,10 @@ async function runDryRun(args) {
|
|
|
8953
9231
|
|
|
8954
9232
|
// src/workspace/worktree/doctor.ts
|
|
8955
9233
|
import { existsSync as existsSync5 } from "node:fs";
|
|
8956
|
-
import
|
|
9234
|
+
import path59 from "node:path";
|
|
8957
9235
|
var HYDRATION_MARKERS = ["node_modules", "vendor", "obj", "bin"];
|
|
8958
9236
|
function looksHydrated(memberPath) {
|
|
8959
|
-
return HYDRATION_MARKERS.some((marker) => existsSync5(
|
|
9237
|
+
return HYDRATION_MARKERS.some((marker) => existsSync5(path59.join(memberPath, marker)));
|
|
8960
9238
|
}
|
|
8961
9239
|
async function runDoctor2() {
|
|
8962
9240
|
const rootInfo = await resolveRoots();
|
|
@@ -8970,7 +9248,7 @@ async function runDoctor2() {
|
|
|
8970
9248
|
};
|
|
8971
9249
|
}
|
|
8972
9250
|
const workspaceRoot = rootInfo.mainRoot;
|
|
8973
|
-
const isWorkspace = existsSync5(
|
|
9251
|
+
const isWorkspace = existsSync5(path59.join(workspaceRoot, WORKSPACE_FILE)) || existsSync5(path59.join(workspaceRoot, REPOS_MANIFEST_FILE));
|
|
8974
9252
|
if (!isWorkspace) {
|
|
8975
9253
|
return {
|
|
8976
9254
|
isWorkspace: false,
|
|
@@ -8983,7 +9261,7 @@ async function runDoctor2() {
|
|
|
8983
9261
|
};
|
|
8984
9262
|
}
|
|
8985
9263
|
const wsWorktreesDir = worktreesDir(workspaceRoot);
|
|
8986
|
-
const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${
|
|
9264
|
+
const underWorktreesDir = rootInfo.repoRoot.startsWith(`${wsWorktreesDir}${path59.sep}`);
|
|
8987
9265
|
const inWorkspaceWorktree = rootInfo.isLinkedWorktree && underWorktreesDir;
|
|
8988
9266
|
const members = await readMembers(rootInfo);
|
|
8989
9267
|
const problems = [];
|
|
@@ -8991,10 +9269,10 @@ async function runDoctor2() {
|
|
|
8991
9269
|
let slug;
|
|
8992
9270
|
if (inWorkspaceWorktree) {
|
|
8993
9271
|
const wtPath = rootInfo.repoRoot;
|
|
8994
|
-
slug = rootInfo.worktreeName ??
|
|
9272
|
+
slug = rootInfo.worktreeName ?? path59.basename(wtPath);
|
|
8995
9273
|
const state = await readWorktreeState(wtPath);
|
|
8996
9274
|
for (const member of members) {
|
|
8997
|
-
const memberPath =
|
|
9275
|
+
const memberPath = path59.join(wtPath, member.folder);
|
|
8998
9276
|
const materialized = existsSync5(memberPath);
|
|
8999
9277
|
const expectedBranch = state?.members.find((m) => m.folder === member.folder)?.branch ?? state?.branch;
|
|
9000
9278
|
const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
|
|
@@ -9025,16 +9303,16 @@ async function runDoctor2() {
|
|
|
9025
9303
|
for (const member of members) {
|
|
9026
9304
|
const entries = await listWorktrees(member.absPath);
|
|
9027
9305
|
for (const entry of entries) {
|
|
9028
|
-
const entryPath =
|
|
9029
|
-
const marker = `${
|
|
9306
|
+
const entryPath = path59.normalize(entry.path);
|
|
9307
|
+
const marker = `${path59.sep}.claude${path59.sep}worktrees${path59.sep}`;
|
|
9030
9308
|
const idx = entryPath.indexOf(marker);
|
|
9031
9309
|
if (idx === -1) continue;
|
|
9032
9310
|
const rest = entryPath.slice(idx + marker.length);
|
|
9033
|
-
const slugName = rest.split(
|
|
9311
|
+
const slugName = rest.split(path59.sep)[0];
|
|
9034
9312
|
if (!slugName) continue;
|
|
9035
|
-
if (!existsSync5(
|
|
9313
|
+
if (!existsSync5(path59.join(wsWorktreesDir, slugName))) {
|
|
9036
9314
|
orphans.push(
|
|
9037
|
-
`${member.id}: worktree registered at ${entry.path} but ${
|
|
9315
|
+
`${member.id}: worktree registered at ${entry.path} but ${path59.join(wsWorktreesDir, slugName)} no longer exists \u2014 run \`git -C "${member.absPath}" worktree prune\`.`
|
|
9038
9316
|
);
|
|
9039
9317
|
}
|
|
9040
9318
|
}
|
|
@@ -9045,7 +9323,7 @@ async function runDoctor2() {
|
|
|
9045
9323
|
|
|
9046
9324
|
// src/workspace/worktree/hydrate-command.ts
|
|
9047
9325
|
import { existsSync as existsSync6 } from "node:fs";
|
|
9048
|
-
import
|
|
9326
|
+
import path60 from "node:path";
|
|
9049
9327
|
async function runHydrateWorktree(opts) {
|
|
9050
9328
|
const rootInfo = await resolveRoots();
|
|
9051
9329
|
if (!rootInfo.isGitRepo || !rootInfo.isLinkedWorktree) {
|
|
@@ -9056,7 +9334,7 @@ async function runHydrateWorktree(opts) {
|
|
|
9056
9334
|
}
|
|
9057
9335
|
const workspaceRoot = rootInfo.mainRoot;
|
|
9058
9336
|
const wtPath = rootInfo.repoRoot;
|
|
9059
|
-
if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${
|
|
9337
|
+
if (!wtPath.startsWith(`${worktreesDir(workspaceRoot)}${path60.sep}`)) {
|
|
9060
9338
|
return {
|
|
9061
9339
|
ok: false,
|
|
9062
9340
|
error: "Current worktree is not one of this workspace's .claude/worktrees/<slug> checkouts."
|
|
@@ -9068,13 +9346,13 @@ async function runHydrateWorktree(opts) {
|
|
|
9068
9346
|
return { ok: false, error: `Unknown --only repo(s): ${unknown.join(", ")}` };
|
|
9069
9347
|
}
|
|
9070
9348
|
const state = await readWorktreeState(wtPath);
|
|
9071
|
-
const slug = state?.slug ??
|
|
9349
|
+
const slug = state?.slug ?? path60.basename(wtPath);
|
|
9072
9350
|
const detectedBranch = await currentBranch(wtPath);
|
|
9073
9351
|
const targetBranch = state?.branch ?? (detectedBranch !== "HEAD" ? detectedBranch : void 0) ?? slug;
|
|
9074
9352
|
if (opts.dryRun) {
|
|
9075
9353
|
const results2 = await Promise.all(
|
|
9076
9354
|
selected.map(async (member) => {
|
|
9077
|
-
const memberPath =
|
|
9355
|
+
const memberPath = path60.join(wtPath, member.folder);
|
|
9078
9356
|
if (existsSync6(memberPath)) {
|
|
9079
9357
|
return { member: member.id, folder: member.folder, status: "planned" };
|
|
9080
9358
|
}
|
|
@@ -9095,7 +9373,7 @@ async function runHydrateWorktree(opts) {
|
|
|
9095
9373
|
const results = await mapWithConcurrency(
|
|
9096
9374
|
selected,
|
|
9097
9375
|
async (member) => {
|
|
9098
|
-
const memberPath =
|
|
9376
|
+
const memberPath = path60.join(wtPath, member.folder);
|
|
9099
9377
|
let branchAction;
|
|
9100
9378
|
if (!existsSync6(memberPath)) {
|
|
9101
9379
|
if (!opts.materialize) {
|
|
@@ -9158,26 +9436,26 @@ async function runHydrateWorktree(opts) {
|
|
|
9158
9436
|
}
|
|
9159
9437
|
|
|
9160
9438
|
// src/workspace/worktree/list.ts
|
|
9161
|
-
import
|
|
9162
|
-
import
|
|
9439
|
+
import path61 from "node:path";
|
|
9440
|
+
import fs42 from "fs-extra";
|
|
9163
9441
|
async function runList2() {
|
|
9164
9442
|
const resolved = await resolveWorkspaceForWorktree();
|
|
9165
9443
|
if (!resolved.ok) return { ok: false, error: resolved.reason };
|
|
9166
9444
|
const { rootInfo, workspaceRoot } = resolved;
|
|
9167
9445
|
const dir = worktreesDir(workspaceRoot);
|
|
9168
|
-
if (!await
|
|
9169
|
-
const entries = await
|
|
9446
|
+
if (!await fs42.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
|
|
9447
|
+
const entries = await fs42.readdir(dir, { withFileTypes: true });
|
|
9170
9448
|
const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
9171
9449
|
const configuredMembers = await readMembers(rootInfo);
|
|
9172
9450
|
const worktrees = [];
|
|
9173
9451
|
for (const slug of slugs) {
|
|
9174
|
-
const wtPath =
|
|
9452
|
+
const wtPath = path61.join(dir, slug);
|
|
9175
9453
|
const state = await readWorktreeState(wtPath);
|
|
9176
9454
|
const members = [];
|
|
9177
9455
|
const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
|
|
9178
9456
|
for (const m of source) {
|
|
9179
|
-
const memberPath =
|
|
9180
|
-
const materialized = await
|
|
9457
|
+
const memberPath = path61.join(wtPath, m.folder);
|
|
9458
|
+
const materialized = await fs42.pathExists(memberPath);
|
|
9181
9459
|
const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
|
|
9182
9460
|
members.push({
|
|
9183
9461
|
id: m.id,
|
|
@@ -9198,14 +9476,14 @@ async function runList2() {
|
|
|
9198
9476
|
}
|
|
9199
9477
|
|
|
9200
9478
|
// src/workspace/worktree/remove.ts
|
|
9201
|
-
import
|
|
9202
|
-
import
|
|
9479
|
+
import path62 from "node:path";
|
|
9480
|
+
import fs43 from "fs-extra";
|
|
9203
9481
|
async function runRemove(opts) {
|
|
9204
9482
|
const resolved = await resolveWorkspaceForWorktree();
|
|
9205
9483
|
if (!resolved.ok) return { ok: false, error: resolved.reason };
|
|
9206
9484
|
const { rootInfo, workspaceRoot } = resolved;
|
|
9207
9485
|
const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
|
|
9208
|
-
if (!await
|
|
9486
|
+
if (!await fs43.pathExists(wsWorktreePath)) {
|
|
9209
9487
|
return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
|
|
9210
9488
|
}
|
|
9211
9489
|
const state = await readWorktreeState(wsWorktreePath);
|
|
@@ -9225,12 +9503,12 @@ async function runRemove(opts) {
|
|
|
9225
9503
|
targets.push({
|
|
9226
9504
|
repo: configMember?.id ?? stateMember.id,
|
|
9227
9505
|
ownerRepo,
|
|
9228
|
-
path:
|
|
9506
|
+
path: path62.join(wsWorktreePath, stateMember.folder)
|
|
9229
9507
|
});
|
|
9230
9508
|
}
|
|
9231
9509
|
const existingTargets = [];
|
|
9232
9510
|
for (const target of targets) {
|
|
9233
|
-
if (await
|
|
9511
|
+
if (await fs43.pathExists(target.path)) existingTargets.push(target);
|
|
9234
9512
|
}
|
|
9235
9513
|
if (!opts.force) {
|
|
9236
9514
|
const blockers = [];
|
|
@@ -9275,14 +9553,19 @@ async function runRemove(opts) {
|
|
|
9275
9553
|
await pruneWorktrees(target.ownerRepo);
|
|
9276
9554
|
}
|
|
9277
9555
|
const wsTarget = existingTargets.find((t) => t.repo === "(workspace)");
|
|
9556
|
+
let wsRemoveOk = false;
|
|
9278
9557
|
if (wsTarget) {
|
|
9279
9558
|
const wsResult = await removeWorktree(workspaceRoot, wsWorktreePath);
|
|
9280
|
-
if (wsResult.ok)
|
|
9281
|
-
|
|
9559
|
+
if (wsResult.ok) {
|
|
9560
|
+
removed.push("(workspace)");
|
|
9561
|
+
wsRemoveOk = true;
|
|
9562
|
+
} else {
|
|
9563
|
+
failed.push({ repo: "(workspace)", error: wsResult.stderr ?? "unknown error" });
|
|
9564
|
+
}
|
|
9282
9565
|
await pruneWorktrees(workspaceRoot);
|
|
9283
9566
|
}
|
|
9284
|
-
if (await
|
|
9285
|
-
await
|
|
9567
|
+
if (wsRemoveOk && await fs43.pathExists(wsWorktreePath)) {
|
|
9568
|
+
await fs43.remove(wsWorktreePath);
|
|
9286
9569
|
}
|
|
9287
9570
|
return { ok: failed.length === 0, dryRun: false, slug: opts.slug, removed, failed };
|
|
9288
9571
|
}
|
|
@@ -9497,7 +9780,7 @@ async function runWorktree(action, opts = {}) {
|
|
|
9497
9780
|
|
|
9498
9781
|
// src/commands/workspace.ts
|
|
9499
9782
|
import { existsSync as existsSync10, statSync as statSync3 } from "node:fs";
|
|
9500
|
-
import
|
|
9783
|
+
import path71 from "node:path";
|
|
9501
9784
|
|
|
9502
9785
|
// src/commands/workspace/aggregate.ts
|
|
9503
9786
|
async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
|
|
@@ -9552,18 +9835,18 @@ ${summaries.map(
|
|
|
9552
9835
|
|
|
9553
9836
|
// src/commands/workspace/doctor.ts
|
|
9554
9837
|
import { existsSync as existsSync7 } from "node:fs";
|
|
9555
|
-
import
|
|
9838
|
+
import path64 from "node:path";
|
|
9556
9839
|
|
|
9557
9840
|
// src/commands/workspace/manifest.ts
|
|
9558
9841
|
import { readFileSync as readFileSync4 } from "node:fs";
|
|
9559
|
-
import
|
|
9842
|
+
import path63 from "node:path";
|
|
9560
9843
|
var MANIFEST_FILE = "workspace.manifest.json";
|
|
9561
9844
|
function manifestPath(workspaceRoot) {
|
|
9562
9845
|
return hausPath(workspaceRoot, MANIFEST_FILE);
|
|
9563
9846
|
}
|
|
9564
9847
|
function hausVersion() {
|
|
9565
9848
|
try {
|
|
9566
|
-
const pkg = JSON.parse(readFileSync4(
|
|
9849
|
+
const pkg = JSON.parse(readFileSync4(path63.join(packageRoot(), "package.json"), "utf8"));
|
|
9567
9850
|
return pkg.version ?? "0.0.0";
|
|
9568
9851
|
} catch {
|
|
9569
9852
|
return "0.0.0";
|
|
@@ -9644,10 +9927,18 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
9644
9927
|
});
|
|
9645
9928
|
return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
|
|
9646
9929
|
}
|
|
9930
|
+
if (!Array.isArray(manifest.repos)) {
|
|
9931
|
+
flag({
|
|
9932
|
+
repo: "(workspace)",
|
|
9933
|
+
kind: "no-manifest",
|
|
9934
|
+
detail: 'workspace.manifest.json\'s "repos" field is not an array \u2014 run `haus workspace setup --write` to regenerate it.'
|
|
9935
|
+
});
|
|
9936
|
+
return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
|
|
9937
|
+
}
|
|
9647
9938
|
const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
|
|
9648
9939
|
const catalogRefByRepo = [];
|
|
9649
9940
|
for (const repo of config.repos) {
|
|
9650
|
-
const repoRoot =
|
|
9941
|
+
const repoRoot = path64.resolve(workspaceRoot, repo.path);
|
|
9651
9942
|
const entry = manifestByName.get(repo.name);
|
|
9652
9943
|
if (!entry) {
|
|
9653
9944
|
flag({
|
|
@@ -9720,7 +10011,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
9720
10011
|
});
|
|
9721
10012
|
continue;
|
|
9722
10013
|
}
|
|
9723
|
-
if (!existsSync7(
|
|
10014
|
+
if (!existsSync7(path64.join(workspaceRoot, entry.path))) {
|
|
9724
10015
|
flag({
|
|
9725
10016
|
repo: entry.repo,
|
|
9726
10017
|
kind: "missing-linked-context-copy",
|
|
@@ -9763,16 +10054,16 @@ function emit(args) {
|
|
|
9763
10054
|
}
|
|
9764
10055
|
|
|
9765
10056
|
// src/workspace/link-context/link.ts
|
|
10057
|
+
import path67 from "node:path";
|
|
10058
|
+
import fs46 from "fs-extra";
|
|
10059
|
+
|
|
10060
|
+
// src/workspace/link-context/plan.ts
|
|
9766
10061
|
import path66 from "node:path";
|
|
9767
10062
|
import fs45 from "fs-extra";
|
|
9768
10063
|
|
|
9769
|
-
// src/workspace/link-context/
|
|
10064
|
+
// src/workspace/link-context/scan-source.ts
|
|
9770
10065
|
import path65 from "node:path";
|
|
9771
10066
|
import fs44 from "fs-extra";
|
|
9772
|
-
|
|
9773
|
-
// src/workspace/link-context/scan-source.ts
|
|
9774
|
-
import path64 from "node:path";
|
|
9775
|
-
import fs43 from "fs-extra";
|
|
9776
10067
|
var ASSET_SUBDIR = {
|
|
9777
10068
|
skill: "skills",
|
|
9778
10069
|
agent: "agents",
|
|
@@ -9780,36 +10071,36 @@ var ASSET_SUBDIR = {
|
|
|
9780
10071
|
};
|
|
9781
10072
|
async function hasLinkableContext(member) {
|
|
9782
10073
|
for (const subdir of Object.values(ASSET_SUBDIR)) {
|
|
9783
|
-
if (await
|
|
10074
|
+
if (await fs44.pathExists(path65.join(member.absPath, ".claude", subdir))) return true;
|
|
9784
10075
|
}
|
|
9785
10076
|
return false;
|
|
9786
10077
|
}
|
|
9787
10078
|
async function collectSkills(member) {
|
|
9788
|
-
const dir =
|
|
9789
|
-
if (!await
|
|
9790
|
-
const names = await
|
|
10079
|
+
const dir = path65.join(member.absPath, ".claude", "skills");
|
|
10080
|
+
if (!await fs44.pathExists(dir)) return [];
|
|
10081
|
+
const names = await fs44.readdir(dir);
|
|
9791
10082
|
const out = [];
|
|
9792
10083
|
for (const name of [...names].sort()) {
|
|
9793
|
-
const abs =
|
|
9794
|
-
const stat = await
|
|
10084
|
+
const abs = path65.join(dir, name);
|
|
10085
|
+
const stat = await fs44.lstat(abs);
|
|
9795
10086
|
if (!stat.isDirectory()) continue;
|
|
9796
|
-
if (!await
|
|
9797
|
-
out.push({ type: "skill", name, sourceRelPath:
|
|
10087
|
+
if (!await fs44.pathExists(path65.join(abs, "SKILL.md"))) continue;
|
|
10088
|
+
out.push({ type: "skill", name, sourceRelPath: path65.posix.join(".claude", "skills", name) });
|
|
9798
10089
|
}
|
|
9799
10090
|
return out;
|
|
9800
10091
|
}
|
|
9801
10092
|
async function collectFlatMarkdown(member, type) {
|
|
9802
10093
|
const subdir = ASSET_SUBDIR[type];
|
|
9803
|
-
const dir =
|
|
9804
|
-
if (!await
|
|
9805
|
-
const names = await
|
|
10094
|
+
const dir = path65.join(member.absPath, ".claude", subdir);
|
|
10095
|
+
if (!await fs44.pathExists(dir)) return [];
|
|
10096
|
+
const names = await fs44.readdir(dir);
|
|
9806
10097
|
const out = [];
|
|
9807
10098
|
for (const fileName of [...names].sort()) {
|
|
9808
10099
|
if (!fileName.endsWith(".md")) continue;
|
|
9809
|
-
const abs =
|
|
9810
|
-
if (!(await
|
|
10100
|
+
const abs = path65.join(dir, fileName);
|
|
10101
|
+
if (!(await fs44.lstat(abs)).isFile()) continue;
|
|
9811
10102
|
const name = fileName.slice(0, -".md".length);
|
|
9812
|
-
out.push({ type, name, sourceRelPath:
|
|
10103
|
+
out.push({ type, name, sourceRelPath: path65.posix.join(".claude", subdir, fileName) });
|
|
9813
10104
|
}
|
|
9814
10105
|
return out;
|
|
9815
10106
|
}
|
|
@@ -9824,9 +10115,9 @@ async function collectSourceAssets(member) {
|
|
|
9824
10115
|
|
|
9825
10116
|
// src/workspace/link-context/plan.ts
|
|
9826
10117
|
function repoFolderPrefix(member) {
|
|
9827
|
-
const base =
|
|
10118
|
+
const base = path66.posix.basename(member.folder);
|
|
9828
10119
|
if (base && base !== "." && base !== "/") return base;
|
|
9829
|
-
const fromAbs =
|
|
10120
|
+
const fromAbs = path66.basename(member.absPath);
|
|
9830
10121
|
return fromAbs || member.id;
|
|
9831
10122
|
}
|
|
9832
10123
|
function assetKey(type, destKey) {
|
|
@@ -9841,7 +10132,7 @@ async function buildLinkPlan(members) {
|
|
|
9841
10132
|
const byKey = /* @__PURE__ */ new Map();
|
|
9842
10133
|
const membersById = new Map(members.map((m) => [m.id, m]));
|
|
9843
10134
|
for (const member of members) {
|
|
9844
|
-
if (!await
|
|
10135
|
+
if (!await fs45.pathExists(member.absPath)) {
|
|
9845
10136
|
skipped.push({ repo: member.id, reason: `not cloned locally (${member.absPath}) \u2014 skipped` });
|
|
9846
10137
|
continue;
|
|
9847
10138
|
}
|
|
@@ -9883,7 +10174,7 @@ async function buildLinkPlan(members) {
|
|
|
9883
10174
|
name: asset.name,
|
|
9884
10175
|
destKey,
|
|
9885
10176
|
sourceRelPath: asset.sourceRelPath,
|
|
9886
|
-
sourceAbsPath:
|
|
10177
|
+
sourceAbsPath: path66.join(member.absPath, asset.sourceRelPath),
|
|
9887
10178
|
sourceHash
|
|
9888
10179
|
});
|
|
9889
10180
|
}
|
|
@@ -9894,7 +10185,7 @@ async function buildLinkPlan(members) {
|
|
|
9894
10185
|
function destRelPathFor(type, destKey) {
|
|
9895
10186
|
const subdir = `${type}s`;
|
|
9896
10187
|
const leaf = type === "skill" ? destKey : `${destKey}.md`;
|
|
9897
|
-
return
|
|
10188
|
+
return path67.posix.join(".claude", subdir, leaf);
|
|
9898
10189
|
}
|
|
9899
10190
|
async function runLinkContext(workspaceRoot, opts = {}) {
|
|
9900
10191
|
const write = opts.write ?? false;
|
|
@@ -9920,17 +10211,17 @@ async function runLinkContext(workspaceRoot, opts = {}) {
|
|
|
9920
10211
|
const linkedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
9921
10212
|
for (const entry of plan.entries) {
|
|
9922
10213
|
const destRel = destRelPathFor(entry.type, entry.destKey);
|
|
9923
|
-
const destAbs =
|
|
10214
|
+
const destAbs = path67.join(workspaceRoot, destRel);
|
|
9924
10215
|
if (write) {
|
|
9925
|
-
await
|
|
9926
|
-
if (await
|
|
9927
|
-
await
|
|
10216
|
+
await fs46.ensureDir(path67.dirname(destAbs));
|
|
10217
|
+
if (await fs46.pathExists(destAbs)) await fs46.remove(destAbs);
|
|
10218
|
+
await fs46.copy(entry.sourceAbsPath, destAbs, {
|
|
9928
10219
|
overwrite: true,
|
|
9929
10220
|
errorOnExist: false,
|
|
9930
10221
|
dereference: false,
|
|
9931
10222
|
filter: (s) => {
|
|
9932
10223
|
try {
|
|
9933
|
-
return !
|
|
10224
|
+
return !fs46.lstatSync(s).isSymbolicLink();
|
|
9934
10225
|
} catch {
|
|
9935
10226
|
return false;
|
|
9936
10227
|
}
|
|
@@ -9954,8 +10245,12 @@ async function runLinkContext(workspaceRoot, opts = {}) {
|
|
|
9954
10245
|
if (nextPaths.has(old.path)) continue;
|
|
9955
10246
|
removed.push(old.path);
|
|
9956
10247
|
if (write) {
|
|
9957
|
-
const abs =
|
|
9958
|
-
if (
|
|
10248
|
+
const abs = resolveContainedPath(workspaceRoot, old.path);
|
|
10249
|
+
if (abs === null) {
|
|
10250
|
+
warn(`Refusing to remove linked context path outside the workspace root: ${old.path}`);
|
|
10251
|
+
continue;
|
|
10252
|
+
}
|
|
10253
|
+
if (await fs46.pathExists(abs)) await fs46.remove(abs);
|
|
9959
10254
|
}
|
|
9960
10255
|
}
|
|
9961
10256
|
if (write) {
|
|
@@ -10007,11 +10302,12 @@ async function runWorkspaceLinkContext(workspaceRoot, opts = {}) {
|
|
|
10007
10302
|
|
|
10008
10303
|
// src/commands/workspace/setup.ts
|
|
10009
10304
|
import { existsSync as existsSync8, statSync } from "node:fs";
|
|
10010
|
-
import
|
|
10305
|
+
import path69 from "node:path";
|
|
10011
10306
|
|
|
10012
10307
|
// src/claude/write-workspace-claude-md.ts
|
|
10013
|
-
import
|
|
10014
|
-
import
|
|
10308
|
+
import path68 from "node:path";
|
|
10309
|
+
import fs47 from "fs-extra";
|
|
10310
|
+
var WORKSPACE_SENTINELS = { begin: WORKSPACE_BLOCK_BEGIN, end: WORKSPACE_BLOCK_END };
|
|
10015
10311
|
function buildWorkspaceImportBlock(client, members) {
|
|
10016
10312
|
const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
|
|
10017
10313
|
const body = [
|
|
@@ -10022,20 +10318,20 @@ function buildWorkspaceImportBlock(client, members) {
|
|
|
10022
10318
|
"Member repos:",
|
|
10023
10319
|
...memberLines
|
|
10024
10320
|
].join("\n");
|
|
10025
|
-
return `${
|
|
10321
|
+
return `${WORKSPACE_BLOCK_BEGIN}
|
|
10026
10322
|
${body}
|
|
10027
|
-
${
|
|
10323
|
+
${WORKSPACE_BLOCK_END}`;
|
|
10028
10324
|
}
|
|
10029
10325
|
async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
|
|
10030
10326
|
const block = buildWorkspaceImportBlock(opts.client, opts.members);
|
|
10031
10327
|
const dryRun = opts.dryRun ?? false;
|
|
10032
|
-
const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") :
|
|
10328
|
+
const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path68.join(workspaceRoot, "CLAUDE.md");
|
|
10033
10329
|
const say = opts.quiet ? () => {
|
|
10034
10330
|
} : log;
|
|
10035
|
-
const existed = await
|
|
10036
|
-
const prev = existed ? await
|
|
10331
|
+
const existed = await fs47.pathExists(filePath);
|
|
10332
|
+
const prev = existed ? await fs47.readFile(filePath, "utf8") : "";
|
|
10037
10333
|
const next = opts.collision ? `${block}
|
|
10038
|
-
` : injectHausBlock(prev, block);
|
|
10334
|
+
` : injectHausBlock(prev, block, WORKSPACE_SENTINELS);
|
|
10039
10335
|
const printable = displayPath(workspaceRoot, filePath);
|
|
10040
10336
|
if (dryRun) {
|
|
10041
10337
|
if (!existed) {
|
|
@@ -10060,20 +10356,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
|
|
|
10060
10356
|
|
|
10061
10357
|
// src/commands/workspace/setup.ts
|
|
10062
10358
|
function resolveWorkspaceRoot(start = process.cwd()) {
|
|
10063
|
-
let dir =
|
|
10359
|
+
let dir = path69.resolve(start);
|
|
10064
10360
|
for (; ; ) {
|
|
10065
|
-
if (existsSync8(
|
|
10066
|
-
const parent =
|
|
10067
|
-
if (parent === dir) return
|
|
10361
|
+
if (existsSync8(path69.join(dir, WORKSPACE_FILE))) return dir;
|
|
10362
|
+
const parent = path69.dirname(dir);
|
|
10363
|
+
if (parent === dir) return path69.resolve(start);
|
|
10068
10364
|
dir = parent;
|
|
10069
10365
|
}
|
|
10070
10366
|
}
|
|
10071
10367
|
function isRootRepo(workspaceRoot, repoPath) {
|
|
10072
|
-
return
|
|
10368
|
+
return path69.resolve(workspaceRoot, repoPath) === path69.resolve(workspaceRoot);
|
|
10073
10369
|
}
|
|
10074
10370
|
async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
10075
10371
|
const apply = options.write ?? false;
|
|
10076
|
-
const configText = await readText(
|
|
10372
|
+
const configText = await readText(path69.join(workspaceRoot, WORKSPACE_FILE));
|
|
10077
10373
|
if (!configText) {
|
|
10078
10374
|
error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
|
|
10079
10375
|
process.exitCode = 1;
|
|
@@ -10100,7 +10396,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
10100
10396
|
const statuses = [];
|
|
10101
10397
|
const aggregateInputs = [];
|
|
10102
10398
|
for (const repo of repos) {
|
|
10103
|
-
const repoRoot =
|
|
10399
|
+
const repoRoot = path69.resolve(workspaceRoot, repo.path);
|
|
10104
10400
|
say(`
|
|
10105
10401
|
\u2192 ${repo.name} (${repo.path})`);
|
|
10106
10402
|
try {
|
|
@@ -10133,7 +10429,15 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
10133
10429
|
status: "failed",
|
|
10134
10430
|
error: message
|
|
10135
10431
|
});
|
|
10136
|
-
if (!options.continueOnError)
|
|
10432
|
+
if (!options.continueOnError) {
|
|
10433
|
+
if (options.json) {
|
|
10434
|
+
const result2 = { workspaceRoot, statuses, written: [] };
|
|
10435
|
+
log(JSON.stringify(result2, null, 2));
|
|
10436
|
+
process.exitCode = 1;
|
|
10437
|
+
return result2;
|
|
10438
|
+
}
|
|
10439
|
+
throw err;
|
|
10440
|
+
}
|
|
10137
10441
|
error(`Setup failed for ${repo.name}: ${message}`);
|
|
10138
10442
|
}
|
|
10139
10443
|
}
|
|
@@ -10147,6 +10451,17 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
10147
10451
|
config.relationships
|
|
10148
10452
|
);
|
|
10149
10453
|
written.push(...artifacts);
|
|
10454
|
+
} else {
|
|
10455
|
+
const sayAggregate = options.json ? () => {
|
|
10456
|
+
} : log;
|
|
10457
|
+
for (const rel of [
|
|
10458
|
+
"workspace-summary.json",
|
|
10459
|
+
"dependency-ownership-map.json",
|
|
10460
|
+
"cross-repo-summary.md",
|
|
10461
|
+
"workspace-context-map.json"
|
|
10462
|
+
]) {
|
|
10463
|
+
sayAggregate(`[dry-run] would write .haus-workflow/${rel}`);
|
|
10464
|
+
}
|
|
10150
10465
|
}
|
|
10151
10466
|
const docPath = await writeWorkspaceClaudeMd(workspaceRoot, {
|
|
10152
10467
|
client: config.client,
|
|
@@ -10171,7 +10486,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
10171
10486
|
const status = statusByName.get(repo.name);
|
|
10172
10487
|
const role = repo.role ?? status?.roles?.[0] ?? "auto";
|
|
10173
10488
|
if (status?.status === "ok") {
|
|
10174
|
-
const lock = await checkLock(
|
|
10489
|
+
const lock = await checkLock(path69.resolve(workspaceRoot, repo.path));
|
|
10175
10490
|
manifestRepos.push({
|
|
10176
10491
|
name: repo.name,
|
|
10177
10492
|
path: repo.path,
|
|
@@ -10227,7 +10542,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
10227
10542
|
try {
|
|
10228
10543
|
const linkResult = await runLinkContext(workspaceRoot, { write: true, json: options.json });
|
|
10229
10544
|
if (linkResult.ok) {
|
|
10230
|
-
written.push(...linkResult.added.map((rel) =>
|
|
10545
|
+
written.push(...linkResult.added.map((rel) => path69.join(workspaceRoot, rel)));
|
|
10231
10546
|
if (!options.json) {
|
|
10232
10547
|
say(
|
|
10233
10548
|
`Linked ${linkResult.linked.length} cross-repo skill/agent/command copy(ies) into .claude/ (${linkResult.added.length} new, ${linkResult.removed.length} removed)${linkResult.skipped.length > 0 ? ` \u2014 ${linkResult.skipped.length} member(s) skipped, see above` : ""}.`
|
|
@@ -10259,14 +10574,22 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
|
|
|
10259
10574
|
|
|
10260
10575
|
// src/commands/workspace/undo.ts
|
|
10261
10576
|
import { existsSync as existsSync9, statSync as statSync2 } from "node:fs";
|
|
10262
|
-
import
|
|
10263
|
-
import
|
|
10577
|
+
import path70 from "node:path";
|
|
10578
|
+
import fs48 from "fs-extra";
|
|
10264
10579
|
var WORKSPACE_AGGREGATE_FILES = [
|
|
10265
10580
|
"workspace-summary.json",
|
|
10266
10581
|
"dependency-ownership-map.json",
|
|
10267
10582
|
"cross-repo-summary.md",
|
|
10268
10583
|
"workspace-context-map.json"
|
|
10269
10584
|
];
|
|
10585
|
+
function hasLegacyWorkspaceBlock(content2) {
|
|
10586
|
+
const beginIdx = content2.indexOf(BLOCK_BEGIN);
|
|
10587
|
+
if (beginIdx < 0) return false;
|
|
10588
|
+
const endIdx = content2.indexOf(BLOCK_END, beginIdx + BLOCK_BEGIN.length);
|
|
10589
|
+
if (endIdx < 0) return false;
|
|
10590
|
+
const block = content2.slice(beginIdx, endIdx + BLOCK_END.length);
|
|
10591
|
+
return block.includes("@.haus-workflow/cross-repo-summary.md") && block.includes("Member repos:");
|
|
10592
|
+
}
|
|
10270
10593
|
async function runWorkspaceUndo(workspaceRoot, options = {}) {
|
|
10271
10594
|
const config = await readWorkspaceConfig(workspaceRoot);
|
|
10272
10595
|
if (!config) {
|
|
@@ -10288,7 +10611,7 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
|
|
|
10288
10611
|
}
|
|
10289
10612
|
let anyRepoFailed = false;
|
|
10290
10613
|
for (const repo of config.repos) {
|
|
10291
|
-
const repoRoot =
|
|
10614
|
+
const repoRoot = path70.resolve(workspaceRoot, repo.path);
|
|
10292
10615
|
log(`
|
|
10293
10616
|
\u2192 ${repo.name} (${repo.path})`);
|
|
10294
10617
|
try {
|
|
@@ -10303,41 +10626,59 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
|
|
|
10303
10626
|
}
|
|
10304
10627
|
const linkedContext = await readLinkedContext(workspaceRoot);
|
|
10305
10628
|
for (const entry of linkedContext) {
|
|
10306
|
-
const abs =
|
|
10307
|
-
if (
|
|
10308
|
-
|
|
10629
|
+
const abs = resolveContainedPath(workspaceRoot, entry.path);
|
|
10630
|
+
if (abs === null) {
|
|
10631
|
+
warn(`Refusing to remove linked context path outside the workspace root: ${entry.path}`);
|
|
10632
|
+
continue;
|
|
10633
|
+
}
|
|
10634
|
+
if (await fs48.pathExists(abs)) {
|
|
10635
|
+
await fs48.remove(abs);
|
|
10309
10636
|
log(`Removed ${entry.path} (linked ${entry.type} from ${entry.repo})`);
|
|
10310
10637
|
}
|
|
10311
10638
|
}
|
|
10312
10639
|
for (const rel of WORKSPACE_AGGREGATE_FILES) {
|
|
10313
10640
|
const abs = hausPath(workspaceRoot, rel);
|
|
10314
|
-
if (await
|
|
10315
|
-
await
|
|
10316
|
-
log(`Removed ${
|
|
10641
|
+
if (await fs48.pathExists(abs)) {
|
|
10642
|
+
await fs48.remove(abs);
|
|
10643
|
+
log(`Removed ${path70.relative(workspaceRoot, abs)}`);
|
|
10317
10644
|
}
|
|
10318
10645
|
}
|
|
10319
10646
|
const manifestFile = manifestPath(workspaceRoot);
|
|
10320
|
-
if (await
|
|
10321
|
-
await
|
|
10322
|
-
log(`Removed ${
|
|
10647
|
+
if (await fs48.pathExists(manifestFile)) {
|
|
10648
|
+
await fs48.remove(manifestFile);
|
|
10649
|
+
log(`Removed ${path70.relative(workspaceRoot, manifestFile)}`);
|
|
10323
10650
|
}
|
|
10324
10651
|
const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
|
|
10325
|
-
if (await
|
|
10326
|
-
await
|
|
10327
|
-
log(`Removed ${
|
|
10652
|
+
if (await fs48.pathExists(workspaceMdPath)) {
|
|
10653
|
+
await fs48.remove(workspaceMdPath);
|
|
10654
|
+
log(`Removed ${path70.relative(workspaceRoot, workspaceMdPath)}`);
|
|
10328
10655
|
} else {
|
|
10329
|
-
const claudeMdPath =
|
|
10656
|
+
const claudeMdPath = path70.join(workspaceRoot, "CLAUDE.md");
|
|
10330
10657
|
if (existsSync9(claudeMdPath)) {
|
|
10331
10658
|
const prev = await readText(claudeMdPath);
|
|
10332
|
-
if (prev?.includes(
|
|
10333
|
-
const next = stripHausBlock(prev
|
|
10659
|
+
if (prev?.includes(WORKSPACE_BLOCK_BEGIN)) {
|
|
10660
|
+
const next = stripHausBlock(prev, {
|
|
10661
|
+
begin: WORKSPACE_BLOCK_BEGIN,
|
|
10662
|
+
end: WORKSPACE_BLOCK_END
|
|
10663
|
+
});
|
|
10334
10664
|
if (next.length === 0) {
|
|
10335
|
-
await
|
|
10665
|
+
await fs48.remove(claudeMdPath);
|
|
10336
10666
|
log("Removed CLAUDE.md (only contained the workspace haus import block).");
|
|
10337
10667
|
} else {
|
|
10338
|
-
await
|
|
10668
|
+
await fs48.writeFile(claudeMdPath, next, "utf8");
|
|
10339
10669
|
log("Removed haus import block from CLAUDE.md (user content preserved).");
|
|
10340
10670
|
}
|
|
10671
|
+
} else if (prev && hasLegacyWorkspaceBlock(prev)) {
|
|
10672
|
+
const next = stripHausBlock(prev, { begin: BLOCK_BEGIN, end: BLOCK_END });
|
|
10673
|
+
if (next.length === 0) {
|
|
10674
|
+
await fs48.remove(claudeMdPath);
|
|
10675
|
+
log("Removed CLAUDE.md (only contained the pre-upgrade workspace haus import block).");
|
|
10676
|
+
} else {
|
|
10677
|
+
await fs48.writeFile(claudeMdPath, next, "utf8");
|
|
10678
|
+
log(
|
|
10679
|
+
"Removed pre-upgrade workspace haus import block from CLAUDE.md (user content preserved)."
|
|
10680
|
+
);
|
|
10681
|
+
}
|
|
10341
10682
|
}
|
|
10342
10683
|
}
|
|
10343
10684
|
}
|
|
@@ -10364,7 +10705,16 @@ function normalizeMaxDepth(maxDepth) {
|
|
|
10364
10705
|
warn(`Ignoring invalid --max-depth "${String(maxDepth)}"; using the default depth.`);
|
|
10365
10706
|
return void 0;
|
|
10366
10707
|
}
|
|
10367
|
-
async function initWorkspace() {
|
|
10708
|
+
async function initWorkspace(options = {}) {
|
|
10709
|
+
if (existsSync10(WORKSPACE_FILE) && !options.yes) {
|
|
10710
|
+
const ok = await confirm(
|
|
10711
|
+
`${WORKSPACE_FILE} already exists \u2014 overwrite with a fresh placeholder? Existing client/repos/relationships will be lost. Run \`haus workspace discover\` instead to merge in newly found repos.`
|
|
10712
|
+
);
|
|
10713
|
+
if (!ok) {
|
|
10714
|
+
log("Cancelled.");
|
|
10715
|
+
return;
|
|
10716
|
+
}
|
|
10717
|
+
}
|
|
10368
10718
|
await writeText(
|
|
10369
10719
|
WORKSPACE_FILE,
|
|
10370
10720
|
`client: unknown
|
|
@@ -10378,7 +10728,7 @@ relationships: []
|
|
|
10378
10728
|
log("Workspace initialized.");
|
|
10379
10729
|
}
|
|
10380
10730
|
async function scanWorkspace(workspaceRoot, opts) {
|
|
10381
|
-
const configText = await readText(
|
|
10731
|
+
const configText = await readText(path71.join(workspaceRoot, WORKSPACE_FILE));
|
|
10382
10732
|
if (!configText) {
|
|
10383
10733
|
error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
|
|
10384
10734
|
process.exitCode = 1;
|
|
@@ -10400,7 +10750,7 @@ async function scanWorkspace(workspaceRoot, opts) {
|
|
|
10400
10750
|
const inputs = [];
|
|
10401
10751
|
const failures = [];
|
|
10402
10752
|
for (const repo of config.repos) {
|
|
10403
|
-
const repoRoot =
|
|
10753
|
+
const repoRoot = path71.resolve(workspaceRoot, repo.path);
|
|
10404
10754
|
try {
|
|
10405
10755
|
if (!existsSync10(repoRoot) || !statSync3(repoRoot).isDirectory()) {
|
|
10406
10756
|
throw new Error("path is not a directory");
|
|
@@ -10426,7 +10776,7 @@ async function scanWorkspace(workspaceRoot, opts) {
|
|
|
10426
10776
|
}
|
|
10427
10777
|
async function runWorkspace(action, options = {}) {
|
|
10428
10778
|
if (action === "init") {
|
|
10429
|
-
await initWorkspace();
|
|
10779
|
+
await initWorkspace({ yes: options.yes });
|
|
10430
10780
|
return;
|
|
10431
10781
|
}
|
|
10432
10782
|
const workspaceRoot = resolveWorkspaceRoot();
|
|
@@ -10466,7 +10816,7 @@ async function runWorkspace(action, options = {}) {
|
|
|
10466
10816
|
// src/cli.ts
|
|
10467
10817
|
function cliVersion() {
|
|
10468
10818
|
try {
|
|
10469
|
-
const pkgPath =
|
|
10819
|
+
const pkgPath = path72.join(packageRoot(), "package.json");
|
|
10470
10820
|
const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
|
|
10471
10821
|
return pkg.version ?? "0.0.0";
|
|
10472
10822
|
} catch {
|
|
@@ -10476,7 +10826,7 @@ function cliVersion() {
|
|
|
10476
10826
|
var program = new Command();
|
|
10477
10827
|
function validateRuntimeNodeVersion() {
|
|
10478
10828
|
try {
|
|
10479
|
-
const pkgPath =
|
|
10829
|
+
const pkgPath = path72.join(packageRoot(), "package.json");
|
|
10480
10830
|
const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
|
|
10481
10831
|
const requiredRange = pkg.engines?.node;
|
|
10482
10832
|
if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
|
|
@@ -10537,7 +10887,7 @@ program.command("install").option("--dry-run").option("--force").option("--check
|
|
|
10537
10887
|
program.command("scaffold [ids...]").description(
|
|
10538
10888
|
"Copy catalog config items (ESLint, Prettier) into the project root. Pass item IDs to scaffold specific items, or omit to scaffold all available config items."
|
|
10539
10889
|
).option("--force", "Overwrite existing files").option("--dry-run", "Preview what would be written without changing anything").option("--root <path>", "Project root (defaults to cwd)", process.cwd()).action(runScaffold);
|
|
10540
|
-
program.command("uninstall").option("--force").action(runUninstallCommand);
|
|
10890
|
+
program.command("uninstall").option("--force").option("-y, --yes", "Skip confirmation").action(runUninstallCommand);
|
|
10541
10891
|
var guard = program.command("guard");
|
|
10542
10892
|
guard.command("file-access").option("--from-hook").action((opts) => runGuard("file-access", opts));
|
|
10543
10893
|
guard.command("bash").option("--from-hook").action((opts) => runGuard("bash", opts));
|
|
@@ -10554,7 +10904,7 @@ function registerDecisionsCommand(name) {
|
|
|
10554
10904
|
registerDecisionsCommand("decisions");
|
|
10555
10905
|
registerDecisionsCommand("adr");
|
|
10556
10906
|
var workspace = program.command("workspace");
|
|
10557
|
-
workspace.command("init").action(() => runWorkspace("init"));
|
|
10907
|
+
workspace.command("init").option("-y, --yes", "Skip confirmation when haus.workspace.yaml already exists").action((opts) => runWorkspace("init", opts));
|
|
10558
10908
|
workspace.command("discover").description("Auto-find member repos and write/merge haus.workspace.yaml").option("--write", "Persist haus.workspace.yaml (default previews only)").option("--json", "Output the discovered repos and proposed config as JSON").option("--max-depth <n>", "Max directory depth to traverse (default 3)").option("--client <name>", "Set the workspace client name").action((opts) => runWorkspace("discover", opts));
|
|
10559
10909
|
workspace.command("scan").description("Aggregate a cross-repo summary from a fast scan of each repo").option("--json", "Output the written artifact paths as JSON").action((opts) => runWorkspace("scan", opts));
|
|
10560
10910
|
workspace.command("setup").description("Per-repo setup loop + workspace layer + manifest").option("--write", "Apply changes (default previews only)").option("--dry-run", "Preview changes without writing").option("--json", "Emit machine-readable per-repo output").option("--continue-on-error", "Keep going past a failed repo (default fail-fast)").option("--only <names>", "Restrict to comma-separated repo names").action((opts) => runWorkspace("setup", opts));
|