@haus-tech/haus-workflow 6.4.0 → 6.5.0
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 +12 -0
- package/dist/cli.js +1179 -599
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -509,9 +509,12 @@ async function writeJson(file, value, opts = {}) {
|
|
|
509
509
|
}
|
|
510
510
|
async function pruneEmptyDir(dir) {
|
|
511
511
|
try {
|
|
512
|
-
|
|
513
|
-
if (
|
|
512
|
+
if (!(await fs2.lstat(dir)).isDirectory()) return false;
|
|
513
|
+
if ((await fs2.readdir(dir)).length > 0) return false;
|
|
514
|
+
await fs2.remove(dir);
|
|
515
|
+
return true;
|
|
514
516
|
} catch {
|
|
517
|
+
return false;
|
|
515
518
|
}
|
|
516
519
|
}
|
|
517
520
|
async function readText(file) {
|
|
@@ -715,7 +718,7 @@ function checkManifestFormat(value, sourceLabel) {
|
|
|
715
718
|
return {
|
|
716
719
|
ok: false,
|
|
717
720
|
kind: "invalid",
|
|
718
|
-
error: `${sourceLabel} declares an invalid manifestFormat (${JSON.stringify(value)})
|
|
721
|
+
error: `${sourceLabel} declares an invalid manifestFormat (${JSON.stringify(value)}): expected a positive integer.`
|
|
719
722
|
};
|
|
720
723
|
}
|
|
721
724
|
if (value > MAX_SUPPORTED_MANIFEST_FORMAT) {
|
|
@@ -941,7 +944,7 @@ function formatGithubRateLimitMessage(info) {
|
|
|
941
944
|
"Fix: export GITHUB_TOKEN=$(gh auth token) # or HAUS_GITHUB_TOKEN=<pat>",
|
|
942
945
|
" # also: gh auth login if gh isn't logged in",
|
|
943
946
|
"Then: retry the command (e.g. haus update)",
|
|
944
|
-
`Or wait until ${when} and retry once (token still recommended
|
|
947
|
+
`Or wait until ${when} and retry once (token still recommended: waiting alone can re-hit the limit).`
|
|
945
948
|
].join("\n");
|
|
946
949
|
}
|
|
947
950
|
var hit;
|
|
@@ -980,7 +983,7 @@ function formatPrivateCatalogMessage(info) {
|
|
|
980
983
|
return [
|
|
981
984
|
`The catalog could not be read (HTTP ${info.status}) because haus is not signed in to GitHub.`,
|
|
982
985
|
"The catalog lives in a private repository, and GitHub answers a signed-out read of",
|
|
983
|
-
"a private repository with 404
|
|
986
|
+
"a private repository with 404, so this is an access problem, not a missing file.",
|
|
984
987
|
"Fix: gh auth login # sign in once, then retry",
|
|
985
988
|
" export GITHUB_TOKEN=$(gh auth token) # or HAUS_GITHUB_TOKEN=<pat>",
|
|
986
989
|
"Then: retry the command (e.g. haus update)"
|
|
@@ -1084,15 +1087,15 @@ function describeCatalogPin(pin, latestTag) {
|
|
|
1084
1087
|
if (pin.kind === "invalid") {
|
|
1085
1088
|
return {
|
|
1086
1089
|
level: "advisory",
|
|
1087
|
-
line: `- CATALOG PIN: ${PROJECT_CONFIG_REL} ${pin.reason}
|
|
1090
|
+
line: `- CATALOG PIN: ${PROJECT_CONFIG_REL} ${pin.reason}, the pin is ignored`,
|
|
1088
1091
|
sentence: `${PROJECT_CONFIG_REL} cannot be read as a catalog pin`,
|
|
1089
|
-
fix: 'fix or remove it
|
|
1092
|
+
fix: 'fix or remove it: it must be JSON shaped { "catalogRef": "v3.2.0" }'
|
|
1090
1093
|
};
|
|
1091
1094
|
}
|
|
1092
1095
|
const behind = latestTag !== null && isCatalogRefOlderThan(pin.ref, latestTag);
|
|
1093
1096
|
return {
|
|
1094
1097
|
level: "advisory",
|
|
1095
|
-
line: behind ? `- CATALOG PIN: ${pin.ref} (from ${PROJECT_CONFIG_REL})
|
|
1098
|
+
line: behind ? `- CATALOG PIN: ${pin.ref} (from ${PROJECT_CONFIG_REL}), latest release is ${latestTag}` : `- CATALOG PIN: ${pin.ref} (from ${PROJECT_CONFIG_REL})`,
|
|
1096
1099
|
sentence: behind ? `The catalog is pinned to ${pin.ref}; ${latestTag} is available` : `The catalog is pinned to ${pin.ref}`,
|
|
1097
1100
|
fix: `remove "catalogRef" from ${PROJECT_CONFIG_REL} to track the latest release`
|
|
1098
1101
|
};
|
|
@@ -1142,7 +1145,7 @@ function getResolvedCatalogRef() {
|
|
|
1142
1145
|
const resolved = cachedCatalogRef?.ref ?? process.env["HAUS_CATALOG_REF"] ?? getBundledCatalogRef();
|
|
1143
1146
|
if (!resolved) {
|
|
1144
1147
|
warn(
|
|
1145
|
-
"Could not determine catalog ref from cache, env, or bundled snapshot
|
|
1148
|
+
"Could not determine catalog ref from cache, env, or bundled snapshot, falling back to main (moving target)."
|
|
1146
1149
|
);
|
|
1147
1150
|
return "main";
|
|
1148
1151
|
}
|
|
@@ -1196,7 +1199,7 @@ async function resolveCatalogRefWithSource(opts) {
|
|
|
1196
1199
|
if (env["HAUS_CATALOG_REF"]) {
|
|
1197
1200
|
if (env["HAUS_CATALOG_REF"] === "main") {
|
|
1198
1201
|
warn(
|
|
1199
|
-
"HAUS_CATALOG_REF=main is set
|
|
1202
|
+
"HAUS_CATALOG_REF=main is set, fetching from the moving main branch. Pin to a release tag for reproducible installs."
|
|
1200
1203
|
);
|
|
1201
1204
|
}
|
|
1202
1205
|
return { ref: env["HAUS_CATALOG_REF"], source: "env" };
|
|
@@ -1205,7 +1208,7 @@ async function resolveCatalogRefWithSource(opts) {
|
|
|
1205
1208
|
const pin = readProjectCatalogPin(opts.projectRoot);
|
|
1206
1209
|
if (pin.kind === "pinned") return { ref: pin.ref, source: "pin" };
|
|
1207
1210
|
if (pin.kind === "invalid") {
|
|
1208
|
-
warn(`${PROJECT_CONFIG_REL} ${pin.reason}
|
|
1211
|
+
warn(`${PROJECT_CONFIG_REL} ${pin.reason}, ignoring the pin and resolving normally.`);
|
|
1209
1212
|
}
|
|
1210
1213
|
}
|
|
1211
1214
|
const fetchLatest = opts?.fetchLatestTag ?? fetchLatestCatalogTag;
|
|
@@ -1352,7 +1355,7 @@ async function fetchGitHubRecursiveBlobPaths(ref) {
|
|
|
1352
1355
|
}
|
|
1353
1356
|
const tree = await treeRes.json();
|
|
1354
1357
|
if (tree.truncated) {
|
|
1355
|
-
warn("Catalog GitHub tree listing was truncated
|
|
1358
|
+
warn("Catalog GitHub tree listing was truncated, refusing partial cache sync");
|
|
1356
1359
|
return null;
|
|
1357
1360
|
}
|
|
1358
1361
|
return tree.tree.filter((e) => e.type === "blob").map((e) => e.path);
|
|
@@ -1619,7 +1622,7 @@ function parseManifest(json) {
|
|
|
1619
1622
|
kept.push(clause);
|
|
1620
1623
|
} else {
|
|
1621
1624
|
warnings.push(
|
|
1622
|
-
`${item.id}: dropping unrecognized requiresAny clause (catalog schema evolved ahead of this CLI version)
|
|
1625
|
+
`${item.id}: dropping unrecognized requiresAny clause (catalog schema evolved ahead of this CLI version): ${JSON.stringify(clause)}`
|
|
1623
1626
|
);
|
|
1624
1627
|
}
|
|
1625
1628
|
}
|
|
@@ -1921,7 +1924,7 @@ async function syncSuperpowersShared(base) {
|
|
|
1921
1924
|
async function syncOneItem(item, base) {
|
|
1922
1925
|
if (!KNOWN_ITEM_TYPES.has(item.type)) {
|
|
1923
1926
|
warn(
|
|
1924
|
-
`Skipping ${item.id}: type "${item.type}" is unknown to this haus version
|
|
1927
|
+
`Skipping ${item.id}: type "${item.type}" is unknown to this haus version. Upgrade to use it`
|
|
1925
1928
|
);
|
|
1926
1929
|
return "failed";
|
|
1927
1930
|
}
|
|
@@ -1971,7 +1974,7 @@ function buildCacheManifest(input2) {
|
|
|
1971
1974
|
}
|
|
1972
1975
|
async function syncRemoteCatalog(projectRoot) {
|
|
1973
1976
|
if (isOffline()) {
|
|
1974
|
-
warn("Offline
|
|
1977
|
+
warn("Offline: skipping catalog sync; using cached or bundled content.");
|
|
1975
1978
|
return { newItems: [], refreshed: [], unchanged: 0, failed: [], skipped: true };
|
|
1976
1979
|
}
|
|
1977
1980
|
_resetBlobPathCacheForNewSync();
|
|
@@ -1989,7 +1992,7 @@ async function syncRemoteCatalog(projectRoot) {
|
|
|
1989
1992
|
...getPrivateCatalogDenial() ? { accessDenied: getPrivateCatalogDenial() } : {}
|
|
1990
1993
|
};
|
|
1991
1994
|
}
|
|
1992
|
-
warn("Remote catalog fetch failed
|
|
1995
|
+
warn("Remote catalog fetch failed, using bundled catalog");
|
|
1993
1996
|
return {
|
|
1994
1997
|
newItems: [],
|
|
1995
1998
|
refreshed: [],
|
|
@@ -2013,7 +2016,7 @@ async function syncRemoteCatalog(projectRoot) {
|
|
|
2013
2016
|
// the tier `apply` actually loads from, and `loadCatalogContext` reads a missing
|
|
2014
2017
|
// field as format 1. Dropping it here would mean a synced cache always claims
|
|
2015
2018
|
// format 1 whatever the catalog declared, so the cache-tier half of the format
|
|
2016
|
-
// gate could never fire
|
|
2019
|
+
// gate could never fire, the remote fetch would be the only thing standing
|
|
2017
2020
|
// between a too-new catalog and an install.
|
|
2018
2021
|
//
|
|
2019
2022
|
// catalogRef binds this cache to the ref that populated it. The cache directory is
|
|
@@ -2030,7 +2033,7 @@ async function syncRemoteCatalog(projectRoot) {
|
|
|
2030
2033
|
);
|
|
2031
2034
|
} catch (err) {
|
|
2032
2035
|
warn(
|
|
2033
|
-
`Catalog cache not writable (${cacheDir})
|
|
2036
|
+
`Catalog cache not writable (${cacheDir}), skipping cache sync: ${err instanceof Error ? err.message : String(err)}`
|
|
2034
2037
|
);
|
|
2035
2038
|
return { newItems: [], refreshed: [], unchanged: 0, failed: [] };
|
|
2036
2039
|
}
|
|
@@ -2115,15 +2118,46 @@ init_paths();
|
|
|
2115
2118
|
import path4 from "node:path";
|
|
2116
2119
|
|
|
2117
2120
|
// src/catalog/install-mapping.ts
|
|
2118
|
-
init_paths();
|
|
2119
|
-
init_path_safety();
|
|
2120
2121
|
import path3 from "node:path";
|
|
2122
|
+
|
|
2123
|
+
// src/claude/install-root.ts
|
|
2124
|
+
import { posix } from "node:path";
|
|
2121
2125
|
var FLATTEN_DIR_BY_TYPE = {
|
|
2122
2126
|
skill: "skills",
|
|
2123
2127
|
agent: "agents",
|
|
2124
2128
|
template: "templates",
|
|
2125
2129
|
command: "commands"
|
|
2126
2130
|
};
|
|
2131
|
+
var INSTALL_ROOT_PREFIXES = [".claude/"];
|
|
2132
|
+
var NEVER_INSTALLED = {
|
|
2133
|
+
files: [
|
|
2134
|
+
".claude/settings.json",
|
|
2135
|
+
".claude/settings.local.json",
|
|
2136
|
+
".claude/CLAUDE.md",
|
|
2137
|
+
".claude/rules/haus.md"
|
|
2138
|
+
],
|
|
2139
|
+
subtrees: [".claude/worktrees", ".claude/rules/project", ".claude/agent-memory"]
|
|
2140
|
+
};
|
|
2141
|
+
function installTypeFolders() {
|
|
2142
|
+
return [.../* @__PURE__ */ new Set([...Object.values(FLATTEN_DIR_BY_TYPE), "rules"])].map(
|
|
2143
|
+
(folder) => `.claude/${folder}`
|
|
2144
|
+
);
|
|
2145
|
+
}
|
|
2146
|
+
function holdsPosix(a, b) {
|
|
2147
|
+
return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
2148
|
+
}
|
|
2149
|
+
function insideInstallRoot(recorded) {
|
|
2150
|
+
const rel = posix.normalize(recorded).replace(/\/+$/, "");
|
|
2151
|
+
if (!INSTALL_ROOT_PREFIXES.some((prefix) => rel.startsWith(prefix))) return false;
|
|
2152
|
+
const lower = rel.toLowerCase();
|
|
2153
|
+
const equalsAny = (entries) => entries.some((entry) => entry.toLowerCase() === lower);
|
|
2154
|
+
if (equalsAny(NEVER_INSTALLED.files) || equalsAny(installTypeFolders())) return false;
|
|
2155
|
+
return !NEVER_INSTALLED.subtrees.some((tree) => holdsPosix(lower, tree.toLowerCase()));
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
// src/catalog/install-mapping.ts
|
|
2159
|
+
init_paths();
|
|
2160
|
+
init_path_safety();
|
|
2127
2161
|
var MIRRORED_TYPES = /* @__PURE__ */ new Set(["rule", "asset"]);
|
|
2128
2162
|
function upstreamRelative(itemPath) {
|
|
2129
2163
|
const match = itemPath.replace(/\\/g, "/").match(/^sources\/[^/]+\/(.+)$/);
|
|
@@ -2144,7 +2178,7 @@ function installTargetFor(item) {
|
|
|
2144
2178
|
return {
|
|
2145
2179
|
ok: false,
|
|
2146
2180
|
code: "unknown-type",
|
|
2147
|
-
reason: `type "${type}" is unknown to this haus version
|
|
2181
|
+
reason: `type "${type}" is unknown to this haus version. Upgrade the CLI to use it`
|
|
2148
2182
|
};
|
|
2149
2183
|
}
|
|
2150
2184
|
const itemPath = typeof item.path === "string" ? item.path : "";
|
|
@@ -2156,11 +2190,7 @@ function installTargetFor(item) {
|
|
|
2156
2190
|
};
|
|
2157
2191
|
}
|
|
2158
2192
|
if (flattenDir) {
|
|
2159
|
-
return {
|
|
2160
|
-
ok: true,
|
|
2161
|
-
layout: "flatten",
|
|
2162
|
-
relPath: `${flattenDir}/${path3.posix.basename(itemPath)}`
|
|
2163
|
-
};
|
|
2193
|
+
return admitted("flatten", `${flattenDir}/${path3.posix.basename(itemPath)}`);
|
|
2164
2194
|
}
|
|
2165
2195
|
const rel = upstreamRelative(itemPath);
|
|
2166
2196
|
if (rel === null) {
|
|
@@ -2177,7 +2207,18 @@ function installTargetFor(item) {
|
|
|
2177
2207
|
reason: `path "${itemPath}" escapes its own sources/<source>/ prefix`
|
|
2178
2208
|
};
|
|
2179
2209
|
}
|
|
2180
|
-
return
|
|
2210
|
+
return admitted("mirror", rel);
|
|
2211
|
+
}
|
|
2212
|
+
function admitted(layout, relPath) {
|
|
2213
|
+
const target = path3.posix.normalize(`.claude/${relPath}`);
|
|
2214
|
+
if (!insideInstallRoot(target)) {
|
|
2215
|
+
return {
|
|
2216
|
+
ok: false,
|
|
2217
|
+
code: "never-installed",
|
|
2218
|
+
reason: `install path "${target}" is one haus never installs to (only into an item's own path below .claude/), so haus leaves the item out; the catalog has to move the item`
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
return { ok: true, layout, relPath };
|
|
2181
2222
|
}
|
|
2182
2223
|
function installDestinationFor(root, item) {
|
|
2183
2224
|
const target = installTargetFor(item);
|
|
@@ -2587,9 +2628,22 @@ var EDITED_REASON = {
|
|
|
2587
2628
|
"no-hash": "has no lock hash",
|
|
2588
2629
|
modified: "was modified locally"
|
|
2589
2630
|
};
|
|
2590
|
-
|
|
2631
|
+
var REPLACE_LINK_RULES = { installRoot: false, linkedParent: false };
|
|
2632
|
+
async function realPathInsideRoot(root, abs) {
|
|
2633
|
+
const [realAbs, realRoot] = await Promise.all([fs4.realpath(abs), fs4.realpath(root)]);
|
|
2634
|
+
return insideReal(realAbs, realRoot);
|
|
2635
|
+
}
|
|
2636
|
+
function insideReal(realAbs, realRoot) {
|
|
2637
|
+
const prefix = realRoot.endsWith(path8.sep) ? realRoot : realRoot + path8.sep;
|
|
2638
|
+
return realAbs === realRoot || realAbs.startsWith(prefix);
|
|
2639
|
+
}
|
|
2640
|
+
async function unsafeTrackedPath(root, rel, rules = {}) {
|
|
2591
2641
|
const abs = resolveContainedPath(root, recordedRel(rel));
|
|
2592
2642
|
if (abs === null) return "escapes-root";
|
|
2643
|
+
if (rules.installRoot !== false) {
|
|
2644
|
+
const normalised = path8.relative(path8.resolve(root), abs).split(path8.sep).join("/");
|
|
2645
|
+
if (!insideInstallRoot(normalised)) return "outside-install-root";
|
|
2646
|
+
}
|
|
2593
2647
|
if (!await fs4.pathExists(abs)) {
|
|
2594
2648
|
try {
|
|
2595
2649
|
if ((await fs4.lstat(abs)).isSymbolicLink()) return "symlink";
|
|
@@ -2599,9 +2653,16 @@ async function unsafeTrackedPath(root, rel) {
|
|
|
2599
2653
|
return null;
|
|
2600
2654
|
}
|
|
2601
2655
|
if ((await fs4.lstat(abs)).isSymbolicLink()) return "symlink";
|
|
2602
|
-
const [realAbs, realRoot] = await Promise.all([
|
|
2603
|
-
|
|
2604
|
-
|
|
2656
|
+
const [realAbs, realRoot, realParent] = await Promise.all([
|
|
2657
|
+
fs4.realpath(abs),
|
|
2658
|
+
fs4.realpath(root),
|
|
2659
|
+
fs4.realpath(path8.dirname(abs))
|
|
2660
|
+
]);
|
|
2661
|
+
if (!insideReal(realAbs, realRoot)) return "symlink";
|
|
2662
|
+
if (rules.linkedParent !== false) {
|
|
2663
|
+
const lexicalParent = path8.relative(path8.resolve(root), path8.dirname(abs));
|
|
2664
|
+
if (realParent !== path8.join(realRoot, lexicalParent)) return "symlink";
|
|
2665
|
+
}
|
|
2605
2666
|
return null;
|
|
2606
2667
|
}
|
|
2607
2668
|
async function hasProtectedUnsafePath(root, relPaths, unsafeByRel) {
|
|
@@ -2609,14 +2670,39 @@ async function hasProtectedUnsafePath(root, relPaths, unsafeByRel) {
|
|
|
2609
2670
|
const unsafe = unsafeByRel.get(rel) ?? null;
|
|
2610
2671
|
if (unsafe === "symlink") return true;
|
|
2611
2672
|
if (unsafe === "escapes-root" && await fs4.pathExists(path8.resolve(root, rel))) return true;
|
|
2673
|
+
if (unsafe === "outside-install-root" && await lstatExists(path8.resolve(root, rel))) {
|
|
2674
|
+
return true;
|
|
2675
|
+
}
|
|
2612
2676
|
}
|
|
2613
2677
|
return false;
|
|
2614
2678
|
}
|
|
2615
|
-
async function
|
|
2679
|
+
async function lstatExists(abs) {
|
|
2680
|
+
try {
|
|
2681
|
+
await fs4.lstat(abs);
|
|
2682
|
+
return true;
|
|
2683
|
+
} catch {
|
|
2684
|
+
return false;
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
function unsafePathLine(id, rel, reason) {
|
|
2688
|
+
if (reason === "escapes-root") {
|
|
2689
|
+
return `Refusing to touch ${id}'s recorded path outside the project root: ${rel}`;
|
|
2690
|
+
}
|
|
2691
|
+
if (reason === "symlink")
|
|
2692
|
+
return `Refusing to touch ${id}'s recorded path through a symlink: ${rel}`;
|
|
2693
|
+
return `Refusing to touch ${id}'s recorded path ${rel}: haus never installs there (only into an item's own path below .claude/), so it leaves it. Delete the row from .haus-workflow/haus.lock.json by hand.`;
|
|
2694
|
+
}
|
|
2695
|
+
function holdsPath(target, held) {
|
|
2696
|
+
const a = path8.resolve(target);
|
|
2697
|
+
const b = path8.resolve(held);
|
|
2698
|
+
const below = (folder) => folder.endsWith(path8.sep) ? folder : `${folder}${path8.sep}`;
|
|
2699
|
+
return a === b || b.startsWith(below(a)) || a.startsWith(below(b));
|
|
2700
|
+
}
|
|
2701
|
+
async function unsafeReasonsFor(root, relPaths, rules = {}) {
|
|
2616
2702
|
const reasons = /* @__PURE__ */ new Map();
|
|
2617
2703
|
for (const rel of relPaths.map(recordedRel)) {
|
|
2618
2704
|
if (reasons.has(rel)) continue;
|
|
2619
|
-
reasons.set(rel, await unsafeTrackedPath(root, rel));
|
|
2705
|
+
reasons.set(rel, await unsafeTrackedPath(root, rel, rules));
|
|
2620
2706
|
}
|
|
2621
2707
|
return reasons;
|
|
2622
2708
|
}
|
|
@@ -2624,12 +2710,8 @@ async function resolveExistingTrackedPaths(root, itemId, relPaths, warnFn, unsaf
|
|
|
2624
2710
|
const existing = [];
|
|
2625
2711
|
for (const rel of new Set(relPaths.map(recordedRel))) {
|
|
2626
2712
|
const unsafe = unsafeByRel?.has(rel) ? unsafeByRel.get(rel) ?? null : await unsafeTrackedPath(root, rel);
|
|
2627
|
-
if (unsafe
|
|
2628
|
-
warnFn(
|
|
2629
|
-
continue;
|
|
2630
|
-
}
|
|
2631
|
-
if (unsafe === "symlink") {
|
|
2632
|
-
warnFn(`Refusing to touch ${itemId}'s recorded path through a symlink: ${rel}`);
|
|
2713
|
+
if (unsafe !== null) {
|
|
2714
|
+
warnFn(unsafePathLine(itemId, rel, unsafe));
|
|
2633
2715
|
continue;
|
|
2634
2716
|
}
|
|
2635
2717
|
const abs = path8.resolve(root, rel);
|
|
@@ -2790,7 +2872,7 @@ function recordedPathSet(rows2) {
|
|
|
2790
2872
|
}
|
|
2791
2873
|
async function recordedPathsOnDisk(root, relPaths) {
|
|
2792
2874
|
const rels = [...new Set(relPaths.map(recordedRel))];
|
|
2793
|
-
const unsafeByRel = await unsafeReasonsFor(root, rels);
|
|
2875
|
+
const unsafeByRel = await unsafeReasonsFor(root, rels, REPLACE_LINK_RULES);
|
|
2794
2876
|
let anyPresent = false;
|
|
2795
2877
|
const refused = [];
|
|
2796
2878
|
for (const rel of rels) {
|
|
@@ -2809,7 +2891,8 @@ async function recordedPathsOnDisk(root, relPaths) {
|
|
|
2809
2891
|
}
|
|
2810
2892
|
var UNSAFE_ON_DISK = {
|
|
2811
2893
|
symlink: "is a symlink (or resolves through one), which haus does not copy or replace",
|
|
2812
|
-
"escapes-root": "is outside the project root, which haus does not touch"
|
|
2894
|
+
"escapes-root": "is outside the project root, which haus does not touch",
|
|
2895
|
+
"outside-install-root": "is where haus never installs (only into an item's own path below .claude/), which haus does not copy or replace"
|
|
2813
2896
|
};
|
|
2814
2897
|
function unsafeOnDiskReason(rel, reason) {
|
|
2815
2898
|
return `${recordedRel(rel)} ${UNSAFE_ON_DISK[reason]}`;
|
|
@@ -3013,6 +3096,10 @@ function inUseLine(rel, id, holder, dryRun = false) {
|
|
|
3013
3096
|
const verb = dryRun ? "[dry-run] would keep (in use)" : "kept (in use)";
|
|
3014
3097
|
return `${verb} ${recordedRel(rel)} ${bracketedId(id)}: ${holder} also records it, so haus leaves it on disk.`;
|
|
3015
3098
|
}
|
|
3099
|
+
function excludedLine(rel, id, dryRun = false) {
|
|
3100
|
+
const verb = dryRun ? "[dry-run] would exclude" : "excluded";
|
|
3101
|
+
return `${verb} ${recordedRel(rel)} ${bracketedId(id)}: haus leaves the file as it is and reports nothing about it. Take ${id} out of excludedIds in .haus-workflow/haus.config.json to hand it back to haus.`;
|
|
3102
|
+
}
|
|
3016
3103
|
function underFileLine(paths, id, blocking, dryRun = false) {
|
|
3017
3104
|
const verb = dryRun ? "[dry-run] would refuse" : "refused";
|
|
3018
3105
|
return `${verb} ${posixPathsOnce(paths).join(", ")} ${bracketedId(id)}: ${recordedRel(blocking)} is a file where a folder belongs. haus left both as they were; move the file aside by hand and run again.`;
|
|
@@ -3381,7 +3468,7 @@ async function loadCatalogContext(root) {
|
|
|
3381
3468
|
const wantedRef = expectedCatalogRef(root);
|
|
3382
3469
|
if (!cacheRefMatches(cacheData.catalogRef, wantedRef)) {
|
|
3383
3470
|
warn(
|
|
3384
|
-
`Catalog cache was populated from ${String(cacheData.catalogRef)} but this project resolves to ${wantedRef}
|
|
3471
|
+
`Catalog cache was populated from ${String(cacheData.catalogRef)} but this project resolves to ${wantedRef}, ignoring the cache. Run \`haus update\` to re-sync it.`
|
|
3385
3472
|
);
|
|
3386
3473
|
} else {
|
|
3387
3474
|
return {
|
|
@@ -3530,7 +3617,7 @@ async function reconcileIndex(root, move, dryRun, notify) {
|
|
|
3530
3617
|
if (tracked.length === 0) return;
|
|
3531
3618
|
if (dryRun) {
|
|
3532
3619
|
notify(
|
|
3533
|
-
move.keepTracked ? `Would stage the move of ${move.from} to ${move.to} in git (${tracked.length} tracked file(s)).` : `Would untrack ${tracked.length} committed file(s) under ${move.from} from git
|
|
3620
|
+
move.keepTracked ? `Would stage the move of ${move.from} to ${move.to} in git (${tracked.length} tracked file(s)).` : `Would untrack ${tracked.length} committed file(s) under ${move.from} from git: request pricing material should never have been committed.`
|
|
3534
3621
|
);
|
|
3535
3622
|
return;
|
|
3536
3623
|
}
|
|
@@ -3543,12 +3630,12 @@ async function reconcileIndex(root, move, dryRun, notify) {
|
|
|
3543
3630
|
if (move.keepTracked) {
|
|
3544
3631
|
await runGitStep(root, ["add", "--", move.to], notify);
|
|
3545
3632
|
notify(
|
|
3546
|
-
`Staged the move of ${move.from} to ${move.to} in git
|
|
3633
|
+
`Staged the move of ${move.from} to ${move.to} in git: it is board configuration and stays tracked. Run \`git status\` to review before committing this change.`
|
|
3547
3634
|
);
|
|
3548
3635
|
return;
|
|
3549
3636
|
}
|
|
3550
3637
|
notify(
|
|
3551
|
-
`Untracked ${tracked.length} file(s) under ${move.from} from git
|
|
3638
|
+
`Untracked ${tracked.length} file(s) under ${move.from} from git: request pricing material should never have been committed, and \`${move.to}\` is gitignored from here on. Run \`git status\` to review before committing this change.`
|
|
3552
3639
|
);
|
|
3553
3640
|
}
|
|
3554
3641
|
async function removeEmptyLegacyDir(root, previewedMoves, dryRun, notify) {
|
|
@@ -3568,7 +3655,7 @@ async function removeEmptyLegacyDir(root, previewedMoves, dryRun, notify) {
|
|
|
3568
3655
|
notify(`Removed the now-empty ${LEGACY_REQUEST_DIR}/ directory.`);
|
|
3569
3656
|
} catch (error2) {
|
|
3570
3657
|
notify(
|
|
3571
|
-
`Could not remove the empty ${LEGACY_REQUEST_DIR}/ directory: ${error2 instanceof Error ? error2.message : String(error2)}. The request state was migrated successfully
|
|
3658
|
+
`Could not remove the empty ${LEGACY_REQUEST_DIR}/ directory: ${error2 instanceof Error ? error2.message : String(error2)}. The request state was migrated successfully. Delete ${LEGACY_REQUEST_DIR}/ by hand.`
|
|
3572
3659
|
);
|
|
3573
3660
|
}
|
|
3574
3661
|
}
|
|
@@ -3601,7 +3688,7 @@ async function migrateRequestState(root, dryRun, notify) {
|
|
|
3601
3688
|
if (await exists2(toAbs)) {
|
|
3602
3689
|
conflicts.push({ ...move, reason: "destination-exists" });
|
|
3603
3690
|
notify(
|
|
3604
|
-
`Refused to migrate ${move.from} to ${move.to}: ${move.to} already exists. Two locations hold request state
|
|
3691
|
+
`Refused to migrate ${move.from} to ${move.to}: ${move.to} already exists. Two locations hold request state. Reconcile them by hand, then remove ${move.from} so this migration can finish.`
|
|
3605
3692
|
);
|
|
3606
3693
|
continue;
|
|
3607
3694
|
}
|
|
@@ -3619,13 +3706,13 @@ async function migrateRequestState(root, dryRun, notify) {
|
|
|
3619
3706
|
const message2 = error2 instanceof Error ? error2.message : String(error2);
|
|
3620
3707
|
failures.push({ ...move, message: message2 });
|
|
3621
3708
|
notify(
|
|
3622
|
-
`Could not migrate ${move.from} to ${move.to}: ${message2}. ${move.from} was left in place
|
|
3709
|
+
`Could not migrate ${move.from} to ${move.to}: ${message2}. ${move.from} was left in place. Fix the cause and re-run \`haus apply --write\`.`
|
|
3623
3710
|
);
|
|
3624
3711
|
continue;
|
|
3625
3712
|
}
|
|
3626
3713
|
moved.push(move);
|
|
3627
3714
|
notify(
|
|
3628
|
-
`Moved ${move.from} to ${move.to}
|
|
3715
|
+
`Moved ${move.from} to ${move.to}: request state now lives under \`${HAUS_DIR}/\`, where haus's gitignore rules cover it (ADR-0103).`
|
|
3629
3716
|
);
|
|
3630
3717
|
if (trackedBefore.length > 0) await reconcileIndex(root, move, false, notify);
|
|
3631
3718
|
}
|
|
@@ -3716,7 +3803,7 @@ function resolveGlobalPlugins(manifest, deps = {}) {
|
|
|
3716
3803
|
const declared = manifest?.globalPlugins;
|
|
3717
3804
|
if (declared === void 0) return [...DEFAULT_GLOBAL_PLUGINS];
|
|
3718
3805
|
if (!Array.isArray(declared)) {
|
|
3719
|
-
warn2("Catalog `globalPlugins` is not an array
|
|
3806
|
+
warn2("Catalog `globalPlugins` is not an array, falling back to the bundled pin.");
|
|
3720
3807
|
return [...DEFAULT_GLOBAL_PLUGINS];
|
|
3721
3808
|
}
|
|
3722
3809
|
const pins = [];
|
|
@@ -3729,13 +3816,13 @@ function resolveGlobalPlugins(manifest, deps = {}) {
|
|
|
3729
3816
|
const activation = entry.activation ?? "always";
|
|
3730
3817
|
if (entry.ref !== void 0 && !isImmutableRef(entry.ref)) {
|
|
3731
3818
|
warn2(
|
|
3732
|
-
`Refusing catalog pin ${entry.id}: ref "${entry.ref}" is not a release tag
|
|
3819
|
+
`Refusing catalog pin ${entry.id}: ref "${entry.ref}" is not a release tag: haus only installs immutable, reviewed plugin content.`
|
|
3733
3820
|
);
|
|
3734
3821
|
continue;
|
|
3735
3822
|
}
|
|
3736
3823
|
if (activation === "project" && !(entry.requiresAny && entry.requiresAny.length > 0)) {
|
|
3737
3824
|
warn2(
|
|
3738
|
-
`Refusing catalog pin ${entry.id}: activation "project" needs a non-empty requiresAny
|
|
3825
|
+
`Refusing catalog pin ${entry.id}: activation "project" needs a non-empty requiresAny: without a signal no project could ever enable it.`
|
|
3739
3826
|
);
|
|
3740
3827
|
continue;
|
|
3741
3828
|
}
|
|
@@ -3825,7 +3912,7 @@ function planBuiltin(pin, state) {
|
|
|
3825
3912
|
pin,
|
|
3826
3913
|
kind: "marketplace-missing",
|
|
3827
3914
|
steps: [],
|
|
3828
|
-
reason: `built-in marketplace ${pin.marketplace} is not listed by this Claude Code
|
|
3915
|
+
reason: `built-in marketplace ${pin.marketplace} is not listed by this Claude Code, and haus does not add it`
|
|
3829
3916
|
};
|
|
3830
3917
|
}
|
|
3831
3918
|
const installed = findManagedInstall(state, pin);
|
|
@@ -3845,7 +3932,7 @@ function planBuiltin(pin, state) {
|
|
|
3845
3932
|
pin,
|
|
3846
3933
|
kind: "re-enable",
|
|
3847
3934
|
steps: ["plugin-enable"],
|
|
3848
|
-
reason: `plugin ${id} is disabled
|
|
3935
|
+
reason: `plugin ${id} is disabled, and haus workflows depend on it`
|
|
3849
3936
|
};
|
|
3850
3937
|
}
|
|
3851
3938
|
if (!wantEnabled && enabled) {
|
|
@@ -3853,7 +3940,7 @@ function planBuiltin(pin, state) {
|
|
|
3853
3940
|
pin,
|
|
3854
3941
|
kind: "disable-at-user-scope",
|
|
3855
3942
|
steps: ["plugin-disable"],
|
|
3856
|
-
reason: `plugin ${id} is enabled at ${MANAGED_SCOPE} scope
|
|
3943
|
+
reason: `plugin ${id} is enabled at ${MANAGED_SCOPE} scope, so it loads into every session instead of only the projects that use it`
|
|
3857
3944
|
};
|
|
3858
3945
|
}
|
|
3859
3946
|
return {
|
|
@@ -3905,7 +3992,7 @@ function planPinned(pin, state) {
|
|
|
3905
3992
|
pin,
|
|
3906
3993
|
kind: "re-enable",
|
|
3907
3994
|
steps: ["plugin-enable"],
|
|
3908
|
-
reason: `plugin ${pluginId(pin)} is disabled
|
|
3995
|
+
reason: `plugin ${pluginId(pin)} is disabled, and haus workflows depend on it`
|
|
3909
3996
|
};
|
|
3910
3997
|
}
|
|
3911
3998
|
return { pin, kind: "up-to-date", steps: [], reason: `pinned to ${ref}` };
|
|
@@ -4574,7 +4661,7 @@ async function readProjectConfigObject(root) {
|
|
|
4574
4661
|
} catch (err) {
|
|
4575
4662
|
if (err.code === "ENOENT") return null;
|
|
4576
4663
|
warn(
|
|
4577
|
-
`${PROJECT_CONFIG_REL} could not be read (${err instanceof Error ? err.message : String(err)})
|
|
4664
|
+
`${PROJECT_CONFIG_REL} could not be read (${err instanceof Error ? err.message : String(err)}), treating project intent as unset`
|
|
4578
4665
|
);
|
|
4579
4666
|
return null;
|
|
4580
4667
|
}
|
|
@@ -4713,7 +4800,7 @@ async function readProjectProfile(root) {
|
|
|
4713
4800
|
const { profile, malformed } = parseProfile(data?.profile);
|
|
4714
4801
|
if (malformed) {
|
|
4715
4802
|
warn(
|
|
4716
|
-
`${PROJECT_CONFIG_REL}: unknown profile ${JSON.stringify(data?.profile)}
|
|
4803
|
+
`${PROJECT_CONFIG_REL}: unknown profile ${JSON.stringify(data?.profile)}: expected one of ${PROFILE_IDS.join(", ")}; treating as ${DEFAULT_PROFILE}`
|
|
4717
4804
|
);
|
|
4718
4805
|
}
|
|
4719
4806
|
return profile;
|
|
@@ -4740,7 +4827,7 @@ function warnOnce(message2) {
|
|
|
4740
4827
|
function stringList(value, label) {
|
|
4741
4828
|
if (value === void 0) return [];
|
|
4742
4829
|
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) {
|
|
4743
|
-
warnOnce(`${label} is not a string array
|
|
4830
|
+
warnOnce(`${label} is not a string array, ignoring it`);
|
|
4744
4831
|
return [];
|
|
4745
4832
|
}
|
|
4746
4833
|
return value;
|
|
@@ -4755,7 +4842,7 @@ function parseProfileRules(raw, profile, knownIds) {
|
|
|
4755
4842
|
for (const key of Object.keys(clauses)) {
|
|
4756
4843
|
if (!KNOWN_CLAUSES.includes(key)) {
|
|
4757
4844
|
warnOnce(
|
|
4758
|
-
`profiles.${profile}.exclude.${key}: unknown clause (catalog schema evolved ahead of this CLI version)
|
|
4845
|
+
`profiles.${profile}.exclude.${key}: unknown clause (catalog schema evolved ahead of this CLI version), ignoring it`
|
|
4759
4846
|
);
|
|
4760
4847
|
}
|
|
4761
4848
|
}
|
|
@@ -4765,7 +4852,7 @@ function parseProfileRules(raw, profile, knownIds) {
|
|
|
4765
4852
|
).filter((id) => {
|
|
4766
4853
|
if (knownIds !== void 0 && !knownIds.has(id)) {
|
|
4767
4854
|
warnOnce(
|
|
4768
|
-
`profiles.${profile}.keepIds names an id the catalog does not have (${id})
|
|
4855
|
+
`profiles.${profile}.keepIds names an id the catalog does not have (${id}), ignoring it`
|
|
4769
4856
|
);
|
|
4770
4857
|
return false;
|
|
4771
4858
|
}
|
|
@@ -5451,8 +5538,8 @@ function computeDetectionStatus(roles, stacks, unsupportedSignals) {
|
|
|
5451
5538
|
}
|
|
5452
5539
|
function describeUnknownDetection(unsupportedSignals, isLinkedWorktree) {
|
|
5453
5540
|
const markers = unsupportedSignals.join(", ");
|
|
5454
|
-
const base = markers ? `Stack not recognised
|
|
5455
|
-
const worktreeNote = isLinkedWorktree ? "You're running from inside a git worktree
|
|
5541
|
+
const base = markers ? `Stack not recognised: detected ${markers}, which haus does not support.` : "Stack not recognised: no supported framework detected.";
|
|
5542
|
+
const worktreeNote = isLinkedWorktree ? "You're running from inside a git worktree: sibling repos may not be on disk here, so this could be a false zero-signal reading rather than truly no stack." : "";
|
|
5456
5543
|
return [base, worktreeNote, "Only stack-agnostic workflow and security guidance is applied."].filter(Boolean).join(" ");
|
|
5457
5544
|
}
|
|
5458
5545
|
function blocked(rel) {
|
|
@@ -6018,17 +6105,13 @@ function describeRequiresAny(clauses) {
|
|
|
6018
6105
|
function mergeRecommendationWarnings(context, isLinkedWorktree = false) {
|
|
6019
6106
|
const markers = context.unsupportedSignals?.join(", ");
|
|
6020
6107
|
const statusLines = context.detectionStatus === "unknown" ? [describeUnknownDetection(context.unsupportedSignals ?? [], isLinkedWorktree)] : context.detectionStatus === "partial" && markers ? [
|
|
6021
|
-
`Partially supported
|
|
6108
|
+
`Partially supported: found unsupported ${markers} alongside recognised stacks; guidance covers the supported parts only.`
|
|
6022
6109
|
] : [];
|
|
6023
6110
|
const riskLines = (context.securityRisks?.length ?? 0) > 0 ? [`Scan reported security signals: ${context.securityRisks.join("; ")}`] : [];
|
|
6024
6111
|
return [.../* @__PURE__ */ new Set([...statusLines, ...context.warnings, ...riskLines])];
|
|
6025
6112
|
}
|
|
6026
6113
|
|
|
6027
6114
|
// src/claude/item-admission.ts
|
|
6028
|
-
var CORE_MANAGED_CLAUDE_PATHS = /* @__PURE__ */ new Set(["settings.json", "rules/haus.md"]);
|
|
6029
|
-
function isCoreManagedClaudePath(relPath) {
|
|
6030
|
-
return CORE_MANAGED_CLAUDE_PATHS.has(relPath.toLowerCase());
|
|
6031
|
-
}
|
|
6032
6115
|
var RETIREMENT_REFUSALS = /* @__PURE__ */ new Set([
|
|
6033
6116
|
"deprecated",
|
|
6034
6117
|
"plugin-superseded"
|
|
@@ -6106,19 +6189,12 @@ async function computeAdmission({ root, contentRoot, manifestById, installPlugin
|
|
|
6106
6189
|
warning: `Skipping ${item.id}: ${placement.reason}`
|
|
6107
6190
|
};
|
|
6108
6191
|
}
|
|
6109
|
-
if (isCoreManagedClaudePath(placement.relPath)) {
|
|
6110
|
-
return {
|
|
6111
|
-
ok: false,
|
|
6112
|
-
refusal: "core-managed-path",
|
|
6113
|
-
warning: `Skipping ${item.id}: .claude/${placement.relPath} is written and owned by haus itself`
|
|
6114
|
-
};
|
|
6115
|
-
}
|
|
6116
6192
|
const sourcePath = catalogItemContentPath(contentRoot, manifestItem);
|
|
6117
6193
|
if (!await fs19.pathExists(sourcePath)) {
|
|
6118
6194
|
return {
|
|
6119
6195
|
ok: false,
|
|
6120
6196
|
refusal: "source-missing",
|
|
6121
|
-
warning: `Skipping ${item.id}: source not found at ${sourcePath}
|
|
6197
|
+
warning: `Skipping ${item.id}: source not found at ${sourcePath}. Run \`haus update\` to populate catalog cache`
|
|
6122
6198
|
};
|
|
6123
6199
|
}
|
|
6124
6200
|
return { ok: true, manifestItem, destination: placement.destination, sourcePath };
|
|
@@ -6140,7 +6216,7 @@ async function sourceValidationFailure(item, manifestItem, sourcePath) {
|
|
|
6140
6216
|
mdContent
|
|
6141
6217
|
);
|
|
6142
6218
|
if (!mdValidation.ok) {
|
|
6143
|
-
return `Skipping ${item.id}: pre-copy validation failed (${relFile})
|
|
6219
|
+
return `Skipping ${item.id}: pre-copy validation failed (${relFile}): ${mdValidation.reason}`;
|
|
6144
6220
|
}
|
|
6145
6221
|
}
|
|
6146
6222
|
return void 0;
|
|
@@ -6154,7 +6230,7 @@ async function sourceValidationFailure(item, manifestItem, sourcePath) {
|
|
|
6154
6230
|
},
|
|
6155
6231
|
await fs19.readFile(sourcePath, "utf8")
|
|
6156
6232
|
);
|
|
6157
|
-
return validation.ok ? void 0 : `Skipping ${item.id}: pre-copy validation failed
|
|
6233
|
+
return validation.ok ? void 0 : `Skipping ${item.id}: pre-copy validation failed: ${validation.reason}`;
|
|
6158
6234
|
}
|
|
6159
6235
|
function assertNeverRenameVerdict(verdict) {
|
|
6160
6236
|
throw new Error(`Unhandled RenameTargetVerdict: ${JSON.stringify(verdict)}`);
|
|
@@ -6194,8 +6270,6 @@ function describeAdmissionRefusal(refusal2) {
|
|
|
6194
6270
|
return "config items are not written into .claude/";
|
|
6195
6271
|
case "placement-refused":
|
|
6196
6272
|
return "haus cannot place it under .claude/";
|
|
6197
|
-
case "core-managed-path":
|
|
6198
|
-
return "its destination is a file haus writes itself";
|
|
6199
6273
|
case "source-missing":
|
|
6200
6274
|
return "its content is missing from the catalog cache";
|
|
6201
6275
|
}
|
|
@@ -6247,17 +6321,13 @@ async function holdsKnownStub(abs, stub) {
|
|
|
6247
6321
|
`, `${stub}\r
|
|
6248
6322
|
`].some((known) => found.equals(Buffer.from(known)));
|
|
6249
6323
|
}
|
|
6250
|
-
async function
|
|
6251
|
-
|
|
6252
|
-
|
|
6253
|
-
|
|
6254
|
-
|
|
6255
|
-
|
|
6256
|
-
} catch {
|
|
6257
|
-
return true;
|
|
6258
|
-
}
|
|
6324
|
+
async function keptForLinkedFolder(root, rel, unsafeByRel) {
|
|
6325
|
+
if (unsafeByRel.get(rel) !== "symlink") return false;
|
|
6326
|
+
try {
|
|
6327
|
+
return await realPathInsideRoot(root, path32.join(root, rel));
|
|
6328
|
+
} catch {
|
|
6329
|
+
return false;
|
|
6259
6330
|
}
|
|
6260
|
-
return false;
|
|
6261
6331
|
}
|
|
6262
6332
|
function legacyKeptLine(rel, kind) {
|
|
6263
6333
|
if (kind === "linked") {
|
|
@@ -6270,20 +6340,27 @@ function legacyKeptLine(rel, kind) {
|
|
|
6270
6340
|
}
|
|
6271
6341
|
var CI_GATE_COMMIT_HINT = 'Commit this with an [adr-skip] line in the commit body (e.g. "[adr-skip] haus moved its gate from CI to Lefthook, ADR-0161"): removing a workflow is a decision trigger.';
|
|
6272
6342
|
var CI_GATE_REL = CI_GATE_WORKFLOW_REL.split(path32.sep).join("/");
|
|
6343
|
+
var CI_GATE_RULES = { installRoot: false };
|
|
6273
6344
|
async function findCiGateWorkflow(root) {
|
|
6274
6345
|
if (await legacyFileState(path32.join(root, CI_GATE_REL)) !== "file") return void 0;
|
|
6275
|
-
const
|
|
6276
|
-
|
|
6277
|
-
|
|
6346
|
+
const unsafeByRel = await unsafeReasonsFor(root, [CI_GATE_REL], CI_GATE_RULES);
|
|
6347
|
+
const [tracked] = await resolveExistingTrackedPaths(
|
|
6348
|
+
root,
|
|
6349
|
+
"haus-gate",
|
|
6350
|
+
[CI_GATE_REL],
|
|
6351
|
+
() => {
|
|
6352
|
+
},
|
|
6353
|
+
unsafeByRel
|
|
6354
|
+
);
|
|
6355
|
+
const linked = tracked === void 0 && await keptForLinkedFolder(root, CI_GATE_REL, unsafeByRel);
|
|
6356
|
+
if (tracked === void 0 && !linked) return void 0;
|
|
6278
6357
|
let content2;
|
|
6279
6358
|
try {
|
|
6280
|
-
content2 = await fs20.readFile(tracked.
|
|
6359
|
+
content2 = await fs20.readFile(tracked?.abs ?? path32.join(root, CI_GATE_REL));
|
|
6281
6360
|
} catch {
|
|
6282
6361
|
return void 0;
|
|
6283
6362
|
}
|
|
6284
|
-
if (isKnownHausCiGate(content2)) {
|
|
6285
|
-
return await throughLinkedFolder(root, CI_GATE_REL) ? { kind: "linked" } : { kind: "known" };
|
|
6286
|
-
}
|
|
6363
|
+
if (isKnownHausCiGate(content2)) return linked ? { kind: "linked" } : { kind: "known" };
|
|
6287
6364
|
return isHausWrittenCiGate(content2.toString("utf8")) ? { kind: "edited" } : void 0;
|
|
6288
6365
|
}
|
|
6289
6366
|
async function findLegacyStubPaths(root) {
|
|
@@ -6291,10 +6368,13 @@ async function findLegacyStubPaths(root) {
|
|
|
6291
6368
|
for (const stub of LEGACY_STUBS) {
|
|
6292
6369
|
const state = await legacyFileState(path32.join(root, stub.rel));
|
|
6293
6370
|
if (state === "absent") continue;
|
|
6371
|
+
const unsafeByRel = state === "file" ? await unsafeReasonsFor(root, [stub.rel]) : /* @__PURE__ */ new Map();
|
|
6294
6372
|
const existing = state === "file" ? await resolveExistingTrackedPaths(root, stub.id, [stub.rel], () => {
|
|
6295
|
-
}) : [];
|
|
6373
|
+
}, unsafeByRel) : [];
|
|
6296
6374
|
const tracked = existing[0];
|
|
6297
|
-
const
|
|
6375
|
+
const linked = tracked === void 0 && await keptForLinkedFolder(root, stub.rel, unsafeByRel);
|
|
6376
|
+
const readAt = tracked?.abs ?? (linked ? path32.join(root, stub.rel) : void 0);
|
|
6377
|
+
const kind = readAt === void 0 || !await holdsKnownStub(readAt, stub.text) ? "other" : linked ? "linked" : "stub";
|
|
6298
6378
|
found.push({ id: stub.id, rel: stub.rel, kind });
|
|
6299
6379
|
}
|
|
6300
6380
|
return found;
|
|
@@ -6315,8 +6395,14 @@ async function removeLegacyHausFiles(root, dryRun, say, opts = {}) {
|
|
|
6315
6395
|
if (found.kind === "linked") warn(legacyKeptLine(found.rel, "linked"));
|
|
6316
6396
|
}
|
|
6317
6397
|
const ciGate = opts.ciGateReplaced ? await findCiGateWorkflow(root) : void 0;
|
|
6318
|
-
const [ciGateTracked] = ciGate?.kind === "known" ? await resolveExistingTrackedPaths(
|
|
6319
|
-
|
|
6398
|
+
const [ciGateTracked] = ciGate?.kind === "known" ? await resolveExistingTrackedPaths(
|
|
6399
|
+
root,
|
|
6400
|
+
"haus-gate",
|
|
6401
|
+
[CI_GATE_REL],
|
|
6402
|
+
() => {
|
|
6403
|
+
},
|
|
6404
|
+
await unsafeReasonsFor(root, [CI_GATE_REL], CI_GATE_RULES)
|
|
6405
|
+
) : [];
|
|
6320
6406
|
if (ciGateTracked !== void 0) {
|
|
6321
6407
|
matched.push({
|
|
6322
6408
|
id: "haus-gate",
|
|
@@ -6382,6 +6468,10 @@ async function removeLegacyHausFiles(root, dryRun, say, opts = {}) {
|
|
|
6382
6468
|
init_paths();
|
|
6383
6469
|
var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md"];
|
|
6384
6470
|
var RULES_HAUS_ID = "core.rules-haus";
|
|
6471
|
+
var HEADER_FILE_RELS = {
|
|
6472
|
+
"template.workflow": ".haus-workflow/WORKFLOW.md",
|
|
6473
|
+
[RULES_HAUS_ID]: ".claude/rules/haus.md"
|
|
6474
|
+
};
|
|
6385
6475
|
var PROJECT_MANAGED_HAUS_REL = [];
|
|
6386
6476
|
function coreManagedAbsolutePaths(root) {
|
|
6387
6477
|
const claude = PROJECT_MANAGED_CLAUDE_REL.map((rel) => claudePath(root, rel));
|
|
@@ -6797,7 +6887,7 @@ async function readProjectExcludedIds(root) {
|
|
|
6797
6887
|
} catch (err) {
|
|
6798
6888
|
if (err.code === "ENOENT") return [];
|
|
6799
6889
|
warn(
|
|
6800
|
-
`${PROJECT_CONFIG_REL} could not be read for excludedIds (${err instanceof Error ? err.message : String(err)})
|
|
6890
|
+
`${PROJECT_CONFIG_REL} could not be read for excludedIds (${err instanceof Error ? err.message : String(err)}), treating as none`
|
|
6801
6891
|
);
|
|
6802
6892
|
return [];
|
|
6803
6893
|
}
|
|
@@ -6979,7 +7069,7 @@ function skillDepInstallCommand(manager) {
|
|
|
6979
7069
|
case "yarn":
|
|
6980
7070
|
return {
|
|
6981
7071
|
ok: false,
|
|
6982
|
-
reason: "yarn resolves installs from the nearest lockfile up the tree, so it cannot install a standalone skill directory without reaching the project root
|
|
7072
|
+
reason: "yarn resolves installs from the nearest lockfile up the tree, so it cannot install a standalone skill directory without reaching the project root. Install it manually if the skill scripts are needed"
|
|
6983
7073
|
};
|
|
6984
7074
|
default:
|
|
6985
7075
|
return { ok: false, reason: "no package manager detected for this project" };
|
|
@@ -7039,7 +7129,7 @@ async function installSkillDependencies(opts, deps = {}) {
|
|
|
7039
7129
|
case "inconclusive":
|
|
7040
7130
|
return {
|
|
7041
7131
|
status: "skipped",
|
|
7042
|
-
reason: `could not determine whether ${command} is on PATH (${probe.reason})
|
|
7132
|
+
reason: `could not determine whether ${command} is on PATH (${probe.reason}), dependency install skipped`
|
|
7043
7133
|
};
|
|
7044
7134
|
default:
|
|
7045
7135
|
return assertNeverCommandProbe(probe);
|
|
@@ -7076,10 +7166,10 @@ async function findStrayFiles(root, items, contentRoot, lockRows, excludedIds, i
|
|
|
7076
7166
|
if (reported.has(relPath) || recorded.has(relPath)) continue;
|
|
7077
7167
|
if (isItemExcluded(item, excludedIds) || trackedIds.has(item.id)) continue;
|
|
7078
7168
|
if (!await anyRecordedPathOnDisk(root, [relPath])) continue;
|
|
7079
|
-
const
|
|
7080
|
-
if (
|
|
7081
|
-
if (await sourceIsInvalid(item,
|
|
7082
|
-
const verdict = await verdictFor(root,
|
|
7169
|
+
const admitted2 = await admission(item).catch(() => void 0);
|
|
7170
|
+
if (admitted2 === void 0 || !admitted2.ok) continue;
|
|
7171
|
+
if (await sourceIsInvalid(item, admitted2.manifestItem, admitted2.sourcePath)) continue;
|
|
7172
|
+
const verdict = await verdictFor(root, admitted2.manifestItem, contentRoot, relPath);
|
|
7083
7173
|
if (verdict.kind === "absent") continue;
|
|
7084
7174
|
reported.add(relPath);
|
|
7085
7175
|
strays.push(
|
|
@@ -7390,7 +7480,7 @@ async function writeManagedText(root, filePath, nextText, dryRun, quiet = false,
|
|
|
7390
7480
|
// src/claude/write-decisions-seed.ts
|
|
7391
7481
|
var SEED_README = `# Architecture Decision Records (ADRs)
|
|
7392
7482
|
|
|
7393
|
-
Why we chose X
|
|
7483
|
+
Why we chose X: a short, dated, write-once decision log for humans and agents.
|
|
7394
7484
|
|
|
7395
7485
|
| ADR | Title | Why (one line) | Status |
|
|
7396
7486
|
| --- | ----- | -------------- | ------ |
|
|
@@ -7706,13 +7796,53 @@ var DEPLOY_SIGNALS = [
|
|
|
7706
7796
|
/\bscp\b/,
|
|
7707
7797
|
/\bssh-agent\b/
|
|
7708
7798
|
];
|
|
7709
|
-
function
|
|
7799
|
+
function environmentNameClass(name) {
|
|
7710
7800
|
const lowered = name.toLowerCase();
|
|
7711
7801
|
if (/\bpreview\b/.test(lowered)) return "preview";
|
|
7712
7802
|
if (/github-pages|\bdocs?\b|docusaurus|github\.io/.test(lowered)) return "customer-visible";
|
|
7713
7803
|
if (/\bprod|\bproduction\b|\blive\b/.test(lowered)) return "production";
|
|
7714
|
-
if (/\bstag(?:e|ing)\b|\btest
|
|
7715
|
-
|
|
7804
|
+
if (/\bstag(?:e|ing)\b|\btest(?:ing)?\b|\bqa\b|\buat\b|\bdev\b|\bdevelop/.test(lowered)) {
|
|
7805
|
+
return "staging";
|
|
7806
|
+
}
|
|
7807
|
+
return void 0;
|
|
7808
|
+
}
|
|
7809
|
+
function classifyEnvironmentName(name) {
|
|
7810
|
+
return environmentNameClass(name) ?? "production";
|
|
7811
|
+
}
|
|
7812
|
+
var TARGET_WORD = "(?:prod(?:uction)?|live|staging)";
|
|
7813
|
+
var STAGE_WORD = "(?:test(?:ing)?|dev(?:elop(?:ment)?)?|stage|qa|uat)";
|
|
7814
|
+
var DEPLOY_WORD = "(?:deploy(?:ment)?|rollout)";
|
|
7815
|
+
var SEP = "[-_ ./]";
|
|
7816
|
+
var NAMED_TARGET = [
|
|
7817
|
+
new RegExp(`\\b${DEPLOY_WORD}${SEP}*(?:to${SEP}+)?${TARGET_WORD}\\b`),
|
|
7818
|
+
new RegExp(`\\b${TARGET_WORD}${SEP}+${DEPLOY_WORD}`),
|
|
7819
|
+
new RegExp(`\\b${DEPLOY_WORD}${SEP}*(?:to${SEP}+)?${STAGE_WORD}$`)
|
|
7820
|
+
];
|
|
7821
|
+
var ENVIRONMENT_WORD = new RegExp(`\\b(?:${TARGET_WORD}|${STAGE_WORD})\\b`, "g");
|
|
7822
|
+
function namedTargetClass(doc, file) {
|
|
7823
|
+
const groupOf = (value) => typeof value === "string" ? value : isPlainObject(value) && typeof value.group === "string" ? value.group : "";
|
|
7824
|
+
const names4 = [file.replace(/\.ya?ml$/, ""), textOf(doc.name), groupOf(doc.concurrency)];
|
|
7825
|
+
const jobs = isPlainObject(doc.jobs) ? doc.jobs : {};
|
|
7826
|
+
for (const job of Object.values(jobs)) {
|
|
7827
|
+
if (isPlainObject(job)) names4.push(groupOf(job.concurrency));
|
|
7828
|
+
}
|
|
7829
|
+
let found;
|
|
7830
|
+
for (const name of names4) {
|
|
7831
|
+
if (!name || name.length > MAX_TARGET_NAME || name.includes("${{")) continue;
|
|
7832
|
+
const lowered = withoutTrailingSeparators(name.toLowerCase());
|
|
7833
|
+
if (!NAMED_TARGET.some((pattern) => pattern.test(lowered))) continue;
|
|
7834
|
+
for (const [word] of lowered.matchAll(ENVIRONMENT_WORD)) {
|
|
7835
|
+
const value = environmentNameClass(word);
|
|
7836
|
+
if (value && value !== "preview") found = found ? maxClass(found, value) : value;
|
|
7837
|
+
}
|
|
7838
|
+
}
|
|
7839
|
+
return found;
|
|
7840
|
+
}
|
|
7841
|
+
var MAX_TARGET_NAME = 200;
|
|
7842
|
+
function withoutTrailingSeparators(name) {
|
|
7843
|
+
let end = name.length;
|
|
7844
|
+
while (end > 0 && "-_ ./".includes(name[end - 1])) end -= 1;
|
|
7845
|
+
return name.slice(0, end);
|
|
7716
7846
|
}
|
|
7717
7847
|
function toList(value) {
|
|
7718
7848
|
if (typeof value === "string") return [value];
|
|
@@ -7730,26 +7860,54 @@ function triggers(doc) {
|
|
|
7730
7860
|
}
|
|
7731
7861
|
return isPlainObject(on) ? on : {};
|
|
7732
7862
|
}
|
|
7863
|
+
function staticEnvironment(job) {
|
|
7864
|
+
const env = job.environment;
|
|
7865
|
+
const name = typeof env === "string" ? env : isPlainObject(env) && typeof env.name === "string" ? env.name : void 0;
|
|
7866
|
+
return name && !name.includes("${{") ? name : void 0;
|
|
7867
|
+
}
|
|
7868
|
+
function isPagesDeploy(doc, text, file) {
|
|
7869
|
+
const named = `${file} ${typeof doc.name === "string" ? doc.name : ""}`.toLowerCase();
|
|
7870
|
+
return /github\.io|docusaurus|gh-pages|deploy-pages|\bdocs?\b/.test(named) || /github\.io|deploy-pages|gh-pages/.test(text);
|
|
7871
|
+
}
|
|
7733
7872
|
function workflowClass(doc, text, file) {
|
|
7734
|
-
let
|
|
7873
|
+
let declared;
|
|
7874
|
+
const raise = (value) => {
|
|
7875
|
+
declared = declared ? maxClass(declared, value) : value;
|
|
7876
|
+
};
|
|
7877
|
+
const unnamed = [];
|
|
7878
|
+
const deploying = [];
|
|
7879
|
+
let anyJobDeploys = false;
|
|
7735
7880
|
const jobs = isPlainObject(doc.jobs) ? doc.jobs : {};
|
|
7736
|
-
for (const job of Object.
|
|
7881
|
+
for (const [id, job] of Object.entries(jobs)) {
|
|
7737
7882
|
if (!isPlainObject(job)) continue;
|
|
7738
|
-
const
|
|
7739
|
-
|
|
7740
|
-
|
|
7741
|
-
|
|
7742
|
-
|
|
7883
|
+
const deploys = DEPLOY_SIGNALS.some((signal) => signal.test(jobText(id, job)));
|
|
7884
|
+
anyJobDeploys ||= deploys;
|
|
7885
|
+
const name = staticEnvironment(job);
|
|
7886
|
+
if (name !== void 0) {
|
|
7887
|
+
const value = classifyEnvironmentName(name);
|
|
7888
|
+
if (value !== "preview") raise(value);
|
|
7889
|
+
} else {
|
|
7890
|
+
unnamed.push([id, job]);
|
|
7891
|
+
if (deploys) deploying.push([id, job]);
|
|
7892
|
+
}
|
|
7743
7893
|
}
|
|
7744
|
-
if (
|
|
7745
|
-
|
|
7746
|
-
|
|
7747
|
-
|
|
7894
|
+
if (!anyJobDeploys) {
|
|
7895
|
+
deploying.push(
|
|
7896
|
+
...unnamed.filter(
|
|
7897
|
+
([id, job]) => !SUPPORT_JOB.test(`${id} ${textOf(job.name)}`.toLowerCase())
|
|
7898
|
+
)
|
|
7899
|
+
);
|
|
7748
7900
|
}
|
|
7749
|
-
|
|
7750
|
-
if (
|
|
7751
|
-
|
|
7901
|
+
const target = namedTargetClass(doc, file);
|
|
7902
|
+
if (target) raise(target);
|
|
7903
|
+
const pages = isPagesDeploy(doc, text, file);
|
|
7904
|
+
if (pages) raise("customer-visible");
|
|
7905
|
+
if (declared && (deploying.length === 0 || target || pages)) return declared;
|
|
7906
|
+
const words = declared ? JSON.stringify(Object.fromEntries(deploying)).toLowerCase() : text;
|
|
7907
|
+
const guessed = /\bprod|production/.test(words) ? "production" : /\bstag(?:e|ing)\b/.test(words) ? "staging" : "production";
|
|
7908
|
+
return declared ? maxClass(declared, guessed) : guessed;
|
|
7752
7909
|
}
|
|
7910
|
+
var SUPPORT_JOB = /^(?:build|test|tests|unit|e2e|lint|check|checks|ci|typecheck|types|format|audit|scan|coverage|report|notify|notification|setup|install|cache|verify)(?:[-_ ]|$)/;
|
|
7753
7911
|
function textOf(value) {
|
|
7754
7912
|
if (typeof value === "string") return value;
|
|
7755
7913
|
if (isPlainObject(value) && typeof value.name === "string") return value.name;
|
|
@@ -7759,19 +7917,71 @@ var REPORTING_ACTION = /test|coverage|report|lint|codecov|sonar/;
|
|
|
7759
7917
|
function deployText(doc, file) {
|
|
7760
7918
|
const parts = [file, textOf(doc.name)];
|
|
7761
7919
|
const jobs = isPlainObject(doc.jobs) ? doc.jobs : {};
|
|
7920
|
+
for (const [id, job] of Object.entries(jobs)) {
|
|
7921
|
+
if (isPlainObject(job)) parts.push(jobText(id, job));
|
|
7922
|
+
}
|
|
7923
|
+
return parts.join("\n").toLowerCase();
|
|
7924
|
+
}
|
|
7925
|
+
function jobText(id, job) {
|
|
7926
|
+
const parts = [id, textOf(job.name), textOf(job.environment), textOf(job.uses)];
|
|
7927
|
+
if (isPlainObject(job.with)) {
|
|
7928
|
+
for (const value of Object.values(job.with)) {
|
|
7929
|
+
if (typeof value === "string") parts.push(value);
|
|
7930
|
+
}
|
|
7931
|
+
}
|
|
7932
|
+
for (const step of Array.isArray(job.steps) ? job.steps : []) {
|
|
7933
|
+
if (!isPlainObject(step)) continue;
|
|
7934
|
+
const uses = textOf(step.uses);
|
|
7935
|
+
if (REPORTING_ACTION.test(uses.toLowerCase())) continue;
|
|
7936
|
+
parts.push(uses, textOf(step.run), textOf(step.name));
|
|
7937
|
+
}
|
|
7938
|
+
return parts.join("\n").toLowerCase();
|
|
7939
|
+
}
|
|
7940
|
+
var SCRIPT_PATH = /(?:^|[\s"'=(;&|])((?:\.\/)?[\w.][\w./-]*\.(?:sh|bash|zsh|mjs|cjs|js|ts|py|rb))(?=$|[\s"';)&|])/gm;
|
|
7941
|
+
var MAX_SCRIPTS = 10;
|
|
7942
|
+
var MAX_SCRIPT_BYTES = 256 * 1024;
|
|
7943
|
+
async function commandText(root, doc) {
|
|
7944
|
+
const texts = [];
|
|
7945
|
+
const rels = /* @__PURE__ */ new Set();
|
|
7946
|
+
const jobs = isPlainObject(doc.jobs) ? doc.jobs : {};
|
|
7762
7947
|
for (const [id, job] of Object.entries(jobs)) {
|
|
7763
7948
|
if (!isPlainObject(job)) continue;
|
|
7764
|
-
|
|
7949
|
+
if (isPlainObject(job.with) && DEPLOY_SIGNALS.some((signal) => signal.test(jobText(id, job)))) {
|
|
7950
|
+
for (const value of Object.values(job.with)) {
|
|
7951
|
+
if (typeof value === "string") texts.push(value.toLowerCase());
|
|
7952
|
+
}
|
|
7953
|
+
}
|
|
7765
7954
|
for (const step of Array.isArray(job.steps) ? job.steps : []) {
|
|
7766
|
-
if (!isPlainObject(step)) continue;
|
|
7767
|
-
const
|
|
7768
|
-
if (
|
|
7769
|
-
|
|
7955
|
+
if (!isPlainObject(step) || typeof step.run !== "string") continue;
|
|
7956
|
+
const run = step.run.toLowerCase();
|
|
7957
|
+
if (!DEPLOY_SIGNALS.some((signal) => signal.test(run))) continue;
|
|
7958
|
+
texts.push(run);
|
|
7959
|
+
for (const match of step.run.matchAll(SCRIPT_PATH)) {
|
|
7960
|
+
const rel = path35.posix.normalize(match[1]);
|
|
7961
|
+
if (!rel.startsWith("..") && !path35.isAbsolute(rel)) rels.add(rel);
|
|
7962
|
+
}
|
|
7770
7963
|
}
|
|
7771
7964
|
}
|
|
7772
|
-
|
|
7965
|
+
let realRoot;
|
|
7966
|
+
try {
|
|
7967
|
+
realRoot = await fs24.realpath(root);
|
|
7968
|
+
} catch {
|
|
7969
|
+
return texts.join("\n");
|
|
7970
|
+
}
|
|
7971
|
+
for (const rel of [...rels].slice(0, MAX_SCRIPTS)) {
|
|
7972
|
+
try {
|
|
7973
|
+
const real = await fs24.realpath(path35.join(root, rel));
|
|
7974
|
+
if (!real.startsWith(`${realRoot}${path35.sep}`)) continue;
|
|
7975
|
+
const stat2 = await fs24.stat(real);
|
|
7976
|
+
if (!stat2.isFile() || stat2.size > MAX_SCRIPT_BYTES) continue;
|
|
7977
|
+
const lines = (await fs24.readFile(real, "utf8")).toLowerCase().split("\n");
|
|
7978
|
+
texts.push(lines.filter((line2) => !/^\s*(?:#|\/\/)/.test(line2)).join("\n"));
|
|
7979
|
+
} catch {
|
|
7980
|
+
}
|
|
7981
|
+
}
|
|
7982
|
+
return texts.join("\n");
|
|
7773
7983
|
}
|
|
7774
|
-
async function workflowEvidence(root, defaultBranch2) {
|
|
7984
|
+
async function workflowEvidence(root, defaultBranch2, mentions) {
|
|
7775
7985
|
const dir = path35.join(root, ".github", "workflows");
|
|
7776
7986
|
let names4;
|
|
7777
7987
|
try {
|
|
@@ -7793,6 +8003,7 @@ async function workflowEvidence(root, defaultBranch2) {
|
|
|
7793
8003
|
const signals = deployText(doc, name);
|
|
7794
8004
|
if (!DEPLOY_SIGNALS.some((signal) => signal.test(signals))) continue;
|
|
7795
8005
|
const deploys = workflowClass(doc, text, name);
|
|
8006
|
+
let mention;
|
|
7796
8007
|
const on = triggers(doc);
|
|
7797
8008
|
const sources = [];
|
|
7798
8009
|
if ("push" in on) sources.push({ event: "push", filter: isPlainObject(on.push) ? on.push : {} });
|
|
@@ -7822,7 +8033,7 @@ async function workflowEvidence(root, defaultBranch2) {
|
|
|
7822
8033
|
if ("workflow_dispatch" in on) {
|
|
7823
8034
|
evidence.push({
|
|
7824
8035
|
source: "workflow",
|
|
7825
|
-
deploys
|
|
8036
|
+
deploys,
|
|
7826
8037
|
dispatch: rel,
|
|
7827
8038
|
detail: `${rel}: deploys on workflow_dispatch`
|
|
7828
8039
|
});
|
|
@@ -7845,6 +8056,10 @@ async function workflowEvidence(root, defaultBranch2) {
|
|
|
7845
8056
|
...pathsIgnore.length > 0 ? { ignorePaths: pathsIgnore } : {},
|
|
7846
8057
|
detail: `${rel}: ${event} to ${branch}${pathNote} deploys ${deploys}`
|
|
7847
8058
|
});
|
|
8059
|
+
if (paths.length === 0) {
|
|
8060
|
+
mention ??= await commandText(root, doc);
|
|
8061
|
+
mentions.set(branch, [...mentions.get(branch) ?? [], mention]);
|
|
8062
|
+
}
|
|
7848
8063
|
}
|
|
7849
8064
|
for (const tag of tags) {
|
|
7850
8065
|
evidence.push({
|
|
@@ -7858,31 +8073,96 @@ async function workflowEvidence(root, defaultBranch2) {
|
|
|
7858
8073
|
}
|
|
7859
8074
|
return evidence;
|
|
7860
8075
|
}
|
|
7861
|
-
async function
|
|
7862
|
-
|
|
7863
|
-
|
|
8076
|
+
async function gitIgnores(root, rel, git) {
|
|
8077
|
+
if (!git) return false;
|
|
8078
|
+
try {
|
|
8079
|
+
const result = await git(["check-ignore", "-q", "--", rel], { cwd: root, timeoutMs: 5e3 });
|
|
8080
|
+
return result.exitCode === 0;
|
|
8081
|
+
} catch {
|
|
8082
|
+
return false;
|
|
8083
|
+
}
|
|
8084
|
+
}
|
|
8085
|
+
async function vercelFileEvidence(root, rel, defaultBranch2) {
|
|
7864
8086
|
let config;
|
|
7865
|
-
|
|
8087
|
+
try {
|
|
8088
|
+
config = JSON.parse(await fs24.readFile(path35.join(root, rel), "utf8"));
|
|
8089
|
+
} catch {
|
|
8090
|
+
config = {};
|
|
8091
|
+
}
|
|
8092
|
+
const git = isPlainObject(config) && isPlainObject(config.git) ? config.git : void 0;
|
|
8093
|
+
const enabled = git?.deploymentEnabled;
|
|
8094
|
+
if (enabled === false) return void 0;
|
|
8095
|
+
if (isPlainObject(enabled) && enabled[defaultBranch2] === false) return void 0;
|
|
8096
|
+
return {
|
|
8097
|
+
source: "vercel",
|
|
8098
|
+
deploys: "production",
|
|
8099
|
+
branch: defaultBranch2,
|
|
8100
|
+
detail: `${rel}: Vercel deploys ${defaultBranch2} to production`
|
|
8101
|
+
};
|
|
8102
|
+
}
|
|
8103
|
+
var NOT_AN_APP = /^(?:node_modules|examples?|samples?|fixtures?|tests?|__tests__|templates?)$/;
|
|
8104
|
+
var skippedFolder = (name) => name.startsWith(".") || name === "node_modules";
|
|
8105
|
+
function isNestedVercelFile(rel) {
|
|
8106
|
+
const parts = rel.split("/");
|
|
8107
|
+
if (parts.length < 2 || parts.length > 3 || parts[parts.length - 1] !== "vercel.json")
|
|
8108
|
+
return false;
|
|
8109
|
+
const folders = parts.slice(0, -1);
|
|
8110
|
+
return !NOT_AN_APP.test(folders[0]) && !folders.some(skippedFolder);
|
|
8111
|
+
}
|
|
8112
|
+
async function nestedVercelFiles(root, git) {
|
|
8113
|
+
if (git) {
|
|
7866
8114
|
try {
|
|
7867
|
-
|
|
8115
|
+
const listed = await git(["ls-files", "-z", "--", "*/vercel.json"], {
|
|
8116
|
+
cwd: root,
|
|
8117
|
+
timeoutMs: 5e3
|
|
8118
|
+
});
|
|
8119
|
+
if (listed.exitCode === 0) {
|
|
8120
|
+
return listed.stdout.split("\0").filter(isNestedVercelFile).sort();
|
|
8121
|
+
}
|
|
7868
8122
|
} catch {
|
|
7869
|
-
config = {};
|
|
7870
8123
|
}
|
|
7871
|
-
} else if (!linked) {
|
|
7872
|
-
return [];
|
|
7873
8124
|
}
|
|
7874
|
-
const
|
|
7875
|
-
|
|
7876
|
-
|
|
7877
|
-
|
|
7878
|
-
|
|
7879
|
-
|
|
7880
|
-
source: "vercel",
|
|
7881
|
-
deploys: "production",
|
|
7882
|
-
branch: defaultBranch2,
|
|
7883
|
-
detail: `${linked ? ".vercel/project.json" : "vercel.json"}: Vercel deploys ${defaultBranch2} to production`
|
|
8125
|
+
const children = async (rel) => {
|
|
8126
|
+
try {
|
|
8127
|
+
const entries = await fs24.readdir(path35.join(root, rel), { withFileTypes: true });
|
|
8128
|
+
return entries.filter((entry) => entry.isDirectory() && !skippedFolder(entry.name)).map((entry) => rel ? `${rel}/${entry.name}` : entry.name).sort();
|
|
8129
|
+
} catch {
|
|
8130
|
+
return [];
|
|
7884
8131
|
}
|
|
7885
|
-
|
|
8132
|
+
};
|
|
8133
|
+
const found = [];
|
|
8134
|
+
for (const first of await children("")) {
|
|
8135
|
+
if (NOT_AN_APP.test(first)) continue;
|
|
8136
|
+
for (const dir of [first, ...await children(first)]) {
|
|
8137
|
+
const rel = `${dir}/vercel.json`;
|
|
8138
|
+
if (isNestedVercelFile(rel) && await fs24.pathExists(path35.join(root, rel))) found.push(rel);
|
|
8139
|
+
}
|
|
8140
|
+
}
|
|
8141
|
+
return found;
|
|
8142
|
+
}
|
|
8143
|
+
async function vercelEvidence(root, defaultBranch2, git) {
|
|
8144
|
+
const evidence = [];
|
|
8145
|
+
if (await fs24.pathExists(path35.join(root, "vercel.json"))) {
|
|
8146
|
+
const item = await vercelFileEvidence(root, "vercel.json", defaultBranch2);
|
|
8147
|
+
if (item) evidence.push(item);
|
|
8148
|
+
} else {
|
|
8149
|
+
const link = ".vercel/project.json";
|
|
8150
|
+
if (await fs24.pathExists(path35.join(root, link))) {
|
|
8151
|
+
const ignored = await gitIgnores(root, link, git);
|
|
8152
|
+
evidence.push({
|
|
8153
|
+
source: "vercel",
|
|
8154
|
+
deploys: "production",
|
|
8155
|
+
branch: defaultBranch2,
|
|
8156
|
+
...ignored ? { tracedTo: "vercel" } : {},
|
|
8157
|
+
detail: `${link}${ignored ? " (git-ignored)" : ""}: Vercel deploys ${defaultBranch2} to production`
|
|
8158
|
+
});
|
|
8159
|
+
}
|
|
8160
|
+
}
|
|
8161
|
+
for (const rel of await nestedVercelFiles(root, git)) {
|
|
8162
|
+
const item = await vercelFileEvidence(root, rel, defaultBranch2);
|
|
8163
|
+
if (item) evidence.push(item);
|
|
8164
|
+
}
|
|
8165
|
+
return evidence;
|
|
7886
8166
|
}
|
|
7887
8167
|
async function fetchDeployments(deps, errors) {
|
|
7888
8168
|
const { gh, slug } = deps;
|
|
@@ -7943,6 +8223,7 @@ async function fetchDeployments(deps, errors) {
|
|
|
7943
8223
|
}
|
|
7944
8224
|
return rows2;
|
|
7945
8225
|
}
|
|
8226
|
+
var SERVICE_NAME = /^[a-z0-9]{2,}(?:[-_][a-z0-9]{2,})+$/i;
|
|
7946
8227
|
function deploymentEvidence(rows2, deps) {
|
|
7947
8228
|
const now = (deps.now ?? /* @__PURE__ */ new Date()).getTime();
|
|
7948
8229
|
const cutoff = now - DEPLOYMENT_WINDOW_DAYS * 24 * 60 * 60 * 1e3;
|
|
@@ -7950,7 +8231,8 @@ function deploymentEvidence(rows2, deps) {
|
|
|
7950
8231
|
const seen = /* @__PURE__ */ new Map();
|
|
7951
8232
|
for (const row of rows2) {
|
|
7952
8233
|
if (Date.parse(row.created_at) < cutoff) continue;
|
|
7953
|
-
const
|
|
8234
|
+
const named = environmentNameClass(row.environment);
|
|
8235
|
+
const deploys = named ?? "production";
|
|
7954
8236
|
if (deploys === "preview") continue;
|
|
7955
8237
|
if (/^v?\d+(?:\.\d+)+/.test(row.ref)) {
|
|
7956
8238
|
const pattern = row.ref.startsWith("v") ? "v*" : "*.*";
|
|
@@ -7974,6 +8256,9 @@ function deploymentEvidence(rows2, deps) {
|
|
|
7974
8256
|
source: "deployments",
|
|
7975
8257
|
deploys,
|
|
7976
8258
|
branch,
|
|
8259
|
+
// Only a service-shaped name (`shop-server`) is traced: a plain word (`backend`, `web`)
|
|
8260
|
+
// could stand for anything in a script, so it is never explained.
|
|
8261
|
+
...named === void 0 && SERVICE_NAME.test(row.environment) ? { tracedTo: row.environment.toLowerCase() } : {},
|
|
7977
8262
|
detail: `GitHub deployments: ${branch} deployed to ${row.environment} (last ${row.created_at.slice(0, 10)})`
|
|
7978
8263
|
});
|
|
7979
8264
|
}
|
|
@@ -8019,15 +8304,23 @@ function configFromEvidence(evidence) {
|
|
|
8019
8304
|
async function detectDeployConfig(root, deps = {}) {
|
|
8020
8305
|
const defaultBranch2 = deps.defaultBranch ?? "main";
|
|
8021
8306
|
const errors = [];
|
|
8022
|
-
const
|
|
8307
|
+
const found = [];
|
|
8308
|
+
const mentions = /* @__PURE__ */ new Map();
|
|
8023
8309
|
if (root) {
|
|
8024
|
-
|
|
8025
|
-
|
|
8310
|
+
found.push(...await workflowEvidence(root, defaultBranch2, mentions));
|
|
8311
|
+
found.push(...await vercelEvidence(root, defaultBranch2, deps.git));
|
|
8026
8312
|
}
|
|
8027
8313
|
const rows2 = await fetchDeployments(deps, errors);
|
|
8028
|
-
if (rows2)
|
|
8314
|
+
if (rows2) found.push(...deploymentEvidence(rows2, { ...deps, defaultBranch: defaultBranch2 }));
|
|
8315
|
+
const evidence = found.filter((item) => !explainedBy(item, mentions));
|
|
8029
8316
|
return { config: configFromEvidence(evidence), evidence, errors };
|
|
8030
8317
|
}
|
|
8318
|
+
function explainedBy(item, mentions) {
|
|
8319
|
+
if (!item.tracedTo || !item.branch) return false;
|
|
8320
|
+
const escaped = item.tracedTo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
8321
|
+
const word = new RegExp(`(?<![\\w-])${escaped}(?![\\w-])`);
|
|
8322
|
+
return (mentions.get(item.branch) ?? []).some((text) => word.test(text));
|
|
8323
|
+
}
|
|
8031
8324
|
|
|
8032
8325
|
// src/deploy/repo.ts
|
|
8033
8326
|
init_exec();
|
|
@@ -8134,6 +8427,7 @@ async function writeDeployConfig(root, dryRun, opts = {}) {
|
|
|
8134
8427
|
const gh = opts.gh ?? (deployDetectOffline() ? void 0 : realGh());
|
|
8135
8428
|
const result = await detectDeployConfig(root, {
|
|
8136
8429
|
gh,
|
|
8430
|
+
git,
|
|
8137
8431
|
slug: context?.slug,
|
|
8138
8432
|
defaultBranch: branch,
|
|
8139
8433
|
now: opts.now,
|
|
@@ -8296,7 +8590,7 @@ function prettierIgnoreProtects(lines, dir) {
|
|
|
8296
8590
|
function buildPrettierIgnoreBlock() {
|
|
8297
8591
|
return [
|
|
8298
8592
|
PRETTIERIGNORE_BEGIN,
|
|
8299
|
-
"# haus-owned files
|
|
8593
|
+
"# haus-owned files, do not reformat (keeps tamper/lock hashes stable)",
|
|
8300
8594
|
...IGNORED_PATHS,
|
|
8301
8595
|
PRETTIERIGNORE_END
|
|
8302
8596
|
].join("\n");
|
|
@@ -8498,7 +8792,7 @@ function line(f) {
|
|
|
8498
8792
|
function buildWorkflowConfig(v) {
|
|
8499
8793
|
const f = productionFields(v);
|
|
8500
8794
|
const byPrefix = (p) => line(f.find((x) => x.prefix === p));
|
|
8501
|
-
return "# How this project works (workflow methodology bindings)\n\n> The few project-specific values the workflow standard (WORKFLOW.md) binds to:\n> where the source-of-truth docs live, the test commands the TDD/verification gate\n> runs, the highest-stakes logic, and the pre-commit tool. This file is yours to\n> edit and haus will not overwrite it.\n>\n> Everyday commands (dev, build, lint, typecheck, format) and project documentation\n> live in `CLAUDE.md` + `docs
|
|
8795
|
+
return "# How this project works (workflow methodology bindings)\n\n> The few project-specific values the workflow standard (WORKFLOW.md) binds to:\n> where the source-of-truth docs live, the test commands the TDD/verification gate\n> runs, the highest-stakes logic, and the pre-commit tool. This file is yours to\n> edit and haus will not overwrite it.\n>\n> Everyday commands (dev, build, lint, typecheck, format) and project documentation\n> live in `CLAUDE.md` + `docs/`. Run **`/docs`** to generate/refresh them.\n\n## Source-of-truth documents\n" + byPrefix("- Spec: ") + "\n" + byPrefix("- Design: ") + "\n" + byPrefix("- UX flows: ") + "\n\n## Test commands (TDD / verification gate)\n" + byPrefix("- Test (unit + integration): ") + "\n" + byPrefix("- Test (E2E): ") + "\n\n## Highest-stakes logic\n<!-- fill in domain areas requiring TDD-only treatment, e.g. payment flows, auth, medical data -->\n\n## Pre-commit tool\n" + byPrefix("- Tool: ") + "\n";
|
|
8502
8796
|
}
|
|
8503
8797
|
function buildPocWorkflowConfig(v) {
|
|
8504
8798
|
const f = pocFields(v);
|
|
@@ -8693,6 +8987,10 @@ var APPLY_FORCE = "haus apply --write --force";
|
|
|
8693
8987
|
var APPLY_WRITE = "haus apply --write";
|
|
8694
8988
|
async function writeWorkflowReport(root, pkgVersion, opts) {
|
|
8695
8989
|
const { dryRun } = opts;
|
|
8990
|
+
if (opts.marked === true) {
|
|
8991
|
+
const left = await leaveMarkedFile(root, WORKFLOW_REL, STABLE_ID, opts.force === true);
|
|
8992
|
+
if (left !== null) return left;
|
|
8993
|
+
}
|
|
8696
8994
|
const templateContent = await readWorkflowTemplate({
|
|
8697
8995
|
dryRun,
|
|
8698
8996
|
projectRoot: root,
|
|
@@ -8700,7 +8998,7 @@ async function writeWorkflowReport(root, pkgVersion, opts) {
|
|
|
8700
8998
|
});
|
|
8701
8999
|
if (templateContent === null) {
|
|
8702
9000
|
warn(
|
|
8703
|
-
isOffline() ? `Workflow template is not in the local catalog cache and this run is offline
|
|
9001
|
+
isOffline() ? `Workflow template is not in the local catalog cache and this run is offline. Run \`haus catalog bundle import <file>\` (or drop \`--offline\`), then re-run \`haus apply --write\`` : `Workflow template could not be fetched from the catalog. Check your network, then re-run \`haus apply --write\` (or \`haus update\`)`
|
|
8704
9002
|
);
|
|
8705
9003
|
return null;
|
|
8706
9004
|
}
|
|
@@ -8712,9 +9010,19 @@ async function writeWorkflowReport(root, pkgVersion, opts) {
|
|
|
8712
9010
|
force: opts.force === true,
|
|
8713
9011
|
quiet: opts.quiet === true,
|
|
8714
9012
|
forceCommand: opts.forceCommand ?? APPLY_FORCE,
|
|
8715
|
-
writeCommand: opts.writeCommand ?? APPLY_WRITE
|
|
9013
|
+
writeCommand: opts.writeCommand ?? APPLY_WRITE,
|
|
9014
|
+
marked: opts.marked === true
|
|
8716
9015
|
});
|
|
8717
9016
|
}
|
|
9017
|
+
var MARK_SET_ASIDE = " (excludedIds marks it as the project's own; --force replaces it anyway)";
|
|
9018
|
+
function leavesMarkedFile(onDisk, force) {
|
|
9019
|
+
return !force || onDisk.kind === "absent" || onDisk.kind === "other";
|
|
9020
|
+
}
|
|
9021
|
+
async function leaveMarkedFile(root, relPath, id, force) {
|
|
9022
|
+
const file = path43.join(root, relPath);
|
|
9023
|
+
if (!leavesMarkedFile(await inspectHeaderHashedFile(file), force)) return null;
|
|
9024
|
+
return { id, relPath, path: file, outcome: "kept", marked: true };
|
|
9025
|
+
}
|
|
8718
9026
|
function headerFileItem(result) {
|
|
8719
9027
|
return {
|
|
8720
9028
|
id: result.id,
|
|
@@ -8805,6 +9113,10 @@ async function writeHeaderHashedFile(root, relPath, opts) {
|
|
|
8805
9113
|
const file = path43.join(root, relPath);
|
|
8806
9114
|
const printable = displayPath(root, file);
|
|
8807
9115
|
const onDisk = await inspectHeaderHashedFile(file);
|
|
9116
|
+
const marked = opts.marked === true;
|
|
9117
|
+
if (marked && leavesMarkedFile(onDisk, opts.force)) {
|
|
9118
|
+
return { id: opts.id, relPath, path: file, outcome: "kept", marked: true };
|
|
9119
|
+
}
|
|
8808
9120
|
const next = `${hausManagedHeader(opts.id, opts.pkgVersion, hashText(normaliseLF(opts.body)))}
|
|
8809
9121
|
${opts.body}`;
|
|
8810
9122
|
const named = { id: opts.id, relPath, path: file };
|
|
@@ -8818,8 +9130,9 @@ ${opts.body}`;
|
|
|
8818
9130
|
...outcome === "refused" && notAFile !== void 0 ? { notAFile } : {}
|
|
8819
9131
|
}
|
|
8820
9132
|
);
|
|
8821
|
-
|
|
8822
|
-
|
|
9133
|
+
const said = marked && outcome === "replaced" ? `${line2}${MARK_SET_ASIDE}` : line2;
|
|
9134
|
+
if (outcomeStream(outcome) === "warn") warn(said);
|
|
9135
|
+
else say(said);
|
|
8823
9136
|
const before = onDisk.kind === "file" ? onDisk.text : onDisk.kind === "absent" ? "" : void 0;
|
|
8824
9137
|
if (opts.dryRun && before !== void 0 && isOwnedAfter(outcome) && outcome !== "unchanged") {
|
|
8825
9138
|
say(createUnifiedDiff(printable, before, next));
|
|
@@ -8889,7 +9202,7 @@ async function reportSkillDependencyInstall(root, itemId, sourceDir, skillDir, m
|
|
|
8889
9202
|
case "none":
|
|
8890
9203
|
return;
|
|
8891
9204
|
case "skipped":
|
|
8892
|
-
warn(`${itemId}: skipping dependency install
|
|
9205
|
+
warn(`${itemId}: skipping dependency install: ${outcome.reason}`);
|
|
8893
9206
|
return;
|
|
8894
9207
|
case "would-install":
|
|
8895
9208
|
say(
|
|
@@ -8903,7 +9216,7 @@ async function reportSkillDependencyInstall(root, itemId, sourceDir, skillDir, m
|
|
|
8903
9216
|
return;
|
|
8904
9217
|
case "failed":
|
|
8905
9218
|
warn(
|
|
8906
|
-
`${itemId}: dependency install failed (${outcome.command} exited ${outcome.exitCode})
|
|
9219
|
+
`${itemId}: dependency install failed (${outcome.command} exited ${outcome.exitCode}): the skill is installed but its scripts may not run${outcome.stderr ? `: ${outcome.stderr.trim().split("\n").slice(-1)[0]}` : ""}`
|
|
8907
9220
|
);
|
|
8908
9221
|
return;
|
|
8909
9222
|
}
|
|
@@ -9069,12 +9382,15 @@ async function writeClaudeFilesReport(root, dryRun, selectedIds, opts = {}) {
|
|
|
9069
9382
|
}
|
|
9070
9383
|
const rootClaudeMdPath = rootClaudeMd.status === "written" ? rootClaudeMd.path : null;
|
|
9071
9384
|
const decisionsSeedPath = profileSpec.writesDecisionsSeed ? await writeDecisionsSeed(root, dryRun, opts.quiet) : null;
|
|
9385
|
+
const excludedIds = await readProjectExcludedIdsIfReadable(root);
|
|
9386
|
+
const isMarked = (id) => excludedIds?.includes(id) === true;
|
|
9072
9387
|
const workflow = await writeWorkflowReport(root, hausVersion2, {
|
|
9073
9388
|
dryRun,
|
|
9074
9389
|
force: opts.force,
|
|
9075
9390
|
quiet: opts.quiet,
|
|
9076
9391
|
templateRel: profileSpec.workflowTemplateRel,
|
|
9077
|
-
forceCommand: APPLY_FORCE2
|
|
9392
|
+
forceCommand: APPLY_FORCE2,
|
|
9393
|
+
marked: isMarked(WORKFLOW_ID)
|
|
9078
9394
|
});
|
|
9079
9395
|
if (workflow?.backupFailed) exitCode = 1;
|
|
9080
9396
|
const workflowPath = workflow !== null && isOwnedAfter(workflow.outcome) ? workflow.path : null;
|
|
@@ -9149,10 +9465,11 @@ async function writeClaudeFilesReport(root, dryRun, selectedIds, opts = {}) {
|
|
|
9149
9465
|
force: opts.force === true,
|
|
9150
9466
|
quiet: opts.quiet === true,
|
|
9151
9467
|
forceCommand: APPLY_FORCE2,
|
|
9152
|
-
writeCommand: APPLY_WRITE2
|
|
9468
|
+
writeCommand: APPLY_WRITE2,
|
|
9469
|
+
marked: isMarked(RULES_HAUS_ID)
|
|
9153
9470
|
});
|
|
9154
9471
|
if (rulesHaus.backupFailed) exitCode = 1;
|
|
9155
|
-
const headerItems = [...workflow === null ? [] : [workflow], rulesHaus].map(headerFileItem);
|
|
9472
|
+
const headerItems = [...workflow === null ? [] : [workflow], rulesHaus].filter((result) => result.marked !== true).map(headerFileItem);
|
|
9156
9473
|
const coreFiles = [
|
|
9157
9474
|
...settingsRefused ? [] : [claudePath(root, "settings.json")],
|
|
9158
9475
|
...isOwnedAfter(rulesHaus.outcome) ? [rulesHaus.path] : []
|
|
@@ -9503,11 +9820,10 @@ async function writeClaudeFilesReport(root, dryRun, selectedIds, opts = {}) {
|
|
|
9503
9820
|
}
|
|
9504
9821
|
if (curatedReviewStatusSkips > 1) {
|
|
9505
9822
|
warn(
|
|
9506
|
-
`${curatedReviewStatusSkips} curated items skipped: reviewStatus is not approved
|
|
9823
|
+
`${curatedReviewStatusSkips} curated items skipped: reviewStatus is not approved (possible catalog field rename upstream)`
|
|
9507
9824
|
);
|
|
9508
9825
|
}
|
|
9509
9826
|
const adoptedStrays = [];
|
|
9510
|
-
const excludedIds = await readProjectExcludedIdsIfReadable(root);
|
|
9511
9827
|
if (excludedIds === void 0) {
|
|
9512
9828
|
warn(
|
|
9513
9829
|
`Not adopting stray files: ${PROJECT_CONFIG_REL} cannot be read as JSON, so haus cannot tell which ids it excludes. Fix the file and run again.`
|
|
@@ -9555,7 +9871,7 @@ async function writeClaudeFilesReport(root, dryRun, selectedIds, opts = {}) {
|
|
|
9555
9871
|
if (opts.prune) {
|
|
9556
9872
|
if (refusePruneWithoutRecommendation) {
|
|
9557
9873
|
warn(
|
|
9558
|
-
"recommendation.json is missing or unreadable
|
|
9874
|
+
"recommendation.json is missing or unreadable, refusing --prune (every installed item would look orphaned). Run `haus recommend` first, or pass --force to override."
|
|
9559
9875
|
);
|
|
9560
9876
|
} else {
|
|
9561
9877
|
const notOrphaned = new Set(rec.recommended.map((r) => r.id));
|
|
@@ -9875,7 +10191,7 @@ async function targetShapeMismatch(root, target, itemType) {
|
|
|
9875
10191
|
for (const rel of target) {
|
|
9876
10192
|
let stat2;
|
|
9877
10193
|
try {
|
|
9878
|
-
if (await unsafeTrackedPath(root, rel) !== null) continue;
|
|
10194
|
+
if (await unsafeTrackedPath(root, rel, REPLACE_LINK_RULES) !== null) continue;
|
|
9879
10195
|
stat2 = await fs28.lstat(path44.resolve(root, recordedRel(rel)));
|
|
9880
10196
|
} catch {
|
|
9881
10197
|
continue;
|
|
@@ -9936,18 +10252,14 @@ async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, q
|
|
|
9936
10252
|
unsafeByRel
|
|
9937
10253
|
);
|
|
9938
10254
|
if (await hasProtectedUnsafePath(root, relPaths, unsafeByRel)) {
|
|
9939
|
-
if (isMove) {
|
|
9940
|
-
|
|
9941
|
-
|
|
9942
|
-
|
|
9943
|
-
|
|
9944
|
-
|
|
9945
|
-
|
|
9946
|
-
|
|
9947
|
-
containmentFailed = true;
|
|
9948
|
-
} else {
|
|
9949
|
-
warn(movedKeptLine(migration.oldId, relPaths, dryRun));
|
|
9950
|
-
}
|
|
10255
|
+
if (isMove && samePath2.force) {
|
|
10256
|
+
const reasons = [...unsafeByRel].flatMap(
|
|
10257
|
+
([rel, reason]) => reason === null ? [] : [unsafeOnDiskReason(rel, reason)]
|
|
10258
|
+
);
|
|
10259
|
+
warn(
|
|
10260
|
+
dryRun ? `[dry-run] would refuse ${posixPathsOnce(relPaths).join(", ")} (${migration.oldId}): ${reasons.join("; ")}` : backupFailedLine(relPaths, migration.oldId, reasons.join("; "))
|
|
10261
|
+
);
|
|
10262
|
+
containmentFailed = true;
|
|
9951
10263
|
}
|
|
9952
10264
|
refusedIds.add(migration.oldId);
|
|
9953
10265
|
continue;
|
|
@@ -9995,11 +10307,11 @@ async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, q
|
|
|
9995
10307
|
if (!gate.ok) {
|
|
9996
10308
|
if (gate.reason === "no-hash") {
|
|
9997
10309
|
warn(
|
|
9998
|
-
`Former catalog item ${migration.oldId} has no lock hash
|
|
10310
|
+
`Former catalog item ${migration.oldId} has no lock hash, leaving old paths in place: ${existing.map((e) => e.rel).join(", ")}`
|
|
9999
10311
|
);
|
|
10000
10312
|
} else {
|
|
10001
10313
|
warn(
|
|
10002
|
-
`Former catalog item ${migration.oldId} was modified locally
|
|
10314
|
+
`Former catalog item ${migration.oldId} was modified locally, leaving old paths in place: ${existing.map((e) => e.rel).join(", ")}`
|
|
10003
10315
|
);
|
|
10004
10316
|
}
|
|
10005
10317
|
refusedIds.add(migration.oldId);
|
|
@@ -10158,11 +10470,11 @@ async function cleanupStaleCatalogItems(root, prevLock, manifestById, dryRun, qu
|
|
|
10158
10470
|
if (!gate.ok) {
|
|
10159
10471
|
if (gate.reason === "no-hash") {
|
|
10160
10472
|
warn(
|
|
10161
|
-
`${label} catalog item ${entry.id} has no lock hash
|
|
10473
|
+
`${label} catalog item ${entry.id} has no lock hash, leaving in place: ${existing.map((e) => e.rel).join(", ")}`
|
|
10162
10474
|
);
|
|
10163
10475
|
} else {
|
|
10164
10476
|
warn(
|
|
10165
|
-
`${label} catalog item ${entry.id} was modified locally
|
|
10477
|
+
`${label} catalog item ${entry.id} was modified locally, leaving in place${superseded ? " (the global plugin provides it; delete the copy manually if you no longer want it)" : ""}: ${existing.map((e) => e.rel).join(", ")}`
|
|
10166
10478
|
);
|
|
10167
10479
|
}
|
|
10168
10480
|
refusedIds.add(entry.id);
|
|
@@ -10172,7 +10484,7 @@ async function cleanupStaleCatalogItems(root, prevLock, manifestById, dryRun, qu
|
|
|
10172
10484
|
const isShared = recordedRel(rel) === LEGACY_SUPERPOWERS_SHARED_REL_PATH;
|
|
10173
10485
|
if (isShared && keepShared) {
|
|
10174
10486
|
say(
|
|
10175
|
-
`Keeping ${displayPath(root, path44.join(root, rel))}
|
|
10487
|
+
`Keeping ${displayPath(root, path44.join(root, rel))}: a locally-edited superpowers skill still uses it`
|
|
10176
10488
|
);
|
|
10177
10489
|
result.heldBack.set(entry.id, [...result.heldBack.get(entry.id) ?? [], rel]);
|
|
10178
10490
|
return false;
|
|
@@ -10272,7 +10584,7 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
|
|
|
10272
10584
|
if (!gate.ok) {
|
|
10273
10585
|
const detail = gate.reason === "no-hash" ? "has no lock hash" : "was modified locally";
|
|
10274
10586
|
warn(
|
|
10275
|
-
`Orphaned catalog item ${entry.id} ${detail}
|
|
10587
|
+
`Orphaned catalog item ${entry.id} ${detail}, leaving in place: ${existing.map((e) => e.rel).join(", ")}`
|
|
10276
10588
|
);
|
|
10277
10589
|
refusedIds.add(entry.id);
|
|
10278
10590
|
continue;
|
|
@@ -10367,8 +10679,8 @@ var GITIGNORED_ARTIFACT_PATHS = [
|
|
|
10367
10679
|
// process state and machine-local diagnostics, meaningless in another checkout.
|
|
10368
10680
|
".haus-workflow/locks/",
|
|
10369
10681
|
".haus-workflow/logs/",
|
|
10370
|
-
// The request pipeline's archive (#472, ADR-0103): per-request pricing material
|
|
10371
|
-
// specs, impact analyses, scores
|
|
10682
|
+
// The request pipeline's archive (#472, ADR-0103): per-request pricing material,
|
|
10683
|
+
// specs, impact analyses, scores, written by the request skill, not this CLI. It
|
|
10372
10684
|
// lived at `.haus/requests/` behind a hand-added ignore line OUTSIDE this block,
|
|
10373
10685
|
// so no client repo ever received the rule; `migrate-request-state.ts` moves it
|
|
10374
10686
|
// here precisely so this block can own it. Note `.haus-workflow/request.json`
|
|
@@ -10408,7 +10720,7 @@ function buildBlockFor(existing, beginMarker, endMarker, comment, patterns) {
|
|
|
10408
10720
|
if (needed.length === 0) return null;
|
|
10409
10721
|
return [beginMarker, comment, ...needed, endMarker].join("\n");
|
|
10410
10722
|
}
|
|
10411
|
-
var ARTIFACT_BLOCK_COMMENT = "# haus machine-local artifacts
|
|
10723
|
+
var ARTIFACT_BLOCK_COMMENT = "# haus machine-local artifacts, must never be committed (see ADR-0025, ADR-0058, ADR-0009)";
|
|
10412
10724
|
function injectBlockFor(existing, beginMarker, endMarker, comment, patterns) {
|
|
10413
10725
|
const range = findBlockRangeFor(existing, beginMarker, endMarker);
|
|
10414
10726
|
if (!range && findLineMarker(existing, beginMarker)) return { refused: "lone-begin" };
|
|
@@ -10436,7 +10748,7 @@ var LINKED_CONTEXT_GITIGNORE_PATTERNS = [
|
|
|
10436
10748
|
".claude/agents/*--*.md",
|
|
10437
10749
|
".claude/commands/*--*.md"
|
|
10438
10750
|
];
|
|
10439
|
-
var LINK_CONTEXT_BLOCK_COMMENT = "# haus workspace link-context copies
|
|
10751
|
+
var LINK_CONTEXT_BLOCK_COMMENT = "# haus workspace link-context copies: derived cross-repo skill/agent/command copies, never committed";
|
|
10440
10752
|
function injectLinkContextGitignoreBlock(existing) {
|
|
10441
10753
|
return injectBlockFor(
|
|
10442
10754
|
existing,
|
|
@@ -10504,7 +10816,7 @@ async function untrackMachineLocalArtifacts(root, dryRun, notify) {
|
|
|
10504
10816
|
for (const rel of tracked) {
|
|
10505
10817
|
if (dryRun) {
|
|
10506
10818
|
notify(
|
|
10507
|
-
`Would untrack ${rel} from git
|
|
10819
|
+
`Would untrack ${rel} from git: it is machine-local haus output and should never have been committed.`
|
|
10508
10820
|
);
|
|
10509
10821
|
continue;
|
|
10510
10822
|
}
|
|
@@ -10512,7 +10824,7 @@ async function untrackMachineLocalArtifacts(root, dryRun, notify) {
|
|
|
10512
10824
|
if (result.exitCode === 0) {
|
|
10513
10825
|
untracked.push(rel);
|
|
10514
10826
|
notify(
|
|
10515
|
-
`Untracked ${rel} from git
|
|
10827
|
+
`Untracked ${rel} from git: it is machine-local haus output and should never have been committed. Run \`git status\` to review before committing this change.`
|
|
10516
10828
|
);
|
|
10517
10829
|
} else {
|
|
10518
10830
|
notify(`Could not untrack ${rel} from git: ${result.stderr.trim() || result.stdout.trim()}`);
|
|
@@ -10669,7 +10981,7 @@ async function probePluginEnv(deps = {}) {
|
|
|
10669
10981
|
return { status: "absent" };
|
|
10670
10982
|
case "inconclusive":
|
|
10671
10983
|
warn2(
|
|
10672
|
-
`Could not determine whether the \`claude\` CLI is on PATH (${probe.reason})
|
|
10984
|
+
`Could not determine whether the \`claude\` CLI is on PATH (${probe.reason}), leaving global plugins untouched this run.`
|
|
10673
10985
|
);
|
|
10674
10986
|
return { status: "inconclusive", reason: probe.reason };
|
|
10675
10987
|
default:
|
|
@@ -10698,7 +11010,7 @@ async function probePluginEnv(deps = {}) {
|
|
|
10698
11010
|
]);
|
|
10699
11011
|
if (listed.exitCode !== 0 || markets.exitCode !== 0) {
|
|
10700
11012
|
warn2(
|
|
10701
|
-
`Could not read Claude Code plugin state (${listed.exitCode !== 0 ? "plugin list" : "marketplace list"} exited ${listed.exitCode !== 0 ? listed.exitCode : markets.exitCode})
|
|
11013
|
+
`Could not read Claude Code plugin state (${listed.exitCode !== 0 ? "plugin list" : "marketplace list"} exited ${listed.exitCode !== 0 ? listed.exitCode : markets.exitCode}), leaving global plugins untouched.`
|
|
10702
11014
|
);
|
|
10703
11015
|
return unread(
|
|
10704
11016
|
"query-failed",
|
|
@@ -10709,7 +11021,7 @@ async function probePluginEnv(deps = {}) {
|
|
|
10709
11021
|
const installed = parseInstalled(listed.stdout);
|
|
10710
11022
|
const marketplaces = await parseMarketplaces(markets.stdout, configDir);
|
|
10711
11023
|
if (installed === null || marketplaces === null) {
|
|
10712
|
-
warn2("Claude Code returned unreadable plugin state
|
|
11024
|
+
warn2("Claude Code returned unreadable plugin state, leaving global plugins untouched.");
|
|
10713
11025
|
return unread("unreadable");
|
|
10714
11026
|
}
|
|
10715
11027
|
const userEnabledPlugins = await readUserEnabledPlugins(configDir);
|
|
@@ -10783,7 +11095,7 @@ async function ensureGlobalPlugins(pins, deps = {}) {
|
|
|
10783
11095
|
for (const action of actions) {
|
|
10784
11096
|
if (action.kind === "unavailable") {
|
|
10785
11097
|
warn2(
|
|
10786
|
-
`Claude Code CLI not found
|
|
11098
|
+
`Claude Code CLI not found, cannot manage the ${action.pin.id} plugin. Install it manually:`
|
|
10787
11099
|
);
|
|
10788
11100
|
manualCommands(action.pin).forEach((cmd) => warn2(` ${cmd}`));
|
|
10789
11101
|
continue;
|
|
@@ -10801,7 +11113,7 @@ async function ensureGlobalPlugins(pins, deps = {}) {
|
|
|
10801
11113
|
}
|
|
10802
11114
|
if (action.overriddenRef !== void 0 && action.overriddenRef !== action.pin.ref) {
|
|
10803
11115
|
warn2(
|
|
10804
|
-
`Re-pointing marketplace ${action.pin.marketplace} from ${action.overriddenRef} to the haus-pinned ${action.pin.ref}
|
|
11116
|
+
`Re-pointing marketplace ${action.pin.marketplace} from ${action.overriddenRef} to the haus-pinned ${action.pin.ref}: haus manages this plugin's version.`
|
|
10805
11117
|
);
|
|
10806
11118
|
}
|
|
10807
11119
|
if (dryRun) {
|
|
@@ -10849,7 +11161,7 @@ async function removeGlobalPlugins(plugins, deps = {}) {
|
|
|
10849
11161
|
const state = await probePluginEnv(deps);
|
|
10850
11162
|
if (!state.available) {
|
|
10851
11163
|
result.unavailable = true;
|
|
10852
|
-
warn2("Claude Code CLI not found
|
|
11164
|
+
warn2("Claude Code CLI not found, leaving the haus-managed global plugin(s) in place.");
|
|
10853
11165
|
return result;
|
|
10854
11166
|
}
|
|
10855
11167
|
const scopeKey = (id, scope) => `${id}@@${scope ?? "user"}`;
|
|
@@ -11110,7 +11422,7 @@ async function resolveWorkspaceForWorktree(start) {
|
|
|
11110
11422
|
if (!hasYaml && !hasManifest) {
|
|
11111
11423
|
return {
|
|
11112
11424
|
ok: false,
|
|
11113
|
-
reason: `No workspace found at ${workspaceRoot}
|
|
11425
|
+
reason: `No workspace found at ${workspaceRoot}: expected ${WORKSPACE_FILE} or ${REPOS_MANIFEST_FILE}. Run \`haus workspace discover --write\` or \`haus workspace init\` first.`
|
|
11114
11426
|
};
|
|
11115
11427
|
}
|
|
11116
11428
|
return { ok: true, rootInfo, workspaceRoot };
|
|
@@ -11131,7 +11443,7 @@ function isValidWorktreeSlug(slug) {
|
|
|
11131
11443
|
function worktreePath(mainRoot, slug) {
|
|
11132
11444
|
if (!isValidWorktreeSlug(slug)) {
|
|
11133
11445
|
throw new Error(
|
|
11134
|
-
`Invalid worktree slug ${JSON.stringify(slug)}
|
|
11446
|
+
`Invalid worktree slug ${JSON.stringify(slug)}: slugs may only contain letters, digits, ".", "_", "-" (no path separators, and not "." or "..").`
|
|
11135
11447
|
);
|
|
11136
11448
|
}
|
|
11137
11449
|
return path50.join(worktreesDir(mainRoot), slug);
|
|
@@ -12531,14 +12843,14 @@ async function applyRun(options) {
|
|
|
12531
12843
|
const skippedIds = new Set((rec2?.skipped ?? []).map((s) => s.id));
|
|
12532
12844
|
for (const id of explicitIds.filter((id2) => !installableIds.has(id2))) {
|
|
12533
12845
|
if (configIds.has(id)) {
|
|
12534
|
-
warn(`--ids: "${id}" is a config item
|
|
12846
|
+
warn(`--ids: "${id}" is a config item. Install it with \`haus configs ${id}\`. Ignoring.`);
|
|
12535
12847
|
} else if (skippedIds.has(id)) {
|
|
12536
12848
|
warn(
|
|
12537
|
-
`--ids: "${id}" is not currently recommended (skipped)
|
|
12849
|
+
`--ids: "${id}" is not currently recommended (skipped). Add it with \`haus recommend --include ${id}\` first. Ignoring.`
|
|
12538
12850
|
);
|
|
12539
12851
|
} else {
|
|
12540
12852
|
warn(
|
|
12541
|
-
`--ids: "${id}" is not in recommendation.json
|
|
12853
|
+
`--ids: "${id}" is not in recommendation.json. Run \`haus recommend\` (or \`--include ${id}\`) first. Ignoring.`
|
|
12542
12854
|
);
|
|
12543
12855
|
}
|
|
12544
12856
|
}
|
|
@@ -12553,7 +12865,7 @@ async function applyRun(options) {
|
|
|
12553
12865
|
}
|
|
12554
12866
|
const rec2 = await readJson(hausPath(root, "recommendation.json"));
|
|
12555
12867
|
if (!rec2) {
|
|
12556
|
-
log("No recommendation.json found
|
|
12868
|
+
log("No recommendation.json found. Run `haus recommend` first. Writing core files only.");
|
|
12557
12869
|
selectedIds = [];
|
|
12558
12870
|
} else {
|
|
12559
12871
|
const items = installableRecommendedItems(rec2.recommended);
|
|
@@ -12562,7 +12874,7 @@ async function applyRun(options) {
|
|
|
12562
12874
|
selectedIds = [];
|
|
12563
12875
|
} else {
|
|
12564
12876
|
const choices = items.map((item) => ({
|
|
12565
|
-
name: `${item.id} [${item.selectionMode}]
|
|
12877
|
+
name: `${item.id} [${item.selectionMode}]: ${item.reason}`,
|
|
12566
12878
|
value: item.id,
|
|
12567
12879
|
checked: true
|
|
12568
12880
|
}));
|
|
@@ -12608,7 +12920,7 @@ async function applyRun(options) {
|
|
|
12608
12920
|
const message2 = "No catalog content found. Run `haus update` first.";
|
|
12609
12921
|
if (!options.allowEmptyCache) {
|
|
12610
12922
|
if (isDryRun) {
|
|
12611
|
-
warn(`Catalog cache is empty
|
|
12923
|
+
warn(`Catalog cache is empty: dry-run will skip catalog items. ${message2}`);
|
|
12612
12924
|
} else {
|
|
12613
12925
|
error(message2);
|
|
12614
12926
|
process.exitCode = 1;
|
|
@@ -12630,7 +12942,7 @@ async function applyRun(options) {
|
|
|
12630
12942
|
const cacheAgeDays = Math.floor(cacheAgeMs / (1e3 * 60 * 60 * 24));
|
|
12631
12943
|
if (cacheAgeDays >= STALE_CACHE_THRESHOLD_DAYS) {
|
|
12632
12944
|
warn(
|
|
12633
|
-
`Catalog cache is ${cacheAgeDays} days old
|
|
12945
|
+
`Catalog cache is ${cacheAgeDays} days old. Run \`haus update\` for the latest content.`
|
|
12634
12946
|
);
|
|
12635
12947
|
}
|
|
12636
12948
|
}
|
|
@@ -12667,7 +12979,7 @@ async function applyRun(options) {
|
|
|
12667
12979
|
if (isOffline()) {
|
|
12668
12980
|
const refCount = collectLlmsTxtUrls(installedItems).length;
|
|
12669
12981
|
if (refCount > 0)
|
|
12670
|
-
log(`Offline
|
|
12982
|
+
log(`Offline: skipping ${refCount} llms.txt reference fetch(es); using the cache as-is.`);
|
|
12671
12983
|
} else {
|
|
12672
12984
|
const summary = await fetchRefsForItems(installedItems, cacheDir);
|
|
12673
12985
|
if (summary.fetched > 0)
|
|
@@ -12694,11 +13006,11 @@ async function applyRun(options) {
|
|
|
12694
13006
|
const dirList = dirs.join(" and ");
|
|
12695
13007
|
const verb = dirs.length === 1 ? "is" : "are";
|
|
12696
13008
|
warn(
|
|
12697
|
-
`${dirList} ${verb} gitignored
|
|
13009
|
+
`${dirList} ${verb} gitignored: installed skills/agents/commands will not be visible to anything relying on git-tracked state. Remove the covering .gitignore rule (only settings.json, settings.local.json, and worktrees/ under .claude/ should ever be ignored).`
|
|
12698
13010
|
);
|
|
12699
13011
|
}
|
|
12700
13012
|
if (isDryRun) {
|
|
12701
|
-
log(`Dry-run complete
|
|
13013
|
+
log(`Dry-run complete: ${files.length} file(s) planned, none written. Run --write to apply.`);
|
|
12702
13014
|
return { report: report2, dryRun: isDryRun };
|
|
12703
13015
|
}
|
|
12704
13016
|
log("Applied files:");
|
|
@@ -12712,7 +13024,7 @@ async function applyRun(options) {
|
|
|
12712
13024
|
log(`${installedWantedCount} of ${countedIds.length} catalog items installed (${tier} tier).`);
|
|
12713
13025
|
if (installedWantedCount === 0) {
|
|
12714
13026
|
error(
|
|
12715
|
-
"No catalog items were installed
|
|
13027
|
+
"No catalog items were installed, only core files were written. Run `haus update` with network access, or import a catalog bundle."
|
|
12716
13028
|
);
|
|
12717
13029
|
process.exitCode = 1;
|
|
12718
13030
|
}
|
|
@@ -12760,7 +13072,7 @@ async function confirm(question, io) {
|
|
|
12760
13072
|
async function input(question, io = defaultIO()) {
|
|
12761
13073
|
if (!io.isTTY) {
|
|
12762
13074
|
throw new Error(
|
|
12763
|
-
`Cannot prompt for "${question}" without a TTY
|
|
13075
|
+
`Cannot prompt for "${question}" without a TTY. Pass the value as a flag/argument.`
|
|
12764
13076
|
);
|
|
12765
13077
|
}
|
|
12766
13078
|
for (let i = 0; i < MAX_PROMPT_RETRIES; i++) {
|
|
@@ -12772,7 +13084,7 @@ async function input(question, io = defaultIO()) {
|
|
|
12772
13084
|
async function select(question, choices, io = defaultIO()) {
|
|
12773
13085
|
if (!io.isTTY) {
|
|
12774
13086
|
throw new Error(
|
|
12775
|
-
`Cannot prompt for "${question}" without a TTY
|
|
13087
|
+
`Cannot prompt for "${question}" without a TTY. Pass the value as a flag/argument.`
|
|
12776
13088
|
);
|
|
12777
13089
|
}
|
|
12778
13090
|
const menu = choices.map((c, i) => ` ${i + 1}. ${c.label}`).join("\n");
|
|
@@ -13133,7 +13445,7 @@ async function restoreDirBackup(root, entry, yes, json, dryRun) {
|
|
|
13133
13445
|
const { files, skippedLinks, skippedOther } = await collectFilesRecursive(entry.absPath);
|
|
13134
13446
|
if (skippedLinks.length > 0) {
|
|
13135
13447
|
warn(
|
|
13136
|
-
`Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id}
|
|
13448
|
+
`Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id}, never followed: ${skippedLinks.map((abs) => path57.relative(entry.absPath, abs)).join(", ")}`
|
|
13137
13449
|
);
|
|
13138
13450
|
}
|
|
13139
13451
|
if (skippedOther.length > 0) {
|
|
@@ -13164,7 +13476,7 @@ async function restoreDirBackup(root, entry, yes, json, dryRun) {
|
|
|
13164
13476
|
}
|
|
13165
13477
|
if (unsafeTargets.length > 0) {
|
|
13166
13478
|
warn(
|
|
13167
|
-
`Skipped ${unsafeTargets.length} restore target(s) that are a symlink (or under one) rather than a plain path
|
|
13479
|
+
`Skipped ${unsafeTargets.length} restore target(s) that are a symlink (or under one) rather than a plain path, never written through: ${unsafeTargets.join(", ")}`
|
|
13168
13480
|
);
|
|
13169
13481
|
}
|
|
13170
13482
|
if (safeFiles.length === 0 && lockInBackup === null) {
|
|
@@ -13482,7 +13794,7 @@ async function runBackups(action, opts = {}) {
|
|
|
13482
13794
|
return;
|
|
13483
13795
|
}
|
|
13484
13796
|
if (opts.olderThan === void 0 && opts.keep === void 0) {
|
|
13485
|
-
const message2 = "Specify --older-than <days> or --keep <n
|
|
13797
|
+
const message2 = "Specify --older-than <days> or --keep <n>. Refusing to prune with no bound.";
|
|
13486
13798
|
if (json) log(JSON.stringify({ ok: false, error: message2 }));
|
|
13487
13799
|
error(message2);
|
|
13488
13800
|
process.exitCode = 1;
|
|
@@ -13492,7 +13804,7 @@ async function runBackups(action, opts = {}) {
|
|
|
13492
13804
|
if (opts.olderThan !== void 0) {
|
|
13493
13805
|
const n = Number(opts.olderThan);
|
|
13494
13806
|
if (!Number.isFinite(n) || n < 0) {
|
|
13495
|
-
const message2 = `Invalid --older-than value "${opts.olderThan}"
|
|
13807
|
+
const message2 = `Invalid --older-than value "${opts.olderThan}": expected a non-negative number of days.`;
|
|
13496
13808
|
if (json) log(JSON.stringify({ ok: false, error: message2 }));
|
|
13497
13809
|
error(message2);
|
|
13498
13810
|
process.exitCode = 1;
|
|
@@ -13504,7 +13816,7 @@ async function runBackups(action, opts = {}) {
|
|
|
13504
13816
|
if (opts.keep !== void 0) {
|
|
13505
13817
|
const n = Number(opts.keep);
|
|
13506
13818
|
if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) {
|
|
13507
|
-
const message2 = `Invalid --keep value "${opts.keep}"
|
|
13819
|
+
const message2 = `Invalid --keep value "${opts.keep}": expected a non-negative integer.`;
|
|
13508
13820
|
if (json) log(JSON.stringify({ ok: false, error: message2 }));
|
|
13509
13821
|
error(message2);
|
|
13510
13822
|
process.exitCode = 1;
|
|
@@ -13578,7 +13890,7 @@ async function runBundleExport(options) {
|
|
|
13578
13890
|
const cacheDir = getCacheDir();
|
|
13579
13891
|
const manifest = await readJson(path58.join(cacheDir, MANIFEST_FILE));
|
|
13580
13892
|
if (!manifest?.items?.length) {
|
|
13581
|
-
error("Catalog cache is empty
|
|
13893
|
+
error("Catalog cache is empty. Run `haus update` on a connected machine first.");
|
|
13582
13894
|
process.exitCode = 1;
|
|
13583
13895
|
return;
|
|
13584
13896
|
}
|
|
@@ -13724,9 +14036,9 @@ async function runBundleImport(file, options = {}) {
|
|
|
13724
14036
|
cwd: staging,
|
|
13725
14037
|
// Refuse anything that would land outside the staging dir. node-tar's own
|
|
13726
14038
|
// extraction already refuses `..`, absolute paths, and symlink escapes by
|
|
13727
|
-
// default (preservePaths is left unset)
|
|
14039
|
+
// default (preservePaths is left unset), this filter is a second, independent
|
|
13728
14040
|
// check using the same path-safety module the network sync uses. Both this
|
|
13729
|
-
// filter's refusals and node-tar's own built-in ones are logged
|
|
14041
|
+
// filter's refusals and node-tar's own built-in ones are logged, a tampered
|
|
13730
14042
|
// bundle's dropped entries must be visible, not silently absorbed.
|
|
13731
14043
|
filter: (entryPath) => {
|
|
13732
14044
|
const safe = isSafeCatalogPath(entryPath);
|
|
@@ -13741,7 +14053,7 @@ async function runBundleImport(file, options = {}) {
|
|
|
13741
14053
|
}
|
|
13742
14054
|
const manifest = await readJson(path58.join(staging, MANIFEST_FILE));
|
|
13743
14055
|
if (!manifest?.items?.length) {
|
|
13744
|
-
error("Bundle contains no catalog manifest
|
|
14056
|
+
error("Bundle contains no catalog manifest, refusing to import.");
|
|
13745
14057
|
process.exitCode = 1;
|
|
13746
14058
|
return;
|
|
13747
14059
|
}
|
|
@@ -14811,7 +15123,7 @@ async function runDecisionsCheck(root, opts = {}) {
|
|
|
14811
15123
|
return {
|
|
14812
15124
|
triggered: false,
|
|
14813
15125
|
satisfied: true,
|
|
14814
|
-
reasons: ["catalog-sync branch
|
|
15126
|
+
reasons: ["catalog-sync branch, exempt"],
|
|
14815
15127
|
securityTriggered: false,
|
|
14816
15128
|
...noRecordFailure()
|
|
14817
15129
|
};
|
|
@@ -14895,7 +15207,7 @@ async function auditDecisionsLayout(root, profile) {
|
|
|
14895
15207
|
if (PROFILES[profile].writesDecisionsSeed) {
|
|
14896
15208
|
findings.push({
|
|
14897
15209
|
level: "advisory",
|
|
14898
|
-
line: `- DECISIONS: legacy ${DECISIONS_TRIGGERS.legacyDecisionsDir}/ present
|
|
15210
|
+
line: `- DECISIONS: legacy ${DECISIONS_TRIGGERS.legacyDecisionsDir}/ present, migrate to docs/decisions/`,
|
|
14899
15211
|
sentence: "Decision records still live under the legacy docs/adr/ path",
|
|
14900
15212
|
fix: `git mv ${DECISIONS_TRIGGERS.legacyDecisionsDir} ${DECISIONS_TRIGGERS.decisionsDir}`
|
|
14901
15213
|
});
|
|
@@ -15219,7 +15531,7 @@ function printConfigDirMigration(result) {
|
|
|
15219
15531
|
if (!result.planned) log("Rewrote the install manifest to the new location.");
|
|
15220
15532
|
}
|
|
15221
15533
|
for (const conflict of result.conflicts) {
|
|
15222
|
-
warn(`Left in place
|
|
15534
|
+
warn(`Left in place: a different file already exists at the destination: ${conflict}`);
|
|
15223
15535
|
}
|
|
15224
15536
|
for (const failure2 of result.failures) {
|
|
15225
15537
|
warn(`Could not move ${failure2.path}: ${failure2.reason}`);
|
|
@@ -15304,12 +15616,12 @@ async function checkPrereqs(deps = {}) {
|
|
|
15304
15616
|
function formatPrereqReport(report2) {
|
|
15305
15617
|
const lines = report2.checks.map((check) => {
|
|
15306
15618
|
const label = check.status === "ok" ? "OK " : check.status === "missing" ? "MISSING" : "UNKNOWN";
|
|
15307
|
-
const head = `- ${label} ${check.name}
|
|
15619
|
+
const head = `- ${label} ${check.name}: ${check.detail}`;
|
|
15308
15620
|
return check.fix ? `${head}
|
|
15309
15621
|
fix: ${check.fix}` : head;
|
|
15310
15622
|
});
|
|
15311
15623
|
const unknown = report2.checks.filter((check) => check.status === "unknown");
|
|
15312
|
-
const verdict = report2.ok ? unknown.length > 0 ? `This machine looks ready, though ${unknown.length} check(s) could not complete.` : "This machine is ready to run haus." : "Some prerequisites are missing
|
|
15624
|
+
const verdict = report2.ok ? unknown.length > 0 ? `This machine looks ready, though ${unknown.length} check(s) could not complete.` : "This machine is ready to run haus." : "Some prerequisites are missing. The fixes are listed above.";
|
|
15313
15625
|
return [...lines, "", verdict].join("\n");
|
|
15314
15626
|
}
|
|
15315
15627
|
|
|
@@ -15350,6 +15662,11 @@ async function fetchNpmVersionStatus(currentVersion) {
|
|
|
15350
15662
|
init_fs();
|
|
15351
15663
|
init_logger();
|
|
15352
15664
|
init_paths();
|
|
15665
|
+
function joinFix(sentence, label, fix) {
|
|
15666
|
+
const trimmed = sentence.replace(/[:;]+$/, "");
|
|
15667
|
+
const closed = /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`;
|
|
15668
|
+
return `${closed} ${label}: ${fix}`;
|
|
15669
|
+
}
|
|
15353
15670
|
async function runDoctor(options, deps = {}) {
|
|
15354
15671
|
const root = process.cwd();
|
|
15355
15672
|
if (options?.prereqs) {
|
|
@@ -15397,7 +15714,7 @@ async function runDoctor(options, deps = {}) {
|
|
|
15397
15714
|
};
|
|
15398
15715
|
const SETTINGS_HAND_FIX = "fix .claude/settings.json by hand, then run haus apply --write";
|
|
15399
15716
|
const UNCLOSED_BLOCK_FIX = "close the block by hand (put the HAUS:END line back, or delete the HAUS:BEGIN line), then run haus apply --write";
|
|
15400
|
-
const notOwnedHeaderFile = (rel, refusal2) => {
|
|
15717
|
+
const notOwnedHeaderFile = (rel, id, refusal2) => {
|
|
15401
15718
|
if (refusal2.kind === "not-a-file") {
|
|
15402
15719
|
const what = notAFileDescription(refusal2.what);
|
|
15403
15720
|
suggest(
|
|
@@ -15410,9 +15727,15 @@ async function runDoctor(options, deps = {}) {
|
|
|
15410
15727
|
suggest(
|
|
15411
15728
|
`- ${rel}: not owned (no haus header). haus refuses it; haus apply --write --force replaces it after a backup`,
|
|
15412
15729
|
`${rel} has no haus header and is not a version haus wrote`,
|
|
15413
|
-
|
|
15730
|
+
`keep it as your own (haus remove ${id} marks it, so apply and doctor stop naming it), or replace it with haus apply --write --force (backed up first)`
|
|
15414
15731
|
);
|
|
15415
15732
|
};
|
|
15733
|
+
const headerMarks = await readProjectExcludedIdsIfReadable(root) ?? [];
|
|
15734
|
+
const markedHeaderRels = new Set(
|
|
15735
|
+
headerMarks.flatMap(
|
|
15736
|
+
(id) => Object.hasOwn(HEADER_FILE_RELS, id) ? [HEADER_FILE_RELS[id]] : []
|
|
15737
|
+
)
|
|
15738
|
+
);
|
|
15416
15739
|
ok2(`Repo: ${context.repoName}`);
|
|
15417
15740
|
ok2(`Roles: ${context.repoRoles.join(", ") || "unknown"}`);
|
|
15418
15741
|
if (context.detectionStatus === "unknown") {
|
|
@@ -15427,7 +15750,7 @@ async function runDoctor(options, deps = {}) {
|
|
|
15427
15750
|
} else {
|
|
15428
15751
|
ok2("Recommended items: none recorded yet (run `haus recommend`)");
|
|
15429
15752
|
suggest(
|
|
15430
|
-
"- CATALOG ITEMS: no recommendation.json
|
|
15753
|
+
"- CATALOG ITEMS: no recommendation.json, so the missing-item drift and orphaned-item checks did not run",
|
|
15431
15754
|
"No recommendation has been computed for this project yet, so two of the checks were skipped",
|
|
15432
15755
|
"haus recommend (or `haus apply --write` to set the project up)"
|
|
15433
15756
|
);
|
|
@@ -15440,7 +15763,7 @@ async function runDoctor(options, deps = {}) {
|
|
|
15440
15763
|
if (hooks.skipped) {
|
|
15441
15764
|
flag(
|
|
15442
15765
|
`- HOOKS: (skipped) ${hooks.message}`,
|
|
15443
|
-
"No Claude Code settings.json
|
|
15766
|
+
"No Claude Code settings.json, so the haus security hooks are not installed",
|
|
15444
15767
|
"haus apply --write"
|
|
15445
15768
|
);
|
|
15446
15769
|
} else if (!hooks.ok) {
|
|
@@ -15520,6 +15843,7 @@ async function runDoctor(options, deps = {}) {
|
|
|
15520
15843
|
const resolved = path67.resolve(root, target);
|
|
15521
15844
|
const relFromRoot = path67.relative(root, resolved);
|
|
15522
15845
|
const escapesRoot = relFromRoot === ".." || relFromRoot.startsWith(`..${path67.sep}`) || path67.isAbsolute(relFromRoot);
|
|
15846
|
+
if (!escapesRoot && markedHeaderRels.has(relFromRoot.split(path67.sep).join("/"))) continue;
|
|
15523
15847
|
if (escapesRoot || !await fs45.pathExists(resolved)) {
|
|
15524
15848
|
flag(
|
|
15525
15849
|
`- CLAUDE.md import: @${target} does not resolve (run \`haus apply --write\`)`,
|
|
@@ -15539,7 +15863,8 @@ async function runDoctor(options, deps = {}) {
|
|
|
15539
15863
|
}
|
|
15540
15864
|
const workflowPath = hausPath(root, "WORKFLOW.md");
|
|
15541
15865
|
const workflowOnDisk = await inspectHeaderHashedFile(workflowPath);
|
|
15542
|
-
if (
|
|
15866
|
+
if (headerMarks.includes(WORKFLOW_ID)) {
|
|
15867
|
+
} else if (workflowOnDisk.kind === "absent") {
|
|
15543
15868
|
flag(
|
|
15544
15869
|
"- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
|
|
15545
15870
|
"The workflow standard file is missing",
|
|
@@ -15549,7 +15874,7 @@ async function runDoctor(options, deps = {}) {
|
|
|
15549
15874
|
const onDisk = workflowOnDisk;
|
|
15550
15875
|
const workflowContent = onDisk.kind === "unreadable" ? null : verdictText(onDisk);
|
|
15551
15876
|
if (workflowContent !== null && typeof workflowContent !== "string") {
|
|
15552
|
-
notOwnedHeaderFile(".haus-workflow/WORKFLOW.md", workflowContent);
|
|
15877
|
+
notOwnedHeaderFile(".haus-workflow/WORKFLOW.md", WORKFLOW_ID, workflowContent);
|
|
15553
15878
|
} else if (workflowContent !== null) {
|
|
15554
15879
|
const firstLine3 = workflowContent.split("\n")[0] ?? "";
|
|
15555
15880
|
const bodyContent = parseHausManagedHeader(firstLine3) === null ? workflowContent : workflowContent.slice(firstLine3.length + 1);
|
|
@@ -15583,7 +15908,7 @@ async function runDoctor(options, deps = {}) {
|
|
|
15583
15908
|
);
|
|
15584
15909
|
} else if (verdict.outcome === "installed") {
|
|
15585
15910
|
suggest(
|
|
15586
|
-
"- .haus-workflow/WORKFLOW.md: stale (template updated
|
|
15911
|
+
"- .haus-workflow/WORKFLOW.md: stale (template updated, run `haus apply --write`)",
|
|
15587
15912
|
"The workflow standard is out of date",
|
|
15588
15913
|
"haus apply --write"
|
|
15589
15914
|
);
|
|
@@ -15592,14 +15917,14 @@ async function runDoctor(options, deps = {}) {
|
|
|
15592
15917
|
} else if (verdict.refusal?.kind === "another-id") {
|
|
15593
15918
|
ok2("- .haus-workflow/WORKFLOW.md: OK (user-owned)");
|
|
15594
15919
|
} else if (verdict.refusal?.kind === "not-owned") {
|
|
15595
|
-
notOwnedHeaderFile(".haus-workflow/WORKFLOW.md", verdict.refusal);
|
|
15920
|
+
notOwnedHeaderFile(".haus-workflow/WORKFLOW.md", WORKFLOW_ID, verdict.refusal);
|
|
15596
15921
|
} else {
|
|
15597
15922
|
ok2("- .haus-workflow/WORKFLOW.md: OK");
|
|
15598
15923
|
}
|
|
15599
15924
|
}
|
|
15600
15925
|
}
|
|
15601
15926
|
const rulesOnDisk = await inspectHeaderHashedFile(claudePath(root, "rules", "haus.md"));
|
|
15602
|
-
const rulesHaus = rulesOnDisk.kind === "unreadable" ? void 0 : headerFileVerdict(verdictText(rulesOnDisk), {
|
|
15927
|
+
const rulesHaus = rulesOnDisk.kind === "unreadable" || headerMarks.includes(RULES_HAUS_ID) ? void 0 : headerFileVerdict(verdictText(rulesOnDisk), {
|
|
15603
15928
|
id: RULES_HAUS_ID,
|
|
15604
15929
|
body: RULES_HAUS_BODY,
|
|
15605
15930
|
knownBodies: KNOWN_RULES_HAUS_BODIES,
|
|
@@ -15609,10 +15934,10 @@ async function runDoctor(options, deps = {}) {
|
|
|
15609
15934
|
suggest(
|
|
15610
15935
|
"- .claude/rules/haus.md: edited (header hash does not match). haus keeps it; haus apply --write --force replaces it after a backup",
|
|
15611
15936
|
"The haus rule (.claude/rules/haus.md) was edited after haus wrote it, so haus keeps it as it is",
|
|
15612
|
-
|
|
15937
|
+
`keep it (haus remove ${RULES_HAUS_ID} marks it as your own, so this stops), or replace it with haus apply --write --force (backed up first)`
|
|
15613
15938
|
);
|
|
15614
15939
|
} else if (rulesHaus?.refusal?.kind === "not-owned" || rulesHaus?.refusal?.kind === "not-a-file") {
|
|
15615
|
-
notOwnedHeaderFile(".claude/rules/haus.md", rulesHaus.refusal);
|
|
15940
|
+
notOwnedHeaderFile(".claude/rules/haus.md", RULES_HAUS_ID, rulesHaus.refusal);
|
|
15616
15941
|
}
|
|
15617
15942
|
const workflowConfigPath = hausPath(root, "workflow-config.md");
|
|
15618
15943
|
const workflowConfigExists = await fs45.pathExists(workflowConfigPath);
|
|
@@ -15640,6 +15965,10 @@ async function runDoctor(options, deps = {}) {
|
|
|
15640
15965
|
ok2(finding.line);
|
|
15641
15966
|
continue;
|
|
15642
15967
|
}
|
|
15968
|
+
if (finding.level === "attention") {
|
|
15969
|
+
flag(finding.line, finding.sentence, finding.fix);
|
|
15970
|
+
continue;
|
|
15971
|
+
}
|
|
15643
15972
|
suggest(finding.line, finding.sentence, finding.fix);
|
|
15644
15973
|
for (const line2 of finding.details ?? []) detail.push({ stream: "warn", text: ` ${line2}` });
|
|
15645
15974
|
}
|
|
@@ -15651,7 +15980,7 @@ async function runDoctor(options, deps = {}) {
|
|
|
15651
15980
|
ok2("- PROJECT DOCS: OK (docs/SUMMARY.md present)");
|
|
15652
15981
|
} else if (PROFILES[profile].runsDocsPass) {
|
|
15653
15982
|
suggest(
|
|
15654
|
-
"- PROJECT DOCS: docs/SUMMARY.md missing
|
|
15983
|
+
"- PROJECT DOCS: docs/SUMMARY.md missing. Run the writing-documentation skill (`/docs` in Claude Code) to generate the project docs",
|
|
15655
15984
|
"The project docs index (docs/SUMMARY.md) has not been generated yet",
|
|
15656
15985
|
"run `/docs` in Claude Code (the writing-documentation skill)"
|
|
15657
15986
|
);
|
|
@@ -15741,7 +16070,7 @@ async function runDoctor(options, deps = {}) {
|
|
|
15741
16070
|
const dirList = dirs.join(" and ");
|
|
15742
16071
|
const verb = dirs.length === 1 ? "is" : "are";
|
|
15743
16072
|
flag(
|
|
15744
|
-
`- GITIGNORE (haus content): ${dirList} ${verb} gitignored
|
|
16073
|
+
`- GITIGNORE (haus content): ${dirList} ${verb} gitignored: installed skills/agents/commands will not be visible to anything relying on git-tracked state`,
|
|
15745
16074
|
`${dirList} ${verb} gitignored, so haus-installed content isn't visible to git-tracked state`,
|
|
15746
16075
|
"remove the covering .gitignore rule (only settings.json, settings.local.json, and worktrees/ under .claude/ should ever be ignored)"
|
|
15747
16076
|
);
|
|
@@ -15762,7 +16091,7 @@ async function runDoctor(options, deps = {}) {
|
|
|
15762
16091
|
flag(
|
|
15763
16092
|
`- CATALOG: ${err.message}`,
|
|
15764
16093
|
err.kind === "too-new" ? "The catalog this project uses needs a newer haus than the one installed" : "The catalog this project uses declares a manifestFormat that is not a valid format number",
|
|
15765
|
-
err.kind === "too-new" ? "npm install -g @haus-tech/haus-workflow@latest" : "fix `manifestFormat` in the catalog manifest
|
|
16094
|
+
err.kind === "too-new" ? "npm install -g @haus-tech/haus-workflow@latest" : "fix `manifestFormat` in the catalog manifest: it must be a positive integer (upgrading haus will not help)"
|
|
15766
16095
|
);
|
|
15767
16096
|
}
|
|
15768
16097
|
const profileRules = catalogCtx ? parseProfileRules(
|
|
@@ -15772,7 +16101,7 @@ async function runDoctor(options, deps = {}) {
|
|
|
15772
16101
|
) : null;
|
|
15773
16102
|
if (catalogCtx && profileRules === null) {
|
|
15774
16103
|
flag(
|
|
15775
|
-
`- PROFILE: the ${describeCatalogTier(catalogCtx)} catalog defines no "${profile}" profile
|
|
16104
|
+
`- PROFILE: the ${describeCatalogTier(catalogCtx)} catalog defines no "${profile}" profile, so \`haus apply\` refuses until it does`,
|
|
15776
16105
|
`This project is set to the ${profile} profile, but the catalog it uses defines no such profile (needs haus-workflow-catalog >= 6.5.0)`,
|
|
15777
16106
|
'haus update (or set "profile" back to "production" in .haus-workflow/haus.config.json)'
|
|
15778
16107
|
);
|
|
@@ -15805,7 +16134,7 @@ async function runDoctor(options, deps = {}) {
|
|
|
15805
16134
|
suggest(
|
|
15806
16135
|
`- GLOBAL PLUGINS: ${marketplaceMissing.length} built-in marketplace not listed (${marketplaceMissing.map((a) => `${a.pin.id}: ${a.reason}`).join("; ")})`,
|
|
15807
16136
|
`${marketplaceMissing.length} haus-managed plugin(s) sit on a built-in marketplace this Claude Code does not list`,
|
|
15808
|
-
"update Claude Code (the claude-plugins-official marketplace is built in)
|
|
16137
|
+
"update Claude Code (the claude-plugins-official marketplace is built in), or: claude plugin marketplace add anthropics/claude-plugins-official"
|
|
15809
16138
|
);
|
|
15810
16139
|
}
|
|
15811
16140
|
if (otherPending.length > 0) {
|
|
@@ -15911,7 +16240,7 @@ async function runDoctor(options, deps = {}) {
|
|
|
15911
16240
|
suggest(
|
|
15912
16241
|
`- GLOBAL PLUGINS: ${untrackedDuplicates.length} project skill folder(s) duplicate the global plugin but are not lock-tracked (${untrackedDuplicates.slice(0, 3).join(", ")}${untrackedDuplicates.length > 3 ? ", \u2026" : ""})`,
|
|
15913
16242
|
`${untrackedDuplicates.length} skill folder(s) load twice in every session; haus does not track them, so no command will remove them`,
|
|
15914
|
-
`delete .claude/skills/{${untrackedDuplicates.join(",")}}
|
|
16243
|
+
`delete .claude/skills/{${untrackedDuplicates.join(",")}} (the global plugin already provides them)`
|
|
15915
16244
|
);
|
|
15916
16245
|
}
|
|
15917
16246
|
}
|
|
@@ -16037,8 +16366,8 @@ async function runDoctor(options, deps = {}) {
|
|
|
16037
16366
|
}).join("; ");
|
|
16038
16367
|
suggest(
|
|
16039
16368
|
`- AGENT REFS: ${gatedOut.length} reference(s) name ${referent(gatedOut)} the catalog gates out of this project's stack (${unmet}) (${gatedOut.slice(0, 3).map(describeAgent).join("; ")}${gatedOut.length > 3 ? "; \u2026" : ""})`,
|
|
16040
|
-
`${gatedOut.length} rule/agent reference(s) ${routedTo(gatedOut, names4)}, which the catalog does not offer a project with this stack (${unmet})
|
|
16041
|
-
`nothing to install here: correct or drop the reference upstream (catalog-owned prose)
|
|
16369
|
+
`${gatedOut.length} rule/agent reference(s) ${routedTo(gatedOut, names4)}, which the catalog does not offer a project with this stack (${unmet}): the reference is upstream prose, not a missing install`,
|
|
16370
|
+
`nothing to install here: correct or drop the reference upstream (catalog-owned prose); the catalog gates ${names4.join(", ")} out of this stack on purpose`
|
|
16042
16371
|
);
|
|
16043
16372
|
}
|
|
16044
16373
|
if (unknown.length > 0) {
|
|
@@ -16079,7 +16408,7 @@ async function runDoctor(options, deps = {}) {
|
|
|
16079
16408
|
if (drifted.length > 0) {
|
|
16080
16409
|
suggest(
|
|
16081
16410
|
`- ELIGIBILITY DRIFT: ${drifted.length} installed item(s) are no longer recommended by the current signals (${drifted.slice(0, 3).join(", ")}${drifted.length > 3 ? ", \u2026" : ""})`,
|
|
16082
|
-
`${drifted.length} installed item(s) no longer match anything haus can detect in this repo
|
|
16411
|
+
`${drifted.length} installed item(s) no longer match anything haus can detect in this repo, often a role that only \`deep-context.json\` knew about, which is not tracked in git`,
|
|
16083
16412
|
"declare the role with `haus recommend --declare-role <role>` or the stack in .haus-workflow/haus.config.json (declaredStacks), re-run the docs pass to regenerate deep-context.json, or `haus remove <id>` if the item is genuinely no longer wanted"
|
|
16084
16413
|
);
|
|
16085
16414
|
}
|
|
@@ -16089,7 +16418,7 @@ async function runDoctor(options, deps = {}) {
|
|
|
16089
16418
|
ok2(`- CLAUDE CONFIG DIR: ${configMigration.to} (CLAUDE_CONFIG_DIR)`);
|
|
16090
16419
|
if (configMigration.status === "pending") {
|
|
16091
16420
|
suggest(
|
|
16092
|
-
`- CLAUDE CONFIG DIR: ${configMigration.moved.length} haus file(s) still under ${configMigration.from}
|
|
16421
|
+
`- CLAUDE CONFIG DIR: ${configMigration.moved.length} haus file(s) still under ${configMigration.from}; \`haus install\` moves them`,
|
|
16093
16422
|
"Your haus global install predates CLAUDE_CONFIG_DIR and still sits in ~/.claude",
|
|
16094
16423
|
"haus install"
|
|
16095
16424
|
);
|
|
@@ -16113,18 +16442,18 @@ async function runDoctor(options, deps = {}) {
|
|
|
16113
16442
|
const tierLabel = `${catalogTier}${catalogVersion ? ` (v${catalogVersion})` : ""}`;
|
|
16114
16443
|
if (catalogTier === "bundled") {
|
|
16115
16444
|
suggest(
|
|
16116
|
-
`- CATALOG TIER: ${tierLabel}
|
|
16117
|
-
`Catalog is being served from the snapshot bundled with this CLI (${tierLabel}), frozen at publish time and carrying metadata only
|
|
16445
|
+
`- CATALOG TIER: ${tierLabel}: frozen at publish time, metadata only (${installableItems} installable item(s))`,
|
|
16446
|
+
`Catalog is being served from the snapshot bundled with this CLI (${tierLabel}), frozen at publish time and carrying metadata only: ${installableItems} of its items have installable content, so \`haus apply\` will write core files only`,
|
|
16118
16447
|
"haus update (with network access), or `haus catalog bundle import` on a disconnected machine"
|
|
16119
16448
|
);
|
|
16120
16449
|
} else if (installableItems === 0) {
|
|
16121
16450
|
suggest(
|
|
16122
|
-
`- CATALOG TIER: ${tierLabel}
|
|
16451
|
+
`- CATALOG TIER: ${tierLabel}: 0 installable items (\`haus apply\` will write core files only)`,
|
|
16123
16452
|
`The ${catalogTier} catalog tier has 0 installable items`,
|
|
16124
16453
|
"haus update (or `haus catalog bundle import` on a disconnected machine)"
|
|
16125
16454
|
);
|
|
16126
16455
|
} else {
|
|
16127
|
-
ok2(`- CATALOG TIER: ${tierLabel}
|
|
16456
|
+
ok2(`- CATALOG TIER: ${tierLabel}: ${installableItems} installable item(s)`);
|
|
16128
16457
|
}
|
|
16129
16458
|
}
|
|
16130
16459
|
const cacheAgeMs = await getCacheManifestAge();
|
|
@@ -16138,7 +16467,7 @@ async function runDoctor(options, deps = {}) {
|
|
|
16138
16467
|
const cacheAgeDays = Math.floor(cacheAgeMs / (1e3 * 60 * 60 * 24));
|
|
16139
16468
|
if (cacheAgeDays >= 7) {
|
|
16140
16469
|
suggest(
|
|
16141
|
-
`- CATALOG CACHE: stale (${cacheAgeDays}d old
|
|
16470
|
+
`- CATALOG CACHE: stale (${cacheAgeDays}d old, run \`haus update\`)`,
|
|
16142
16471
|
`The catalog cache is ${cacheAgeDays} days old`,
|
|
16143
16472
|
"haus update"
|
|
16144
16473
|
);
|
|
@@ -16155,7 +16484,7 @@ async function runDoctor(options, deps = {}) {
|
|
|
16155
16484
|
if (recommendation) {
|
|
16156
16485
|
if (!Array.isArray(recommendation.recommended)) {
|
|
16157
16486
|
suggest(
|
|
16158
|
-
`- CATALOG ITEMS: recommendation.json's "recommended" field is not an array
|
|
16487
|
+
`- CATALOG ITEMS: recommendation.json's "recommended" field is not an array, skipping orphaned-item check`,
|
|
16159
16488
|
'recommendation.json looks malformed (its "recommended" field is not an array)',
|
|
16160
16489
|
"run `haus recommend` to regenerate it"
|
|
16161
16490
|
);
|
|
@@ -16282,9 +16611,9 @@ async function runDoctor(options, deps = {}) {
|
|
|
16282
16611
|
if (orphans.length > 0) {
|
|
16283
16612
|
const paths = orphans.map((orphan) => orphan.relPath);
|
|
16284
16613
|
suggest(
|
|
16285
|
-
`- CATALOG RENAMES: ${orphans.length} directory/file left by a rename is not tracked in haus.lock.json (${orphans.map((orphan) => `${orphan.relPath}
|
|
16614
|
+
`- CATALOG RENAMES: ${orphans.length} directory/file left by a rename is not tracked in haus.lock.json (${orphans.map((orphan) => `${orphan.relPath}, renamed to ${orphan.currentId}`).join("; ")})`,
|
|
16286
16615
|
`${orphans.length} leftover item(s) from a catalog rename are still in .claude/ and haus cannot remove them`,
|
|
16287
|
-
`nothing in haus.lock.json records them, so haus leaves them alone
|
|
16616
|
+
`nothing in haus.lock.json records them, so haus leaves them alone. Delete them by hand once you have checked for local edits: rm -rf ${paths.join(" ")}`
|
|
16288
16617
|
);
|
|
16289
16618
|
}
|
|
16290
16619
|
} catch (err) {
|
|
@@ -16377,7 +16706,7 @@ async function runDoctor(options, deps = {}) {
|
|
|
16377
16706
|
if (lingering.length > 0) {
|
|
16378
16707
|
const idList = `${lingering.slice(0, 3).join(", ")}${lingering.length > 3 ? ", \u2026" : ""}`;
|
|
16379
16708
|
suggest(
|
|
16380
|
-
`- CATALOG ITEMS: ${lingering.length} installed item(s) are removed or deprecated in the current catalog (${idList})
|
|
16709
|
+
`- CATALOG ITEMS: ${lingering.length} installed item(s) are removed or deprecated in the current catalog (${idList}); pre-0.30.0 CLIs never pruned these`,
|
|
16381
16710
|
`${lingering.length} installed item(s) no longer exist in the catalog (removed or deprecated)`,
|
|
16382
16711
|
"haus update (prunes them when unmodified; see docs/runbook.md for locally-edited copies)"
|
|
16383
16712
|
);
|
|
@@ -16454,11 +16783,11 @@ async function runDoctor(options, deps = {}) {
|
|
|
16454
16783
|
log("\u2705 Your project is set up and healthy.");
|
|
16455
16784
|
} else {
|
|
16456
16785
|
log(`\u26A0\uFE0F ${attention.length} thing(s) need attention:`);
|
|
16457
|
-
for (const a of attention) log(` \u2022 ${a.sentence
|
|
16786
|
+
for (const a of attention) log(` \u2022 ${joinFix(a.sentence, "Fix", a.fix)}`);
|
|
16458
16787
|
}
|
|
16459
16788
|
if (suggestions.length > 0) {
|
|
16460
16789
|
log(`\u{1F4A1} ${suggestions.length} suggestion(s):`);
|
|
16461
|
-
for (const s of suggestions) log(` \u2022 ${s.sentence
|
|
16790
|
+
for (const s of suggestions) log(` \u2022 ${joinFix(s.sentence, "Try", s.fix)}`);
|
|
16462
16791
|
}
|
|
16463
16792
|
log("Haus Doctor");
|
|
16464
16793
|
for (const line2 of detail) {
|
|
@@ -16488,6 +16817,7 @@ function deployDisagreements(config, evidence) {
|
|
|
16488
16817
|
groups.set(key, group);
|
|
16489
16818
|
};
|
|
16490
16819
|
for (const item of evidence) {
|
|
16820
|
+
if (item.dispatch && !needsHumanClass(item.deploys)) continue;
|
|
16491
16821
|
if (item.dispatch) {
|
|
16492
16822
|
const listed = config.dispatchDeploys.includes(item.dispatch);
|
|
16493
16823
|
add(
|
|
@@ -16531,16 +16861,57 @@ function deployDisagreements(config, evidence) {
|
|
|
16531
16861
|
subject: group.subject,
|
|
16532
16862
|
detected,
|
|
16533
16863
|
fileSays: group.file === void 0 ? group.noRow : `says ${group.file}`,
|
|
16864
|
+
...group.file === void 0 ? {} : { file: group.file },
|
|
16534
16865
|
details: [...new Set(higher.map((item) => item.detail))]
|
|
16535
16866
|
});
|
|
16536
16867
|
}
|
|
16537
16868
|
return out;
|
|
16538
16869
|
}
|
|
16870
|
+
function topDetected(result) {
|
|
16871
|
+
let top;
|
|
16872
|
+
const consider = (subject, deploys) => {
|
|
16873
|
+
if (!top || classRank(deploys) > classRank(top.deploys)) top = { subject, deploys };
|
|
16874
|
+
};
|
|
16875
|
+
for (const [branch, row] of Object.entries(result.config.branches)) {
|
|
16876
|
+
consider(branch, row.deploys);
|
|
16877
|
+
for (const [glob, value] of Object.entries(row.pathDeploys ?? {})) {
|
|
16878
|
+
consider(`a change to ${glob} on ${branch}`, value);
|
|
16879
|
+
}
|
|
16880
|
+
}
|
|
16881
|
+
for (const [tag, value] of Object.entries(result.config.tags)) consider(`tag ${tag}`, value);
|
|
16882
|
+
for (const item of result.evidence) {
|
|
16883
|
+
if (item.dispatch) consider(`${item.dispatch}, when dispatched,`, item.deploys);
|
|
16884
|
+
}
|
|
16885
|
+
return top;
|
|
16886
|
+
}
|
|
16539
16887
|
async function auditDeployConfig(root, workflowConfigPath, deps) {
|
|
16540
16888
|
const rel = DEPLOY_CONFIG_REL;
|
|
16541
16889
|
const fallback2 = `the deploy gate guesses from detection on every merge, push and deploy until it is fixed`;
|
|
16890
|
+
const git = realGit();
|
|
16891
|
+
const detect = async () => {
|
|
16892
|
+
const context = await repoContext(root, git);
|
|
16893
|
+
const branch = context ? await defaultBranch(git, context.root) : void 0;
|
|
16894
|
+
return detectDeployConfig(root, {
|
|
16895
|
+
gh: deps.deployGh ?? (deployDetectOffline() ? void 0 : realGh()),
|
|
16896
|
+
git,
|
|
16897
|
+
slug: context?.slug,
|
|
16898
|
+
defaultBranch: branch,
|
|
16899
|
+
cache: true
|
|
16900
|
+
});
|
|
16901
|
+
};
|
|
16542
16902
|
const found = await readTextDetailed(path67.join(root, rel));
|
|
16543
16903
|
if (found.status === "missing") {
|
|
16904
|
+
const top = topDetected(await detect());
|
|
16905
|
+
if (top && isGatedClass(top.deploys)) {
|
|
16906
|
+
return [
|
|
16907
|
+
{
|
|
16908
|
+
level: "attention",
|
|
16909
|
+
line: `- DEPLOY TARGETS: ${rel} missing while detection says ${top.subject} deploys ${top.deploys} (run \`haus apply --write\`, then set confirmedBy); ${fallback2}`,
|
|
16910
|
+
sentence: `${rel} is missing while detection says ${top.subject} deploys ${top.deploys}, so the deploy gate guesses on every merge, push and deploy`,
|
|
16911
|
+
fix: "run haus apply --write to write it, then check what each branch deploys and set confirmedBy"
|
|
16912
|
+
}
|
|
16913
|
+
];
|
|
16914
|
+
}
|
|
16544
16915
|
return [
|
|
16545
16916
|
{
|
|
16546
16917
|
level: "suggest",
|
|
@@ -16613,15 +16984,7 @@ async function auditDeployConfig(root, workflowConfigPath, deps) {
|
|
|
16613
16984
|
fix: `set branches.<branch>.localCheck in ${rel} to the command that runs the change locally (the app, a dev server, a build or a docs preview)`
|
|
16614
16985
|
});
|
|
16615
16986
|
}
|
|
16616
|
-
const
|
|
16617
|
-
const context = await repoContext(root, git);
|
|
16618
|
-
const branch = context ? await defaultBranch(git, context.root) : void 0;
|
|
16619
|
-
const detection = await detectDeployConfig(root, {
|
|
16620
|
-
gh: deps.deployGh ?? (deployDetectOffline() ? void 0 : realGh()),
|
|
16621
|
-
slug: context?.slug,
|
|
16622
|
-
defaultBranch: branch,
|
|
16623
|
-
cache: true
|
|
16624
|
-
});
|
|
16987
|
+
const detection = await detect();
|
|
16625
16988
|
for (const message2 of detection.errors) {
|
|
16626
16989
|
findings.push({
|
|
16627
16990
|
level: "ok",
|
|
@@ -16630,11 +16993,12 @@ async function auditDeployConfig(root, workflowConfigPath, deps) {
|
|
|
16630
16993
|
}
|
|
16631
16994
|
const disagreements = deployDisagreements(config, detection.evidence);
|
|
16632
16995
|
for (const d of disagreements) {
|
|
16996
|
+
const nameIt = d.file === void 0 ? "" : "; if the file is right, name the target in the workflow (a job environment:, or a concurrency group or file name such as deploy-test) so detection agrees";
|
|
16633
16997
|
findings.push({
|
|
16634
16998
|
level: "suggest",
|
|
16635
16999
|
line: `- DEPLOY TARGETS: detection says ${d.subject} deploys ${d.detected}, but ${rel} ${d.fileSays} (${d.details.join("; ")})`,
|
|
16636
17000
|
sentence: `Detection says ${d.subject} deploys ${d.detected} (${d.details.join("; ")}), but ${rel} ${d.fileSays}`,
|
|
16637
|
-
fix: `check it, and raise the class in ${rel} if detection is right (a higher class than detection's is fine)`
|
|
17001
|
+
fix: `check it, and raise the class in ${rel} if detection is right (a higher class than detection's is fine)${nameIt}`
|
|
16638
17002
|
});
|
|
16639
17003
|
}
|
|
16640
17004
|
const configText = await readText(workflowConfigPath);
|
|
@@ -16914,12 +17278,7 @@ async function pruneEmptyDirsUpward(dirs, stopAt) {
|
|
|
16914
17278
|
for (const start of ordered) {
|
|
16915
17279
|
let dir = start;
|
|
16916
17280
|
while (dir.startsWith(stopAt) && dir !== stopAt) {
|
|
16917
|
-
|
|
16918
|
-
if ((await fs48.readdir(dir)).length > 0) break;
|
|
16919
|
-
await fs48.remove(dir);
|
|
16920
|
-
} catch {
|
|
16921
|
-
break;
|
|
16922
|
-
}
|
|
17281
|
+
if (!await pruneEmptyDir(dir)) break;
|
|
16923
17282
|
dir = path70.dirname(dir);
|
|
16924
17283
|
}
|
|
16925
17284
|
}
|
|
@@ -17036,7 +17395,7 @@ async function applyInstall(options = {}) {
|
|
|
17036
17395
|
}
|
|
17037
17396
|
if (stampableMarkerForm(entry.destPath) === void 0)
|
|
17038
17397
|
throw new Error(
|
|
17039
|
-
`No safe ownership-marker form for ${entry.srcRelPath}
|
|
17398
|
+
`No safe ownership-marker form for ${entry.srcRelPath}: only .md and .js/.mjs/.cjs aux files can ship in library/global (see ADR-0072)`
|
|
17040
17399
|
);
|
|
17041
17400
|
const stamped = stampMarkdown(
|
|
17042
17401
|
rawContent,
|
|
@@ -17069,7 +17428,7 @@ async function applyInstall(options = {}) {
|
|
|
17069
17428
|
continue;
|
|
17070
17429
|
}
|
|
17071
17430
|
if (existing && hashContent(currentContent) !== existing.hash && !force) {
|
|
17072
|
-
warn(`User edited haus file (skipping): ${entry.destPath}
|
|
17431
|
+
warn(`User edited haus file (skipping): ${entry.destPath}. Use --force to overwrite`);
|
|
17073
17432
|
result.skipped.push(entry.destPath);
|
|
17074
17433
|
result.userEdited.push(entry.destPath);
|
|
17075
17434
|
manifestFiles.push(existing);
|
|
@@ -17136,7 +17495,7 @@ async function applyInstall(options = {}) {
|
|
|
17136
17495
|
emptiedDirs.add(path70.dirname(entry.destPath));
|
|
17137
17496
|
result.deleted.push(entry.destPath);
|
|
17138
17497
|
} else {
|
|
17139
|
-
warn(`Orphaned file ${entry.destPath} was user-modified
|
|
17498
|
+
warn(`Orphaned file ${entry.destPath} was user-modified, leaving in place`);
|
|
17140
17499
|
result.skipped.push(entry.destPath);
|
|
17141
17500
|
}
|
|
17142
17501
|
}
|
|
@@ -17168,7 +17527,7 @@ function printInternalChannel(result, prefix) {
|
|
|
17168
17527
|
}
|
|
17169
17528
|
if (!enabled && result.deleted.some((p) => p.includes("request") || p.includes("slack-cowork"))) {
|
|
17170
17529
|
log(
|
|
17171
|
-
`${prefix}Internal channel is off
|
|
17530
|
+
`${prefix}Internal channel is off: the request skills and slack-cowork were removed. Bring them back with \`haus install --internal\`.`
|
|
17172
17531
|
);
|
|
17173
17532
|
}
|
|
17174
17533
|
}
|
|
@@ -17197,7 +17556,7 @@ function printApplyResult(result, dryRun) {
|
|
|
17197
17556
|
}
|
|
17198
17557
|
printInternalChannel(result, prefix);
|
|
17199
17558
|
if (result.drift) {
|
|
17200
|
-
warn("Install drift detected
|
|
17559
|
+
warn("Install drift detected. Run `haus install` to sync.");
|
|
17201
17560
|
}
|
|
17202
17561
|
}
|
|
17203
17562
|
|
|
@@ -17328,7 +17687,7 @@ What changed in ${npmStatus.latest}:`);
|
|
|
17328
17687
|
const notAProject = await projectSkipReason(root);
|
|
17329
17688
|
if (notAProject !== null) warn(`Skipped the project refresh: ${root} ${notAProject}.`);
|
|
17330
17689
|
if (notAProject === null && await hasLocalOverrides(root)) {
|
|
17331
|
-
log("Existing .claude/settings.json
|
|
17690
|
+
log("Existing .claude/settings.json: haus rules will be merged, not replaced.");
|
|
17332
17691
|
}
|
|
17333
17692
|
const lockBefore = await readLockRaw(root);
|
|
17334
17693
|
log("Syncing remote catalog...");
|
|
@@ -17423,7 +17782,7 @@ async function refreshProjectFiles(root) {
|
|
|
17423
17782
|
try {
|
|
17424
17783
|
const result = await refreshProjectApply(root);
|
|
17425
17784
|
if (!result.setUp) {
|
|
17426
|
-
warn("No prior haus project setup detected
|
|
17785
|
+
warn("No prior haus project setup detected, skipped project re-apply.");
|
|
17427
17786
|
return null;
|
|
17428
17787
|
}
|
|
17429
17788
|
if (result.report.refusedFiles.includes(LOCK_FILE)) return result.report;
|
|
@@ -17731,10 +18090,10 @@ async function runClone(url, opts = {}) {
|
|
|
17731
18090
|
if (existsSync4(target) && !await isEmptyDirectory(target)) {
|
|
17732
18091
|
const check = await checkExistingTarget(target, url);
|
|
17733
18092
|
if (check.kind === "matches") {
|
|
17734
|
-
log(`\u2022 ${path72.basename(target)} already cloned here, matches ${url}
|
|
18093
|
+
log(`\u2022 ${path72.basename(target)} already cloned here, matches ${url}, skipped`);
|
|
17735
18094
|
return;
|
|
17736
18095
|
}
|
|
17737
|
-
const message2 = check.kind === "not-a-repo" ? `${target} already exists and is not a git repository
|
|
18096
|
+
const message2 = check.kind === "not-a-repo" ? `${target} already exists and is not a git repository, 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)"}), refusing to clone ${url} there. Remove or rename it, or pass a different [dir].`;
|
|
17738
18097
|
if (opts.dryRun) {
|
|
17739
18098
|
warn(`[dry-run] ${message2}`);
|
|
17740
18099
|
return;
|
|
@@ -18505,9 +18864,143 @@ var INERT_COMMANDS = /* @__PURE__ */ new Set([
|
|
|
18505
18864
|
"wc"
|
|
18506
18865
|
]);
|
|
18507
18866
|
var INERT_GIT_SUBCOMMANDS = /* @__PURE__ */ new Set(["add", "diff", "log", "status"]);
|
|
18508
|
-
var GIT_CONTROL_FILE = /(?:^|\/)(?:\.git(?:\/|$)|\.git(?:attributes|config|modules)$|git\/[^/]+$|config$|hooks\/|info\/attributes$)
|
|
18509
|
-
|
|
18510
|
-
|
|
18867
|
+
var GIT_CONTROL_FILE = /(?:^|\/)(?:\.git(?:\/|$)|\.git(?:attributes|config|modules)$|git\/[^/]+$|config$|hooks\/|info\/attributes$)/i;
|
|
18868
|
+
var GH_BODY_FILE_FLAGS = /* @__PURE__ */ new Map([
|
|
18869
|
+
[
|
|
18870
|
+
"pr",
|
|
18871
|
+
/* @__PURE__ */ new Map([
|
|
18872
|
+
["create", ["--body-file", "-F"]],
|
|
18873
|
+
["edit", ["--body-file", "-F"]],
|
|
18874
|
+
["comment", ["--body-file", "-F"]]
|
|
18875
|
+
])
|
|
18876
|
+
],
|
|
18877
|
+
[
|
|
18878
|
+
"issue",
|
|
18879
|
+
/* @__PURE__ */ new Map([
|
|
18880
|
+
["create", ["--body-file", "-F"]],
|
|
18881
|
+
["edit", ["--body-file", "-F"]],
|
|
18882
|
+
["comment", ["--body-file", "-F"]]
|
|
18883
|
+
])
|
|
18884
|
+
],
|
|
18885
|
+
[
|
|
18886
|
+
"release",
|
|
18887
|
+
/* @__PURE__ */ new Map([
|
|
18888
|
+
["create", ["--notes-file", "-F"]],
|
|
18889
|
+
["edit", ["--notes-file", "-F"]]
|
|
18890
|
+
])
|
|
18891
|
+
]
|
|
18892
|
+
]);
|
|
18893
|
+
var GH_CONTROL_FILE = /(?:^|\/)gh\/|(?:^|\/)(?:config|hosts)\.ya?ml$/i;
|
|
18894
|
+
var BARE_GH_PREFIXES = /* @__PURE__ */ new Set(["command", "exec"]);
|
|
18895
|
+
var SUBSTITUTION = /\$\(|`|<\(|>\(/;
|
|
18896
|
+
var UNREAD_PATH = /[*?[\]$`~{}]/;
|
|
18897
|
+
var WRITES_ONLY_WHERE_SENT = /* @__PURE__ */ new Set([
|
|
18898
|
+
"cat",
|
|
18899
|
+
"echo",
|
|
18900
|
+
"grep",
|
|
18901
|
+
"head",
|
|
18902
|
+
"jq",
|
|
18903
|
+
"printf",
|
|
18904
|
+
"tail",
|
|
18905
|
+
"tee",
|
|
18906
|
+
"wc"
|
|
18907
|
+
]);
|
|
18908
|
+
function plainPath(path137) {
|
|
18909
|
+
return path137.replace(/^(?:\.\/+)+/, "");
|
|
18910
|
+
}
|
|
18911
|
+
function folded(path137) {
|
|
18912
|
+
return path137.normalize("NFKC").toLowerCase();
|
|
18913
|
+
}
|
|
18914
|
+
var NOT_PRINTABLE_ASCII = /[^ -~]/;
|
|
18915
|
+
function mayBeControlFile(path137) {
|
|
18916
|
+
const plain2 = folded(path137);
|
|
18917
|
+
return NOT_PRINTABLE_ASCII.test(path137) || GIT_CONTROL_FILE.test(plain2) || GH_CONTROL_FILE.test(plain2);
|
|
18918
|
+
}
|
|
18919
|
+
var NOTE_EXTENSION = /\.(?:md|markdown|txt)$/;
|
|
18920
|
+
function everyWrittenFile(segment) {
|
|
18921
|
+
const embedded = segment.filter((token) => !token.quoted && token.value.includes(">")).filter((token) => !OUTPUT_REDIRECTION.test(token.value)).map((token) => token.value);
|
|
18922
|
+
const values = withoutRedirections(segment).map((token) => token.value);
|
|
18923
|
+
const tee = values.findIndex((value) => commandBaseName(value) === "tee");
|
|
18924
|
+
const teeOperands = tee === -1 ? [] : values.slice(tee + 1).filter((value) => !value.startsWith("-"));
|
|
18925
|
+
const git = trustedName(commandWords(withoutRedirections(segment))[0] ?? "") === "git";
|
|
18926
|
+
const gitOutput = git ? values.filter((value) => value.startsWith("--out")) : [];
|
|
18927
|
+
return [...writtenFiles(segment), ...embedded, ...teeOperands, ...gitOutput];
|
|
18928
|
+
}
|
|
18929
|
+
function safeNotePath(file) {
|
|
18930
|
+
const plain2 = plainPath(file);
|
|
18931
|
+
if (UNREAD_PATH.test(plain2) || mayBeControlFile(plain2)) return false;
|
|
18932
|
+
if (!NOTE_EXTENSION.test(folded(plain2))) return false;
|
|
18933
|
+
const directories = folded(plain2).split("/").slice(0, -1);
|
|
18934
|
+
return !directories.some((part) => part.startsWith(".") && part !== ".");
|
|
18935
|
+
}
|
|
18936
|
+
function writesOnlyWhereSent(segment) {
|
|
18937
|
+
const bare = withoutRedirections(segment);
|
|
18938
|
+
if (bare.length === 0) return true;
|
|
18939
|
+
const name = trustedName(commandWords(bare)[0] ?? "");
|
|
18940
|
+
return name !== void 0 && WRITES_ONLY_WHERE_SENT.has(name);
|
|
18941
|
+
}
|
|
18942
|
+
function callWrites(commands, written) {
|
|
18943
|
+
return {
|
|
18944
|
+
targets: written.flatMap((body) => body.targets),
|
|
18945
|
+
files: commands.flatMap(({ segment }) => everyWrittenFile(segment)),
|
|
18946
|
+
hiddenWriter: written.some((body) => !body.writers.every(writesOnlyWhereSent))
|
|
18947
|
+
};
|
|
18948
|
+
}
|
|
18949
|
+
function attachedBodyFile(word, flags) {
|
|
18950
|
+
for (const flag of flags) {
|
|
18951
|
+
if (flag.startsWith("--")) {
|
|
18952
|
+
if (word.startsWith(`${flag}=`)) return word.slice(flag.length + 1);
|
|
18953
|
+
} else if (word.startsWith(flag) && word.length > flag.length) {
|
|
18954
|
+
return word.slice(flag.length).replace(/^=/, "");
|
|
18955
|
+
}
|
|
18956
|
+
}
|
|
18957
|
+
return void 0;
|
|
18958
|
+
}
|
|
18959
|
+
function ghSendsOnlyAsBody(segment, words, call, bodyTargets) {
|
|
18960
|
+
const flags = GH_BODY_FILE_FLAGS.get(words[1] ?? "")?.get(words[2] ?? "");
|
|
18961
|
+
if (flags === void 0 || bodyTargets.length === 0 || call.hiddenWriter) return false;
|
|
18962
|
+
if (segment.some((token) => SUBSTITUTION.test(token.value))) return false;
|
|
18963
|
+
const values = withoutRedirections(segment).map((token) => token.value);
|
|
18964
|
+
if (values.some((value) => commandBaseName(value) === "xargs")) return false;
|
|
18965
|
+
const writes = [...call.targets, ...call.files];
|
|
18966
|
+
if (writes.some(mayBeControlFile)) {
|
|
18967
|
+
return false;
|
|
18968
|
+
}
|
|
18969
|
+
let at = 0;
|
|
18970
|
+
while (at < values.length && BARE_GH_PREFIXES.has(values[at])) at += 1;
|
|
18971
|
+
if (values[at] !== words[0] || values[at + 1] !== words[1] || values[at + 2] !== words[2]) {
|
|
18972
|
+
return false;
|
|
18973
|
+
}
|
|
18974
|
+
const names4 = writes.filter((file) => plainPath(file) !== "/dev/null").map((file) => commandBaseName(file)).filter(Boolean);
|
|
18975
|
+
const bodyFiles = [];
|
|
18976
|
+
let bodyFlags = 0;
|
|
18977
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
18978
|
+
const value = values[i];
|
|
18979
|
+
if (i > at + 2 && flags.includes(value)) {
|
|
18980
|
+
bodyFlags += 1;
|
|
18981
|
+
if (i + 1 < values.length) bodyFiles.push(values[i + 1]);
|
|
18982
|
+
i += 1;
|
|
18983
|
+
continue;
|
|
18984
|
+
}
|
|
18985
|
+
const attached = i > at + 2 ? attachedBodyFile(value, flags) : void 0;
|
|
18986
|
+
if (attached !== void 0) {
|
|
18987
|
+
bodyFlags += 1;
|
|
18988
|
+
bodyFiles.push(attached);
|
|
18989
|
+
continue;
|
|
18990
|
+
}
|
|
18991
|
+
if (names4.some((name) => value.toLowerCase().includes(name))) return false;
|
|
18992
|
+
}
|
|
18993
|
+
if (bodyFlags !== 1 || bodyFiles.length !== 1 || !safeNotePath(bodyFiles[0])) return false;
|
|
18994
|
+
const note = plainPath(bodyFiles[0]);
|
|
18995
|
+
const isNote = (file) => !UNREAD_PATH.test(file) && plainPath(file) === note;
|
|
18996
|
+
return bodyTargets.every(isNote) && writes.every((file) => plainPath(file) === "/dev/null" || isNote(file));
|
|
18997
|
+
}
|
|
18998
|
+
function runsNothing(segment, call, bodyTargets) {
|
|
18999
|
+
const viaEnv = segment.some((token) => commandBaseName(token.value) === "env");
|
|
19000
|
+
const assigns = segment.some(
|
|
19001
|
+
(token) => (!token.quoted || viaEnv) && ENV_ASSIGNMENT.test(token.value)
|
|
19002
|
+
);
|
|
19003
|
+
if (assigns) return false;
|
|
18511
19004
|
const bare = withoutRedirections(segment);
|
|
18512
19005
|
if (bare.length === 0) return true;
|
|
18513
19006
|
const words = commandWords(bare);
|
|
@@ -18515,19 +19008,20 @@ function runsNothing(segment, callTargets) {
|
|
|
18515
19008
|
const name = trustedName(words[0]);
|
|
18516
19009
|
if (name === void 0) return false;
|
|
18517
19010
|
if (name === "git") {
|
|
18518
|
-
return INERT_GIT_SUBCOMMANDS.has(words[1] ?? "") && !
|
|
19011
|
+
return INERT_GIT_SUBCOMMANDS.has(words[1] ?? "") && !call.targets.some((target) => GIT_CONTROL_FILE.test(folded(target)));
|
|
18519
19012
|
}
|
|
19013
|
+
if (name === "gh") return ghSendsOnlyAsBody(segment, words, call, bodyTargets);
|
|
18520
19014
|
return INERT_COMMANDS.has(name);
|
|
18521
19015
|
}
|
|
18522
19016
|
function writtenBodiesThatRun(commands, written, sawGrouping) {
|
|
18523
19017
|
const writers = written.flatMap((body) => body.writers);
|
|
18524
|
-
const
|
|
19018
|
+
const call = callWrites(commands, written);
|
|
18525
19019
|
return written.filter(({ targets }) => {
|
|
18526
19020
|
const names4 = targets.map((target) => commandBaseName(target)).filter(Boolean);
|
|
18527
19021
|
const mentionsFile = (value) => names4.some((name) => value.toLowerCase().includes(name));
|
|
18528
19022
|
return commands.some(({ segment }) => {
|
|
18529
19023
|
if (writers.includes(segment)) return false;
|
|
18530
|
-
if (!runsNothing(segment,
|
|
19024
|
+
if (!runsNothing(segment, call, targets)) return true;
|
|
18531
19025
|
const words = commandWords(withoutRedirections(segment));
|
|
18532
19026
|
if (words.length > 0 && names4.includes(commandBaseName(words[0]))) return true;
|
|
18533
19027
|
const copies = segment.some(
|
|
@@ -18913,7 +19407,7 @@ async function guardRepository(root) {
|
|
|
18913
19407
|
if (result.triggered && !result.satisfied) {
|
|
18914
19408
|
const rel = relativeDecisionPath(await resolveDecisionsDir(root), root);
|
|
18915
19409
|
emitPreToolUseDeny(
|
|
18916
|
-
result.securityTriggered ? `I didn't run that: this change touches a security path, so an ADR is required before opening a PR (${result.reasons.join("; ")}). Run haus decisions suggest, add the decision under ${rel}/, then retry.` : `I didn't run that
|
|
19410
|
+
result.securityTriggered ? `I didn't run that: this change touches a security path, so an ADR is required before opening a PR (${result.reasons.join("; ")}). Run haus decisions suggest, add the decision under ${rel}/, then retry.` : `I didn't run that: this change needs an ADR before opening a PR (${result.reasons.join("; ")}). Run haus decisions suggest, add the decision under ${rel}/, then retry, or put [adr-skip] on its own line in a commit body for non-security changes.`
|
|
18917
19411
|
);
|
|
18918
19412
|
return true;
|
|
18919
19413
|
}
|
|
@@ -18960,11 +19454,11 @@ _(One clear sentence: what we chose.)_
|
|
|
18960
19454
|
|
|
18961
19455
|
## Motivation (why)
|
|
18962
19456
|
|
|
18963
|
-
_(Why this over alternatives
|
|
19457
|
+
_(Why this over alternatives: the line agents read for "why".)_
|
|
18964
19458
|
|
|
18965
19459
|
## Alternatives considered
|
|
18966
19460
|
|
|
18967
|
-
- _(Alternative)_
|
|
19461
|
+
- _(Alternative)_: rejected because _(reason)_.
|
|
18968
19462
|
|
|
18969
19463
|
## Consequences
|
|
18970
19464
|
|
|
@@ -20358,6 +20852,7 @@ async function loadDeployConfig(context, options) {
|
|
|
20358
20852
|
const detected = await detectDeployConfig(context.root, {
|
|
20359
20853
|
// `HAUS_DEPLOY_DETECT_OFFLINE=1` keeps detection off the network (the test runner sets it).
|
|
20360
20854
|
gh: process.env.HAUS_DEPLOY_DETECT_OFFLINE === "1" || options.detectOnline === false ? void 0 : options.gh,
|
|
20855
|
+
git: options.git,
|
|
20361
20856
|
slug: context.slug,
|
|
20362
20857
|
defaultBranch: await defaultName(),
|
|
20363
20858
|
cache: options.cacheDetection ?? true,
|
|
@@ -20367,13 +20862,15 @@ async function loadDeployConfig(context, options) {
|
|
|
20367
20862
|
return {
|
|
20368
20863
|
config: strictestOf(detected.config, local.config),
|
|
20369
20864
|
source: `${local.where} (no copy on ${remoteRef} yet, so read with detection)`,
|
|
20370
|
-
errors: [...errors, ...detected.errors]
|
|
20865
|
+
errors: [...errors, ...detected.errors],
|
|
20866
|
+
detected: true
|
|
20371
20867
|
};
|
|
20372
20868
|
}
|
|
20373
20869
|
return {
|
|
20374
20870
|
config: detected.config,
|
|
20375
20871
|
source: "detection (no deploy.json yet)",
|
|
20376
|
-
errors: [...errors, ...detected.errors]
|
|
20872
|
+
errors: [...errors, ...detected.errors],
|
|
20873
|
+
detected: true
|
|
20377
20874
|
};
|
|
20378
20875
|
}
|
|
20379
20876
|
async function loadRemoteDeployConfig(slug, branch, options) {
|
|
@@ -20404,6 +20901,7 @@ async function loadRemoteDeployConfig(slug, branch, options) {
|
|
|
20404
20901
|
config: detected.config,
|
|
20405
20902
|
source: `detection for ${slug} (no deploy.json)`,
|
|
20406
20903
|
errors: [...errors, ...detected.errors],
|
|
20904
|
+
detected: true,
|
|
20407
20905
|
...unreadable2 ? { unreadable: unreadable2 } : {}
|
|
20408
20906
|
};
|
|
20409
20907
|
}
|
|
@@ -20433,9 +20931,15 @@ var Freezes = class {
|
|
|
20433
20931
|
}
|
|
20434
20932
|
}
|
|
20435
20933
|
};
|
|
20934
|
+
function detectionHint(change) {
|
|
20935
|
+
if (!change.branch || !needsHumanClass(change.deploys) || !change.detected) return "";
|
|
20936
|
+
const branch = change.branch;
|
|
20937
|
+
return ` If ${branch} deploys a test or staging environment, ask the human: only they may set branches.${branch}.deploys and confirmedBy in ${DEPLOY_CONFIG_REL} (haus apply --write creates it), and the gate reads it once it is on origin/${branch}.`;
|
|
20938
|
+
}
|
|
20436
20939
|
async function verdictFor2(change, freezes) {
|
|
20437
20940
|
const where = change.surface ? ` (${change.surface})` : "";
|
|
20438
20941
|
const summary = `${change.what} deploys ${change.deploys}${where}: ${change.why}; read from ${change.source}`;
|
|
20942
|
+
const hint = detectionHint(change);
|
|
20439
20943
|
const known = await freezes.get();
|
|
20440
20944
|
if (known instanceof Error) {
|
|
20441
20945
|
return deny(
|
|
@@ -20451,19 +20955,19 @@ async function verdictFor2(change, freezes) {
|
|
|
20451
20955
|
}
|
|
20452
20956
|
if (needsHumanClass(change.deploys) && change.auto) {
|
|
20453
20957
|
return deny(
|
|
20454
|
-
`haus: ${summary}. Auto-merge would land it later with no one confirming the deploy. Wait for the checks yourself, then merge without auto so the human confirms
|
|
20958
|
+
`haus: ${summary}. Auto-merge would land it later with no one confirming the deploy. Wait for the checks yourself, then merge without auto so the human confirms.${hint}`
|
|
20455
20959
|
);
|
|
20456
20960
|
}
|
|
20457
20961
|
if (!change.hasRecord) {
|
|
20458
20962
|
return deny(
|
|
20459
|
-
`haus: ${summary}. No local-verification record. ${HOW_TO_RECORD} ${change.recordHint}`
|
|
20963
|
+
`haus: ${summary}. No local-verification record. ${HOW_TO_RECORD} ${change.recordHint}${hint}`
|
|
20460
20964
|
);
|
|
20461
20965
|
}
|
|
20462
20966
|
if (needsHumanClass(change.deploys)) {
|
|
20463
20967
|
return {
|
|
20464
20968
|
decision: "ask",
|
|
20465
20969
|
gated: true,
|
|
20466
|
-
reason: `haus: ${summary}. A local run is recorded. This is a ${change.deploys} deploy: confirm only if you want it live now
|
|
20970
|
+
reason: `haus: ${summary}. A local run is recorded. This is a ${change.deploys} deploy: confirm only if you want it live now.${hint}`
|
|
20467
20971
|
};
|
|
20468
20972
|
}
|
|
20469
20973
|
return { gated: true };
|
|
@@ -20621,7 +21125,8 @@ async function evaluateMerge(action, hookCwd, deps, freezes) {
|
|
|
20621
21125
|
auto: action.auto,
|
|
20622
21126
|
hasRecord,
|
|
20623
21127
|
recordHint: prRecordHint(`#${pr2.number}`, pr2.baseRefName, classification.entry?.localCheck),
|
|
20624
|
-
source: loaded.source
|
|
21128
|
+
source: loaded.source,
|
|
21129
|
+
detected: loaded.detected === true
|
|
20625
21130
|
},
|
|
20626
21131
|
freezes
|
|
20627
21132
|
);
|
|
@@ -20773,6 +21278,7 @@ async function evaluatePush(action, deps, freezes) {
|
|
|
20773
21278
|
let deploys = "none";
|
|
20774
21279
|
let classification;
|
|
20775
21280
|
let source = overview.source;
|
|
21281
|
+
let detected = overview.detected === true;
|
|
20776
21282
|
if (target.kind === "branch") {
|
|
20777
21283
|
const loaded = await loadDeployConfig(context, {
|
|
20778
21284
|
git: deps.git,
|
|
@@ -20782,6 +21288,7 @@ async function evaluatePush(action, deps, freezes) {
|
|
|
20782
21288
|
detectOnline
|
|
20783
21289
|
});
|
|
20784
21290
|
source = loaded.source;
|
|
21291
|
+
detected = loaded.detected === true;
|
|
20785
21292
|
const files = await changedFilesSince(deps.git, context.root, `${remote}/${target.name}`, sha2);
|
|
20786
21293
|
classification = classifyBranchChange(loaded.config, target.name, files);
|
|
20787
21294
|
deploys = classification.deploys;
|
|
@@ -20808,7 +21315,8 @@ async function evaluatePush(action, deps, freezes) {
|
|
|
20808
21315
|
auto: false,
|
|
20809
21316
|
hasRecord: await hasReceipt([repo], sha2, localCheck, deps.verifyDir),
|
|
20810
21317
|
recordHint: shaRecordHint(checkBranch, localCheck),
|
|
20811
|
-
source
|
|
21318
|
+
source,
|
|
21319
|
+
detected
|
|
20812
21320
|
},
|
|
20813
21321
|
freezes
|
|
20814
21322
|
)
|
|
@@ -20868,7 +21376,8 @@ async function evaluateRelease(action, deps, freezes) {
|
|
|
20868
21376
|
auto: false,
|
|
20869
21377
|
hasRecord: await hasReceipt([slug], sha2, localCheck, deps.verifyDir),
|
|
20870
21378
|
recordHint: context ? shaRecordHint(branch, localCheck) : "from a checkout of that repository, by running haus verify local on the commit the tag lands on.",
|
|
20871
|
-
source: loaded.source
|
|
21379
|
+
source: loaded.source,
|
|
21380
|
+
detected: loaded.detected === true
|
|
20872
21381
|
},
|
|
20873
21382
|
freezes
|
|
20874
21383
|
);
|
|
@@ -20907,7 +21416,8 @@ async function evaluateDirectDeploy(what, cwd, ref, deploysOverride, deps, freez
|
|
|
20907
21416
|
auto: false,
|
|
20908
21417
|
hasRecord: await hasReceipt([repo], sha2, row?.localCheck, deps.verifyDir),
|
|
20909
21418
|
recordHint: shaRecordHint(branch, row?.localCheck),
|
|
20910
|
-
source: loaded.source
|
|
21419
|
+
source: loaded.source,
|
|
21420
|
+
detected: loaded.detected === true
|
|
20911
21421
|
},
|
|
20912
21422
|
freezes
|
|
20913
21423
|
);
|
|
@@ -21287,12 +21797,11 @@ function resolveOnDisk(candidate) {
|
|
|
21287
21797
|
}
|
|
21288
21798
|
function guardFileAccess(candidate) {
|
|
21289
21799
|
if (matchesDenyTier(candidate))
|
|
21290
|
-
return `I didn't open ${candidate}
|
|
21800
|
+
return `I didn't open ${candidate}: it looks like it holds secrets or sensitive data`;
|
|
21291
21801
|
const { resolved, unverifiable } = resolveOnDisk(candidate);
|
|
21292
|
-
if (unverifiable)
|
|
21293
|
-
return `I didn't open ${candidate} \u2014 it is a link whose target I couldn't verify`;
|
|
21802
|
+
if (unverifiable) return `I didn't open ${candidate}: it is a link whose target I couldn't verify`;
|
|
21294
21803
|
if (resolved && matchesDenyTier(resolved))
|
|
21295
|
-
return `I didn't open ${candidate}
|
|
21804
|
+
return `I didn't open ${candidate}: it leads to a file that looks like it holds secrets or sensitive data`;
|
|
21296
21805
|
return void 0;
|
|
21297
21806
|
}
|
|
21298
21807
|
|
|
@@ -22212,7 +22721,7 @@ function guardBash(command) {
|
|
|
22212
22721
|
if (shape === UNGATED_MERGE_CHAIN) {
|
|
22213
22722
|
return `I didn't run that: the merge would run even if the CI wait before it fails, because they are joined by a semicolon, a newline, || or a pipe. Join the wait and the merge with && and no pipe, so a red check stops the merge: ${command.replace(/`/g, "'")}`;
|
|
22214
22723
|
}
|
|
22215
|
-
return `I didn't run that
|
|
22724
|
+
return `I didn't run that: it looks like ${shape}, which can permanently change or delete things: ${command}`;
|
|
22216
22725
|
}
|
|
22217
22726
|
function allowBash(command, cwd, options = {}) {
|
|
22218
22727
|
if (matchDangerousCommandShape(command)) return void 0;
|
|
@@ -23278,7 +23787,7 @@ async function runDiscover(workspaceRoot, opts = {}) {
|
|
|
23278
23787
|
}
|
|
23279
23788
|
|
|
23280
23789
|
// src/scanner/sibling-repos.ts
|
|
23281
|
-
var SIBLING_REPO_WARNING = 'Multiple sibling repos detected under this root
|
|
23790
|
+
var SIBLING_REPO_WARNING = 'Multiple sibling repos detected under this root. Consider "haus workspace discover" instead of running init/scan per repo.';
|
|
23282
23791
|
async function hasMultipleSiblingRepos(root) {
|
|
23283
23792
|
try {
|
|
23284
23793
|
const repoRoots = await findRepoRoots(root);
|
|
@@ -23398,7 +23907,7 @@ async function runSetupCore(root, opts) {
|
|
|
23398
23907
|
const supersededCount = recommendation.skipped.filter(
|
|
23399
23908
|
(s) => s.skipReasons?.some((r) => r.code === "superseded-by-global-plugin")
|
|
23400
23909
|
).length;
|
|
23401
|
-
const message2 = supersededCount > 0 ? `No catalog items matched this project after ${supersededCount} item(s) were gated as already provided by an installed global plugin
|
|
23910
|
+
const message2 = supersededCount > 0 ? `No catalog items matched this project after ${supersededCount} item(s) were gated as already provided by an installed global plugin, refusing to write recommendation.json/haus.lock.json. Those skills are available globally; re-run with \`haus init --force\` to write anyway.` : "No catalog items matched this project at all (zero signal), refusing to write recommendation.json/haus.lock.json. Re-run with `haus init --force` to write anyway.";
|
|
23402
23911
|
warn(message2);
|
|
23403
23912
|
process.exitCode = 1;
|
|
23404
23913
|
const warnings = [...warningLines2, message2];
|
|
@@ -23419,7 +23928,7 @@ async function runSetupCore(root, opts) {
|
|
|
23419
23928
|
warnings,
|
|
23420
23929
|
hooksOk: false,
|
|
23421
23930
|
written: [],
|
|
23422
|
-
// Pre-existing zero-signal guard, not the #325 catalog-install honesty check
|
|
23931
|
+
// Pre-existing zero-signal guard, not the #325 catalog-install honesty check,
|
|
23423
23932
|
// `catalogInstallOk` stays true; `setupOk` is false so workspace setup cannot
|
|
23424
23933
|
// record this repo as ok (issue #330).
|
|
23425
23934
|
catalogInstallOk: true,
|
|
@@ -23526,7 +24035,7 @@ async function runSetupCore(root, opts) {
|
|
|
23526
24035
|
const message2 = "No catalog content found. Run `haus update` first.";
|
|
23527
24036
|
if (!opts.allowEmptyCache) {
|
|
23528
24037
|
if (isPreview) {
|
|
23529
|
-
warn(`Catalog cache is empty
|
|
24038
|
+
warn(`Catalog cache is empty: dry-run will skip catalog items. ${message2}`);
|
|
23530
24039
|
} else {
|
|
23531
24040
|
error(message2);
|
|
23532
24041
|
process.exitCode = 1;
|
|
@@ -23582,7 +24091,7 @@ async function runSetupCore(root, opts) {
|
|
|
23582
24091
|
say(`${installedWantedCount} of ${countedIds.length} catalog items installed (${tier} tier).`);
|
|
23583
24092
|
if (installedWantedCount === 0) {
|
|
23584
24093
|
error(
|
|
23585
|
-
"No catalog items were installed
|
|
24094
|
+
"No catalog items were installed, only core files were written. Run `haus update` with network access, or import a catalog bundle."
|
|
23586
24095
|
);
|
|
23587
24096
|
process.exitCode = 1;
|
|
23588
24097
|
catalogInstallOk = false;
|
|
@@ -23648,7 +24157,7 @@ var COMPANION_TOOLS = [
|
|
|
23648
24157
|
{
|
|
23649
24158
|
bin: "caveman",
|
|
23650
24159
|
label: "Caveman",
|
|
23651
|
-
blurb: "Ultra-compressed Claude Code responses
|
|
24160
|
+
blurb: "Ultra-compressed Claude Code responses; cuts output tokens ~75%.",
|
|
23652
24161
|
installCmds: [
|
|
23653
24162
|
"curl -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash"
|
|
23654
24163
|
],
|
|
@@ -23657,13 +24166,13 @@ var COMPANION_TOOLS = [
|
|
|
23657
24166
|
{
|
|
23658
24167
|
bin: "rtk",
|
|
23659
24168
|
label: "RTK (Rust Token Killer)",
|
|
23660
|
-
blurb: "Token-optimized CLI proxy
|
|
24169
|
+
blurb: "Token-optimized CLI proxy, 60-90% savings on dev operations.",
|
|
23661
24170
|
installCmds: [
|
|
23662
24171
|
"brew install rtk",
|
|
23663
24172
|
"cargo install --git https://github.com/rtk-ai/rtk # if Homebrew is unavailable"
|
|
23664
24173
|
],
|
|
23665
24174
|
url: "https://github.com/rtk-ai/rtk",
|
|
23666
|
-
note: 'Another crate named "rtk" (Rust Type Kit) exists on crates.io
|
|
24175
|
+
note: 'Another crate named "rtk" (Rust Type Kit) exists on crates.io. Use the commands above, not a bare `cargo install rtk`.'
|
|
23667
24176
|
}
|
|
23668
24177
|
];
|
|
23669
24178
|
function buildCompanionSuggestions(tools, isInstalled) {
|
|
@@ -23671,7 +24180,7 @@ function buildCompanionSuggestions(tools, isInstalled) {
|
|
|
23671
24180
|
if (missing.length === 0) return [];
|
|
23672
24181
|
const lines = ["", "Optional token-saving tools you can add:"];
|
|
23673
24182
|
for (const tool of missing) {
|
|
23674
|
-
lines.push("", ` ${tool.label}
|
|
24183
|
+
lines.push("", ` ${tool.label}: ${tool.blurb}`);
|
|
23675
24184
|
for (const cmd of tool.installCmds) {
|
|
23676
24185
|
lines.push(` ${cmd}`);
|
|
23677
24186
|
}
|
|
@@ -23751,7 +24260,7 @@ async function runInstall(options, deps = {}) {
|
|
|
23751
24260
|
if (result.created.length) parts.push(`${result.created.length} file(s) added`);
|
|
23752
24261
|
if (result.updated.length) parts.push(`${result.updated.length} file(s) updated`);
|
|
23753
24262
|
log(
|
|
23754
|
-
parts.length ? ` \u2022 ${parts.join(", ")} in ${claudeConfigDirLabel()} (skills, agents, slash commands)` : " \u2022 already up to date
|
|
24263
|
+
parts.length ? ` \u2022 ${parts.join(", ")} in ${claudeConfigDirLabel()} (skills, agents, slash commands)` : " \u2022 already up to date, no files changed"
|
|
23755
24264
|
);
|
|
23756
24265
|
log(
|
|
23757
24266
|
` \u2022 ensured hooks + security rules are present in ${claudeConfigDirLabel()}/settings.json`
|
|
@@ -23797,20 +24306,20 @@ async function safeExec(exec, command, args, options) {
|
|
|
23797
24306
|
function inconclusive(result, what) {
|
|
23798
24307
|
if (!result.killed) return void 0;
|
|
23799
24308
|
const reason = result.stderr.trim() || "the probe did not complete";
|
|
23800
|
-
return `Could not confirm ${what}: ${reason}. This is usually transient load
|
|
24309
|
+
return `Could not confirm ${what}: ${reason}. This is usually transient load. Try again, or use --no-github.`;
|
|
23801
24310
|
}
|
|
23802
24311
|
async function checkGh(exec = runCommand) {
|
|
23803
24312
|
const version = await safeExec(exec, "gh", ["--version"], { timeout: PROBE_TIMEOUT_MS2 });
|
|
23804
24313
|
const versionUnknown = inconclusive(version, "the gh CLI is installed");
|
|
23805
24314
|
if (versionUnknown) return { ok: false, reason: versionUnknown };
|
|
23806
24315
|
if (version.exitCode !== 0) {
|
|
23807
|
-
return { ok: false, reason: "gh CLI not found
|
|
24316
|
+
return { ok: false, reason: "gh CLI not found. Install it or re-run with --no-github" };
|
|
23808
24317
|
}
|
|
23809
24318
|
const auth = await safeExec(exec, "gh", ["auth", "status"], { timeout: PROBE_TIMEOUT_MS2 });
|
|
23810
24319
|
const authUnknown = inconclusive(auth, "gh is authenticated");
|
|
23811
24320
|
if (authUnknown) return { ok: false, reason: authUnknown };
|
|
23812
24321
|
if (auth.exitCode !== 0) {
|
|
23813
|
-
return { ok: false, reason: "gh is not authenticated
|
|
24322
|
+
return { ok: false, reason: "gh is not authenticated. Run `gh auth login` or use --no-github" };
|
|
23814
24323
|
}
|
|
23815
24324
|
return { ok: true };
|
|
23816
24325
|
}
|
|
@@ -23819,7 +24328,7 @@ async function checkOrg(org, exec = runCommand) {
|
|
|
23819
24328
|
if (!GITHUB_LOGIN.test(org)) {
|
|
23820
24329
|
return {
|
|
23821
24330
|
ok: false,
|
|
23822
|
-
reason: `"${org}" is not a GitHub login
|
|
24331
|
+
reason: `"${org}" is not a GitHub login: expected letters, digits and hyphens (e.g. acme or octocat).`
|
|
23823
24332
|
};
|
|
23824
24333
|
}
|
|
23825
24334
|
const res = await safeExec(exec, "gh", ["api", `users/${org}`], { timeout: PROBE_TIMEOUT_MS2 });
|
|
@@ -23843,13 +24352,13 @@ async function createRepo(opts, exec = runCommand) {
|
|
|
23843
24352
|
if (result.exitCode !== 0) {
|
|
23844
24353
|
const stderr = result.stderr.trim();
|
|
23845
24354
|
const repoAlreadyExists = /already exists/i.test(stderr);
|
|
23846
|
-
const resumeHint = result.killed ? `Repo creation timed out (${stderr || "no output before the timeout"}). It may or may not have been created
|
|
24355
|
+
const resumeHint = result.killed ? `Repo creation timed out (${stderr || "no output before the timeout"}). It may or may not have been created. Check first, then finish:
|
|
23847
24356
|
gh repo view ${slug}
|
|
23848
24357
|
# if it exists: git remote get-url origin || git remote add origin <url>
|
|
23849
24358
|
# git push -u origin main
|
|
23850
24359
|
# if it does not: gh ${args.join(" ")}` : repoAlreadyExists ? `Repo creation failed (${stderr || "unknown error"}). GitHub reports "${slug}" already exists. If this run created it, the push is all that is left; if it existed before, check what is in it before pushing over it:
|
|
23851
24360
|
gh repo view ${slug}
|
|
23852
|
-
git push -u origin main` : `Repo creation failed (${stderr || "unknown error"}). Your project is intact
|
|
24361
|
+
git push -u origin main` : `Repo creation failed (${stderr || "unknown error"}). Your project is intact. Resume with:
|
|
23853
24362
|
gh ${args.join(" ")}
|
|
23854
24363
|
git push -u origin main`;
|
|
23855
24364
|
return { ok: false, resumeHint };
|
|
@@ -23957,7 +24466,7 @@ var defaultDeps = {
|
|
|
23957
24466
|
loadCatalog: loadCatalogContext,
|
|
23958
24467
|
overlay: scaffoldConfigItems,
|
|
23959
24468
|
setup: runSetupCore,
|
|
23960
|
-
// persist:false (scan-project.ts #101): the role check is a probe
|
|
24469
|
+
// persist:false (scan-project.ts #101): the role check is a probe, the chained
|
|
23961
24470
|
// init already wrote the scan artifacts; re-persisting here would clobber them.
|
|
23962
24471
|
scan: (root) => scanProject(root, { persist: false })
|
|
23963
24472
|
};
|
|
@@ -23978,7 +24487,7 @@ async function recordPackageManager(projectPath, pm, deps) {
|
|
|
23978
24487
|
const field = `${pm}@${version}`;
|
|
23979
24488
|
if (detectPackageManager(projectPath, field) !== pm) {
|
|
23980
24489
|
return {
|
|
23981
|
-
note: `installed ${pm} is ${version}, which haus does not support
|
|
24490
|
+
note: `installed ${pm} is ${version}, which haus does not support: the package manager was not recorded, so the scanner will report it as unknown until you upgrade`
|
|
23982
24491
|
};
|
|
23983
24492
|
}
|
|
23984
24493
|
await fs55.writeJson(pkgPath, { ...pkg, packageManager: field }, { spaces: 2 });
|
|
@@ -24041,7 +24550,7 @@ async function createProject(opts, deps = defaultDeps) {
|
|
|
24041
24550
|
if (!parentStat?.isDirectory()) {
|
|
24042
24551
|
return fail3(
|
|
24043
24552
|
"preflight",
|
|
24044
|
-
`Parent directory ${opts.parentDir} is not an existing directory
|
|
24553
|
+
`Parent directory ${opts.parentDir} is not an existing directory. Pass an existing one to --dir.`
|
|
24045
24554
|
);
|
|
24046
24555
|
}
|
|
24047
24556
|
if (!NAME_RE2.test(opts.name) || opts.name.includes("/") || opts.name.includes("\\") || path88.dirname(projectPath) !== path88.resolve(opts.parentDir)) {
|
|
@@ -24051,7 +24560,7 @@ async function createProject(opts, deps = defaultDeps) {
|
|
|
24051
24560
|
);
|
|
24052
24561
|
}
|
|
24053
24562
|
if (await fs55.pathExists(projectPath)) {
|
|
24054
|
-
return fail3("preflight", `Target ${projectPath} already exists
|
|
24563
|
+
return fail3("preflight", `Target ${projectPath} already exists, refusing to write into it.`);
|
|
24055
24564
|
}
|
|
24056
24565
|
const scaffoldArgs = stack.scaffold.args.map(
|
|
24057
24566
|
(a) => a.replaceAll("{name}", opts.name).replaceAll("{pm}", opts.pm)
|
|
@@ -24103,7 +24612,7 @@ async function createProject(opts, deps = defaultDeps) {
|
|
|
24103
24612
|
if (inconclusiveTools.length > 0) {
|
|
24104
24613
|
return fail3(
|
|
24105
24614
|
"preflight",
|
|
24106
|
-
`Could not confirm tool availability on PATH: ${inconclusiveTools.join("; ")}
|
|
24615
|
+
`Could not confirm tool availability on PATH: ${inconclusiveTools.join("; ")}: the probe did not complete (likely transient load). Try again.`
|
|
24107
24616
|
);
|
|
24108
24617
|
}
|
|
24109
24618
|
if (opts.github) {
|
|
@@ -24125,8 +24634,8 @@ async function createProject(opts, deps = defaultDeps) {
|
|
|
24125
24634
|
timeout: SCAFFOLD_TIMEOUT_MS
|
|
24126
24635
|
});
|
|
24127
24636
|
if (scaffold.exitCode !== 0) {
|
|
24128
|
-
const why = scaffold.killed ? `${pinned} did not complete: ${scaffold.stderr.trim() || `timed out after ${SCAFFOLD_TIMEOUT_MS}ms`}. Scaffolders that stall this way are usually waiting on a prompt despite --yes
|
|
24129
|
-
const leftBehind = await fs55.pathExists(projectPath) ? ` The directory was left for inspection
|
|
24637
|
+
const why = scaffold.killed ? `${pinned} did not complete: ${scaffold.stderr.trim() || `timed out after ${SCAFFOLD_TIMEOUT_MS}ms`}. Scaffolders that stall this way are usually waiting on a prompt despite --yes. Run it yourself to see: npx ${npxArgs.join(" ")}` : `${pinned} exited ${scaffold.exitCode}.`;
|
|
24638
|
+
const leftBehind = await fs55.pathExists(projectPath) ? ` The directory was left for inspection. Review it and remove it manually if unwanted (haus never auto-deletes).` : "";
|
|
24130
24639
|
return fail3("scaffold", `${why}${leftBehind}`);
|
|
24131
24640
|
}
|
|
24132
24641
|
steps.push({ step: "scaffold", status: "done" });
|
|
@@ -24189,7 +24698,7 @@ async function createProject(opts, deps = defaultDeps) {
|
|
|
24189
24698
|
steps.push({
|
|
24190
24699
|
step: "init",
|
|
24191
24700
|
status: "failed",
|
|
24192
|
-
detail: `haus init threw (${err instanceof Error ? err.message : String(err)}). Project and repo are intact
|
|
24701
|
+
detail: `haus init threw (${err instanceof Error ? err.message : String(err)}). Project and repo are intact. Run cd ${opts.name} && haus init${profileFlag} to retry.`
|
|
24193
24702
|
});
|
|
24194
24703
|
return { ok: false, projectPath, repoUrl, installHint, steps };
|
|
24195
24704
|
}
|
|
@@ -24218,7 +24727,7 @@ async function createProject(opts, deps = defaultDeps) {
|
|
|
24218
24727
|
steps.push({
|
|
24219
24728
|
step: "init",
|
|
24220
24729
|
status: "skipped",
|
|
24221
|
-
detail: `setup was declined
|
|
24730
|
+
detail: `setup was declined, no Claude files were written. Finish it with: ${retryCmd}`
|
|
24222
24731
|
});
|
|
24223
24732
|
return { ok: false, projectPath, repoUrl, installHint, steps, ...applied };
|
|
24224
24733
|
}
|
|
@@ -24233,7 +24742,7 @@ async function createProject(opts, deps = defaultDeps) {
|
|
|
24233
24742
|
steps.push({
|
|
24234
24743
|
step: "init",
|
|
24235
24744
|
status: "failed",
|
|
24236
|
-
detail: `haus init reported failure. Project and repo are intact
|
|
24745
|
+
detail: `haus init reported failure. Project and repo are intact. Run ${retryCmd} to retry.`
|
|
24237
24746
|
});
|
|
24238
24747
|
return { ok: false, projectPath, repoUrl, installHint, steps, ...applied };
|
|
24239
24748
|
}
|
|
@@ -24290,12 +24799,12 @@ async function createProject(opts, deps = defaultDeps) {
|
|
|
24290
24799
|
const add2 = await deps.git(["add", "-A"], gitOptions);
|
|
24291
24800
|
if (add2.exitCode !== 0) {
|
|
24292
24801
|
landedFully = false;
|
|
24293
|
-
detail += `; follow-up git add failed (${add2.stderr.trim()})
|
|
24802
|
+
detail += `; follow-up git add failed (${add2.stderr.trim()}): ${resumeHint}`;
|
|
24294
24803
|
} else {
|
|
24295
24804
|
const st = await deps.git(["status", "--porcelain"], gitOptions);
|
|
24296
24805
|
if (st.exitCode !== 0) {
|
|
24297
24806
|
landedFully = false;
|
|
24298
|
-
detail += `; follow-up git status failed (${st.stderr.trim()})
|
|
24807
|
+
detail += `; follow-up git status failed (${st.stderr.trim()}): ${resumeHint}`;
|
|
24299
24808
|
} else if (st.stdout.trim() !== "") {
|
|
24300
24809
|
const commit = await deps.git(
|
|
24301
24810
|
["commit", "-m", "chore: add haus config files"],
|
|
@@ -24303,12 +24812,12 @@ async function createProject(opts, deps = defaultDeps) {
|
|
|
24303
24812
|
);
|
|
24304
24813
|
if (commit.exitCode !== 0) {
|
|
24305
24814
|
landedFully = false;
|
|
24306
|
-
detail += `; follow-up commit failed (${commit.stderr.trim()})
|
|
24815
|
+
detail += `; follow-up commit failed (${commit.stderr.trim()}), commit manually`;
|
|
24307
24816
|
} else if (opts.github) {
|
|
24308
24817
|
const push = await deps.git(["push", "-u", "origin", "main"], gitOptions);
|
|
24309
24818
|
if (push.exitCode !== 0) {
|
|
24310
24819
|
landedFully = false;
|
|
24311
|
-
detail += "; push failed
|
|
24820
|
+
detail += "; push failed, run `git push -u origin main`";
|
|
24312
24821
|
}
|
|
24313
24822
|
}
|
|
24314
24823
|
}
|
|
@@ -24316,7 +24825,7 @@ async function createProject(opts, deps = defaultDeps) {
|
|
|
24316
24825
|
} catch (err) {
|
|
24317
24826
|
landedFully = false;
|
|
24318
24827
|
const reason = err instanceof Error ? err.message : String(err);
|
|
24319
|
-
detail += `; follow-up commit/push failed (${reason})
|
|
24828
|
+
detail += `; follow-up commit/push failed (${reason}): files are on disk but uncommitted, resume with: cd ${opts.name} && git add -A && git commit -m "chore: add haus config files"` + (opts.github ? " && git push -u origin main" : "");
|
|
24320
24829
|
}
|
|
24321
24830
|
if (!landedFully) overlayIncomplete = true;
|
|
24322
24831
|
steps.push({ step: "overlay", status: landedFully ? "done" : "partial", detail });
|
|
@@ -24329,7 +24838,7 @@ async function createProject(opts, deps = defaultDeps) {
|
|
|
24329
24838
|
steps.push({ step: "role-check", status: "done" });
|
|
24330
24839
|
} else {
|
|
24331
24840
|
warn(
|
|
24332
|
-
`Scanner did not detect role "${stack.expectedRole}" in the new project
|
|
24841
|
+
`Scanner did not detect role "${stack.expectedRole}" in the new project. The upstream scaffolder output may have changed. Review the project layout.`
|
|
24333
24842
|
);
|
|
24334
24843
|
steps.push({
|
|
24335
24844
|
step: "role-check",
|
|
@@ -24346,7 +24855,7 @@ async function createProject(opts, deps = defaultDeps) {
|
|
|
24346
24855
|
steps.push({
|
|
24347
24856
|
step: phase,
|
|
24348
24857
|
status: "failed",
|
|
24349
|
-
detail: `Unexpected error during ${phase}: ${err instanceof Error ? err.message : String(err)}. Nothing was auto-deleted
|
|
24858
|
+
detail: `Unexpected error during ${phase}: ${err instanceof Error ? err.message : String(err)}. Nothing was auto-deleted. Inspect ${projectPath} and resume manually.`
|
|
24350
24859
|
});
|
|
24351
24860
|
return { ok: false, projectPath, repoUrl, installHint, steps, ...applied };
|
|
24352
24861
|
}
|
|
@@ -24379,10 +24888,10 @@ function resolveNewOptions(stackArg, nameArg, flags, cwd) {
|
|
|
24379
24888
|
const parentDir = flags.dir === void 0 ? cwd : path89.resolve(cwd, flags.dir);
|
|
24380
24889
|
const pm = flags.pm ?? "yarn";
|
|
24381
24890
|
if (!PMS.includes(pm)) {
|
|
24382
|
-
return { ok: false, error: `Unsupported --pm "${pm}"
|
|
24891
|
+
return { ok: false, error: `Unsupported --pm "${pm}". Use yarn, npm, or pnpm.` };
|
|
24383
24892
|
}
|
|
24384
24893
|
if (flags.profile !== void 0 && !isProfileId(flags.profile)) {
|
|
24385
|
-
return { ok: false, error: `Unsupported --profile "${flags.profile}"
|
|
24894
|
+
return { ok: false, error: `Unsupported --profile "${flags.profile}". Use production or poc.` };
|
|
24386
24895
|
}
|
|
24387
24896
|
if (stackArg === void 0 || nameArg === void 0) {
|
|
24388
24897
|
return { ok: false, error: "missing-args" };
|
|
@@ -24409,7 +24918,7 @@ async function runNew(stackArg, nameArg, flags) {
|
|
|
24409
24918
|
if (flags.list) {
|
|
24410
24919
|
const stacks = STACKS.map(({ id, title }) => ({ id, title }));
|
|
24411
24920
|
log(
|
|
24412
|
-
flags.json ? JSON.stringify({ stacks }, null, 2) : stacks.map((s) => `${s.id}
|
|
24921
|
+
flags.json ? JSON.stringify({ stacks }, null, 2) : stacks.map((s) => `${s.id}: ${s.title}`).join("\n")
|
|
24413
24922
|
);
|
|
24414
24923
|
return;
|
|
24415
24924
|
}
|
|
@@ -24419,7 +24928,7 @@ async function runNew(stackArg, nameArg, flags) {
|
|
|
24419
24928
|
try {
|
|
24420
24929
|
stack ??= await select(
|
|
24421
24930
|
"Which stack?",
|
|
24422
|
-
STACKS.map((s) => ({ value: s.id, label: `${s.id}
|
|
24931
|
+
STACKS.map((s) => ({ value: s.id, label: `${s.id}: ${s.title}` }))
|
|
24423
24932
|
);
|
|
24424
24933
|
name ??= await input("Project name?");
|
|
24425
24934
|
} catch (err) {
|
|
@@ -24430,7 +24939,7 @@ async function runNew(stackArg, nameArg, flags) {
|
|
|
24430
24939
|
}
|
|
24431
24940
|
const resolved = resolveNewOptions(stack, name, flags, process.cwd());
|
|
24432
24941
|
if (!resolved.ok) {
|
|
24433
|
-
const message2 = resolved.error === "missing-args" ? process.stdin.isTTY ? "Missing stack/name
|
|
24942
|
+
const message2 = resolved.error === "missing-args" ? process.stdin.isTTY ? "Missing stack/name, refusing to prompt under --json; run `haus new <stack> <name>` (see --list)." : "Missing stack/name and no TTY to prompt. Run `haus new <stack> <name>` (see --list)." : resolved.error;
|
|
24434
24943
|
error(message2);
|
|
24435
24944
|
process.exitCode = 1;
|
|
24436
24945
|
return;
|
|
@@ -24441,7 +24950,7 @@ async function runNew(stackArg, nameArg, flags) {
|
|
|
24441
24950
|
} else {
|
|
24442
24951
|
for (const s of result.steps) {
|
|
24443
24952
|
log(
|
|
24444
|
-
`${s.status === "done" ? "\u2713" : s.status === "failed" ? "\u2717" : "\xB7"} ${s.step}${s.detail ?
|
|
24953
|
+
`${s.status === "done" ? "\u2713" : s.status === "failed" ? "\u2717" : "\xB7"} ${s.step}${s.detail ? `: ${s.detail}` : ""}`
|
|
24445
24954
|
);
|
|
24446
24955
|
}
|
|
24447
24956
|
}
|
|
@@ -24880,6 +25389,8 @@ async function runRecommend(options) {
|
|
|
24880
25389
|
|
|
24881
25390
|
// src/commands/remove.ts
|
|
24882
25391
|
import path90 from "node:path";
|
|
25392
|
+
import fs56 from "fs-extra";
|
|
25393
|
+
init_fs();
|
|
24883
25394
|
init_logger();
|
|
24884
25395
|
init_paths();
|
|
24885
25396
|
function blankReport(dryRun, missing = []) {
|
|
@@ -24888,6 +25399,7 @@ function blankReport(dryRun, missing = []) {
|
|
|
24888
25399
|
forced: [],
|
|
24889
25400
|
skipped: [],
|
|
24890
25401
|
missing,
|
|
25402
|
+
excluded: [],
|
|
24891
25403
|
backupDir: null,
|
|
24892
25404
|
backupDirs: [],
|
|
24893
25405
|
dryRun,
|
|
@@ -24910,6 +25422,44 @@ function reportLock(before, after, selfHealed, opts) {
|
|
|
24910
25422
|
function emitReport(report2, json) {
|
|
24911
25423
|
if (json) log(JSON.stringify(report2, null, 2));
|
|
24912
25424
|
}
|
|
25425
|
+
function heldBy(item, others) {
|
|
25426
|
+
return item.existing.flatMap(({ rel, abs }) => {
|
|
25427
|
+
const other = others.find((o) => o.id !== item.id && holdsPath(abs, o.abs));
|
|
25428
|
+
return other === void 0 ? [] : [{ rel, holder: other.id }];
|
|
25429
|
+
});
|
|
25430
|
+
}
|
|
25431
|
+
function holdSharedPaths(root, lock, cleared, dryRun) {
|
|
25432
|
+
const goingIds = new Set(cleared.map(({ id }) => id));
|
|
25433
|
+
const recordedOnDisk = (row) => recordedPathsOf(row).flatMap((rel) => {
|
|
25434
|
+
const abs = resolveContainedPath(root, recordedRel(rel));
|
|
25435
|
+
if (abs === null) return [];
|
|
25436
|
+
try {
|
|
25437
|
+
fs56.lstatSync(abs);
|
|
25438
|
+
} catch (err) {
|
|
25439
|
+
const code2 = err.code;
|
|
25440
|
+
if (code2 === "ENOENT" || code2 === "ENOTDIR") return [];
|
|
25441
|
+
}
|
|
25442
|
+
return [{ abs, id: rowName(row) }];
|
|
25443
|
+
});
|
|
25444
|
+
const others = lock.filter((row) => !goingIds.has(rowName(row))).flatMap(recordedOnDisk);
|
|
25445
|
+
return holdUntilSettled(cleared, others, dryRun);
|
|
25446
|
+
}
|
|
25447
|
+
function holdUntilSettled(items, holders, dryRun) {
|
|
25448
|
+
const held = [];
|
|
25449
|
+
for (let changed = true; changed; ) {
|
|
25450
|
+
changed = false;
|
|
25451
|
+
for (const item of items) {
|
|
25452
|
+
if (held.includes(item)) continue;
|
|
25453
|
+
const holding = heldBy(item, holders);
|
|
25454
|
+
if (holding.length === 0) continue;
|
|
25455
|
+
for (const { rel, holder } of holding) warn(inUseLine(rel, item.id, holder, dryRun));
|
|
25456
|
+
held.push(item);
|
|
25457
|
+
holders.push(...item.existing.map(({ abs }) => ({ abs, id: item.id })));
|
|
25458
|
+
changed = true;
|
|
25459
|
+
}
|
|
25460
|
+
}
|
|
25461
|
+
return held;
|
|
25462
|
+
}
|
|
24913
25463
|
async function runRemove(ids, options = {}) {
|
|
24914
25464
|
const root = process.cwd();
|
|
24915
25465
|
const dryRun = Boolean(options.dryRun);
|
|
@@ -24918,7 +25468,7 @@ async function runRemove(ids, options = {}) {
|
|
|
24918
25468
|
} : log;
|
|
24919
25469
|
const wanted = [...new Set(parseIdList(ids))];
|
|
24920
25470
|
if (wanted.length === 0) {
|
|
24921
|
-
error("Specify at least one catalog item id
|
|
25471
|
+
error("Specify at least one catalog item id. See `haus doctor` for what is installed.");
|
|
24922
25472
|
process.exitCode = 1;
|
|
24923
25473
|
emitReport(blankReport(dryRun), options.json);
|
|
24924
25474
|
return;
|
|
@@ -24931,13 +25481,15 @@ async function runRemove(ids, options = {}) {
|
|
|
24931
25481
|
}
|
|
24932
25482
|
const lock = lockRead.rows;
|
|
24933
25483
|
const byId = new Map(lock.map((entry) => [entry.id, entry]));
|
|
24934
|
-
const
|
|
24935
|
-
const
|
|
25484
|
+
const headerIds = wanted.filter((id) => Object.hasOwn(HEADER_FILE_RELS, id));
|
|
25485
|
+
const itemIds = wanted.filter((id) => !Object.hasOwn(HEADER_FILE_RELS, id));
|
|
25486
|
+
const missing = itemIds.filter((id) => !byId.has(id));
|
|
25487
|
+
const found = itemIds.filter((id) => byId.has(id));
|
|
24936
25488
|
for (const id of missing) {
|
|
24937
25489
|
warn(`Not installed (no lock entry): ${id}`);
|
|
24938
25490
|
}
|
|
24939
|
-
if (found.length === 0) {
|
|
24940
|
-
error("No installed items matched
|
|
25491
|
+
if (found.length === 0 && headerIds.length === 0) {
|
|
25492
|
+
error("No installed items matched, nothing to remove.");
|
|
24941
25493
|
process.exitCode = 1;
|
|
24942
25494
|
emitReport(blankReport(dryRun, missing), options.json);
|
|
24943
25495
|
return;
|
|
@@ -24948,6 +25500,32 @@ async function runRemove(ids, options = {}) {
|
|
|
24948
25500
|
return;
|
|
24949
25501
|
}
|
|
24950
25502
|
const report2 = blankReport(dryRun, missing);
|
|
25503
|
+
if (headerIds.length > 0) {
|
|
25504
|
+
if (found.length > 0 && options.json && !options.yes && !dryRun) {
|
|
25505
|
+
error(
|
|
25506
|
+
"`--json` requires `-y` (a destructive command will not prompt on a machine-readable stream)."
|
|
25507
|
+
);
|
|
25508
|
+
process.exitCode = 1;
|
|
25509
|
+
return;
|
|
25510
|
+
}
|
|
25511
|
+
for (const id of headerIds) say(excludedLine(HEADER_FILE_RELS[id] ?? id, id, dryRun));
|
|
25512
|
+
const marked = await addProjectExcludedIds(root, headerIds, { dryRun });
|
|
25513
|
+
if (marked !== null && "refused" in marked) {
|
|
25514
|
+
process.exitCode = 1;
|
|
25515
|
+
emitReport(report2, options.json);
|
|
25516
|
+
return;
|
|
25517
|
+
}
|
|
25518
|
+
if (marked !== null) {
|
|
25519
|
+
report2.records.push(marked);
|
|
25520
|
+
say(recordLine(marked, dryRun));
|
|
25521
|
+
}
|
|
25522
|
+
report2.excluded = headerIds;
|
|
25523
|
+
}
|
|
25524
|
+
if (found.length === 0) {
|
|
25525
|
+
if (report2.missing.length > 0) process.exitCode = 1;
|
|
25526
|
+
emitReport(report2, options.json);
|
|
25527
|
+
return;
|
|
25528
|
+
}
|
|
24951
25529
|
const cleared = [];
|
|
24952
25530
|
let unreadable2 = false;
|
|
24953
25531
|
for (const id of found) {
|
|
@@ -24956,11 +25534,7 @@ async function runRemove(ids, options = {}) {
|
|
|
24956
25534
|
const unsafeByRel = await unsafeReasonsFor(root, relPaths);
|
|
24957
25535
|
if ([...unsafeByRel.values()].some((reason) => reason !== null)) {
|
|
24958
25536
|
for (const [rel, reason] of unsafeByRel) {
|
|
24959
|
-
if (reason
|
|
24960
|
-
warn(`Refusing to touch ${id}'s recorded path outside the project root: ${rel}`);
|
|
24961
|
-
} else if (reason === "symlink") {
|
|
24962
|
-
warn(`Refusing to touch ${id}'s recorded path through a symlink: ${rel}`);
|
|
24963
|
-
}
|
|
25537
|
+
if (reason !== null) warn(unsafePathLine(id, rel, reason));
|
|
24964
25538
|
}
|
|
24965
25539
|
report2.skipped.push({ id, reason: "unsafe-path" });
|
|
24966
25540
|
continue;
|
|
@@ -24968,7 +25542,7 @@ async function runRemove(ids, options = {}) {
|
|
|
24968
25542
|
const existing = await resolveExistingTrackedPaths(root, id, relPaths, warn, unsafeByRel);
|
|
24969
25543
|
if (existing.length === 0) {
|
|
24970
25544
|
if (relPaths.length === 0) {
|
|
24971
|
-
warn(`${id} has no recorded paths
|
|
25545
|
+
warn(`${id} has no recorded paths, leaving its lock entry in place.`);
|
|
24972
25546
|
report2.skipped.push({ id, reason: "no-paths" });
|
|
24973
25547
|
continue;
|
|
24974
25548
|
}
|
|
@@ -24992,6 +25566,10 @@ async function runRemove(ids, options = {}) {
|
|
|
24992
25566
|
}
|
|
24993
25567
|
cleared.push({ id, paths: relPaths, existing, forced: gate.ok ? null : gate.reason });
|
|
24994
25568
|
}
|
|
25569
|
+
for (const held of holdSharedPaths(root, lock, cleared, dryRun)) {
|
|
25570
|
+
cleared.splice(cleared.indexOf(held), 1);
|
|
25571
|
+
report2.skipped.push({ id: held.id, reason: "in-use" });
|
|
25572
|
+
}
|
|
24995
25573
|
if (report2.missing.length > 0 || report2.skipped.length > 0 || unreadable2) {
|
|
24996
25574
|
process.exitCode = 1;
|
|
24997
25575
|
}
|
|
@@ -25034,7 +25612,7 @@ async function runRemove(ids, options = {}) {
|
|
|
25034
25612
|
say(recordLine(exclusion2, true));
|
|
25035
25613
|
}
|
|
25036
25614
|
}
|
|
25037
|
-
if (!options.json) log(`Dry-run complete
|
|
25615
|
+
if (!options.json) log(`Dry-run complete: ${cleared.length} item(s) planned, nothing written.`);
|
|
25038
25616
|
emitReport(report2, options.json);
|
|
25039
25617
|
return;
|
|
25040
25618
|
}
|
|
@@ -25046,15 +25624,19 @@ async function runRemove(ids, options = {}) {
|
|
|
25046
25624
|
process.exitCode = 1;
|
|
25047
25625
|
return;
|
|
25048
25626
|
}
|
|
25049
|
-
const
|
|
25050
|
-
({ id, forced }) =>
|
|
25627
|
+
const going2 = cleared.flatMap(
|
|
25628
|
+
({ id, existing, forced }) => existing.map(
|
|
25629
|
+
({ rel }) => forced === null ? `${rel} (${id})` : `${rel} (${id}): ${EDITED_REASON[forced]}; removed with --force`
|
|
25630
|
+
)
|
|
25051
25631
|
);
|
|
25052
|
-
const
|
|
25632
|
+
const listed = going2.length > 0 ? `
|
|
25633
|
+
${going2.join("\n ")}
|
|
25634
|
+
` : " ";
|
|
25053
25635
|
const ok2 = await confirm(
|
|
25054
|
-
`Remove ${cleared.length} installed item(s) (${cleared.map((c) => c.id).join(", ")})
|
|
25636
|
+
`Remove ${cleared.length} installed item(s) (${cleared.map((c) => c.id).join(", ")})?${listed}Files are backed up first.`
|
|
25055
25637
|
);
|
|
25056
25638
|
if (!ok2) {
|
|
25057
|
-
log("Aborted
|
|
25639
|
+
log("Aborted, nothing removed.");
|
|
25058
25640
|
return;
|
|
25059
25641
|
}
|
|
25060
25642
|
}
|
|
@@ -25123,6 +25705,14 @@ async function runRemove(ids, options = {}) {
|
|
|
25123
25705
|
}
|
|
25124
25706
|
if (copyFailures.length > 0) process.exitCode = 1;
|
|
25125
25707
|
const failedIds = new Set(copyFailures.map(({ item }) => item.id));
|
|
25708
|
+
const failedPaths = copyFailures.flatMap(
|
|
25709
|
+
({ item }) => item.existing.map(({ abs }) => ({ abs, id: item.id }))
|
|
25710
|
+
);
|
|
25711
|
+
const copied = cleared.filter(({ id }) => !failedIds.has(id));
|
|
25712
|
+
for (const item of holdUntilSettled(copied, failedPaths, false)) {
|
|
25713
|
+
failedIds.add(item.id);
|
|
25714
|
+
report2.skipped.push({ id: item.id, reason: "in-use" });
|
|
25715
|
+
}
|
|
25126
25716
|
const going = cleared.filter(({ id }) => !failedIds.has(id));
|
|
25127
25717
|
report2.backupDirs = copyDirs;
|
|
25128
25718
|
report2.backupDir = copyDirs.length === 1 ? copyDirs[0] ?? null : null;
|
|
@@ -25268,7 +25858,7 @@ async function runScan(options) {
|
|
|
25268
25858
|
|
|
25269
25859
|
// src/commands/undo.ts
|
|
25270
25860
|
import path91 from "node:path";
|
|
25271
|
-
import
|
|
25861
|
+
import fs57 from "fs-extra";
|
|
25272
25862
|
init_fs();
|
|
25273
25863
|
init_logger();
|
|
25274
25864
|
init_paths();
|
|
@@ -25276,7 +25866,8 @@ var FORCE_COMMAND = "haus undo --force";
|
|
|
25276
25866
|
var FORCE_LINE = { force: FORCE_COMMAND, forceVerb: "removes" };
|
|
25277
25867
|
var REFUSED_REASON = {
|
|
25278
25868
|
symlink: "recorded path is a symlink or reaches through one",
|
|
25279
|
-
"escapes-root": "recorded path is outside the project root"
|
|
25869
|
+
"escapes-root": "recorded path is outside the project root",
|
|
25870
|
+
"outside-install-root": "haus never installs there, so it leaves it"
|
|
25280
25871
|
};
|
|
25281
25872
|
var notRegularReason = (what) => what === void 0 ? "is not a regular file" : `is ${what}, not a regular file`;
|
|
25282
25873
|
var notRegularLine = (rel) => `kept (not owned) ${rel} (${RULES_HAUS_ID}): it is not a regular file, so haus leaves it as it is; delete it by hand.`;
|
|
@@ -25380,7 +25971,7 @@ async function claudeMdBlock(root) {
|
|
|
25380
25971
|
}
|
|
25381
25972
|
async function stripProjectSettings(root) {
|
|
25382
25973
|
const settingsPath = claudePath(root, "settings.json");
|
|
25383
|
-
if (!await
|
|
25974
|
+
if (!await fs57.pathExists(settingsPath)) return false;
|
|
25384
25975
|
const read = await readSettingsOrRefuse(root);
|
|
25385
25976
|
if (read === null) return false;
|
|
25386
25977
|
const settings = stripHausHooks(
|
|
@@ -25392,7 +25983,7 @@ async function stripProjectSettings(root) {
|
|
|
25392
25983
|
log(`Stripped haus rules from ${path91.relative(root, settingsPath)} (user settings preserved).`);
|
|
25393
25984
|
return true;
|
|
25394
25985
|
}
|
|
25395
|
-
await
|
|
25986
|
+
await fs57.remove(settingsPath);
|
|
25396
25987
|
log(`Removed ${path91.relative(root, settingsPath)} (no user-owned settings remained).`);
|
|
25397
25988
|
return true;
|
|
25398
25989
|
}
|
|
@@ -25413,39 +26004,31 @@ async function stripRootClaudeMd(root) {
|
|
|
25413
26004
|
}
|
|
25414
26005
|
if (edit.text === prev) return false;
|
|
25415
26006
|
if (edit.text.trim().length === 0) {
|
|
25416
|
-
await
|
|
26007
|
+
await fs57.remove(filePath);
|
|
25417
26008
|
log("Removed CLAUDE.md (only contained haus import block).");
|
|
25418
26009
|
} else {
|
|
25419
|
-
await
|
|
26010
|
+
await fs57.writeFile(filePath, edit.text, "utf8");
|
|
25420
26011
|
log("Removed haus import block from CLAUDE.md (user content preserved).");
|
|
25421
26012
|
}
|
|
25422
26013
|
return true;
|
|
25423
26014
|
}
|
|
25424
|
-
async function pruneDirIfEmpty(dir) {
|
|
25425
|
-
if (!await fs56.pathExists(dir)) return;
|
|
25426
|
-
try {
|
|
25427
|
-
const entries = await fs56.readdir(dir);
|
|
25428
|
-
if (entries.length === 0) await fs56.remove(dir);
|
|
25429
|
-
} catch {
|
|
25430
|
-
}
|
|
25431
|
-
}
|
|
25432
26015
|
async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
|
|
25433
26016
|
if (managedAbsPaths.length === 0) return null;
|
|
25434
26017
|
const stamp = newBackupStamp();
|
|
25435
26018
|
const backupRoot = hausPath(root, "backups", dirBackupDirName("undo", stamp));
|
|
25436
|
-
const existedBefore = await
|
|
26019
|
+
const existedBefore = await fs57.pathExists(backupRoot);
|
|
25437
26020
|
let copied = 0;
|
|
25438
26021
|
try {
|
|
25439
26022
|
for (const abs of managedAbsPaths) {
|
|
25440
|
-
if (!await
|
|
26023
|
+
if (!await fs57.pathExists(abs)) continue;
|
|
25441
26024
|
const backupPath = path91.join(backupRoot, path91.relative(root, abs));
|
|
25442
|
-
await
|
|
25443
|
-
const dereference = (await
|
|
25444
|
-
await
|
|
26025
|
+
await fs57.ensureDir(path91.dirname(backupPath));
|
|
26026
|
+
const dereference = (await fs57.lstat(abs)).isSymbolicLink();
|
|
26027
|
+
await fs57.copy(abs, backupPath, { dereference });
|
|
25445
26028
|
copied++;
|
|
25446
26029
|
}
|
|
25447
26030
|
} catch (err) {
|
|
25448
|
-
if (!existedBefore) await
|
|
26031
|
+
if (!existedBefore) await fs57.remove(backupRoot).catch(() => void 0);
|
|
25449
26032
|
throw err;
|
|
25450
26033
|
}
|
|
25451
26034
|
if (copied === 0) return null;
|
|
@@ -25482,13 +26065,7 @@ async function runUndo(options) {
|
|
|
25482
26065
|
const keptPaths = [...keptItems, ...unreadableItems].flatMap(
|
|
25483
26066
|
({ item }) => item.existing.map((e) => path91.resolve(e.abs))
|
|
25484
26067
|
);
|
|
25485
|
-
const
|
|
25486
|
-
const holdsKept = (abs) => {
|
|
25487
|
-
const target = path91.resolve(abs);
|
|
25488
|
-
return keptPaths.some(
|
|
25489
|
-
(kept) => kept === target || kept.startsWith(below(target)) || target.startsWith(below(kept))
|
|
25490
|
-
);
|
|
25491
|
-
};
|
|
26068
|
+
const holdsKept = (abs) => keptPaths.some((kept) => holdsPath(abs, kept));
|
|
25492
26069
|
const toDelete = tracked.filter(
|
|
25493
26070
|
(item) => item.existing.length > 0 && item.unreadable === void 0 && (item.edited === null || force)
|
|
25494
26071
|
).map((item) => ({ item, paths: item.existing.filter((e) => !holdsKept(e.abs)) })).filter(({ paths }) => paths.length > 0);
|
|
@@ -25579,7 +26156,7 @@ async function runUndo(options) {
|
|
|
25579
26156
|
if (!lockExisted) return;
|
|
25580
26157
|
if (carried.length === 0) {
|
|
25581
26158
|
if (dryRun) return;
|
|
25582
|
-
await
|
|
26159
|
+
await fs57.remove(lockFile);
|
|
25583
26160
|
log(`Removed ${LOCK_FILE}`);
|
|
25584
26161
|
return;
|
|
25585
26162
|
}
|
|
@@ -25661,7 +26238,7 @@ ${keeping}User-owned .claude/ files will be preserved.`
|
|
|
25661
26238
|
return;
|
|
25662
26239
|
}
|
|
25663
26240
|
for (const plan of coreRemoved) {
|
|
25664
|
-
await
|
|
26241
|
+
await fs57.remove(plan.abs);
|
|
25665
26242
|
log(`Removed ${relOf(plan.abs)}`);
|
|
25666
26243
|
}
|
|
25667
26244
|
const failed = /* @__PURE__ */ new Set();
|
|
@@ -25688,15 +26265,15 @@ ${keeping}User-owned .claude/ files will be preserved.`
|
|
|
25688
26265
|
if (stripSettings) await stripProjectSettings(root);
|
|
25689
26266
|
if (stripClaudeMd) await stripRootClaudeMd(root);
|
|
25690
26267
|
await settleLock();
|
|
25691
|
-
await
|
|
25692
|
-
await
|
|
26268
|
+
await pruneEmptyDir(claudePath(root));
|
|
26269
|
+
await pruneEmptyDir(hausPath(root));
|
|
25693
26270
|
if (failed.size > 0) return;
|
|
25694
26271
|
log("haus undo complete. Scan artifacts under .haus-workflow/ were left in place.");
|
|
25695
26272
|
}
|
|
25696
26273
|
|
|
25697
26274
|
// src/install/uninstall.ts
|
|
25698
26275
|
import path92 from "node:path";
|
|
25699
|
-
import
|
|
26276
|
+
import fs58 from "fs-extra";
|
|
25700
26277
|
init_fs();
|
|
25701
26278
|
init_logger();
|
|
25702
26279
|
init_paths();
|
|
@@ -25710,7 +26287,7 @@ async function runUninstall(options = {}) {
|
|
|
25710
26287
|
pluginsRemoved: []
|
|
25711
26288
|
};
|
|
25712
26289
|
if (!manifest) {
|
|
25713
|
-
warn("No install manifest found
|
|
26290
|
+
warn("No install manifest found, nothing to uninstall.");
|
|
25714
26291
|
return result;
|
|
25715
26292
|
}
|
|
25716
26293
|
await readSettings();
|
|
@@ -25718,7 +26295,7 @@ async function runUninstall(options = {}) {
|
|
|
25718
26295
|
const malformed = recordedPlugins.filter((entry) => !isValidPin(entry)).length;
|
|
25719
26296
|
if (malformed > 0) {
|
|
25720
26297
|
warn(
|
|
25721
|
-
`Ignoring ${malformed} malformed plugin record(s) in the install manifest
|
|
26298
|
+
`Ignoring ${malformed} malformed plugin record(s) in the install manifest, continuing with the rest of the uninstall.`
|
|
25722
26299
|
);
|
|
25723
26300
|
}
|
|
25724
26301
|
const managedPlugins = recordedPlugins.filter(isValidPin).map(withDefaultActivation);
|
|
@@ -25727,11 +26304,11 @@ async function runUninstall(options = {}) {
|
|
|
25727
26304
|
result.pluginsRemoved = pluginRemoval.removed;
|
|
25728
26305
|
} catch (err) {
|
|
25729
26306
|
warn(
|
|
25730
|
-
`Could not remove global plugin(s): ${err instanceof Error ? err.message : String(err)}
|
|
26307
|
+
`Could not remove global plugin(s): ${err instanceof Error ? err.message : String(err)}. Continuing with the rest of the uninstall.`
|
|
25731
26308
|
);
|
|
25732
26309
|
}
|
|
25733
26310
|
for (const entry of manifest.files) {
|
|
25734
|
-
const exists3 =
|
|
26311
|
+
const exists3 = fs58.pathExistsSync(entry.destPath);
|
|
25735
26312
|
if (!exists3) continue;
|
|
25736
26313
|
const content2 = await readText(entry.destPath);
|
|
25737
26314
|
if (content2 === void 0) continue;
|
|
@@ -25744,12 +26321,12 @@ async function runUninstall(options = {}) {
|
|
|
25744
26321
|
const currentHash = hashText(normaliseLF(content2));
|
|
25745
26322
|
if (currentHash !== entry.hash && !force) {
|
|
25746
26323
|
warn(
|
|
25747
|
-
`Skipping user-edited haus file (hash mismatch): ${entry.destPath}
|
|
26324
|
+
`Skipping user-edited haus file (hash mismatch): ${entry.destPath}. Use --force to delete`
|
|
25748
26325
|
);
|
|
25749
26326
|
result.skipped.push(entry.destPath);
|
|
25750
26327
|
continue;
|
|
25751
26328
|
}
|
|
25752
|
-
await
|
|
26329
|
+
await fs58.remove(entry.destPath);
|
|
25753
26330
|
await pruneEmptyDir(path92.dirname(entry.destPath));
|
|
25754
26331
|
result.deleted.push(entry.destPath);
|
|
25755
26332
|
}
|
|
@@ -25761,14 +26338,14 @@ async function runUninstall(options = {}) {
|
|
|
25761
26338
|
result.hooksStripped = true;
|
|
25762
26339
|
const hausDir = path92.join(globalClaudeDir(), "haus");
|
|
25763
26340
|
const manifestPath2 = hausManifestPath();
|
|
25764
|
-
if (
|
|
25765
|
-
await
|
|
26341
|
+
if (fs58.pathExistsSync(manifestPath2)) {
|
|
26342
|
+
await fs58.remove(manifestPath2);
|
|
25766
26343
|
}
|
|
25767
26344
|
await removeInstallConfig();
|
|
25768
|
-
if (
|
|
26345
|
+
if (fs58.pathExistsSync(hausDir)) {
|
|
25769
26346
|
try {
|
|
25770
|
-
const remaining = await
|
|
25771
|
-
if (remaining.length === 0) await
|
|
26347
|
+
const remaining = await fs58.readdir(hausDir);
|
|
26348
|
+
if (remaining.length === 0) await fs58.remove(hausDir);
|
|
25772
26349
|
} catch {
|
|
25773
26350
|
}
|
|
25774
26351
|
}
|
|
@@ -25800,7 +26377,7 @@ async function runUninstallCommand(options) {
|
|
|
25800
26377
|
if (options.dryRun) {
|
|
25801
26378
|
const manifest = await readManifest();
|
|
25802
26379
|
if (!manifest) {
|
|
25803
|
-
log("[dry-run] No install manifest found
|
|
26380
|
+
log("[dry-run] No install manifest found, nothing would be removed.");
|
|
25804
26381
|
return;
|
|
25805
26382
|
}
|
|
25806
26383
|
if (manifest.files.length > 0) {
|
|
@@ -25844,7 +26421,7 @@ init_forbidden_content();
|
|
|
25844
26421
|
init_manifest_item_fields();
|
|
25845
26422
|
init_path_safety();
|
|
25846
26423
|
init_validation_rules2();
|
|
25847
|
-
import
|
|
26424
|
+
import fs59 from "node:fs";
|
|
25848
26425
|
import path93 from "node:path";
|
|
25849
26426
|
var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
25850
26427
|
var ITEM_ID_PATTERN = /^haus\./;
|
|
@@ -25954,7 +26531,7 @@ function auditManifestStructure(manifestVersion2, items) {
|
|
|
25954
26531
|
if (typeof item.tokenEstimate === "number" && item.tokenEstimate > 0) {
|
|
25955
26532
|
if (typeof item.tokenBudget !== "number") {
|
|
25956
26533
|
failures.push(
|
|
25957
|
-
`${item.id}: tokenEstimate ${item.tokenEstimate} but no tokenBudget
|
|
26534
|
+
`${item.id}: tokenEstimate ${item.tokenEstimate} but no tokenBudget: an item that loads into context needs a growth ceiling (estimate + ~30% headroom, so ${Math.ceil(item.tokenEstimate * 1.3)})`
|
|
25958
26535
|
);
|
|
25959
26536
|
} else if (item.tokenEstimate > item.tokenBudget) {
|
|
25960
26537
|
failures.push(
|
|
@@ -26005,7 +26582,7 @@ function checkRequiredFrontmatter(text, label) {
|
|
|
26005
26582
|
}
|
|
26006
26583
|
function readForAudit(absPath, label, failures) {
|
|
26007
26584
|
try {
|
|
26008
|
-
return
|
|
26585
|
+
return fs59.readFileSync(absPath, "utf8");
|
|
26009
26586
|
} catch (err) {
|
|
26010
26587
|
failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
|
|
26011
26588
|
return null;
|
|
@@ -26037,7 +26614,7 @@ function auditShippedFiles(manifestDir, items) {
|
|
|
26037
26614
|
const absPath = path93.join(manifestDir, item.path);
|
|
26038
26615
|
if (item.type === "skill") {
|
|
26039
26616
|
const skillMd = path93.join(absPath, "SKILL.md");
|
|
26040
|
-
if (!
|
|
26617
|
+
if (!fs59.existsSync(skillMd)) {
|
|
26041
26618
|
failures.push(`${item.id}: missing ${path93.relative(manifestDir, skillMd)}`);
|
|
26042
26619
|
continue;
|
|
26043
26620
|
}
|
|
@@ -26048,7 +26625,7 @@ function auditShippedFiles(manifestDir, items) {
|
|
|
26048
26625
|
...auditForbiddenTagsInText(text, `${item.id}: ${path93.relative(manifestDir, skillMd)}`)
|
|
26049
26626
|
);
|
|
26050
26627
|
} else if (item.type === "agent") {
|
|
26051
|
-
if (!
|
|
26628
|
+
if (!fs59.existsSync(absPath)) {
|
|
26052
26629
|
failures.push(`${item.id}: missing agent file ${item.path}`);
|
|
26053
26630
|
continue;
|
|
26054
26631
|
}
|
|
@@ -26058,13 +26635,13 @@ function auditShippedFiles(manifestDir, items) {
|
|
|
26058
26635
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
|
|
26059
26636
|
failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
|
|
26060
26637
|
} else if (item.type === "template") {
|
|
26061
|
-
if (!
|
|
26638
|
+
if (!fs59.existsSync(absPath)) {
|
|
26062
26639
|
failures.push(`${item.id}: missing template file ${item.path}`);
|
|
26063
26640
|
continue;
|
|
26064
26641
|
}
|
|
26065
26642
|
failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
|
|
26066
26643
|
} else if (item.type === "command") {
|
|
26067
|
-
if (!
|
|
26644
|
+
if (!fs59.existsSync(absPath)) {
|
|
26068
26645
|
failures.push(`${item.id}: missing command file ${item.path}`);
|
|
26069
26646
|
continue;
|
|
26070
26647
|
}
|
|
@@ -26074,15 +26651,15 @@ function auditShippedFiles(manifestDir, items) {
|
|
|
26074
26651
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
|
|
26075
26652
|
failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
|
|
26076
26653
|
} else if (item.type === "config") {
|
|
26077
|
-
if (!
|
|
26654
|
+
if (!fs59.existsSync(absPath)) {
|
|
26078
26655
|
failures.push(`${item.id}: missing config path ${item.path}`);
|
|
26079
26656
|
}
|
|
26080
26657
|
} else if (item.type === "rule") {
|
|
26081
|
-
if (!
|
|
26658
|
+
if (!fs59.existsSync(absPath)) {
|
|
26082
26659
|
failures.push(`${item.id}: missing rule file ${item.path}`);
|
|
26083
26660
|
}
|
|
26084
26661
|
} else if (item.type === "asset") {
|
|
26085
|
-
if (!
|
|
26662
|
+
if (!fs59.existsSync(absPath)) {
|
|
26086
26663
|
failures.push(`${item.id}: missing asset file ${item.path}`);
|
|
26087
26664
|
}
|
|
26088
26665
|
}
|
|
@@ -26117,7 +26694,7 @@ function resolveItemType(relPath, pathTypeMap) {
|
|
|
26117
26694
|
function walkAllFiles(dir, fn, failures) {
|
|
26118
26695
|
let entries;
|
|
26119
26696
|
try {
|
|
26120
|
-
entries =
|
|
26697
|
+
entries = fs59.readdirSync(dir, { withFileTypes: true });
|
|
26121
26698
|
} catch (err) {
|
|
26122
26699
|
failures.push(
|
|
26123
26700
|
`${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
|
|
@@ -26151,7 +26728,7 @@ function auditDiskOrphans(manifestDir, items) {
|
|
|
26151
26728
|
const claimedPaths = items.filter((item) => item.path && isSafeCatalogPath(item.path)).map((item) => item.path.replace(/\\/g, "/"));
|
|
26152
26729
|
for (const dir of CONTENT_ROOTS) {
|
|
26153
26730
|
const abs = path93.join(manifestDir, dir);
|
|
26154
|
-
if (!
|
|
26731
|
+
if (!fs59.existsSync(abs)) continue;
|
|
26155
26732
|
walkAllFiles(
|
|
26156
26733
|
abs,
|
|
26157
26734
|
(file) => {
|
|
@@ -26168,7 +26745,7 @@ function auditDiskOrphans(manifestDir, items) {
|
|
|
26168
26745
|
function walkMd(dir, fn, failures) {
|
|
26169
26746
|
let entries;
|
|
26170
26747
|
try {
|
|
26171
|
-
entries =
|
|
26748
|
+
entries = fs59.readdirSync(dir, { withFileTypes: true });
|
|
26172
26749
|
} catch (err) {
|
|
26173
26750
|
failures.push(
|
|
26174
26751
|
`${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
|
|
@@ -26187,7 +26764,7 @@ function auditMarkdownContent(manifestDir, items) {
|
|
|
26187
26764
|
const pathTypeMap = buildItemPathTypeMap(items);
|
|
26188
26765
|
for (const dir of ["skills", "agents", "templates", "commands", "sources"]) {
|
|
26189
26766
|
const abs = path93.join(manifestDir, dir);
|
|
26190
|
-
if (!
|
|
26767
|
+
if (!fs59.existsSync(abs)) continue;
|
|
26191
26768
|
const dirType = DIR_ITEM_TYPE[dir] ?? "";
|
|
26192
26769
|
walkMd(
|
|
26193
26770
|
abs,
|
|
@@ -26451,12 +27028,12 @@ async function runVerifyLocal(options) {
|
|
|
26451
27028
|
// src/commands/workspace/env.ts
|
|
26452
27029
|
init_logger();
|
|
26453
27030
|
import { existsSync as existsSync11 } from "node:fs";
|
|
26454
|
-
import
|
|
27031
|
+
import fs68 from "node:fs/promises";
|
|
26455
27032
|
import path105 from "node:path";
|
|
26456
27033
|
|
|
26457
27034
|
// src/workspace/env/datastores/container.ts
|
|
26458
27035
|
init_exec();
|
|
26459
|
-
import
|
|
27036
|
+
import fs60 from "node:fs/promises";
|
|
26460
27037
|
import path95 from "node:path";
|
|
26461
27038
|
|
|
26462
27039
|
// src/workspace/env/datastores/names.ts
|
|
@@ -26687,18 +27264,18 @@ async function dumpToFile(ctx, target, argv, file, what) {
|
|
|
26687
27264
|
await dumpCommandToFile(ctx, "docker", composeExecArgs(target, argv), {}, file, what);
|
|
26688
27265
|
}
|
|
26689
27266
|
async function dumpCommandToFile(ctx, cmd, args, opts, file, what) {
|
|
26690
|
-
await
|
|
27267
|
+
await fs60.mkdir(path95.dirname(file), { recursive: true });
|
|
26691
27268
|
try {
|
|
26692
27269
|
assertOk(await ctx.exec(cmd, args, { ...opts, stdoutFile: file }), what);
|
|
26693
27270
|
await assertPrivateNonEmpty(file, what);
|
|
26694
27271
|
} catch (err) {
|
|
26695
|
-
await
|
|
27272
|
+
await fs60.rm(file, { force: true });
|
|
26696
27273
|
throw err instanceof DatastoreError ? err : new DatastoreError(`${what} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
26697
27274
|
}
|
|
26698
27275
|
}
|
|
26699
27276
|
async function assertPrivateNonEmpty(file, what) {
|
|
26700
|
-
await
|
|
26701
|
-
const { size } = await
|
|
27277
|
+
await fs60.chmod(file, 384);
|
|
27278
|
+
const { size } = await fs60.stat(file);
|
|
26702
27279
|
if (size === 0) throw new DatastoreError(`${what} produced an empty file.`);
|
|
26703
27280
|
}
|
|
26704
27281
|
|
|
@@ -27055,7 +27632,7 @@ var mariadbDriver = {
|
|
|
27055
27632
|
};
|
|
27056
27633
|
|
|
27057
27634
|
// src/workspace/env/datastores/mssql.ts
|
|
27058
|
-
import
|
|
27635
|
+
import fs61 from "node:fs/promises";
|
|
27059
27636
|
import path96 from "node:path";
|
|
27060
27637
|
var MSSQL_TOOLS_IMAGE = "mcr.microsoft.com/mssql-tools:latest";
|
|
27061
27638
|
var DETECT_SCRIPT = 'for p in /opt/mssql-tools18/bin/sqlcmd /opt/mssql-tools/bin/sqlcmd; do if [ -x "$p" ]; then echo "$p"; exit 0; fi; done; command -v sqlcmd || exit 3';
|
|
@@ -27351,7 +27928,7 @@ DROP DATABASE ${q(n.template)};`,
|
|
|
27351
27928
|
const dir = dataDir(await databaseFiles(ctx, t.datastore, n.worktree));
|
|
27352
27929
|
const bak = assertContainerPath(`${dir}/haus-dump-${n.worktree}.bak`);
|
|
27353
27930
|
const what = `Dumping ${n.worktree}`;
|
|
27354
|
-
await
|
|
27931
|
+
await fs61.mkdir(path96.dirname(file), { recursive: true });
|
|
27355
27932
|
try {
|
|
27356
27933
|
await sqlcmd(
|
|
27357
27934
|
ctx,
|
|
@@ -27369,7 +27946,7 @@ DROP DATABASE ${q(n.template)};`,
|
|
|
27369
27946
|
assertOk(await ctx.exec("docker", ["cp", `${cid}:${bak}`, file]), `Copying ${bak} out`);
|
|
27370
27947
|
await assertPrivateNonEmpty(file, what);
|
|
27371
27948
|
} catch (err) {
|
|
27372
|
-
await
|
|
27949
|
+
await fs61.rm(file, { force: true });
|
|
27373
27950
|
throw err instanceof DatastoreError ? err : new DatastoreError(`${what} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
27374
27951
|
} finally {
|
|
27375
27952
|
await removeContainerFile(ctx, t.datastore, bak).catch(() => void 0);
|
|
@@ -27974,13 +28551,13 @@ function derivedEnvState(instance, base, worktreePath2) {
|
|
|
27974
28551
|
|
|
27975
28552
|
// src/workspace/env/processes.ts
|
|
27976
28553
|
import { spawn } from "node:child_process";
|
|
27977
|
-
import
|
|
28554
|
+
import fs63 from "node:fs";
|
|
27978
28555
|
import http from "node:http";
|
|
27979
28556
|
import path99 from "node:path";
|
|
27980
28557
|
|
|
27981
28558
|
// src/workspace/env/orphans.ts
|
|
27982
28559
|
import { execFile } from "node:child_process";
|
|
27983
|
-
import
|
|
28560
|
+
import fs62 from "node:fs";
|
|
27984
28561
|
import path98 from "node:path";
|
|
27985
28562
|
function parseLsofListeners(text) {
|
|
27986
28563
|
const out = [];
|
|
@@ -28033,7 +28610,7 @@ function portOwnerOf(port, ctx) {
|
|
|
28033
28610
|
function classifyListener(l, ctx) {
|
|
28034
28611
|
const owner = portOwnerOf(l.port, ctx);
|
|
28035
28612
|
if (!owner) return void 0;
|
|
28036
|
-
const exists3 = ctx.exists ??
|
|
28613
|
+
const exists3 = ctx.exists ?? fs62.existsSync;
|
|
28037
28614
|
const portOwner = owner.kind === "lease" ? owner.lease.slug : "main";
|
|
28038
28615
|
const result = (cls, reason) => ({
|
|
28039
28616
|
...l,
|
|
@@ -28075,7 +28652,7 @@ async function resolveCwds(pids, exec = execText) {
|
|
|
28075
28652
|
}
|
|
28076
28653
|
function realOr(p) {
|
|
28077
28654
|
try {
|
|
28078
|
-
return
|
|
28655
|
+
return fs62.realpathSync(p);
|
|
28079
28656
|
} catch {
|
|
28080
28657
|
return p;
|
|
28081
28658
|
}
|
|
@@ -28198,7 +28775,7 @@ async function anyHostHealthy(port, pathname) {
|
|
|
28198
28775
|
}
|
|
28199
28776
|
function tail(file) {
|
|
28200
28777
|
try {
|
|
28201
|
-
const lines =
|
|
28778
|
+
const lines = fs63.readFileSync(file, "utf8").trimEnd().split("\n");
|
|
28202
28779
|
return lines.slice(-LOG_TAIL_LINES).join("\n");
|
|
28203
28780
|
} catch {
|
|
28204
28781
|
return "";
|
|
@@ -28225,7 +28802,7 @@ function prepare(ctx, name, state) {
|
|
|
28225
28802
|
}
|
|
28226
28803
|
const member = ctx.members.find((m) => m.id === spec.repo || m.folder === spec.repo);
|
|
28227
28804
|
const cwd = member?.worktreePath;
|
|
28228
|
-
if (!cwd || !
|
|
28805
|
+
if (!cwd || !fs63.existsSync(cwd)) {
|
|
28229
28806
|
return {
|
|
28230
28807
|
ok: false,
|
|
28231
28808
|
reason: ctx.mode === "single-repo" ? `Folder "${spec.repo}" does not exist in this worktree.` : `Repo "${spec.repo}" is not materialised in this worktree. Run \`haus workspace worktree hydrate --materialize\` first.`
|
|
@@ -28308,13 +28885,13 @@ async function startProcess(ctx, name, opts = {}) {
|
|
|
28308
28885
|
}
|
|
28309
28886
|
const logRel = logRelPath(name);
|
|
28310
28887
|
const logAbs = path99.join(p.worktree, logRel);
|
|
28311
|
-
|
|
28312
|
-
|
|
28888
|
+
fs63.mkdirSync(path99.dirname(logAbs), { recursive: true });
|
|
28889
|
+
fs63.appendFileSync(
|
|
28313
28890
|
logAbs,
|
|
28314
28891
|
`--- haus env start ${name} ${(/* @__PURE__ */ new Date()).toISOString()}: ${p.command}
|
|
28315
28892
|
`
|
|
28316
28893
|
);
|
|
28317
|
-
const fd =
|
|
28894
|
+
const fd = fs63.openSync(logAbs, "a");
|
|
28318
28895
|
let child;
|
|
28319
28896
|
try {
|
|
28320
28897
|
child = spawn(p.command, {
|
|
@@ -28325,7 +28902,7 @@ async function startProcess(ctx, name, opts = {}) {
|
|
|
28325
28902
|
env: { ...process.env, HAUS_ENV_FILE: envStatePath(p.worktree) }
|
|
28326
28903
|
});
|
|
28327
28904
|
} finally {
|
|
28328
|
-
|
|
28905
|
+
fs63.closeSync(fd);
|
|
28329
28906
|
}
|
|
28330
28907
|
const pid = child.pid;
|
|
28331
28908
|
if (pid === void 0) {
|
|
@@ -28420,12 +28997,12 @@ function errorText(err) {
|
|
|
28420
28997
|
|
|
28421
28998
|
// src/workspace/env/sites/herd.ts
|
|
28422
28999
|
import { existsSync as existsSync7, lstatSync, readlinkSync } from "node:fs";
|
|
28423
|
-
import
|
|
29000
|
+
import fs66 from "node:fs/promises";
|
|
28424
29001
|
import path102 from "node:path";
|
|
28425
29002
|
|
|
28426
29003
|
// src/workspace/env/dotenv-writer.ts
|
|
28427
29004
|
import { createHash as createHash4 } from "node:crypto";
|
|
28428
|
-
import
|
|
29005
|
+
import fs64 from "node:fs/promises";
|
|
28429
29006
|
var DOTENV_MODE = 384;
|
|
28430
29007
|
var LINE_KEY_RE = /^(\s*)(export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)(\s*)=/;
|
|
28431
29008
|
function hashContent2(content2) {
|
|
@@ -28466,7 +29043,7 @@ function linesByKey(content2) {
|
|
|
28466
29043
|
}
|
|
28467
29044
|
async function readIfExists(file) {
|
|
28468
29045
|
try {
|
|
28469
|
-
return await
|
|
29046
|
+
return await fs64.readFile(file, "utf8");
|
|
28470
29047
|
} catch (err) {
|
|
28471
29048
|
if (err.code === "ENOENT") return void 0;
|
|
28472
29049
|
throw new Error(`Could not read ${file} (${err.code ?? "error"}).`);
|
|
@@ -28581,7 +29158,7 @@ function describeDotenvResult(r) {
|
|
|
28581
29158
|
|
|
28582
29159
|
// src/workspace/env/sites/wp-config-override.ts
|
|
28583
29160
|
import { existsSync as existsSync6 } from "node:fs";
|
|
28584
|
-
import
|
|
29161
|
+
import fs65 from "node:fs/promises";
|
|
28585
29162
|
import path101 from "node:path";
|
|
28586
29163
|
init_paths();
|
|
28587
29164
|
var WP_CONFIG_FILE = "wp-config.php";
|
|
@@ -28662,7 +29239,7 @@ async function patchMainWpConfig(mainDocroot, keys, opts) {
|
|
|
28662
29239
|
const file = path101.join(mainDocroot, WP_CONFIG_FILE);
|
|
28663
29240
|
if (!existsSync6(file)) return { status: "missing", file };
|
|
28664
29241
|
const exec = opts.exec ?? defaultExec;
|
|
28665
|
-
const source = await
|
|
29242
|
+
const source = await fs65.readFile(file, "utf8");
|
|
28666
29243
|
const patch = patchWpConfigSource(source, keys);
|
|
28667
29244
|
if (patch.text === source) {
|
|
28668
29245
|
return {
|
|
@@ -28680,11 +29257,11 @@ async function patchMainWpConfig(mainDocroot, keys, opts) {
|
|
|
28680
29257
|
dirBackupDirName("worktreeEnv", newBackupStamp())
|
|
28681
29258
|
);
|
|
28682
29259
|
const backup = path101.join(backupDir, inside2 ? rel : WP_CONFIG_FILE);
|
|
28683
|
-
const mode = (await
|
|
28684
|
-
await
|
|
28685
|
-
await
|
|
28686
|
-
await
|
|
28687
|
-
if (await
|
|
29260
|
+
const mode = (await fs65.stat(file)).mode & 511;
|
|
29261
|
+
await fs65.mkdir(path101.dirname(backup), { recursive: true });
|
|
29262
|
+
await fs65.copyFile(file, backup);
|
|
29263
|
+
await fs65.chmod(backup, 384);
|
|
29264
|
+
if (await fs65.readFile(backup, "utf8") !== source) {
|
|
28688
29265
|
throw new SiteConfigError(
|
|
28689
29266
|
`The backup of ${file} does not match the original; wp-config.php was not changed.`
|
|
28690
29267
|
);
|
|
@@ -28692,8 +29269,8 @@ async function patchMainWpConfig(mainDocroot, keys, opts) {
|
|
|
28692
29269
|
await writeFileAtomic(file, patch.text, { mode });
|
|
28693
29270
|
const lint = await exec(opts.php ?? "php", ["-l", file]);
|
|
28694
29271
|
if (lint.exitCode !== 0) {
|
|
28695
|
-
await
|
|
28696
|
-
await
|
|
29272
|
+
await fs65.copyFile(backup, file);
|
|
29273
|
+
await fs65.chmod(file, mode);
|
|
28697
29274
|
return {
|
|
28698
29275
|
status: "restored",
|
|
28699
29276
|
file,
|
|
@@ -28808,8 +29385,8 @@ function normalizeRel(rel) {
|
|
|
28808
29385
|
return path102.normalize(rel).replace(/\/+$/, "");
|
|
28809
29386
|
}
|
|
28810
29387
|
async function cowCopy(exec, main, dest, skip, rel = "") {
|
|
28811
|
-
await
|
|
28812
|
-
for (const entry of await
|
|
29388
|
+
await fs66.mkdir(path102.join(dest, rel), { recursive: true });
|
|
29389
|
+
for (const entry of await fs66.readdir(path102.join(main, rel), { withFileTypes: true })) {
|
|
28813
29390
|
const childRel = rel ? path102.join(rel, entry.name) : entry.name;
|
|
28814
29391
|
if (skip.has(childRel)) continue;
|
|
28815
29392
|
const nested = [...skip].some((s) => s.startsWith(`${childRel}/`));
|
|
@@ -28832,12 +29409,12 @@ async function ensureSymlink(link, target) {
|
|
|
28832
29409
|
}
|
|
28833
29410
|
if (st?.isSymbolicLink()) {
|
|
28834
29411
|
if (readlinkSync(link) === target) return;
|
|
28835
|
-
await
|
|
29412
|
+
await fs66.unlink(link);
|
|
28836
29413
|
} else if (st) {
|
|
28837
29414
|
throw new SiteError(`${link} exists and is not a symlink; leaving it alone.`);
|
|
28838
29415
|
}
|
|
28839
|
-
await
|
|
28840
|
-
await
|
|
29416
|
+
await fs66.mkdir(path102.dirname(link), { recursive: true });
|
|
29417
|
+
await fs66.symlink(target, link);
|
|
28841
29418
|
}
|
|
28842
29419
|
function memberPath(ctx, ref) {
|
|
28843
29420
|
const m = ctx.members.find((x) => x.folder === ref || x.id === ref);
|
|
@@ -28866,7 +29443,7 @@ function siteLockPath(workspaceRoot, name) {
|
|
|
28866
29443
|
}
|
|
28867
29444
|
async function readLock2(file) {
|
|
28868
29445
|
try {
|
|
28869
|
-
const v = JSON.parse(await
|
|
29446
|
+
const v = JSON.parse(await fs66.readFile(file, "utf8"));
|
|
28870
29447
|
return typeof v.owner === "string" && typeof v.expiresAt === "string" ? v : void 0;
|
|
28871
29448
|
} catch {
|
|
28872
29449
|
return void 0;
|
|
@@ -28895,7 +29472,7 @@ async function releaseSiteLock(ctx, name) {
|
|
|
28895
29472
|
const file = siteLockPath(ctx.workspaceRoot, name);
|
|
28896
29473
|
const held = await readLock2(file);
|
|
28897
29474
|
if (!held || held.owner !== ctx.slug) return false;
|
|
28898
|
-
await
|
|
29475
|
+
await fs66.rm(file, { force: true });
|
|
28899
29476
|
return true;
|
|
28900
29477
|
}
|
|
28901
29478
|
function resolvedDefines(site, withSite) {
|
|
@@ -28918,19 +29495,19 @@ async function refreshHerdSiteConfig(ctx, name, site, opts = {}) {
|
|
|
28918
29495
|
const defines = resolvedDefines(site, siteTemplateValues(ctx, name, site, worktree2));
|
|
28919
29496
|
let changed = false;
|
|
28920
29497
|
const instanceFile = path102.join(worktree2, INSTANCE_CONFIG_FILE);
|
|
28921
|
-
const current = existsSync7(instanceFile) ? await
|
|
29498
|
+
const current = existsSync7(instanceFile) ? await fs66.readFile(instanceFile, "utf8") : void 0;
|
|
28922
29499
|
if (current !== renderInstanceConfig(defines)) {
|
|
28923
29500
|
changed = true;
|
|
28924
29501
|
if (!opts.dryRun) await writeInstanceConfig(worktree2, defines);
|
|
28925
29502
|
}
|
|
28926
29503
|
const wpConfig = path102.join(worktree2, WP_CONFIG_FILE);
|
|
28927
29504
|
if (existsSync7(wpConfig)) {
|
|
28928
|
-
const source = await
|
|
29505
|
+
const source = await fs66.readFile(wpConfig, "utf8");
|
|
28929
29506
|
const patch = patchWpConfigSource(source, Object.keys(defines));
|
|
28930
29507
|
if (patch.text !== source) {
|
|
28931
29508
|
changed = true;
|
|
28932
29509
|
if (!opts.dryRun) {
|
|
28933
|
-
const mode = (await
|
|
29510
|
+
const mode = (await fs66.stat(wpConfig)).mode & 511;
|
|
28934
29511
|
await writeFileAtomic(wpConfig, patch.text, { mode });
|
|
28935
29512
|
}
|
|
28936
29513
|
}
|
|
@@ -28977,7 +29554,7 @@ async function provisionHerdSite(ctx, name, site, opts = {}) {
|
|
|
28977
29554
|
ctx.log?.(`Cloning ${site.root} (copy-on-write).`);
|
|
28978
29555
|
const skip = /* @__PURE__ */ new Set([...shares, ...plugins.map((p) => p.rel), INSTANCE_CONFIG_FILE]);
|
|
28979
29556
|
await cowCopy(ctx.exec, main, partial, skip);
|
|
28980
|
-
await
|
|
29557
|
+
await fs66.rename(partial, worktree2);
|
|
28981
29558
|
status = "created";
|
|
28982
29559
|
}
|
|
28983
29560
|
const links = [];
|
|
@@ -29091,7 +29668,7 @@ async function teardownHerdSite(ctx, name, site, recorded) {
|
|
|
29091
29668
|
trashed = true;
|
|
29092
29669
|
} else {
|
|
29093
29670
|
for (const link of siteLinksInWorktree(ctx.worktreePath, worktree2, site, recorded)) {
|
|
29094
|
-
await
|
|
29671
|
+
await fs66.unlink(link);
|
|
29095
29672
|
}
|
|
29096
29673
|
}
|
|
29097
29674
|
return { unlinked, trashed, lockReleased: false, notes };
|
|
@@ -29831,7 +30408,7 @@ function formatEnvStatus(r) {
|
|
|
29831
30408
|
init_fs();
|
|
29832
30409
|
import { createHash as createHash5 } from "node:crypto";
|
|
29833
30410
|
import { existsSync as existsSync10 } from "node:fs";
|
|
29834
|
-
import
|
|
30411
|
+
import fs67 from "node:fs/promises";
|
|
29835
30412
|
import path104 from "node:path";
|
|
29836
30413
|
function upRefusal(ctx) {
|
|
29837
30414
|
if (!ctx.enabled) return envEnableHint(ctx);
|
|
@@ -30154,7 +30731,7 @@ async function copyLocal(run) {
|
|
|
30154
30731
|
continue;
|
|
30155
30732
|
}
|
|
30156
30733
|
const key = `copyLocal:${label}`;
|
|
30157
|
-
const bytes = await
|
|
30734
|
+
const bytes = await fs67.readFile(src);
|
|
30158
30735
|
const hash = sha(bytes);
|
|
30159
30736
|
if (existsSync10(dest) && run.prior?.sourceEnvHashes[key] === hash) continue;
|
|
30160
30737
|
run.changes.copiedLocal.push(label);
|
|
@@ -30677,7 +31254,7 @@ async function handlePatchWpConfig(opts) {
|
|
|
30677
31254
|
if (opts.dryRun) {
|
|
30678
31255
|
const file = path105.join(docroot, WP_CONFIG_FILE);
|
|
30679
31256
|
if (!existsSync11(file)) return fail2(opts, `${file} does not exist.`);
|
|
30680
|
-
const p = patchWpConfigSource(await
|
|
31257
|
+
const p = patchWpConfigSource(await fs68.readFile(file, "utf8"), keys);
|
|
30681
31258
|
const doc = {
|
|
30682
31259
|
ok: true,
|
|
30683
31260
|
dryRun: true,
|
|
@@ -30757,7 +31334,7 @@ init_logger();
|
|
|
30757
31334
|
// src/workspace/worktree/add.ts
|
|
30758
31335
|
init_fs();
|
|
30759
31336
|
import path111 from "node:path";
|
|
30760
|
-
import
|
|
31337
|
+
import fs72 from "fs-extra";
|
|
30761
31338
|
|
|
30762
31339
|
// src/workspace/worktree/git-worktree.ts
|
|
30763
31340
|
init_exec();
|
|
@@ -30898,12 +31475,12 @@ async function hasUnpushedWork(cwd) {
|
|
|
30898
31475
|
// src/workspace/worktree/hydrate.ts
|
|
30899
31476
|
init_exec();
|
|
30900
31477
|
import path109 from "node:path";
|
|
30901
|
-
import
|
|
31478
|
+
import fs70 from "fs-extra";
|
|
30902
31479
|
|
|
30903
31480
|
// src/workspace/worktree/cow-copy.ts
|
|
30904
31481
|
init_exec();
|
|
30905
31482
|
import path107 from "node:path";
|
|
30906
|
-
import
|
|
31483
|
+
import fs69 from "fs-extra";
|
|
30907
31484
|
async function getMacFilesystemType(dir) {
|
|
30908
31485
|
const df = await runCommand("df", ["-P", dir]);
|
|
30909
31486
|
if (df.exitCode !== 0) return void 0;
|
|
@@ -30934,14 +31511,14 @@ async function detectCowStrategy(dir) {
|
|
|
30934
31511
|
return "unknown-platform";
|
|
30935
31512
|
}
|
|
30936
31513
|
async function cowCopyDir(src, dest) {
|
|
30937
|
-
const srcExists = await
|
|
31514
|
+
const srcExists = await fs69.pathExists(src);
|
|
30938
31515
|
if (!srcExists)
|
|
30939
31516
|
return { strategy: "unsupported", attempted: false, ok: false, error: "source missing" };
|
|
30940
31517
|
const strategy = await detectCowStrategy(path107.dirname(dest));
|
|
30941
31518
|
if (strategy === "unsupported" || strategy === "unknown-platform") {
|
|
30942
31519
|
return { strategy, attempted: false, ok: false };
|
|
30943
31520
|
}
|
|
30944
|
-
await
|
|
31521
|
+
await fs69.ensureDir(path107.dirname(dest));
|
|
30945
31522
|
const args = strategy === "darwin-clonefile" ? ["-c", "-R", src, dest] : ["-a", "--reflink=always", src, dest];
|
|
30946
31523
|
const result = await runCommand("cp", args);
|
|
30947
31524
|
return {
|
|
@@ -31119,7 +31696,7 @@ function summarizeInstall(install) {
|
|
|
31119
31696
|
}
|
|
31120
31697
|
if (install.plan.manager === "none") return "no install step (no recognized lockfile)";
|
|
31121
31698
|
const envNotes = (install.missingEnvVars ?? []).map(
|
|
31122
|
-
(name) => `${name} not set in environment
|
|
31699
|
+
(name) => `${name} not set in environment, so yarn install will fail`
|
|
31123
31700
|
);
|
|
31124
31701
|
if (!install.ran) {
|
|
31125
31702
|
const skip = `install skipped (${install.skippedReason ?? "unavailable"})`;
|
|
@@ -31234,8 +31811,8 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
|
|
|
31234
31811
|
for (const target of HYDRATION_TARGETS) {
|
|
31235
31812
|
const src = path109.join(mainMemberPath, target);
|
|
31236
31813
|
const dest = path109.join(worktreeMemberPath, target);
|
|
31237
|
-
if (!await
|
|
31238
|
-
const destExists = await
|
|
31814
|
+
if (!await fs70.pathExists(src)) continue;
|
|
31815
|
+
const destExists = await fs70.pathExists(dest);
|
|
31239
31816
|
if (!opts.force && destExists) continue;
|
|
31240
31817
|
if (opts.force && destExists) {
|
|
31241
31818
|
const refusal2 = await replaceRefusal(worktreeMemberPath, target);
|
|
@@ -31243,7 +31820,7 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
|
|
|
31243
31820
|
refusedTargets.push({ target, path: dest, reason: refusal2 });
|
|
31244
31821
|
continue;
|
|
31245
31822
|
}
|
|
31246
|
-
await
|
|
31823
|
+
await fs70.remove(dest);
|
|
31247
31824
|
}
|
|
31248
31825
|
const copy = await cowCopyDir(src, dest);
|
|
31249
31826
|
targets.push({ target, copy });
|
|
@@ -31286,7 +31863,7 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
|
|
|
31286
31863
|
init_exec();
|
|
31287
31864
|
init_fs();
|
|
31288
31865
|
import path110 from "node:path";
|
|
31289
|
-
import
|
|
31866
|
+
import fs71 from "fs-extra";
|
|
31290
31867
|
var MACHINE_LOCAL_FILES = [
|
|
31291
31868
|
"docker-compose.local.yml",
|
|
31292
31869
|
path110.join(".claude", "settings.local.json")
|
|
@@ -31305,7 +31882,7 @@ async function copyLocalFiles(mainMemberPath, worktreeMemberPath, relPaths, opts
|
|
|
31305
31882
|
result.skipped.push({ path: rel, reason: "outside-repo" });
|
|
31306
31883
|
continue;
|
|
31307
31884
|
}
|
|
31308
|
-
if (!await
|
|
31885
|
+
if (!await fs71.pathExists(src)) {
|
|
31309
31886
|
result.skipped.push({ path: rel, reason: "missing" });
|
|
31310
31887
|
continue;
|
|
31311
31888
|
}
|
|
@@ -31313,9 +31890,9 @@ async function copyLocalFiles(mainMemberPath, worktreeMemberPath, relPaths, opts
|
|
|
31313
31890
|
result.skipped.push({ path: rel, reason: "tracked" });
|
|
31314
31891
|
continue;
|
|
31315
31892
|
}
|
|
31316
|
-
await
|
|
31317
|
-
await
|
|
31318
|
-
if (opts.mode !== void 0) await
|
|
31893
|
+
await fs71.ensureDir(path110.dirname(dest));
|
|
31894
|
+
await fs71.copy(src, dest, { overwrite: true });
|
|
31895
|
+
if (opts.mode !== void 0) await fs71.chmod(dest, opts.mode);
|
|
31319
31896
|
result.copied.push(rel);
|
|
31320
31897
|
}
|
|
31321
31898
|
return result;
|
|
@@ -31350,10 +31927,10 @@ async function runAdd(opts) {
|
|
|
31350
31927
|
}
|
|
31351
31928
|
const branch = opts.branch ?? opts.slug;
|
|
31352
31929
|
const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
|
|
31353
|
-
if (await
|
|
31930
|
+
if (await fs72.pathExists(wsWorktreePath)) {
|
|
31354
31931
|
return {
|
|
31355
31932
|
ok: false,
|
|
31356
|
-
error: `Workspace worktree already exists at ${wsWorktreePath}
|
|
31933
|
+
error: `Workspace worktree already exists at ${wsWorktreePath}. 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.`
|
|
31357
31934
|
};
|
|
31358
31935
|
}
|
|
31359
31936
|
if (opts.dryRun) {
|
|
@@ -31363,7 +31940,7 @@ async function runAdd(opts) {
|
|
|
31363
31940
|
if (!wsOutcome.ok) {
|
|
31364
31941
|
return {
|
|
31365
31942
|
ok: false,
|
|
31366
|
-
error: wsOutcome.checkedOutElsewhere ? `Branch "${branch}" is already checked out elsewhere in the workspace repo
|
|
31943
|
+
error: wsOutcome.checkedOutElsewhere ? `Branch "${branch}" is already checked out elsewhere in the workspace repo. Pick a different --branch.` : `Failed to create the workspace worktree: ${wsOutcome.stderr ?? "unknown error"}`
|
|
31367
31944
|
};
|
|
31368
31945
|
}
|
|
31369
31946
|
const memberResults = [];
|
|
@@ -31380,7 +31957,7 @@ async function runAdd(opts) {
|
|
|
31380
31957
|
status: "failed",
|
|
31381
31958
|
branch,
|
|
31382
31959
|
branchAction: outcome.branchAction,
|
|
31383
|
-
error: outcome.checkedOutElsewhere ? `Branch "${branch}" is already checked out elsewhere in ${member.id}
|
|
31960
|
+
error: outcome.checkedOutElsewhere ? `Branch "${branch}" is already checked out elsewhere in ${member.id}. Pick a different --branch.` : outcome.stderr ?? "unknown error",
|
|
31384
31961
|
durationMs: Date.now() - start
|
|
31385
31962
|
});
|
|
31386
31963
|
continue;
|
|
@@ -31468,7 +32045,7 @@ async function runDryRun(args) {
|
|
|
31468
32045
|
import { spawn as spawn2 } from "node:child_process";
|
|
31469
32046
|
import fsSync from "node:fs";
|
|
31470
32047
|
import path117 from "node:path";
|
|
31471
|
-
import
|
|
32048
|
+
import fs74 from "fs-extra";
|
|
31472
32049
|
|
|
31473
32050
|
// src/workspace/worktree/doctor.ts
|
|
31474
32051
|
import { existsSync as existsSync14 } from "node:fs";
|
|
@@ -31477,7 +32054,7 @@ import path114 from "node:path";
|
|
|
31477
32054
|
// src/workspace/worktree/hydrate-lock.ts
|
|
31478
32055
|
init_paths();
|
|
31479
32056
|
import path112 from "node:path";
|
|
31480
|
-
import
|
|
32057
|
+
import fs73 from "fs-extra";
|
|
31481
32058
|
var STALE_LOCK_MS = 30 * 60 * 1e3;
|
|
31482
32059
|
function locksDir(mainRoot) {
|
|
31483
32060
|
return hausPath(mainRoot, "locks");
|
|
@@ -31488,7 +32065,7 @@ function logsDir(mainRoot) {
|
|
|
31488
32065
|
function assertSlug(slug) {
|
|
31489
32066
|
if (!isValidWorktreeSlug(slug)) {
|
|
31490
32067
|
throw new Error(
|
|
31491
|
-
`Invalid worktree slug ${JSON.stringify(slug)}
|
|
32068
|
+
`Invalid worktree slug ${JSON.stringify(slug)}: slugs may only contain letters, digits, ".", "_", "-" (no path separators, and not "." or "..").`
|
|
31492
32069
|
);
|
|
31493
32070
|
}
|
|
31494
32071
|
return slug;
|
|
@@ -31512,7 +32089,7 @@ function defaultIsAlive2(pid) {
|
|
|
31512
32089
|
}
|
|
31513
32090
|
async function readOwnerPid(lockPath) {
|
|
31514
32091
|
try {
|
|
31515
|
-
const raw = (await
|
|
32092
|
+
const raw = (await fs73.readFile(path112.join(lockPath, "pid"), "utf8")).trim();
|
|
31516
32093
|
if (raw === "") return void 0;
|
|
31517
32094
|
const pid = Number.parseInt(raw, 10);
|
|
31518
32095
|
return Number.isInteger(pid) && pid > 0 ? pid : void 0;
|
|
@@ -31521,13 +32098,13 @@ async function readOwnerPid(lockPath) {
|
|
|
31521
32098
|
}
|
|
31522
32099
|
}
|
|
31523
32100
|
async function claim(lockPath, pid) {
|
|
31524
|
-
await
|
|
32101
|
+
await fs73.ensureDir(path112.dirname(lockPath));
|
|
31525
32102
|
try {
|
|
31526
|
-
await
|
|
32103
|
+
await fs73.mkdir(lockPath);
|
|
31527
32104
|
} catch {
|
|
31528
32105
|
return false;
|
|
31529
32106
|
}
|
|
31530
|
-
await
|
|
32107
|
+
await fs73.writeFile(path112.join(lockPath, "pid"), String(pid), "utf8");
|
|
31531
32108
|
return true;
|
|
31532
32109
|
}
|
|
31533
32110
|
async function acquireHydrateLock(mainRoot, slug, deps = {}) {
|
|
@@ -31546,7 +32123,7 @@ async function acquireHydrateLock(mainRoot, slug, deps = {}) {
|
|
|
31546
32123
|
}
|
|
31547
32124
|
if (alive) return { acquired: false, reason: "live", ownerPid, lockPath };
|
|
31548
32125
|
} else {
|
|
31549
|
-
const stat2 = await
|
|
32126
|
+
const stat2 = await fs73.stat(lockPath).catch(() => void 0);
|
|
31550
32127
|
if (stat2 === void 0) {
|
|
31551
32128
|
return await claim(lockPath, pid) ? { acquired: true, lockPath } : { acquired: false, reason: "live", lockPath };
|
|
31552
32129
|
}
|
|
@@ -31554,18 +32131,18 @@ async function acquireHydrateLock(mainRoot, slug, deps = {}) {
|
|
|
31554
32131
|
return { acquired: false, reason: "starting", lockPath };
|
|
31555
32132
|
}
|
|
31556
32133
|
}
|
|
31557
|
-
await
|
|
32134
|
+
await fs73.remove(lockPath);
|
|
31558
32135
|
return await claim(lockPath, pid) ? { acquired: true, lockPath } : { acquired: false, reason: "live", ownerPid, lockPath };
|
|
31559
32136
|
}
|
|
31560
32137
|
async function recordHydrateLockOwner(mainRoot, slug, pid) {
|
|
31561
|
-
await
|
|
32138
|
+
await fs73.writeFile(path112.join(hydrateLockPath(mainRoot, slug), "pid"), String(pid), "utf8");
|
|
31562
32139
|
}
|
|
31563
32140
|
async function releaseHydrateLock(mainRoot, slug) {
|
|
31564
|
-
await
|
|
32141
|
+
await fs73.remove(hydrateLockPath(mainRoot, slug));
|
|
31565
32142
|
}
|
|
31566
32143
|
async function readHydrateLock(mainRoot, slug) {
|
|
31567
32144
|
const lockPath = hydrateLockPath(mainRoot, slug);
|
|
31568
|
-
const stat2 = await
|
|
32145
|
+
const stat2 = await fs73.stat(lockPath).catch(() => void 0);
|
|
31569
32146
|
if (stat2 === void 0) return { held: false };
|
|
31570
32147
|
const ownerPid = await readOwnerPid(lockPath);
|
|
31571
32148
|
return {
|
|
@@ -31659,7 +32236,7 @@ async function runDoctor2() {
|
|
|
31659
32236
|
members: [],
|
|
31660
32237
|
orphans: [],
|
|
31661
32238
|
problems: [
|
|
31662
|
-
`Not a haus workspace
|
|
32239
|
+
`Not a haus workspace: no ${WORKSPACE_FILE} or ${REPOS_MANIFEST_FILE} at ${workspaceRoot}.`
|
|
31663
32240
|
],
|
|
31664
32241
|
remedies: [],
|
|
31665
32242
|
autoHydrate: false,
|
|
@@ -31719,7 +32296,7 @@ async function runDoctor2() {
|
|
|
31719
32296
|
}
|
|
31720
32297
|
if (memberChecks.some((m) => !m.materialized)) {
|
|
31721
32298
|
remedies.push(
|
|
31722
|
-
hydrationInProgress ? `A hydration is already running for "${slug}"${hydrationOwnerPid !== void 0 ? ` (pid ${hydrationOwnerPid})` : ""}
|
|
32299
|
+
hydrationInProgress ? `A hydration is already running for "${slug}"${hydrationOwnerPid !== void 0 ? ` (pid ${hydrationOwnerPid})` : ""}. Wait for ${lock.held ? lock.lockPath : "the lock"} to disappear rather than starting another.` : "Run `haus workspace worktree hydrate --materialize` to materialize the missing members."
|
|
31723
32300
|
);
|
|
31724
32301
|
}
|
|
31725
32302
|
}
|
|
@@ -31793,7 +32370,7 @@ async function runHydrateWorktree(opts) {
|
|
|
31793
32370
|
member: member.id,
|
|
31794
32371
|
folder: member.folder,
|
|
31795
32372
|
status: "skipped",
|
|
31796
|
-
error: "not materialized in this worktree
|
|
32373
|
+
error: "not materialized in this worktree. Re-run with --materialize to create it"
|
|
31797
32374
|
};
|
|
31798
32375
|
}
|
|
31799
32376
|
const branchAction = await branchExists(member.absPath, targetBranch) ? "checkout" : "create";
|
|
@@ -31813,7 +32390,7 @@ async function runHydrateWorktree(opts) {
|
|
|
31813
32390
|
member: member.id,
|
|
31814
32391
|
folder: member.folder,
|
|
31815
32392
|
status: "skipped",
|
|
31816
|
-
error: "not materialized in this worktree
|
|
32393
|
+
error: "not materialized in this worktree. Re-run with --materialize to create it"
|
|
31817
32394
|
};
|
|
31818
32395
|
}
|
|
31819
32396
|
const outcome = await addWorktree(member.absPath, memberPath2, targetBranch, {
|
|
@@ -31824,7 +32401,7 @@ async function runHydrateWorktree(opts) {
|
|
|
31824
32401
|
member: member.id,
|
|
31825
32402
|
folder: member.folder,
|
|
31826
32403
|
status: "failed",
|
|
31827
|
-
error: outcome.checkedOutElsewhere ? `Branch "${targetBranch}" is already checked out elsewhere in ${member.id}
|
|
32404
|
+
error: outcome.checkedOutElsewhere ? `Branch "${targetBranch}" is already checked out elsewhere in ${member.id}. Remove that worktree or resolve the branch conflict first.` : outcome.stderr ?? "unknown error creating the member worktree"
|
|
31828
32405
|
};
|
|
31829
32406
|
}
|
|
31830
32407
|
branchAction = outcome.branchAction;
|
|
@@ -31977,11 +32554,11 @@ async function consumeLastRunFailure(mainRoot, slug) {
|
|
|
31977
32554
|
const file = hydrateLastRunPath(mainRoot, slug);
|
|
31978
32555
|
let parsed;
|
|
31979
32556
|
try {
|
|
31980
|
-
parsed = JSON.parse(await
|
|
32557
|
+
parsed = JSON.parse(await fs74.readFile(file, "utf8"));
|
|
31981
32558
|
} catch {
|
|
31982
32559
|
return void 0;
|
|
31983
32560
|
}
|
|
31984
|
-
await
|
|
32561
|
+
await fs74.remove(file).catch(() => void 0);
|
|
31985
32562
|
if (parsed && parsed.ok === false) return parsed;
|
|
31986
32563
|
return void 0;
|
|
31987
32564
|
}
|
|
@@ -32053,7 +32630,7 @@ async function runAutoHydrate(opts = {}, deps = {}) {
|
|
|
32053
32630
|
reason: "could not determine the haus CLI entry point to re-invoke"
|
|
32054
32631
|
};
|
|
32055
32632
|
}
|
|
32056
|
-
await
|
|
32633
|
+
await fs74.ensureDir(logsDir(mainRoot));
|
|
32057
32634
|
appendLog(
|
|
32058
32635
|
logPath,
|
|
32059
32636
|
`autohydrate: forking hydrate --materialize for "${slug}" (${missingMembers.join(", ")})`
|
|
@@ -32105,7 +32682,7 @@ async function runDetachedHydration() {
|
|
|
32105
32682
|
}
|
|
32106
32683
|
const detail = "results" in result ? result.results : [];
|
|
32107
32684
|
const failures = detail.filter((r) => r.status === "failed" || anyInstallFailed(r.hydration)).map(
|
|
32108
|
-
(r) => r.status === "failed" ? `${r.member}: failed to materialize
|
|
32685
|
+
(r) => r.status === "failed" ? `${r.member}: failed to materialize: ${r.error ?? "unknown error"}` : `${r.member}: materialized, but its install FAILED via ${failedManagers(r.hydration)}`
|
|
32109
32686
|
);
|
|
32110
32687
|
await writeLastRun(mainRoot, slug, result.ok, failures);
|
|
32111
32688
|
appendLog(logPath, `hydrate finished: ${result.ok ? "ok" : `FAILED (${failures.join("; ")})`}`);
|
|
@@ -32122,22 +32699,22 @@ async function runDetachedHydration() {
|
|
|
32122
32699
|
async function writeLastRun(mainRoot, slug, ok2, failures) {
|
|
32123
32700
|
const file = hydrateLastRunPath(mainRoot, slug);
|
|
32124
32701
|
if (ok2) {
|
|
32125
|
-
await
|
|
32702
|
+
await fs74.remove(file).catch(() => void 0);
|
|
32126
32703
|
return;
|
|
32127
32704
|
}
|
|
32128
32705
|
const record = { slug, finishedAt: (/* @__PURE__ */ new Date()).toISOString(), ok: ok2, failures };
|
|
32129
|
-
await
|
|
32130
|
-
await
|
|
32706
|
+
await fs74.ensureDir(path117.dirname(file));
|
|
32707
|
+
await fs74.writeJson(file, record, { spaces: 2 }).catch(() => void 0);
|
|
32131
32708
|
}
|
|
32132
32709
|
|
|
32133
32710
|
// src/workspace/worktree/dematerialize.ts
|
|
32134
32711
|
import { existsSync as existsSync17 } from "node:fs";
|
|
32135
32712
|
import path121 from "node:path";
|
|
32136
|
-
import
|
|
32713
|
+
import fs78 from "fs-extra";
|
|
32137
32714
|
|
|
32138
32715
|
// src/workspace/worktree/member-targets.ts
|
|
32139
32716
|
import path118 from "node:path";
|
|
32140
|
-
import
|
|
32717
|
+
import fs75 from "fs-extra";
|
|
32141
32718
|
function normalizeStateMembers(state, allMembers) {
|
|
32142
32719
|
return state?.members.map((m) => ({ id: m.id, folder: m.folder, absPath: m.absPath })) ?? allMembers.map((m) => ({ id: m.id, folder: m.folder, absPath: m.absPath }));
|
|
32143
32720
|
}
|
|
@@ -32162,15 +32739,15 @@ function resolveMemberTargets(wsWorktreePath, stateMembers, allMembers) {
|
|
|
32162
32739
|
async function filterExistingTargets(targets) {
|
|
32163
32740
|
const existing = [];
|
|
32164
32741
|
for (const target of targets) {
|
|
32165
|
-
if (await
|
|
32742
|
+
if (await fs75.pathExists(target.path)) existing.push(target);
|
|
32166
32743
|
}
|
|
32167
32744
|
return existing;
|
|
32168
32745
|
}
|
|
32169
32746
|
function unresolvedDroppedBlockerReason() {
|
|
32170
|
-
return "no longer in the workspace config and this worktree predates absPath tracking
|
|
32747
|
+
return "no longer in the workspace config and this worktree predates absPath tracking: cannot verify for uncommitted work or unregister its git worktree; removing anyway will delete its directory here but leak the registration in whatever repo it came from (run `git worktree prune` there manually afterward)";
|
|
32171
32748
|
}
|
|
32172
32749
|
function unresolvedDroppedFailureReason() {
|
|
32173
|
-
return "directory removed, but its git worktree registration could not be unregistered (no recorded absPath)
|
|
32750
|
+
return "directory removed, but its git worktree registration could not be unregistered (no recorded absPath); run `git worktree prune` in its original owning repo";
|
|
32174
32751
|
}
|
|
32175
32752
|
async function findWorkBlockers(targets, ignorePathsFor = () => [], envLinksFor = () => []) {
|
|
32176
32753
|
const blockers = [];
|
|
@@ -32194,12 +32771,12 @@ async function findWorkBlockers(targets, ignorePathsFor = () => [], envLinksFor
|
|
|
32194
32771
|
// src/workspace/worktree/remove.ts
|
|
32195
32772
|
import { existsSync as existsSync16 } from "node:fs";
|
|
32196
32773
|
import path120 from "node:path";
|
|
32197
|
-
import
|
|
32774
|
+
import fs77 from "fs-extra";
|
|
32198
32775
|
|
|
32199
32776
|
// src/workspace/worktree/snapshot.ts
|
|
32200
32777
|
import { realpathSync as realpathSync3 } from "node:fs";
|
|
32201
32778
|
import path119 from "node:path";
|
|
32202
|
-
import
|
|
32779
|
+
import fs76 from "fs-extra";
|
|
32203
32780
|
init_exec();
|
|
32204
32781
|
init_paths();
|
|
32205
32782
|
function pathspec(ignore) {
|
|
@@ -32213,7 +32790,7 @@ function samePath(a, b) {
|
|
|
32213
32790
|
}
|
|
32214
32791
|
}
|
|
32215
32792
|
async function probeDirty(target) {
|
|
32216
|
-
if (!await
|
|
32793
|
+
if (!await fs76.pathExists(target.path)) return { state: "not-a-worktree" };
|
|
32217
32794
|
const notOwnWorktree = (cause) => target.skipIfNotWorktree ? { state: "not-a-worktree" } : { state: "unknown", cause };
|
|
32218
32795
|
const top = await runGit(["rev-parse", "--show-toplevel"], { cwd: target.path });
|
|
32219
32796
|
if (top.exitCode !== 0) {
|
|
@@ -32270,9 +32847,9 @@ async function writeOne(target, dest) {
|
|
|
32270
32847
|
const branch = (await gitOrThrow(["rev-parse", "--abbrev-ref", "HEAD"], target.path)).trim();
|
|
32271
32848
|
const commit = (await gitOrThrow(["rev-parse", "HEAD"], target.path)).trim();
|
|
32272
32849
|
const untracked = (await gitOrThrow(["ls-files", "--others", "--exclude-standard", "-z", ...spec], target.path)).split("\0").filter((entry) => entry.length > 0);
|
|
32273
|
-
await
|
|
32274
|
-
await
|
|
32275
|
-
await
|
|
32850
|
+
await fs76.ensureDir(dest);
|
|
32851
|
+
await fs76.writeFile(path119.join(dest, "changes.patch"), patch);
|
|
32852
|
+
await fs76.writeFile(
|
|
32276
32853
|
path119.join(dest, "HEAD.txt"),
|
|
32277
32854
|
`branch: ${branch}
|
|
32278
32855
|
commit: ${commit}
|
|
@@ -32281,14 +32858,14 @@ worktree: ${target.path}
|
|
|
32281
32858
|
);
|
|
32282
32859
|
for (const rel of untracked) {
|
|
32283
32860
|
const to = path119.join(dest, "untracked", rel);
|
|
32284
|
-
await
|
|
32285
|
-
await
|
|
32861
|
+
await fs76.ensureDir(path119.dirname(to));
|
|
32862
|
+
await fs76.copy(path119.join(target.path, rel), to, { dereference: false });
|
|
32286
32863
|
}
|
|
32287
|
-
if (!(await
|
|
32864
|
+
if (!(await fs76.readFile(path119.join(dest, "changes.patch"))).equals(patch)) {
|
|
32288
32865
|
throw new Error("changes.patch does not read back as written");
|
|
32289
32866
|
}
|
|
32290
32867
|
for (const rel of untracked) {
|
|
32291
|
-
const copied = await
|
|
32868
|
+
const copied = await fs76.lstat(path119.join(dest, "untracked", rel)).catch(() => void 0);
|
|
32292
32869
|
if (!copied) throw new Error(`untracked/${rel} is missing from the snapshot`);
|
|
32293
32870
|
}
|
|
32294
32871
|
}
|
|
@@ -32303,7 +32880,7 @@ async function snapshotDirtyWorktrees(workspaceRoot, targets) {
|
|
|
32303
32880
|
try {
|
|
32304
32881
|
await writeOne(target, path119.join(abs, target.label));
|
|
32305
32882
|
} catch (err) {
|
|
32306
|
-
await
|
|
32883
|
+
await fs76.remove(abs).catch(() => {
|
|
32307
32884
|
});
|
|
32308
32885
|
const message2 = err instanceof Error ? err.message : String(err);
|
|
32309
32886
|
const fix = err instanceof GitStepError ? "fix that git error in the worktree and run again" : `fix the backup location (${dir}) and run again`;
|
|
@@ -32363,7 +32940,7 @@ async function runRemove2(opts, deps = {}) {
|
|
|
32363
32940
|
if (!resolved.ok) return { ok: false, error: resolved.reason };
|
|
32364
32941
|
const { rootInfo, workspaceRoot } = resolved;
|
|
32365
32942
|
const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
|
|
32366
|
-
if (!await
|
|
32943
|
+
if (!await fs77.pathExists(wsWorktreePath)) {
|
|
32367
32944
|
return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
|
|
32368
32945
|
}
|
|
32369
32946
|
const state = await readWorktreeState(wsWorktreePath);
|
|
@@ -32443,8 +33020,8 @@ async function runRemove2(opts, deps = {}) {
|
|
|
32443
33020
|
}
|
|
32444
33021
|
await pruneWorktrees(workspaceRoot);
|
|
32445
33022
|
}
|
|
32446
|
-
if (wsRemoveOk && await
|
|
32447
|
-
await
|
|
33023
|
+
if (wsRemoveOk && await fs77.pathExists(wsWorktreePath)) {
|
|
33024
|
+
await fs77.remove(wsWorktreePath);
|
|
32448
33025
|
}
|
|
32449
33026
|
return {
|
|
32450
33027
|
ok: failed.length === 0,
|
|
@@ -32479,14 +33056,14 @@ async function runDematerialize(opts, deps = {}) {
|
|
|
32479
33056
|
} catch (err) {
|
|
32480
33057
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
32481
33058
|
}
|
|
32482
|
-
if (!await
|
|
33059
|
+
if (!await fs78.pathExists(wsWorktreePath)) {
|
|
32483
33060
|
return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
|
|
32484
33061
|
}
|
|
32485
33062
|
const lock = await readHydrateLock(workspaceRoot, slug);
|
|
32486
33063
|
if (lock.held) {
|
|
32487
33064
|
return {
|
|
32488
33065
|
ok: false,
|
|
32489
|
-
error: `A hydration is running for "${slug}"${lock.ownerPid !== void 0 ? ` (pid ${lock.ownerPid})` : ""}
|
|
33066
|
+
error: `A hydration is running for "${slug}"${lock.ownerPid !== void 0 ? ` (pid ${lock.ownerPid})` : ""}. Wait for ${lock.lockPath} to disappear before dematerializing it.`
|
|
32490
33067
|
};
|
|
32491
33068
|
}
|
|
32492
33069
|
const state = await readWorktreeState(wsWorktreePath);
|
|
@@ -32539,8 +33116,8 @@ async function runDematerialize(opts, deps = {}) {
|
|
|
32539
33116
|
}
|
|
32540
33117
|
for (const folder of unresolvedDroppedMembers) {
|
|
32541
33118
|
const droppedPath = path121.join(wsWorktreePath, folder);
|
|
32542
|
-
if (await
|
|
32543
|
-
await
|
|
33119
|
+
if (await fs78.pathExists(droppedPath)) {
|
|
33120
|
+
await fs78.remove(droppedPath);
|
|
32544
33121
|
failed.push({ repo: folder, error: unresolvedDroppedFailureReason() });
|
|
32545
33122
|
}
|
|
32546
33123
|
}
|
|
@@ -32563,14 +33140,14 @@ async function runDematerialize(opts, deps = {}) {
|
|
|
32563
33140
|
|
|
32564
33141
|
// src/workspace/worktree/list.ts
|
|
32565
33142
|
import path122 from "node:path";
|
|
32566
|
-
import
|
|
33143
|
+
import fs79 from "fs-extra";
|
|
32567
33144
|
async function runList2() {
|
|
32568
33145
|
const resolved = await resolveWorkspaceForWorktree();
|
|
32569
33146
|
if (!resolved.ok) return { ok: false, error: resolved.reason };
|
|
32570
33147
|
const { rootInfo, workspaceRoot } = resolved;
|
|
32571
33148
|
const dir = worktreesDir(workspaceRoot);
|
|
32572
|
-
if (!await
|
|
32573
|
-
const entries = await
|
|
33149
|
+
if (!await fs79.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
|
|
33150
|
+
const entries = await fs79.readdir(dir, { withFileTypes: true });
|
|
32574
33151
|
const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
32575
33152
|
const configuredMembers = await readMembers(rootInfo);
|
|
32576
33153
|
const worktrees = [];
|
|
@@ -32581,7 +33158,7 @@ async function runList2() {
|
|
|
32581
33158
|
const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
|
|
32582
33159
|
for (const m of source) {
|
|
32583
33160
|
const memberPath2 = path122.join(wtPath, m.folder);
|
|
32584
|
-
const materialized = await
|
|
33161
|
+
const materialized = await fs79.pathExists(memberPath2);
|
|
32585
33162
|
const actualBranch = materialized ? await currentBranch(memberPath2) : void 0;
|
|
32586
33163
|
members.push({
|
|
32587
33164
|
id: m.id,
|
|
@@ -32604,12 +33181,12 @@ async function runList2() {
|
|
|
32604
33181
|
// src/workspace/worktree/reclaim.ts
|
|
32605
33182
|
import { existsSync as existsSync18 } from "node:fs";
|
|
32606
33183
|
import path123 from "node:path";
|
|
32607
|
-
import
|
|
33184
|
+
import fs80 from "fs-extra";
|
|
32608
33185
|
var MS_PER_DAY = 24 * 60 * 60 * 1e3;
|
|
32609
33186
|
async function lastTouchedMs(worktreeRoot) {
|
|
32610
33187
|
const candidates = [worktreeRoot, path123.join(worktreeRoot, ".haus-worktree.json")];
|
|
32611
33188
|
try {
|
|
32612
|
-
const dotGit = await
|
|
33189
|
+
const dotGit = await fs80.readFile(path123.join(worktreeRoot, ".git"), "utf8");
|
|
32613
33190
|
const match = /^gitdir:\s*(.+)$/m.exec(dotGit);
|
|
32614
33191
|
if (match?.[1]) {
|
|
32615
33192
|
const gitdir = match[1].trim();
|
|
@@ -32619,7 +33196,7 @@ async function lastTouchedMs(worktreeRoot) {
|
|
|
32619
33196
|
}
|
|
32620
33197
|
let newest = 0;
|
|
32621
33198
|
for (const candidate of candidates) {
|
|
32622
|
-
const stat2 = await
|
|
33199
|
+
const stat2 = await fs80.stat(candidate).catch(() => void 0);
|
|
32623
33200
|
if (stat2 && stat2.mtimeMs > newest) newest = stat2.mtimeMs;
|
|
32624
33201
|
}
|
|
32625
33202
|
return newest;
|
|
@@ -32646,7 +33223,7 @@ async function runReclaim(opts = {}, deps = {}) {
|
|
|
32646
33223
|
if (!existsSync18(dir)) {
|
|
32647
33224
|
return { ok: true, dryRun: !opts.write, olderThanDays, candidates: [], reclaimed: 0 };
|
|
32648
33225
|
}
|
|
32649
|
-
const allEntries = (await
|
|
33226
|
+
const allEntries = (await fs80.readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
32650
33227
|
const entries = allEntries.filter((name) => isValidWorktreeSlug(name));
|
|
32651
33228
|
const unaddressable = allEntries.filter((name) => !isValidWorktreeSlug(name));
|
|
32652
33229
|
const members = await readMembers(rootInfo);
|
|
@@ -32659,7 +33236,7 @@ async function runReclaim(opts = {}, deps = {}) {
|
|
|
32659
33236
|
ageDays: (Date.now() - await lastTouchedMs(path123.join(dir, name))) / MS_PER_DAY,
|
|
32660
33237
|
materializedMembers: 0,
|
|
32661
33238
|
action: "skip",
|
|
32662
|
-
reason: "directory name is not a valid worktree slug
|
|
33239
|
+
reason: "directory name is not a valid worktree slug: no haus worktree command can address it; remove it by hand if it is debris"
|
|
32663
33240
|
});
|
|
32664
33241
|
}
|
|
32665
33242
|
for (const slug of entries) {
|
|
@@ -32812,10 +33389,10 @@ async function handleAdd(opts) {
|
|
|
32812
33389
|
const hydrationNote = m.hydration ? describeInstall(m.hydration) : "not hydrated (--no-hydrate)";
|
|
32813
33390
|
emitMemberLine(
|
|
32814
33391
|
m.hydration,
|
|
32815
|
-
` ${m.member}: ${m.branchAction} "${m.branch}"
|
|
33392
|
+
` ${m.member}: ${m.branchAction} "${m.branch}": ${hydrationNote} (${m.durationMs}ms)`
|
|
32816
33393
|
);
|
|
32817
33394
|
} else {
|
|
32818
|
-
warn(` ${m.member}: FAILED
|
|
33395
|
+
warn(` ${m.member}: FAILED: ${m.error}`);
|
|
32819
33396
|
}
|
|
32820
33397
|
}
|
|
32821
33398
|
if (!result.ok) {
|
|
@@ -32862,7 +33439,7 @@ async function handleHydrate(opts) {
|
|
|
32862
33439
|
warn(` ${refusedTargetLine(r.member, refused)}`);
|
|
32863
33440
|
}
|
|
32864
33441
|
} else {
|
|
32865
|
-
warn(` ${r.member}: ${r.status}${r.error ?
|
|
33442
|
+
warn(` ${r.member}: ${r.status}${r.error ? `: ${r.error}` : ""}`);
|
|
32866
33443
|
}
|
|
32867
33444
|
}
|
|
32868
33445
|
if (!result.ok) {
|
|
@@ -32902,7 +33479,7 @@ async function handleRemove(opts) {
|
|
|
32902
33479
|
return;
|
|
32903
33480
|
}
|
|
32904
33481
|
if (!result.ok && "blocked" in result && result.blocked) {
|
|
32905
|
-
error(`Refusing to remove "${opts.slug}"
|
|
33482
|
+
error(`Refusing to remove "${opts.slug}": uncommitted or unpushed work found:`);
|
|
32906
33483
|
for (const b of result.blockers) error(` ${b.repo}: ${b.reason}`);
|
|
32907
33484
|
error("Re-run with --force to remove anyway.");
|
|
32908
33485
|
process.exitCode = 1;
|
|
@@ -32961,7 +33538,7 @@ function pruneLines(r) {
|
|
|
32961
33538
|
const left = r.orphans.filter((o) => !r.removed.includes(o));
|
|
32962
33539
|
if (left.length > 0) {
|
|
32963
33540
|
const label = r.error === STILL_REGISTERED_ERROR ? "Not pruned" : "FAILED to prune";
|
|
32964
|
-
const detail = r.error ?
|
|
33541
|
+
const detail = r.error ? `: ${r.error}` : "";
|
|
32965
33542
|
lines.push(`${label} ${r.repo}: ${left.join(", ")}${detail}`);
|
|
32966
33543
|
}
|
|
32967
33544
|
return lines;
|
|
@@ -33053,7 +33630,7 @@ function describeAutoHydrate(result) {
|
|
|
33053
33630
|
switch (result.status) {
|
|
33054
33631
|
case "started":
|
|
33055
33632
|
lines.push(
|
|
33056
|
-
`The member repos are missing in this worktree and are being hydrated in the background now (${result.missingMembers?.join(", ")}). This takes minutes, not seconds. Wait until ${result.lockPath} disappears before reading any file in a member repo
|
|
33633
|
+
`The member repos are missing in this worktree and are being hydrated in the background now (${result.missingMembers?.join(", ")}). This takes minutes, not seconds. Wait until ${result.lockPath} disappears before reading any file in a member repo: \`haus workspace worktree doctor\` turns green before the work is finished. Log: ${result.logPath}`
|
|
33057
33634
|
);
|
|
33058
33635
|
break;
|
|
33059
33636
|
case "in-progress":
|
|
@@ -33129,7 +33706,7 @@ async function handleDematerialize(opts) {
|
|
|
33129
33706
|
return;
|
|
33130
33707
|
}
|
|
33131
33708
|
if (!result.ok && "blocked" in result) {
|
|
33132
|
-
error(`Refusing to dematerialize "${result.slug}"
|
|
33709
|
+
error(`Refusing to dematerialize "${result.slug}": uncommitted or unpushed work found:`);
|
|
33133
33710
|
for (const b of result.blockers) error(` ${b.repo}: ${b.reason}`);
|
|
33134
33711
|
error("Re-run with --force to dematerialize anyway.");
|
|
33135
33712
|
process.exitCode = 1;
|
|
@@ -33150,7 +33727,7 @@ async function handleDematerialize(opts) {
|
|
|
33150
33727
|
if (result.backup) log(snapshotLine(result.backup));
|
|
33151
33728
|
logEnvironment(result.environment);
|
|
33152
33729
|
log(
|
|
33153
|
-
`Dematerialized "${result.slug}": ${result.removed.join(", ") || "(nothing)"}. The workspace worktree and its branch are untouched
|
|
33730
|
+
`Dematerialized "${result.slug}": ${result.removed.join(", ") || "(nothing)"}. The workspace worktree and its branch are untouched. Run \`haus workspace worktree hydrate --materialize\` to bring the members back.`
|
|
33154
33731
|
);
|
|
33155
33732
|
if (result.failed.length > 0) {
|
|
33156
33733
|
for (const f of result.failed) error(` Failed to dematerialize ${f.repo}: ${f.error}`);
|
|
@@ -33190,14 +33767,14 @@ async function handleReclaim(opts) {
|
|
|
33190
33767
|
);
|
|
33191
33768
|
break;
|
|
33192
33769
|
case "blocked":
|
|
33193
|
-
warn(`Blocked ${c.slug} (${age})
|
|
33770
|
+
warn(`Blocked ${c.slug} (${age}): uncommitted or unpushed work:`);
|
|
33194
33771
|
for (const b of c.blockers ?? []) warn(` ${b.repo}: ${b.reason}`);
|
|
33195
33772
|
break;
|
|
33196
33773
|
case "failed":
|
|
33197
33774
|
error(`FAILED ${c.slug} (${age}): ${c.reason ?? "unknown error"}`);
|
|
33198
33775
|
break;
|
|
33199
33776
|
case "skip":
|
|
33200
|
-
log(`Keeping ${c.slug} (${age})
|
|
33777
|
+
log(`Keeping ${c.slug} (${age}): ${c.reason ?? "not a candidate"}`);
|
|
33201
33778
|
break;
|
|
33202
33779
|
}
|
|
33203
33780
|
}
|
|
@@ -33476,17 +34053,17 @@ init_paths();
|
|
|
33476
34053
|
// src/workspace/link-context/copy-record.ts
|
|
33477
34054
|
import os13 from "node:os";
|
|
33478
34055
|
import path127 from "node:path";
|
|
33479
|
-
import
|
|
34056
|
+
import fs81 from "fs-extra";
|
|
33480
34057
|
init_fs();
|
|
33481
34058
|
init_paths();
|
|
33482
34059
|
async function copyWithoutLinks(source, dest) {
|
|
33483
|
-
await
|
|
34060
|
+
await fs81.copy(source, dest, {
|
|
33484
34061
|
overwrite: true,
|
|
33485
34062
|
errorOnExist: false,
|
|
33486
34063
|
dereference: false,
|
|
33487
34064
|
filter: (s) => {
|
|
33488
34065
|
try {
|
|
33489
|
-
return !
|
|
34066
|
+
return !fs81.lstatSync(s).isSymbolicLink();
|
|
33490
34067
|
} catch {
|
|
33491
34068
|
return false;
|
|
33492
34069
|
}
|
|
@@ -33494,11 +34071,11 @@ async function copyWithoutLinks(source, dest) {
|
|
|
33494
34071
|
});
|
|
33495
34072
|
}
|
|
33496
34073
|
async function withStage(fn) {
|
|
33497
|
-
const stage = await
|
|
34074
|
+
const stage = await fs81.mkdtemp(path127.join(os13.tmpdir(), "haus-staged-link-"));
|
|
33498
34075
|
try {
|
|
33499
34076
|
return await fn(stage);
|
|
33500
34077
|
} finally {
|
|
33501
|
-
await
|
|
34078
|
+
await fs81.remove(stage);
|
|
33502
34079
|
}
|
|
33503
34080
|
}
|
|
33504
34081
|
async function stagedCopyHash(sourceAbs, destRel) {
|
|
@@ -33516,7 +34093,7 @@ async function recordedCopyHash(workspaceRoot, entry) {
|
|
|
33516
34093
|
const rel = recordedRel(entry.path);
|
|
33517
34094
|
if (await unsafeTrackedPath(workspaceRoot, rel) !== null) return void 0;
|
|
33518
34095
|
const copyAbs = path127.resolve(workspaceRoot, rel);
|
|
33519
|
-
if (!await
|
|
34096
|
+
if (!await fs81.pathExists(copyAbs)) return void 0;
|
|
33520
34097
|
if (!await holdsOnlyPlainFiles(copyAbs)) return void 0;
|
|
33521
34098
|
const sourceRel = entry.sourceRelPath.replace(/\\/g, "/");
|
|
33522
34099
|
const staged = await withStage(async (stage) => {
|
|
@@ -33542,7 +34119,10 @@ var EDITED_COPY_REASON = {
|
|
|
33542
34119
|
};
|
|
33543
34120
|
var UNSAFE_PATH_LINE = {
|
|
33544
34121
|
"escapes-root": (rel) => `Refusing to remove linked context path outside the workspace root: ${rel}`,
|
|
33545
|
-
symlink: (rel) => `Refusing to remove linked context path through a symlink: ${rel}
|
|
34122
|
+
symlink: (rel) => `Refusing to remove linked context path through a symlink: ${rel}`,
|
|
34123
|
+
// A copy lands below the workspace root's `.claude/` only, so an entry naming anything else
|
|
34124
|
+
// was edited by hand (#758): its path is left, and so is the entry.
|
|
34125
|
+
"outside-install-root": (rel) => `Refusing to remove linked context path ${rel}: haus never copies there (only into a copy's own path below .claude/), so it leaves it. Delete the entry from .haus-workflow/workspace.manifest.json by hand.`
|
|
33546
34126
|
};
|
|
33547
34127
|
async function removeLinkedCopies(workspaceRoot, entries, opts, say) {
|
|
33548
34128
|
const result = {
|
|
@@ -33804,7 +34384,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
33804
34384
|
flag({
|
|
33805
34385
|
repo: "(workspace)",
|
|
33806
34386
|
kind: "no-config",
|
|
33807
|
-
detail: "Missing or malformed haus.workspace.yaml
|
|
34387
|
+
detail: "Missing or malformed haus.workspace.yaml. Run `haus workspace discover --write` or `init`."
|
|
33808
34388
|
});
|
|
33809
34389
|
return report2();
|
|
33810
34390
|
}
|
|
@@ -33831,7 +34411,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
33831
34411
|
flag({
|
|
33832
34412
|
repo: "(workspace)",
|
|
33833
34413
|
kind: "no-manifest",
|
|
33834
|
-
detail: "No workspace.manifest.json
|
|
34414
|
+
detail: "No workspace.manifest.json. Run `haus workspace setup --write` first."
|
|
33835
34415
|
});
|
|
33836
34416
|
return report2();
|
|
33837
34417
|
}
|
|
@@ -33839,7 +34419,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
33839
34419
|
flag({
|
|
33840
34420
|
repo: "(workspace)",
|
|
33841
34421
|
kind: "no-manifest",
|
|
33842
|
-
detail: 'workspace.manifest.json\'s "repos" field is not an array
|
|
34422
|
+
detail: 'workspace.manifest.json\'s "repos" field is not an array. Run `haus workspace setup --write` to regenerate it.'
|
|
33843
34423
|
});
|
|
33844
34424
|
return report2();
|
|
33845
34425
|
}
|
|
@@ -33852,7 +34432,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
33852
34432
|
flag({
|
|
33853
34433
|
repo: repo.name,
|
|
33854
34434
|
kind: "missing-from-manifest",
|
|
33855
|
-
detail: "Configured in yaml but absent from the manifest
|
|
34435
|
+
detail: "Configured in yaml but absent from the manifest. Run `haus workspace setup --write`."
|
|
33856
34436
|
});
|
|
33857
34437
|
continue;
|
|
33858
34438
|
}
|
|
@@ -33868,14 +34448,14 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
33868
34448
|
flag({
|
|
33869
34449
|
repo: repo.name,
|
|
33870
34450
|
kind: "version-mismatch",
|
|
33871
|
-
detail: `Set up at haus ${entry.hausVersionAtSetup}, current is ${currentVersion}
|
|
34451
|
+
detail: `Set up at haus ${entry.hausVersionAtSetup}, current is ${currentVersion}. Re-run setup.`
|
|
33872
34452
|
});
|
|
33873
34453
|
}
|
|
33874
34454
|
if (!existsSync19(claudePath(repoRoot))) {
|
|
33875
34455
|
flag({
|
|
33876
34456
|
repo: repo.name,
|
|
33877
34457
|
kind: "missing-claude",
|
|
33878
|
-
detail: "Missing .claude
|
|
34458
|
+
detail: "Missing .claude/. Run `haus workspace setup --write`."
|
|
33879
34459
|
});
|
|
33880
34460
|
}
|
|
33881
34461
|
const lock = await checkLock(repoRoot);
|
|
@@ -33888,7 +34468,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
33888
34468
|
flag({
|
|
33889
34469
|
repo: repo.name,
|
|
33890
34470
|
kind: "missing-lock",
|
|
33891
|
-
detail: "Missing .haus-workflow/haus.lock.json
|
|
34471
|
+
detail: "Missing .haus-workflow/haus.lock.json. Run `haus workspace setup --write`."
|
|
33892
34472
|
});
|
|
33893
34473
|
} else if (lock.count > 0 && !lock.ok) {
|
|
33894
34474
|
flag({ repo: repo.name, kind: "invalid-lock", detail: describeUnhealthyLock(lock) });
|
|
@@ -33902,7 +34482,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
33902
34482
|
flag({
|
|
33903
34483
|
repo: "(workspace)",
|
|
33904
34484
|
kind: "catalog-ref-mismatch",
|
|
33905
|
-
detail: `Repos are on different catalog refs
|
|
34485
|
+
detail: `Repos are on different catalog refs: ${catalogRefByRepo.map((r) => `${r.repo}: ${r.ref}`).join(", ")}. Run \`haus workspace setup --write\` to bring them onto the same ref.`
|
|
33906
34486
|
});
|
|
33907
34487
|
}
|
|
33908
34488
|
if (manifest.linkedContext && manifest.linkedContext.length > 0) {
|
|
@@ -34070,7 +34650,7 @@ function describeUnhealthyLock(lock) {
|
|
|
34070
34650
|
);
|
|
34071
34651
|
}
|
|
34072
34652
|
if (parts.length === 0) {
|
|
34073
|
-
return "haus.lock.json present but invalid (e.g. a malformed version)
|
|
34653
|
+
return "haus.lock.json present but invalid (e.g. a malformed version). Re-run `haus workspace setup --write`.";
|
|
34074
34654
|
}
|
|
34075
34655
|
return `haus.lock.json: ${parts.join("; ")}.`;
|
|
34076
34656
|
}
|
|
@@ -34081,7 +34661,7 @@ init_logger();
|
|
|
34081
34661
|
// src/workspace/link-context/link.ts
|
|
34082
34662
|
import { rm } from "node:fs/promises";
|
|
34083
34663
|
import path132 from "node:path";
|
|
34084
|
-
import
|
|
34664
|
+
import fs83 from "fs-extra";
|
|
34085
34665
|
init_fs();
|
|
34086
34666
|
init_logger();
|
|
34087
34667
|
init_paths();
|
|
@@ -34091,7 +34671,7 @@ import path131 from "node:path";
|
|
|
34091
34671
|
|
|
34092
34672
|
// src/workspace/link-context/scan-source.ts
|
|
34093
34673
|
import path130 from "node:path";
|
|
34094
|
-
import
|
|
34674
|
+
import fs82 from "fs-extra";
|
|
34095
34675
|
var ASSET_SUBDIR = {
|
|
34096
34676
|
skill: "skills",
|
|
34097
34677
|
agent: "agents",
|
|
@@ -34099,7 +34679,7 @@ var ASSET_SUBDIR = {
|
|
|
34099
34679
|
};
|
|
34100
34680
|
async function sourceExists(abs) {
|
|
34101
34681
|
try {
|
|
34102
|
-
await
|
|
34682
|
+
await fs82.stat(abs);
|
|
34103
34683
|
return true;
|
|
34104
34684
|
} catch (err) {
|
|
34105
34685
|
const code2 = err.code;
|
|
@@ -34116,11 +34696,11 @@ async function hasLinkableContext(member) {
|
|
|
34116
34696
|
async function collectSkills(member) {
|
|
34117
34697
|
const dir = path130.join(member.absPath, ".claude", "skills");
|
|
34118
34698
|
if (!await sourceExists(dir)) return [];
|
|
34119
|
-
const names4 = await
|
|
34699
|
+
const names4 = await fs82.readdir(dir);
|
|
34120
34700
|
const out = [];
|
|
34121
34701
|
for (const name of [...names4].sort()) {
|
|
34122
34702
|
const abs = path130.join(dir, name);
|
|
34123
|
-
const stat2 = await
|
|
34703
|
+
const stat2 = await fs82.lstat(abs);
|
|
34124
34704
|
if (!stat2.isDirectory()) continue;
|
|
34125
34705
|
if (!await sourceExists(path130.join(abs, "SKILL.md"))) continue;
|
|
34126
34706
|
out.push({ type: "skill", name, sourceRelPath: path130.posix.join(".claude", "skills", name) });
|
|
@@ -34131,12 +34711,12 @@ async function collectFlatMarkdown(member, type) {
|
|
|
34131
34711
|
const subdir = ASSET_SUBDIR[type];
|
|
34132
34712
|
const dir = path130.join(member.absPath, ".claude", subdir);
|
|
34133
34713
|
if (!await sourceExists(dir)) return [];
|
|
34134
|
-
const names4 = await
|
|
34714
|
+
const names4 = await fs82.readdir(dir);
|
|
34135
34715
|
const out = [];
|
|
34136
34716
|
for (const fileName of [...names4].sort()) {
|
|
34137
34717
|
if (!fileName.endsWith(".md")) continue;
|
|
34138
34718
|
const abs = path130.join(dir, fileName);
|
|
34139
|
-
if (!(await
|
|
34719
|
+
if (!(await fs82.lstat(abs)).isFile()) continue;
|
|
34140
34720
|
const name = fileName.slice(0, -".md".length);
|
|
34141
34721
|
out.push({ type, name, sourceRelPath: path130.posix.join(".claude", subdir, fileName) });
|
|
34142
34722
|
}
|
|
@@ -34265,7 +34845,7 @@ async function runLinkContext(workspaceRoot, opts = {}) {
|
|
|
34265
34845
|
const detail = plan.collisions.map((c) => `${c.type} "${c.destKey}" claimed by: ${c.repos.join(", ")}`).join("; ");
|
|
34266
34846
|
return {
|
|
34267
34847
|
ok: false,
|
|
34268
|
-
error: `Name collision(s) after the repo-folder prefix
|
|
34848
|
+
error: `Name collision(s) after the repo-folder prefix, refusing to overwrite either copy. Rename one of the conflicting repos' folders (in haus.workspace.yaml/repos.manifest.json) or the conflicting skill/agent/command itself to resolve: ${detail}`,
|
|
34269
34849
|
collisions: plan.collisions,
|
|
34270
34850
|
refusedFiles: []
|
|
34271
34851
|
};
|
|
@@ -34499,8 +35079,8 @@ async function swapInCopy(workspaceRoot, sourceAbs, destRel) {
|
|
|
34499
35079
|
const destAbs = path132.join(workspaceRoot, destRel);
|
|
34500
35080
|
let folder;
|
|
34501
35081
|
try {
|
|
34502
|
-
await
|
|
34503
|
-
folder = await
|
|
35082
|
+
await fs83.ensureDir(path132.dirname(destAbs));
|
|
35083
|
+
folder = await fs83.mkdtemp(path132.join(path132.dirname(destAbs), STAGING_PREFIX));
|
|
34504
35084
|
} catch (err) {
|
|
34505
35085
|
return { failure: messageOf(err) };
|
|
34506
35086
|
}
|
|
@@ -34519,13 +35099,13 @@ async function swapInCopy(workspaceRoot, sourceAbs, destRel) {
|
|
|
34519
35099
|
}
|
|
34520
35100
|
if (recordFailure === void 0) {
|
|
34521
35101
|
const hadOld = await isOnDisk2(destAbs);
|
|
34522
|
-
if (hadOld) await
|
|
35102
|
+
if (hadOld) await fs83.rename(destAbs, aside);
|
|
34523
35103
|
try {
|
|
34524
|
-
await
|
|
35104
|
+
await fs83.rename(staged, destAbs);
|
|
34525
35105
|
} catch (err) {
|
|
34526
35106
|
if (hadOld) {
|
|
34527
35107
|
try {
|
|
34528
|
-
await
|
|
35108
|
+
await fs83.rename(aside, destAbs);
|
|
34529
35109
|
} catch (back) {
|
|
34530
35110
|
return {
|
|
34531
35111
|
failure: `${messageOf(err)}; putting the old copy back failed too (${messageOf(back)})`,
|
|
@@ -34549,7 +35129,7 @@ async function swapInCopy(workspaceRoot, sourceAbs, destRel) {
|
|
|
34549
35129
|
}
|
|
34550
35130
|
async function isOnDisk2(abs) {
|
|
34551
35131
|
try {
|
|
34552
|
-
await
|
|
35132
|
+
await fs83.lstat(abs);
|
|
34553
35133
|
return true;
|
|
34554
35134
|
} catch (err) {
|
|
34555
35135
|
const code2 = err.code;
|
|
@@ -34688,7 +35268,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
34688
35268
|
continue;
|
|
34689
35269
|
}
|
|
34690
35270
|
if (!res.setupOk) {
|
|
34691
|
-
const catalogReason = !res.catalogInstallOk ? "No catalog items were installed for this repo (zero installable content)
|
|
35271
|
+
const catalogReason = !res.catalogInstallOk ? "No catalog items were installed for this repo (zero installable content). See the per-repo output above, or re-run with --allow-empty-cache." : void 0;
|
|
34692
35272
|
const reasons = [
|
|
34693
35273
|
...catalogReason === void 0 ? [] : [catalogReason],
|
|
34694
35274
|
...res.refusals ?? []
|
|
@@ -34844,7 +35424,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
34844
35424
|
const manifest = buildManifest2({
|
|
34845
35425
|
client: config.client,
|
|
34846
35426
|
repos: manifestRepos,
|
|
34847
|
-
// buildManifest rebuilds the manifest from scratch each run
|
|
35427
|
+
// buildManifest rebuilds the manifest from scratch each run, carry the prior
|
|
34848
35428
|
// `linkedContext` section forward so a plain `setup --write` never silently
|
|
34849
35429
|
// drops entries `link-context` wrote on an earlier run.
|
|
34850
35430
|
linkedContext: prior?.linkedContext
|
|
@@ -34935,7 +35515,7 @@ async function linkContextStep(workspaceRoot, json, written, say) {
|
|
|
34935
35515
|
// src/commands/workspace/undo.ts
|
|
34936
35516
|
import { existsSync as existsSync21, statSync as statSync2 } from "node:fs";
|
|
34937
35517
|
import path134 from "node:path";
|
|
34938
|
-
import
|
|
35518
|
+
import fs84 from "fs-extra";
|
|
34939
35519
|
init_fs();
|
|
34940
35520
|
init_logger();
|
|
34941
35521
|
init_paths();
|
|
@@ -35015,7 +35595,7 @@ async function planWorkspaceRoot(workspaceRoot, config) {
|
|
|
35015
35595
|
const aggregates = [];
|
|
35016
35596
|
for (const name of WORKSPACE_AGGREGATE_FILES) {
|
|
35017
35597
|
const abs = hausPath(workspaceRoot, name);
|
|
35018
|
-
if (await
|
|
35598
|
+
if (await fs84.pathExists(abs)) aggregates.push({ abs, rel: `.haus-workflow/${name}` });
|
|
35019
35599
|
}
|
|
35020
35600
|
if (manifestRead.status === "refused") {
|
|
35021
35601
|
return {
|
|
@@ -35088,7 +35668,7 @@ async function settleManifest(workspaceRoot, kept, dryRun) {
|
|
|
35088
35668
|
log(`[dry-run] would remove ${WORKSPACE_MANIFEST_FILE}`);
|
|
35089
35669
|
return false;
|
|
35090
35670
|
}
|
|
35091
|
-
await
|
|
35671
|
+
await fs84.remove(manifestPath(workspaceRoot));
|
|
35092
35672
|
log(`Removed ${WORKSPACE_MANIFEST_FILE}`);
|
|
35093
35673
|
return false;
|
|
35094
35674
|
}
|
|
@@ -35174,11 +35754,11 @@ async function undoWorkspaceRoot(plan, workspaceRoot, force) {
|
|
|
35174
35754
|
);
|
|
35175
35755
|
if (removal.exitCode === 1) owesExit = true;
|
|
35176
35756
|
for (const { abs, rel } of plan.aggregates) {
|
|
35177
|
-
await
|
|
35757
|
+
await fs84.remove(abs);
|
|
35178
35758
|
log(`Removed ${rel}`);
|
|
35179
35759
|
}
|
|
35180
35760
|
if (docGoes) {
|
|
35181
|
-
await
|
|
35761
|
+
await fs84.remove(docFile.abs);
|
|
35182
35762
|
log(
|
|
35183
35763
|
doc.kind === "kept" ? `Removed ${WORKSPACE_DOC_FILE} (${doc.reason}; removed with --force)` : `Removed ${WORKSPACE_DOC_FILE}`
|
|
35184
35764
|
);
|
|
@@ -35186,10 +35766,10 @@ async function undoWorkspaceRoot(plan, workspaceRoot, force) {
|
|
|
35186
35766
|
if (strip !== null) {
|
|
35187
35767
|
const what = strip.legacy ? "pre-upgrade workspace haus import block" : "workspace haus import block";
|
|
35188
35768
|
if (strip.remaining.length === 0) {
|
|
35189
|
-
await
|
|
35769
|
+
await fs84.remove(claudeMd.abs);
|
|
35190
35770
|
log(`Removed CLAUDE.md (only contained the ${what}).`);
|
|
35191
35771
|
} else {
|
|
35192
|
-
await
|
|
35772
|
+
await fs84.writeFile(claudeMd.abs, strip.remaining, "utf8");
|
|
35193
35773
|
log(
|
|
35194
35774
|
strip.legacy ? "Removed pre-upgrade workspace haus import block from CLAUDE.md (user content preserved)." : "Removed haus import block from CLAUDE.md (user content preserved)."
|
|
35195
35775
|
);
|
|
@@ -35552,7 +36132,7 @@ catalogBundle.command("import <file>").description("Unpack a catalog bundle into
|
|
|
35552
36132
|
);
|
|
35553
36133
|
program.command("update").description("Sync the remote catalog cache, refresh the lockfile, and check for CLI updates").option("--check").option("--fast", "With --check, skip per-item content hashing (cheap count+ref only)").option("--from-hook", "Emit JSON for the Claude Code SessionStart hook (silent if up to date)").action(runUpdate);
|
|
35554
36134
|
program.command("ci-gate").description(
|
|
35555
|
-
"Aggregate `doctor` + `decisions check` + `update --check --fast` into one pass/fail gate for CI. Uses the fast/cheap update-check tier (no per-item content hashing) to keep CI runs quick
|
|
36135
|
+
"Aggregate `doctor` + `decisions check` + `update --check --fast` into one pass/fail gate for CI. Uses the fast/cheap update-check tier (no per-item content hashing) to keep CI runs quick. Run `haus update --check` directly for the fully-hashed tier. Each constituent command remains independently runnable with its own unchanged exit-code contract."
|
|
35556
36136
|
).option("--json", "Emit machine-readable {doctor, decisions, update, ok}").option(
|
|
35557
36137
|
"--no-update",
|
|
35558
36138
|
"Skip the catalog leg (update --check), which needs the network; the Lefthook gate uses it"
|
|
@@ -35683,7 +36263,7 @@ worktree.command("prune").description(
|
|
|
35683
36263
|
);
|
|
35684
36264
|
worktree.command("autohydrate").description(
|
|
35685
36265
|
"Materialize + hydrate this worktree's missing member repos in the background when `worktree.autoHydrate` is enabled (the acting half of the flag; see ADR-0074)"
|
|
35686
|
-
).option("--from-hook", "Emit for the SessionStart hook
|
|
36266
|
+
).option("--from-hook", "Emit for the SessionStart hook (always exits 0)").option("--dry-run", "Report what would be hydrated without forking anything").option("--json", "Emit the structured autohydrate result as JSON").addOption(new Option("--detached-child", "internal: run the forked hydration").hideHelp()).action(
|
|
35687
36267
|
(opts) => runWorktree("autohydrate", {
|
|
35688
36268
|
fromHook: opts.fromHook,
|
|
35689
36269
|
dryRun: opts.dryRun,
|
|
@@ -35702,7 +36282,7 @@ worktree.command("dematerialize [slug]").description(
|
|
|
35702
36282
|
})
|
|
35703
36283
|
);
|
|
35704
36284
|
worktree.command("reclaim").description(
|
|
35705
|
-
"Dematerialize workspace worktrees untouched for longer than `worktree.reclaimAfterDays
|
|
36285
|
+
"Dematerialize workspace worktrees untouched for longer than `worktree.reclaimAfterDays`; previews unless --write (ADR-0075)"
|
|
35706
36286
|
).option("--older-than-days <n>", "Override worktree.reclaimAfterDays", Number.parseFloat).option("--write", "Perform the reclamation (default previews only)").option("--json", "Emit the structured reclaim result as JSON").action(
|
|
35707
36287
|
(opts) => runWorktree("reclaim", {
|
|
35708
36288
|
olderThanDays: opts.olderThanDays,
|
|
@@ -35710,7 +36290,7 @@ worktree.command("reclaim").description(
|
|
|
35710
36290
|
json: opts.json
|
|
35711
36291
|
})
|
|
35712
36292
|
);
|
|
35713
|
-
worktree.command("doctor").description("Fast, side-effect-free health check for the current workspace worktree").option("--from-hook", "Emit for the SessionStart hook
|
|
36293
|
+
worktree.command("doctor").description("Fast, side-effect-free health check for the current workspace worktree").option("--from-hook", "Emit for the SessionStart hook (always exits 0)").action((opts) => runWorktree("doctor", { fromHook: opts.fromHook }));
|
|
35714
36294
|
var worktreeEnv = worktree.command("env").description(
|
|
35715
36295
|
"Per-worktree local environment from localdev.yml's `instance:` section: own ports, databases, Redis index, index prefix and site (ADR-0163; enabled by `worktree.env: true`)"
|
|
35716
36296
|
);
|